From a789e93ceacf1bc5b2ea3fbe5c07b78714f71db6 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Sat, 12 Apr 2025 00:57:27 +0530 Subject: [PATCH 1/8] added refresh?:boolean to getAccessToken() to force refresh AT --- src/server/auth-client.ts | 119 +++++++++++++++++++++----------------- src/server/client.ts | 44 ++++++++------ 2 files changed, 94 insertions(+), 69 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 78a4344e..5cc92ace 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -669,7 +669,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) { @@ -682,68 +683,82 @@ 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 (tokenSet.refreshToken) { + // either the access token has expired or we are forcing a refresh + if (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) { + return this.refreshTokenFlow({ + ...tokenSet, + refreshToken: tokenSet.refreshToken + }); + } + } - if (discoveryError) { - console.error(discoveryError); - return [discoveryError, null]; + return [null, tokenSet]; + } + + // refreshTokenFlow refreshes the access token using the refresh token. + private async refreshTokenFlow( + tokenSet: TokenSet & { + refreshToken: string; + } + ): Promise<[null, TokenSet] | [SdkError, null]> { + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + + if (discoveryError) { + console.error(discoveryError); + return [discoveryError, null]; + } + + const refreshTokenRes = await oauth.refreshTokenGrantRequest( + authorizationServerMetadata, + this.clientMetadata, + await this.getClientAuth(), + tokenSet.refreshToken, + { + ...this.httpOptions(), + [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests } + ); - const refreshTokenRes = await oauth.refreshTokenGrantRequest( + let oauthRes: oauth.TokenEndpointResponse; + try { + oauthRes = await oauth.processRefreshTokenResponse( authorizationServerMetadata, this.clientMetadata, - await this.getClientAuth(), - tokenSet.refreshToken, - { - ...this.httpOptions(), - [oauth.customFetch]: this.fetch, - [oauth.allowInsecureRequests]: this.allowInsecureRequests - } + 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 + ]; + } - 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 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 - }; + const accessTokenExpiresAt = + Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in); - 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; - } + const updatedTokenSet = { + ...tokenSet, // contains the existing `iat` claim to maintain the session lifetime + accessToken: oauthRes.access_token, + idToken: oauthRes.id_token, + expiresAt: accessTokenExpiresAt + }; - return [null, updatedTokenSet]; + 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, tokenSet]; + return [null, updatedTokenSet]; } private async discoverAuthorizationServerMetadata(): Promise< diff --git a/src/server/client.ts b/src/server/client.ts index 8bcc3a21..bd66e4ea 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -315,7 +315,9 @@ 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 }>; + async getAccessToken( + refresh?: boolean + ): Promise<{ token: string; expiresAt: number }>; /** * getAccessToken returns the access token. @@ -324,19 +326,32 @@ export class Auth0Client { */ async getAccessToken( req: PagesRouterRequest | NextRequest, - res: PagesRouterResponse | NextResponse + res: PagesRouterResponse | NextResponse, + refresh?: boolean ): Promise<{ token: string; expiresAt: number }>; /** * getAccessToken returns the access token. - * - * 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( - req?: PagesRouterRequest | NextRequest, - res?: PagesRouterResponse | NextResponse + reqOrRefresh?: PagesRouterRequest | NextRequest | boolean, + res?: PagesRouterResponse | NextResponse, + refresh?: boolean ): Promise<{ token: string; expiresAt: number; scope?: string }> { + // Parameter type handling + let req: PagesRouterRequest | NextRequest | undefined; + let actualForceRefresh: boolean | undefined; + + // Check if the first parameter is a request object or a boolean + if (typeof reqOrRefresh === "boolean" || reqOrRefresh === undefined) { + // App Router case (forceRefresh as first param) + actualForceRefresh = reqOrRefresh as boolean | undefined; + } else { + // Pages Router case (req/res as first params) + req = reqOrRefresh; + actualForceRefresh = refresh; + } + const session: SessionData | null = req ? await this.getSession(req) : await this.getSession(); @@ -349,26 +364,21 @@ export class Auth0Client { } const [error, tokenSet] = await this.authClient.getTokenSet( - session.tokenSet + session.tokenSet, + actualForceRefresh // Pass forceRefresh to token refresh logic ); + if (error) { throw error; } - // update the session with the new token set, if necessary + // Update session if token changed if ( tokenSet.accessToken !== session.tokenSet.accessToken || tokenSet.expiresAt !== session.tokenSet.expiresAt || tokenSet.refreshToken !== session.tokenSet.refreshToken ) { - await this.saveToSession( - { - ...session, - tokenSet - }, - req, - res - ); + await this.saveToSession({ ...session, tokenSet }, req, res); } return { From 1dddd67e09e8fa2c0d565fc591dcc6ef66665ae2 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 16 Apr 2025 14:48:57 +0530 Subject: [PATCH 2/8] moved refresh to options object --- src/server/client.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/server/client.ts b/src/server/client.ts index bd66e4ea..1e797815 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; +import { IncomingMessage, type ServerResponse } from "node:http"; import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { NextApiRequest, NextApiResponse } from "next/types"; @@ -175,6 +175,13 @@ export type PagesRouterResponse = | ServerResponse | NextApiResponse; +export type GetAccessTokenOptions = { + /** + * Force a refresh of the access token. + */ + refresh?: boolean; +}; + export class Auth0Client { private transactionStore: TransactionStore; private sessionStore: AbstractSessionStore; @@ -316,8 +323,8 @@ 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( - refresh?: boolean - ): Promise<{ token: string; expiresAt: number }>; + options?: GetAccessTokenOptions + ): Promise<{ token: string; expiresAt: number; scope?: string }>; /** * getAccessToken returns the access token. @@ -327,29 +334,32 @@ export class Auth0Client { async getAccessToken( req: PagesRouterRequest | NextRequest, res: PagesRouterResponse | NextResponse, - refresh?: boolean - ): Promise<{ token: string; expiresAt: number }>; + options?: GetAccessTokenOptions + ): Promise<{ token: string; expiresAt: number; scope?: string }>; /** * getAccessToken returns the access token. */ async getAccessToken( - reqOrRefresh?: PagesRouterRequest | NextRequest | boolean, + reqOrOptions?: PagesRouterRequest | NextRequest | GetAccessTokenOptions, res?: PagesRouterResponse | NextResponse, - refresh?: boolean + options?: GetAccessTokenOptions ): Promise<{ token: string; expiresAt: number; scope?: string }> { // Parameter type handling let req: PagesRouterRequest | NextRequest | undefined; let actualForceRefresh: boolean | undefined; - // Check if the first parameter is a request object or a boolean - if (typeof reqOrRefresh === "boolean" || reqOrRefresh === undefined) { - // App Router case (forceRefresh as first param) - actualForceRefresh = reqOrRefresh as boolean | undefined; - } else { + // Check if the first parameter is a request object or an options object + if ( + reqOrOptions instanceof IncomingMessage || + reqOrOptions instanceof NextRequest + ) { // Pages Router case (req/res as first params) - req = reqOrRefresh; - actualForceRefresh = refresh; + req = reqOrOptions; + actualForceRefresh = options?.refresh; + } else { + // App Router case (options as first param) + actualForceRefresh = (reqOrOptions as GetAccessTokenOptions)?.refresh; } const session: SessionData | null = req From bd34bb271ee236c72b057625a5393c302011102a Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 16 Apr 2025 15:08:16 +0530 Subject: [PATCH 3/8] added docs --- EXAMPLES.md | 63 ++++++++++++++++++++++++++++++++++++++++++++ src/server/client.ts | 9 +++++++ 2 files changed, 72 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index f0629f40..4b119dd2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -520,6 +520,69 @@ 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 + // ... + + return Response.json({ token, expiresAt }); + } 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 + // ... + + res.status(200).json({ token, expiresAt }); + } 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, also update the `id_token` field of `tokenset` in the session. + ## `` ### Passing an initial user from the server diff --git a/src/server/client.ts b/src/server/client.ts index 1e797815..8cf861d2 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -322,6 +322,10 @@ 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. */ + /** + * @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 }>; @@ -330,6 +334,11 @@ export class Auth0Client { * 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, From 7b5e48195a6f7406f8ec0547aad804298e8489a8 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 16 Apr 2025 16:38:24 +0530 Subject: [PATCH 4/8] linting fixes --- src/server/auth-client.test.ts | 138 ++++++++++-------- src/server/auth-client.ts | 24 ++- src/server/chunked-cookies.test.ts | 2 +- src/server/cookies.ts | 8 +- .../session/stateful-session-store.test.ts | 3 +- .../session/stateless-session-store.test.ts | 18 ++- src/server/session/stateless-session-store.ts | 29 ++-- 7 files changed, 120 insertions(+), 102 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index daf49634..459088b4 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -394,7 +394,7 @@ ca/T0LLtgmbMmxSv/MmzIg== // When a route doesn't match, the handler returns a NextResponse.next() with status 200 expect(response.status).toBe(200); }); - + it("should use the default value (true) for enableAccessTokenEndpoint when not explicitly provided", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ @@ -4374,34 +4374,42 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ signInReturnToPath: defaultReturnTo }); - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { expect(state.returnTo).toBe(defaultReturnTo); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin(); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should sanitize and use the provided returnTo parameter", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-return-path"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // The full URL is saved, not just the path expect(state.returnTo).toBe("https://example.com/custom-return-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should reject unsafe returnTo URLs", async () => { @@ -4409,18 +4417,22 @@ ca/T0LLtgmbMmxSv/MmzIg== signInReturnToPath: "/safe-path" }); const unsafeReturnTo = "https://malicious-site.com"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // Should use the default safe path instead of the malicious one expect(state.returnTo).toBe("/safe-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should pass authorization parameters to the authorization URL", async () => { @@ -4429,10 +4441,10 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: "https://api.example.com", scope: "openid profile email custom_scope" }; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Verify the audience is set correctly expect(params.get("audience")).toBe(authorizationParameters.audience); // Verify the scope is set correctly @@ -4441,8 +4453,8 @@ ca/T0LLtgmbMmxSv/MmzIg== }); await authClient.startInteractiveLogin({ authorizationParameters }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); it("should handle pushed authorization requests (PAR) correctly", async () => { @@ -4452,11 +4464,11 @@ ca/T0LLtgmbMmxSv/MmzIg== parRequestCalled = true; } }); - + const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); - + const authClient = new AuthClient({ transactionStore, sessionStore, @@ -4471,33 +4483,41 @@ ca/T0LLtgmbMmxSv/MmzIg== }, fetch: mockFetch }); - + await authClient.startInteractiveLogin(); - + // Verify that PAR was used expect(parRequestCalled).toBe(true); }); - + it("should save the transaction state with correct values", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-path"; - + // Instead of mocking the oauth functions, we'll just check the structure of the transaction state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, transactionState) => { - expect(transactionState).toEqual(expect.objectContaining({ - nonce: expect.any(String), - codeVerifier: expect.any(String), - responseType: "code", - state: expect.any(String), - returnTo: "https://example.com/custom-path" - })); - return originalSave.call(authClient['transactionStore'], cookies, transactionState); - }); + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn( + async (cookies, transactionState) => { + expect(transactionState).toEqual( + expect.objectContaining({ + nonce: expect.any(String), + codeVerifier: expect.any(String), + responseType: "code", + state: expect.any(String), + returnTo: "https://example.com/custom-path" + }) + ); + return originalSave.call( + authClient["transactionStore"], + cookies, + transactionState + ); + } + ); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should merge configuration authorizationParameters with method arguments", async () => { @@ -4509,13 +4529,13 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: configAudience } }); - + const methodScope = "openid profile email custom_scope"; const methodAudience = "https://custom-api.example.com"; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Method's authorization parameters should override config expect(params.get("audience")).toBe(methodAudience); expect(params.get("scope")).toBe(methodScope); @@ -4528,14 +4548,14 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: methodAudience } }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); // Add tests for handleLogin method it("should create correct options in handleLogin with returnTo parameter", async () => { const authClient = await createAuthClient(); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4546,11 +4566,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); @@ -4558,7 +4580,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ pushedAuthorizationRequests: true }); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4569,11 +4591,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); }); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5cc92ace..1206952d 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -6,21 +6,21 @@ import packageJson from "../../package.json"; import { AccessTokenError, AccessTokenErrorCode, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, AuthorizationCodeGrantError, AuthorizationError, BackchannelLogoutError, DiscoveryError, - AccessTokenForConnectionError, - AccessTokenForConnectionErrorCode, InvalidStateError, MissingStateError, OAuth2Error, SdkError } from "../errors"; import { + AccessTokenForConnectionOptions, AuthorizationParameters, ConnectionTokenSet, - AccessTokenForConnectionOptions, LogoutToken, SessionData, StartInteractiveLoginOptions, @@ -65,7 +65,6 @@ const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"].join( " " ); - /** * A constant representing the grant type for federated connection access token exchange. * @@ -1031,19 +1030,20 @@ export class AuthClient { tokenSet: TokenSet, connectionTokenSet: ConnectionTokenSet | undefined, options: AccessTokenForConnectionOptions - ): Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]> { + ): Promise< + [AccessTokenForConnectionError, null] | [null, ConnectionTokenSet] + > { // If we do not have a refresh token // and we do not have a connection token set in the cache or the one we have is expired, // there is noting to retrieve and we return an error. if ( !tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { return [ new AccessTokenForConnectionError( AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, - "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate.", + "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate." ), null ]; @@ -1054,8 +1054,7 @@ export class AuthClient { // we need to exchange the refresh token for a connection access token. if ( tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { const params = new URLSearchParams(); @@ -1126,10 +1125,7 @@ export class AuthClient { ]; } - return [null, connectionTokenSet] as [ - null, - ConnectionTokenSet - ]; + return [null, connectionTokenSet] as [null, ConnectionTokenSet]; } } diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index aba497bf..e17aec65 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -231,7 +231,7 @@ describe("Chunked Cookie Utils", () => { // It is called 3 times. // 2 times for the chunks // 1 time for the non chunked cookie - expect(reqCookies.delete).toHaveBeenCalledTimes(3); + expect(reqCookies.delete).toHaveBeenCalledTimes(3); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__3`); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__4`); expect(reqCookies.delete).toHaveBeenCalledWith(name); diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 635942d9..3418540b 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -187,7 +187,7 @@ export function setChunkedCookie( reqCookies.set(name, value); // When we are writing a non-chunked cookie, we should remove the chunked cookies - getAllChunkedCookies(reqCookies, name).forEach(cookieChunk => { + getAllChunkedCookies(reqCookies, name).forEach((cookieChunk) => { resCookies.delete(cookieChunk.name); reqCookies.delete(cookieChunk.name); }); @@ -223,9 +223,9 @@ export function setChunkedCookie( } } - // When we have written chunked cookies, we should remove the non-chunked cookie - resCookies.delete(name); - reqCookies.delete(name); + // When we have written chunked cookies, we should remove the non-chunked cookie + resCookies.delete(name); + reqCookies.delete(name); } /** diff --git a/src/server/session/stateful-session-store.test.ts b/src/server/session/stateful-session-store.test.ts index fab837bc..6e690756 100644 --- a/src/server/session/stateful-session-store.test.ts +++ b/src/server/session/stateful-session-store.test.ts @@ -690,7 +690,6 @@ describe("Stateful Session Store", async () => { }); }); - it("should remove the legacy cookie if it exists", async () => { const currentTime = Date.now(); const createdAt = Math.floor(currentTime / 1000); @@ -718,7 +717,7 @@ describe("Stateful Session Store", async () => { const sessionStore = new StatefulSessionStore({ secret, - store, + store }); vi.spyOn(requestCookies, "has").mockReturnValue(true); diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 24a427ed..23f853d7 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -334,7 +334,7 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); @@ -365,19 +365,23 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); vi.spyOn(requestCookies, "getAll").mockReturnValue([ - { name: `${LEGACY_COOKIE_NAME}__0`, value: '' }, - { name: `${LEGACY_COOKIE_NAME}__1`, value: '' } + { name: `${LEGACY_COOKIE_NAME}__0`, value: "" }, + { name: `${LEGACY_COOKIE_NAME}__1`, value: "" } ]); await sessionStore.set(requestCookies, responseCookies, session); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__0`); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__1`); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__0` + ); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__1` + ); }); }); @@ -516,7 +520,7 @@ describe("Stateless Session Store", async () => { const sessionStore = new StatelessSessionStore({ secret, cookieOptions: { - path: '/custom-path' + path: "/custom-path" } }); await sessionStore.set(requestCookies, responseCookies, session); diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index bdf628aa..2e804830 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,7 +1,6 @@ -import { CookieOptions, ConnectionTokenSet, SessionData } from "../../types"; - import type { JWTPayload } from "jose"; +import { ConnectionTokenSet, CookieOptions, SessionData } from "../../types"; import * as cookies from "../cookies"; import { AbstractSessionStore, @@ -55,17 +54,14 @@ export class StatelessSessionStore extends AbstractSessionStore { SessionData | LegacySessionPayload >(cookieValue, this.secret); - const normalizedStatelessSession = normalizeStatelessSession(originalSession); + const normalizedStatelessSession = + normalizeStatelessSession(originalSession); // As connection access tokens are stored in seperate cookies, // we need to get all cookies and only use those that are prefixed with `this.connectionTokenSetsCookieName` const connectionTokenSets = await Promise.all( - this.getConnectionTokenSetsCookies(reqCookies).map( - (cookie) => - cookies.decrypt( - cookie.value, - this.secret - ) + this.getConnectionTokenSetsCookies(reqCookies).map((cookie) => + cookies.decrypt(cookie.value, this.secret) ) ); @@ -73,7 +69,11 @@ export class StatelessSessionStore extends AbstractSessionStore { ...normalizedStatelessSession, // Ensure that when there are no connection token sets, we omit the property. ...(connectionTokenSets.length - ? { connectionTokenSets: connectionTokenSets.map(tokenSet => tokenSet.payload) } + ? { + connectionTokenSets: connectionTokenSets.map( + (tokenSet) => tokenSet.payload + ) + } : {}) }; } @@ -117,7 +117,7 @@ export class StatelessSessionStore extends AbstractSessionStore { ) ); } - + // Any existing v3 cookie can be deleted as soon as we have set a v4 cookie. // In stateless sessions, we do have to ensure we delete all chunks. cookies.deleteChunkedCookie(LEGACY_COOKIE_NAME, reqCookies, resCookies); @@ -127,11 +127,7 @@ export class StatelessSessionStore extends AbstractSessionStore { reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies ) { - cookies.deleteChunkedCookie( - this.sessionCookieName, - reqCookies, - resCookies - ); + cookies.deleteChunkedCookie(this.sessionCookieName, reqCookies, resCookies); this.getConnectionTokenSetsCookies(reqCookies).forEach((cookie) => resCookies.delete(cookie.name) @@ -177,7 +173,6 @@ export class StatelessSessionStore extends AbstractSessionStore { "You can use a stateful session implementation to store the session data in a data store." ); } - } } From 672d317519042181bf45cf4a23ce169294566c0c Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Apr 2025 01:02:20 +0530 Subject: [PATCH 5/8] update getAccessToken overload logic --- src/server/client.ts | 79 +++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/src/server/client.ts b/src/server/client.ts index 8cf861d2..393096c6 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1,4 +1,4 @@ -import { IncomingMessage, type ServerResponse } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { NextApiRequest, NextApiResponse } from "next/types"; @@ -175,13 +175,6 @@ export type PagesRouterResponse = | ServerResponse | NextApiResponse; -export type GetAccessTokenOptions = { - /** - * Force a refresh of the access token. - */ - refresh?: boolean; -}; - export class Auth0Client { private transactionStore: TransactionStore; private sessionStore: AbstractSessionStore; @@ -348,27 +341,51 @@ export class Auth0Client { /** * getAccessToken returns the access token. + * + * 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( - reqOrOptions?: PagesRouterRequest | NextRequest | GetAccessTokenOptions, - res?: PagesRouterResponse | NextResponse, - options?: GetAccessTokenOptions + arg1?: PagesRouterRequest | NextRequest | GetAccessTokenOptions, + arg2?: PagesRouterResponse | NextResponse, + arg3?: GetAccessTokenOptions ): Promise<{ token: string; expiresAt: number; scope?: string }> { - // Parameter type handling - let req: PagesRouterRequest | NextRequest | undefined; - let actualForceRefresh: boolean | undefined; + const defaultOptions: Required = { + refresh: false + }; - // Check if the first parameter is a request object or an options object + let req: PagesRouterRequest | NextRequest | undefined = undefined; + let res: PagesRouterResponse | NextResponse | undefined = undefined; + let options: GetAccessTokenOptions = {}; + + // Determine which overload was called based on arguments if ( - reqOrOptions instanceof IncomingMessage || - reqOrOptions instanceof NextRequest + arg1 && + (arg1 instanceof Request || typeof (arg1 as any).headers === "object") ) { - // Pages Router case (req/res as first params) - req = reqOrOptions; - actualForceRefresh = options?.refresh; + // 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 { - // App Router case (options as first param) - actualForceRefresh = (reqOrOptions as GetAccessTokenOptions)?.refresh; + // 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 @@ -384,20 +401,26 @@ export class Auth0Client { const [error, tokenSet] = await this.authClient.getTokenSet( session.tokenSet, - actualForceRefresh // Pass forceRefresh to token refresh logic + options.refresh ); - if (error) { throw error; } - // Update session if token changed + // update the session with the new token set, if necessary if ( tokenSet.accessToken !== session.tokenSet.accessToken || tokenSet.expiresAt !== session.tokenSet.expiresAt || tokenSet.refreshToken !== session.tokenSet.refreshToken ) { - await this.saveToSession({ ...session, tokenSet }, req, res); + await this.saveToSession( + { + ...session, + tokenSet + }, + req, + res + ); } return { @@ -752,3 +775,7 @@ export class Auth0Client { }; } } + +export type GetAccessTokenOptions = { + refresh?: boolean; +}; From eeb53f6960e60b0cc16e1979edc335c82d839b58 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Apr 2025 12:38:14 +0530 Subject: [PATCH 6/8] removed redundant refreshtokenflow method --- src/server/auth-client.ts | 120 +++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 66 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 1206952d..0cd9eec7 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -685,79 +685,67 @@ export class AuthClient { if (tokenSet.refreshToken) { // either the access token has expired or we are forcing a refresh if (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) { - return this.refreshTokenFlow({ - ...tokenSet, - refreshToken: tokenSet.refreshToken - }); - } - } - - return [null, tokenSet]; - } + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); - // refreshTokenFlow refreshes the access token using the refresh token. - private async refreshTokenFlow( - tokenSet: TokenSet & { - refreshToken: string; - } - ): Promise<[null, TokenSet] | [SdkError, null]> { - const [discoveryError, authorizationServerMetadata] = - await this.discoverAuthorizationServerMetadata(); - - if (discoveryError) { - console.error(discoveryError); - return [discoveryError, null]; - } - - 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( - 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 refreshTokenRes = await oauth.refreshTokenGrantRequest( + authorizationServerMetadata, + this.clientMetadata, + await this.getClientAuth(), + tokenSet.refreshToken, + { + ...this.httpOptions(), + [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests + } + ); - 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]; } private async discoverAuthorizationServerMetadata(): Promise< From 2b7110d4d3dc7955a6932d2d188992143b850047 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Apr 2025 13:28:20 +0530 Subject: [PATCH 7/8] update tests --- EXAMPLES.md | 6 +-- src/server/client.test.ts | 100 +++++++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 4b119dd2..a4756335 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -541,8 +541,6 @@ export async function GET() { // Use the refreshed token // ... - - return Response.json({ token, expiresAt }); } catch (error) { console.error('Error getting access token:', error); return Response.json({ error: 'Failed to get access token' }, { status: 500 }); @@ -571,8 +569,6 @@ export default withApiAuthRequired(async function handler( // Use the refreshed token // ... - - res.status(200).json({ token, expiresAt }); } catch (error: any) { console.error('Error getting access token:', error); res.status(error.status || 500).json({ error: error.message }); @@ -581,7 +577,7 @@ export default withApiAuthRequired(async function handler( ``` 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, also update the `id_token` field of `tokenset` in the session. +This will in turn, update the `access_token`, `id_token` and `expires_at` fields of `tokenset` in the session. ## `` diff --git a/src/server/client.test.ts b/src/server/client.test.ts index dc7a44ad..075cadca 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1,22 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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(); @@ -112,3 +113,80 @@ describe("Auth0Client", () => { }); }); }); + +describe("Auth0Client getAccessToken", () => { + const setupClient = () => { + // Set required 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_string_at_least_32_bytes"; + return new Auth0Client(); + }; + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + // Restore spyOn mocks + vi.restoreAllMocks(); + }); + + it("should call getTokenSet with forceRefresh=true when refresh option is true", async () => { + const client = setupClient(); + + // Define mock session data first + const mockSession = { + user: { sub: "user123" }, + tokenSet: { + accessToken: "initial_at", + idToken: "initial_idt", + refreshToken: "initial_rt", + scope: "openid profile", + expiresAt: Math.floor(Date.now() / 1000) + 3600 // Not expired + }, + internal: { sid: "sid123", createdAt: Date.now() / 1000 } + }; + const refreshedTokenSet = { + accessToken: "refreshed_at", + idToken: "refreshed_idt", + refreshToken: "rotated_rt", + scope: "openid profile", + expiresAt: Math.floor(Date.now() / 1000) + 7200 + }; + + // Mock getSession directly on the Auth0Client prototype + vi.spyOn(Auth0Client.prototype, "getSession").mockResolvedValue( + mockSession + ); + + // Mock getTokenSet directly on the AuthClient prototype + const getTokenSetSpy = vi + .spyOn(AuthClient.prototype, "getTokenSet") + .mockResolvedValue([null, refreshedTokenSet]); + + const result = await client.getAccessToken({ refresh: true }); + + // Verify session was checked (by checking our mock of getSession) + expect(Auth0Client.prototype.getSession).toHaveBeenCalledTimes(1); + + // Verify the spy on getTokenSet was called + expect(getTokenSetSpy).toHaveBeenCalledTimes(1); + expect(getTokenSetSpy).toHaveBeenCalledWith( + mockSession.tokenSet, // The initial token set from session + true // forceRefresh flag + ); + + // Verify the refreshed token is returned + expect(result).toEqual({ + token: refreshedTokenSet.accessToken, + scope: refreshedTokenSet.scope, + expiresAt: refreshedTokenSet.expiresAt + }); + + // Restore the spy after the test + getTokenSetSpy.mockRestore(); + }); + + // Add other tests for getAccessToken: no session, no refresh token, expired token, etc. +}); From 28f7a053112e3fb8cad86d005a64736fa74c85b8 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 25 Apr 2025 11:29:28 +0530 Subject: [PATCH 8/8] updated tests, added integration test for force refresh getAccessToken --- src/server/client.test.ts | 159 ++++--- src/server/force-refresh.integration.test.ts | 472 +++++++++++++++++++ 2 files changed, 564 insertions(+), 67 deletions(-) create mode 100644 src/server/force-refresh.integration.test.ts diff --git a/src/server/client.test.ts b/src/server/client.test.ts index 075cadca..c24b82d6 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1,5 +1,8 @@ +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"; @@ -34,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", () => { @@ -112,81 +116,102 @@ describe("Auth0Client", () => { } }); }); -}); - -describe("Auth0Client getAccessToken", () => { - const setupClient = () => { - // Set required 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_string_at_least_32_bytes"; - return new Auth0Client(); - }; - - beforeEach(() => { - // Reset mocks before each test - vi.clearAllMocks(); - // Restore spyOn mocks - vi.restoreAllMocks(); - }); - - it("should call getTokenSet with forceRefresh=true when refresh option is true", async () => { - const client = setupClient(); - // Define mock session data first - const mockSession = { + describe("getAccessToken", () => { + const mockSession: SessionData = { user: { sub: "user123" }, tokenSet: { - accessToken: "initial_at", - idToken: "initial_idt", - refreshToken: "initial_rt", - scope: "openid profile", - expiresAt: Math.floor(Date.now() / 1000) + 3600 // Not expired + accessToken: "old_access_token", + idToken: "old_id_token", + refreshToken: "old_refresh_token", + expiresAt: Date.now() / 1000 - 3600 // Expired }, - internal: { sid: "sid123", createdAt: Date.now() / 1000 } + internal: { + sid: "mock_sid", + createdAt: Date.now() / 1000 - 7200 // Some time in the past + }, + createdAt: Date.now() / 1000 }; - const refreshedTokenSet = { - accessToken: "refreshed_at", - idToken: "refreshed_idt", - refreshToken: "rotated_rt", - scope: "openid profile", - expiresAt: Math.floor(Date.now() / 1000) + 7200 + + // 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" }; - // Mock getSession directly on the Auth0Client prototype - vi.spyOn(Auth0Client.prototype, "getSession").mockResolvedValue( - mockSession - ); - - // Mock getTokenSet directly on the AuthClient prototype - const getTokenSetSpy = vi - .spyOn(AuthClient.prototype, "getTokenSet") - .mockResolvedValue([null, refreshedTokenSet]); - - const result = await client.getAccessToken({ refresh: true }); - - // Verify session was checked (by checking our mock of getSession) - expect(Auth0Client.prototype.getSession).toHaveBeenCalledTimes(1); - - // Verify the spy on getTokenSet was called - expect(getTokenSetSpy).toHaveBeenCalledTimes(1); - expect(getTokenSetSpy).toHaveBeenCalledWith( - mockSession.tokenSet, // The initial token set from session - true // forceRefresh flag - ); - - // Verify the refreshed token is returned - expect(result).toEqual({ - token: refreshedTokenSet.accessToken, - scope: refreshedTokenSet.scope, - expiresAt: refreshedTokenSet.expiresAt + 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 }); - // Restore the spy after the test - getTokenSetSpy.mockRestore(); - }); + 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(); - // Add other tests for getAccessToken: no session, no refresh token, expired token, etc. + 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/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(); + }); +});