diff --git a/.changeset/session-exchange.md b/.changeset/session-exchange.md new file mode 100644 index 0000000..21f7c53 --- /dev/null +++ b/.changeset/session-exchange.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/types': minor +--- + +Add the session exchange contract from ADR 0003 in seamless-auth-api: `sessionExchangeEnabled` on OpenID Provider applications (accepted only on a first-party public native client), `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on `SessionSchema`, the `POST /sessions/exchange` request, response and error schemas, the `session:exchange` scope and token type constants, and the `session_exchanged`, `session_exchange_replayed` and `admin_oidc_application_session_exchange_updated` auth events. `OidcApplicationSchema` now requires `sessionExchangeEnabled`, so a consumer of an older API has to treat it as absent. diff --git a/src/schemas/authEvent/schema.ts b/src/schemas/authEvent/schema.ts index c9f2a27..04964a7 100644 --- a/src/schemas/authEvent/schema.ts +++ b/src/schemas/authEvent/schema.ts @@ -19,6 +19,7 @@ export const AUTH_EVENT_TYPES = [ 'admin_oidc_application_pkce_updated', 'admin_oidc_application_resources_updated', 'admin_oidc_application_secret_rotated', + 'admin_oidc_application_session_exchange_updated', 'admin_oidc_application_updated', 'admin_oidc_resource_created', 'admin_oidc_resource_deleted', @@ -101,6 +102,8 @@ export const AUTH_EVENT_TYPES = [ 'service_token_success', 'service_token_suspicious', 'session_evicted', + 'session_exchange_replayed', + 'session_exchanged', 'step_up_challenge', 'step_up_failed', 'step_up_success', diff --git a/src/schemas/oidcApplication/schema.test.ts b/src/schemas/oidcApplication/schema.test.ts index 33a9166..3532493 100644 --- a/src/schemas/oidcApplication/schema.test.ts +++ b/src/schemas/oidcApplication/schema.test.ts @@ -356,3 +356,55 @@ describe('HostedUiSchema', () => { expect(SystemConfigPatchSchema.parse({ hosted_ui: null })).toEqual({ hosted_ui: null }); }); }); + +describe('sessionExchangeEnabled', () => { + const nativeClient = { + name: 'CLI', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + }; + + it('defaults to off', () => { + const parsed = OidcApplicationCreateRequestSchema.parse({ + ...nativeClient, + sessionExchangeEnabled: undefined, + }); + expect(parsed.sessionExchangeEnabled).toBe(false); + }); + + it('is allowed on a first-party public client with a loopback or private-use redirect', () => { + expect(OidcApplicationCreateRequestSchema.safeParse(nativeClient).success).toBe(true); + expect( + OidcApplicationCreateRequestSchema.safeParse({ + ...nativeClient, + redirectUris: ['com.example.cli:/callback'], + }).success, + ).toBe(true); + }); + + it.each([ + ['a third-party client', { firstParty: false }], + [ + 'a confidential client', + { + clientType: 'confidential', + tokenEndpointAuthMethod: 'private_key_jwt', + jwksUri: 'https://app.example.com/jwks', + }, + ], + ['a web client', { redirectUris: ['https://app.example.com/callback'] }], + ['a client with no redirect', { redirectUris: [], grantTypes: [], deviceFlowEnabled: true }], + ])('is refused on %s', (_name, overrides) => { + const parsed = OidcApplicationCreateRequestSchema.safeParse({ ...nativeClient, ...overrides }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues.map((issue) => issue.path[0])).toContain('sessionExchangeEnabled'); + }); + + it('can be named on an update', () => { + expect(OidcApplicationUpdateRequestSchema.parse({ sessionExchangeEnabled: false })).toEqual({ + sessionExchangeEnabled: false, + }); + }); +}); diff --git a/src/schemas/oidcApplication/schema.ts b/src/schemas/oidcApplication/schema.ts index a02ed98..bef30b3 100644 --- a/src/schemas/oidcApplication/schema.ts +++ b/src/schemas/oidcApplication/schema.ts @@ -158,6 +158,11 @@ const applicationShape = { deviceFlowEnabled: z.boolean(), /** Lets the application send account notices to its own users (ADR D14). */ noticesEnabled: z.boolean(), + /** + * Lets a first-party public native client exchange a provider sign-in for a first-party + * session (ADR 0003). Turning it off revokes the sessions it produced. + */ + sessionExchangeEnabled: z.boolean(), /** * PKCE is required by default. A confidential client may opt out (RFC 9700 section * 2.1.1), and then must send `nonce` on every request for an ID token instead, unless @@ -182,12 +187,18 @@ type ApplicationChecked = { redirectUris: string[]; grantTypes: OidcApplicationGrantType[]; deviceFlowEnabled: boolean; + sessionExchangeEnabled?: boolean | undefined; pkceRequired?: boolean | undefined; nonceRequired?: boolean | undefined; jwks?: OidcApplicationJwks | null | undefined; jwksUri?: string | null | undefined; }; +function isNativeRedirectUri(uri: string) { + const kind = oidcRedirectUriKind(uri); + return kind === 'loopback' || kind === 'private_use'; +} + export function defaultOidcTokenEndpointAuthMethod(clientType: OidcClientType) { return clientType === 'public' ? ('none' as const) : ('private_key_jwt' as const); } @@ -234,6 +245,18 @@ export function checkOidcApplication(app: ApplicationChecked, ctx: z.RefinementC if (app.deviceFlowEnabled && !app.firstParty) { issue('deviceFlowEnabled', 'the device grant is only available to first-party clients'); } + if ( + app.sessionExchangeEnabled && + (!app.firstParty || + app.clientType !== 'public' || + method !== 'none' || + !app.redirectUris.some(isNativeRedirectUri)) + ) { + issue( + 'sessionExchangeEnabled', + 'session exchange is only available to a first-party public native client', + ); + } if (app.pkceRequired === false && (app.clientType === 'public' || method === 'none')) { issue('pkceRequired', 'public clients must use PKCE'); } @@ -252,6 +275,7 @@ export const OidcApplicationCreateRequestSchema = z grantTypes: applicationShape.grantTypes.default(['authorization_code', 'refresh_token']), deviceFlowEnabled: applicationShape.deviceFlowEnabled.default(false), noticesEnabled: applicationShape.noticesEnabled.default(false), + sessionExchangeEnabled: applicationShape.sessionExchangeEnabled.default(false), pkceRequired: applicationShape.pkceRequired.default(true), nonceRequired: applicationShape.nonceRequired.default(true), backchannelLogoutUri: applicationShape.backchannelLogoutUri.optional(), @@ -281,6 +305,7 @@ export const OidcApplicationUpdateRequestSchema = z grantTypes: applicationShape.grantTypes, deviceFlowEnabled: applicationShape.deviceFlowEnabled, noticesEnabled: applicationShape.noticesEnabled, + sessionExchangeEnabled: applicationShape.sessionExchangeEnabled, pkceRequired: applicationShape.pkceRequired, nonceRequired: applicationShape.nonceRequired, backchannelLogoutUri: applicationShape.backchannelLogoutUri, @@ -322,6 +347,7 @@ export const OidcApplicationSchema = z.object({ grantTypes: z.array(OidcApplicationGrantTypeSchema), deviceFlowEnabled: z.boolean(), noticesEnabled: z.boolean(), + sessionExchangeEnabled: z.boolean(), pkceRequired: z.boolean(), nonceRequired: z.boolean(), jwks: OidcApplicationJwksSchema.nullable(), diff --git a/src/schemas/session/schema.test.ts b/src/schemas/session/schema.test.ts index cf724e6..20a7148 100644 --- a/src/schemas/session/schema.test.ts +++ b/src/schemas/session/schema.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { SessionSchema } from './schema.js'; +import { + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, + SessionSchema, +} from './schema.js'; const now = new Date().toISOString(); const later = new Date(Date.now() + 1000 * 60 * 60).toISOString(); @@ -81,3 +85,67 @@ describe('SessionSchema', () => { ).not.toThrow(); }); }); + +describe('exchanged sessions', () => { + const base = { + id: 's1', + lastUsedAt: '2026-11-04T00:00:00.000Z', + expiresAt: '2026-11-05T00:00:00.000Z', + current: false, + }; + + it('keeps the origin of a child session in a session list', () => { + const origin = { + parentSessionId: 'p1', + exchangeClientId: 'cli', + exchangeGrantType: 'urn:ietf:params:oauth:grant-type:device_code', + }; + expect(SessionSchema.parse({ ...base, ...origin })).toMatchObject(origin); + }); + + it('still parses a session with no origin', () => { + expect(SessionSchema.parse(base)).toEqual(base); + }); +}); + +describe('SessionExchangeRequestSchema', () => { + const request = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: 'a.b.c', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }; + + it('accepts the RFC 8693 parameters, with or without a requested token type', () => { + expect(SessionExchangeRequestSchema.safeParse(request).success).toBe(true); + expect( + SessionExchangeRequestSchema.safeParse({ + ...request, + requested_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }).success, + ).toBe(true); + }); + + it.each([ + ['another grant', { grant_type: 'refresh_token' }], + ['another subject token type', { subject_token_type: 'urn:ietf:params:oauth:token-type:jwt' }], + ['another requested type', { requested_token_type: 'urn:ietf:params:oauth:token-type:jwt' }], + ['an empty token', { subject_token: '' }], + ['an actor token', { actor_token: 'a.b.c' }], + ])('refuses %s', (_name, overrides) => { + expect(SessionExchangeRequestSchema.safeParse({ ...request, ...overrides }).success).toBe( + false, + ); + }); +}); + +describe('SessionExchangeResponseSchema', () => { + it('is the refresh response plus the issued token type', () => { + const parsed = SessionExchangeResponseSchema.parse({ + message: 'Success', + token: 'jwt', + refreshToken: 'opaque', + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }); + expect(parsed.issued_token_type).toBe('urn:ietf:params:oauth:token-type:refresh_token'); + }); +}); diff --git a/src/schemas/session/schema.ts b/src/schemas/session/schema.ts index 9c06aac..9a07dd8 100644 --- a/src/schemas/session/schema.ts +++ b/src/schemas/session/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { RefreshSuccessResponseSchema } from '../auth/auth.schema.js'; export const SessionSchema = z.object({ id: z.string(), @@ -8,6 +9,12 @@ export const SessionSchema = z.object({ lastUsedAt: z.string(), expiresAt: z.string(), current: z.boolean(), + /** The session a token exchange linked this one to. Absent on an ordinary session. */ + parentSessionId: z.string().nullable().optional(), + /** The native client that exchanged for this session. */ + exchangeClientId: z.string().nullable().optional(), + /** The grant behind the provider sign-in the exchange came from. */ + exchangeGrantType: z.string().nullable().optional(), }); export type Session = z.infer; @@ -24,3 +31,43 @@ export const SessionListResponseSchema = z.object({ }); export type SessionListResponse = z.infer; + +/** + * `POST /sessions/exchange` (ADR 0003): a native client turns the single-use provider access + * token it just received into a first-party session of its own. RFC 8693 parameter names, in + * the API's JSON body style. + */ +export const SESSION_EXCHANGE_SCOPE = 'session:exchange'; +export const SESSION_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +export const SESSION_EXCHANGE_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +export const SESSION_EXCHANGE_ISSUED_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:refresh_token'; + +export const SessionExchangeRequestSchema = z + .object({ + grant_type: z.literal(SESSION_EXCHANGE_GRANT_TYPE), + subject_token: z.string().min(1).max(8192), + subject_token_type: z.literal(SESSION_EXCHANGE_SUBJECT_TOKEN_TYPE), + requested_token_type: z.literal(SESSION_EXCHANGE_ISSUED_TOKEN_TYPE).optional(), + }) + .strict(); + +export type SessionExchangeRequest = z.infer; + +/** The `/refresh` response, plus `issued_token_type` (RFC 8693 section 2.2.1). */ +export const SessionExchangeResponseSchema = RefreshSuccessResponseSchema.extend({ + issued_token_type: z.literal(SESSION_EXCHANGE_ISSUED_TOKEN_TYPE), +}); + +export type SessionExchangeResponse = z.infer; + +/** + * RFC 8693 section 2.2.2. One body for every subject token failure, so the route cannot be + * used to probe sessions. `token_already_used` tells the client its sign-in may have been + * intercepted, which only it and a thief can know. + */ +export const SessionExchangeErrorSchema = z.object({ + error: z.literal('invalid_request'), + error_description: z.literal('token_already_used').optional(), +}); + +export type SessionExchangeError = z.infer;