From fd021175aa6a9af8a1217d2087d11d8869624961 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 12:43:08 -0400 Subject: [PATCH] fix: bind aud claim on signed user tokens Signers set aud equal to ISSUER so the Seamless adapter, which now verifies signed auth responses with aud === audience, accepts them. The deployment contract requires the adopter's audience to equal its authServerUrl, which is byte-identical to this server's ISSUER. Without the claim, jose rejects every token the adapter checks, breaking login, registration, OAuth, OTP, magic-link, and organization-switch once the adapter audience binding ships. --- .changeset/set-token-audience.md | 5 +++++ src/lib/token.ts | 7 +++++++ tests/unit/lib/token.spec.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 .changeset/set-token-audience.md diff --git a/.changeset/set-token-audience.md b/.changeset/set-token-audience.md new file mode 100644 index 0000000..389de2e --- /dev/null +++ b/.changeset/set-token-audience.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +Bind the `aud` claim on signed user tokens. `signAccessToken`, `signRefreshToken`, and `signEphemeralToken` now call `.setAudience(ISSUER)` in addition to `.setIssuer(ISSUER)`. The Seamless adapter verifies signed auth responses with `aud === audience`, and the deployment contract requires the adopter's `audience` to equal its `authServerUrl`, which is byte-identical to this server's `ISSUER`. Without the claim, jose rejects every token the adapter checks, so login, registration, OAuth, OTP, magic-link, and organization-switch all fail once the adapter's audience binding ships. The claim is additive and ignored by verifiers that do not check it. diff --git a/src/lib/token.ts b/src/lib/token.ts index 32fc82b..7db560f 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -14,6 +14,10 @@ import { getSigningKey } from '../utils/signingKeyStore.js'; const logger = getLogger('tokens'); +// User tokens are signed with `aud` equal to ISSUER. The Seamless adapter verifies +// signed auth responses with `aud === audience`, and the deployment contract requires +// the adopter's `audience` to equal its `authServerUrl`, which is byte-identical to +// this ISSUER. Omitting `aud` makes jose reject every token the adapter checks. const ISSUER = process.env.ISSUER!; let warnedAboutDevLookupSecret = false; @@ -66,6 +70,7 @@ export async function signAccessToken( .setProtectedHeader({ alg: 'RS256', kid }) .setIssuedAt() .setIssuer(ISSUER) + .setAudience(ISSUER) .setExpirationTime(access_token_ttl) .sign(privateKey); @@ -87,6 +92,7 @@ export async function signRefreshToken(sessionId: string, userId: string) { .setProtectedHeader({ alg: 'RS256', kid }) .setIssuedAt() .setIssuer(ISSUER) + .setAudience(ISSUER) .setExpirationTime(refresh_token_ttl) .sign(privateKey); @@ -107,6 +113,7 @@ export async function signEphemeralToken(userId: string) { .setProtectedHeader({ alg: 'RS256', kid }) .setIssuedAt() .setIssuer(ISSUER) + .setAudience(ISSUER) .setExpirationTime('5m') .sign(privateKey); diff --git a/tests/unit/lib/token.spec.ts b/tests/unit/lib/token.spec.ts index 4d0de18..42eab64 100644 --- a/tests/unit/lib/token.spec.ts +++ b/tests/unit/lib/token.spec.ts @@ -11,6 +11,7 @@ vi.mock('../../../src/config/getSystemConfig.js', () => ({ })); const signPayloads = vi.hoisted(() => [] as unknown[]); +const signAudiences = vi.hoisted(() => [] as unknown[]); vi.mock('jose', () => { class MockSignJWT { @@ -26,6 +27,10 @@ vi.mock('jose', () => { setIssuer() { return this; } + setAudience(audience: unknown) { + signAudiences.push(audience); + return this; + } setExpirationTime() { return this; } @@ -80,11 +85,14 @@ describe('token utils', () => { access_token_ttl: '15m', }); + signAudiences.length = 0; + const { signAccessToken } = await import('../../../src/lib/token'); const result = await signAccessToken('sid', 'user', ['admin']); expect(result).toBe('mock-jwt'); + expect(signAudiences.at(-1)).toBe('issuer'); }); it('embeds an organization claim when an organization id is provided', async () => { @@ -132,11 +140,14 @@ describe('token utils', () => { refresh_token_ttl: '1h', }); + signAudiences.length = 0; + const { signRefreshToken } = await import('../../../src/lib/token'); const result = await signRefreshToken('sid', 'user'); expect(result).toBe('mock-jwt'); + expect(signAudiences.at(-1)).toBe('issuer'); }); it('signs ephemeral token', async () => { @@ -147,11 +158,14 @@ describe('token utils', () => { privateKeyPem: 'pem', }); + signAudiences.length = 0; + const { signEphemeralToken } = await import('../../../src/lib/token'); const result = await signEphemeralToken('user'); expect(result).toBe('mock-jwt'); + expect(signAudiences.at(-1)).toBe('issuer'); }); it('throws if signing fails', async () => {