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;