From 3c9fc92565c79cc819dcd294852d10f026d1247e Mon Sep 17 00:00:00 2001 From: Bryan Dichtl Date: Thu, 5 Nov 2026 10:46:43 +1100 Subject: [PATCH] fix(auth-next-server): stop resending a rejected refresh token When the token endpoint rejects a refresh token with `invalid_grant`, the jwt callback kept the token and only set `error: 'RefreshTokenError'`. The access token stays expired, so every later session read (each `/api/auth/session` poll, each `auth()` call) sent the same dead token again, for the life of the session cookie (365 days, renewed on each read). Now a terminal failure (`invalid_grant`, which Auth0 returns with 403 and Ory with 400) drops the refresh token. Later reads take the no-refresh-token branch without a network call and keep reporting `RefreshTokenError`, so apps that sign out on it still do. Network errors, 5xx and 429 keep the token so the next read can retry. `refreshAccessToken` now throws `RefreshTokenRequestError` (status + OAuth error code); it and `isTerminalRefreshError` are exported. Co-Authored-By: Claude Opus 5.5 --- packages/auth-next-server/README.md | 2 + packages/auth-next-server/src/config.test.ts | 110 ++++++++++++++++++ packages/auth-next-server/src/config.ts | 35 ++++-- packages/auth-next-server/src/index.ts | 2 + packages/auth-next-server/src/refresh.test.ts | 50 ++++++++ packages/auth-next-server/src/refresh.ts | 40 ++++++- 6 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 packages/auth-next-server/src/config.test.ts create mode 100644 packages/auth-next-server/src/refresh.test.ts diff --git a/packages/auth-next-server/README.md b/packages/auth-next-server/README.md index 7b4e816c77..5adbefcce1 100644 --- a/packages/auth-next-server/README.md +++ b/packages/auth-next-server/README.md @@ -556,6 +556,8 @@ The session may contain an `error` field indicating authentication issues: | `"TokenExpired"` | Access token expired, refresh token may be valid | Let client refresh via `@imtbl/auth-next-client` | | `"RefreshTokenError"` | Refresh token invalid/expired | Redirect to login | +When the auth server rejects the refresh token with `invalid_grant`, the token is removed from the session, so later session reads do not send it again. The error stays `"RefreshTokenError"` until the user signs in again. Other failures (network errors, 5xx, 429) keep the refresh token, and the next session read retries. + ## TypeScript All types are exported for use in your application: diff --git a/packages/auth-next-server/src/config.test.ts b/packages/auth-next-server/src/config.test.ts new file mode 100644 index 0000000000..4a223c6d81 --- /dev/null +++ b/packages/auth-next-server/src/config.test.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable import/first */ + +jest.mock('next-auth/providers/credentials', () => jest.fn((options: unknown) => options)); +jest.mock('next-auth/jwt', () => ({ encode: jest.fn() })); + +import { createAuthConfig } from './config'; + +const jwt = createAuthConfig({ + clientId: 'client', + redirectUri: 'http://localhost:3000/callback', +}).callbacks!.jwt as (params: any) => Promise; + +// An access token past its expiry, so the jwt callback tries to refresh. +const expiredToken = { + sub: 'user', + accessToken: 'old-access', + refreshToken: 'old-refresh', + accessTokenExpires: Date.now() - 60_000, +}; + +function mockTokenEndpoint(status: number, body: unknown) { + return jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify(body), { status }), + ); +} + +describe('jwt callback refresh failures', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('drops a rejected refresh token so it is not sent again', async () => { + const fetchMock = mockTokenEndpoint(400, { error: 'invalid_grant' }); + + const failed = await jwt({ token: expiredToken }); + + expect(failed.error).toBe('RefreshTokenError'); + expect(failed.refreshToken).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Every later session read: no token endpoint call, error stays RefreshTokenError. + const again = await jwt({ token: failed }); + const third = await jwt({ token: again }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(third.error).toBe('RefreshTokenError'); + }); + + it('treats an Auth0 403 invalid_grant the same way', async () => { + mockTokenEndpoint(403, { error: 'invalid_grant', error_description: 'Unknown or invalid refresh token.' }); + + const failed = await jwt({ token: expiredToken }); + + expect(failed.error).toBe('RefreshTokenError'); + expect(failed.refreshToken).toBeUndefined(); + }); + + it('keeps the refresh token after a transient failure and retries on the next read', async () => { + const fetchMock = mockTokenEndpoint(503, { error: 'temporarily_unavailable' }); + + const failed = await jwt({ token: expiredToken }); + + expect(failed.error).toBe('RefreshTokenError'); + expect(failed.refreshToken).toBe('old-refresh'); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + access_token: 'new-access', + refresh_token: 'new-refresh', + }), { status: 200 })); + + const recovered = await jwt({ token: failed }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(recovered.error).toBeUndefined(); + expect(recovered.accessToken).toBe('new-access'); + expect(recovered.refreshToken).toBe('new-refresh'); + }); + + it('keeps the refresh token after a network error', async () => { + jest.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed')); + + const failed = await jwt({ token: expiredToken }); + + expect(failed.error).toBe('RefreshTokenError'); + expect(failed.refreshToken).toBe('old-refresh'); + }); + + it('drops a rejected refresh token on forceRefresh too', async () => { + mockTokenEndpoint(400, { error: 'invalid_grant' }); + + const failed = await jwt({ + token: { ...expiredToken, accessTokenExpires: Date.now() + 600_000 }, + trigger: 'update', + session: { forceRefresh: true }, + }); + + expect(failed.error).toBe('RefreshTokenError'); + expect(failed.refreshToken).toBeUndefined(); + }); + + it('still reports TokenExpired when a session never had a refresh token', async () => { + const fetchMock = jest.spyOn(global, 'fetch'); + + const result = await jwt({ token: { ...expiredToken, refreshToken: undefined } }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.error).toBe('TokenExpired'); + }); +}); diff --git a/packages/auth-next-server/src/config.ts b/packages/auth-next-server/src/config.ts index bed62fbb3e..74adbddc11 100644 --- a/packages/auth-next-server/src/config.ts +++ b/packages/auth-next-server/src/config.ts @@ -5,7 +5,9 @@ import type { NextAuthConfig } from 'next-auth'; import CredentialsImport from 'next-auth/providers/credentials'; import { encode as encodeImport } from 'next-auth/jwt'; import type { ImmutableAuthConfig, ImmutableTokenData, UserInfoResponse } from './types'; -import { isTokenExpired, refreshAccessToken, extractZkEvmFromIdToken } from './refresh'; +import { + isTokenExpired, isTerminalRefreshError, refreshAccessToken, extractZkEvmFromIdToken, +} from './refresh'; import { DEFAULT_AUTH_DOMAIN, DEFAULT_REDIRECT_URI_PATH, @@ -69,6 +71,22 @@ function resolveDefaultRedirectUri(): string { return DEFAULT_REDIRECT_URI_PATH; } +/** + * JWT state after a failed refresh. + * + * A rejected token (`invalid_grant`) is dropped. The session is read on every request and + * every `/api/auth/session` poll, so keeping it would resend the same dead token to the + * token endpoint each time, for as long as the session cookie lives. A transient failure + * keeps the token so a later read can retry. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function refreshFailed(token: any, error: unknown) { + if (isTerminalRefreshError(error)) { + return { ...token, refreshToken: undefined, error: 'RefreshTokenError' }; + } + return { ...token, error: 'RefreshTokenError' }; +} + /** * Create Auth.js v5 configuration for Immutable authentication. * @@ -252,10 +270,7 @@ export function createAuthConfig(config?: ImmutableAuthConfig): NextAuthConfig { } catch (error) { // eslint-disable-next-line no-console console.error('[auth-next-server] Force refresh failed:', error); - return { - ...token, - error: 'RefreshTokenError', - }; + return refreshFailed(token, error); } } @@ -300,17 +315,15 @@ export function createAuthConfig(config?: ImmutableAuthConfig): NextAuthConfig { } catch (error) { // eslint-disable-next-line no-console console.error('[auth-next-server] Token refresh failed:', error); - return { - ...token, - error: 'RefreshTokenError', - }; + return refreshFailed(token, error); } } - // No refresh token available + // No refresh token available. Keep RefreshTokenError if a rejected token was + // dropped: TokenExpired tells callers a client-side refresh may still work. return { ...token, - error: 'TokenExpired', + error: token.error === 'RefreshTokenError' ? token.error : 'TokenExpired', }; } catch (error) { // eslint-disable-next-line no-console diff --git a/packages/auth-next-server/src/index.ts b/packages/auth-next-server/src/index.ts index 8b64f7b88d..5cbba889b6 100644 --- a/packages/auth-next-server/src/index.ts +++ b/packages/auth-next-server/src/index.ts @@ -40,7 +40,9 @@ export { default as NextAuth } from 'next-auth'; export { createAuthConfig, createAuthOptions } from './config'; export { isTokenExpired, + isTerminalRefreshError, refreshAccessToken, + RefreshTokenRequestError, extractZkEvmFromIdToken, type RefreshedTokens, type ZkEvmData, diff --git a/packages/auth-next-server/src/refresh.test.ts b/packages/auth-next-server/src/refresh.test.ts new file mode 100644 index 0000000000..77d1df7ab8 --- /dev/null +++ b/packages/auth-next-server/src/refresh.test.ts @@ -0,0 +1,50 @@ +import { + isTerminalRefreshError, + refreshAccessToken, + RefreshTokenRequestError, +} from './refresh'; + +function mockTokenEndpoint(status: number, body: string) { + return jest.spyOn(global, 'fetch').mockResolvedValue(new Response(body, { status })); +} + +describe('refreshAccessToken', () => { + it('throws RefreshTokenRequestError with the status and OAuth error code', async () => { + mockTokenEndpoint(400, JSON.stringify({ + error: 'invalid_grant', + error_description: 'The refresh token can not be found.', + })); + + const error = await refreshAccessToken('rt', 'client').catch((e) => e); + + expect(error).toBeInstanceOf(RefreshTokenRequestError); + expect(error.message).toBe('The refresh token can not be found.'); + expect(error.status).toBe(400); + expect(error.oauthError).toBe('invalid_grant'); + }); + + it('leaves oauthError unset when the body is not JSON', async () => { + mockTokenEndpoint(502, 'Bad Gateway'); + + const error = await refreshAccessToken('rt', 'client').catch((e) => e); + + expect(error).toBeInstanceOf(RefreshTokenRequestError); + expect(error.message).toBe('Bad Gateway'); + expect(error.status).toBe(502); + expect(error.oauthError).toBeUndefined(); + }); +}); + +describe('isTerminalRefreshError', () => { + it('is true for invalid_grant from Ory (400) and Auth0 (403)', () => { + expect(isTerminalRefreshError(new RefreshTokenRequestError('x', 400, 'invalid_grant'))).toBe(true); + expect(isTerminalRefreshError(new RefreshTokenRequestError('x', 403, 'invalid_grant'))).toBe(true); + }); + + it('is false for errors a retry may recover from', () => { + expect(isTerminalRefreshError(new RefreshTokenRequestError('x', 429, 'rate_limited'))).toBe(false); + expect(isTerminalRefreshError(new RefreshTokenRequestError('x', 503))).toBe(false); + expect(isTerminalRefreshError(new RefreshTokenRequestError('x', 401, 'invalid_client'))).toBe(false); + expect(isTerminalRefreshError(new TypeError('fetch failed'))).toBe(false); + }); +}); diff --git a/packages/auth-next-server/src/refresh.ts b/packages/auth-next-server/src/refresh.ts index dbae50a641..cfaf89ee32 100644 --- a/packages/auth-next-server/src/refresh.ts +++ b/packages/auth-next-server/src/refresh.ts @@ -31,6 +31,37 @@ export interface RefreshedTokens { accessTokenExpires: number; } +/** + * Thrown by refreshAccessToken when the token endpoint rejects the request. + * `oauthError` is the RFC 6749 `error` code, when the response carried one. + */ +export class RefreshTokenRequestError extends Error { + readonly status: number; + + readonly oauthError?: string; + + constructor(message: string, status: number, oauthError?: string) { + super(message); + this.name = 'RefreshTokenRequestError'; + this.status = status; + this.oauthError = oauthError; + } +} + +/** + * True when sending the same refresh token again can never succeed. + * + * `invalid_grant` means the auth server does not know the token: it expired, was + * revoked or already rotated, or was issued by a different auth server (e.g. an Auth0 + * token presented after the client moved to Ory). Auth0 returns it with a 403 and Ory + * with a 400, so the OAuth error code is checked rather than the HTTP status. + * + * Network errors, 5xx and 429 are not terminal: the token may still be good. + */ +export function isTerminalRefreshError(error: unknown): boolean { + return error instanceof RefreshTokenRequestError && error.oauthError === 'invalid_grant'; +} + /** * Decode JWT payload to extract expiry time */ @@ -93,7 +124,8 @@ export function extractZkEvmFromIdToken(idToken?: string): ZkEvmData | undefined * @param clientId - The OAuth client ID * @param authDomain - The authentication domain (default: https://auth.immutable.com) * @returns The refreshed tokens - * @throws Error if refresh fails + * @throws RefreshTokenRequestError if the token endpoint rejects the request + * @throws Error if the request itself fails (e.g. network error) */ export async function refreshAccessToken( refreshToken: string, @@ -117,8 +149,12 @@ export async function refreshAccessToken( if (!response.ok) { const errorText = await response.text(); let errorMessage = `Token refresh failed with status ${response.status}`; + let oauthError: string | undefined; try { const errorData = JSON.parse(errorText); + if (typeof errorData.error === 'string') { + oauthError = errorData.error; + } if (errorData.error_description) { errorMessage = errorData.error_description; } else if (errorData.error) { @@ -129,7 +165,7 @@ export async function refreshAccessToken( errorMessage = errorText; } } - throw new Error(errorMessage); + throw new RefreshTokenRequestError(errorMessage, response.status, oauthError); } const tokenData = await response.json();