Skip to content

feat(client,next): support force-refreshing access and organization tokens - #1139

Open
wangsijie wants to merge 1 commit into
masterfrom
feat-force-refresh-organization-token
Open

feat(client,next): support force-refreshing access and organization tokens#1139
wangsijie wants to merge 1 commit into
masterfrom
feat-force-refresh-organization-token

Conversation

@wangsijie

Copy link
Copy Markdown
Contributor

Problem

Reported by a user building multi-tenant org RBAC on @logto/next (App Router):

The organization owner promotes this user from Interviewer to Admin. The user refreshes the browser. We call getOrganizationToken() again. The returned Organization Token is identical to the previous one and still contains the old scopes. Only after signing out and signing back in do we receive a new Organization Token with the updated scopes.

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, the refresh_token grant re-reads the user's organization scopes from the database on every exchange (packages/core/src/oidc/grants/refresh-token.ts):

if (organizationId && !params.resource) {
  const availableScopes = await queries.organizations.relations.usersRoles
    .getUserScopes(organizationId, account.accountId)
    .then((scopes) => scopes.map(({ name }) => name));
  await handleOrganizationToken({ envSet, availableScopes, accessToken: at, organizationId, scope });
}

handleOrganizationToken then issues availableScopes ∩ scope, where scope falls 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.#getAccessToken short-circuits on any unexpired cached token:

const accessToken = this.accessTokenMap.get(accessTokenKey);

if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
  return accessToken.token;   // never reaches the refresh grant
}

In @logto/next the accessTokenMap is persisted into the encrypted session cookie via CookieStorage, 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 call clearAllTokens() internally.

@logto/client does have clearAccessToken() / clearAllTokens(), and @logto/react / @logto/vue proxy them — but @logto/next exposes neither, so App Router users have no supported escape hatch.

Changes

@logto/client:

  • New GetAccessTokenOptions type. getAccessToken(resource?, organizationId?, options?) and getOrganizationToken(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:

  • Pages Router: getAccessToken / getOrganizationToken take the new options, plus a new clearAccessToken(request, response, resource?, organizationId?).
  • Server actions: getAccessToken / getOrganizationToken / getAccessTokenRSC / getOrganizationTokenRSC take the new options, plus a new clearAccessToken(config, resource?, organizationId?).
  • Edge runtime re-exports the new type.

@logto/node re-exports GetAccessTokenOptions.

Every addition is an optional parameter, so this is fully backward compatible.

Usage

'use server';

import { getOrganizationToken, clearAccessToken } from '@logto/next/server-actions';

// Right after promoting/demoting a member — pick up the new scopes immediately:
const token = await getOrganizationToken(logtoConfig, organizationId, { forceRefresh: true });

// Or evict the cache so the next read refreshes:
await clearAccessToken(logtoConfig, undefined, organizationId);

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

  • New packages/client/src/index.token-cache.test.ts: asserts the cache is honored by default, that forceRefresh bypasses it for both access tokens and organization tokens (verifying the organization_id refresh-token request body), and that a targeted clearAccessToken evicts only the matching entry while persisting the rest.
  • packages/next/src/index.test.ts: option pass-through for getAccessToken / getOrganizationToken, and coverage for the new clearAccessToken.

@logto/client (79), @logto/node (43), @logto/next (31) all pass; tsc --noEmit and eslint are clean across the workspace.

…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.
@wangsijie
wangsijie requested a review from charIeszhao as a code owner August 3, 2026 04:12
Copilot AI review requested due to automatic review settings August 3, 2026 04:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 GetAccessTokenOptions with { forceRefresh?: boolean } and threads it through getAccessToken() / 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);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants