Skip to content
Merged
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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,51 @@ const { user, loading } = useAuth({ ensureSignedIn: true });

Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated.

### Re-authentication

For sensitive actions you may want to require that the user authenticated _recently_, not just that they have a session. Use `checkRecentAuth` to read the access token's `auth_time` claim and decide whether to force a re-authentication.

`checkRecentAuth` returns data only and never redirects, so it's safe to call as the enforcement step inside a server action or server component. It **fails closed**: a session with no usable `auth_time` (or no signed-in user) is reported as `isStale: true`.

```ts
'use server';

import { checkRecentAuth, getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';

export async function deleteAccount() {
// Require that the user authenticated within the last 5 minutes
const { isStale } = await checkRecentAuth({ maxAge: 300 });

if (isStale) {
// Send the user through re-authentication. Passing `maxAge` forwards the OIDC
// `max_age` parameter, so AuthKit forces a fresh login when the most recent
// authentication is older than `maxAge` seconds.
redirect(await getSignInUrl({ maxAge: 300 }));
}

// ...perform the sensitive action
}
```

For client components, use the `useRecentAuth` hook to reflect recency in the UI. This is **presentation only** — always enforce recency on the server with `checkRecentAuth`.

```tsx
'use client';

import { useRecentAuth } from '@workos-inc/authkit-nextjs/components';

function SensitiveActionButton() {
const { loading, isStale } = useRecentAuth({ maxAge: 300 });

if (loading) {
return <button disabled>Loading…</button>;
}

return <button>{isStale ? 'Re-authenticate to continue' : 'Delete account'}</button>;
}
```

### Refreshing the session

Use the `refreshSession` method in a server action or route handler to fetch the latest session details, including any changes to the user's roles or permissions.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@types/react": "18.2.67",
"@types/react-dom": "18.2.22",
"@vitest/coverage-v8": "^3.2.4",
"@workos-inc/node": "^10.0.0",
"@workos-inc/node": "^10.7.0",
"jsdom": "^26.1.0",
"next": "^16.2.1",
"oxfmt": "^0.42.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import { Impersonation } from './impersonation.js';
import { AuthKitProvider, useAuth } from './authkit-provider.js';
import { useAccessToken } from './useAccessToken.js';
import { useTokenClaims } from './useTokenClaims.js';
import { useRecentAuth } from './useRecentAuth.js';

export { Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims };
export { Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims, useRecentAuth };
69 changes: 69 additions & 0 deletions src/components/useRecentAuth.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import { useRecentAuth } from './useRecentAuth.js';
import { useAccessToken } from './useAccessToken.js';
import { useTokenClaims } from './useTokenClaims.js';

vi.mock('./useAccessToken.js');
vi.mock('./useTokenClaims.js');

const NOW_MS = 1_700_000_000_000;
const NOW_S = NOW_MS / 1000;

function mockAccessToken(loading: boolean) {
vi.mocked(useAccessToken).mockReturnValue({
accessToken: undefined,
loading,
error: null,
refresh: vi.fn(),
getAccessToken: vi.fn(),
});
}

describe('useRecentAuth', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_MS);
mockAccessToken(false);
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

it('reports recent auth as not stale', () => {
vi.mocked(useTokenClaims).mockReturnValue({ auth_time: NOW_S - 60 });

const { result } = renderHook(() => useRecentAuth({ maxAge: 300 }));

expect(result.current.isStale).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.authenticatedAt).toEqual(new Date((NOW_S - 60) * 1000));
});

it('reports stale auth past maxAge', () => {
vi.mocked(useTokenClaims).mockReturnValue({ auth_time: NOW_S - 600 });

const { result } = renderHook(() => useRecentAuth({ maxAge: 300 }));

expect(result.current.isStale).toBe(true);
});

it('fails closed when auth_time is absent', () => {
vi.mocked(useTokenClaims).mockReturnValue({});

const { result } = renderHook(() => useRecentAuth({ maxAge: 300 }));

expect(result.current.isStale).toBe(true);
expect(result.current.authenticatedAt).toBeNull();
});

it('surfaces the token loading state', () => {
mockAccessToken(true);
vi.mocked(useTokenClaims).mockReturnValue({});

const { result } = renderHook(() => useRecentAuth({ maxAge: 300 }));

expect(result.current.loading).toBe(true);
});
});
31 changes: 31 additions & 0 deletions src/components/useRecentAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { evaluateRecentAuth } from '../utils.js';
import { useAccessToken } from './useAccessToken.js';
import { useTokenClaims } from './useTokenClaims.js';

/**
* Reports how recently the current user authenticated, from the `auth_time`
* claim already held in client memory. Presentation only — enforce recency
* server-side with `checkRecentAuth`.
*/
export function useRecentAuth({ maxAge }: { maxAge: number }) {
const { loading } = useAccessToken();
const { auth_time } = useTokenClaims();

if (loading) {
return {
loading: true,
authenticatedAt: undefined,
isStale: undefined,
} as const;
}

const recentAuth = evaluateRecentAuth({
authTime: auth_time,
maxAgeSeconds: maxAge,
nowSeconds: Math.floor(Date.now() / 1000),
});

return { ...recentAuth, loading } as const;
}
34 changes: 34 additions & 0 deletions src/get-authorization-url.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,40 @@ describe('getAuthorizationUrl', () => {
);
});

it('forwards maxAge when provided', async () => {
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');

await getAuthorizationUrl({ maxAge: 300 });

expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({
maxAge: 300,
}),
);
});

it('forwards maxAge of 0 (force reauth)', async () => {
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');

await getAuthorizationUrl({ maxAge: 0 });

expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({
maxAge: 0,
}),
);
});

it('omits maxAge when not provided', async () => {
vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');

await getAuthorizationUrl({});

expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
expect.not.objectContaining({ maxAge: expect.anything() }),
);
});

describe('claim nonce', () => {
afterEach(() => {
delete process.env.WORKOS_CLAIM_TOKEN;
Expand Down
2 changes: 2 additions & 0 deletions src/get-authorization-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async function getAuthorizationUrl({
prompt,
state: customState,
redirectUri,
maxAge,
}: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
const redirectUriToUse = await (async () => {
if (redirectUri) {
Expand Down Expand Up @@ -75,6 +76,7 @@ async function getAuthorizationUrl({
codeChallenge: pkce.codeChallenge,
codeChallengeMethod: pkce.codeChallengeMethod,
...(claimNonce && { claimNonce }),
...(maxAge !== undefined && { maxAge }),
});

return { url, sealedState };
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export {
type AuthkitRequestHeader,
type HandleAuthkitHeadersOptions,
} from './middleware-helpers.js';
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
import { checkRecentAuth, getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
import { validateApiKey } from './validate-api-key.js';
import { getFeatureFlagsRuntimeClient } from './feature-flags.js';
import { getWorkOS } from './workos.js';
Expand All @@ -27,6 +27,7 @@ export {
authkit,
authkitMiddleware,
authkitProxy,
checkRecentAuth,
getSignInUrl,
getSignUpUrl,
getFeatureFlagsRuntimeClient,
Expand Down
21 changes: 20 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node';
import type { AuthenticationResponse, OauthTokens, User, WorkOS } from '@workos-inc/node';
import { type NextRequest } from 'next/server';
import * as v from 'valibot';

/**
* The options object accepted by the installed SDK's `getAuthorizationUrl`.
*
* Derived from the method authkit already calls at runtime rather than from a
* named type import, so the version gate below never depends on whether a given
* `@workos-inc/node` release exports its options type by name — it only needs
* the method to exist, which it does across the entire peer range. `keyof` is
* read from the consumer's installed version at *their* compile time, so the
* surface tracks the peer.
*/
type SdkAuthorizationUrlOptions = Parameters<WorkOS['userManagement']['getAuthorizationUrl']>[0];

export interface HandleAuthOptions {
returnPathname?: string;
baseURL?: string;
Expand Down Expand Up @@ -85,6 +97,13 @@ export interface GetAuthURLOptions {
loginHint?: string;
prompt?: 'consent';
state?: string;
/**
* Maximum allowable elapsed time, in seconds, since the user last actively
* authenticated (OIDC `max_age`).
*
* Requires `@workos-inc/node` >= 10.7.0, where the param was added.
*/
maxAge?: 'maxAge' extends keyof SdkAuthorizationUrlOptions ? number : never;
}

export interface AuthkitMiddlewareAuth {
Expand Down
5 changes: 5 additions & 0 deletions src/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface JWTPayload {
* Permissions granted to the user associated with the JWT
*/
permissions?: string[];

/**
* Time of user authentication, represented as epoch seconds
*/
auth_time?: number;
}

export type TokenClaims<T> = Partial<JWTPayload & T>;
Expand Down
47 changes: 46 additions & 1 deletion src/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { generateTestToken } from './test-helpers.js';
import { withAuth, updateSession, refreshSession, updateSessionMiddleware, getTokenClaims } from './session.js';
import {
withAuth,
updateSession,
refreshSession,
updateSessionMiddleware,
getTokenClaims,
checkRecentAuth,
} from './session.js';
import { getWorkOS } from './workos.js';
import * as envVariables from './env-variables.js';

Expand Down Expand Up @@ -1099,6 +1106,44 @@ describe('session.ts', () => {
});
});

describe('checkRecentAuth', () => {
async function authenticate(authTime?: number) {
mockSession.accessToken = await generateTestToken(authTime === undefined ? {} : { auth_time: authTime });
const nextHeaders = await headers();
nextHeaders.set(
'x-workos-session',
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
);
}

it('reports recent auth as not stale', async () => {
const now = Math.floor(Date.now() / 1000);
await authenticate(now - 60);

const result = await checkRecentAuth({ maxAge: 300 });

expect(result.isStale).toBe(false);
expect(result.authenticatedAt).toEqual(new Date((now - 60) * 1000));
});

it('reports stale auth past maxAge', async () => {
const now = Math.floor(Date.now() / 1000);
await authenticate(now - 600);

expect((await checkRecentAuth({ maxAge: 300 })).isStale).toBe(true);
});

it('fails closed when auth_time claim is missing', async () => {
await authenticate(undefined);

expect(await checkRecentAuth({ maxAge: 300 })).toEqual({ authenticatedAt: null, isStale: true });
});

it('fails closed when there is no authenticated user', async () => {
expect(await checkRecentAuth({ maxAge: 300 })).toEqual({ authenticatedAt: null, isStale: true });
});
});

describe('eager auth functionality', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
Loading