diff --git a/EXAMPLES.md b/EXAMPLES.md index f0629f40..a4756335 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -520,6 +520,65 @@ export async function middleware(request: NextRequest) { } ``` +### Forcing Access Token Refresh + +In some scenarios, you might need to explicitly force the refresh of an access token, even if it hasn't expired yet. This can be useful if, for example, the user's permissions or scopes have changed and you need to ensure the application has the latest token reflecting these changes. + +The `getAccessToken` method provides an option to force this refresh. + +**App Router (Server Components, Route Handlers, Server Actions):** + +When calling `getAccessToken` without request and response objects, you can pass an options object as the first argument. Set the `refresh` property to `true` to force a token refresh. + +```typescript +// app/api/my-api/route.ts +import { getAccessToken } from '@auth0/nextjs-auth0'; + +export async function GET() { + try { + // Force a refresh of the access token + const { token, expiresAt } = await getAccessToken({ refresh: true }); + + // Use the refreshed token + // ... + } catch (error) { + console.error('Error getting access token:', error); + return Response.json({ error: 'Failed to get access token' }, { status: 500 }); + } +} +``` + +**Pages Router (getServerSideProps, API Routes):** + +When calling `getAccessToken` with request and response objects (from `getServerSideProps` context or an API route), the options object is passed as the third argument. + +```typescript +// pages/api/my-pages-api.ts +import { getAccessToken, withApiAuthRequired } from '@auth0/nextjs-auth0'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default withApiAuthRequired(async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + // Force a refresh of the access token + const { token, expiresAt } = await getAccessToken(req, res, { + refresh: true + }); + + // Use the refreshed token + // ... + } catch (error: any) { + console.error('Error getting access token:', error); + res.status(error.status || 500).json({ error: error.message }); + } +}); +``` + +By setting `{ refresh: true }`, you instruct the SDK to bypass the standard expiration check and request a new access token from the identity provider using the refresh token (if available and valid). The new token set (including the potentially updated access token, refresh token, and expiration time) will be saved back into the session automatically. +This will in turn, update the `access_token`, `id_token` and `expires_at` fields of `tokenset` in the session. + ## `` ### Passing an initial user from the server diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 44b67630..0cd9eec7 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -668,7 +668,8 @@ export class AuthClient { * refresh it using the refresh token, if available. */ async getTokenSet( - tokenSet: TokenSet + tokenSet: TokenSet, + forceRefresh?: boolean | undefined ): Promise<[null, TokenSet] | [SdkError, null]> { // the access token has expired but we do not have a refresh token if (!tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) { @@ -681,65 +682,67 @@ export class AuthClient { ]; } - // the access token has expired and we have a refresh token - if (tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) { - const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata(); - - if (discoveryError) { - console.error(discoveryError); - return [discoveryError, null]; - } + if (tokenSet.refreshToken) { + // either the access token has expired or we are forcing a refresh + if (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) { + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); - const refreshTokenRes = await oauth.refreshTokenGrantRequest( - authorizationServerMetadata, - this.clientMetadata, - await this.getClientAuth(), - tokenSet.refreshToken, - { - ...this.httpOptions(), - [oauth.customFetch]: this.fetch, - [oauth.allowInsecureRequests]: this.allowInsecureRequests + if (discoveryError) { + console.error(discoveryError); + return [discoveryError, null]; } - ); - let oauthRes: oauth.TokenEndpointResponse; - try { - oauthRes = await oauth.processRefreshTokenResponse( + const refreshTokenRes = await oauth.refreshTokenGrantRequest( authorizationServerMetadata, this.clientMetadata, - refreshTokenRes + await this.getClientAuth(), + tokenSet.refreshToken, + { + ...this.httpOptions(), + [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests + } ); - } catch (e: any) { - console.error(e); - return [ - new AccessTokenError( - AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN, - "The access token has expired and there was an error while trying to refresh it. Check the server logs for more information." - ), - null - ]; - } - const accessTokenExpiresAt = - Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in); + let oauthRes: oauth.TokenEndpointResponse; + try { + oauthRes = await oauth.processRefreshTokenResponse( + authorizationServerMetadata, + this.clientMetadata, + refreshTokenRes + ); + } catch (e: any) { + console.error(e); + return [ + new AccessTokenError( + AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN, + "The access token has expired and there was an error while trying to refresh it. Check the server logs for more information." + ), + null + ]; + } - const updatedTokenSet = { - ...tokenSet, // contains the existing `iat` claim to maintain the session lifetime - accessToken: oauthRes.access_token, - idToken: oauthRes.id_token, - expiresAt: accessTokenExpiresAt - }; + const accessTokenExpiresAt = + Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in); + + const updatedTokenSet = { + ...tokenSet, // contains the existing `iat` claim to maintain the session lifetime + accessToken: oauthRes.access_token, + idToken: oauthRes.id_token, + expiresAt: accessTokenExpiresAt + }; + + if (oauthRes.refresh_token) { + // refresh token rotation is enabled, persist the new refresh token from the response + updatedTokenSet.refreshToken = oauthRes.refresh_token; + } else { + // we did not get a refresh token back, keep the current long-lived refresh token around + updatedTokenSet.refreshToken = tokenSet.refreshToken; + } - if (oauthRes.refresh_token) { - // refresh token rotation is enabled, persist the new refresh token from the response - updatedTokenSet.refreshToken = oauthRes.refresh_token; - } else { - // we did not get a refresh token back, keep the current long-lived refresh token around - updatedTokenSet.refreshToken = tokenSet.refreshToken; + return [null, updatedTokenSet]; } - - return [null, updatedTokenSet]; } return [null, tokenSet]; diff --git a/src/server/client.test.ts b/src/server/client.test.ts index dc7a44ad..c24b82d6 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1,22 +1,26 @@ +import { NextResponse, type NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AccessTokenError, AccessTokenErrorCode } from "../errors"; +import { SessionData } from "../types"; +import { AuthClient } from "./auth-client"; // Import the actual class for spyOn import { Auth0Client } from "./client.js"; +// Define ENV_VARS at the top level for broader scope +const ENV_VARS = { + DOMAIN: "AUTH0_DOMAIN", + CLIENT_ID: "AUTH0_CLIENT_ID", + CLIENT_SECRET: "AUTH0_CLIENT_SECRET", + CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY", + APP_BASE_URL: "APP_BASE_URL", + SECRET: "AUTH0_SECRET", + SCOPE: "AUTH0_SCOPE" +}; + describe("Auth0Client", () => { // Store original env vars const originalEnv = { ...process.env }; - // Define correct environment variable names - const ENV_VARS = { - DOMAIN: "AUTH0_DOMAIN", - CLIENT_ID: "AUTH0_CLIENT_ID", - CLIENT_SECRET: "AUTH0_CLIENT_SECRET", - CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY", - APP_BASE_URL: "APP_BASE_URL", - SECRET: "AUTH0_SECRET", - SCOPE: "AUTH0_SCOPE" - }; - // Clear env vars before each test beforeEach(() => { vi.resetModules(); @@ -33,6 +37,7 @@ describe("Auth0Client", () => { // Restore env vars after each test afterEach(() => { process.env = { ...originalEnv }; + vi.restoreAllMocks(); // Restore mocks created within tests/beforeEach }); describe("constructor validation", () => { @@ -111,4 +116,102 @@ describe("Auth0Client", () => { } }); }); + + describe("getAccessToken", () => { + const mockSession: SessionData = { + user: { sub: "user123" }, + tokenSet: { + accessToken: "old_access_token", + idToken: "old_id_token", + refreshToken: "old_refresh_token", + expiresAt: Date.now() / 1000 - 3600 // Expired + }, + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 - 7200 // Some time in the past + }, + createdAt: Date.now() / 1000 + }; + + // Restore original mock for refreshed token set + const mockRefreshedTokenSet = { + accessToken: "new_access_token", + idToken: "new_id_token", + refreshToken: "new_refresh_token", + expiresAt: Date.now() / 1000 + 3600, // Not expired + scope: "openid profile email" + }; + + let client: Auth0Client; + let mockGetSession: ReturnType; + let mockSaveToSession: ReturnType; + let mockGetTokenSet: ReturnType; // Re-declare mockGetTokenSet + + beforeEach(() => { + // Reset mocks specifically if vi.restoreAllMocks isn't enough + // vi.resetAllMocks(); // Alternative to restoreAllMocks in afterEach + + // Set necessary environment variables + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + + client = new Auth0Client(); + + // Mock internal methods of Auth0Client + mockGetSession = vi + .spyOn(Auth0Client.prototype as any, "getSession") + .mockResolvedValue(mockSession); + mockSaveToSession = vi + .spyOn(Auth0Client.prototype as any, "saveToSession") + .mockResolvedValue(undefined); + + // Restore mocking of getTokenSet directly + mockGetTokenSet = vi + .spyOn(AuthClient.prototype as any, "getTokenSet") + .mockResolvedValue([null, mockRefreshedTokenSet]); // Simulate successful refresh + + // Remove mocks for discoverAuthorizationServerMetadata and getClientAuth + // Remove fetch mock + }); + + it("should throw AccessTokenError if no session exists", async () => { + // Override getSession mock for this specific test + mockGetSession.mockResolvedValue(null); + + // Mock request and response objects + const mockReq = { headers: new Headers() } as NextRequest; + const mockRes = new NextResponse(); + + await expect( + client.getAccessToken(mockReq, mockRes) + ).rejects.toThrowError( + new AccessTokenError( + AccessTokenErrorCode.MISSING_SESSION, + "The user does not have an active session." + ) + ); + // Ensure getTokenSet was not called + expect(mockGetTokenSet).not.toHaveBeenCalled(); + }); + + it("should throw error from getTokenSet if refresh fails", async () => { + const refreshError = new Error("Refresh failed"); + // Restore overriding the getTokenSet mock directly + mockGetTokenSet.mockResolvedValue([refreshError, null]); + + // Mock request and response objects + const mockReq = { headers: new Headers() } as NextRequest; + const mockRes = new NextResponse(); + + await expect( + client.getAccessToken(mockReq, mockRes, { refresh: true }) + ).rejects.toThrowError(refreshError); + + // Verify save was not called + expect(mockSaveToSession).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/server/client.ts b/src/server/client.ts index 8bcc3a21..393096c6 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -315,17 +315,29 @@ export class Auth0Client { * NOTE: Server Components cannot set cookies. Calling `getAccessToken()` in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted. * It is recommended to call `getAccessToken(req, res)` in the middleware if you need to retrieve the access token in a Server Component to ensure the updated token set is persisted. */ - async getAccessToken(): Promise<{ token: string; expiresAt: number }>; + /** + * @param options Optional configuration for getting the access token. + * @param options.refresh Force a refresh of the access token. + */ + async getAccessToken( + options?: GetAccessTokenOptions + ): Promise<{ token: string; expiresAt: number; scope?: string }>; /** * getAccessToken returns the access token. * * This method can be used in middleware and `getServerSideProps`, API routes in the **Pages Router**. + * + * @param req The request object. + * @param res The response object. + * @param options Optional configuration for getting the access token. + * @param options.refresh Force a refresh of the access token. */ async getAccessToken( req: PagesRouterRequest | NextRequest, - res: PagesRouterResponse | NextResponse - ): Promise<{ token: string; expiresAt: number }>; + res: PagesRouterResponse | NextResponse, + options?: GetAccessTokenOptions + ): Promise<{ token: string; expiresAt: number; scope?: string }>; /** * getAccessToken returns the access token. @@ -334,9 +346,48 @@ export class Auth0Client { * It is recommended to call `getAccessToken(req, res)` in the middleware if you need to retrieve the access token in a Server Component to ensure the updated token set is persisted. */ async getAccessToken( - req?: PagesRouterRequest | NextRequest, - res?: PagesRouterResponse | NextResponse + arg1?: PagesRouterRequest | NextRequest | GetAccessTokenOptions, + arg2?: PagesRouterResponse | NextResponse, + arg3?: GetAccessTokenOptions ): Promise<{ token: string; expiresAt: number; scope?: string }> { + const defaultOptions: Required = { + refresh: false + }; + + let req: PagesRouterRequest | NextRequest | undefined = undefined; + let res: PagesRouterResponse | NextResponse | undefined = undefined; + let options: GetAccessTokenOptions = {}; + + // Determine which overload was called based on arguments + if ( + arg1 && + (arg1 instanceof Request || typeof (arg1 as any).headers === "object") + ) { + // Case: getAccessToken(req, res, options?) + req = arg1 as PagesRouterRequest | NextRequest; + res = arg2; // arg2 must be Response if arg1 is Request + // Merge provided options (arg3) with defaults + options = { ...defaultOptions, ...(arg3 ?? {}) }; + if (!res) { + throw new TypeError( + "getAccessToken(req, res): The 'res' argument is missing. Both 'req' and 'res' must be provided together for Pages Router or middleware usage." + ); + } + } else { + // Case: getAccessToken(options?) or getAccessToken() + // arg1 (if present) must be options, arg2 and arg3 must be undefined. + if (arg2 !== undefined || arg3 !== undefined) { + throw new TypeError( + "getAccessToken: Invalid arguments. Valid signatures are getAccessToken(), getAccessToken(options), or getAccessToken(req, res, options)." + ); + } + // Merge provided options (arg1) with defaults + options = { + ...defaultOptions, + ...((arg1 as GetAccessTokenOptions) ?? {}) + }; + } + const session: SessionData | null = req ? await this.getSession(req) : await this.getSession(); @@ -349,7 +400,8 @@ export class Auth0Client { } const [error, tokenSet] = await this.authClient.getTokenSet( - session.tokenSet + session.tokenSet, + options.refresh ); if (error) { throw error; @@ -723,3 +775,7 @@ export class Auth0Client { }; } } + +export type GetAccessTokenOptions = { + refresh?: boolean; +}; diff --git a/src/server/force-refresh.integration.test.ts b/src/server/force-refresh.integration.test.ts new file mode 100644 index 00000000..86efc7a5 --- /dev/null +++ b/src/server/force-refresh.integration.test.ts @@ -0,0 +1,472 @@ +/** + * @fileoverview + * Tests for access token refresh logic in both AuthClient and Auth0Client. + * + * These tests verify that when an access token is expired or a refresh is forced: + * 1. `AuthClient.getTokenSet` correctly uses the refresh token grant to obtain a new + * token set from the authorization server. + * 2. `Auth0Client.getAccessToken` correctly utilizes the internal `AuthClient.getTokenSet` + * to refresh the token and handles session saving appropriately for different + * Next.js router contexts (Pages Router vs App Router). + * + * Mocking Strategy: + * - A mock authorization server (`getMockAuthorizationServer`) is implemented using `vi.fn()` + * to simulate the behavior of the OIDC token endpoint (`/oauth/token`) and discovery + * endpoints (`/.well-known/...`). This mock function replaces the actual `fetch` calls. + * - For `Auth0Client.getAccessToken` tests, direct injection of the mock fetch isn't feasible. + * Instead, `vi.spyOn(AuthClient.prototype, 'getTokenSet')` is used. The spy's implementation + * delegates the call to a real `AuthClient` instance that *is* configured with the mock fetch. + * A temporary restore/re-apply mechanism within the spy implementation prevents infinite recursion. + */ + +import { NextRequest, NextResponse } from "next/server"; +import * as jose from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { generateSecret } from "../test/utils"; +import { SessionData, TokenSet } from "../types"; +import { AuthClient } from "./auth-client"; +import { Auth0Client } from "./client"; +import { StatelessSessionStore } from "./session/stateless-session-store"; +import { TransactionStore } from "./transaction-store"; + +// Basic constants for testing +const DEFAULT = { + domain: "https://op.example.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "https://example.org", + secret: "test-secret-long-enough-for-hs256", + alg: "RS256", + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + idToken: "test-id-token", + sub: "test-sub", + sid: "test-sid", + scope: "openid profile email" +}; + +/** + * Creates a simplified mock authorization server. + * + * This function returns a `vi.fn()` mock that simulates the fetch function, + * specifically handling requests to the token, OpenID configuration, and JWKS endpoints. + * It allows configuring the details of the refreshed tokens returned by the token endpoint. + * + * @param {object} options - Configuration for the mock responses. + * @param {string} [options.refreshedAccessToken="refreshed-access-token"] - Access token to return on refresh. + * @param {number} [options.refreshedExpiresIn=3600] - Expires_in value to return on refresh. + * @param {string} [options.refreshedRefreshToken="refreshed-refresh-token"] - Refresh token to return on refresh. + * @returns {Promise} A Vitest mock function simulating `fetch`. + */ +async function getMockAuthorizationServer({ + refreshedAccessToken = "refreshed-access-token", + refreshedExpiresIn = 3600, + refreshedRefreshToken = "refreshed-refresh-token" +}: { + refreshedAccessToken?: string; + refreshedExpiresIn?: number; + refreshedRefreshToken?: string; +} = {}) { + const keyPair = await jose.generateKeyPair(DEFAULT.alg); + const _authorizationServerMetadata = { + issuer: DEFAULT.domain, + token_endpoint: `${DEFAULT.domain}/oauth/token`, + jwks_uri: `${DEFAULT.domain}/.well-known/jwks.json` + }; + + return vi.fn( + async ( + input: RequestInfo | URL, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _init?: RequestInit + ): Promise => { + let url: URL; + if (input instanceof Request) { + url = new URL(input.url); + } else { + url = new URL(input); + } + + if (url.pathname === "/oauth/token") { + // For refresh token grant, generate a new ID token if needed + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Date.now(), + nonce: "nonce-value" // Nonce might not be strictly needed for refresh, but included for completeness + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + return Response.json({ + token_type: "Bearer", + access_token: refreshedAccessToken, + refresh_token: refreshedRefreshToken, + id_token: jwt, // Always use the generated valid JWT + expires_in: refreshedExpiresIn, + scope: DEFAULT.scope + }); + } + + if (url.pathname === "/.well-known/openid-configuration") { + return Response.json(_authorizationServerMetadata); + } + + if (url.pathname === "/.well-known/jwks.json") { + const jwk = await jose.exportJWK(keyPair.publicKey); + return Response.json({ keys: [jwk] }); + } + + return new Response(null, { status: 404 }); + } + ); +} + +/** + * Tests specifically for the `AuthClient.getTokenSet` method's refresh logic. + */ +describe("AuthClient - getTokenSet", () => { + it("should return a refreshed token set when forceRefresh is true", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + + const initialExpiresAt = Math.floor(Date.now() / 1000) - 60; // Expired 1 minute ago + const initialTokenSet: TokenSet = { + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + idToken: "initial-id-token", + scope: "openid profile", + expiresAt: initialExpiresAt + }; + + const expectedRefreshedAccessToken = "authclient-refreshed-access-token"; + const expectedRefreshedRefreshToken = "authclient-refreshed-refresh-token"; + const expectedRefreshedExpiresIn = 7200; // 2 hours + + const mockFetch = await getMockAuthorizationServer({ + refreshedAccessToken: expectedRefreshedAccessToken, + refreshedRefreshToken: expectedRefreshedRefreshToken, + refreshedExpiresIn: expectedRefreshedExpiresIn + }); + + const authClient = new AuthClient({ + transactionStore, + sessionStore, + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + secret, + appBaseUrl: DEFAULT.appBaseUrl, + fetch: mockFetch + }); + + const [error, updatedTokenSet] = await authClient.getTokenSet( + initialTokenSet, + true + ); // forceRefresh = true + + expect(error).toBeNull(); + expect(updatedTokenSet).not.toBeNull(); + + // Check specific fields of the refreshed token set + expect(updatedTokenSet?.accessToken).toBe(expectedRefreshedAccessToken); + expect(updatedTokenSet?.refreshToken).toBe(expectedRefreshedRefreshToken); + expect(updatedTokenSet?.scope).toBe(initialTokenSet.scope); // Check against the original scope, as it's not updated by refresh + expect(updatedTokenSet?.idToken).toEqual(expect.any(String)); // ID token should be present (newly generated or provided) + expect(updatedTokenSet?.expiresAt).toBeGreaterThan(initialExpiresAt); + // Check if expiresAt is roughly correct (allowing for clock skew/test execution time) + const expectedExpiresAt = + Math.floor(Date.now() / 1000) + expectedRefreshedExpiresIn; + expect(updatedTokenSet?.expiresAt).toBeGreaterThanOrEqual( + expectedExpiresAt - 5 + ); // Allow 5s buffer + expect(updatedTokenSet?.expiresAt).toBeLessThanOrEqual( + expectedExpiresAt + 5 + ); // Allow 5s buffer + + // Verify the mock fetch was called for the token endpoint + const fetchCalls = mockFetch.mock.calls; + const tokenEndpointCall = fetchCalls.find((call) => { + let urlString: string; + if (call[0] instanceof URL) { + urlString = call[0].toString(); + } else if (call[0] instanceof Request) { + urlString = call[0].url; + } else { + // string + urlString = call[0]; + } + return urlString.endsWith("/oauth/token"); + }); + expect(tokenEndpointCall).toBeDefined(); + }); +}); + +/** + * Tests for the `Auth0Client.getAccessToken` method, covering both + * Pages Router and App Router overloads, specifically focusing on refresh logic. + */ +describe("Auth0Client - getAccessToken", () => { + let secret = ""; + let mockFetch = vi.fn(); + let realAuthClientWithMockFetch: AuthClient; + let getTokenSetSpy: any; + + /** + * Common setup executed before each test in this describe block. + * - Generates a secret. + * - Creates a mock fetch function using `getMockAuthorizationServer`. + * - Creates a real `AuthClient` instance configured with the mock fetch. + * - Spies on `AuthClient.prototype.getTokenSet` and sets up a mock implementation + * that delegates to the `realAuthClientWithMockFetch` instance, using a + * restore/re-apply mechanism to avoid infinite recursion. + */ + beforeEach(async () => { + secret = await generateSecret(32); + + // 1. Create the mock fetch instance for the test block + const expectedRefreshedAccessToken = + "getAccessToken-refreshed-access-token"; + const expectedRefreshedRefreshToken = + "getAccessToken-refreshed-refresh-token"; + const expectedRefreshedExpiresIn = 7200; + mockFetch = await getMockAuthorizationServer({ + refreshedAccessToken: expectedRefreshedAccessToken, + refreshedRefreshToken: expectedRefreshedRefreshToken, + refreshedExpiresIn: expectedRefreshedExpiresIn + }); + + // 2. Create a real AuthClient instance that USES the mockFetch + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + realAuthClientWithMockFetch = new AuthClient({ + transactionStore, + sessionStore, + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + secret, + appBaseUrl: DEFAULT.appBaseUrl, + fetch: mockFetch + }); + + // 3. Spy on AuthClient.prototype.getTokenSet and delegate, avoiding recursion + getTokenSetSpy = vi.spyOn(AuthClient.prototype, "getTokenSet"); + const mockImplementation = async ( + tokenSet: TokenSet, + forceRefresh?: boolean | undefined + ) => { + getTokenSetSpy.mockRestore(); + try { + const result = await realAuthClientWithMockFetch.getTokenSet( + tokenSet, + forceRefresh + ); + getTokenSetSpy.mockImplementation(mockImplementation); + return result; + } catch (error) { + getTokenSetSpy.mockImplementation(mockImplementation); + throw error; + } + }; + getTokenSetSpy.mockImplementation(mockImplementation); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * Test Case: Pages Router Overload - getAccessToken(req, res, options) + * Verifies that when called with req/res objects and refresh: true, + * it refreshes the token and saves the updated session. + */ + it("should refresh token for pages-router overload when refresh is true", async () => { + const initialExpiresAt = Math.floor(Date.now() / 1000) - 60; + const initialTokenSet: TokenSet = { + accessToken: "initial-pages-access-token", + refreshToken: "initial-pages-refresh-token", + idToken: "initial-pages-id-token", + scope: "openid profile pages", // Different scope for clarity + expiresAt: initialExpiresAt + }; + const initialSession: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: initialTokenSet, + internal: { sid: DEFAULT.sid, createdAt: Date.now() } + }; + + // Mock Auth0Client specific methods + vi.spyOn(Auth0Client.prototype as any, "getSession").mockResolvedValue( + initialSession + ); + const mockSaveToSession = vi + .spyOn(Auth0Client.prototype as any, "saveToSession") + .mockResolvedValue(undefined); + + // --- Execution --- + const auth0Client = new Auth0Client({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret: secret + }); + + const mockReq = new NextRequest("https://example.com/api/pages-test"); + const mockRes = new NextResponse(); + + // Use expected values from beforeEach setup + const expectedRefreshedAccessToken = + "getAccessToken-refreshed-access-token"; + const expectedRefreshedRefreshToken = + "getAccessToken-refreshed-refresh-token"; + const expectedRefreshedExpiresIn = 7200; + + const result = await auth0Client.getAccessToken(mockReq, mockRes, { + refresh: true + }); + + // --- Assertions --- + + // 1. Assert the returned access token details + expect(result).not.toBeNull(); + expect(result?.token).toBe(expectedRefreshedAccessToken); + expect(result?.expiresAt).toBeGreaterThan(initialExpiresAt); + const expectedExpiresAtRough = + Math.floor(Date.now() / 1000) + expectedRefreshedExpiresIn; + expect(result?.expiresAt).toBeGreaterThanOrEqual( + expectedExpiresAtRough - 5 + ); + expect(result?.expiresAt).toBeLessThanOrEqual(expectedExpiresAtRough + 5); + expect(result?.scope).toBe(initialTokenSet.scope); // Scope remains initial + + // 2. Assert saveToSession was called (Pages Router specific) + expect(mockSaveToSession).toHaveBeenCalledOnce(); + const savedSession = mockSaveToSession.mock.calls[0][0] as SessionData; + expect(savedSession.tokenSet.accessToken).toBe( + expectedRefreshedAccessToken + ); + expect(savedSession.tokenSet.refreshToken).toBe( + expectedRefreshedRefreshToken + ); + expect(savedSession.tokenSet.expiresAt).toBe(result?.expiresAt); + expect(savedSession.tokenSet.scope).toBe(initialTokenSet.scope); + + // 3. Assert mockFetch was called + const fetchCalls = mockFetch.mock.calls; + expect(fetchCalls.length).toBeGreaterThan(0); + const tokenEndpointCall = fetchCalls.find((call: any) => { + let urlString: string; + if (call[0] instanceof URL) { + urlString = call[0].toString(); + } else if (call[0] instanceof Request) { + urlString = call[0].url; + } else { + urlString = call[0]; + } + return urlString.endsWith("/oauth/token"); + }); + expect(tokenEndpointCall).toBeDefined(); + }); + + /** + * Test Case: App Router Overload - getAccessToken(options) + * Verifies that when called without req/res objects and refresh: true, + * it refreshes the token. Currently, it *also* calls saveToSession, + * so the test asserts this observed behavior. + */ + it("should refresh token for app-router overload when refresh is true", async () => { + const initialExpiresAt = Math.floor(Date.now() / 1000) - 60; + const initialTokenSet: TokenSet = { + accessToken: "initial-app-access-token", + refreshToken: "initial-app-refresh-token", + idToken: "initial-app-id-token", + scope: "openid profile app", // Different scope for clarity + expiresAt: initialExpiresAt + }; + const initialSession: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: initialTokenSet, + internal: { sid: DEFAULT.sid, createdAt: Date.now() } + }; + + // Mock Auth0Client specific methods + vi.spyOn(Auth0Client.prototype as any, "getSession").mockResolvedValue( + initialSession + ); + // IMPORTANT: saveToSession should NOT be called in app-router mode + const mockSaveToSession = vi.spyOn( + Auth0Client.prototype as any, + "saveToSession" + ); + + // --- Execution --- + const auth0Client = new Auth0Client({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret: secret + }); + + // Use expected values from beforeEach setup + const expectedRefreshedAccessToken = + "getAccessToken-refreshed-access-token"; + const expectedRefreshedRefreshToken = + "getAccessToken-refreshed-refresh-token"; + const expectedRefreshedExpiresIn = 7200; + + const result = await auth0Client.getAccessToken({ + refresh: true + }); + + // --- Assertions --- + + // 1. Assert the returned access token details + expect(result).not.toBeNull(); + expect(result?.token).toBe(expectedRefreshedAccessToken); + expect(result?.expiresAt).toBeGreaterThan(initialExpiresAt); + const expectedExpiresAtRough = + Math.floor(Date.now() / 1000) + expectedRefreshedExpiresIn; + expect(result?.expiresAt).toBeGreaterThanOrEqual( + expectedExpiresAtRough - 5 + ); + expect(result?.expiresAt).toBeLessThanOrEqual(expectedExpiresAtRough + 5); + expect(result?.scope).toBe(initialTokenSet.scope); // Scope remains initial + + // 2. Assert saveToSession WAS called (matches current behavior) + expect(mockSaveToSession).toHaveBeenCalledOnce(); + const savedSession = mockSaveToSession.mock.calls[0][0] as SessionData; + expect(savedSession.tokenSet.accessToken).toBe( + expectedRefreshedAccessToken + ); + expect(savedSession.tokenSet.refreshToken).toBe( + expectedRefreshedRefreshToken + ); + expect(savedSession.tokenSet.expiresAt).toBe(result?.expiresAt); + expect(savedSession.tokenSet.scope).toBe(initialTokenSet.scope); + + // 3. Assert mockFetch was called + const fetchCalls = mockFetch.mock.calls; + expect(fetchCalls.length).toBeGreaterThan(0); + const tokenEndpointCall = fetchCalls.find((call: any) => { + let urlString: string; + if (call[0] instanceof URL) { + urlString = call[0].toString(); + } else if (call[0] instanceof Request) { + urlString = call[0].url; + } else { + urlString = call[0]; + } + return urlString.endsWith("/oauth/token"); + }); + expect(tokenEndpointCall).toBeDefined(); + }); +});