From aa22f3672bc44d4f323e68460383b5b10bb6c05a Mon Sep 17 00:00:00 2001 From: Thijs Date: Thu, 16 Jul 2026 19:21:25 +0200 Subject: [PATCH 1/2] fix: proactively refresh near-expiry access tokens in the proxy middleware The client token store refreshes access tokens 60 seconds before expiry (30 seconds for tokens with a lifetime of 5 minutes or less), but the proxy/middleware refreshed only reactively, once the token was already past exp. A token with a few seconds of life left could pass the middleware, be handed to withAuth() and then to a server-side consumer, and expire in transit (render latency, network round trips, clock skew), surfacing as an intermittent 500 during the RSC render. The middleware now refreshes a still-valid token once it is within the same buffer the client uses, configurable via a new refreshBufferSeconds option (0 disables). If a proactive refresh fails (for example a concurrent request already rotated the single-use refresh token), the request is served with the current, still-valid token instead of deleting the session cookie and redirecting to sign-in. Fixes #452 Co-Authored-By: Claude Fable 5 --- README.md | 29 ++++++-- src/interfaces.ts | 16 ++++ src/middleware.spec.ts | 1 + src/middleware.ts | 11 ++- src/session.spec.ts | 163 ++++++++++++++++++++++++++++++++++++++++- src/session.ts | 53 +++++++++++++- 6 files changed, 262 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4e9c358..5e7d625 100644 --- a/README.md +++ b/README.md @@ -200,13 +200,14 @@ export const config = { matcher: ['/', '/admin'] }; The proxy/middleware can be configured with several options. -| Option | Default | Description | -| ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- | -| `redirectUri` | `undefined` | Used in cases where you need your redirect URI to be set dynamically (e.g. Vercel preview deployments) | -| `middlewareAuth` | `undefined` | Used to configure proxy/middleware auth options. See [middleware auth](#middleware-auth) for more details. | -| `debug` | `false` | Enables debug logs. | -| `signUpPaths` | `[]` | Used to specify paths that should use the 'sign-up' screen hint when redirecting to AuthKit. | -| `eagerAuth` | `false` | Enables synchronous access token availability for third-party services. See [eager auth](#eager-auth) for more details. | +| Option | Default | Description | +| ---------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `redirectUri` | `undefined` | Used in cases where you need your redirect URI to be set dynamically (e.g. Vercel preview deployments) | +| `middlewareAuth` | `undefined` | Used to configure proxy/middleware auth options. See [middleware auth](#middleware-auth) for more details. | +| `debug` | `false` | Enables debug logs. | +| `signUpPaths` | `[]` | Used to specify paths that should use the 'sign-up' screen hint when redirecting to AuthKit. | +| `eagerAuth` | `false` | Enables synchronous access token availability for third-party services. See [eager auth](#eager-auth) for more details. | +| `refreshBufferSeconds` | `60` (`30` for tokens with a lifetime of 5 minutes or less) | Seconds before access token expiry at which the session is proactively refreshed. See [proactive session refresh](#proactive-session-refresh) for more details. | #### Custom redirect URI @@ -834,6 +835,20 @@ Eager auth makes tokens briefly accessible via JavaScript (30-second window) to - Most API calls where a brief loading state is acceptable - When you don't need immediate token access on page load +### Proactive session refresh + +The proxy/middleware proactively refreshes the session when the access token is within a buffer of its expiry, mirroring the buffer the client token store already uses: 60 seconds, or 30 seconds for tokens with a total lifetime of 5 minutes or less. This ensures a token handed to `withAuth()` and then to a server-side consumer (a Server Component fetch, an API call to a service that validates the token) cannot expire mid-request due to render latency, network round trips, or clock skew. + +Use the `refreshBufferSeconds` option to tune the buffer, or set it to `0` to disable proactive refresh and only refresh once the token has expired: + +```ts +export default authkitProxy({ + refreshBufferSeconds: 120, +}); +``` + +If a proactive refresh fails (for example, a concurrent request already rotated the single-use refresh token), the request is served with the current, still-valid access token; the session is never destroyed while the access token remains valid. + ### Signing out Use the `signOut` method to sign out the current logged in user and redirect to your app's default Logout URI. The Logout URI is set in your WorkOS dashboard settings under "Redirect". diff --git a/src/interfaces.ts b/src/interfaces.ts index b8d40e3..d8c7f7b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -117,6 +117,16 @@ export interface AuthkitMiddlewareOptions { redirectUri?: string; signUpPaths?: string[]; eagerAuth?: boolean; + /** + * Number of seconds before access token expiry at which the proxy/middleware + * proactively refreshes the session, so server-side consumers of the access + * token never receive a token that expires mid-request. + * + * Defaults to the same buffer the client token store uses: 60 seconds, or 30 + * seconds for tokens with a total lifetime of 5 minutes or less. Set to `0` + * to disable proactive refresh and only refresh once the token has expired. + */ + refreshBufferSeconds?: number; } export interface AuthkitOptions { @@ -124,6 +134,12 @@ export interface AuthkitOptions { debug?: boolean; redirectUri?: string; screenHint?: 'sign-up' | 'sign-in'; + /** + * Number of seconds before access token expiry at which the session is + * proactively refreshed. Defaults to 60 seconds (30 seconds for tokens with + * a total lifetime of 5 minutes or less). Set to `0` to disable. + */ + refreshBufferSeconds?: number; onSessionRefreshSuccess?: (data: { accessToken: string; user: User; diff --git a/src/middleware.spec.ts b/src/middleware.spec.ts index d0e85ab..5a4798f 100644 --- a/src/middleware.spec.ts +++ b/src/middleware.spec.ts @@ -12,6 +12,7 @@ describe('middleware', () => { debug: true, middlewareAuth: { enabled: true, unauthenticatedPaths: ['/public'] }, signUpPaths: ['/sign-up'], + refreshBufferSeconds: 120, }); expect(typeof middleware).toBe('function'); }); diff --git a/src/middleware.ts b/src/middleware.ts index 6a2b4bc..dcd12a0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,9 +9,18 @@ export function authkitProxy({ redirectUri = WORKOS_REDIRECT_URI, signUpPaths = [], eagerAuth = false, + refreshBufferSeconds, }: AuthkitMiddlewareOptions = {}): NextMiddleware { return function (request) { - return updateSessionMiddleware(request, debug, middlewareAuth, redirectUri, signUpPaths, eagerAuth); + return updateSessionMiddleware( + request, + debug, + middlewareAuth, + redirectUri, + signUpPaths, + eagerAuth, + refreshBufferSeconds, + ); }; } diff --git a/src/session.spec.ts b/src/session.spec.ts index 77d65d2..6baa764 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -14,7 +14,7 @@ import { import { getWorkOS } from './workos.js'; import * as envVariables from './env-variables.js'; -import { jwtVerify } from 'jose'; +import { SignJWT, jwtVerify } from 'jose'; // Helper to override env variable exports without triggering no-import-assign on the import binding function setEnvVar(mod: Record, key: string, value: unknown) { @@ -914,6 +914,167 @@ describe('session.ts', () => { request, }); }); + + describe('proactive refresh', () => { + // generateTestToken always signs with a 2h expiry, so build tokens with a + // controlled exp/iat here to place them inside or outside the refresh buffer. + async function generateTokenWithExpiry(secondsUntilExpiry: number, lifetimeSeconds = 3600) { + const now = Math.floor(Date.now() / 1000); + const secret = new TextEncoder().encode(process.env.WORKOS_COOKIE_PASSWORD as string); + + return await new SignJWT({ sid: 'session_123', org_id: 'org_123' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now - (lifetimeSeconds - secondsUntilExpiry)) + .setExpirationTime(now + secondsUntilExpiry) + .sign(secret); + } + + async function requestWithSessionToken(accessToken: string) { + const request = new NextRequest(new URL('http://example.com/protected')); + request.cookies.set( + 'wos-session', + await sealData({ ...mockSession, accessToken }, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + return request; + } + + it('should refresh a valid session that is within the refresh buffer', async () => { + const newAccessToken = await generateTestToken(); + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: newAccessToken, + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + const request = await requestWithSessionToken(await generateTokenWithExpiry(30)); + const response = await updateSession(request, { debug: true }); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(response.session.user).toBeDefined(); + expect(response.session.accessToken).toBe(newAccessToken); + expect(response.headers.getSetCookie().some((c) => c.startsWith('wos-session=') && c.length > 20)).toBe(true); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Session expiring soon. Proactively refreshing access token that ends in'), + ); + }); + + it('should not refresh a valid session outside the refresh buffer', async () => { + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken'); + + const accessToken = await generateTokenWithExpiry(300); + const request = await requestWithSessionToken(accessToken); + const response = await updateSession(request); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(response.session.accessToken).toBe(accessToken); + }); + + it('should use a 30 second buffer for tokens with a lifetime of 5 minutes or less', async () => { + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: await generateTestToken(), + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + // 45 seconds left on a 5 minute token: outside the 30 second buffer + await updateSession(await requestWithSessionToken(await generateTokenWithExpiry(45, 300))); + expect(refreshSpy).not.toHaveBeenCalled(); + + // 20 seconds left on a 5 minute token: inside the 30 second buffer + await updateSession(await requestWithSessionToken(await generateTokenWithExpiry(20, 300))); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should respect a custom refreshBufferSeconds', async () => { + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: await generateTestToken(), + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + // 90 seconds left is outside the default 60 second buffer, but inside a 120 second one + const request = await requestWithSessionToken(await generateTokenWithExpiry(90)); + await updateSession(request, { refreshBufferSeconds: 120 }); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should disable proactive refresh when refreshBufferSeconds is 0', async () => { + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken'); + + const accessToken = await generateTokenWithExpiry(5); + const request = await requestWithSessionToken(accessToken); + const response = await updateSession(request, { refreshBufferSeconds: 0 }); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(response.session.accessToken).toBe(accessToken); + }); + + it('should serve the request with the still-valid token when a proactive refresh fails', async () => { + const mockErrorCallback = vi.fn(); + vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(new Error('Refresh failed')); + + const accessToken = await generateTokenWithExpiry(30); + const request = await requestWithSessionToken(accessToken); + const response = await updateSession(request, { debug: true, onSessionRefreshError: mockErrorCallback }); + + expect(response.session.user).toBeDefined(); + expect(response.session.accessToken).toBe(accessToken); + expect(response.authorizationUrl).toBeUndefined(); + // The session cookie must not be deleted while the access token is still valid + expect(response.headers.getSetCookie().some((c) => c.startsWith('wos-session=;'))).toBe(false); + expect(mockErrorCallback).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + 'Proactive refresh failed. Serving request with the still-valid access token.', + expect.any(Error), + ); + }); + + it('should call onSessionRefreshSuccess when a proactive refresh succeeds', async () => { + const mockSuccessCallback = vi.fn(); + const newAccessToken = await generateTestToken(); + vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: newAccessToken, + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + const request = await requestWithSessionToken(await generateTokenWithExpiry(30)); + await updateSession(request, { onSessionRefreshSuccess: mockSuccessCallback }); + + expect(mockSuccessCallback).toHaveBeenCalledTimes(1); + expect(mockSuccessCallback).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: newAccessToken, user: mockSession.user }), + ); + }); + + it('should thread refreshBufferSeconds through updateSessionMiddleware', async () => { + const refreshSpy = vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: await generateTestToken(), + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + // 90 seconds left is outside the default 60 second buffer, but inside a 120 second one + const request = await requestWithSessionToken(await generateTokenWithExpiry(90)); + const result = await updateSessionMiddleware( + request, + false, + { + enabled: false, + unauthenticatedPaths: [], + }, + process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string, + [], + false, + 120, + ); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(result.status).toBe(200); + }); + }); }); describe('refreshSession', () => { diff --git a/src/session.ts b/src/session.ts index 92b1e40..281f5f8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -95,6 +95,7 @@ async function updateSessionMiddleware( redirectUri: string, signUpPaths: string[], eagerAuth = false, + refreshBufferSeconds?: number, ) { if (!redirectUri && !WORKOS_REDIRECT_URI) { throw new Error('You must provide a redirect URI in the AuthKit middleware or in the environment variables.'); @@ -140,6 +141,7 @@ async function updateSessionMiddleware( redirectUri, screenHint: getScreenHint(signUpPaths, request.nextUrl.pathname), eagerAuth, + refreshBufferSeconds, }); // Record the sign up paths so we can use them later @@ -210,12 +212,13 @@ async function updateSession( } const hasValidSession = await verifyAccessToken(session.accessToken); + const isExpiring = hasValidSession && isTokenExpiring(session.accessToken, options.refreshBufferSeconds); const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; applyCacheSecurityHeaders(newRequestHeaders, request, session); - if (hasValidSession) { + const respondWithCurrentToken = (): AuthkitResponse => { newRequestHeaders.set(sessionHeaderName, request.cookies.get(cookieName)!.value); const { @@ -253,13 +256,19 @@ async function updateSession( }, headers: newRequestHeaders, }; + }; + + if (hasValidSession && !isExpiring) { + return respondWithCurrentToken(); } try { if (options.debug) { // istanbul ignore next console.log( - `Session invalid. ${session.accessToken ? `Refreshing access token that ends in ${session.accessToken.slice(-10)}` : 'Access token missing.'}`, + isExpiring + ? `Session expiring soon. Proactively refreshing access token that ends in ${session.accessToken.slice(-10)}` + : `Session invalid. ${session.accessToken ? `Refreshing access token that ends in ${session.accessToken.slice(-10)}` : 'Access token missing.'}`, ); } @@ -321,6 +330,19 @@ async function updateSession( headers: newRequestHeaders, }; } catch (e) { + if (isExpiring) { + // The current token is still valid, so a failed proactive refresh is not fatal. + // Refresh tokens are single-use, so a concurrent request in the same buffer + // window may have already rotated this one; that request has persisted the new + // session cookie. Serve this request with the current token instead of + // destroying the session. + if (options.debug) { + console.log('Proactive refresh failed. Serving request with the still-valid access token.', e); + } + + return respondWithCurrentToken(); + } + if (options.debug) { console.log('Failed to refresh. Deleting cookie.', e); } @@ -544,6 +566,33 @@ async function verifyAccessToken(accessToken: string) { } } +/** + * Determines whether a still-valid access token is close enough to expiry that it + * should be proactively refreshed, so it cannot expire in the hands of a + * server-side consumer (render latency, network round trips, clock skew). + * + * Mirrors the buffer the client token store uses (`components/tokenStore.ts`): + * 60 seconds, or 30 seconds for tokens with a total lifetime of 5 minutes or + * less, unless an explicit `refreshBufferSeconds` is provided. A buffer of 0 + * disables proactive refresh. + */ +function isTokenExpiring(accessToken: string, refreshBufferSeconds?: number): boolean { + try { + const { exp, iat } = decodeJwt(accessToken); + if (typeof exp !== 'number') { + return false; + } + + const now = Math.floor(Date.now() / 1000); + const totalTokenLifetime = exp - (iat ?? exp); + const bufferSeconds = refreshBufferSeconds ?? (totalTokenLifetime <= 300 ? 30 : 60); + + return exp < now + bufferSeconds; + } catch { + return false; + } +} + export async function getSessionFromCookie(request?: NextRequest) { const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; let cookie; From 852196277191fa06e87c7d9733317c58dfdd7840 Mon Sep 17 00:00:00 2001 From: Thijs Date: Tue, 21 Jul 2026 14:58:12 +0200 Subject: [PATCH 2/2] fix: re-check token validity before serving after a failed proactive refresh --- README.md | 4 +++- src/session.spec.ts | 25 +++++++++++++++++++++++++ src/session.ts | 23 ++++++++++++++--------- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5e7d625..4507abe 100644 --- a/README.md +++ b/README.md @@ -847,7 +847,9 @@ export default authkitProxy({ }); ``` -If a proactive refresh fails (for example, a concurrent request already rotated the single-use refresh token), the request is served with the current, still-valid access token; the session is never destroyed while the access token remains valid. +If a proactive refresh fails (for example, a concurrent request already rotated the single-use refresh token), the request is served with the current access token, provided it is still valid at that point; the session is never destroyed while the access token remains valid. If the token expired during the failed refresh attempt, the session is cleared and the request is redirected to sign in, as with any expired session. + +Note that when several requests land inside the buffer window at the same time, only one wins the refresh; each of the others may pay a failed-refresh round trip to WorkOS before being served with the current token. This costs some added latency on those requests during the buffer window, but no user-visible failure. ### Signing out diff --git a/src/session.spec.ts b/src/session.spec.ts index 6baa764..be0ca63 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -1031,6 +1031,31 @@ describe('session.ts', () => { ); }); + it('should delete the session when a proactive refresh fails and the token expired during the attempt', async () => { + const mockErrorCallback = vi.fn(); + const accessToken = await generateTokenWithExpiry(30); + const request = await requestWithSessionToken(accessToken); + + vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockImplementation(async () => { + // The token runs out while the refresh round trip is in flight + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 31_000); + throw new Error('Refresh failed'); + }); + + try { + const response = await updateSession(request, { debug: true, onSessionRefreshError: mockErrorCallback }); + + expect(response.session.user).toBeNull(); + expect(response.authorizationUrl).toBeDefined(); + expect(response.headers.getSetCookie().some((c) => c.startsWith('wos-session=;'))).toBe(true); + expect(mockErrorCallback).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith('Failed to refresh. Deleting cookie.', expect.any(Error)); + } finally { + vi.useRealTimers(); + } + }); + it('should call onSessionRefreshSuccess when a proactive refresh succeeds', async () => { const mockSuccessCallback = vi.fn(); const newAccessToken = await generateTestToken(); diff --git a/src/session.ts b/src/session.ts index 281f5f8..b448142 100644 --- a/src/session.ts +++ b/src/session.ts @@ -331,16 +331,21 @@ async function updateSession( }; } catch (e) { if (isExpiring) { - // The current token is still valid, so a failed proactive refresh is not fatal. - // Refresh tokens are single-use, so a concurrent request in the same buffer - // window may have already rotated this one; that request has persisted the new - // session cookie. Serve this request with the current token instead of - // destroying the session. - if (options.debug) { - console.log('Proactive refresh failed. Serving request with the still-valid access token.', e); + // A failed proactive refresh is not fatal while the current token is still + // valid. Refresh tokens are single-use, so a concurrent request in the same + // buffer window may have already rotated this one; that request has persisted + // the new session cookie. Serve this request with the current token instead of + // destroying the session. Re-check validity here: the token may have expired + // during the refresh round trip, in which case fall through to the + // delete-cookie path below. + const { exp } = decodeJwt(session.accessToken); + if (typeof exp === 'number' && exp > Math.floor(Date.now() / 1000)) { + if (options.debug) { + console.log('Proactive refresh failed. Serving request with the still-valid access token.', e); + } + + return respondWithCurrentToken(); } - - return respondWithCurrentToken(); } if (options.debug) {