From 6543ae910969028f2ba4c35eb778536db302ac78 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 24 Sep 2026 21:29:08 +0000 Subject: [PATCH 1/2] feat(backend): expose the act claim on verified OAuth access tokens --- .changeset/oauth-token-actor-chain.md | 5 +++ .../src/api/resources/IdPOAuthAccessToken.ts | 7 +++- packages/backend/src/api/resources/JSON.ts | 7 ++++ .../src/tokens/__tests__/verify.test.ts | 38 +++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .changeset/oauth-token-actor-chain.md diff --git a/.changeset/oauth-token-actor-chain.md b/.changeset/oauth-token-actor-chain.md new file mode 100644 index 00000000000..fad12140721 --- /dev/null +++ b/.changeset/oauth-token-actor-chain.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Verified OAuth access tokens now expose `act`, the RFC 8693 actor chain, when the token was issued by an OAuth 2.0 Token Exchange. It is available on the `IdPOAuthAccessToken` returned for both opaque and JWT access tokens. diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index e0028ab1ffc..358e72d163c 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -1,6 +1,6 @@ import type { JwtPayload } from '@clerk/shared/types'; -import type { IdPOAuthAccessTokenJSON } from './JSON'; +import type { IdPOAuthAccessTokenActorJSON, IdPOAuthAccessTokenJSON } from './JSON'; type OAuthJwtPayload = JwtPayload & { aud?: string | string[]; @@ -8,6 +8,7 @@ type OAuthJwtPayload = JwtPayload & { client_id?: string; scope?: string; scp?: string[]; + act?: IdPOAuthAccessTokenActorJSON; }; export class IdPOAuthAccessToken { @@ -28,6 +29,8 @@ export class IdPOAuthAccessToken { readonly updatedAt: number, /** The intended audience for the access token. */ readonly aud?: string[], + /** The actor chain of a token issued by an OAuth 2.0 Token Exchange (RFC 8693 section 4.1). */ + readonly act?: IdPOAuthAccessTokenActorJSON, ) {} static fromJSON(data: IdPOAuthAccessTokenJSON) { @@ -44,6 +47,7 @@ export class IdPOAuthAccessToken { data.created_at, data.updated_at, data.aud, + data.act, ); } @@ -69,6 +73,7 @@ export class IdPOAuthAccessToken { payload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim payload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat oauthPayload.aud === undefined ? undefined : [oauthPayload.aud].flat(), + oauthPayload.act, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index ca744df6376..61d6ea6544a 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -968,6 +968,13 @@ export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { created_at: number; updated_at: number; aud?: string[]; + act?: IdPOAuthAccessTokenActorJSON; +} + +export interface IdPOAuthAccessTokenActorJSON { + iss?: string; + sub: string; + act?: IdPOAuthAccessTokenActorJSON; } export interface BillingPayerJSON extends ClerkResourceJSON { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 5cbc3628071..015c48b7eaf 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -280,6 +280,44 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.aud).toEqual(aud); }); + describe.each(['opaque', 'at+jwt'] as const)('%s OAuth token actor', format => { + const act = { + iss: 'https://clerk.oauth.example.test', + sub: 'client_exchanging', + act: { iss: 'https://clerk.oauth.example.test', sub: 'client_subject' }, + }; + + beforeEach(() => { + vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000)); + }); + + it.each([{ act: undefined }, { act: { sub: 'client_exchanging' } }, { act }])( + 'returns act=$act', + async ({ act }) => { + let token: string; + if (format === 'opaque') { + token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => + HttpResponse.json({ ...mockVerificationResults.oauth_token, ...(act === undefined ? {} : { act }) }), + ), + ); + } else { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, act }, format); + } + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).act).toEqual(act); + }, + ); + }); + describe.each(['opaque', 'at+jwt', 'application/at+jwt'] as const)('%s OAuth token audience verification', format => { const audience = 'https://resource.example.com'; const otherAudience = 'https://other.example.com'; From 0c1aedbcef7092d7fcf87ab607bcc3aa61364ed2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 28 Sep 2026 12:44:12 -0600 Subject: [PATCH 2/2] fix(backend): normalize the act claim on verified OAuth access tokens --- .../src/api/resources/IdPOAuthAccessToken.ts | 22 +++++- .../src/tokens/__tests__/verify.test.ts | 67 +++++++++++++------ 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 358e72d163c..95f7caddc88 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -8,9 +8,25 @@ type OAuthJwtPayload = JwtPayload & { client_id?: string; scope?: string; scp?: string[]; - act?: IdPOAuthAccessTokenActorJSON; + act?: unknown; }; +function toActor(value: unknown, nestedLevels: number): IdPOAuthAccessTokenActorJSON | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const { iss, sub, act } = value as Record; + if (typeof sub !== 'string') { + return undefined; + } + const nested = nestedLevels > 0 ? toActor(act, nestedLevels - 1) : undefined; + return { + ...(typeof iss === 'string' ? { iss } : {}), + sub, + ...(nested ? { act: nested } : {}), + }; +} + export class IdPOAuthAccessToken { constructor( readonly id: string, @@ -47,7 +63,7 @@ export class IdPOAuthAccessToken { data.created_at, data.updated_at, data.aud, - data.act, + toActor(data.act, 1), ); } @@ -73,7 +89,7 @@ export class IdPOAuthAccessToken { payload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim payload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat oauthPayload.aud === undefined ? undefined : [oauthPayload.aud].flat(), - oauthPayload.act, + toActor(oauthPayload.act, 1), ); } } diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 015c48b7eaf..6ff8e915a52 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -280,8 +280,8 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.aud).toEqual(aud); }); - describe.each(['opaque', 'at+jwt'] as const)('%s OAuth token actor', format => { - const act = { + describe.each(['opaque', 'at+jwt', 'application/at+jwt'] as const)('%s OAuth token actor', format => { + const chainedAct = { iss: 'https://clerk.oauth.example.test', sub: 'client_exchanging', act: { iss: 'https://clerk.oauth.example.test', sub: 'client_subject' }, @@ -291,29 +291,54 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000)); }); - it.each([{ act: undefined }, { act: { sub: 'client_exchanging' } }, { act }])( + async function verifyWithAct(act: unknown) { + let token: string; + if (format === 'opaque') { + token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => + HttpResponse.json({ + object: 'clerk_idp_oauth_access_token', + ...mockVerificationResults.oauth_token, + ...(act === undefined ? {} : { act }), + }), + ), + ); + } else { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, act }, format); + } + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeUndefined(); + return (result.data as IdPOAuthAccessToken).act; + } + + it.each([{ act: undefined }, { act: { sub: 'client_exchanging' } }, { act: chainedAct }])( 'returns act=$act', async ({ act }) => { - let token: string; - if (format === 'opaque') { - token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; - server.use( - http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => - HttpResponse.json({ ...mockVerificationResults.oauth_token, ...(act === undefined ? {} : { act }) }), - ), - ); - } else { - server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); - token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, act }, format); - } + expect(await verifyWithAct(act)).toEqual(act); + }, + ); - const result = await verifyMachineAuthToken(token, { - apiUrl: 'https://api.clerk.test', - secretKey: 'a-valid-key', - }); + it('keeps only iss, sub, and one nested actor', async () => { + const act = await verifyWithAct({ + ...chainedAct, + extra: 'dropped', + act: { ...chainedAct.act, extra: 'dropped', act: { sub: 'client_deeper' } }, + }); - expect(result.errors).toBeUndefined(); - expect((result.data as IdPOAuthAccessToken).act).toEqual(act); + expect(act).toStrictEqual(chainedAct); + }); + + it.each([{ act: 'client_exchanging' }, { act: { iss: 'https://clerk.oauth.example.test' } }, { act: { sub: 42 } }])( + 'drops malformed act=$act', + async ({ act }) => { + expect(await verifyWithAct(act)).toBeUndefined(); }, ); });