Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/auth-next-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions packages/auth-next-server/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>;

// 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');
});
});
35 changes: 24 additions & 11 deletions packages/auth-next-server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/auth-next-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/auth-next-server/src/refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 38 additions & 2 deletions packages/auth-next-server/src/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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();
Expand Down
Loading