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..95f7caddc88 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,8 +8,25 @@ type OAuthJwtPayload = JwtPayload & { client_id?: string; scope?: string; scp?: string[]; + 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, @@ -28,6 +45,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 +63,7 @@ export class IdPOAuthAccessToken { data.created_at, data.updated_at, data.aud, + toActor(data.act, 1), ); } @@ -69,6 +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(), + toActor(oauthPayload.act, 1), ); } } 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..6ff8e915a52 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -280,6 +280,69 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.aud).toEqual(aud); }); + 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' }, + }; + + beforeEach(() => { + vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000)); + }); + + 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 }) => { + expect(await verifyWithAct(act)).toEqual(act); + }, + ); + + 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(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(); + }, + ); + }); + 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';