From 1bf3069f7fc000210ba6e9632c759e88913c163e Mon Sep 17 00:00:00 2001 From: guabu <135956181+guabu@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:45:22 +0200 Subject: [PATCH] feat: add configurable access token leeway parameter --- src/server/auth-client.test.ts | 63 ++++++++++++++++++++++++++++++++++ src/server/auth-client.ts | 12 ++++++- src/server/client.ts | 12 ++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index d85e735c..783681f6 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -4865,6 +4865,69 @@ ca/T0LLtgmbMmxSv/MmzIg== }); }); + it("should refresh the access token if it expired — with leeway", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: "new-at", + expires_in: 3600 // expires in 1 hour + } as oauth.TokenEndpointResponse + }), + + accessTokenExpiryLeeway: 30 * 60 // 30 minutes + }); + + // expires in 31 minutes, so it will not be refreshed because the leeway is 30 minutes + let tokenSet = { + accessToken: "old-at", + refreshToken: "old-rt", + expiresAt: Math.floor(Date.now() / 1000) + 31 * 60 // expires in 31 minutes + }; + + let [error, updatedTokenSet] = await authClient.getTokenSet(tokenSet); + expect(error).toBeNull(); + expect(updatedTokenSet?.tokenSet).toEqual({ + accessToken: "old-at", + refreshToken: "old-rt", + expiresAt: expect.any(Number) + }); + + // expires in 30 minutes, so it will be refreshed because the leeway is 30 minutes + tokenSet = { + accessToken: "old-at", + refreshToken: "old-rt", + expiresAt: Math.floor(Date.now() / 1000) + 30 * 60 // expires in 30 minutes + }; + + [error, updatedTokenSet] = await authClient.getTokenSet(tokenSet); + expect(error).toBeNull(); + expect(updatedTokenSet?.tokenSet).toEqual({ + accessToken: "new-at", + refreshToken: "old-rt", + expiresAt: expect.any(Number) + }); + }); + it("should return an error if an error occurred during the refresh token exchange", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 9d48d30d..3c5371d9 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -139,8 +139,11 @@ export interface AuthClientOptions { allowInsecureRequests?: boolean; httpTimeout?: number; enableTelemetry?: boolean; + enableAccessTokenEndpoint?: boolean; noContentProfileResponseWhenUnauthenticated?: boolean; + + accessTokenExpiryLeeway?: number; } function createRouteUrl(path: string, baseUrl: string) { @@ -181,6 +184,8 @@ export class AuthClient { private readonly enableAccessTokenEndpoint: boolean; private readonly noContentProfileResponseWhenUnauthenticated: boolean; + private readonly accessTokenExpiryLeeway: number; + constructor(options: AuthClientOptions) { // dependencies this.fetch = options.fetch || fetch; @@ -273,6 +278,8 @@ export class AuthClient { this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true; this.noContentProfileResponseWhenUnauthenticated = options.noContentProfileResponseWhenUnauthenticated ?? false; + + this.accessTokenExpiryLeeway = options.accessTokenExpiryLeeway ?? 0; } async handler(req: NextRequest): Promise { @@ -778,7 +785,10 @@ 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) { + if ( + forceRefresh || + tokenSet.expiresAt <= Date.now() / 1000 + this.accessTokenExpiryLeeway + ) { const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata(); diff --git a/src/server/client.ts b/src/server/client.ts index 93b6a600..0dff3e15 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -196,6 +196,13 @@ export interface Auth0ClientOptions { * Defaults to `false`. */ noContentProfileResponseWhenUnauthenticated?: boolean; + + /** + * The amount of time in seconds to refresh the access token before it expires. + * + * Defaults to `0` seconds. + */ + accessTokenExpiryLeeway?: number; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -311,9 +318,12 @@ export class Auth0Client { allowInsecureRequests: options.allowInsecureRequests, httpTimeout: options.httpTimeout, enableTelemetry: options.enableTelemetry, + enableAccessTokenEndpoint: options.enableAccessTokenEndpoint, noContentProfileResponseWhenUnauthenticated: - options.noContentProfileResponseWhenUnauthenticated + options.noContentProfileResponseWhenUnauthenticated, + + accessTokenExpiryLeeway: options.accessTokenExpiryLeeway ?? 0 }); }