Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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".
Expand Down
16 changes: 16 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,29 @@ 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 {
eagerAuth?: boolean;
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;
Expand Down
1 change: 1 addition & 0 deletions src/middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('middleware', () => {
debug: true,
middlewareAuth: { enabled: true, unauthenticatedPaths: ['/public'] },
signUpPaths: ['/sign-up'],
refreshBufferSeconds: 120,
});
expect(typeof middleware).toBe('function');
});
Expand Down
11 changes: 10 additions & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
};
}

Expand Down
163 changes: 162 additions & 1 deletion src/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, key: string, value: unknown) {
Expand Down Expand Up @@ -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', () => {
Expand Down
53 changes: 51 additions & 2 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.'}`,
);
}

Expand Down Expand Up @@ -321,6 +330,19 @@ async function updateSession(
headers: newRequestHeaders,
};
} catch (e) {
if (isExpiring) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should re-check the remaining validity in the catch before falling back:

if (isExpiring) {
  const { exp } = decodeJwt(session.accessToken);
  if (typeof exp === 'number' && exp > Math.floor(Date.now() / 1000)) {
    return respondWithCurrentToken();
  }
  // fall through to the existing delete-cookie path
}

// 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);
}
Expand Down Expand Up @@ -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;
Expand Down