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/session-exchange.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/schemas/authEvent/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
52 changes: 52 additions & 0 deletions src/schemas/oidcApplication/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
26 changes: 26 additions & 0 deletions src/schemas/oidcApplication/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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');
}
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
70 changes: 69 additions & 1 deletion src/schemas/session/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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');
});
});
47 changes: 47 additions & 0 deletions src/schemas/session/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { RefreshSuccessResponseSchema } from '../auth/auth.schema.js';

export const SessionSchema = z.object({
id: z.string(),
Expand All @@ -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<typeof SessionSchema>;
Expand All @@ -24,3 +31,43 @@ export const SessionListResponseSchema = z.object({
});

export type SessionListResponse = z.infer<typeof SessionListResponseSchema>;

/**
* `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<typeof SessionExchangeRequestSchema>;

/** 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<typeof SessionExchangeResponseSchema>;

/**
* 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<typeof SessionExchangeErrorSchema>;
Loading