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
31 changes: 30 additions & 1 deletion src/core/AuthKitCore.issuer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { jwtVerify } from 'jose';
import { AuthKitCore } from './AuthKitCore.js';

const userAccessTokenPayload = vi.hoisted(() => ({
sid: 'session_123',
auth_time: 1700000000,
}));

vi.mock('jose', async importOriginal => {
const actual = await importOriginal<typeof import('jose')>();
return {
...actual,
jwtVerify: vi.fn().mockResolvedValue({ payload: {}, protectedHeader: {} }),
jwtVerify: vi.fn().mockResolvedValue({
payload: userAccessTokenPayload,
protectedHeader: {},
}),
};
});

Expand Down Expand Up @@ -95,3 +103,24 @@ describe('AuthKitCore.verifyToken() issuer validation', () => {
await expect(core.verifyToken('some.jwt.token')).resolves.toBe(false);
});
});

describe('AuthKitCore.verifyToken() token claims', () => {
it.each([
['sub_profile user', { sub_profile: 'user' }, true],
['agent sub_profile', { sub_profile: 'ai_agent' }, false],
['widget sub_type', { sub_type: 'authkit' }, false],
['missing auth_time', { auth_time: undefined }, false],
['string auth_time', { auth_time: '1700000000' }, false],
['missing sid', { sid: undefined }, false],
['numeric sid', { sid: 123 }, false],
])('%s', async (_name, claims, expected) => {
vi.mocked(jwtVerify).mockResolvedValueOnce({
payload: { ...userAccessTokenPayload, ...claims },
protectedHeader: { alg: 'RS256' },
} as any);

await expect(
createCore(baseConfig).verifyToken('some.jwt.token'),
).resolves.toBe(expected);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
});
});
22 changes: 19 additions & 3 deletions src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import {
type User,
type WorkOS,
} from '@workos-inc/node';
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
import {
createRemoteJWKSet,
decodeJwt,
jwtVerify,
type JWTPayload,
} from 'jose';
import { constantTimeEqual, once } from '../utils.js';
import type { AuthKitConfig } from './config/types.js';
import {
Expand Down Expand Up @@ -81,12 +86,12 @@ export class AuthKitCore {
async verifyToken(token: string): Promise<boolean> {
const issuer = this.config.issuer;
try {
await jwtVerify(
const { payload } = await jwtVerify(
token,
this.getPublicKey(),
issuer ? { issuer } : undefined,
);
return true;
return isUserAccessTokenPayload(payload);
} catch {
return false;
}
Expand Down Expand Up @@ -379,3 +384,14 @@ export class AuthKitCore {
};
}
}

// Other token types signed by the same environment key (agent and widget
// tokens) must not authenticate a session cookie.
function isUserAccessTokenPayload(payload: JWTPayload): boolean {
return (
typeof payload.sid === 'string' &&
typeof payload.auth_time === 'number' &&
(payload.sub_profile === undefined || payload.sub_profile === 'user') &&
payload.sub_type === undefined
);
}
16 changes: 15 additions & 1 deletion src/service/publicClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function cookiePair(setCookie: string | string[] | undefined) {
const signingKey = generateKeyPair('RS256');
async function signAccessToken(expiresAt: number) {
const { privateKey } = await signingKey;
return new SignJWT({ sid: 'session_123' })
return new SignJWT({ sid: 'session_123', auth_time: expiresAt - 300 })
.setProtectedHeader({ alg: 'RS256', kid: 'key_1' })
.setIssuedAt(expiresAt - 300)
.setExpirationTime(expiresAt)
Expand Down Expand Up @@ -246,6 +246,20 @@ describe.each([
refresh_token: 'refresh_1',
});
expectClientCredentials(authenticateCalls[0]!);

// The refreshed session authenticates without another refresh.
const { headers: refreshedHeaders } = await service.saveSession(
undefined,
refreshedSessionData!,
);
const next = await service.withAuth(
new Request('http://localhost:3000/', {
headers: { cookie: cookiePair(refreshedHeaders?.['Set-Cookie']) },
}),
);
expect(next.auth.user?.id).toBe('user_123');
expect(next.refreshedSessionData).toBeUndefined();
expect(authenticateCalls).toHaveLength(1);
});

it.runIf(!apiKey)(
Expand Down
Loading