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
5 changes: 5 additions & 0 deletions .changeset/set-token-audience.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -66,6 +70,7 @@ export async function signAccessToken(
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuedAt()
.setIssuer(ISSUER)
.setAudience(ISSUER)
.setExpirationTime(access_token_ttl)
.sign(privateKey);

Expand All @@ -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);

Expand All @@ -107,6 +113,7 @@ export async function signEphemeralToken(userId: string) {
.setProtectedHeader({ alg: 'RS256', kid })
.setIssuedAt()
.setIssuer(ISSUER)
.setAudience(ISSUER)
.setExpirationTime('5m')
.sign(privateKey);

Expand Down
14 changes: 14 additions & 0 deletions tests/unit/lib/token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,6 +27,10 @@ vi.mock('jose', () => {
setIssuer() {
return this;
}
setAudience(audience: unknown) {
signAudiences.push(audience);
return this;
}
setExpirationTime() {
return this;
}
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading