feat(client,next): support force-refreshing access and organization tokens - #1139
Open
wangsijie wants to merge 1 commit into
Open
feat(client,next): support force-refreshing access and organization tokens#1139wangsijie wants to merge 1 commit into
wangsijie wants to merge 1 commit into
Conversation
…okens
Access tokens (including organization tokens) are cached in the session until
they expire, so a cached organization token keeps its original scopes even
after the user's organization roles have changed on the Logto side. Logto
re-reads the user's organization scopes from the database on every
`refresh_token` exchange, so a freshly exchanged token already reflects role
changes immediately -- there was just no supported way to trigger that
exchange from `@logto/next`, whose only escape hatch (sign out and sign in
again) is a poor user experience.
- `getAccessToken()` / `getOrganizationToken()` now accept an optional
`{ forceRefresh: boolean }` argument that skips the cached token and
exchanges a new one with the Refresh Token.
- `clearAccessToken()` now accepts optional `resource` / `organizationId`
arguments to evict a single cached token. Calling it with no arguments
keeps the existing "clear everything" behavior.
Both are exposed through `@logto/next` for the Pages Router, the Edge
runtime, and server actions, where `clearAccessToken()` is now exported from
`@logto/next/server-actions`.
All the additions are optional parameters, so this is fully backward
compatible.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in way to bypass the client-side token cache so @logto/next apps can immediately pick up updated access/organization token scopes (e.g., after org role promotion/demotion) without requiring a sign-out/sign-in.
Changes:
- Introduces
GetAccessTokenOptionswith{ forceRefresh?: boolean }and threads it throughgetAccessToken()/getOrganizationToken()across@logto/client,@logto/node, and@logto/next. - Extends
clearAccessToken(resource?, organizationId?)to support targeted eviction of a single cached token (while keeping the “clear all” behavior when called with no args). - Adds/updates tests to verify cache behavior, forced refresh behavior, and targeted eviction.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/client/src/client.ts | Adds GetAccessTokenOptions, implements cache bypass on forceRefresh, and supports targeted clearAccessToken. |
| packages/client/src/index.token-cache.test.ts | Adds coverage for cache hit behavior, forced refresh (incl. org token), and targeted cache eviction persistence. |
| packages/node/src/exports.ts | Re-exports GetAccessTokenOptions from @logto/client. |
| packages/next/src/index.ts | Pages Router APIs: pass through options for token getters and add clearAccessToken(...). |
| packages/next/src/index.test.ts | Updates tests for options pass-through and adds tests for clearAccessToken. |
| packages/next/server-actions/index.ts | Server Actions/RSC: threads options through token getters and exports clearAccessToken(...). |
| packages/next/edge/index.ts | Edge runtime re-exports GetAccessTokenOptions. |
| .changeset/force-refresh-organization-token.md | Documents the new forceRefresh option and targeted clearAccessToken behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
199
to
203
| ): Promise<string> => { | ||
| const client = new LogtoClient(config); | ||
| const nodeClient = await client.createNodeClient({ ignoreCookieChange: true }); | ||
| return nodeClient.getAccessToken(resource, organizationId); | ||
| return nodeClient.getAccessToken(resource, organizationId, options); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Reported by a user building multi-tenant org RBAC on
@logto/next(App Router):They observed the same in the opposite direction (demotion), and asked whether there is a supported way to force a refresh-token exchange from the official Next.js SDK.
Root cause
This is purely a client-side caching gap, not an OAuth or server limitation.
Server side is already correct. In
logto-io/logto, therefresh_tokengrant re-reads the user's organization scopes from the database on every exchange (packages/core/src/oidc/grants/refresh-token.ts):handleOrganizationTokenthen issuesavailableScopes ∩ scope, wherescopefalls back to the refresh token's original scope set. So promotions and demotions are reflected immediately in a newly exchanged organization token — no re-login required, as long as the permission was part of the original authorization request.Client side never gets there.
StandardLogtoClient.#getAccessTokenshort-circuits on any unexpired cached token:In
@logto/nexttheaccessTokenMapis persisted into the encrypted session cookie viaCookieStorage, so the same token survives page reloads and new server processes — which is why the user saw a byte-identical token. Organization tokens have a fixed 1h TTL (reversedResourceAccessTokenTtl = 3600), so the stale window is up to an hour. Sign-in/sign-out appear to "fix" it only because both callclearAllTokens()internally.@logto/clientdoes haveclearAccessToken()/clearAllTokens(), and@logto/react/@logto/vueproxy them — but@logto/nextexposes neither, so App Router users have no supported escape hatch.Changes
@logto/client:GetAccessTokenOptionstype.getAccessToken(resource?, organizationId?, options?)andgetOrganizationToken(organizationId, options?)accept{ forceRefresh: true }to skip the cache and always exchange a new token with the Refresh Token.clearAccessToken(resource?, organizationId?)can now evict a single cached token instead of all of them. Called with no arguments it keeps the existing "clear everything" behavior.@logto/next:getAccessToken/getOrganizationTokentake the newoptions, plus a newclearAccessToken(request, response, resource?, organizationId?).getAccessToken/getOrganizationToken/getAccessTokenRSC/getOrganizationTokenRSCtake the newoptions, plus a newclearAccessToken(config, resource?, organizationId?).@logto/nodere-exportsGetAccessTokenOptions.Every addition is an optional parameter, so this is fully backward compatible.
Usage
Note this must run in a Server Action or Route Handler, not an RSC, so the refreshed token and any rotated refresh token can be written back to the session cookie.
Note also that a token can still only carry scopes present in the original authorization request. Introducing a brand new scope continues to require a new authorization request, e.g.
signIn({ prompt: 'consent' }).Tests
packages/client/src/index.token-cache.test.ts: asserts the cache is honored by default, thatforceRefreshbypasses it for both access tokens and organization tokens (verifying theorganization_idrefresh-token request body), and that a targetedclearAccessTokenevicts only the matching entry while persisting the rest.packages/next/src/index.test.ts: option pass-through forgetAccessToken/getOrganizationToken, and coverage for the newclearAccessToken.@logto/client(79),@logto/node(43),@logto/next(31) all pass;tsc --noEmitandeslintare clean across the workspace.