Skip to content
Draft
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: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Environment variables override programmatic config.
```typescript
// Authentication
authService.withAuth(request) // → { auth, refreshedSessionData? }
authService.handleCallback(request, response, { code, state })
authService.handleCallback(request, response, { code, state, cookieValue })
authService.getSession(request) // → Session | null
authService.saveSession(response, sessionData) // → { response?, headers? }
authService.clearSession(response)
Expand All @@ -184,12 +184,51 @@ authService.signOut(sessionId, { returnTo }) // → { logoutUrl, clearCookie
authService.refreshSession(session, organizationId?)
authService.switchOrganization(session, organizationId)

// URL Generation
// URL Generation — return { url, sealedState, cookieOptions }
authService.getAuthorizationUrl(options)
authService.getSignInUrl(options)
authService.getSignUpUrl(options)

// PKCE cookie helpers — so adapters never need to read config directly
authService.getPKCECookieOptions(redirectUri?) // → PKCECookieOptions
authService.buildPKCEDeleteCookieHeader(redirectUri?) // → Set-Cookie string
```

### PKCE verifier cookie (`wos-auth-verifier`)

This library binds every OAuth sign-in to a PKCE code verifier, so a leaked
`state` value on its own cannot be used to complete a session hijack.

The verifier is sealed into a single blob that serves two roles:

1. It is sent to WorkOS as the OAuth `state` query parameter.
2. It is set as a short-lived HTTP-only cookie (`wos-auth-verifier`, 10 min).

On callback, the adapter passes BOTH channels to `handleCallback`:

```typescript
// Sign in: set the verifier cookie, redirect to `url`
const { url, sealedState, cookieOptions } = await authService.getSignInUrl({
returnPathname: '/dashboard',
});
response.setHeader(
'Set-Cookie',
serializePKCESetCookie(cookieOptions, sealedState),
);
return redirect(url);

// Callback: pass both channels; the library byte-compares before decrypting
await authService.handleCallback(request, response, {
code,
state, // from URL
cookieValue: request.cookies.get('wos-auth-verifier'),
});
```

Mismatched state and cookie raise `OAuthStateMismatchError`. A missing cookie
(typical cause: Set-Cookie stripped by a proxy) raises
`PKCECookieMissingError` with deployment guidance in the message.

### Direct Access (Advanced)

For maximum control, use the primitives directly:
Expand All @@ -205,7 +244,7 @@ import {
const config = getConfigurationProvider();
const client = getWorkOS(config.getConfig());
const core = new AuthKitCore(config, client, encryption);
const operations = new AuthOperations(core, client, config);
const operations = new AuthOperations(core, client, config, encryption);

// Use core.validateAndRefresh(), core.encryptSession(), etc.
```
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"dependencies": {
"@workos-inc/node": "8.0.0",
"iron-webcrypto": "^2.0.0",
"jose": "^6.1.3"
"jose": "^6.1.3",
"valibot": "^1.3.1"
},
"devDependencies": {
"@types/node": "^20.17.0",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

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

109 changes: 109 additions & 0 deletions src/core/AuthKitCore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,113 @@ describe('AuthKitCore', () => {
);
});
});

describe('validateAndRefresh()', () => {
// Decodable JWT with sid + exp + org_id. Signature is garbage → verifyToken false.
const oldJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fOTk5IiwiZXhwIjoxMDAwMDAwMDAwLCJvcmdfaWQiOiJvcmdfYWJjIn0.sig';
const newJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig';

function makeRefreshClient(capture?: { opts?: any }) {
return {
userManagement: {
getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id',
authenticateWithRefreshToken: async (opts: any) => {
if (capture) capture.opts = opts;
return {
accessToken: newJwt,
refreshToken: 'new-rt',
user: mockUser,
impersonator: undefined,
};
},
},
};
}

it('forces refresh when token invalid and returns new session', async () => {
const testCore = new AuthKitCore(
mockConfig as any,
makeRefreshClient() as any,
mockEncryption as any,
);
const session = {
accessToken: oldJwt,
refreshToken: 'rt',
user: mockUser,
impersonator: undefined,
};

const result = await testCore.validateAndRefresh(session);

expect(result.refreshed).toBe(true);
expect(result.session.accessToken).toBe(newJwt);
});

it('propagates explicit organizationId into refresh', async () => {
const capture: { opts?: any } = {};
const testCore = new AuthKitCore(
mockConfig as any,
makeRefreshClient(capture) as any,
mockEncryption as any,
);

await testCore.validateAndRefresh(
{
accessToken: oldJwt,
refreshToken: 'rt',
user: mockUser,
impersonator: undefined,
},
{ organizationId: 'org_explicit' },
);

expect(capture.opts?.organizationId).toBe('org_explicit');
});

it('continues when access token is unparseable', async () => {
const testCore = new AuthKitCore(
mockConfig as any,
makeRefreshClient() as any,
mockEncryption as any,
);
const session = {
accessToken: 'not-a-jwt',
refreshToken: 'rt',
user: mockUser,
impersonator: undefined,
};

const result = await testCore.validateAndRefresh(session);

expect(result.refreshed).toBe(true);
});
});

describe('verifyCallbackState()', () => {
it('throws OAuthStateMismatchError when stateFromUrl missing', async () => {
await expect(
core.verifyCallbackState({
stateFromUrl: undefined,
cookieValue: 'x',
}),
).rejects.toMatchObject({ name: 'OAuthStateMismatchError' });
});

it('throws PKCECookieMissingError when cookie missing', async () => {
await expect(
core.verifyCallbackState({
stateFromUrl: 'x',
cookieValue: undefined,
}),
).rejects.toMatchObject({ name: 'PKCECookieMissingError' });
});

it('throws OAuthStateMismatchError when values differ', async () => {
await expect(
core.verifyCallbackState({ stateFromUrl: 'a', cookieValue: 'b' }),
).rejects.toMatchObject({ name: 'OAuthStateMismatchError' });
});
});
});
56 changes: 55 additions & 1 deletion src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { timingSafeEqual } from 'node:crypto';
import type { Impersonator, User, WorkOS } from '@workos-inc/node';
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
import { once } from '../utils.js';
import type { AuthKitConfig } from './config/types.js';
import { SessionEncryptionError, TokenRefreshError } from './errors.js';
import {
OAuthStateMismatchError,
PKCECookieMissingError,
SessionEncryptionError,
TokenRefreshError,
} from './errors.js';
import { type PKCEState, unsealState } from './pkce/state.js';
import type {
BaseTokenClaims,
CustomClaims,
Expand Down Expand Up @@ -147,6 +154,53 @@ export class AuthKitCore {
}
}

/**
* Verify the OAuth callback state against the PKCE verifier cookie and
* return the unsealed state blob.
*
* Core owns encryption, so verification lives here rather than on
* AuthService. Byte-compares the query-string `state` against the cookie
* value BEFORE attempting to decrypt — smaller crypto attack surface and
* no decryption cost on obvious mismatches.
*
* @throws {OAuthStateMismatchError} state missing or does not match cookie
* @throws {PKCECookieMissingError} cookie not sent — typically a proxy or
* Set-Cookie propagation issue on the adapter's callback path
* @throws {SessionEncryptionError} seal tampered, wrong password, TTL expired,
* or schema mismatch on the unsealed payload
*/
async verifyCallbackState(params: {
stateFromUrl: string | undefined;
cookieValue: string | undefined;
}): Promise<PKCEState> {
const { stateFromUrl, cookieValue } = params;

if (!stateFromUrl) {
throw new OAuthStateMismatchError(
'Missing state parameter from callback URL',
);
}
if (!cookieValue) {
throw new PKCECookieMissingError(
'PKCE verifier cookie missing — cannot verify OAuth state. Ensure Set-Cookie headers are propagated on redirects.',
);
}
const urlBytes = Buffer.from(stateFromUrl, 'utf8');
const cookieBytes = Buffer.from(cookieValue, 'utf8');
if (
urlBytes.length !== cookieBytes.length ||
!timingSafeEqual(urlBytes, cookieBytes)
) {
throw new OAuthStateMismatchError('OAuth state mismatch');
}

return unsealState(
this.encryption,
this.config.cookiePassword,
cookieValue,
);
}

/**
* Refresh tokens using WorkOS API.
*
Expand Down
62 changes: 62 additions & 0 deletions src/core/encryption/ironWebcryptoEncryption.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,66 @@ describe('ironWebcryptoEncryption', () => {
expect(unsealed).toEqual(testData);
});
});

describe('TTL enforcement', () => {
beforeEach(() => {
vi.useFakeTimers();
});

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

it('unseals successfully before TTL expires', async () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const sealed = await encryption.sealData(testData, {
password: testPassword,
ttl: 600,
});

// Advance less than TTL
vi.setSystemTime(new Date('2026-01-01T00:09:00.000Z')); // +540s
const unsealed = await encryption.unsealData(sealed, {
password: testPassword,
ttl: 600,
});

expect(unsealed).toEqual(testData);
});

it('throws when TTL has expired on unseal', async () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const sealed = await encryption.sealData(testData, {
password: testPassword,
ttl: 1,
});

// Advance past TTL + skew (60s default)
vi.setSystemTime(new Date('2026-01-01T00:02:00.000Z'));
await expect(
encryption.unsealData(sealed, {
password: testPassword,
ttl: 1,
}),
).rejects.toThrow();
});

it('preserves session-cookie flow: seal without TTL, unseal without TTL', async () => {
// This is the invariant AuthKitCore.encryptSession/decryptSession relies on.
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const sealed = await encryption.sealData(testData, {
password: testPassword,
// no ttl — matches AuthKitCore.encryptSession
});

// Advance time substantially — session cookies must still unseal.
vi.setSystemTime(new Date('2027-01-01T00:00:00.000Z'));
const unsealed = await encryption.unsealData(sealed, {
password: testPassword,
// no ttl — matches AuthKitCore.decryptSession
});

expect(unsealed).toEqual(testData);
});
});
});
4 changes: 2 additions & 2 deletions src/core/encryption/ironWebcryptoEncryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class SessionEncryption implements SessionEncryptionInterface {
// Decrypt data from iron-session with HMAC verification
async unsealData<T = unknown>(
encryptedData: string,
{ password }: { password: string },
{ password, ttl = 0 }: { password: string; ttl?: number | undefined },
): Promise<T> {
// First, parse the seal to extract the version
const { sealWithoutVersion, tokenVersion } = this.parseSeal(encryptedData);
Expand All @@ -80,7 +80,7 @@ export class SessionEncryption implements SessionEncryptionInterface {
iterations: 1,
minPasswordlength: 32,
},
ttl: 0,
ttl: ttl * 1000, // Convert seconds to milliseconds. 0 = no TTL enforcement.
timestampSkewSec: 60,
localtimeOffsetMsec: 0,
});
Expand Down
Loading
Loading