Skip to content

Token refresh logic#657

Merged
feruzm merged 2 commits into
developfrom
token
Feb 18, 2026
Merged

Token refresh logic#657
feruzm merged 2 commits into
developfrom
token

Conversation

@feruzm
Copy link
Copy Markdown
Member

@feruzm feruzm commented Feb 18, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced token validation and refresh logic to prevent authentication failures when saving drafts, scheduling posts, and switching users
    • Tokens are now proactively validated before API requests to ensure continued authentication
  • New Features

    • Automatic background token refresh for seamless authentication without interrupting user actions

@feruzm feruzm marked this pull request as draft February 18, 2026 10:12
@feruzm feruzm marked this pull request as ready for review February 18, 2026 10:12
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

This PR refactors token handling to introduce automatic token refresh, timestamp tracking, and guaranteed-valid token provision through a new ensureValidToken utility, replacing direct getAccessToken calls across API mutation flows while adding tokenObtainedAt field to track token acquisition time.

Changes

Cohort / File(s) Summary
Token Utility Refactoring
apps/web/src/utils/user-token.ts
Introduces token expiry detection with 5-minute pre-refresh buffer, background refresh coordination with deduplication, and new ensureValidToken function that guarantees a valid token before returning. Exports new helpers: getRefreshToken, updated getAccessToken behavior, and getPostingKey/getLoginType accessors. Core logic change affecting all downstream token usage.
Publish API Mutations
apps/web/src/app/publish/_api/use-save-draft.ts, apps/web/src/app/publish/_api/use-schedule.ts
Replaces getAccessToken with ensureValidToken calls to obtain validated tokens before draft updates/creation and schedule operations, ensuring token freshness at mutation time.
Submit API Mutations
apps/web/src/app/submit/_api/save-draft.ts, apps/web/src/app/submit/_api/schedule.ts
Similar to publish APIs: adopts ensureValidToken for draft and schedule mutations to guarantee valid tokens before API calls.
User Entity & Global State
apps/web/src/entities/users.ts, apps/web/src/core/global-store/modules/users-module.ts
Adds optional tokenObtainedAt?: number field to User interface and updates loadUsers mapping to include this Unix timestamp (ms) when tokens are obtained.
Authentication & Login Flows
apps/web/src/features/shared/login/hooks/use-login-in-app.ts, apps/web/src/features/shared/login/hooks/use-user-select.ts
Captures tokenObtainedAt: Date.now() during user login and adds pre-switch token validation via ensureValidToken during active user transitions to maintain token freshness across sessions.
Chat Integration
apps/web/src/features/chat/mattermost-api.ts
Augments token refresh payload in mattermost bootstrap flow with tokenObtainedAt: number timestamp when storing updated user credentials.

Sequence Diagram(s)

sequenceDiagram
    participant Client as API Client 1 & 2
    participant ensureValidToken as ensureValidToken()
    participant Storage as Storage
    participant TokenRefresh as hsTokenRenew()

    Client->>ensureValidToken: ensureValidToken(username)
    ensureValidToken->>Storage: Get user from storage
    Storage-->>ensureValidToken: User object
    ensureValidToken->>ensureValidToken: isTokenExpired(user)?
    alt Token Valid
        ensureValidToken-->>Client: Return current token
    else Token Expired
        alt Refresh Already Pending
            ensureValidToken->>ensureValidToken: Get pending promise
            ensureValidToken-->>ensureValidToken: Wait for promise
        else Start New Refresh
            ensureValidToken->>TokenRefresh: Refresh with refreshToken
            TokenRefresh-->>Storage: Updated user (new token + timestamp)
            Storage-->>ensureValidToken: Store complete
        end
        ensureValidToken-->>Client: Return refreshed token
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Move private api to sdk #606 — Both PRs modify the same token authentication call sites across draft, schedule, and API mutation flows, with this PR introducing ensureValidToken for validated tokens while #606 moves toward token-based SDK calls.
  • Fix editing/draft loading #623 — Related through modifications to apps/web/src/app/submit/_api/save-draft.ts; both PRs update draft handling, with this PR focusing on token validation and #623 on cache/signature updates.

Poem

🐰 Tokens once fleeting, now last the distance,
With timestamps and refresh, we've won our resistance!
Five minutes ahead we refresh with care,
Background dedupe keeps the load fair,
No stale auth here—every call is divine! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Token refresh logic' directly describes the main change: introducing token refresh handling, expiry checks, background refresh coordination, and new token validation utilities across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch token

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/app/publish/_api/use-save-draft.ts (1)

82-100: ⚠️ Potential issue | 🟠 Major

Add a guard to validate the token before passing it to SDK calls.

ensureValidToken returns Promise<string | undefined> — it can be undefined when getUser(username) finds no matching entry in storage. While the activeUser?.username check at line 51 guards against missing React-state user, storage can become inconsistent (e.g., cleared mid-session). Currently the token is passed directly to updateDraft and addDraft without validation, causing the backend to return a confusing auth error instead of failing early with a clear message.

Add this guard after line 82:

const token = await ensureValidToken(username);
if (!token) {
  throw new Error("[Draft] Failed to obtain a valid token");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/publish/_api/use-save-draft.ts` around lines 82 - 100,
ensureValidToken returns string | undefined but its result is passed directly
into updateDraft and addDraft; add a guard after the ensureValidToken call that
checks the token from ensureValidToken(username) and throws a clear Error (e.g.
"[Draft] Failed to obtain a valid token") when falsy so updateDraft and addDraft
are never called with an undefined token; update the block around
ensureValidToken, updateDraft, and addDraft in use-save-draft.ts accordingly.
🧹 Nitpick comments (3)
apps/web/src/features/chat/mattermost-api.ts (1)

81-97: Duplicate token refresh logic — potential race with ensureValidToken.

This block calls hsTokenRenew directly instead of using the new ensureValidToken utility. If another part of the app triggers ensureValidToken (or refreshTokenInBackground) for the same user concurrently, two independent refresh requests hit the auth server with the same refresh token. Depending on the server's token rotation policy, one refresh could invalidate the other's newly issued token.

Consider replacing the inline refresh with ensureValidToken and retrying bootstrap with the returned token:

♻️ Suggested refactor
-        try {
-          console.log("Chat token expired, attempting auto-refresh...");
-
-          // Call HiveSigner to refresh tokens
-          const refreshedTokens = await hsTokenRenew(refreshToken);
-
-          // Update tokens in global store (saves to localStorage)
-          const currentUser = users.find((u) => u.username === username);
-          addUser({
-            username: refreshedTokens.username,
-            accessToken: refreshedTokens.access_token,
-            refreshToken: refreshedTokens.refresh_token,
-            expiresIn: refreshedTokens.expires_in,
-            tokenObtainedAt: Date.now(),
-            postingKey: currentUser?.postingKey,
-            loginType: currentUser?.loginType
-          });
-
-          console.log("✓ Chat tokens refreshed successfully");
-
-          // Retry bootstrap with refreshed tokens
-          res = await fetch("/api/mattermost/bootstrap", {
+        try {
+          console.log("Chat token expired, attempting auto-refresh...");
+          const freshAccessToken = await ensureValidToken(username || "");
+          const freshRefreshToken = getRefreshToken(username || "");
+          console.log("✓ Chat tokens refreshed successfully");
+
+          res = await fetch("/api/mattermost/bootstrap", {
             method: "POST",
             headers: { "Content-Type": "application/json" },
             body: JSON.stringify({
               username,
-              accessToken: refreshedTokens.access_token,
-              refreshToken: refreshedTokens.refresh_token,
+              accessToken: freshAccessToken,
+              refreshToken: freshRefreshToken,
               displayName: username,
               community
             })
           });

This would require importing ensureValidToken from @/utils.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/features/chat/mattermost-api.ts` around lines 81 - 97, Replace
the direct hsTokenRenew call and manual token handling with the shared token
helper to avoid concurrent refresh races: import and call ensureValidToken for
the given username (instead of hsTokenRenew), use the returned token object to
call addUser (preserving postingKey/loginType from currentUser) and then retry
the bootstrap logic with the new access token; remove the direct hsTokenRenew
invocation so refreshTokenInBackground/ensureValidToken manage rotation
consistently.
apps/web/src/features/shared/login/hooks/use-user-select.ts (1)

39-40: Consider using the returned token or verifying the refresh succeeded.

ensureValidToken returns the (possibly refreshed) access token, but its return value is discarded here. If the refresh fails, ensureValidToken silently returns the stale token and the user switch proceeds anyway. If the intent is best-effort refresh this is fine, but if a valid token is required before switching, you should check the result or at least log a warning when the token couldn't be refreshed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/features/shared/login/hooks/use-user-select.ts` around lines 39
- 40, The call site in use-user-select (the hook that invokes
ensureValidToken(user.username)) currently ignores the returned token; update
the logic to capture the returned value from ensureValidToken and verify it
succeeded before proceeding with the user switch: call const token = await
ensureValidToken(user.username), then if token is null/undefined or otherwise
indicates an expired/invalid value, either abort the switch (return/throw) or
emit a warning via the same logger used in this module; if best-effort is
intended, at minimum log a warning when the returned token is stale/unchanged so
callers can diagnose refresh failures.
apps/web/src/utils/user-token.ts (1)

38-45: Legacy sessions will trigger a refresh on every getAccessToken call until a refresh succeeds.

When tokenObtainedAt is undefined (legacy sessions), isTokenExpired always returns true. This means every getAccessToken call triggers refreshTokenInBackground. The dedup guard in refreshTokenInBackground prevents concurrent requests, but once the promise resolves and is deleted from pendingRefreshes, the next getAccessToken will trigger another refresh — because the in-memory state was never updated (only localStorage was). This will keep happening until the component re-reads from localStorage.

This is a minor concern since refreshTokenInBackground updates localStorage with tokenObtainedAt, so subsequent calls to getUser (which reads from localStorage) will find the updated timestamp. Just ensure this is the intended migration path for legacy sessions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/utils/user-token.ts` around lines 38 - 45, isTokenExpired
currently returns true for legacy sessions when tokenObtainedAt is undefined,
causing every getAccessToken call to re-trigger refreshTokenInBackground until
localStorage is re-read; fix by updating the in-memory user state when a
background refresh succeeds so subsequent getAccessToken/isTokenExpired checks
see the new tokenObtainedAt. Concretely, inside refreshTokenInBackground (the
function that writes tokenObtainedAt to localStorage and manages
pendingRefreshes), after a successful refresh update the in-memory cached user
object returned by getUser (or the module-level user cache) to include the new
tokenObtainedAt/expiresIn and persist that change so isTokenExpired no longer
treats the session as legacy; keep the existing dedupe via pendingRefreshes
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/publish/_api/use-schedule.ts`:
- Around line 137-139: ensureValidToken(author) may return undefined, so add a
guard in use-schedule before calling addSchedule: check the token variable
returned from ensureValidToken and if it's undefined, short-circuit with a clear
authentication error (throw or return a structured error/response) instead of
calling addSchedule; mirror the guard/handling used in use-save-draft.ts to
ensure downstream API calls never receive an undefined token and to surface a
proper auth failure.

In `@apps/web/src/app/submit/_api/save-draft.ts`:
- Around line 79-83: After calling ensureValidToken(username) in save-draft.ts
you must guard against it returning undefined before passing the token into
updateDraft or addDraft: check the token variable (from ensureValidToken) and if
undefined exit early (return an error/throw or respond with appropriate status)
rather than calling updateDraft(token, ...) or addDraft(token, ...); update the
logic around the editingDraft branch and the addDraft path to use this guard so
neither updateDraft nor addDraft receives an undefined token.

In `@apps/web/src/app/submit/_api/schedule.ts`:
- Around line 104-106: ensureValidToken(author) can return undefined, so add a
guard before calling addSchedule to avoid passing an undefined token; in the
function invoking ensureValidToken (where addSchedule is called), check the
returned token from ensureValidToken(author) and if it's undefined either throw
a controlled error or return early (e.g., surface an authentication/validation
error to the caller) rather than calling addSchedule(token, ...). Update any
related error logging or user-facing messages so they reflect the auth-missing
case.

In `@apps/web/src/utils/user-token.ts`:
- Around line 118-162: The function ensureValidToken currently catches
hsTokenRenew errors and returns the stale user.accessToken, which hides
failures; change the catch block inside the refreshPromise to return undefined
(or re-throw) instead of the expired token so callers can detect a refresh
failure — specifically modify the async IIFE in ensureValidToken (the block that
calls hsTokenRenew and builds updatedUser, and which currently references
hsTokenRenew, updateUserInStorage, pendingRefreshes, and user) to: on error log
the error, return undefined (or re-throw the error if you prefer
caller-handling), and still delete pendingRefreshes in the finally so concurrent
refresh deduping cleans up correctly. Ensure the promise stored in
pendingRefreshes reflects the new return type (Promise<string | undefined>).
- Around line 86-96: getAccessToken currently returns a possibly stale token and
triggers refreshTokenInBackground without waiting, so update critical call sites
to obtain a guaranteed-valid token by using ensureValidToken() (or awaiting a
new async wrapper that awaits refreshTokenInBackground) instead of
getAccessToken; specifically replace usages in setup-external-create.tsx (around
line ~100), setup-external-import.tsx (around ~133), hive-signer.ts (around ~29)
and any direct API calls for chat/image
uploads/publish/schedule/save-draft/user-select to call ensureValidToken (or
convert the calling flow to async and await token refresh) while leaving
non-critical query-based usages unchanged.
- Around line 47-79: The issue is that updateUserInStorage only writes refreshed
tokens to localStorage, leaving the in-memory Zustand users array stale; to fix,
after any successful refresh in refreshTokenInBackground and ensureValidToken
replace the call to updateUserInStorage (or follow it) with an invocation of the
Zustand action addUser (or otherwise dispatch an event) so the updatedUser
object (the same structure created in refreshTokenInBackground and returned from
ensureValidToken) is added to the store; if addUser isn't in scope, accept a
store action or callback into these functions (or emit a lightweight update
event) and call that with the updatedUser so both localStorage and the
useGlobalStore/state.users are kept in sync (mirroring the mattermost-api.ts
pattern).

---

Outside diff comments:
In `@apps/web/src/app/publish/_api/use-save-draft.ts`:
- Around line 82-100: ensureValidToken returns string | undefined but its result
is passed directly into updateDraft and addDraft; add a guard after the
ensureValidToken call that checks the token from ensureValidToken(username) and
throws a clear Error (e.g. "[Draft] Failed to obtain a valid token") when falsy
so updateDraft and addDraft are never called with an undefined token; update the
block around ensureValidToken, updateDraft, and addDraft in use-save-draft.ts
accordingly.

---

Nitpick comments:
In `@apps/web/src/features/chat/mattermost-api.ts`:
- Around line 81-97: Replace the direct hsTokenRenew call and manual token
handling with the shared token helper to avoid concurrent refresh races: import
and call ensureValidToken for the given username (instead of hsTokenRenew), use
the returned token object to call addUser (preserving postingKey/loginType from
currentUser) and then retry the bootstrap logic with the new access token;
remove the direct hsTokenRenew invocation so
refreshTokenInBackground/ensureValidToken manage rotation consistently.

In `@apps/web/src/features/shared/login/hooks/use-user-select.ts`:
- Around line 39-40: The call site in use-user-select (the hook that invokes
ensureValidToken(user.username)) currently ignores the returned token; update
the logic to capture the returned value from ensureValidToken and verify it
succeeded before proceeding with the user switch: call const token = await
ensureValidToken(user.username), then if token is null/undefined or otherwise
indicates an expired/invalid value, either abort the switch (return/throw) or
emit a warning via the same logger used in this module; if best-effort is
intended, at minimum log a warning when the returned token is stale/unchanged so
callers can diagnose refresh failures.

In `@apps/web/src/utils/user-token.ts`:
- Around line 38-45: isTokenExpired currently returns true for legacy sessions
when tokenObtainedAt is undefined, causing every getAccessToken call to
re-trigger refreshTokenInBackground until localStorage is re-read; fix by
updating the in-memory user state when a background refresh succeeds so
subsequent getAccessToken/isTokenExpired checks see the new tokenObtainedAt.
Concretely, inside refreshTokenInBackground (the function that writes
tokenObtainedAt to localStorage and manages pendingRefreshes), after a
successful refresh update the in-memory cached user object returned by getUser
(or the module-level user cache) to include the new tokenObtainedAt/expiresIn
and persist that change so isTokenExpired no longer treats the session as
legacy; keep the existing dedupe via pendingRefreshes intact.

Comment thread apps/web/src/app/publish/_api/use-schedule.ts
Comment thread apps/web/src/app/submit/_api/save-draft.ts
Comment thread apps/web/src/app/submit/_api/schedule.ts
Comment thread apps/web/src/utils/user-token.ts
Comment thread apps/web/src/utils/user-token.ts
Comment thread apps/web/src/utils/user-token.ts
@feruzm feruzm merged commit dbd3e71 into develop Feb 18, 2026
@feruzm feruzm deleted the token branch February 18, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant