From c8459aeed17d2ca7de7fd08a93ce0a7df459b770 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:39:07 -0500 Subject: [PATCH 01/20] test(oidc): cover the sessionExchangeEnabled client flag Refs #544 --- tests/postgres/sessionExchange.spec.ts | 431 +++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 tests/postgres/sessionExchange.spec.ts diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts new file mode 100644 index 0000000..3f35bde --- /dev/null +++ b/tests/postgres/sessionExchange.spec.ts @@ -0,0 +1,431 @@ +import { generateKeyPairSync, randomUUID } from 'crypto'; +import fs from 'fs'; +import { SignJWT } from 'jose'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; +import { AuthEvent } from '../../src/models/authEvents.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { OidcClient, type OidcClientAttributes } from '../../src/models/oidcClients.js'; +import { OidcPayload } from '../../src/models/oidcPayloads.js'; +import { Session, type SessionAttributes } from '../../src/models/sessions.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; +import { User } from '../../src/models/users.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +const API_ISSUER = 'http://localhost:5312'; +const PROVIDER_ISSUER = 'https://login.session-exchange.test'; +const BUILT_IN_RESOURCE = `${PROVIDER_ISSUER}/seamless`; +const KID = 'exchange-key'; +const AUTHORIZATION_CODE = 'authorization_code'; +const DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code'; +const SCOPE = 'session:exchange'; + +const providerKey = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + +type App = Awaited>; +type Signing = typeof import('../../src/services/oidcSigning.js'); +type Registry = typeof import('../../src/services/oidcClientRegistry.js'); +type Applications = typeof import('../../src/services/oidcApplicationService.js'); +type Store = typeof import('../../src/services/oidcPayloadStore.js'); +type Tokens = typeof import('../../src/lib/token.js'); + +let app: App; +let signing: Signing; +let registry: Registry; +let applications: Applications; +let store: Store; +let tokens: Tokens; +const savedEnv: Record = {}; + +function setEnv(values: Record) { + for (const [key, value] of Object.entries(values)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } +} + +async function setConfig(key: string, value: unknown) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + { replacements: { key, value: JSON.stringify(value) } }, + ); +} + +beforeAll(async () => { + setEnv({ + ISSUER: API_ISSUER, + ALLOW_UNCREDENTIALED_DELIVERY_SECRETS: 'true', + DISABLE_AUTH_RATE_LIMITS: 'true', + SEAMLESS_DEV_KEYS_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'seamless-keys-')), + OIDC_JWKS_ACTIVE_KID: KID, + OIDC_JWKS_KEYS: JSON.stringify({ + keys: [{ ...providerKey.export({ format: 'jwk' }), kid: KID }], + }), + }); + + await initializeModels(); + + for (const [key, value] of Object.entries(buildSystemConfig())) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + await setConfig('oidc_issuer', PROVIDER_ISSUER); + await setConfig('max_concurrent_sessions', null); + invalidateSystemConfigCache(); + + signing = await import('../../src/services/oidcSigning.js'); + registry = await import('../../src/services/oidcClientRegistry.js'); + applications = await import('../../src/services/oidcApplicationService.js'); + store = await import('../../src/services/oidcPayloadStore.js'); + tokens = await import('../../src/lib/token.js'); + + const { initDevSigningKey } = await import('../../src/utils/signingKeyStore.js'); + const { createApp } = await import('../../src/app.js'); + initDevSigningKey(); + app = await createApp(); +}); + +afterAll(async () => { + await SystemConfig.destroy({ where: { key: ['oidc_issuer', 'max_concurrent_sessions'] } }); + await getSequelize().query(`DELETE FROM users WHERE email LIKE :pattern`, { + replacements: { pattern: '%@session-exchange.test' }, + }); + invalidateSystemConfigCache(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await getSequelize().close(); +}); + +beforeEach(() => { + invalidateSystemConfigCache(); +}); + +function nativeClient(overrides: Partial = {}) { + return OidcClient.create({ + id: `cli-${randomUUID()}`, + name: 'Seamless CLI', + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: [AUTHORIZATION_CODE, DEVICE_CODE], + deviceFlowEnabled: true, + sessionExchangeEnabled: true, + ...overrides, + }); +} + +function createUser(roles: string[] = ['user']) { + return User.create({ + email: `${randomUUID()}@session-exchange.test`, + phone: null, + roles, + } as never); +} + +function createSession(userId: string, overrides: Partial = {}) { + const now = Date.now(); + return Session.create({ + userId, + mode: 'server', + refreshTokenLookup: randomUUID().replaceAll('-', ''), + expiresAt: new Date(now + 3_600_000), + idleExpiresAt: new Date(now + 3_600_000), + lastUsedAt: new Date(now), + chainStartedAt: new Date(now), + ...overrides, + }); +} + +/** The first-party session a provider sign-in produced for the client (ADR 0001, D3). */ +async function providerSignIn( + client: OidcClient, + user: User, + overrides: Partial = {}, +) { + const parent = await createSession(user.id, overrides); + await store.bindOidcSession(parent.id, user.id, client.id); + await store.upsertOidcPayload('Grant', randomUUID(), { + payload: { accountId: user.id, clientId: client.id }, + expiresIn: 3600, + sessionId: parent.id, + }); + return parent; +} + +function accessTokenFor(session: Session, roles: string[] = ['user']) { + return tokens.signAccessToken(session.id, session.userId, roles, null); +} + +function jwtInput(header: Record, claims: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode(header)}.${encode(claims)}`; +} + +function subjectClaims(client: OidcClient, userId: string, overrides = {}) { + const now = Math.floor(Date.now() / 1000); + return { + iss: PROVIDER_ISSUER, + sub: userId, + aud: BUILT_IN_RESOURCE, + exp: now + 60, + iat: now, + jti: randomUUID(), + client_id: client.id, + scope: SCOPE, + ...overrides, + }; +} + +/** Has `/oidc/sign` sign an access token, the way the edge asks for one. */ +async function signSubject( + client: OidcClient, + parent: Session, + { grantType = AUTHORIZATION_CODE, claims = {} } = {}, +) { + const body = subjectClaims(client, parent.userId, claims); + const signingInput = jwtInput({ alg: 'RS256', kid: KID, typ: 'at+jwt' }, body); + const outcome = await signing.signForOidcEdge({ + kind: 'access_token', + kid: KID, + clientId: client.id, + sessionId: parent.id, + grantType, + signingInput, + } as never); + return { + outcome, + jti: body.jti as string, + token: outcome.ok ? `${signingInput}.${outcome.signature}` : '', + }; +} + +async function admin(roles = ['admin:write', 'admin:read'], stepUp = true) { + const user = await createUser(roles); + const session = await createSession(user.id, stepUp ? { stepUpVerifiedAt: new Date() } : {}); + return { user, session, token: await accessTokenFor(session, roles) }; +} + +describe('the sessionExchangeEnabled registry flag', () => { + it('adds the built-in resource for an eligible client, with a 60 second lifetime', async () => { + const client = await nativeClient(); + + expect(await registry.getOidcClientResources(client.id)).toEqual([ + { identifier: BUILT_IN_RESOURCE, scopes: [SCOPE], accessTokenTtlSeconds: 60 }, + ]); + }); + + it('keeps one built-in entry when notices are on as well', async () => { + const client = await nativeClient({ noticesEnabled: true }); + const resources = await registry.getOidcClientResources(client.id); + + expect(resources).toHaveLength(1); + expect(resources[0].scopes.sort()).toEqual(['notices:send', SCOPE]); + expect(resources[0].accessTokenTtlSeconds).toBe(60); + }); + + it.each([ + ['the flag is off', { sessionExchangeEnabled: false }], + ['the client is third-party', { firstParty: false }], + [ + 'the client is confidential', + { clientType: 'confidential', tokenEndpointAuthMethod: 'private_key_jwt' }, + ], + ['the client is a web client', { redirectUris: ['https://app.example.com/callback'] }], + ['the client is disabled', { disabledAt: new Date() }], + ] as [string, Partial][])( + 'lists nothing when %s, whatever is stored', + async (_name, overrides) => { + const client = await nativeClient(overrides); + + expect(await registry.getOidcClientResources(client.id)).toEqual([]); + }, + ); + + it('is off by default and refused on a client that is not first-party public native', async () => { + const { client } = await applications.createOidcApplication({ + name: 'Default', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: ['authorization_code'], + } as never); + expect(client.sessionExchangeEnabled).toBe(false); + + const web = await nativeClient({ + sessionExchangeEnabled: false, + redirectUris: ['https://app.example.com/callback'], + }); + await expect( + applications.updateOidcApplication(web.id, { sessionExchangeEnabled: true }), + ).rejects.toBeInstanceOf(applications.OidcApplicationInvalidError); + await web.reload(); + expect(web.sessionExchangeEnabled).toBe(false); + }); + + it('refuses an update that would make an enabled client ineligible', async () => { + const client = await nativeClient(); + + await expect( + applications.updateOidcApplication(client.id, { + redirectUris: ['https://app.example.com/callback'], + }), + ).rejects.toBeInstanceOf(applications.OidcApplicationInvalidError); + await expect( + applications.updateOidcApplication(client.id, { firstParty: false }), + ).rejects.toBeInstanceOf(applications.OidcApplicationError); + }); + + async function exchangedSessions(client: OidcClient) { + const user = await createUser(); + const parent = await createSession(user.id); + const child = await createSession(user.id, { + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + const rotated = await createSession(user.id, { + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + return { parent, child, rotated }; + } + + it('revokes what the client exchanged for when the flag goes off, and only that', async () => { + const client = await nativeClient(); + const other = await nativeClient(); + const { parent, child, rotated } = await exchangedSessions(client); + const unrelated = await exchangedSessions(other); + + await applications.updateOidcApplication(client.id, { sessionExchangeEnabled: false }); + + await child.reload(); + await rotated.reload(); + await parent.reload(); + await unrelated.child.reload(); + expect(child.revokedReason).toBe('session_exchange_disabled'); + expect(rotated.revokedAt).not.toBeNull(); + expect(parent.revokedAt).toBeNull(); + expect(unrelated.child.revokedAt).toBeNull(); + expect(await registry.getOidcClientResources(client.id)).toEqual([]); + }); + + it('revokes what the client exchanged for when the client is disabled', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.updateOidcApplication(client.id, { disabled: true }); + + await child.reload(); + expect(child.revokedReason).toBe('oidc_client_disabled'); + }); + + it('revokes what the client exchanged for when the client is deleted', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.deleteOidcApplication(client.id); + + await child.reload(); + expect(child.revokedReason).toBe('oidc_client_deleted'); + }); + + it('leaves exchanged sessions alone on an unrelated update', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.updateOidcApplication(client.id, { name: 'Renamed' }); + + await child.reload(); + expect(child.revokedAt).toBeNull(); + }); + + describe('on the admin routes', () => { + it('needs a step-up, and audits the change', async () => { + const client = await nativeClient({ sessionExchangeEnabled: false }); + const noStepUp = await admin(['admin:write'], false); + + const refused = await request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${noStepUp.token}`) + .send({ sessionExchangeEnabled: true }); + expect(refused.status).toBe(403); + expect(refused.body.error).toBe('step_up_required'); + + const stepped = await admin(); + const res = await request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${stepped.token}`) + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(200); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + const events = await AuthEvent.findAll({ + where: { type: 'admin_oidc_application_session_exchange_updated' }, + }); + expect( + events.some( + (event) => + (event.metadata as Record)?.clientId === client.id && + (event.metadata as Record)?.sessionExchangeEnabled === true, + ), + ).toBe(true); + }); + + it('answers 400 for a client that is not eligible', async () => { + const web = await nativeClient({ + sessionExchangeEnabled: false, + redirectUris: ['https://app.example.com/callback'], + }); + const { token } = await admin(); + + const res = await request(app) + .patch(`/admin/applications/${web.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(400); + }); + + it('audits an application created with the flag on', async () => { + const { token } = await admin(); + + const res = await request(app) + .post('/admin/applications') + .set('Authorization', `Bearer ${token}`) + .send({ + name: 'Created', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: ['authorization_code'], + sessionExchangeEnabled: true, + }); + + expect(res.status).toBe(201); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + const events = await AuthEvent.findAll({ + where: { type: 'admin_oidc_application_session_exchange_updated' }, + }); + expect( + events.some( + (event) => + (event.metadata as Record)?.clientId === res.body.application.id, + ), + ).toBe(true); + }); + }); +}); From 52d5eb67a2ceb7a206d270eb66155ed9de9e2547 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:40:37 -0500 Subject: [PATCH 02/20] feat(oidc): add the sessionExchangeEnabled client flag An admin can turn token exchange on for a first-party public native client. Such a client gets the built-in resource with session:exchange and a 60 second token lifetime. Turning it off, or disabling or deleting the client, revokes the sessions it exchanged for. Refs #544 --- openapi.json | 47 ++++++++++++++--- resources/coverage-badge.svg | 8 +-- src/controllers/oidcApplications.ts | 21 ++++++-- src/generated/api.ts | 34 +++++++++++-- .../20261104120000-add-session-exchange.cjs | 50 +++++++++++++++++++ src/models/oidcClients.ts | 5 ++ src/models/sessions.ts | 23 +++++++++ src/routes/oidcApplications.routes.ts | 6 +-- src/schemas/authEvent.types.ts | 1 + src/services/oidcApplicationService.ts | 22 ++++++-- src/services/oidcClientRegistry.ts | 33 ++++++++++-- src/services/sessionRevocation.ts | 20 ++++++++ .../integration/oidc/oidcApplications.spec.ts | 1 + .../integration/oidc/oidcClientLinks.spec.ts | 1 + 14 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 src/migrations/20261104120000-add-session-exchange.cjs diff --git a/openapi.json b/openapi.json index eac67b8..fec806f 100644 --- a/openapi.json +++ b/openapi.json @@ -5287,6 +5287,7 @@ "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", @@ -5412,6 +5413,7 @@ "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", @@ -6407,7 +6409,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -6949,7 +6954,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -7058,7 +7066,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -14053,6 +14064,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14103,6 +14115,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14157,7 +14170,7 @@ }, "post": { "summary": "Create an OpenID Provider application", - "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.", + "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003).", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "requestBody": { @@ -14196,6 +14209,7 @@ }, "deviceFlowEnabled": { "type": "boolean", "default": false }, "noticesEnabled": { "type": "boolean", "default": false }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean", "default": true }, "nonceRequired": { "type": "boolean", "default": true }, "backchannelLogoutUri": { @@ -14276,6 +14290,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14328,6 +14343,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14378,6 +14394,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14536,6 +14553,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14587,6 +14605,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14637,6 +14656,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14744,7 +14764,7 @@ }, "patch": { "summary": "Update an OpenID Provider application", - "description": "Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens.", + "description": "Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -14787,6 +14807,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "backchannelLogoutUri": { @@ -14843,6 +14864,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14894,6 +14916,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14944,6 +14967,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15084,7 +15108,7 @@ }, "delete": { "summary": "Delete an OpenID Provider application", - "description": "Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.", + "description": "Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -15360,6 +15384,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15412,6 +15437,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15462,6 +15488,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15681,6 +15708,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15732,6 +15760,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15782,6 +15811,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -22257,7 +22287,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index fdefd25..c931b02 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.3% + + coverage: 99.2% @@ -17,7 +17,7 @@ coverage coverage - 99.3% - 99.3% + 99.2% + 99.2% diff --git a/src/controllers/oidcApplications.ts b/src/controllers/oidcApplications.ts index 0fdd7a0..0cd1712 100644 --- a/src/controllers/oidcApplications.ts +++ b/src/controllers/oidcApplications.ts @@ -82,6 +82,7 @@ export async function createApplication(req: Request, res: Response) { tokenEndpointAuthMethod: application.tokenEndpointAuthMethod, grantTypes: application.grantTypes, deviceFlowEnabled: application.deviceFlowEnabled, + sessionExchangeEnabled: application.sessionExchangeEnabled, pkceRequired: application.pkceRequired, nonceRequired: application.nonceRequired, redirectUris: application.redirectUris, @@ -89,6 +90,12 @@ export async function createApplication(req: Request, res: Response) { }, }); + if (application.sessionExchangeEnabled) { + await audit(req, { + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: client.id, sessionExchangeEnabled: true }, + }); + } if (!application.pkceRequired) { await audit(req, { type: 'admin_oidc_application_pkce_updated', @@ -110,11 +117,19 @@ export async function createApplication(req: Request, res: Response) { export async function updateApplication(req: Request, res: Response) { try { - const { pkceRequired: pkceBefore, nonceRequired: nonceBefore } = await findOidcApplication( - req.params.id as string, - ); + const { + pkceRequired: pkceBefore, + nonceRequired: nonceBefore, + sessionExchangeEnabled: sessionExchangeBefore, + } = await findOidcApplication(req.params.id as string); const client = await updateOidcApplication(req.params.id as string, req.body); + if (client.sessionExchangeEnabled !== sessionExchangeBefore) { + await audit(req, { + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: client.id, sessionExchangeEnabled: client.sessionExchangeEnabled }, + }); + } if (client.pkceRequired !== pkceBefore) { await audit(req, { type: 'admin_oidc_application_pkce_updated', diff --git a/src/generated/api.ts b/src/generated/api.ts index c8fad78..3122a2e 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4976,6 +4976,7 @@ export interface paths { | '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' @@ -5095,6 +5096,7 @@ export interface paths { | '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' @@ -5911,6 +5913,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; credentials: { id: string; @@ -6625,6 +6630,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; @@ -6747,6 +6755,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; @@ -14009,6 +14020,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14079,7 +14091,7 @@ export interface paths { put?: never; /** * Create an OpenID Provider application - * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. + * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003). */ post: { parameters: { @@ -14114,6 +14126,8 @@ export interface paths { deviceFlowEnabled?: boolean; /** @default false */ noticesEnabled?: boolean; + /** @default false */ + sessionExchangeEnabled?: boolean; /** @default true */ pkceRequired?: boolean; /** @default true */ @@ -14168,6 +14182,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14214,6 +14229,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14392,6 +14408,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14437,6 +14454,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14556,7 +14574,7 @@ export interface paths { post?: never; /** * Delete an OpenID Provider application - * @description Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it. + * @description Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for. */ delete: { parameters: { @@ -14774,7 +14792,7 @@ export interface paths { head?: never; /** * Update an OpenID Provider application - * @description Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. + * @description Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for. */ patch: { parameters: { @@ -14798,6 +14816,7 @@ export interface paths { grantTypes?: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled?: boolean; noticesEnabled?: boolean; + sessionExchangeEnabled?: boolean; pkceRequired?: boolean; nonceRequired?: boolean; /** Format: uri */ @@ -14837,6 +14856,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14882,6 +14902,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -15078,6 +15099,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15124,6 +15146,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -15345,6 +15368,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15390,6 +15414,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -21777,6 +21802,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; diff --git a/src/migrations/20261104120000-add-session-exchange.cjs b/src/migrations/20261104120000-add-session-exchange.cjs new file mode 100644 index 0000000..b9c39fa --- /dev/null +++ b/src/migrations/20261104120000-add-session-exchange.cjs @@ -0,0 +1,50 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * A native client can exchange a provider sign-in for a first-party session of its own, + * linked to the sign-in's session as its parent (ADR 0003). The link cascades on delete: a + * child whose parent row is gone must end, and clearing the link would turn it into an + * ordinary session with nothing bounding it. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.oidc_clients + ADD COLUMN IF NOT EXISTS "sessionExchangeEnabled" boolean DEFAULT false NOT NULL; + + ALTER TABLE public.sessions + ADD COLUMN IF NOT EXISTS "parentSessionId" uuid + REFERENCES public.sessions(id) ON UPDATE CASCADE ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS "exchangeClientId" character varying(255), + ADD COLUMN IF NOT EXISTS "exchangeGrantType" character varying(255); + + CREATE INDEX IF NOT EXISTS sessions_parent_session_idx + ON public.sessions ("parentSessionId") WHERE "parentSessionId" IS NOT NULL; + + CREATE INDEX IF NOT EXISTS sessions_exchange_client_idx + ON public.sessions ("exchangeClientId") WHERE "exchangeClientId" IS NOT NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX IF EXISTS public.sessions_exchange_client_idx; + DROP INDEX IF EXISTS public.sessions_parent_session_idx; + + ALTER TABLE public.sessions + DROP COLUMN IF EXISTS "exchangeGrantType", + DROP COLUMN IF EXISTS "exchangeClientId", + DROP COLUMN IF EXISTS "parentSessionId"; + + ALTER TABLE public.oidc_clients DROP COLUMN IF EXISTS "sessionExchangeEnabled"; + `); + }, +}; diff --git a/src/models/oidcClients.ts b/src/models/oidcClients.ts index 295ee41..5351148 100644 --- a/src/models/oidcClients.ts +++ b/src/models/oidcClients.ts @@ -30,6 +30,8 @@ export interface OidcClientAttributes { refreshTokenTtlSeconds?: number | null; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** Lets a first-party public native client exchange a sign-in for a session (ADR 0003). */ + sessionExchangeEnabled: boolean; /** https origins an account notice's action link may point at, besides https redirects. */ noticeActionOrigins: string[]; pkceRequired: boolean; @@ -54,6 +56,7 @@ type OidcClientCreationAttributes = Optional< | 'refreshTokenTtlSeconds' | 'deviceFlowEnabled' | 'noticesEnabled' + | 'sessionExchangeEnabled' | 'noticeActionOrigins' | 'pkceRequired' | 'nonceRequired' @@ -81,6 +84,7 @@ export class OidcClient declare refreshTokenTtlSeconds: number | null; declare deviceFlowEnabled: boolean; declare noticesEnabled: boolean; + declare sessionExchangeEnabled: boolean; declare noticeActionOrigins: string[]; declare pkceRequired: boolean; declare nonceRequired: boolean; @@ -113,6 +117,7 @@ const initializeOidcClientModel = (sequelize: Sequelize) => { refreshTokenTtlSeconds: { type: DataTypes.INTEGER, allowNull: true }, deviceFlowEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, noticesEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + sessionExchangeEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, noticeActionOrigins: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, pkceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, nonceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, diff --git a/src/models/sessions.ts b/src/models/sessions.ts index fe3cb81..91483b9 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -29,6 +29,9 @@ export interface SessionAttributes { authMethods?: SessionAuthMethod[]; authTime?: Date | null; replacedBySessionId?: string | null; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; revokedAt?: Date | null; revokedReason?: string | null; createdAt?: Date; @@ -47,6 +50,9 @@ type SessionCreationAttributes = Optional< | 'stepUpMethod' | 'authMethods' | 'authTime' + | 'parentSessionId' + | 'exchangeClientId' + | 'exchangeGrantType' >; export class Session @@ -72,6 +78,9 @@ export class Session declare authMethods: SessionAuthMethod[]; declare authTime: Date | null; declare replacedBySessionId: string | null; + declare parentSessionId: string | null; + declare exchangeClientId: string | null; + declare exchangeGrantType: string | null; declare revokedAt: Date | null; declare revokedReason: string | null; declare readonly createdAt: Date; @@ -157,6 +166,20 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.UUID, allowNull: true, }, + // A session a token exchange issued (ADR 0003): the provider sign-in it ends with, the + // client that exchanged, and the grant behind the sign-in. Carried across rotation. + parentSessionId: { + type: DataTypes.UUID, + allowNull: true, + }, + exchangeClientId: { + type: DataTypes.STRING(255), + allowNull: true, + }, + exchangeGrantType: { + type: DataTypes.STRING(255), + allowNull: true, + }, revokedAt: DataTypes.DATE, revokedReason: DataTypes.STRING, }, diff --git a/src/routes/oidcApplications.routes.ts b/src/routes/oidcApplications.routes.ts index 151e944..94c0925 100644 --- a/src/routes/oidcApplications.routes.ts +++ b/src/routes/oidcApplications.routes.ts @@ -69,7 +69,7 @@ router.post( ...write, summary: 'Create an OpenID Provider application', description: - 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.', + 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003).', schemas: { body: OidcApplicationCreateRequestSchema, response: { @@ -101,7 +101,7 @@ router.patch( ...write, summary: 'Update an OpenID Provider application', description: - 'Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens.', + 'Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for.', schemas: { params: OidcApplicationIdParamsSchema, body: OidcApplicationUpdateRequestSchema, @@ -122,7 +122,7 @@ router.delete( ...write, summary: 'Delete an OpenID Provider application', description: - 'Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.', + 'Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for.', schemas: { params: OidcApplicationIdParamsSchema, response: { 200: MessageSchema, 403: StepUpRequiredErrorSchema, 404: ErrorSchema }, diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 7c07266..d1ba37f 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -28,6 +28,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', diff --git a/src/services/oidcApplicationService.ts b/src/services/oidcApplicationService.ts index c4bb608..8641fe1 100644 --- a/src/services/oidcApplicationService.ts +++ b/src/services/oidcApplicationService.ts @@ -26,6 +26,7 @@ import type { } from '../schemas/oidcApplications.requests.js'; import type { OidcApplicationView } from '../schemas/oidcApplications.responses.js'; import { createOidcClientSecretLookup, isNativeRedirectUri } from './oidcClientRegistry.js'; +import { revokeSessionsExchangedBy } from './sessionRevocation.js'; export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; @@ -66,6 +67,7 @@ export function serializeOidcApplication(client: OidcClient): OidcApplication { ), deviceFlowEnabled: client.deviceFlowEnabled, noticesEnabled: client.noticesEnabled, + sessionExchangeEnabled: client.sessionExchangeEnabled, pkceRequired: client.pkceRequired, nonceRequired: client.nonceRequired, jwks: (client.jwks as OidcApplication['jwks']) ?? null, @@ -185,6 +187,7 @@ export async function createOidcApplication(input: OidcApplicationCreateRequest) refreshTokenTtlSeconds: input.refreshTokenTtlSeconds ?? null, deviceFlowEnabled: input.deviceFlowEnabled, noticesEnabled: input.noticesEnabled, + sessionExchangeEnabled: input.sessionExchangeEnabled, noticeActionOrigins: input.noticeActionOrigins, pkceRequired: input.pkceRequired, nonceRequired: input.nonceRequired, @@ -218,6 +221,7 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp grantTypes: current.grantTypes, deviceFlowEnabled: current.deviceFlowEnabled, noticesEnabled: current.noticesEnabled, + sessionExchangeEnabled: current.sessionExchangeEnabled, pkceRequired: current.pkceRequired, nonceRequired: current.nonceRequired, backchannelLogoutUri: current.backchannelLogoutUri, @@ -233,6 +237,8 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp if (!parsed.success) throw new OidcApplicationInvalidError(parsed.error); const next = parsed.data; + const wasExchanging = client.sessionExchangeEnabled; + const wasDisabled = Boolean(client.disabledAt); await client.update({ name: next.name, @@ -243,6 +249,7 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp grantTypes: storedGrantTypes(next.grantTypes, next.deviceFlowEnabled), deviceFlowEnabled: next.deviceFlowEnabled, noticesEnabled: next.noticesEnabled, + sessionExchangeEnabled: next.sessionExchangeEnabled, pkceRequired: next.pkceRequired, nonceRequired: next.nonceRequired, backchannelLogoutUri: next.backchannelLogoutUri ?? null, @@ -257,6 +264,14 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp : { disabledAt: disabled ? (client.disabledAt ?? new Date()) : null }), }); + // After the write, so an exchange that read the client before it is one the exchange's own + // re-read or this revoke catches, whichever comes last (ADR 0003). + if (!wasDisabled && client.disabledAt) { + await revokeSessionsExchangedBy(id, 'oidc_client_disabled'); + } else if (wasExchanging && !client.sessionExchangeEnabled) { + await revokeSessionsExchangedBy(id, 'session_exchange_disabled'); + } + return client; } @@ -294,14 +309,15 @@ export async function rotateOidcApplicationSecret(id: string) { } /** - * Deletes the application with its resource grants, its links in both directions and - * every provider record issued to it (grants, codes and tokens), so nothing it was given - * outlives it. + * Deletes the application with its resource grants, its links in both directions, every + * provider record issued to it (grants, codes and tokens) and the sessions it exchanged for, + * so nothing it was given outlives it. */ export async function deleteOidcApplication(id: string) { const client = await findOidcApplication(id); await OidcClient.sequelize!.transaction(async (transaction) => { + await revokeSessionsExchangedBy(id, 'oidc_client_deleted', transaction); await OidcPayload.destroy({ where: { payload: { clientId: id } }, transaction }); await OidcClientResource.destroy({ where: { clientId: id }, transaction }); await OidcClientLink.destroy({ diff --git a/src/services/oidcClientRegistry.ts b/src/services/oidcClientRegistry.ts index e9f3db2..1d7968f 100644 --- a/src/services/oidcClientRegistry.ts +++ b/src/services/oidcClientRegistry.ts @@ -11,6 +11,7 @@ import { type OidcPublicJwk, oidcRedirectUriKind, seamlessBuiltInResource, + SESSION_EXCHANGE_SCOPE, } from '@seamless-auth/types'; import { createHmac, timingSafeEqual } from 'crypto'; import { Op } from 'sequelize'; @@ -26,6 +27,7 @@ import { getDefaultOrganizationIdForUser } from './organizationService.js'; const SHARED_SECRET_METHODS = new Set(['client_secret_basic', 'client_secret_post']); const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +const SESSION_EXCHANGE_TOKEN_TTL_SECONDS = 60; /** * Keyed with the refresh token lookup secret, but over a prefixed input, so a client @@ -57,6 +59,20 @@ export function isNativeRedirectUri(uri: string) { return kind === 'loopback' || kind === 'private_use'; } +/** + * ADR 0003, X2. Checked on the client as stored rather than trusting the flag alone, so a + * row that reached this state some other way still cannot exchange. + */ +export function isSessionExchangeClient(client: OidcClient) { + return ( + client.sessionExchangeEnabled && + client.firstParty && + client.clientType === 'public' && + client.tokenEndpointAuthMethod === 'none' && + client.redirectUris.some(isNativeRedirectUri) + ); +} + export async function getOidcClientMetadata(id: string): Promise { const client = await findActiveOidcClient(id); @@ -128,19 +144,30 @@ export async function verifyOidcClientSecret(id: string, secret: string) { * The resources a client may ask an access token for, each with the scopes it is allowed * there. A link may only narrow what the resource defines, whatever was stored on it. The * built-in `/seamless` resource is never stored: an admin turning notices on - * for the client is what links it (ADR 0001, D14). + * for the client (ADR 0001, D14) or session exchange (ADR 0003, X2) is what links it. */ export async function getOidcClientResources(clientId: string) { const client = await findActiveOidcClient(clientId); const { oidc_issuer } = await getSystemConfig(); const builtIn = oidc_issuer ? seamlessBuiltInResource(oidc_issuer) : null; + const exchange = client ? isSessionExchangeClient(client) : false; + const builtInScopes = [ + ...(client?.noticesEnabled ? [APPLICATION_NOTICE_SCOPE] : []), + ...(exchange ? [SESSION_EXCHANGE_SCOPE] : []), + ]; const resources: { identifier: string; scopes: string[]; accessTokenTtlSeconds: number | null; }[] = - builtIn && client?.noticesEnabled - ? [{ identifier: builtIn, scopes: [APPLICATION_NOTICE_SCOPE], accessTokenTtlSeconds: null }] + builtIn && builtInScopes.length > 0 + ? [ + { + identifier: builtIn, + scopes: builtInScopes, + accessTokenTtlSeconds: exchange ? SESSION_EXCHANGE_TOKEN_TTL_SECONDS : null, + }, + ] : []; const links = await OidcClientResource.findAll({ where: { clientId } }); diff --git a/src/services/sessionRevocation.ts b/src/services/sessionRevocation.ts index 5762d60..e3dc242 100644 --- a/src/services/sessionRevocation.ts +++ b/src/services/sessionRevocation.ts @@ -53,3 +53,23 @@ export function isRevokedByCutoff( return new Date(session.createdAt).getTime() <= new Date(sessionsRevokedBefore).getTime(); } + +/** + * Ends every session a client exchanged for, including superseded rows (ADR 0003). Used when + * exchange is turned off for the client, or the client is disabled or deleted. + */ +export async function revokeSessionsExchangedBy( + clientId: string, + reason: string, + transaction?: Transaction, +): Promise { + const [revoked] = await Session.update( + { revokedAt: new Date(), revokedReason: reason }, + { + where: { exchangeClientId: clientId, revokedAt: null }, + ...(transaction ? { transaction } : {}), + }, + ); + + return revoked; +} diff --git a/tests/integration/oidc/oidcApplications.spec.ts b/tests/integration/oidc/oidcApplications.spec.ts index abdaa25..2d66ca1 100644 --- a/tests/integration/oidc/oidcApplications.spec.ts +++ b/tests/integration/oidc/oidcApplications.spec.ts @@ -60,6 +60,7 @@ function row(overrides: Record = {}): Row { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + sessionExchangeEnabled: false, noticeActionOrigins: [], pkceRequired: true, nonceRequired: true, diff --git a/tests/integration/oidc/oidcClientLinks.spec.ts b/tests/integration/oidc/oidcClientLinks.spec.ts index bd7a7fa..adaf57c 100644 --- a/tests/integration/oidc/oidcClientLinks.spec.ts +++ b/tests/integration/oidc/oidcClientLinks.spec.ts @@ -38,6 +38,7 @@ function client(id: string, overrides: Record = {}) { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + sessionExchangeEnabled: false, noticeActionOrigins: [], pkceRequired: true, nonceRequired: true, From 32afa3b85b6e1f94def18502ee3bcfa002fe602c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:40:54 -0500 Subject: [PATCH 03/20] test(oidc): cover signing and recording session:exchange tokens Refs #544 --- tests/postgres/sessionExchange.spec.ts | 161 +++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 3f35bde..85f6baa 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -429,3 +429,164 @@ describe('the sessionExchangeEnabled registry flag', () => { }); }); }); + +describe('/oidc/sign for a session:exchange token', () => { + async function eligible(overrides: Partial = {}) { + const client = await nativeClient(); + const user = await createUser(); + const parent = await providerSignIn(client, user, overrides); + return { client, user, parent }; + } + + it('signs it, and records which session and grant it came from', async () => { + const { client, user, parent } = await eligible(); + + const { outcome, jti } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + + expect(outcome.ok).toBe(true); + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.sessionId).toBe(parent.id); + expect(record?.consumedAt).toBeNull(); + expect(record?.payload).toEqual({ + clientId: client.id, + accountId: user.id, + grantType: DEVICE_CODE, + }); + }); + + it('expires the record with the token', async () => { + const { client, parent } = await eligible(); + const exp = Math.floor(Date.now() / 1000) + 90; + + const { jti } = await signSubject(client, parent, { claims: { exp } }); + + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.expiresAt.getTime()).toBe(exp * 1000); + }); + + it('records nothing for a token with no session:exchange scope', async () => { + const client = await nativeClient({ noticesEnabled: true }); + const user = await createUser(); + const parent = await providerSignIn(client, user); + + const { outcome, jti } = await signSubject(client, parent, { + claims: { scope: 'notices:send' }, + }); + + expect(outcome.ok).toBe(true); + expect(await OidcPayload.count({ where: { model: 'SessionExchangeToken', id: jti } })).toBe(0); + }); + + it.each([ + ['another scope with it', { scope: `${SCOPE} notices:send` }, 'grant_not_allowed'], + ['an OIDC scope with it', { scope: `openid ${SCOPE}` }, 'grant_not_allowed'], + ['an audience list', { aud: [BUILT_IN_RESOURCE] }, 'audience_mismatch'], + ['another audience', { aud: 'https://api.example.com' }, 'audience_mismatch'], + ['no jti', { jti: undefined }, 'malformed_signing_input'], + ['no iat', { iat: undefined }, 'malformed_signing_input'], + [ + 'a lifetime over two minutes', + { exp: Math.floor(Date.now() / 1000) + 121 }, + 'malformed_signing_input', + ], + [ + 'an iat that hides a longer lifetime', + { iat: Math.floor(Date.now() / 1000) + 1000, exp: Math.floor(Date.now() / 1000) + 1100 }, + 'malformed_signing_input', + ], + ] as [string, Record, string][])( + 'refuses a token with %s', + async (_name, claims, code) => { + const { client, parent } = await eligible(); + + const { outcome } = await signSubject(client, parent, { claims }); + + expect(outcome).toMatchObject({ ok: false, code }); + }, + ); + + it('refuses another resource that happens to declare the scope', async () => { + const { client, parent } = await eligible(); + const identifier = `https://api-${randomUUID()}.example.com`; + const { createOidcResource, setOidcApplicationResources } = + await import('../../src/services/oidcResourceService.js'); + const resource = await createOidcResource({ + identifier, + name: 'Lookalike', + scopes: [SCOPE], + } as never); + await setOidcApplicationResources(client.id, { + resources: [{ resourceId: resource.id, scopes: [SCOPE] }], + } as never); + + const { outcome } = await signSubject(client, parent, { claims: { aud: identifier } }); + + expect(outcome).toMatchObject({ ok: false, code: 'audience_mismatch' }); + }); + + it('refuses a refresh grant, which has no business minting one', async () => { + const client = await nativeClient({ + grantTypes: [AUTHORIZATION_CODE, 'refresh_token', DEVICE_CODE], + }); + const parent = await providerSignIn(client, await createUser()); + + const { outcome } = await signSubject(client, parent, { grantType: 'refresh_token' }); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); + + it.each([ + ['exchange is off', { sessionExchangeEnabled: false }, 'audience_mismatch'], + ['it is a web client', { redirectUris: ['https://app.example.com/cb'] }, 'audience_mismatch'], + ['it is disabled', { disabledAt: new Date() }, 'client_inactive'], + ] as [string, Partial, string][])( + 'refuses a client when %s', + async (_name, overrides, code) => { + const client = await nativeClient(overrides); + const parent = await providerSignIn(client, await createUser()); + + const { outcome } = await signSubject(client, parent); + + expect(outcome).toMatchObject({ ok: false, code }); + }, + ); + + it('never signs the scope onto a client credentials token', async () => { + const client = await nativeClient({ + grantTypes: ['client_credentials', AUTHORIZATION_CODE], + }); + const claims = subjectClaims(client, client.id); + const signingInput = jwtInput({ alg: 'RS256', kid: KID, typ: 'at+jwt' }, claims); + + const outcome = await signing.signForOidcEdge({ + kind: 'client_credentials', + kid: KID, + clientId: client.id, + grantType: 'client_credentials', + signingInput, + } as never); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); + + it('refuses to sign the same jti twice, so a consumed record cannot be reset', async () => { + const { client, parent } = await eligible(); + const { jti } = await signSubject(client, parent); + await OidcPayload.update( + { consumedAt: new Date() }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + const { outcome } = await signSubject(client, parent, { claims: { jti } }); + + expect(outcome).toMatchObject({ ok: false, code: 'malformed_signing_input' }); + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.consumedAt).not.toBeNull(); + }); + + it('keeps the record out of reach of the edge payload routes', async () => { + const { OidcPayloadModelSchema } = await import('@seamless-auth/types'); + + expect(OidcPayloadModelSchema.safeParse('SessionExchangeToken').success).toBe(false); + }); +}); From 5627739382fd87a488f9bd3175a14a08f695b206 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:42:38 -0500 Subject: [PATCH 04/20] feat(oidc): sign and record session:exchange tokens /oidc/sign now signs a session:exchange token only when the scope is exact, the audience is the built-in resource, jti is present and it lives at most 120 seconds. It writes the SessionExchangeToken record the exchange consumes. Refs #544 --- resources/coverage-badge.svg | 14 +++--- src/services/oidcPayloadStore.ts | 39 ++++++++++++++- src/services/oidcSigning.ts | 67 ++++++++++++++++++++++++-- tests/postgres/sessionExchange.spec.ts | 2 +- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02..8bd6c88 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99.2% - 99.2% + 99% + 99% diff --git a/src/services/oidcPayloadStore.ts b/src/services/oidcPayloadStore.ts index dfc0ea0..45be3f4 100644 --- a/src/services/oidcPayloadStore.ts +++ b/src/services/oidcPayloadStore.ts @@ -9,7 +9,7 @@ import type { OidcPayloadRecord, OidcPayloadUpsertRequest, } from '@seamless-auth/types'; -import { Op, QueryTypes, WhereOptions } from 'sequelize'; +import { Op, QueryTypes, UniqueConstraintError, WhereOptions } from 'sequelize'; import { OidcPayload, OidcPayloadAttributes } from '../models/oidcPayloads.js'; import getLogger from '../utils/logger.js'; @@ -186,6 +186,43 @@ export async function bindOidcSession(sessionId: string, userId: string, clientI }); } +// ADR 0003, X3. Under a model the edge's payload routes cannot name, for the same reason as +// the binding: the edge must not be able to read, write or consume these. +export const SESSION_EXCHANGE_TOKEN = 'SessionExchangeToken'; + +export interface SessionExchangeTokenPayload extends Record { + clientId: string; + accountId: string; + grantType: string; + /** Set when the token is exchanged, so a replay can revoke what the first use created. */ + childSessionId?: string; +} + +/** + * Written when `/oidc/sign` signs a `session:exchange` token. Only ever inserted: a second + * signature over the same `jti` is refused rather than resetting a record already consumed. + */ +export async function recordSessionExchangeToken( + jti: string, + sessionId: string, + payload: SessionExchangeTokenPayload, + expiresAt: Date, +): Promise { + try { + await OidcPayload.create({ + id: jti, + model: SESSION_EXCHANGE_TOKEN, + payload, + sessionId, + expiresAt, + }); + return true; + } catch (error) { + if (error instanceof UniqueConstraintError) return false; + throw error; + } +} + function findBinding(where: Record) { return OidcPayload.findOne({ where: { diff --git a/src/services/oidcSigning.ts b/src/services/oidcSigning.ts index 0ce3b58..168eebf 100644 --- a/src/services/oidcSigning.ts +++ b/src/services/oidcSigning.ts @@ -4,7 +4,12 @@ * See LICENSE file in the project root for full license information */ -import type { OidcSignRefusalCode, OidcSignRequest } from '@seamless-auth/types'; +import { + type OidcSignRefusalCode, + type OidcSignRequest, + seamlessBuiltInResource, + SESSION_EXCHANGE_SCOPE, +} from '@seamless-auth/types'; import { constants, sign } from 'crypto'; import { getSystemConfig } from '../config/getSystemConfig.js'; @@ -16,7 +21,7 @@ import { getOidcAccountClaims, getOidcClientResourceScopes, } from './oidcClientRegistry.js'; -import { hasOidcGrantForSession } from './oidcPayloadStore.js'; +import { hasOidcGrantForSession, recordSessionExchangeToken } from './oidcPayloadStore.js'; import { getActiveOidcSigningKey, OidcProviderKeysError } from './oidcProviderKeys.js'; type JsonObject = Record; @@ -35,6 +40,12 @@ const MAX_TOKEN_LIFETIME_SECONDS = 60 * 60 * 24; const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +// ADR 0003, X2. The edge sets 60 seconds, and this is the most the API will sign. +const MAX_SESSION_EXCHANGE_TOKEN_SECONDS = 120; +// A token the user just signed in for. A refresh is not a sign-in, and the record has to +// say which grant to treat the child as. +const SESSION_EXCHANGE_GRANTS = new Set(['authorization_code', DEVICE_CODE_GRANT]); + const PROTOCOL_CLAIMS = [ 'iss', 'sub', @@ -78,6 +89,10 @@ const IDENTITY_CLAIMS = [ 'org_id', ] as const; +function isSessionExchangeToken(request: OidcSignRequest, claims: JsonObject) { + return request.kind === 'access_token' && scopesOf(claims).includes(SESSION_EXCHANGE_SCOPE); +} + function refuse(code: OidcSignRefusalCode, userId?: string): OidcSignOutcome { return { ok: false, code, ...(userId ? { userId } : {}) }; } @@ -199,6 +214,7 @@ export async function signForOidcEdge(request: OidcSignRequest): Promise MAX_SESSION_EXCHANGE_TOKEN_SECONDS || + (claims.exp as number) - now > MAX_SESSION_EXCHANGE_TOKEN_SECONDS + ) { + return refuse('malformed_signing_input', userId); + } + + return null; } diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 85f6baa..102228b 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -486,7 +486,7 @@ describe('/oidc/sign for a session:exchange token', () => { ['no iat', { iat: undefined }, 'malformed_signing_input'], [ 'a lifetime over two minutes', - { exp: Math.floor(Date.now() / 1000) + 121 }, + { exp: Math.floor(Date.now() / 1000) + 600 }, 'malformed_signing_input', ], [ From 7a81fac480d4ff9d020101deab6db50e07fa0cd0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:43:52 -0500 Subject: [PATCH 05/20] test(sessions): cover POST /sessions/exchange Replay sequential and concurrent, wrong audience, extra scope, expired record, disabled client, recovery parent, and the X5 assurance copy. Refs #544 --- tests/postgres/sessionExchange.spec.ts | 516 ++++++++++++++++++++++++- 1 file changed, 515 insertions(+), 1 deletion(-) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 102228b..e5d0996 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -4,7 +4,7 @@ import { SignJWT } from 'jose'; import os from 'os'; import path from 'path'; import request from 'supertest'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; import { AuthEvent } from '../../src/models/authEvents.js'; @@ -590,3 +590,517 @@ describe('/oidc/sign for a session:exchange token', () => { expect(OidcPayloadModelSchema.safeParse('SessionExchangeToken').success).toBe(false); }); }); + +const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const REFRESH_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:refresh_token'; +const TOKEN_FAILURE = { error: 'invalid_request' }; + +function exchange(subjectToken: string, overrides: Record = {}) { + return request(app) + .post('/sessions/exchange') + .send({ + grant_type: GRANT_TYPE, + subject_token: subjectToken, + subject_token_type: ACCESS_TOKEN_TYPE, + requested_token_type: REFRESH_TOKEN_TYPE, + ...overrides, + }); +} + +async function signedInToCli( + overrides: Partial = {}, + clientOverrides: Partial = {}, + roles: string[] = ['user'], +) { + const client = await nativeClient(clientOverrides); + const user = await createUser(roles); + const parent = await providerSignIn(client, user, overrides); + return { client, user, parent }; +} + +/** A token signed with the provider key that `/oidc/sign` never saw. */ +function forge(claims: Record, typ = 'at+jwt') { + return new SignJWT(claims).setProtectedHeader({ alg: 'RS256', kid: KID, typ }).sign(providerKey); +} + +function me(token: string) { + return request(app).get('/users/me').set('Authorization', `Bearer ${token}`); +} + +describe('POST /sessions/exchange', () => { + it('issues a linked child session the first-party routes accept', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + + const res = await exchange(token); + + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.body).toMatchObject({ + message: 'Success', + sub: user.id, + email: user.email, + roles: user.roles, + issued_token_type: REFRESH_TOKEN_TYPE, + }); + expect(res.body.ttl).toBeGreaterThan(0); + expect(res.body.refreshTtl).toBeGreaterThan(0); + expect(res.body.sessionId).not.toBe(parent.id); + + const child = await Session.findByPk(res.body.sessionId); + expect(child).toMatchObject({ + userId: user.id, + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + organizationId: parent.organizationId, + }); + expect(child?.refreshTokenLookup).not.toBe(parent.refreshTokenLookup); + + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.consumedAt).not.toBeNull(); + + expect((await me(res.body.token)).status).toBe(200); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + + const refreshed = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${res.body.refreshToken}`); + expect(refreshed.status).toBe(200); + + const event = await AuthEvent.findOne({ + where: { type: 'session_exchanged', session_id: res.body.sessionId }, + }); + expect(event?.user_id).toBe(user.id); + expect(event?.metadata).toMatchObject({ + clientId: client.id, + grantType: AUTHORIZATION_CODE, + parentSessionId: parent.id, + }); + }); + + it('accepts a request that leaves out the optional requested_token_type', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + + const res = await exchange(token, { requested_token_type: undefined }); + + expect(res.status).toBe(200); + }); + + it('caps the child at what is left of the parent', async () => { + const parentExpiry = new Date(Date.now() + 30 * 60_000); + const { client, parent } = await signedInToCli({ + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.expiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(child?.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(res.body.refreshTtl).toBeLessThanOrEqual(30 * 60); + }); + + it('resolves the parent forward through rotation', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const rotated = await createSession(user.id, { organizationId: null }); + await Session.update({ replacedBySessionId: rotated.id }, { where: { id: parent.id } }); + + const res = await exchange(token); + + expect(res.status).toBe(200); + expect((await Session.findByPk(res.body.sessionId))?.parentSessionId).toBe(rotated.id); + }); + + describe('single use', () => { + it('refuses a second presentation and revokes what the first one created', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + + const first = await exchange(token); + const second = await exchange(token); + + expect(second.status).toBe(400); + expect(second.body).toEqual({ + error: 'invalid_request', + error_description: 'token_already_used', + }); + expect((await me(first.body.token)).status).toBe(401); + const child = await Session.findByPk(first.body.sessionId); + expect(child?.revokedReason).toBe('session_exchange_replayed'); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + const event = await AuthEvent.findOne({ + where: { type: 'session_exchange_replayed', user_id: user.id }, + }); + expect(event?.metadata).toMatchObject({ clientId: client.id }); + }); + + it('revokes a child that has since rotated', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const first = await exchange(token); + const refreshed = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${first.body.refreshToken}`); + + await exchange(token); + + expect((await me(refreshed.body.token)).status).toBe(401); + }); + + it('lets one of many concurrent presentations win, then revokes it', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + + const results = await Promise.all(Array.from({ length: 6 }, () => exchange(token))); + + const winners = results.filter((res) => res.status === 200); + const losers = results.filter((res) => res.status === 400); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(5); + for (const loser of losers) { + expect(loser.body.error_description).toBe('token_already_used'); + } + expect((await me(winners[0].body.token)).status).toBe(401); + + const children = await Session.findAll({ + where: { userId: user.id, parentSessionId: parent.id }, + }); + expect(children).toHaveLength(1); + expect(children[0].revokedAt).not.toBeNull(); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.payload.childSessionId).toBe(children[0].id); + }); + }); + + describe('refuses with one body', () => { + async function expectTokenFailure(token: string) { + const res = await exchange(token); + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + } + + function goodClaims(client: OidcClient, userId: string) { + return subjectClaims(client, userId); + } + + it.each([ + ['with no signing record', {}], + ['with another audience', { aud: 'https://api.example.com' }], + ['with an audience list', { aud: [BUILT_IN_RESOURCE, 'https://api.example.com'] }], + ['with an extra scope', { scope: `${SCOPE} notices:send` }], + ['with another scope', { scope: 'notices:send' }], + ['from another issuer', { iss: 'https://elsewhere.example.com' }], + ['with no jti', { jti: undefined }], + ['with no client_id', { client_id: undefined }], + ] as [string, Record][])('a token %s', async (_name, overrides) => { + const { client, parent } = await signedInToCli(); + + await expectTokenFailure(await forge({ ...goodClaims(client, parent.userId), ...overrides })); + }); + + it.each([ + ['another audience', { aud: 'https://api.example.com' }], + ['an extra scope', { scope: `${SCOPE} notices:send` }], + ] as [string, Record][])( + 'a token with %s, even when its record exists', + async (_name, overrides) => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure( + await forge({ ...goodClaims(client, parent.userId), jti, ...overrides }), + ); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.consumedAt).toBeNull(); + }, + ); + + it('a token whose subject is the client', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure(await forge({ ...goodClaims(client, client.id), jti })); + }); + + it('a token that is not an at+jwt', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure(await forge({ ...goodClaims(client, parent.userId), jti }, 'JWT')); + }); + + it('a token signed by someone else', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + const stranger = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const token = await new SignJWT({ ...goodClaims(client, parent.userId), jti }) + .setProtectedHeader({ alg: 'RS256', kid: KID, typ: 'at+jwt' }) + .sign(stranger); + + await expectTokenFailure(token); + }); + + it('a first-party access token', async () => { + const { parent } = await signedInToCli(); + + await expectTokenFailure(await accessTokenFor(parent)); + }); + + it('an expired token', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + const now = Math.floor(Date.now() / 1000); + + await expectTokenFailure( + await forge({ ...goodClaims(client, parent.userId), jti, iat: now - 90, exp: now - 30 }), + ); + }); + + it('an expired record', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { expiresAt: new Date(Date.now() - 1000) }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a record that names another user', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: client.id, accountId: randomUUID(), grantType: DEVICE_CODE } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a record that names another client', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: 'someone-else', accountId: user.id, grantType: DEVICE_CODE } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it.each([ + ['disabled', { disabledAt: new Date() }], + ['no longer enabled for exchange', { sessionExchangeEnabled: false }], + ['no longer first-party', { firstParty: false }], + ['no longer public', { clientType: 'confidential' }], + ['no longer native', { redirectUris: ['https://app.example.com/cb'] }], + ] as [string, Partial][])('a client %s', async (_name, change) => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcClient.update(change, { where: { id: client.id } }); + + await expectTokenFailure(token); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.consumedAt).toBeNull(); + }); + + it('a recovery parent', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await Session.update({ recovery: true }, { where: { id: parent.id } }); + + await expectTokenFailure(token); + }); + + it('a parent that is no longer live', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + await expectTokenFailure(token); + }); + + it('a parent that belongs to another user', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + const stranger = await createUser(); + const elsewhere = await createSession(stranger.id); + await OidcPayload.update( + { sessionId: elsewhere.id }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a parent that is itself an exchanged session', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const grandparent = await createSession(user.id); + await Session.update( + { parentSessionId: grandparent.id, exchangeClientId: client.id }, + { where: { id: parent.id } }, + ); + + await expectTokenFailure(token); + }); + + it('a revoked user', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await User.update({ revoked: true }, { where: { id: user.id } }); + + await expectTokenFailure(token); + }); + + it('a record with a grant that cannot exchange', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: client.id, accountId: user.id, grantType: 'refresh_token' } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + }); + + it.each([ + ['another grant type', { 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' }], + ['no subject token', { subject_token: undefined }], + ] as [string, Record][])( + 'answers invalid_request to a request with %s', + async (_name, overrides) => { + const res = await exchange('a.b.c', overrides); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }, + ); + + it('is not a route adapters proxy', async () => { + const manifest = await request(app).get('/.well-known/seamless-adapter.json'); + + expect(JSON.stringify(manifest.body)).not.toContain('/sessions/exchange'); + }); + + describe('assurance (X5)', () => { + const passkey = { + method: 'passkey', + at: '2026-11-04T12:00:00.000Z', + deviceBound: true, + userVerified: true, + }; + + it('copies the parent auth context for an authorization code grant', async () => { + const authTime = new Date(Date.now() - 60_000); + const stepUpVerifiedAt = new Date(Date.now() - 30_000); + const { client, parent } = await signedInToCli({ + authMethods: [passkey] as never, + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.authMethods).toEqual([passkey]); + expect(child?.authTime?.getTime()).toBe(authTime.getTime()); + expect(child?.stepUpVerifiedAt?.getTime()).toBe(stepUpVerifiedAt.getTime()); + expect(child?.stepUpMethod).toBe('webauthn'); + }); + + it('lets a stale step-up stay stale', async () => { + const { client, parent } = await signedInToCli({ + stepUpVerifiedAt: new Date(Date.now() - 10 * 60_000), + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const { getSessionStepUpStatus } = await import('../../src/services/stepUpService.js'); + const status = await getSessionStepUpStatus({ + sessionId: res.body.sessionId, + userId: parent.userId, + }); + expect(status.fresh).toBe(false); + }); + + it('copies nothing for a device grant', async () => { + const { client, parent } = await signedInToCli({ + authMethods: [passkey] as never, + authTime: new Date(), + stepUpVerifiedAt: new Date(), + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.exchangeGrantType).toBe(DEVICE_CODE); + expect(child?.authMethods).toEqual([]); + expect(child?.authTime).toBeNull(); + expect(child?.stepUpVerifiedAt).toBeNull(); + expect(child?.stepUpMethod).toBeNull(); + }); + + it('keeps later step-ups on either session to that session', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const res = await exchange(token); + const { recordStepUpVerification } = await import('../../src/services/stepUpService.js'); + + await recordStepUpVerification({ + sessionId: res.body.sessionId, + userId: parent.userId, + method: 'totp', + }); + + await parent.reload(); + expect(parent.stepUpVerifiedAt).toBeNull(); + }); + }); + + it('refuses when the client is switched off between the checks and the write', async () => { + // Stands in for an admin's write landing mid-exchange: the exchange's re-read after its + // commit, or the admin's revoke after the write, has to catch it either way. + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const original = OidcClient.findByPk.bind(OidcClient); + let reads = 0; + const spy = vi.spyOn(OidcClient, 'findByPk').mockImplementation(async (...args) => { + reads += 1; + if (reads === 2) { + await OidcClient.update({ sessionExchangeEnabled: false }, { where: { id: client.id } }); + } + return original(...args); + }); + + const res = await exchange(token); + spy.mockRestore(); + + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + const children = await Session.findAll({ where: { parentSessionId: parent.id } }); + expect(children.every((child) => child.revokedAt !== null)).toBe(true); + }); +}); From 5e92c68e358505d0dd7872818e3f4239c2905f75 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:47:14 -0500 Subject: [PATCH 06/20] feat(sessions): add POST /sessions/exchange A first-party native client presents the single-use provider access token and gets a first-party session linked to the provider sign-in. The record is consumed and the child created in one transaction, so of concurrent presentations one wins and the rest revoke what it created. An authorization code grant copies the parent's assurance and step-up, a device grant nothing. Refs #544 --- openapi.json | 152 ++++++++ src/controllers/sessions.ts | 16 + src/generated/api.ts | 159 ++++++++ src/lib/authEventCategories.ts | 2 + src/middleware/rateLimit.ts | 12 + src/routes/session.routes.ts | 45 ++- src/schemas/authEvent.types.ts | 2 + src/schemas/session.responses.ts | 7 +- src/services/sessionExchangeService.ts | 302 +++++++++++++++ tests/integration/session/session.spec.ts | 30 ++ tests/postgres/sessionExchange.spec.ts | 5 +- tests/setup/mocks.ts | 2 + .../services/sessionExchangeService.spec.ts | 358 ++++++++++++++++++ 13 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 src/services/sessionExchangeService.ts create mode 100644 tests/unit/services/sessionExchangeService.spec.ts diff --git a/openapi.json b/openapi.json index fec806f..79a2905 100644 --- a/openapi.json +++ b/openapi.json @@ -5359,6 +5359,8 @@ "service_token_rotated", "service_token_success", "session_evicted", + "session_exchange_replayed", + "session_exchanged", "step_up_challenge", "step_up_failed", "step_up_success", @@ -5485,6 +5487,8 @@ "service_token_rotated", "service_token_success", "session_evicted", + "session_exchange_replayed", + "session_exchanged", "step_up_challenge", "step_up_failed", "step_up_success", @@ -22506,6 +22510,154 @@ } } }, + "/sessions/exchange": { + "post": { + "summary": "Exchange a provider sign-in for a first-party session", + "description": "For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created.", + "tags": ["Sessions"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:grant-type:token-exchange"] + }, + "subject_token": { "type": "string", "minLength": 1, "maxLength": 8192 }, + "subject_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:access_token"] + }, + "requested_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:refresh_token"] + } + }, + "required": ["grant_type", "subject_token", "subject_token_type"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "message": "string", + "token": "string", + "refreshToken": "string", + "sub": "string", + "sessionId": "string", + "organizationId": "string", + "roles": [null], + "email": "string", + "phone": "string", + "ttl": 0, + "refreshTtl": 0, + "recovery": true, + "issued_token_type": null + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "refreshToken": { "type": "string" }, + "sub": { "type": "string" }, + "sessionId": { "type": "string" }, + "organizationId": { "type": "string", "nullable": true }, + "roles": { "type": "array", "items": { "type": "string" } }, + "email": { "type": "string" }, + "phone": { "type": "string", "nullable": true }, + "ttl": { "type": "number" }, + "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, + "issued_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:refresh_token"] + } + }, + "required": ["message", "issued_token_type"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": null, + "error_description": null, + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string", "enum": ["invalid_request"] }, + "error_description": { "type": "string", "enum": ["token_already_used"] }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/sessions/{id}": { "delete": { "summary": "Revoke a session", diff --git a/src/controllers/sessions.ts b/src/controllers/sessions.ts index a15257e..a21d045 100644 --- a/src/controllers/sessions.ts +++ b/src/controllers/sessions.ts @@ -9,6 +9,7 @@ import { Op } from 'sequelize'; import { Session } from '../models/sessions.js'; import { serializeSession } from '../services/apiResponseSerializers.js'; +import { exchangeSubjectToken } from '../services/sessionExchangeService.js'; import { revokeAllSessionsForUsers } from '../services/sessionRevocation.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -84,3 +85,18 @@ export const revokeAllSessions = async (req: Request, res: Response) => { return res.json({ message: 'Success' }); }; + +export const exchangeSession = async (req: Request, res: Response) => { + res.set('Cache-Control', 'no-store'); + + const outcome = await exchangeSubjectToken(req.body.subject_token, req); + + if (outcome.ok) { + return res.status(200).json(outcome.response); + } + + return res.status(400).json({ + error: 'invalid_request', + ...(outcome.replayed ? { error_description: 'token_already_used' } : {}), + }); +}; diff --git a/src/generated/api.ts b/src/generated/api.ts index 3122a2e..0bba8af 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -5048,6 +5048,8 @@ export interface paths { | 'service_token_rotated' | 'service_token_success' | 'session_evicted' + | 'session_exchange_replayed' + | 'session_exchanged' | 'step_up_challenge' | 'step_up_failed' | 'step_up_success' @@ -5168,6 +5170,8 @@ export interface paths { | 'service_token_rotated' | 'service_token_success' | 'session_evicted' + | 'session_exchange_replayed' + | 'session_exchanged' | 'step_up_challenge' | 'step_up_failed' | 'step_up_success' @@ -22025,6 +22029,161 @@ export interface paths { patch?: never; trace?: never; }; + '/sessions/exchange': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a provider sign-in for a first-party session + * @description For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** @enum {string} */ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange'; + subject_token: string; + /** @enum {string} */ + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token'; + /** @enum {string} */ + requested_token_type?: 'urn:ietf:params:oauth:token-type:refresh_token'; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "refreshToken": "string", + * "sub": "string", + * "sessionId": "string", + * "organizationId": "string", + * "roles": [ + * null + * ], + * "email": "string", + * "phone": "string", + * "ttl": 0, + * "refreshTtl": 0, + * "recovery": true, + * "issued_token_type": null + * } + */ + 'application/json': { + message: string; + token?: string; + refreshToken?: string; + sub?: string; + sessionId?: string; + organizationId?: string | null; + roles?: string[]; + email?: string; + phone?: string | null; + ttl?: number; + refreshTtl?: number; + recovery?: boolean; + /** @enum {string} */ + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token'; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": null, + * "error_description": null, + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + /** @enum {string} */ + error: 'invalid_request'; + /** @enum {string} */ + error_description?: 'token_already_used'; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/sessions/{id}': { parameters: { query?: never; diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index 5ef27b9..8e26f65 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -64,6 +64,8 @@ const EXACT_CATEGORIES: Readonly> = { // Session lifecycle sits with login, which is where logout_ already lands, and // an eviction is a direct consequence of a sign-in rather than an admin action. session_evicted: 'login', + session_exchange_replayed: 'login', + session_exchanged: 'login', user_created: 'registration', user_deleted: 'system', }; diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 0279701..98ea789 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -201,3 +201,15 @@ export function oauthIpLimiter(req: Request, res: Response, next: NextFunction) export function oauthProviderLimiter(req: Request, res: Response, next: NextFunction) { return oauthProviderCachedLimiter(req, res, next); } + +// Not one of the shared `flow_rate_limits` keys, because a new key there needs a types +// release. A client exchanges once per sign-in, so this is well above any real traffic. +const SESSION_EXCHANGES_PER_WINDOW_PER_ADDRESS = 30; + +const sessionExchangeCachedLimiter = createFlowLimiter( + () => SESSION_EXCHANGES_PER_WINDOW_PER_ADDRESS, +); + +export function sessionExchangeLimiter(req: Request, res: Response, next: NextFunction) { + return sessionExchangeCachedLimiter(req, res, next); +} diff --git a/src/routes/session.routes.ts b/src/routes/session.routes.ts index 44fa5bf..989c9f1 100644 --- a/src/routes/session.routes.ts +++ b/src/routes/session.routes.ts @@ -4,11 +4,22 @@ * See LICENSE file in the project root for full license information */ -import { listSessions, revokeAllSessions, revokeSession } from '../controllers/sessions.js'; +import { + exchangeSession, + listSessions, + revokeAllSessions, + revokeSession, +} from '../controllers/sessions.js'; import { createRouter } from '../lib/createRouter.js'; -import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { sessionExchangeLimiter } from '../middleware/rateLimit.js'; +import { ErrorSchema, MessageSchema, ValidationErrorSchema } from '../schemas/generic.responses.js'; import { SessionIdParamsSchema } from '../schemas/session.params.js'; -import { SessionListResponseSchema } from '../schemas/session.responses.js'; +import { + SessionExchangeErrorSchema, + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, + SessionListResponseSchema, +} from '../schemas/session.responses.js'; const sessionsRouter = createRouter('/sessions'); @@ -29,6 +40,34 @@ sessionsRouter.get( listSessions, ); +sessionsRouter.post( + '/exchange', + { + adapter: false, + middleware: [sessionExchangeLimiter], + summary: 'Exchange a provider sign-in for a first-party session', + description: + 'For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created.', + tags: ['Sessions'], + + schemas: { + body: SessionExchangeRequestSchema, + + response: { + 200: SessionExchangeResponseSchema, + // Also what a request that fails schema validation answers with. + 400: SessionExchangeErrorSchema.extend( + ValidationErrorSchema.pick({ + message: true, + details: true, + }).shape, + ), + }, + }, + }, + exchangeSession, +); + sessionsRouter.delete( '/:id', { diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index d1ba37f..99ad522 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -100,6 +100,8 @@ export const AUTH_EVENT_TYPES = [ 'service_token_rotated', 'service_token_success', 'session_evicted', + 'session_exchange_replayed', + 'session_exchanged', 'step_up_challenge', 'step_up_failed', 'step_up_success', diff --git a/src/schemas/session.responses.ts b/src/schemas/session.responses.ts index 89f34ea..829e865 100644 --- a/src/schemas/session.responses.ts +++ b/src/schemas/session.responses.ts @@ -4,4 +4,9 @@ * See LICENSE file in the project root for full license information */ -export { SessionListResponseSchema } from '@seamless-auth/types'; +export { + SessionExchangeErrorSchema, + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, + SessionListResponseSchema, +} from '@seamless-auth/types'; diff --git a/src/services/sessionExchangeService.ts b/src/services/sessionExchangeService.ts new file mode 100644 index 0000000..54f58c7 --- /dev/null +++ b/src/services/sessionExchangeService.ts @@ -0,0 +1,302 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + seamlessBuiltInResource, + SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + SESSION_EXCHANGE_SCOPE, + type SessionExchangeResponse, +} from '@seamless-auth/types'; +import { Request } from 'express'; +import { createLocalJWKSet, type JSONWebKeySet, type JWTPayload, jwtVerify } from 'jose'; +import { Op, WhereOptions } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from '../lib/token.js'; +import { OidcPayload, OidcPayloadAttributes } from '../models/oidcPayloads.js'; +import { Session } from '../models/sessions.js'; +import { User } from '../models/users.js'; +import getLogger from '../utils/logger.js'; +import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; +import { AuthEventService } from './authEventService.js'; +import { resolveCurrentSession } from './oidcAuthContext.js'; +import { findActiveOidcClient, isSessionExchangeClient } from './oidcClientRegistry.js'; +import { SESSION_EXCHANGE_TOKEN, SessionExchangeTokenPayload } from './oidcPayloadStore.js'; +import { getOidcPublicJwks } from './oidcProviderKeys.js'; +import { revokeSessionChain } from './sessionService.js'; + +const logger = getLogger('sessionExchange'); + +const AUTHORIZATION_CODE_GRANT = 'authorization_code'; +const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +export type SessionExchangeOutcome = + { ok: true; response: SessionExchangeResponse } | { ok: false; replayed: boolean }; + +const REFUSED: SessionExchangeOutcome = { ok: false, replayed: false }; + +type SubjectClaims = JWTPayload & { sub: string; jti: string; client_id: string }; + +function refuse(reason: string): SessionExchangeOutcome { + logger.warn(`Session exchange refused: ${reason}`); + return REFUSED; +} + +/** ADR 0003, X4 to X7: only a token the API signed for this one purpose gets past here. */ +async function verifySubjectToken(token: string): Promise { + const { oidc_issuer } = await getSystemConfig(); + if (!oidc_issuer) return null; + + const audience = seamlessBuiltInResource(oidc_issuer); + + let claims: JWTPayload; + try { + const jwks = createLocalJWKSet({ keys: getOidcPublicJwks().keys } as JSONWebKeySet); + ({ payload: claims } = await jwtVerify(token, jwks, { + issuer: oidc_issuer, + audience, + algorithms: ['RS256'], + typ: 'at+jwt', + requiredClaims: ['exp', 'iat', 'sub', 'jti', 'client_id', 'scope'], + })); + } catch (error) { + logger.debug(`Subject token refused: ${(error as Error).message}`); + return null; + } + + const clientId: unknown = claims.client_id; + if ( + claims.aud !== audience || + claims.scope !== SESSION_EXCHANGE_SCOPE || + typeof clientId !== 'string' || + claims.sub === clientId + ) { + return null; + } + + return claims as SubjectClaims; +} + +function findRecord(jti: string) { + return OidcPayload.findOne({ where: { model: SESSION_EXCHANGE_TOKEN, id: jti } }); +} + +/** Whatever the first presentation created goes, wherever its refresh chain has reached. */ +async function handleReplay( + claims: SubjectClaims, + record: OidcPayload, + req: Request, +): Promise { + const { childSessionId } = record.payload as SessionExchangeTokenPayload; + const child = childSessionId ? await Session.findByPk(childSessionId) : null; + + if (child) { + await revokeSessionChain(child, 'session_exchange_replayed'); + } + + await AuthEventService.log({ + userId: claims.sub, + sessionId: child?.id ?? null, + type: 'session_exchange_replayed', + req, + metadata: { + clientId: claims.client_id, + parentSessionId: child?.parentSessionId ?? record.sessionId, + }, + }); + + return { ok: false, replayed: true }; +} + +function issuedChildTimes(parent: Session, ttls: { absoluteTtl: string; idleTtl: string }) { + const { expiresAt, idleExpiresAt } = computeSessionTimes(ttls); + const cap = Math.min(expiresAt.getTime(), new Date(parent.expiresAt).getTime()); + + return { + expiresAt: new Date(cap), + idleExpiresAt: new Date(Math.min(idleExpiresAt.getTime(), cap)), + }; +} + +/** + * ADR 0003, X5. The passkey behind a device grant proved who approved, not who holds the + * token, so that child starts with nothing (ADR 0001, D10). + */ +function inheritedAssurance(parent: Session, grantType: string) { + if (grantType === DEVICE_CODE_GRANT) { + return { authMethods: [], authTime: null, stepUpVerifiedAt: null, stepUpMethod: null }; + } + + return { + authMethods: parent.authMethods ?? [], + authTime: parent.authTime ?? parent.chainStartedAt ?? parent.createdAt ?? null, + stepUpVerifiedAt: parent.stepUpVerifiedAt ?? null, + stepUpMethod: parent.stepUpMethod ?? null, + }; +} + +/** + * Consumes the record and creates the child in one transaction. Concurrent presentations + * queue on the record's row, so the losers read a `childSessionId` the winner has committed + * and can revoke it, rather than finding the record consumed with nothing to revoke yet. + */ +async function consumeAndIssue( + record: OidcPayload, + parent: Session, + user: User, + clientId: string, + grantType: string, + req: Request, +) { + const { access_token_ttl, refresh_token_ttl, session_idle_ttl } = await getSystemConfig(); + const now = new Date(); + const { expiresAt, idleExpiresAt } = issuedChildTimes(parent, { + absoluteTtl: refresh_token_ttl || '1d', + idleTtl: session_idle_ttl || '8h', + }); + const refreshToken = generateRefreshToken(); + + const issued = await OidcPayload.sequelize!.transaction(async (transaction) => { + const [consumed] = await OidcPayload.update( + { consumedAt: now }, + { + where: { + model: SESSION_EXCHANGE_TOKEN, + id: record.id, + consumedAt: { [Op.is]: null }, + expiresAt: { [Op.gt]: now }, + } as WhereOptions, + transaction, + }, + ); + if (consumed === 0) return null; + + const child = await Session.create( + { + userId: user.id, + infraId: parent.infraId, + organizationId: parent.organizationId, + mode: 'server', + refreshTokenLookup: createRefreshTokenLookup(refreshToken), + userAgent: req.get('user-agent'), + ipAddress: req.ip, + expiresAt, + idleExpiresAt, + chainStartedAt: now, + lastUsedAt: now, + parentSessionId: parent.id, + exchangeClientId: clientId, + exchangeGrantType: grantType, + ...inheritedAssurance(parent, grantType), + }, + { transaction }, + ); + + await OidcPayload.update( + { payload: { ...record.payload, childSessionId: child.id } }, + { + where: { + model: SESSION_EXCHANGE_TOKEN, + id: record.id, + } as WhereOptions, + transaction, + }, + ); + + // Inside, so a signing failure hands the token back rather than spending it. + const token = await signAccessToken(child.id, user.id, user.roles, parent.organizationId); + + return { child, token }; + }); + + if (!issued) return null; + + return { + ...issued, + response: { + message: 'Success', + token: issued.token, + refreshToken, + sub: user.id, + sessionId: issued.child.id, + organizationId: parent.organizationId, + roles: user.roles, + email: user.email, + phone: user.phone, + ttl: parseDurationToSeconds(access_token_ttl || '15m'), + refreshTtl: Math.floor((expiresAt.getTime() - now.getTime()) / 1000), + issued_token_type: SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + } as SessionExchangeResponse, + }; +} + +/** + * `POST /sessions/exchange` (ADR 0003). The one place a provider token is accepted, and only + * to mint a first-party session (ADR 0001, D11). Every token failure answers the same, so + * the route cannot be used to learn about sessions; the reason goes to the log. + */ +export async function exchangeSubjectToken( + subjectToken: string, + req: Request, +): Promise { + const claims = await verifySubjectToken(subjectToken); + if (!claims) return refuse('token did not verify'); + + const record = await findRecord(claims.jti); + if (!record) return refuse('no signing record'); + + const payload = record.payload as SessionExchangeTokenPayload; + if (payload.accountId !== claims.sub || payload.clientId !== claims.client_id) { + return refuse('signing record names another user or client'); + } + + if (record.consumedAt) return handleReplay(claims, record, req); + + if (record.expiresAt <= new Date()) return refuse('signing record expired'); + + const { grantType } = payload; + if (grantType !== AUTHORIZATION_CODE_GRANT && grantType !== DEVICE_CODE_GRANT) { + return refuse('signing record names a grant that cannot exchange'); + } + + const client = await findActiveOidcClient(claims.client_id); + if (!client || !isSessionExchangeClient(client)) return refuse('client cannot exchange'); + + const parent = record.sessionId ? await resolveCurrentSession(record.sessionId) : null; + if (!parent || parent.userId !== claims.sub) return refuse('parent session is not live'); + if (parent.recovery) return refuse('parent is a recovery session'); + if (parent.parentSessionId) return refuse('parent is an exchanged session'); + + const user = await User.findOne({ where: { id: parent.userId, revoked: false } }); + if (!user) return refuse('user is not active'); + + const issued = await consumeAndIssue(record, parent, user, client.id, grantType, req); + + if (!issued) { + const latest = await findRecord(claims.jti); + return latest?.consumedAt + ? handleReplay(claims, latest, req) + : refuse('signing record expired'); + } + + // An admin turning the client off revokes its sessions after writing the flag, so reading + // the flag after the child is committed means one of the two always sees the other. + const current = await findActiveOidcClient(client.id); + if (!current || !isSessionExchangeClient(current)) { + await revokeSessionChain(issued.child, 'session_exchange_disabled'); + return refuse('client stopped being able to exchange'); + } + + await AuthEventService.log({ + userId: user.id, + sessionId: issued.child.id, + type: 'session_exchanged', + req, + metadata: { clientId: client.id, grantType, parentSessionId: parent.id }, + }); + + return { ok: true, response: issued.response }; +} diff --git a/tests/integration/session/session.spec.ts b/tests/integration/session/session.spec.ts index 1760a0f..ad35e01 100644 --- a/tests/integration/session/session.spec.ts +++ b/tests/integration/session/session.spec.ts @@ -4,6 +4,7 @@ import { createApp } from '../../../src/app'; import { Application } from 'express'; import { Op } from 'sequelize'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { Session } from '../../../src/models/sessions.js'; import { User } from '../../../src/models/users.js'; import { hardRevokeSession } from '../../../src/services/sessionService.js'; @@ -105,3 +106,32 @@ describe('DELETE /sessions', () => { expect(hardRevokeSession).not.toHaveBeenCalled(); }); }); + +describe('POST /sessions/exchange', () => { + const body = { + 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('answers invalid_request to a malformed request, with no store', async () => { + const res = await request(app) + .post('/sessions/exchange') + .send({ ...body, grant_type: 'password' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }); + + it('answers one body for a token that does not verify', async () => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: 'https://login.example.com', + } as never); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request' }); + expect(res.headers['cache-control']).toBe('no-store'); + }); +}); diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index e5d0996..ffe080a 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -903,7 +903,10 @@ describe('POST /sessions/exchange', () => { ['disabled', { disabledAt: new Date() }], ['no longer enabled for exchange', { sessionExchangeEnabled: false }], ['no longer first-party', { firstParty: false }], - ['no longer public', { clientType: 'confidential' }], + [ + 'no longer public', + { clientType: 'confidential', tokenEndpointAuthMethod: 'private_key_jwt' }, + ], ['no longer native', { redirectUris: ['https://app.example.com/cb'] }], ] as [string, Partial][])('a client %s', async (_name, change) => { const { client, parent } = await signedInToCli(); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 5137ae7..a1787ce 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -175,6 +175,7 @@ vi.mock('../../src/models/sessions.js', () => ({ Session: { create: vi.fn(), findAll: vi.fn(), + findByPk: vi.fn(), findOne: vi.fn(), count: vi.fn(), update: vi.fn(async () => [0]), @@ -278,6 +279,7 @@ vi.mock('../../src/middleware/rateLimit.js', () => ({ magicLinkEmailLimiter: (_req: any, _res: any, next: any) => next(), oauthIpLimiter: (_req: any, _res: any, next: any) => next(), oauthProviderLimiter: (_req: any, _res: any, next: any) => next(), + sessionExchangeLimiter: (_req: any, _res: any, next: any) => next(), otpIpLimiter: (_req: any, _res: any, next: any) => next(), otpIdentityLimiter: (_req: any, _res: any, next: any) => next(), dynamicRateLimit: (_req: any, _res: any, next: any) => next(), diff --git a/tests/unit/services/sessionExchangeService.spec.ts b/tests/unit/services/sessionExchangeService.spec.ts new file mode 100644 index 0000000..a8e0f67 --- /dev/null +++ b/tests/unit/services/sessionExchangeService.spec.ts @@ -0,0 +1,358 @@ +import { generateKeyPairSync, randomUUID } from 'crypto'; +import { SignJWT } from 'jose'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { provider } = vi.hoisted(() => ({ provider: { jwk: {} as Record } })); + +vi.mock('../../../src/models/oidcPayloads', () => ({ + OidcPayload: { + findOne: vi.fn(), + update: vi.fn(), + sequelize: { transaction: (fn: (transaction: unknown) => unknown) => fn({}) }, + }, +})); + +vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + +vi.mock('../../../src/services/oidcClientRegistry', async (importOriginal) => ({ + ...(await importOriginal()), + findActiveOidcClient: vi.fn(), +})); + +vi.mock('../../../src/services/oidcProviderKeys', () => ({ + getOidcPublicJwks: vi.fn(() => ({ activeKid: 'k1', keys: [provider.jwk] })), +})); + +vi.mock('../../../src/services/authEventService', () => ({ + AuthEventService: { log: vi.fn() }, +})); + +vi.mock('../../../src/lib/token', () => ({ + generateRefreshToken: vi.fn(() => 'refresh-token'), + createRefreshTokenLookup: vi.fn(() => 'lookup'), + signAccessToken: vi.fn(async () => 'access-token'), +})); + +import { getSystemConfig } from '../../../src/config/getSystemConfig'; +import { OidcPayload } from '../../../src/models/oidcPayloads'; +import { Session } from '../../../src/models/sessions'; +import { User } from '../../../src/models/users'; +import { AuthEventService } from '../../../src/services/authEventService'; +import { resolveCurrentSession } from '../../../src/services/oidcAuthContext'; +import { findActiveOidcClient } from '../../../src/services/oidcClientRegistry'; +import { getOidcPublicJwks } from '../../../src/services/oidcProviderKeys'; +import { exchangeSubjectToken } from '../../../src/services/sessionExchangeService'; +import { revokeSessionChain } from '../../../src/services/sessionService'; + +const ISSUER = 'https://login.example.com'; +const RESOURCE = `${ISSUER}/seamless`; +const USER = '11111111-1111-4111-8111-111111111111'; +const PARENT = '22222222-2222-4222-8222-222222222222'; +const CHILD = '33333333-3333-4333-8333-333333333333'; +const CLIENT = 'cli'; +const DEVICE = 'urn:ietf:params:oauth:grant-type:device_code'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +provider.jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256' }; + +const req = { ip: '127.0.0.1', get: () => 'test-agent' } as never; + +function sign(claims: Record = {}, typ = 'at+jwt') { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ + iss: ISSUER, + sub: USER, + aud: RESOURCE, + iat: now, + exp: now + 60, + jti: 'jti-1', + client_id: CLIENT, + scope: 'session:exchange', + ...claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1', typ }) + .sign(privateKey); +} + +function record(overrides: Record = {}) { + return { + id: 'jti-1', + sessionId: PARENT, + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + payload: { clientId: CLIENT, accountId: USER, grantType: 'authorization_code' }, + ...overrides, + }; +} + +function client(overrides: Record = {}) { + return { + id: CLIENT, + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + ...overrides, + }; +} + +const stepUpVerifiedAt = new Date(); +const authTime = new Date('2026-11-04T12:00:00.000Z'); + +function parent(overrides: Record = {}) { + return { + id: PARENT, + userId: USER, + infraId: 'infra', + organizationId: 'org', + recovery: false, + parentSessionId: null, + authMethods: [{ method: 'passkey' }], + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; +} + +function created() { + return vi.mocked(Session.create).mock.calls[0][0] as unknown as Record; +} + +beforeEach(() => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: ISSUER, + access_token_ttl: '15m', + refresh_token_ttl: '7d', + session_idle_ttl: '8h', + } as never); + vi.mocked(getOidcPublicJwks).mockImplementation( + () => ({ activeKid: 'k1', keys: [provider.jwk] }) as never, + ); + vi.mocked(OidcPayload.findOne).mockResolvedValue(record() as never); + vi.mocked(OidcPayload.update).mockResolvedValue([1] as never); + vi.mocked(findActiveOidcClient).mockResolvedValue(client() as never); + vi.mocked(resolveCurrentSession).mockResolvedValue(parent() as never); + vi.mocked(User.findOne).mockResolvedValue({ + id: USER, + email: 'a@example.com', + phone: null, + roles: ['user'], + } as never); + vi.mocked(Session.create).mockResolvedValue({ id: CHILD, parentSessionId: PARENT } as never); +}); + +describe('exchangeSubjectToken', () => { + it('issues a child capped at the parent, carrying its assurance', async () => { + const outcome = await exchangeSubjectToken(await sign(), req); + + expect(outcome).toMatchObject({ + ok: true, + response: { + token: 'access-token', + refreshToken: 'refresh-token', + sessionId: CHILD, + sub: USER, + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }, + }); + const row = created(); + expect(row).toMatchObject({ + parentSessionId: PARENT, + exchangeClientId: CLIENT, + exchangeGrantType: 'authorization_code', + organizationId: 'org', + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + }); + expect((row.expiresAt as Date).getTime()).toBeLessThanOrEqual(Date.now() + 60_000); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'session_exchanged', + sessionId: CHILD, + }); + }); + + it('falls back to the chain start for a parent with no recorded auth time', async () => { + const chainStartedAt = new Date('2026-11-01T00:00:00.000Z'); + vi.mocked(resolveCurrentSession).mockResolvedValue( + parent({ authTime: null, chainStartedAt }) as never, + ); + + await exchangeSubjectToken(await sign(), req); + + expect(created().authTime).toEqual(chainStartedAt); + }); + + it('copies no assurance for a device grant', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue( + record({ payload: { clientId: CLIENT, accountId: USER, grantType: DEVICE } }) as never, + ); + + await exchangeSubjectToken(await sign(), req); + + expect(created()).toMatchObject({ + exchangeGrantType: DEVICE, + authMethods: [], + authTime: null, + stepUpVerifiedAt: null, + stepUpMethod: null, + }); + }); + + it.each([ + ['another audience', { aud: 'https://api.example.com' }], + ['an audience list', { aud: [RESOURCE, 'https://api.example.com'] }], + ['an extra scope', { scope: 'session:exchange notices:send' }], + ['a client as subject', { sub: CLIENT }], + ['no client_id', { client_id: undefined }], + ['a numeric client_id', { client_id: 7 }], + ['no jti', { jti: undefined }], + ])('refuses a token with %s', async (_name, claims) => { + expect(await exchangeSubjectToken(await sign(claims), req)).toEqual({ + ok: false, + replayed: false, + }); + expect(OidcPayload.update).not.toHaveBeenCalled(); + }); + + it('refuses a token that is not at+jwt', async () => { + expect(await exchangeSubjectToken(await sign({}, 'JWT'), req)).toMatchObject({ ok: false }); + }); + + it('refuses when no provider is configured', async () => { + vi.mocked(getSystemConfig).mockResolvedValue({} as never); + + expect(await exchangeSubjectToken(await sign(), req)).toMatchObject({ ok: false }); + }); + + it('refuses when provider keys are unusable', async () => { + vi.mocked(getOidcPublicJwks).mockImplementation(() => { + throw new Error('no keys'); + }); + + expect(await exchangeSubjectToken(await sign(), req)).toMatchObject({ ok: false }); + }); + + it.each([ + ['no record', () => null], + ['another user', () => record({ payload: { clientId: CLIENT, accountId: randomUUID() } })], + ['another client', () => record({ payload: { clientId: 'x', accountId: USER } })], + ['an expired record', () => record({ expiresAt: new Date(Date.now() - 1) })], + [ + 'a grant that cannot exchange', + () => record({ payload: { clientId: CLIENT, accountId: USER, grantType: 'refresh_token' } }), + ], + ])('refuses %s', async (_name, found) => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(found() as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it.each([ + ['disabled', null], + ['with the flag off', client({ sessionExchangeEnabled: false })], + ['third-party', client({ firstParty: false })], + ])('refuses a client that is %s', async (_name, found) => { + vi.mocked(findActiveOidcClient).mockResolvedValue(found as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it.each([ + ['gone', null], + ['another user', parent({ userId: randomUUID() })], + ['a recovery session', parent({ recovery: true })], + ['itself a child', parent({ parentSessionId: randomUUID() })], + ])('refuses a parent that is %s', async (_name, found) => { + vi.mocked(resolveCurrentSession).mockResolvedValue(found as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('refuses a record with no session', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(record({ sessionId: null }) as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('refuses a revoked user', async () => { + vi.mocked(User.findOne).mockResolvedValue(null as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + describe('a replay', () => { + const consumed = () => + record({ + consumedAt: new Date(), + payload: { clientId: CLIENT, accountId: USER, childSessionId: CHILD }, + }); + + it('revokes what the first use created', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(consumed() as never); + const child = { id: CHILD, parentSessionId: PARENT }; + vi.mocked(Session.findByPk).mockResolvedValue(child as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + + expect(revokeSessionChain).toHaveBeenCalledWith(child, 'session_exchange_replayed'); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'session_exchange_replayed', + sessionId: CHILD, + metadata: { clientId: CLIENT, parentSessionId: PARENT }, + }); + }); + + it('is still refused when the child is gone', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue( + record({ + consumedAt: new Date(), + payload: { clientId: CLIENT, accountId: USER }, + }) as never, + ); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + expect(revokeSessionChain).not.toHaveBeenCalled(); + }); + + it('is caught when another presentation wins the consume', async () => { + vi.mocked(OidcPayload.update).mockResolvedValue([0] as never); + vi.mocked(OidcPayload.findOne) + .mockResolvedValueOnce(record() as never) + .mockResolvedValueOnce(consumed() as never); + vi.mocked(Session.findByPk).mockResolvedValue({ id: CHILD } as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + expect(Session.create).not.toHaveBeenCalled(); + }); + }); + + it('refuses without a replay when the record expires during the consume', async () => { + vi.mocked(OidcPayload.update).mockResolvedValue([0] as never); + vi.mocked(OidcPayload.findOne) + .mockResolvedValueOnce(record() as never) + .mockResolvedValueOnce(record() as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('revokes the child when the client is switched off during the exchange', async () => { + vi.mocked(findActiveOidcClient) + .mockResolvedValueOnce(client() as never) + .mockResolvedValueOnce(client({ sessionExchangeEnabled: false }) as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + + expect(revokeSessionChain).toHaveBeenCalledWith( + expect.objectContaining({ id: CHILD }), + 'session_exchange_disabled', + ); + expect(AuthEventService.log).not.toHaveBeenCalled(); + }); +}); From 5a3ccdb3932b9e8eb47adb3c61c0a0eeb4a27e22 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:48:58 -0500 Subject: [PATCH 07/20] test(sessions): cover the child session lifecycle Parent check on validate and refresh, the lifetime cap, parent activity, and each revoke path ending the child and not the reverse. Refs #544 --- tests/postgres/sessionExchange.spec.ts | 308 +++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index ffe080a..c3d6117 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -32,6 +32,7 @@ type Registry = typeof import('../../src/services/oidcClientRegistry.js'); type Applications = typeof import('../../src/services/oidcApplicationService.js'); type Store = typeof import('../../src/services/oidcPayloadStore.js'); type Tokens = typeof import('../../src/lib/token.js'); +type Sessions = typeof import('../../src/services/sessionService.js'); let app: App; let signing: Signing; @@ -39,6 +40,7 @@ let registry: Registry; let applications: Applications; let store: Store; let tokens: Tokens; +let sessions: Sessions; const savedEnv: Record = {}; function setEnv(values: Record) { @@ -88,6 +90,7 @@ beforeAll(async () => { applications = await import('../../src/services/oidcApplicationService.js'); store = await import('../../src/services/oidcPayloadStore.js'); tokens = await import('../../src/lib/token.js'); + sessions = await import('../../src/services/sessionService.js'); const { initDevSigningKey } = await import('../../src/utils/signingKeyStore.js'); const { createApp } = await import('../../src/app.js'); @@ -1107,3 +1110,308 @@ describe('POST /sessions/exchange', () => { expect(children.every((child) => child.revokedAt !== null)).toBe(true); }); }); + +describe('the child session lifecycle', () => { + /** A signed-in parent with known refresh token, and the child exchanged from it. */ + async function family( + parentOverrides: Partial = {}, + roles: string[] = ['user'], + grantType = AUTHORIZATION_CODE, + ) { + const parentRefresh = tokens.generateRefreshToken(); + const { client, user, parent } = await signedInToCli( + { refreshTokenLookup: tokens.createRefreshTokenLookup(parentRefresh), ...parentOverrides }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + expect(res.status).toBe(200); + return { + client, + user, + parent, + parentRefresh, + childId: res.body.sessionId as string, + childToken: res.body.token as string, + childRefresh: res.body.refreshToken as string, + }; + } + + function refresh(token: string) { + return request(app).post('/refresh').set('Authorization', `Bearer ${token}`); + } + + async function expectEnded(childId: string, childToken: string) { + expect((await me(childToken)).status).toBe(401); + const child = await Session.findByPk(childId); + expect(child?.revokedAt).not.toBeNull(); + } + + describe('validity', () => { + it('refuses a child whose parent ended, even if no cascade reached it', async () => { + const { parent, childId, childToken } = await family(); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + const row = await Session.findByPk(childId); + expect(row?.revokedAt).toBeNull(); + expect((await me(childToken)).status).toBe(401); + }); + + it('refuses a child whose parent idled out', async () => { + const { parent, childToken } = await family(); + await Session.update( + { idleExpiresAt: new Date(Date.now() - 1000) }, + { where: { id: parent.id } }, + ); + + expect((await me(childToken)).status).toBe(401); + }); + + it('keeps a child live while its parent rotates, and ends it with the current row', async () => { + const { parentRefresh, childToken, childId } = await family(); + + const rotated = await refresh(parentRefresh); + expect(rotated.status).toBe(200); + expect((await me(childToken)).status).toBe(200); + + const logout = await request(app) + .delete('/logout') + .set('Authorization', `Bearer ${rotated.body.token}`); + expect(logout.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('leaves an ordinary session with no parent alone', async () => { + const user = await createUser(); + const session = await createSession(user.id); + + expect((await me(await accessTokenFor(session))).status).toBe(200); + }); + }); + + describe('refresh', () => { + it('carries the link forward on rotation', async () => { + const { client, parent, childRefresh, childId } = await family(); + + const res = await refresh(childRefresh); + + expect(res.status).toBe(200); + expect(res.body.sessionId).not.toBe(childId); + expect(await Session.findByPk(res.body.sessionId)).toMatchObject({ + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + }); + + it('refuses and ends a child whose parent ended', async () => { + const { user, parent, childRefresh, childId } = await family(); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + const res = await refresh(childRefresh); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_refresh_token'); + const child = await Session.findByPk(childId); + expect(child?.revokedReason).toBe('parent_session_ended'); + expect(child?.replacedBySessionId).toBeNull(); + const event = await AuthEvent.findOne({ + where: { type: 'refresh_token_failed', user_id: user.id, session_id: childId }, + }); + expect((event?.metadata as Record)?.refusal).toBe('parent_session_ended'); + }); + + it('never outlives the parent', async () => { + const parentExpiry = new Date(Date.now() + 30 * 60_000); + const { childRefresh } = await family({ + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }); + + const res = await refresh(childRefresh); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.expiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(child?.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(res.body.refreshTtl).toBeLessThanOrEqual(30 * 60); + }); + + it('counts as activity on the parent, up to its absolute expiry', async () => { + const parentExpiry = new Date(Date.now() + 2 * 3_600_000); + const { parent, childRefresh } = await family({ + expiresAt: parentExpiry, + idleExpiresAt: new Date(Date.now() + 60_000), + lastUsedAt: new Date(Date.now() - 3_600_000), + }); + + const res = await refresh(childRefresh); + expect(res.status).toBe(200); + + await parent.reload(); + expect(parent.idleExpiresAt.getTime()).toBeGreaterThan(Date.now() + 60_000); + expect(parent.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(parent.lastUsedAt.getTime()).toBeGreaterThan(Date.now() - 60_000); + }); + + it('revokes only the child chain on refresh token reuse', async () => { + const { parent, childRefresh } = await family(); + + const rotated = await refresh(childRefresh); + const reused = await refresh(childRefresh); + + expect(reused.status).toBe(401); + expect((await me(rotated.body.token)).status).toBe(401); + await parent.reload(); + expect(parent.revokedAt).toBeNull(); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + }); + + describe('every way a parent ends takes the child with it', () => { + it('logout', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/logout') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('sign out everywhere', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/logout/all') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('revoking the session from the user session list', async () => { + const { parent, childId, childToken } = await family(); + const other = await createSession(parent.userId); + + const res = await request(app) + .delete(`/sessions/${parent.id}`) + .set('Authorization', `Bearer ${await accessTokenFor(other)}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('revoking all sessions from the user session list', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/sessions') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('an admin revoking the session', async () => { + const { parent, childId, childToken } = await family(); + const { token } = await admin(); + + const res = await request(app) + .delete(`/admin/sessions/by-id/${parent.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('an admin revoking every session of the user', async () => { + const { user, childId, childToken } = await family(); + const { token } = await admin(); + + const res = await request(app) + .delete(`/admin/sessions/${user.id}/revoke-all`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('refresh token reuse on the parent chain', async () => { + const { parentRefresh, childId, childToken } = await family(); + + await refresh(parentRefresh); + const reused = await refresh(parentRefresh); + expect(reused.status).toBe(401); + + await expectEnded(childId, childToken); + }); + + it('the provider revoking the sign-in, from the row it recorded', async () => { + const { parent, parentRefresh, childId, childToken } = await family(); + const rotated = await refresh(parentRefresh); + expect(rotated.status).toBe(200); + + await sessions.revokeSessionChain(parent, 'oidc_end_session'); + + await expectEnded(childId, childToken); + }); + + it('concurrent session eviction', async () => { + const { user, parent, childId, childToken } = await family(); + const { enforceConcurrentSessionLimit } = + await import('../../src/services/concurrentSessionPolicy.js'); + + const evicted = await enforceConcurrentSessionLimit({ + userId: user.id, + limit: 1, + req: { ip: '127.0.0.1', get: () => undefined, headers: {} } as never, + }); + + expect(evicted).toBe(1); + await parent.reload(); + expect(parent.revokedReason).toBe('concurrent_session_limit'); + await expectEnded(childId, childToken); + }); + }); + + describe('ending a child leaves the parent alone', () => { + it('logout', async () => { + const { parent, childId, childToken } = await family(); + + const res = await request(app).delete('/logout').set('Authorization', `Bearer ${childToken}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + + it('an admin revoking the child', async () => { + const { parent, childId, childToken } = await family(); + const { token } = await admin(); + + await request(app) + .delete(`/admin/sessions/by-id/${childId}`) + .set('Authorization', `Bearer ${token}`); + + await expectEnded(childId, childToken); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + }); + + it('does not count a child against the concurrent session limit', async () => { + const { user, parent, childId } = await family(); + const { enforceConcurrentSessionLimit } = + await import('../../src/services/concurrentSessionPolicy.js'); + + const evicted = await enforceConcurrentSessionLimit({ + userId: user.id, + limit: 2, + req: { ip: '127.0.0.1', get: () => undefined, headers: {} } as never, + }); + + expect(evicted).toBe(0); + await parent.reload(); + expect(parent.revokedAt).toBeNull(); + expect((await Session.findByPk(childId))?.revokedAt).toBeNull(); + }); +}); From 03184aa21f74b97db572f7f43d1a26c0f3883f93 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:50:01 -0500 Subject: [PATCH 08/20] feat(sessions): tie an exchanged session to its parent validateSessionRecord and the refresh path refuse a child whose parent no longer resolves to a live session. Rotation carries the link and is capped at the parent's lifetime, a child refresh counts as activity on the parent, and every revoke path cascades. A child is not counted against the concurrent session limit, since evicting a parent to make room for its own child would end both. Refs #544 --- resources/coverage-badge.svg | 14 +++---- src/controllers/authentication.ts | 41 ++++++++++++++++++++- src/services/concurrentSessionPolicy.ts | 7 ++++ src/services/oidcSigning.ts | 5 ++- src/services/sessionRevocation.ts | 3 ++ src/services/sessionService.ts | 49 ++++++++++++++++++++++++- tests/setup/mocks.ts | 1 + 7 files changed, 108 insertions(+), 12 deletions(-) diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88..bfd39a2 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99% + + coverage: 98.9% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99% - 99% + 98.9% + 98.9% diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 1bce624..2b014af 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -33,9 +33,15 @@ import { LoginMethod, resolveAvailableLoginMethods, } from '../services/loginPolicyService.js'; +import { resolveCurrentSession } from '../services/oidcAuthContext.js'; import { sweepBackchannelLogoutInBackground } from '../services/oidcBackchannelLogout.js'; +import { recordProviderActivity } from '../services/oidcSigning.js'; import { hasUsableRecoveryCode } from '../services/recoveryCodeService.js'; -import { isRevokedByCutoff, revokeAllSessionsForUsers } from '../services/sessionRevocation.js'; +import { + isRevokedByCutoff, + PARENT_SESSION_ENDED, + revokeAllSessionsForUsers, +} from '../services/sessionRevocation.js'; import { claimSessionRotation, classifyExpiredRefreshToken, @@ -618,13 +624,34 @@ export const refreshSession = async (req: Request, res: Response) => { return refuseRevokedRefresh(req, res, user.id, session); } + // An exchanged session ends with its parent (ADR 0003, X4), and is never refreshed past it. + const parent = session.parentSessionId + ? await resolveCurrentSession(session.parentSessionId) + : null; + + if (session.parentSessionId && !parent) { + await hardRevokeSession(session, PARENT_SESSION_ENDED); + await AuthEventService.log({ + userId: user.id, + sessionId: session.id, + type: 'refresh_token_failed', + req, + metadata: { reason: 'Parent session ended', refusal: PARENT_SESSION_ENDED }, + }); + return res.status(401).json({ error: 'invalid_refresh_token' }); + } + const { access_token_ttl, refresh_token_ttl, session_idle_ttl } = await getSystemConfig(); const chainStartedAt = session.chainStartedAt ?? session.createdAt ?? now; - const { expiresAt, idleExpiresAt } = computeRotatedSessionTimes( + const times = computeRotatedSessionTimes( { absoluteTtl: refresh_token_ttl || '1d', idleTtl: session_idle_ttl || '8h' }, chainStartedAt, now, ); + const expiresAt = parent + ? new Date(Math.min(times.expiresAt.getTime(), new Date(parent.expiresAt).getTime())) + : times.expiresAt; + const idleExpiresAt = new Date(Math.min(times.idleExpiresAt.getTime(), expiresAt.getTime())); // Only reachable when the absolute lifetime was shortened after this chain began, since // the lookup already refuses a session past its own expiry. @@ -663,6 +690,9 @@ export const refreshSession = async (req: Request, res: Response) => { // the sign-in. authMethods: session.authMethods ?? [], authTime: session.authTime ?? null, + parentSessionId: session.parentSessionId ?? null, + exchangeClientId: session.exchangeClientId ?? null, + exchangeGrantType: session.exchangeGrantType ?? null, }); // The read, the reuse check and this link are separate statements, so two refreshes @@ -714,6 +744,13 @@ export const refreshSession = async (req: Request, res: Response) => { return refuseRevokedRefresh(req, res, user.id, newSession); } + if (parent) { + // After the rotation, so a failure here cannot cost the client the tokens it just rotated. + await recordProviderActivity(parent.id).catch((error: unknown) => { + logger.error(`Could not record activity on a parent session: ${error}`); + }); + } + const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId, { recovery: session.recovery === true, }); diff --git a/src/services/concurrentSessionPolicy.ts b/src/services/concurrentSessionPolicy.ts index 6748ccb..15e02ed 100644 --- a/src/services/concurrentSessionPolicy.ts +++ b/src/services/concurrentSessionPolicy.ts @@ -10,6 +10,7 @@ import { Op } from 'sequelize'; import { Session } from '../models/sessions.js'; import getLogger from '../utils/logger.js'; import { AuthEventService } from './authEventService.js'; +import { revokeOrphanedChildSessions } from './sessionService.js'; const logger = getLogger('concurrentSessionPolicy'); @@ -32,6 +33,10 @@ export const EVICTION_REASON = 'concurrent_session_limit'; * leaves users above it, and they should converge on the next sign-in rather * than one session per login forever. * + * An exchanged session does not count and is never evicted here. It ends with its parent, so + * the parent is what the limit counts, and evicting the parent to make room for its own child + * would end both. + * * Never throws. A session that could not be revoked is logged and the sign-in * continues, because failing an authentication over a housekeeping step is worse * than briefly exceeding the cap. @@ -56,6 +61,7 @@ export async function enforceConcurrentSessionLimit(params: { userId, revokedAt: null, replacedBySessionId: null, + parentSessionId: null, expiresAt: { [Op.gt]: now }, }, order: [['createdAt', 'ASC']], @@ -73,6 +79,7 @@ export async function enforceConcurrentSessionLimit(params: { for (const session of evicted) { await session.update({ revokedAt: now, revokedReason: EVICTION_REASON }); + await revokeOrphanedChildSessions(session); await AuthEventService.log({ userId, diff --git a/src/services/oidcSigning.ts b/src/services/oidcSigning.ts index 168eebf..3bf6989 100644 --- a/src/services/oidcSigning.ts +++ b/src/services/oidcSigning.ts @@ -251,9 +251,10 @@ export async function signForOidcEdge(request: OidcSignRequest): Promise, +) { + if (session.parentSessionId) return; + + try { + const children = await Session.findAll({ + where: { + userId: session.userId, + parentSessionId: { [Op.ne]: null }, + revokedAt: null, + replacedBySessionId: null, + }, + }); + + for (const child of children) { + if (await resolveCurrentSession(child.parentSessionId as string)) continue; + + await Session.update( + { revokedAt: new Date(), revokedReason: PARENT_SESSION_ENDED }, + { where: { id: child.id, revokedAt: null } }, + ); + } + } catch (error) { + logger.error(`Could not revoke the child sessions of a session that ended: ${error}`); + } } /** @@ -255,6 +296,12 @@ export async function validateSessionRecord(sessionId: string) { }); if (!owner || isRevokedByCutoff(session, owner.sessionsRevokedBefore)) return null; + // An exchanged session ends with its parent whether or not a revoke's cascade reached it, + // and the parent also ends without any revoke when it expires or idles out (ADR 0003, X4). + if (session.parentSessionId && !(await resolveCurrentSession(session.parentSessionId))) { + return null; + } + return session; } diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index a1787ce..bc3e20d 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -224,6 +224,7 @@ vi.mock('../../src/services/sessionService.js', () => ({ verifyJwtWithKid: vi.fn(), revokeSessionChain: vi.fn(), hardRevokeSession: vi.fn(), + revokeOrphanedChildSessions: vi.fn(), liftRecoveryRestriction: vi.fn(async () => true), })); From 4bc92811a3afeec9aa33b05341bd219dc84e8f71 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:50:34 -0500 Subject: [PATCH 09/20] test(sessions): cover device child step-up and session origin in lists Refs #544 --- tests/postgres/sessionExchange.spec.ts | 153 +++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index c3d6117..1310206 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -1415,3 +1415,156 @@ describe('the child session lifecycle', () => { expect((await Session.findByPk(childId))?.revokedAt).toBeNull(); }); }); + +describe('a device child on the admin routes (X7)', () => { + const ADMIN_ROLES = ['admin:read', 'admin:write']; + + async function child(grantType: string, roles = ADMIN_ROLES) { + const { client, user, parent } = await signedInToCli( + { stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + return { user, parent, token: res.body.token as string, sessionId: res.body.sessionId }; + } + + function adminRead(token: string) { + return request(app).get('/admin/users').set('Authorization', `Bearer ${token}`); + } + + it('is refused on an admin read until it steps up, with the step-up body', async () => { + const { token } = await child(DEVICE_CODE); + + const res = await adminRead(token); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: null }); + }); + + it('borrows nothing from the approving browser', async () => { + const { parent, token, sessionId } = await child(DEVICE_CODE); + + await parent.reload(); + expect(parent.stepUpVerifiedAt).not.toBeNull(); + expect((await adminRead(token)).status).toBe(403); + expect((await Session.findByPk(sessionId))?.stepUpVerifiedAt).toBeNull(); + }); + + it('gets in after a TOTP step-up on its own session', async () => { + const { user, token, sessionId } = await child(DEVICE_CODE); + const { recordStepUpVerification } = await import('../../src/services/stepUpService.js'); + + await recordStepUpVerification({ sessionId, userId: user.id, method: 'totp' }); + + expect((await adminRead(token)).status).toBe(200); + }); + + it('is refused again once the step-up goes stale', async () => { + const { token, sessionId } = await child(DEVICE_CODE); + await Session.update( + { stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), stepUpMethod: 'totp' }, + { where: { id: sessionId } }, + ); + + expect((await adminRead(token)).status).toBe(403); + }); + + it('still needs the role, and says so before it asks for a step-up', async () => { + const { token } = await child(DEVICE_CODE, ['user']); + + const res = await adminRead(token); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + }); + + it('can reach its own account without a step-up', async () => { + const { token } = await child(DEVICE_CODE); + + expect((await me(token)).status).toBe(200); + }); + + it('does not apply to a loopback child, which inherited its step-up', async () => { + const { token } = await child(AUTHORIZATION_CODE); + + expect((await adminRead(token)).status).toBe(200); + }); + + it('does not change a loopback child that has no step-up on an admin read', async () => { + const { client, parent } = await signedInToCli({}, {}, ADMIN_ROLES); + const { token: subject } = await signSubject(client, parent); + const res = await exchange(subject); + + expect((await adminRead(res.body.token)).status).toBe(200); + }); + + it('applies to a rotated device child too', async () => { + const { client, parent } = await signedInToCli({}, {}, ADMIN_ROLES); + const { token: subject } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const first = await exchange(subject); + const rotated = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${first.body.refreshToken}`); + + expect((await adminRead(rotated.body.token)).status).toBe(403); + }); +}); + +describe('session lists', () => { + async function family(grantType = DEVICE_CODE) { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + return { client, user, parent, child: res.body as Record }; + } + + it('labels a child with its client and grant in /sessions, and leaves the parent plain', async () => { + const { client, parent, child } = await family(); + + const res = await request(app).get('/sessions').set('Authorization', `Bearer ${child.token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed).toMatchObject({ + current: true, + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: DEVICE_CODE, + }); + const plain = res.body.sessions.find((row: { id: string }) => row.id === parent.id); + expect(plain).not.toHaveProperty('exchangeClientId'); + expect(plain).not.toHaveProperty('parentSessionId'); + }); + + it.each([ + ['the user sessions view', (userId: string) => `/admin/sessions/${userId}`], + ['the user detail view', (userId: string) => `/admin/users/${userId}`], + ])('labels a child in %s', async (_name, url) => { + const { client, user, child } = await family(); + const { token } = await admin(); + + const res = await request(app).get(url(user.id)).set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed).toMatchObject({ + exchangeClientId: client.id, + exchangeGrantType: DEVICE_CODE, + }); + }); + + it('labels a child in the instance-wide view', async () => { + const { client, child } = await family(); + const { token } = await admin(); + + const res = await request(app) + .get('/admin/sessions?limit=1000') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed?.exchangeClientId).toBe(client.id); + }); +}); From da2771d6c52d4e7cf1c50caee9c86520a3404e45 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:53:08 -0500 Subject: [PATCH 10/20] feat(sessions): require a step-up on admin routes for device children requireAdmin answers 403 step_up_required to a session exchanged from a device grant until it has a fresh step-up of its own, reads included (ADR 0003, X7). Session lists show the parent, client and grant of a child session. Refs #544 --- resources/coverage-badge.svg | 8 ++++---- src/middleware/requireAdmin.ts | 15 +++++++++++++++ src/middleware/verifyBearerAuth.ts | 3 +++ src/services/apiResponseSerializers.ts | 3 +++ src/services/sessionService.ts | 11 +++++++++++ src/types/types.ts | 2 ++ tests/postgres/sessionExchange.spec.ts | 7 ++++++- 7 files changed, 44 insertions(+), 5 deletions(-) diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index bfd39a2..fdefd25 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 98.9% + + coverage: 99.3% @@ -17,7 +17,7 @@ coverage coverage - 98.9% - 98.9% + 99.3% + 99.3% diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts index ab0d2bc..0b11838 100644 --- a/src/middleware/requireAdmin.ts +++ b/src/middleware/requireAdmin.ts @@ -7,6 +7,7 @@ import { NextFunction, Response } from 'express'; import { hasScopedRole } from '../lib/scopedRoles.js'; +import { getStepUpStatusFromSession, serializeStepUpStatus } from '../services/stepUpService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -29,6 +30,20 @@ export function requireAdmin(accessLevel?: AdminAccessLevel) { return res.status(403).json({ error: 'Forbidden' }); } + // ADR 0003, X7. Someone who phished a device approval holds a session like this without + // holding the user's factor, so even a read waits for one (RFC 8628 section 5.4). + if (req.deviceChildSession) { + const status = getStepUpStatusFromSession(req.deviceChildSession); + + if (!status.fresh) { + return res.status(403).json({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + ...serializeStepUpStatus(status), + }); + } + } + next(); } catch (err) { logger.error(`requireAdmin failure: ${err}`); diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index e0a2495..dab3c9f 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -89,6 +89,9 @@ export async function verifyBearerAuth( if (result.attemptId !== undefined) { (req as AuthenticatedRequest).attemptId = result.attemptId; } + if (result.deviceChildSession !== undefined) { + (req as AuthenticatedRequest).deviceChildSession = result.deviceChildSession; + } if (result.organizationId !== undefined) { (req as AuthenticatedRequest).organizationId = result.organizationId; } diff --git a/src/services/apiResponseSerializers.ts b/src/services/apiResponseSerializers.ts index 94afbbd..aa1312f 100644 --- a/src/services/apiResponseSerializers.ts +++ b/src/services/apiResponseSerializers.ts @@ -159,5 +159,8 @@ export function serializeSession(session: unknown, currentSessionId?: string | n lastUsedAt: dateField(session, 'lastUsedAt'), expiresAt: dateField(session, 'expiresAt'), current: currentSessionId ? id === currentSessionId : false, + parentSessionId: nullableStringField(session, 'parentSessionId') ?? undefined, + exchangeClientId: nullableStringField(session, 'exchangeClientId') ?? undefined, + exchangeGrantType: nullableStringField(session, 'exchangeGrantType') ?? undefined, }); } diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index d02e91b..3375c7c 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -22,6 +22,7 @@ const logger = getLogger('sessionService'); export type AuthTokenType = 'ephemeral' | 'access'; const ISSUER = process.env.ISSUER!; +const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; export interface ValidatedAccessToken { userId: string; @@ -322,6 +323,8 @@ export interface ValidatedBearerToken { attemptId?: string; /** An access session a recovery issued, restricted until a new factor is enrolled. */ recovery?: boolean; + /** The step-up state of a session exchanged from a device grant (ADR 0003, X7). */ + deviceChildSession?: Pick; } export async function validateEphemeralToken(token: string): Promise { @@ -391,6 +394,14 @@ export async function validateBearerToken( // Read from the session rather than the token: enrolling a factor clears it on // the row, which takes effect at once, before the client next refreshes. recovery: session.recovery === true, + ...(session.exchangeGrantType === DEVICE_CODE_GRANT + ? { + deviceChildSession: { + stepUpVerifiedAt: session.stepUpVerifiedAt, + stepUpMethod: session.stepUpMethod, + }, + } + : {}), } : null; } diff --git a/src/types/types.ts b/src/types/types.ts index 555c6ce..a6fc5f6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -35,6 +35,8 @@ export interface AuthenticatedRequest extends RouteRequest { attemptId?: string; /** Set on an access session a recovery issued; see `allowRecoverySession`. */ recoverySession?: boolean; + /** The step-up state of a session exchanged from a device grant; see `requireAdmin`. */ + deviceChildSession?: Pick; organizationId?: string | null; clientId?: string; trustedClientIp?: string; diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 1310206..355fc26 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -1559,8 +1559,13 @@ describe('session lists', () => { const { client, child } = await family(); const { token } = await admin(); + // Newest rows come last, and the route takes at most a page at a time. + const first = await request(app) + .get('/admin/sessions?limit=1') + .set('Authorization', `Bearer ${token}`); + const offset = Math.max(first.body.total - 50, 0); const res = await request(app) - .get('/admin/sessions?limit=1000') + .get(`/admin/sessions?limit=50&offset=${offset}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); From 957be2f3b58ba7e7aa0d21b0b970982200a1b20d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:53:25 -0500 Subject: [PATCH 11/20] test(sessions): add mock database coverage for session exchange The coverage gate runs without Postgres, so the exchange, signing, lifecycle and admin paths need coverage there too. Refs #544 --- .../authentication/authentication.spec.ts | 92 +++++++++++++ .../integration/oidc/oidcApplications.spec.ts | 77 +++++++++++ .../session/sessionExchangeRoute.spec.ts | 60 +++++++++ tests/unit/middleware/rateLimit.spec.ts | 1 + tests/unit/middleware/requireAdmin.spec.ts | 38 ++++++ .../unit/middleware/verifyBearerAuth.spec.ts | 15 +++ tests/unit/services/oidcPayloadStore.spec.ts | 36 +++++ tests/unit/services/oidcSigning.spec.ts | 127 +++++++++++++++++- tests/unit/services/sessionService.spec.ts | 89 ++++++++++++ 9 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 tests/integration/session/sessionExchangeRoute.spec.ts diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 8cf6420..91dd559 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -24,6 +24,8 @@ import { AuthEvent } from '../../../src/models/authEvents'; import { AuthFailure } from '../../../src/models/authFailures'; import { AuthEventService } from '../../../src/services/authEventService'; import { logoutCurrentSession } from '../../../src/controllers/authentication'; +import { resolveCurrentSession } from '../../../src/services/oidcAuthContext'; +import { recordProviderActivity } from '../../../src/services/oidcSigning'; let app: Application; @@ -44,6 +46,16 @@ function buildReq(overrides: Record = {}) { } as any; } +vi.mock('../../../src/services/oidcAuthContext.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + +vi.mock('../../../src/services/oidcSigning.js', async (importOriginal) => ({ + ...(await importOriginal()), + recordProviderActivity: vi.fn(), +})); + vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { const actual = await importOriginal(); @@ -684,6 +696,86 @@ describe('POST /refresh', () => { expect(res.body.refreshToken).toBe('refresh'); }); + describe('of an exchanged session', () => { + const child = () => ({ + id: 'child-1', + replacedBySessionId: null, + revokedAt: null, + userId: 'user-1', + infraId: 'app', + mode: 'server', + parentSessionId: 'parent-1', + exchangeClientId: 'cli', + exchangeGrantType: 'authorization_code', + chainStartedAt: new Date(), + save: vi.fn(), + }); + + beforeEach(() => { + (User.findOne as any).mockResolvedValue(buildUser()); + (Session.create as any).mockResolvedValue({ id: 'new-session' }); + (claimSessionRotation as any).mockResolvedValue(true); + (signAccessToken as any).mockResolvedValue('access'); + (generateRefreshToken as any).mockReturnValue('refresh'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + }); + vi.mocked(recordProviderActivity).mockResolvedValue(undefined); + }); + + it('refuses and ends it once its parent has ended', async () => { + const session = child(); + (findRefreshSessionByToken as any).mockResolvedValue(session); + vi.mocked(resolveCurrentSession).mockResolvedValue(null); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_refresh_token'); + expect(hardRevokeSession).toHaveBeenCalledWith(session, 'parent_session_ended'); + expect(Session.create).not.toHaveBeenCalled(); + }); + + it('keeps the link, stays inside the parent, and records activity on it', async () => { + const parentExpiry = new Date(Date.now() + 10 * 60_000); + (findRefreshSessionByToken as any).mockResolvedValue(child()); + vi.mocked(resolveCurrentSession).mockResolvedValue({ + id: 'parent-now', + expiresAt: parentExpiry, + } as never); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(200); + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: 'parent-1', + exchangeClientId: 'cli', + exchangeGrantType: 'authorization_code', + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }), + ); + expect(recordProviderActivity).toHaveBeenCalledWith('parent-now'); + }); + + it('still answers when the activity cannot be recorded', async () => { + (findRefreshSessionByToken as any).mockResolvedValue(child()); + vi.mocked(resolveCurrentSession).mockResolvedValue({ + id: 'parent-now', + expiresAt: new Date(Date.now() + 10 * 60_000), + } as never); + vi.mocked(recordProviderActivity).mockRejectedValue(new Error('down')); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(200); + }); + }); + it('carries the sign-in auth context to the rotated session', async () => { const authTime = new Date('2026-10-30T12:00:00.000Z'); const authMethods = [{ method: 'passkey', at: authTime.toISOString(), deviceBound: true }]; diff --git a/tests/integration/oidc/oidcApplications.spec.ts b/tests/integration/oidc/oidcApplications.spec.ts index 2d66ca1..3d6f26b 100644 --- a/tests/integration/oidc/oidcApplications.spec.ts +++ b/tests/integration/oidc/oidcApplications.spec.ts @@ -175,6 +175,25 @@ describe('POST /admin/applications', () => { expect(loggedTypes()).toEqual(['admin_oidc_application_created']); }); + it('audits creating a client with session exchange on', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'CLI', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + }); + + expect(res.status).toBe(201); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_created', + 'admin_oidc_application_session_exchange_updated', + ]); + }); + it('defaults a confidential client to private_key_jwt', async () => { const res = await request(app) .post('/admin/applications') @@ -510,6 +529,64 @@ describe('PATCH /admin/applications/:id', () => { }); }); + describe('sessionExchangeEnabled', () => { + const native = () => + row({ + clientType: 'public', + firstParty: true, + tokenEndpointAuthMethod: 'none', + jwksUri: null, + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + }); + + it('audits turning it off and revokes what the client exchanged for', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + const res = await request(app) + .patch('/admin/applications/app-1') + .send({ sessionExchangeEnabled: false }); + + expect(res.status).toBe(200); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: 'app-1', sessionExchangeEnabled: false }, + }); + expect(vi.mocked(Session.update).mock.calls[0]).toEqual([ + expect.objectContaining({ revokedReason: 'session_exchange_disabled' }), + { where: { exchangeClientId: 'app-1', revokedAt: null } }, + ]); + }); + + it('revokes them when the client is disabled', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + await request(app).patch('/admin/applications/app-1').send({ disabled: true }); + + expect(vi.mocked(Session.update).mock.calls[0][0]).toMatchObject({ + revokedReason: 'oidc_client_disabled', + }); + }); + + it('leaves them alone on any other change', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + await request(app).patch('/admin/applications/app-1').send({ name: 'Renamed' }); + + expect(Session.update).not.toHaveBeenCalled(); + }); + + it('is refused on a client that is not first-party public native', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(row() as never); + + const res = await request(app) + .patch('/admin/applications/app-1') + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(400); + }); + }); + it('audits a PKCE change on its own, and not a patch that leaves it alone', async () => { const existing = row(); vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); diff --git a/tests/integration/session/sessionExchangeRoute.spec.ts b/tests/integration/session/sessionExchangeRoute.spec.ts new file mode 100644 index 0000000..ecd4290 --- /dev/null +++ b/tests/integration/session/sessionExchangeRoute.spec.ts @@ -0,0 +1,60 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/services/sessionExchangeService.js', () => ({ + exchangeSubjectToken: vi.fn(), +})); + +import { createApp } from '../../../src/app'; +import { exchangeSubjectToken } from '../../../src/services/sessionExchangeService.js'; + +let app: Application; + +const body = { + 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', +}; + +beforeAll(async () => { + app = await createApp(); +}); + +describe('POST /sessions/exchange', () => { + it('answers the session the service issued, and no one may cache it', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ + ok: true, + response: { + message: 'Success', + token: 'access', + refreshToken: 'refresh', + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }, + }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.body).toMatchObject({ token: 'access', refreshToken: 'refresh' }); + }); + + it('tells the client a replay happened', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ ok: false, replayed: true }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request', error_description: 'token_already_used' }); + }); + + it('answers every other failure with one body', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ ok: false, replayed: false }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request' }); + }); +}); diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index 8529e84..b7e0c3d 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -208,6 +208,7 @@ describe('flow limiters', () => { ['magicLinkIpLimiter', 20], ['magicLinkEmailLimiter', 20], ['otpIpLimiter', 10], + ['sessionExchangeLimiter', 30], ['otpIdentityLimiter', 20], ['oauthIpLimiter', 30], ['oauthProviderLimiter', 10], diff --git a/tests/unit/middleware/requireAdmin.spec.ts b/tests/unit/middleware/requireAdmin.spec.ts index 68c3582..b36211c 100644 --- a/tests/unit/middleware/requireAdmin.spec.ts +++ b/tests/unit/middleware/requireAdmin.spec.ts @@ -151,4 +151,42 @@ describe('requireAdmin', () => { error: 'Internal server error', }); }); + + describe('a session exchanged from a device grant', () => { + const admin = { id: 'user-1', roles: ['admin:read'] }; + + it('needs a fresh step-up, even to read', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = admin; + req.deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + await requireAdmin('read')(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes once it has one', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = admin; + req.deviceChildSession = { stepUpVerifiedAt: new Date(), stepUpMethod: 'totp' }; + + await requireAdmin('read')(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('checks the role first', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = { id: 'user-1', roles: ['user'] }; + req.deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + await requireAdmin('read')(req, res, next); + + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + }); }); diff --git a/tests/unit/middleware/verifyBearerAuth.spec.ts b/tests/unit/middleware/verifyBearerAuth.spec.ts index 06a89cf..e083d3a 100644 --- a/tests/unit/middleware/verifyBearerAuth.spec.ts +++ b/tests/unit/middleware/verifyBearerAuth.spec.ts @@ -175,6 +175,21 @@ describe('verifyBearerAuth', () => { expect(next).toHaveBeenCalled(); }); + it('attaches the step-up state of a session exchanged from a device grant', async () => { + req.headers.authorization = 'Bearer token'; + const deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + (validateBearerToken as any).mockResolvedValue({ + user: { id: 'user-1' }, + sessionId: 'session-1', + deviceChildSession, + }); + + await verifyBearerAuth(req, res, next); + + expect(req.deviceChildSession).toBe(deviceChildSession); + }); + it('returns 401 if validation throws', async () => { req.headers.authorization = 'Bearer token'; diff --git a/tests/unit/services/oidcPayloadStore.spec.ts b/tests/unit/services/oidcPayloadStore.spec.ts index 618ef5a..994f4d3 100644 --- a/tests/unit/services/oidcPayloadStore.spec.ts +++ b/tests/unit/services/oidcPayloadStore.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../src/models/oidcPayloads', () => ({ OidcPayload: { + create: vi.fn(), upsert: vi.fn(), findOne: vi.fn(), update: vi.fn(), @@ -467,3 +468,38 @@ describe('startOidcPayloadPurge', () => { } }); }); + +describe('recordSessionExchangeToken', () => { + const payload = { clientId: 'cli', accountId: 'user-1', grantType: 'authorization_code' }; + const expiresAt = new Date('2026-11-04T12:00:00.000Z'); + + it('inserts under a model the edge routes cannot name', async () => { + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + + expect(await recordSessionExchangeToken('jti', 'session-1', payload, expiresAt)).toBe(true); + expect(OidcPayload.create).toHaveBeenCalledWith({ + id: 'jti', + model: 'SessionExchangeToken', + payload, + sessionId: 'session-1', + expiresAt, + }); + }); + + it('refuses a jti it has seen, without touching the record', async () => { + const { UniqueConstraintError } = await import('sequelize'); + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + vi.mocked(OidcPayload.create).mockRejectedValue(new UniqueConstraintError({})); + + expect(await recordSessionExchangeToken('jti', 'session-1', payload, expiresAt)).toBe(false); + }); + + it('lets any other failure surface', async () => { + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + vi.mocked(OidcPayload.create).mockRejectedValue(new Error('down')); + + await expect( + recordSessionExchangeToken('jti', 'session-1', payload, expiresAt), + ).rejects.toThrow('down'); + }); +}); diff --git a/tests/unit/services/oidcSigning.spec.ts b/tests/unit/services/oidcSigning.spec.ts index ab35d26..1bf3a7b 100644 --- a/tests/unit/services/oidcSigning.spec.ts +++ b/tests/unit/services/oidcSigning.spec.ts @@ -10,6 +10,7 @@ vi.mock('../../../src/services/oidcClientRegistry', () => ({ vi.mock('../../../src/services/oidcPayloadStore', () => ({ hasOidcGrantForSession: vi.fn(), + recordSessionExchangeToken: vi.fn(), })); vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ @@ -30,7 +31,10 @@ import { getOidcAccountClaims, getOidcClientResourceScopes, } from '../../../src/services/oidcClientRegistry'; -import { hasOidcGrantForSession } from '../../../src/services/oidcPayloadStore'; +import { + hasOidcGrantForSession, + recordSessionExchangeToken, +} from '../../../src/services/oidcPayloadStore'; import { getActiveOidcSigningKey, OidcProviderKeysError, @@ -565,3 +569,124 @@ describe('signForOidcEdge', () => { }); }); }); + +describe('a session:exchange token', () => { + const BUILT_IN = `${ISSUER}/seamless`; + const DEVICE = 'urn:ietf:params:oauth:grant-type:device_code'; + + function exchangeRequest(claims: Record = {}, grantType = 'authorization_code') { + const issuedAt = now(); + return { + kind: 'access_token' as const, + kid: 'k1', + clientId: 'cli', + sessionId: SESSION, + grantType, + signingInput: input( + { alg: 'RS256', kid: 'k1', typ: 'at+jwt' }, + { + iss: ISSUER, + sub: 'user-1', + aud: BUILT_IN, + iat: issuedAt, + exp: issuedAt + 60, + jti: 'jti-1', + client_id: 'cli', + scope: 'session:exchange', + ...claims, + }, + ), + }; + } + + beforeEach(() => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: ISSUER, + session_idle_ttl: '8h', + } as never); + vi.mocked(getActiveOidcSigningKey).mockReturnValue(privateKey); + vi.mocked(findActiveOidcClient).mockResolvedValue({ + id: 'cli', + grantTypes: ['authorization_code', DEVICE, 'refresh_token', 'client_credentials'], + firstParty: true, + deviceFlowEnabled: true, + } as never); + vi.mocked(getOidcClientResourceScopes).mockResolvedValue( + new Map([ + [BUILT_IN, new Set(['session:exchange', 'notices:send'])], + [API, new Set(['session:exchange'])], + ]), + ); + vi.mocked(resolveCurrentSession).mockResolvedValue(passkeySession as never); + vi.mocked(hasOidcGrantForSession).mockResolvedValue(true); + vi.mocked(recordSessionExchangeToken).mockResolvedValue(true); + }); + + it('is signed and recorded', async () => { + const outcome = await signForOidcEdge(exchangeRequest({}, DEVICE)); + + expect(outcome).toMatchObject({ ok: true }); + expect(recordSessionExchangeToken).toHaveBeenCalledWith( + 'jti-1', + SESSION, + { clientId: 'cli', accountId: 'user-1', grantType: DEVICE }, + expect.any(Date), + ); + }); + + it('is refused when the jti was seen before', async () => { + vi.mocked(recordSessionExchangeToken).mockResolvedValue(false); + + expect(await signForOidcEdge(exchangeRequest())).toMatchObject({ + ok: false, + code: 'malformed_signing_input', + }); + }); + + it.each([ + ['another scope', { scope: 'session:exchange notices:send' }, 'grant_not_allowed'], + ['another resource', { aud: API }, 'audience_mismatch'], + ['no jti', { jti: undefined }, 'malformed_signing_input'], + ['an empty jti', { jti: '' }, 'malformed_signing_input'], + ['no iat', { iat: undefined }, 'malformed_signing_input'], + ['a long lifetime', { exp: now() + 600 }, 'malformed_signing_input'], + ['a future iat', { iat: now() + 1000, exp: now() + 1100 }, 'malformed_signing_input'], + ] as [string, Record, string][])( + 'is refused with %s', + async (_name, claims, code) => { + expect(await signForOidcEdge(exchangeRequest(claims))).toMatchObject({ ok: false, code }); + expect(recordSessionExchangeToken).not.toHaveBeenCalled(); + }, + ); + + it('is refused on a refresh grant', async () => { + expect(await signForOidcEdge(exchangeRequest({}, 'refresh_token'))).toMatchObject({ + ok: false, + code: 'grant_not_allowed', + }); + }); + + it('is refused on a client credentials token', async () => { + const issuedAt = now(); + const outcome = await signForOidcEdge({ + kind: 'client_credentials', + kid: 'k1', + clientId: 'cli', + signingInput: input( + { alg: 'RS256', kid: 'k1', typ: 'at+jwt' }, + { + iss: ISSUER, + sub: 'cli', + aud: BUILT_IN, + iat: issuedAt, + exp: issuedAt + 60, + jti: 'jti-1', + client_id: 'cli', + scope: 'session:exchange', + }, + ), + } as never); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); +}); diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index fd22763..e456346 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -45,6 +45,11 @@ vi.mock('jsonwebtoken', () => ({ }, })); +vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + describe('sessionService', () => { beforeEach(() => { vi.resetModules(); @@ -842,3 +847,87 @@ describe('liftRecoveryRestriction', () => { expect(await liftRecoveryRestriction(undefined)).toBe(false); }); }); + +describe('exchanged sessions', () => { + const future = new Date(Date.now() + 60_000); + const childRow = { + id: 'child-1', + userId: 'user-1', + parentSessionId: 'parent-1', + expiresAt: future, + idleExpiresAt: future, + createdAt: new Date(), + }; + + beforeEach(async () => { + const { User } = await import('../../../src/models/users'); + vi.mocked(User.findOne).mockResolvedValue({ + id: 'user-1', + sessionsRevokedBefore: null, + } as never); + }); + + it('refuses a child whose parent no longer resolves to a live session', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { validateSessionRecord } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findByPk).mockResolvedValue(childRow as never); + vi.mocked(resolveCurrentSession).mockResolvedValue(null); + + expect(await validateSessionRecord('child-1')).toBeNull(); + expect(resolveCurrentSession).toHaveBeenCalledWith('parent-1'); + }); + + it('accepts a child whose parent is live', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { validateSessionRecord } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findByPk).mockResolvedValue(childRow as never); + vi.mocked(resolveCurrentSession).mockResolvedValue({ id: 'parent-now' } as never); + + expect(await validateSessionRecord('child-1')).toBe(childRow); + }); + + describe('revokeOrphanedChildSessions', () => { + it('ends the children whose parent is gone, and only those', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findAll).mockResolvedValue([ + { id: 'orphan', parentSessionId: 'gone' }, + { id: 'kept', parentSessionId: 'live' }, + ] as never); + vi.mocked(resolveCurrentSession).mockImplementation((async (id: string) => + id === 'live' ? { id } : null) as never); + + await revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: null }); + + expect(Session.update).toHaveBeenCalledTimes(1); + expect(vi.mocked(Session.update).mock.calls[0][1]).toMatchObject({ + where: { id: 'orphan', revokedAt: null }, + }); + expect(vi.mocked(Session.update).mock.calls[0][0]).toMatchObject({ + revokedReason: 'parent_session_ended', + }); + }); + + it('does nothing for a session that is itself a child', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + + await revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: 'parent-1' }); + + expect(Session.findAll).not.toHaveBeenCalled(); + }); + + it('never fails the revoke that caused it', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findAll).mockRejectedValue(new Error('down')); + + await expect( + revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: null }), + ).resolves.toBeUndefined(); + }); + }); +}); From cb36ea14c563773f1f36591fed4aaab08d7c4363 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 20:57:13 -0500 Subject: [PATCH 12/20] docs(sessions): document the session exchange and amend ADR 0001 D11 Refs #544 --- .changeset/session-exchange.md | 7 ++++ .github/workflows/postgres.yml | 2 ++ docs/adr/0001-openid-provider.md | 2 ++ docs/api-contract.md | 56 ++++++++++++++++++++++++++++++++ docs/architecture.md | 26 +++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 .changeset/session-exchange.md diff --git a/.changeset/session-exchange.md b/.changeset/session-exchange.md new file mode 100644 index 0000000..9065362 --- /dev/null +++ b/.changeset/session-exchange.md @@ -0,0 +1,7 @@ +--- +'seamless-auth-api': minor +--- + +A first-party native client signed in through the OpenID Provider can now reach the admin API, by exchanging the sign-in for a first-party session of its own (ADR 0003). An admin turns `sessionExchangeEnabled` on for a first-party public native application, with step-up, audited as `admin_oidc_application_session_exchange_updated`. Such a client can ask for a 60 second access token for `/seamless` with the single scope `session:exchange`, and `/oidc/sign` records each one. `POST /sessions/exchange` (`adapter: false`, RFC 8693 parameter names) consumes that token once and returns the `/refresh` body plus `issued_token_type`, for a new session linked to the provider sign-in. A second presentation answers `400 invalid_request` with `token_already_used` and revokes the session the first one created, audited as `session_exchange_replayed`. A successful exchange is audited as `session_exchanged`. + +The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` on every route behind `requireAdmin`, reads included, until it has a fresh step-up. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match. diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 10b702e..f8ca4f1 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -25,6 +25,8 @@ on: - 'src/services/signInMetrics.ts' # Conditional updates whose single winner only a real database can show. - 'src/services/totpService.ts' + - 'src/services/oidcPayloadStore.ts' + - 'src/services/sessionExchangeService.ts' - 'src/utils/otp.ts' - 'tests/postgres/**' - 'scripts/upgrade-check/**' diff --git a/docs/adr/0001-openid-provider.md b/docs/adr/0001-openid-provider.md index 6f29598..f5a7bb7 100644 --- a/docs/adr/0001-openid-provider.md +++ b/docs/adr/0001-openid-provider.md @@ -263,6 +263,8 @@ A passkey resists phishing because it is bound to an origin. Two flows can still The first-party verifier rejects all of these. + **Amendment, 2026-11-04: one exchange route ([ADR 0003](0003-native-admin-access.md)).** `POST /sessions/exchange` is the one place the API accepts a provider token, and only to mint a first-party session. It takes a single-use access token for the built-in resource with the `session:exchange` scope alone, which `/oidc/sign` recorded when it signed it. Admin routes and every other first-party route still accept first-party tokens only, and the first-party verifier still rejects every provider token. + - **Claims.** - `sub` is the user id. - Standard `email`, `email_verified`, `phone_number` and `phone_number_verified` follow the requested scopes. diff --git a/docs/api-contract.md b/docs/api-contract.md index 988df97..d5953cb 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -42,6 +42,9 @@ All tokens are returned in the JSON body (the API never sets cookies). Present t > `POST /refresh` reads the refresh token from the `Authorization` header, not the request body. +`POST /sessions/exchange` also issues an access and refresh token pair, from a provider sign-in; +see [Native admin tools](#native-admin-tools). + ## Status codes to branch on Most endpoints use conventional codes (`200` success, `400` invalid input, `401`/`403` auth @@ -56,6 +59,8 @@ failures, `500` server error). A few carry branch-significant meaning that clien | `POST /login` | `423` | The account is locked out. Carries `retryAfterSeconds`. | | `POST /refresh` | `401` | Missing, invalid, expired, or already-rotated refresh token. | | `POST /refresh` | `405` | Method other than POST. | +| `POST /sessions/exchange` | `400` | `invalid_request`. With `token_already_used`, a replay: see below. | +| `/admin/*` | `403` | `step_up_required`, even on a read, for a device-grant child session. | The SDK adapters branch on these exact codes (for example, the magic-link poll treats `204` as "pending"). Changing a branch-significant status code is a contract change; see the ripple @@ -123,6 +128,57 @@ IP forwarding in the path while the app holds the tokens. What a deployment has admit iCloud Keychain and Google Password Manager passkeys. A tenant on `syncedPasskeys: "block"` refuses every iOS passkey. +### Native admin tools + +A first-party public native client signed in through the OpenID Provider reaches `/admin/*` and +`/users/me` by exchanging the sign-in for a first-party session of its own +([ADR 0003](./adr/0003-native-admin-access.md)). An admin first turns `sessionExchangeEnabled` on +for the client, with `PATCH /admin/applications/:id`. It is off by default, and accepted only on a +first-party public client with a loopback or private-use redirect URI. + +The client runs authorization code with PKCE, or the device grant, with +`resource=/seamless` and `scope=openid session:exchange`, and without +`offline_access`. It then has 60 seconds to send the access token here, directly rather than +through an adapter: + +```http +POST /sessions/exchange +Content-Type: application/json + +{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": "", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token" +} +``` + +`requested_token_type` is optional. A `200` is the body `POST /refresh` returns, plus +`issued_token_type`. From there the client uses `/refresh`, `/logout`, `/users/me`, +`/totp/verify-mfa` and `/admin/*` as any first-party client does. `/logout` ends this session and +leaves the browser's alone. + +Every failure is a `400` whose `error` is `invalid_request`. A request that fails schema validation +also carries `message` and `details`. Every subject token failure is the bare +`{ "error": "invalid_request" }`, whatever the reason, so the route says nothing about sessions. A +token presented a second time adds `"error_description": "token_already_used"` and revokes the +session the first presentation created, so a client seeing it should sign in again and tell the +user their sign-in may have been intercepted. + +**Step-up.** A session from an authorization code grant starts with the parent's step-up as it +stood at the exchange, with the five minute window still running from the original step-up, so a +stale one stays stale. A session from a device grant starts with none, and answers +`403 step_up_required` on every `/admin/*` route until it completes `POST /totp/verify-mfa`. As for +any session, a refresh does not carry a step-up to the new row. + +**Lifetime.** The session is capped at its parent's expiry, and `refreshTtl` says what is left. It +ends when its parent does: sign-out at the provider, revocation, expiry or idling out. Refreshing +it keeps the parent from idling out. An expired parent shows up as +`401 invalid_refresh_token` on `POST /refresh`. + +**Session lists.** `GET /sessions` and the admin session views add `parentSessionId`, +`exchangeClientId` and `exchangeGrantType` to a child, and leave them out otherwise. + ### Error body Every `4xx` and `5xx` response uses one shape, with one additive extension for schema diff --git a/docs/architecture.md b/docs/architecture.md index e68b26b..eb46dc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,6 +91,32 @@ flowchart LR Provider tokens carry `iss` equal to `oidc_issuer`, never `ISSUER`, and are signed with provider keys, so the first-party verifier rejects them. +A first-party native client signed in through the edge reaches the admin API by exchanging, not +by presenting a provider token to it ([ADR 0003](./adr/0003-native-admin-access.md)). An admin +turns `sessionExchangeEnabled` on for the client, which adds the built-in resource with the +`session:exchange` scope and a 60 second token lifetime to its resource list. `POST /oidc/sign` +writes a `SessionExchangeToken` record in `oidc_payloads` for each such token, and +`POST /sessions/exchange` consumes it once, in the transaction that creates the session. A +second presentation is refused and revokes the session the first one created. + +The result is a child session: an ordinary `sessions` row with its own refresh chain and step-up +state, plus `parentSessionId`, `exchangeClientId` and `exchangeGrantType`, carried across +rotation. Its parent is the session the edge holds for the provider sign-in. + +- It ends with its parent. Validation and refresh follow `parentSessionId` forward through the + parent's rotations and refuse a child with no live parent, which covers a parent that expired + or idled out, and every revoke path also revokes the children, which keeps session lists + accurate. Ending a child leaves the parent alone. +- It never outlives its parent, and refreshing it counts as activity on the parent. +- An authorization code grant copies the parent's auth methods, auth time and step-up at the + exchange. A device grant copies nothing (ADR 0001, D10). +- `requireAdmin` refuses a session exchanged from a device grant until it has a fresh step-up of + its own, reads included. +- It is not counted against `max_concurrent_sessions`, since evicting a parent to make room for + its own child would end both. +- Disabling or deleting the client, or turning `sessionExchangeEnabled` off, revokes every + session it exchanged for. + For the full dependency and contract-coupling map across sibling repositories, see [ecosystem.md](./ecosystem.md). From 6c89170742260c934e53a84fe0f7bbfe35809c81 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:16:56 -0500 Subject: [PATCH 13/20] test(sessions): cover factor changes on a device child A device child without a fresh step-up could enroll its own TOTP, then step up with it and pass X7. These fail until factor changes are gated. Refs #544 --- tests/postgres/sessionExchange.spec.ts | 269 +++++++++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 355fc26..e21744d 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -1573,3 +1573,272 @@ describe('session lists', () => { expect(listed?.exchangeClientId).toBe(client.id); }); }); + +describe('factor changes on a device child', () => { + const ADMIN_ROLES = ['admin:read', 'admin:write']; + + async function childOf(grantType: string, roles = ADMIN_ROLES) { + const { client, user, parent } = await signedInToCli( + { stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + expect(res.status).toBe(200); + return { + user, + parent, + token: res.body.token as string, + sessionId: res.body.sessionId as string, + }; + } + + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + function adminRead(token: string) { + return request(app).get('/admin/users').set(bearer(token)); + } + + /** The user's own authenticator, from before the device sign-in. */ + async function enrollTotp(user: User) { + const { startTotpEnrollment, verifyTotpEnrollment } = + await import('../../src/services/totpService.js'); + const { generateTotpCode, getTotpCounter } = await import('../../src/utils/totp.js'); + const { secret } = await startTotpEnrollment({ + userId: user.id, + email: user.email, + issuer: 'Test', + }); + // The previous step's code, so the user's next one is not refused as a repeat. + const enrolled = await verifyTotpEnrollment( + user.id, + generateTotpCode({ secret, counter: getTotpCounter() - 1 }), + ); + expect(enrolled.verified).toBe(true); + return { secret, code: () => generateTotpCode({ secret }) }; + } + + async function credentialsOf(userId: string) { + const { TotpCredential } = await import('../../src/models/totpCredentials.js'); + return TotpCredential.findAll({ where: { userId } }); + } + + function expectStepUpRequired(res: request.Response) { + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + fresh: false, + method: null, + maxAgeSeconds: 300, + }); + } + + it('leaves a device child with no TOTP unable to enroll one, so X7 holds', async () => { + const { user, token } = await childOf(DEVICE_CODE); + const { generateTotpCode, generateTotpSecret } = await import('../../src/utils/totp.js'); + const chosen = generateTotpSecret(); + + expectStepUpRequired(await adminRead(token)); + + expectStepUpRequired(await request(app).post('/totp/enroll/start').set(bearer(token))); + expect(await credentialsOf(user.id)).toEqual([]); + + const code = generateTotpCode({ secret: chosen }); + expectStepUpRequired( + await request(app).post('/totp/enroll/verify').set(bearer(token)).send({ code }), + ); + + const stepUp = await request(app).post('/totp/verify-mfa').set(bearer(token)).send({ code }); + expect(stepUp.status).toBe(401); + + expect(await credentialsOf(user.id)).toEqual([]); + expectStepUpRequired(await adminRead(token)); + }); + + it('cannot replace the TOTP of a user who has one', async () => { + const { user, token } = await childOf(DEVICE_CODE); + await enrollTotp(user); + const [original] = await credentialsOf(user.id); + const { generateTotpCode, generateTotpSecret } = await import('../../src/utils/totp.js'); + const code = generateTotpCode({ secret: generateTotpSecret() }); + + expectStepUpRequired(await request(app).post('/totp/enroll/start').set(bearer(token))); + expectStepUpRequired( + await request(app).post('/totp/enroll/verify').set(bearer(token)).send({ code }), + ); + expectStepUpRequired( + await request(app).post('/totp/disable').set(bearer(token)).send({ code }), + ); + + const after = await credentialsOf(user.id); + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after[0].enabled).toBe(true); + expectStepUpRequired(await adminRead(token)); + }); + + it('lets a device child through once it steps up with the real TOTP', async () => { + const { user, token } = await childOf(DEVICE_CODE); + const { code } = await enrollTotp(user); + + expectStepUpRequired(await request(app).post('/recovery-codes').set(bearer(token)).send({})); + + const stepUp = await request(app) + .post('/totp/verify-mfa') + .set(bearer(token)) + .send({ code: code() }); + expect(stepUp.status).toBe(200); + expect(stepUp.body.method).toBe('totp'); + + expect((await adminRead(token)).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + }); + + it('refuses again once the step-up goes stale', async () => { + const { token, sessionId } = await childOf(DEVICE_CODE); + await Session.update( + { stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), stepUpMethod: 'totp' }, + { where: { id: sessionId } }, + ); + + const res = await request(app).post('/totp/enroll/start').set(bearer(token)); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: 'totp' }); + }); + + it.each([ + ['post', '/totp/enroll/start'], + ['post', '/totp/enroll/verify'], + ['post', '/totp/disable'], + ['get', '/webauthn/register/start'], + ['post', '/webauthn/register/finish'], + ['post', '/recovery-codes'], + ['delete', '/users/credentials'], + ['post', '/registration/phone'], + ['post', '/registration/phone/verify'], + ['delete', `/recovery/requests/${randomUUID()}`], + ] as ['post' | 'get' | 'delete', string][])( + 'answers step_up_required on %s %s, before reading the request', + async (method, url) => { + const { token } = await childOf(DEVICE_CODE); + + expectStepUpRequired(await request(app)[method](url).set(bearer(token))); + }, + ); + + it('does not delete a credential', async () => { + const { Credential } = await import('../../src/models/credentials.js'); + const { user, token } = await childOf(DEVICE_CODE); + const seed = (id: string) => + Credential.create({ + id, + userId: user.id, + publicKey: Buffer.from('key'), + counter: 0, + transports: [], + deviceType: 'singleDevice', + backedup: false, + } as never); + const id = `cred-${randomUUID()}`; + await seed(id); + await seed(`cred-${randomUUID()}`); + + expectStepUpRequired( + await request(app).delete('/users/credentials').set(bearer(token)).send({ id }), + ); + + expect(await Credential.count({ where: { userId: user.id } })).toBe(2); + }); + + it('does not change the phone on the account', async () => { + const { user, token } = await childOf(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .post('/registration/phone') + .set(bearer(token)) + .send({ phone: '+14155550123' }), + ); + + await user.reload(); + expect(user.phone).toBeNull(); + }); + + it('does not change a loopback child, with a step-up or without one', async () => { + const { token } = await childOf(AUTHORIZATION_CODE); + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + + const { client, parent } = await signedInToCli(); + const { token: subject } = await signSubject(client, parent); + const plain = await exchange(subject); + expect( + (await request(app).post('/totp/enroll/start').set(bearer(plain.body.token))).status, + ).toBe(200); + }); + + it('does not change a first-party session', async () => { + const user = await createUser(); + const session = await createSession(user.id); + const token = await accessTokenFor(session); + + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + expect((await request(app).get('/webauthn/register/start').set(bearer(token))).status).toBe( + 200, + ); + }); + + it('cannot turn a device child into a parent for another exchange', async () => { + const { client, parent } = await signedInToCli(); + const { token: first } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const child = await exchange(first); + await store.bindOidcSession(child.body.sessionId, parent.userId, client.id); + await store.upsertOidcPayload('Grant', randomUUID(), { + payload: { accountId: parent.userId, clientId: client.id }, + expiresIn: 3600, + sessionId: child.body.sessionId, + }); + + const childRow = await Session.findByPk(child.body.sessionId); + const { token: second } = await signSubject(client, childRow as Session); + const res = await exchange(second); + + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + }); + + it('keeps the child and its device grant on an organization switch', async () => { + const { Organization } = await import('../../src/models/organizations.js'); + const { OrganizationMembership } = await import('../../src/models/organizationMemberships.js'); + const { user, token, sessionId } = await childOf(DEVICE_CODE); + const organization = await Organization.create({ + name: 'Org', + slug: `org-${randomUUID()}`, + } as never); + await OrganizationMembership.create({ + userId: user.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never); + + const res = await request(app) + .post(`/organizations/${organization.id}/switch`) + .set(bearer(token)); + + expect(res.status).toBe(200); + expect(res.body.sessionId).toBe(sessionId); + expectStepUpRequired(await adminRead(res.body.token)); + }); +}); From a434ca12f31998dd51d28a0ce488d827dd9220a3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:19:14 -0500 Subject: [PATCH 14/20] feat(sessions): gate factor changes on a device child requireDeviceSessionStepUp answers 403 step_up_required on routes that change how an account signs in or recovers, unless a device child has a fresh step-up of its own: TOTP enrollment and disable, passkey registration, recovery codes, credential deletion, phone registration and cancelling account recovery. X7 shares the check. A device sign-in with no TOTP cannot enroll one, and the user does it from a browser. defineRoute now documents this 403 wherever the check applies, admin reads included. ADR 0003 is amended. Refs #544 --- .changeset/session-exchange.md | 2 +- docs/adr/0003-native-admin-access.md | 17 +- docs/api-contract.md | 17 +- openapi.json | 1569 ++++++++++++++++- src/generated/api.ts | 1357 +++++++++++++- src/lib/defineRoute.ts | 31 +- src/middleware/requireAdmin.ts | 25 +- src/middleware/requireDeviceSessionStepUp.ts | 51 + src/routes/accountRecovery.routes.ts | 2 + src/routes/recoveryCodes.routes.ts | 2 + src/routes/registration.routes.ts | 4 +- src/routes/totp.routes.ts | 6 +- src/routes/users.routes.ts | 2 + src/routes/webauthn.routes.ts | 3 + tests/integration/admin/adminStepUp.spec.ts | 14 +- tests/setup/mocks.ts | 10 +- .../requireDeviceSessionStepUp.spec.ts | 55 + 17 files changed, 3094 insertions(+), 73 deletions(-) create mode 100644 src/middleware/requireDeviceSessionStepUp.ts create mode 100644 tests/unit/middleware/requireDeviceSessionStepUp.spec.ts diff --git a/.changeset/session-exchange.md b/.changeset/session-exchange.md index 9065362..e64c0b3 100644 --- a/.changeset/session-exchange.md +++ b/.changeset/session-exchange.md @@ -4,4 +4,4 @@ A first-party native client signed in through the OpenID Provider can now reach the admin API, by exchanging the sign-in for a first-party session of its own (ADR 0003). An admin turns `sessionExchangeEnabled` on for a first-party public native application, with step-up, audited as `admin_oidc_application_session_exchange_updated`. Such a client can ask for a 60 second access token for `/seamless` with the single scope `session:exchange`, and `/oidc/sign` records each one. `POST /sessions/exchange` (`adapter: false`, RFC 8693 parameter names) consumes that token once and returns the `/refresh` body plus `issued_token_type`, for a new session linked to the provider sign-in. A second presentation answers `400 invalid_request` with `token_already_used` and revokes the session the first one created, audited as `session_exchange_replayed`. A successful exchange is audited as `session_exchanged`. -The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` on every route behind `requireAdmin`, reads included, until it has a fresh step-up. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match. +The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` until it has a fresh step-up, on every route behind `requireAdmin`, reads included, and on `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`, so a device sign-in with no TOTP cannot enroll one and the user does it from a browser. OpenAPI now documents this 403 on those routes and on admin reads. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match. diff --git a/docs/adr/0003-native-admin-access.md b/docs/adr/0003-native-admin-access.md index 468ad5e..3bc3184 100644 --- a/docs/adr/0003-native-admin-access.md +++ b/docs/adr/0003-native-admin-access.md @@ -77,8 +77,8 @@ Restricting the subject token to the built-in resource and a dedicated scope mat - **At the exchange, for an authorization code grant,** the child copies the parent's `authMethods`, `authTime`, `stepUpVerifiedAt` and `stepUpMethod` as they are at that instant. The API reads them from the parent row, the same source `/oidc/sign` already treats as the truth (D4, D9), so it does not depend on oidc#34. The five minute window still runs from the original step-up time, so a stale step-up stays stale. Later step-ups on either session never flow to the other. - **For a device grant,** the child copies nothing. It starts with no auth methods and no step-up, as D10 requires: the passkey proved who approved, not who holds the token (RFC 8628 section 5.4). - **A loopback CLI steps up with a passkey** by running a new loopback authorization with `acr_values=phr` and `max_age=300`. The hosted page runs `/step-up/*` on the parent (D3), the CLI exchanges the fresh subject token for a new child session with a fresh step-up, and logs out the old child. It can also use TOTP. -- **A device sign-in steps up with the authenticator app only,** through the existing `/totp/verify-mfa` on its own child session. A user with no TOTP enrolled cannot do admin writes from a device sign-in. That is a deliberate limit, not a gap: any passkey path for a headless machine would hand a browser's assurance to whoever holds the device code, which is the attack D10 exists to stop. -- **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up already stops that attacker from changing anything, because they cannot produce the victim's TOTP code. Without X7 they could still list every user on the instance. +- **A device sign-in steps up with the authenticator app only,** through the existing `/totp/verify-mfa` on its own child session. A user with no TOTP enrolled cannot do admin writes from a device sign-in, and cannot enroll TOTP from one either: they enroll from a browser, then sign in on the device again. That is a deliberate limit, not a gap: any passkey path for a headless machine would hand a browser's assurance to whoever holds the device code, which is the attack D10 exists to stop. +- **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up stops that attacker from changing anything only while they cannot produce a TOTP code, which is why X7 also covers every route that changes how the account signs in or recovers: an attacker able to enroll their own authenticator could then step up with it. Without X7 they could also list every user on the instance. ### Revocation @@ -113,7 +113,7 @@ B has no usable revocation story: every event above that should end the CLI's ac | X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | | X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | | X6 | A device-grant child steps up with TOTP only. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | -| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route, reads included. | +| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route, reads included, and before any route that changes how the account signs in or recovers. It cannot enroll a first factor. | | X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | | X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | @@ -161,7 +161,7 @@ The child session is an ordinary `sessions` row with three new columns, carried - **Validity.** `validateSessionRecord` and the refresh path refuse a child whose parent no longer resolves to a live session. The cascade on revoke keeps session lists accurate, and the read-time check makes a missed cascade harmless. - **Activity.** A child refresh counts as activity on the parent, as a provider token signed for the parent already does (`recordProviderActivity`, D3). Without that, an active CLI would be signed out when the browser session idles out. - **Assurance (X5).** Copied from the parent at the exchange for `authorization_code`, never for `urn:ietf:params:oauth:grant-type:device_code`. -- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. +- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. `requireDeviceSessionStepUp` gives the same answer, and is listed first in the middleware of every route that changes how the account signs in or recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`. A new route of that kind opts in the same way. A loopback child and every first-party session are unaffected. - **Audit.** `session_exchanged` (client, grant, parent), `session_exchange_replayed`, and the existing session events for the child. ## X8. No silent authorization for exchange-enabled clients @@ -179,7 +179,7 @@ So for an exchange-enabled client, the edge always shows a confirmation that nam 5. **Use the API as today.** `/refresh`, `/logout`, `/users/me`, `/admin/*` and `/totp/verify-mfa` are unchanged. 6. **Step up on `403 step_up_required`.** - Loopback session: offer a passkey (repeat step 2 with `acr_values=phr` and `max_age=300`, exchange, swap the keychain tokens, `/logout` the old child) or the authenticator app. - - Device session: offer the authenticator app only, and say why when TOTP is not enrolled. + - Device session: offer the authenticator app only. When TOTP is not enrolled, say so and send the user to a browser to enroll it, since a device session cannot, then sign in on the device again. 7. **On `invalid_request` from the exchange,** sign in again. If the description is `token_already_used`, tell the user their sign-in may have been intercepted. The CLI's e2e covers both paths against a v0.23 API and edge. @@ -191,7 +191,7 @@ The CLI's e2e covers both paths against a v0.23 API and edge. 3. **`/oidc/sign`.** The X2 checks, and the X3 signing record. 4. **`POST /sessions/exchange`.** `defineRoute` with `adapter: false` (native clients call it directly, adapters never proxy it), request and response schemas, a per-IP rate limit, the checks above, child session issuance through `sessionService`, the X5 copy, audit events. Regenerate `openapi.json` and `adapter-manifest.json`. 5. **Session lifecycle.** The parent check in `validateSessionRecord` and the refresh path, the lifetime cap, parent activity on child refresh, and the cascade from every revoke path (logout, admin revoke, sign out everywhere, chain revocation, concurrent session eviction). -6. **Device children.** The X7 check in `requireAdmin`. +6. **Device children.** The X7 check in `requireAdmin`, and `requireDeviceSessionStepUp` on the routes that change how the account signs in or recovers. 7. **Session lists.** `/sessions` and the admin session views show a child's client and grant. A types change. 8. **Docs.** An amendment to ADR 0001 D11 naming `/sessions/exchange` as the one place a provider token is accepted, and only to mint a first-party session. `docs/architecture.md`, `docs/api-contract.md` and the native apps guide. 9. **Tests.** Replay (sequential and concurrent), wrong `aud`, extra scope, expired record, disabled client, recovery parent, device child step-up, and each revoke path ending the child. @@ -202,6 +202,8 @@ Edge work: X8, and DPoP on the exchange hop with #512 (X9). The maintainer accepted option C on 2026-11-04, with the settings this record recommends: bearer first-party tokens on admin calls until first-party DPoP exists, X4 (the child's lifetime is tied to its parent and child refreshes count as parent activity), X5 (a loopback exchange inherits the parent's step-up from the last five minutes), X7 (device grant sessions need a fresh step-up before any admin call), X8 (no silent authorization for exchange-enabled clients), the names `session:exchange`, `POST /sessions/exchange` and `sessionExchangeEnabled`, and the narrow amendment to ADR 0001 D11. +**Confirmed 2026-11-04: X7 covers factor changes.** As first accepted, X7 guarded only `/admin/*`, and its reasoning was wrong. Every factor route took any access session, so a device child with a phished approval could enroll a TOTP secret of its own, step up with it, and pass both X7 and `requireStepUp`. For a user who already had TOTP, the same enrollment disabled theirs. Both were run against real Postgres. The maintainer chose to refuse factor changes on a device child without a fresh step-up, through one shared middleware. A device sign-in with no TOTP therefore cannot enroll one, and the user does it from a browser. + ## Security properties | Property | Provided by | @@ -211,6 +213,7 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re | A subject token works once, for at most two minutes, and a replay is detected | X2, X3 | | Device sign-ins never borrow the approving browser's assurance | X5, ADR 0001 D10 | | A phished device approval cannot read or change admin data without the user's own factor | X6, X7 | +| A phished device approval cannot add, replace or remove a factor, or the account's phone | X7 | | Ending the provider sign-in ends the CLI's admin access at once | X4 | | Ending the CLI's session does not end the user's browser session | X4 | | A local program cannot silently obtain an admin session through the CLI's client id | X8 | @@ -228,7 +231,7 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re - Three `sessions` columns, one client flag, one route and a signing record, plus a read-time parent lookup on every request from an exchanged session. - A narrow, documented exception to D11: one route accepts a provider token, to mint a first-party session. - Until first-party DPoP exists, the CLI's refresh token is a bearer credential, as it is today. -- Device sign-ins without TOTP can read nothing on `/admin/*` (X7) and must use the loopback flow or email code sign-in. +- Device sign-ins without TOTP can read nothing on `/admin/*` and cannot enroll TOTP (X7). The user enrolls from a browser, or uses the loopback flow or email code sign-in. ## Issue map diff --git a/docs/api-contract.md b/docs/api-contract.md index d5953cb..04682e2 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -60,7 +60,7 @@ failures, `500` server error). A few carry branch-significant meaning that clien | `POST /refresh` | `401` | Missing, invalid, expired, or already-rotated refresh token. | | `POST /refresh` | `405` | Method other than POST. | | `POST /sessions/exchange` | `400` | `invalid_request`. With `token_already_used`, a replay: see below. | -| `/admin/*` | `403` | `step_up_required`, even on a read, for a device-grant child session. | +| Admin and factor routes | `403` | `step_up_required`, even on a read, for a device-grant child session. | The SDK adapters branch on these exact codes (for example, the magic-link poll treats `204` as "pending"). Changing a branch-significant status code is a contract change; see the ripple @@ -167,9 +167,18 @@ user their sign-in may have been intercepted. **Step-up.** A session from an authorization code grant starts with the parent's step-up as it stood at the exchange, with the five minute window still running from the original step-up, so a -stale one stays stale. A session from a device grant starts with none, and answers -`403 step_up_required` on every `/admin/*` route until it completes `POST /totp/verify-mfa`. As for -any session, a refresh does not carry a step-up to the new row. +stale one stays stale. A session from a device grant starts with none. Until it completes +`POST /totp/verify-mfa`, it answers `403 step_up_required`, with the same body as `requireStepUp`, on +every route behind `requireAdmin`, reads included, and on every route that changes how the account +signs in or recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, +`POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, +`/registration/phone/verify` and `DELETE /recovery/requests/:id`. As for any session, a refresh does +not carry a step-up to the new row. + +**A device sign-in with no TOTP cannot enroll one.** The user enrolls from a browser, then signs in +on the device again. Without this, someone who phished a device approval could enroll their own +authenticator and step up with it. A session from an authorization code grant, and every other +first-party session, can enroll as before. **Lifetime.** The session is capped at its parent's expiry, and `refreshTtl` says what is left. It ends when its parent does: sign-out at the provider, revocation, expiry or idling out. Refreshing diff --git a/openapi.json b/openapi.json index 79a2905..135dba8 100644 --- a/openapi.json +++ b/openapi.json @@ -1222,10 +1222,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -1547,6 +1567,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -1977,6 +2030,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -2632,6 +2718,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -4004,6 +4123,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4241,6 +4393,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -4555,6 +4740,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -4694,6 +4912,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4842,7 +5093,40 @@ } } }, - "429": { + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "429": { "description": "HTTP 429", "content": { "application/json": { @@ -5621,6 +5905,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -5840,6 +6157,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -5888,6 +6238,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -6552,6 +6935,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -6891,6 +7307,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -7014,6 +7463,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -7126,6 +7608,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8717,6 +9232,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8834,6 +9382,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8937,6 +9518,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9093,6 +9707,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9235,6 +9882,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9358,6 +10038,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9499,6 +10212,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9675,6 +10421,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -14139,7 +14918,40 @@ } } }, - "required": ["applications"] + "required": ["applications"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] } } } @@ -14725,6 +15537,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -16037,6 +16882,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -16364,6 +17242,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -16868,6 +17779,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -17193,6 +18137,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -21479,10 +22456,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -22085,6 +23082,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -22231,7 +23261,40 @@ "example": { "message": "string", "error": "string" }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -23295,6 +24358,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -23735,6 +24831,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -24285,6 +25414,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25527,6 +26689,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25881,6 +27076,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25940,6 +27168,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -26211,10 +27472,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -26505,10 +27786,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -26748,6 +28049,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -27029,6 +28363,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -27182,6 +28549,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -27308,6 +28708,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -27524,6 +28957,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -28469,6 +29935,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -28802,10 +30301,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -28997,10 +30516,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } diff --git a/src/generated/api.ts b/src/generated/api.ts index 0bba8af..79d78fd 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -1316,12 +1316,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -1616,6 +1627,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -1989,6 +2029,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -2571,6 +2640,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -3797,6 +3895,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4007,6 +4134,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -4256,6 +4412,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -4407,6 +4592,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4544,6 +4758,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -5278,8 +5521,8 @@ export interface paths { }; }; }; - /** @description HTTP 429 */ - 429: { + /** @description HTTP 403 */ + 403: { headers: { [name: string]: unknown; }; @@ -5287,7 +5530,36 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" * } */ 'application/json': { @@ -5514,6 +5786,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -5593,6 +5894,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -5996,6 +6326,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -6546,6 +6905,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -6672,6 +7060,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -6797,6 +7214,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8422,6 +8868,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8545,6 +9020,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8662,6 +9166,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8815,6 +9348,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8966,6 +9528,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9092,6 +9683,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9245,6 +9865,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9417,6 +10066,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -14054,6 +14732,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -14518,6 +15225,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -15643,8 +16379,8 @@ export interface paths { }; }; }; - /** @description HTTP 404 */ - 404: { + /** @description HTTP 403 */ + 403: { headers: { [name: string]: unknown; }; @@ -15652,17 +16388,28 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; - /** @description HTTP 429 */ - 429: { + /** @description HTTP 404 */ + 404: { headers: { [name: string]: unknown; }; @@ -15679,9 +16426,27 @@ export interface paths { }; }; }; - /** @description HTTP 500 */ - 500: { - headers: { + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { [name: string]: unknown; }; content: { @@ -15954,6 +16719,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -16447,6 +17241,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -16749,6 +17572,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -20945,12 +21797,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -21546,6 +22409,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -21722,6 +22614,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -22906,6 +23827,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -23269,6 +24219,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -23736,8 +24715,8 @@ export interface paths { }; }; }; - /** @description HTTP 429 */ - 429: { + /** @description HTTP 403 */ + 403: { headers: { [name: string]: unknown; }; @@ -23745,17 +24724,46 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; - /** @description HTTP 500 */ - 500: { + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { headers: { [name: string]: unknown; }; @@ -25008,6 +26016,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -25339,6 +26376,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -25422,6 +26488,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -25695,12 +26790,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -25997,12 +27103,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -26236,6 +27353,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -26499,6 +27645,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -26712,6 +27887,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -26863,6 +28067,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -27066,6 +28299,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -27933,6 +29195,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -28283,12 +29574,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -28484,12 +29786,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index 9d91904..e0acf16 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -12,9 +12,11 @@ import { AuthAwareRequestHandler, getSecuritySchemeName, } from '../middleware/attachAuthMiddleware.js'; +import type { DeviceStepUpAwareHandler } from '../middleware/requireDeviceSessionStepUp.js'; import type { SecuritySchemeAwareHandler } from '../middleware/requireOidcEdge.js'; import { registry } from '../openapi/registry.js'; import { ErrorSchema, ValidationErrorSchema } from '../schemas/generic.responses.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; import { AuthTokenType } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -145,6 +147,28 @@ function withValidationResponse( return { ...responseMap, 400: ValidationErrorSchema }; } +/** + * The same for a 403 only middleware writes: `requireAdmin` and `requireDeviceSessionStepUp` + * answer `step_up_required` to a session from a device grant (ADR 0003, X7), on routes whose + * handlers never mention it. + */ +function withDeviceStepUpResponse( + response: ZodTypeAny | Record | undefined, + middleware: RequestHandler[] | undefined, +): ZodTypeAny | Record | undefined { + const refuses = middleware?.some( + (handler) => (handler as DeviceStepUpAwareHandler).seamlessDeviceStepUp, + ); + if (!refuses) return response; + + const responseMap = response && isZodSchema(response) ? { 200: response } : (response ?? {}); + const declared = (responseMap as Record)[403]; + + if (declared && declared !== ErrorSchema) return response; + + return { ...responseMap, 403: StepUpRequiredErrorSchema }; +} + /** * Statuses every route can answer with, whatever its handler declares. * @@ -314,7 +338,12 @@ export function defineRoute( } : undefined, }, - responses: buildResponses(withValidationResponse(response, validatesRequest)), + responses: buildResponses( + withValidationResponse( + withDeviceStepUpResponse(response, options.middleware), + validatesRequest, + ), + ), }); const validate: RequestHandler = (req, res, next) => { diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts index 0b11838..63f287f 100644 --- a/src/middleware/requireAdmin.ts +++ b/src/middleware/requireAdmin.ts @@ -7,9 +7,12 @@ import { NextFunction, Response } from 'express'; import { hasScopedRole } from '../lib/scopedRoles.js'; -import { getStepUpStatusFromSession, serializeStepUpStatus } from '../services/stepUpService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; +import { + type DeviceStepUpAwareHandler, + refuseDeviceSessionWithoutStepUp, +} from './requireDeviceSessionStepUp.js'; const logger = getLogger('requireAdmin'); @@ -18,7 +21,7 @@ export type AdminAccessLevel = 'read' | 'write'; export function requireAdmin(accessLevel?: AdminAccessLevel) { const requiredRole = accessLevel ? `admin:${accessLevel}` : 'admin'; - return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const guard = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { if (!req.user) { logger.error('Admin route hit without authenticated user'); @@ -30,19 +33,7 @@ export function requireAdmin(accessLevel?: AdminAccessLevel) { return res.status(403).json({ error: 'Forbidden' }); } - // ADR 0003, X7. Someone who phished a device approval holds a session like this without - // holding the user's factor, so even a read waits for one (RFC 8628 section 5.4). - if (req.deviceChildSession) { - const status = getStepUpStatusFromSession(req.deviceChildSession); - - if (!status.fresh) { - return res.status(403).json({ - error: 'step_up_required', - message: 'Recent step-up authentication is required', - ...serializeStepUpStatus(status), - }); - } - } + if (refuseDeviceSessionWithoutStepUp(req, res)) return; next(); } catch (err) { @@ -50,4 +41,8 @@ export function requireAdmin(accessLevel?: AdminAccessLevel) { return res.status(500).json({ error: 'Internal server error' }); } }; + + const handler = guard as typeof guard & DeviceStepUpAwareHandler; + handler.seamlessDeviceStepUp = true; + return handler; } diff --git a/src/middleware/requireDeviceSessionStepUp.ts b/src/middleware/requireDeviceSessionStepUp.ts new file mode 100644 index 0000000..aeab195 --- /dev/null +++ b/src/middleware/requireDeviceSessionStepUp.ts @@ -0,0 +1,51 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { getStepUpStatusFromSession, serializeStepUpStatus } from '../services/stepUpService.js'; +import { AuthenticatedRequest } from '../types/types.js'; + +/** Marks a middleware that can answer `403 step_up_required`, so `defineRoute` documents it. */ +export type DeviceStepUpAwareHandler = RequestHandler & { seamlessDeviceStepUp?: true }; + +/** + * ADR 0003, X7. Someone who phished a device approval holds a session from a device grant + * without holding the user's factor (RFC 8628 section 5.4). This answers for them until that + * session has a fresh step-up of its own, and answers nothing for any other session. + * + * @returns whether a refusal was sent. + */ +export function refuseDeviceSessionWithoutStepUp(req: Request, res: Response): boolean { + const { deviceChildSession } = req as AuthenticatedRequest; + if (!deviceChildSession) return false; + + const status = getStepUpStatusFromSession(deviceChildSession); + if (status.fresh) return false; + + res.status(403).json({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + ...serializeStepUpStatus(status), + }); + return true; +} + +/** + * For a route that changes how the account signs in or recovers. Anything a session from a + * device grant could add here, it could then authenticate with, so without this an attacker's + * own TOTP would satisfy every other step-up on the instance. A new route of that kind opts in + * by listing this first in its `middleware`. + */ +export const requireDeviceSessionStepUp: DeviceStepUpAwareHandler = ( + req: Request, + res: Response, + next: NextFunction, +) => { + if (!refuseDeviceSessionWithoutStepUp(req, res)) next(); +}; + +requireDeviceSessionStepUp.seamlessDeviceStepUp = true; diff --git a/src/routes/accountRecovery.routes.ts b/src/routes/accountRecovery.routes.ts index 8c70650..b428284 100644 --- a/src/routes/accountRecovery.routes.ts +++ b/src/routes/accountRecovery.routes.ts @@ -21,6 +21,7 @@ import { } from '../controllers/decoyResponders.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { AccountRecoveryCancelRequestSchema, AccountRecoveryRequestIdParamSchema, @@ -184,6 +185,7 @@ accountRecoveryRouter.get( accountRecoveryRouter.delete( '/requests/:id', { + middleware: [requireDeviceSessionStepUp], auth: 'access', summary: 'Cancel an account recovery request from a session', description: diff --git a/src/routes/recoveryCodes.routes.ts b/src/routes/recoveryCodes.routes.ts index 169b4ac..4b018a9 100644 --- a/src/routes/recoveryCodes.routes.ts +++ b/src/routes/recoveryCodes.routes.ts @@ -12,6 +12,7 @@ import { } from '../controllers/recoveryCodes.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; import { RecoveryCodeRedeemRequestSchema, @@ -43,6 +44,7 @@ recoveryCodesRouter.get( recoveryCodesRouter.post( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Generate recovery codes', diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index 449778d..071092f 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -7,6 +7,7 @@ import { register, registerPhone, verifyRegisteredPhone } from '../controllers/registration.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; import { VerifyOTPRequestSchema } from '../schemas/otp.requests.js'; import { OTPVerifyTokenSuccessSchema } from '../schemas/otp.responses.js'; @@ -50,7 +51,7 @@ registrationRouter.post( auth: 'access', summary: 'Register a phone number for the authenticated user', tags: ['Registration'], - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], schemas: { body: RegisterPhoneRequestSchema, @@ -70,6 +71,7 @@ registrationRouter.post( registrationRouter.post( '/phone/verify', { + middleware: [requireDeviceSessionStepUp], auth: 'access', summary: 'Verify the authenticated user phone number', tags: ['Registration'], diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index 3b4602e..36e8690 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -15,6 +15,7 @@ import { } from '../controllers/totp.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema, InternalErrorSchema, @@ -53,6 +54,7 @@ totpRouter.get( totpRouter.post( '/enroll/start', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Start TOTP enrollment', @@ -73,7 +75,7 @@ totpRouter.post( totpRouter.post( '/enroll/verify', { - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], auth: 'access', allowRecoverySession: true, summary: 'Verify TOTP enrollment', @@ -94,7 +96,7 @@ totpRouter.post( totpRouter.post( '/disable', { - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], auth: 'access', summary: 'Disable TOTP for the current user', tags: ['TOTP'], diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index 657e88c..dfdd6ed 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -8,6 +8,7 @@ import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@s import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; @@ -77,6 +78,7 @@ usersRouter.delete( usersRouter.delete( '/credentials', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Users'], summary: 'Delete credential', diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index b38ff7e..b7149cc 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -15,6 +15,7 @@ import { verifyWebAuthnRegistration, } from '../controllers/webauthn.js'; import { createRouter } from '../lib/createRouter.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { @@ -39,6 +40,7 @@ const webauthnRouter = createRouter('/webauthn'); webauthnRouter.get( '/register/start', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Start WebAuthn registration', @@ -62,6 +64,7 @@ webauthnRouter.get( webauthnRouter.post( '/register/finish', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Finish WebAuthn registration', diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index 60f6224..987e3da 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -82,7 +82,7 @@ beforeEach(() => { }); describe('admin writes require a fresh step-up', () => { - it('documents the step-up 403 on exactly the guarded routes', async () => { + it('documents the step-up 403 on the guarded routes and the device session gate', async () => { await loadRoutes(express()); const documented = new Set(); @@ -98,7 +98,17 @@ describe('admin writes require a fresh step-up', () => { } } - expect([...documented].sort()).toEqual([...GUARDED].sort()); + for (const route of [ + ...GUARDED, + 'GET /admin/users', + 'POST /totp/enroll/start', + 'POST /recovery-codes/', + ]) { + expect(documented).toContain(route); + } + for (const route of ['GET /users/me', 'GET /sessions', 'POST /totp/verify-mfa']) { + expect(documented).not.toContain(route); + } }); it.each(GUARDED)('%s refuses a session that never stepped up', async (route) => { diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index bc3e20d..fbe4121 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -270,9 +270,13 @@ vi.mock('../../src/middleware/authenticateServiceToken.js', () => ({ })); vi.mock('../../src/middleware/requireAdmin.js', () => ({ - requireAdmin: () => (_req: any, _res: any, next: any) => { - next(); - }, + requireAdmin: () => + Object.assign( + (_req: any, _res: any, next: any) => { + next(); + }, + { seamlessDeviceStepUp: true }, + ), })); vi.mock('../../src/middleware/rateLimit.js', () => ({ diff --git a/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts b/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts new file mode 100644 index 0000000..fcfd07a --- /dev/null +++ b/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requireDeviceSessionStepUp } from '../../../src/middleware/requireDeviceSessionStepUp'; + +describe('requireDeviceSessionStepUp', () => { + let res: any; + let next: any; + + beforeEach(() => { + res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + next = vi.fn(); + }); + + it('passes every session that is not from a device grant', () => { + requireDeviceSessionStepUp({} as never, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('refuses a session from a device grant with no step-up', () => { + const req = { deviceChildSession: { stepUpVerifiedAt: null, stepUpMethod: null } }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false, method: null }), + ); + }); + + it('refuses a stale step-up', () => { + const req = { + deviceChildSession: { + stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), + stepUpMethod: 'totp', + }, + }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false, method: 'totp' }), + ); + }); + + it('passes a fresh step-up', () => { + const req = { deviceChildSession: { stepUpVerifiedAt: new Date(), stepUpMethod: 'totp' } }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(next).toHaveBeenCalled(); + }); +}); From 8f2ff8f90bb68ce16d28e6037e6a2e9003eae2ef Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:32:58 -0500 Subject: [PATCH 15/20] test(oidc): seed system_config in the applications spec It relied on an earlier spec having seeded the table, so it failed whenever it ran first. Refs #544 --- tests/postgres/oidcApplications.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/postgres/oidcApplications.spec.ts b/tests/postgres/oidcApplications.spec.ts index 7dcaa00..51095e2 100644 --- a/tests/postgres/oidcApplications.spec.ts +++ b/tests/postgres/oidcApplications.spec.ts @@ -14,6 +14,7 @@ import { listOidcApplicationLinks, } from '../../src/services/oidcClientLinkService.js'; import { getOidcClientResourceScopes } from '../../src/services/oidcClientRegistry.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; import { upsertOidcPayload } from '../../src/services/oidcPayloadStore.js'; import { createOidcResource, @@ -21,9 +22,14 @@ import { setOidcApplicationResources, updateOidcResource, } from '../../src/services/oidcResourceService.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; beforeAll(async () => { await initializeModels(); + await SystemConfig.bulkCreate( + Object.entries(buildSystemConfig()).map(([key, value]) => ({ key, value })) as never, + { ignoreDuplicates: true }, + ); }); afterAll(async () => { From ab78cef689075f81e1a9f0a8a290288be6dbb79f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:33:28 -0500 Subject: [PATCH 16/20] test(organizations): cover organization routes on a device child A device child read member emails and changed roles with no step-up, as a global admin or as an ordinary owner. These fail until the routes are gated. Refs #544 --- tests/postgres/sessionExchange.spec.ts | 263 +++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index e21744d..04fefe0 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -1842,3 +1842,266 @@ describe('factor changes on a device child', () => { expectStepUpRequired(await adminRead(res.body.token)); }); }); + +describe('organization routes on a device child', () => { + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + async function organizationWith(ownerId: string | null) { + const { Organization } = await import('../../src/models/organizations.js'); + const { OrganizationMembership } = await import('../../src/models/organizationMemberships.js'); + const member = await createUser(); + const organization = await Organization.create({ + name: 'Before', + slug: `org-${randomUUID()}`, + } as never); + const membership = await OrganizationMembership.create({ + userId: member.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never); + if (ownerId) { + await OrganizationMembership.create({ + userId: ownerId, + organizationId: organization.id, + roles: ['owner'], + scopes: [], + } as never); + } + return { organization, member, membership }; + } + + /** A child acting through a global admin role, with no membership of the organization. */ + async function adminPersona(grantType: string) { + const { client, user, parent } = await signedInToCli({}, {}, ['admin:read', 'admin:write']); + const { token: subject } = await signSubject(client, parent, { grantType }); + const res = await exchange(subject); + return { user, token: res.body.token as string, ...(await organizationWith(null)) }; + } + + /** A child acting as an ordinary owner of the organization, with no global admin role. */ + async function ownerPersona(grantType: string) { + const { client, user, parent } = await signedInToCli(); + const { token: subject } = await signSubject(client, parent, { grantType }); + const res = await exchange(subject); + return { user, token: res.body.token as string, ...(await organizationWith(user.id)) }; + } + + const personas = [ + ['a global admin', adminPersona], + ['an ordinary organization owner', ownerPersona], + ] as const; + + function expectStepUpRequired(res: request.Response) { + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: null }); + } + + async function stepUpWithRealTotp(user: User, token: string) { + const { startTotpEnrollment, verifyTotpEnrollment } = + await import('../../src/services/totpService.js'); + const { generateTotpCode, getTotpCounter } = await import('../../src/utils/totp.js'); + const { secret } = await startTotpEnrollment({ + userId: user.id, + email: user.email, + issuer: 'Test', + }); + await verifyTotpEnrollment( + user.id, + generateTotpCode({ secret, counter: getTotpCounter() - 1 }), + ); + const stepUp = await request(app) + .post('/totp/verify-mfa') + .set(bearer(token)) + .send({ code: generateTotpCode({ secret }) }); + expect(stepUp.status).toBe(200); + } + + describe.each(personas)('acting as %s', (_name, persona) => { + it('cannot read the member list, with its emails', async () => { + const { token, organization } = await persona(DEVICE_CODE); + + const res = await request(app) + .get(`/organizations/${organization.id}/members`) + .set(bearer(token)); + + expectStepUpRequired(res); + expect(JSON.stringify(res.body)).not.toContain('@session-exchange.test'); + }); + + it('cannot change a member to owner', async () => { + const { token, organization, member, membership } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['owner'] }), + ); + + await membership.reload(); + expect(membership.roles).toEqual(['member']); + }); + + it('cannot remove a member', async () => { + const { token, organization, member, membership } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .delete(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)), + ); + + await expect(membership.reload()).resolves.toBeDefined(); + }); + + it('cannot add a member', async () => { + const { token, organization } = await persona(DEVICE_CODE); + const stranger = await createUser(); + + expectStepUpRequired( + await request(app) + .post(`/organizations/${organization.id}/members`) + .set(bearer(token)) + .send({ userId: stranger.id, roles: ['member'] }), + ); + }); + + it('cannot get or change the organization', async () => { + const { token, organization } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app).get(`/organizations/${organization.id}`).set(bearer(token)), + ); + expectStepUpRequired( + await request(app) + .patch(`/organizations/${organization.id}`) + .set(bearer(token)) + .send({ name: 'After' }), + ); + + await organization.reload(); + expect(organization.name).toBe('Before'); + }); + + it('cannot list or create organizations', async () => { + const { token } = await persona(DEVICE_CODE); + + expectStepUpRequired(await request(app).get('/organizations').set(bearer(token))); + expectStepUpRequired( + await request(app) + .post('/organizations') + .set(bearer(token)) + .send({ name: 'New', slug: `new-${randomUUID()}` }), + ); + }); + + it('can still switch organization, and X7 still applies afterwards', async () => { + const { token, organization, user } = await persona(DEVICE_CODE); + const { OrganizationMembership } = + await import('../../src/models/organizationMemberships.js'); + await OrganizationMembership.findOrCreate({ + where: { userId: user.id, organizationId: organization.id }, + defaults: { + userId: user.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never, + }); + + const res = await request(app) + .post(`/organizations/${organization.id}/switch`) + .set(bearer(token)); + + expect(res.status).toBe(200); + expectStepUpRequired( + await request(app).get(`/organizations/${organization.id}`).set(bearer(res.body.token)), + ); + }); + + it('proceeds once it steps up with the real TOTP', async () => { + const { user, token, organization, member, membership } = await persona(DEVICE_CODE); + await stepUpWithRealTotp(user, token); + + const members = await request(app) + .get(`/organizations/${organization.id}/members`) + .set(bearer(token)); + expect(members.status).toBe(200); + expect(JSON.stringify(members.body)).toContain(member.email); + + expect((await request(app).get('/organizations').set(bearer(token))).status).toBe(200); + expect( + (await request(app).get(`/organizations/${organization.id}`).set(bearer(token))).status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}`) + .set(bearer(token)) + .send({ name: 'After' }) + ).status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + await membership.reload(); + expect(membership.roles).toEqual(['admin']); + expect( + ( + await request(app) + .delete(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + ).status, + ).toBe(200); + }); + + it('does not change a loopback child', async () => { + const { token, organization, member } = await persona(AUTHORIZATION_CODE); + + expect( + (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) + .status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + }); + }); + + it.each([ + ['a global admin', ['admin:read', 'admin:write'], false], + ['an ordinary organization owner', ['user'], true], + ] as [string, string[], boolean][])( + 'does not change a first-party session acting as %s', + async (_name, roles, owner) => { + const user = await createUser(roles); + const session = await createSession(user.id); + const token = await accessTokenFor(session, roles); + const { organization, member } = await organizationWith(owner ? user.id : null); + + expect( + (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) + .status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + }, + ); +}); From a4723f49e061c0825079df70a99ab8df9ebb1add Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:35:03 -0500 Subject: [PATCH 17/20] feat(organizations): gate organization routes on a device child Organization routes check roles without requireAdmin, so a device child with no step-up could read member emails and change roles. Every organization route now uses requireDeviceSessionStepUp, reads included, as a global admin or an ordinary member or owner. Switching stays open: it keeps the same session and grant, and X7 still applies afterwards. Refs #544 --- openapi.json | 231 ++++++++++++++++++++++++++++++ src/generated/api.ts | 203 ++++++++++++++++++++++++++ src/routes/organization.routes.ts | 10 +- 3 files changed, 443 insertions(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index 135dba8..2efa795 100644 --- a/openapi.json +++ b/openapi.json @@ -18759,6 +18759,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -18962,6 +18995,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -19156,6 +19222,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -19379,6 +19478,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -19760,6 +19892,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -20167,6 +20332,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -20384,6 +20582,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { diff --git a/src/generated/api.ts b/src/generated/api.ts index 79d78fd..dbb551d 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -18121,6 +18121,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -18291,6 +18320,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -18465,6 +18523,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -18659,6 +18746,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -19009,6 +19125,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -19423,6 +19568,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -19584,6 +19758,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index 1c5f996..c2a3d00 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -17,6 +17,7 @@ import { } from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { @@ -41,6 +42,7 @@ const organizationRouter = createRouter('/organizations'); organizationRouter.get( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'List organizations for the authenticated user', @@ -56,6 +58,7 @@ organizationRouter.get( organizationRouter.post( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Create organization', @@ -72,6 +75,7 @@ organizationRouter.post( organizationRouter.get( '/:organizationId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Get organization', @@ -89,6 +93,7 @@ organizationRouter.get( organizationRouter.patch( '/:organizationId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Update organization', @@ -126,6 +131,7 @@ organizationRouter.post( organizationRouter.get( '/:organizationId/members', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'List organization members', @@ -150,7 +156,7 @@ organizationRouter.post( 'Requires admin:write. Adding someone by email without their consent would let any ' + 'account holder learn which addresses have accounts and attach strangers to an ' + 'organization, so self-service adds wait for invitations.', - middleware: [requireAdmin('write'), requireStepUp()], + middleware: [requireDeviceSessionStepUp, requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, @@ -168,6 +174,7 @@ organizationRouter.post( organizationRouter.patch( '/:organizationId/members/:userId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Update organization member', @@ -187,6 +194,7 @@ organizationRouter.patch( organizationRouter.delete( '/:organizationId/members/:userId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Remove organization member', From cdcaa1c3e089608c48beed6d61fa5d2cfc25ac7f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:35:19 -0500 Subject: [PATCH 18/20] test(sessions): pin a passkey step-up on a device child Accepted from the registered key on the relying party's origin, refused from another origin or another key. Refs #544 --- tests/postgres/sessionExchange.spec.ts | 88 ++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index 04fefe0..f8fefb8 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -2105,3 +2105,91 @@ describe('organization routes on a device child', () => { }, ); }); + +describe('a passkey step-up on a device child', () => { + const RP_ID = 'localhost'; + const ORIGIN = 'http://localhost:5174'; + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + /** The user's own passkey, registered from a first-party session, and a device sign-in. */ + async function signedInWithPasskey() { + const { SoftwareAuthenticator } = await import('../factories/softwareAuthenticator.js'); + const { client, user, parent } = await signedInToCli({}, {}, ['admin:read']); + const browser = await accessTokenFor(parent, ['admin:read']); + const passkey = new SoftwareAuthenticator(); + + const start = await request(app).get('/webauthn/register/start').set(bearer(browser)); + expect(start.status).toBe(200); + const finish = await request(app) + .post('/webauthn/register/finish') + .set(bearer(browser)) + .send({ + attestationResponse: passkey.register({ + challenge: start.body.challenge, + origin: ORIGIN, + rpId: RP_ID, + }), + }); + expect(finish.status).toBe(200); + + const { token: subject } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const child = await exchange(subject); + return { passkey, token: child.body.token as string }; + } + + async function assertWith( + token: string, + authenticator: { + authenticate: (params: { challenge: string; origin: string; rpId: string }) => unknown; + }, + origin = ORIGIN, + ) { + const start = await request(app).post('/step-up/webauthn/start').set(bearer(token)).send({}); + expect(start.status).toBe(200); + return request(app) + .post('/step-up/webauthn/finish') + .set(bearer(token)) + .send({ + assertionResponse: authenticator.authenticate({ + challenge: start.body.challenge, + origin, + rpId: RP_ID, + }), + }); + } + + function adminRead(token: string) { + return request(app).get('/admin/users').set(bearer(token)); + } + + it('is accepted with the user own passkey on the relying party domain', async () => { + const { passkey, token } = await signedInWithPasskey(); + expect((await adminRead(token)).status).toBe(403); + + const res = await assertWith(token, passkey); + + expect(res.status).toBe(200); + expect(res.body.method).toBe('webauthn'); + expect((await adminRead(token)).status).toBe(200); + }); + + it('is refused from another origin, which is what a phisher would have', async () => { + const { passkey, token } = await signedInWithPasskey(); + + const res = await assertWith(token, passkey, 'https://phish.example.com'); + + expect(res.status).not.toBe(200); + expect((await adminRead(token)).status).toBe(403); + }); + + it('is refused from a key that is not the registered one', async () => { + const { SoftwareAuthenticator } = await import('../factories/softwareAuthenticator.js'); + const { passkey, token } = await signedInWithPasskey(); + const forged = new SoftwareAuthenticator(Buffer.from(passkey.credentialId)); + + const res = await assertWith(token, forged); + + expect(res.status).not.toBe(200); + expect((await adminRead(token)).status).toBe(403); + }); +}); From 2346e1ef5ef4d9e7c81328fbd6a10a2f17dc1b7a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 4 Nov 2026 21:35:36 -0500 Subject: [PATCH 19/20] docs(sessions): record the edges of X6 and X7 in ADR 0003 A device sign-in steps up with the user's own factor, and the CLI uses TOTP. Organization routes need the step-up. Signing out other sessions stays open. Refs #544 --- .changeset/session-exchange.md | 2 +- docs/adr/0003-native-admin-access.md | 38 +++++++++++++++++----------- docs/api-contract.md | 20 +++++++++------ 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/.changeset/session-exchange.md b/.changeset/session-exchange.md index e64c0b3..18a5bec 100644 --- a/.changeset/session-exchange.md +++ b/.changeset/session-exchange.md @@ -4,4 +4,4 @@ A first-party native client signed in through the OpenID Provider can now reach the admin API, by exchanging the sign-in for a first-party session of its own (ADR 0003). An admin turns `sessionExchangeEnabled` on for a first-party public native application, with step-up, audited as `admin_oidc_application_session_exchange_updated`. Such a client can ask for a 60 second access token for `/seamless` with the single scope `session:exchange`, and `/oidc/sign` records each one. `POST /sessions/exchange` (`adapter: false`, RFC 8693 parameter names) consumes that token once and returns the `/refresh` body plus `issued_token_type`, for a new session linked to the provider sign-in. A second presentation answers `400 invalid_request` with `token_already_used` and revokes the session the first one created, audited as `session_exchange_replayed`. A successful exchange is audited as `session_exchanged`. -The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` until it has a fresh step-up, on every route behind `requireAdmin`, reads included, and on `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`, so a device sign-in with no TOTP cannot enroll one and the user does it from a browser. OpenAPI now documents this 403 on those routes and on admin reads. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match. +The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` until it has a fresh step-up, on every route behind `requireAdmin` and every `/organizations` route except `POST /organizations/:id/switch`, reads included, and on `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`, so a device sign-in with no TOTP cannot enroll one and the user does it from a browser. It can step up with TOTP or a real passkey assertion on the relying party's domain. OpenAPI now documents this 403 on those routes and on admin reads. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match. diff --git a/docs/adr/0003-native-admin-access.md b/docs/adr/0003-native-admin-access.md index 3bc3184..3182361 100644 --- a/docs/adr/0003-native-admin-access.md +++ b/docs/adr/0003-native-admin-access.md @@ -77,7 +77,7 @@ Restricting the subject token to the built-in resource and a dedicated scope mat - **At the exchange, for an authorization code grant,** the child copies the parent's `authMethods`, `authTime`, `stepUpVerifiedAt` and `stepUpMethod` as they are at that instant. The API reads them from the parent row, the same source `/oidc/sign` already treats as the truth (D4, D9), so it does not depend on oidc#34. The five minute window still runs from the original step-up time, so a stale step-up stays stale. Later step-ups on either session never flow to the other. - **For a device grant,** the child copies nothing. It starts with no auth methods and no step-up, as D10 requires: the passkey proved who approved, not who holds the token (RFC 8628 section 5.4). - **A loopback CLI steps up with a passkey** by running a new loopback authorization with `acr_values=phr` and `max_age=300`. The hosted page runs `/step-up/*` on the parent (D3), the CLI exchanges the fresh subject token for a new child session with a fresh step-up, and logs out the old child. It can also use TOTP. -- **A device sign-in steps up with the authenticator app only,** through the existing `/totp/verify-mfa` on its own child session. A user with no TOTP enrolled cannot do admin writes from a device sign-in, and cannot enroll TOTP from one either: they enroll from a browser, then sign in on the device again. That is a deliberate limit, not a gap: any passkey path for a headless machine would hand a browser's assurance to whoever holds the device code, which is the attack D10 exists to stop. +- **A device sign-in steps up with the user's own factor,** on its own child session: TOTP through the existing `/totp/verify-mfa`, or a real passkey assertion on the relying party's domain through `/step-up/webauthn/*`. A phisher can produce neither. The CLI itself uses TOTP, since a terminal cannot run a WebAuthn ceremony. So from the CLI, a user with no TOTP enrolled cannot do admin writes from a device sign-in, and cannot enroll TOTP from one either: they enroll from a browser, then sign in on the device again. That is a deliberate limit, not a gap: copying the approving browser's step-up to a headless machine would hand its assurance to whoever holds the device code, which is the attack D10 exists to stop. - **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up stops that attacker from changing anything only while they cannot produce a TOTP code, which is why X7 also covers every route that changes how the account signs in or recovers: an attacker able to enroll their own authenticator could then step up with it. Without X7 they could also list every user on the instance. ### Revocation @@ -105,17 +105,17 @@ B has no usable revocation story: every event above that should end the CLI's ac ## Decision summary -| # | Decision | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | -| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | -| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | -| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | -| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | -| X6 | A device-grant child steps up with TOTP only. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | -| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route, reads included, and before any route that changes how the account signs in or recovers. It cannot enroll a first factor. | -| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | -| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | +| # | Decision | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | +| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | +| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | +| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | +| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | +| X6 | A device-grant child steps up with the user's own factor, TOTP or a real passkey assertion on the relying party's domain, and the CLI uses TOTP. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | +| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route and any organization route except switching, reads included, and before any route that changes how the account signs in or recovers. From the CLI it cannot enroll a first factor. | +| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | +| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | ## X2. The subject token @@ -161,7 +161,7 @@ The child session is an ordinary `sessions` row with three new columns, carried - **Validity.** `validateSessionRecord` and the refresh path refuse a child whose parent no longer resolves to a live session. The cascade on revoke keeps session lists accurate, and the read-time check makes a missed cascade harmless. - **Activity.** A child refresh counts as activity on the parent, as a provider token signed for the parent already does (`recordProviderActivity`, D3). Without that, an active CLI would be signed out when the browser session idles out. - **Assurance (X5).** Copied from the parent at the exchange for `authorization_code`, never for `urn:ietf:params:oauth:grant-type:device_code`. -- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. `requireDeviceSessionStepUp` gives the same answer, and is listed first in the middleware of every route that changes how the account signs in or recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`. A new route of that kind opts in the same way. A loopback child and every first-party session are unaffected. +- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. `requireDeviceSessionStepUp` gives the same answer, and is listed first in the middleware of every route that changes how the account signs in or recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`. Every `/organizations` route has it too, reads included, except `POST /organizations/:id/switch`, which keeps the same session and grant. That is one rule for a child acting through a global admin role or as an ordinary member or owner. A new route of either kind opts in the same way. A loopback child and every first-party session are unaffected. - **Audit.** `session_exchanged` (client, grant, parent), `session_exchange_replayed`, and the existing session events for the child. ## X8. No silent authorization for exchange-enabled clients @@ -179,7 +179,7 @@ So for an exchange-enabled client, the edge always shows a confirmation that nam 5. **Use the API as today.** `/refresh`, `/logout`, `/users/me`, `/admin/*` and `/totp/verify-mfa` are unchanged. 6. **Step up on `403 step_up_required`.** - Loopback session: offer a passkey (repeat step 2 with `acr_values=phr` and `max_age=300`, exchange, swap the keychain tokens, `/logout` the old child) or the authenticator app. - - Device session: offer the authenticator app only. When TOTP is not enrolled, say so and send the user to a browser to enroll it, since a device session cannot, then sign in on the device again. + - Device session: offer the authenticator app. When TOTP is not enrolled, say so and send the user to a browser to enroll it, since a device session cannot, then sign in on the device again. `seamless org` commands need this step-up too, except switching. 7. **On `invalid_request` from the exchange,** sign in again. If the description is `token_already_used`, tell the user their sign-in may have been intercepted. The CLI's e2e covers both paths against a v0.23 API and edge. @@ -204,6 +204,12 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re **Confirmed 2026-11-04: X7 covers factor changes.** As first accepted, X7 guarded only `/admin/*`, and its reasoning was wrong. Every factor route took any access session, so a device child with a phished approval could enroll a TOTP secret of its own, step up with it, and pass both X7 and `requireStepUp`. For a user who already had TOTP, the same enrollment disabled theirs. Both were run against real Postgres. The maintainer chose to refuse factor changes on a device child without a fresh step-up, through one shared middleware. A device sign-in with no TOTP therefore cannot enroll one, and the user does it from a browser. +**Confirmed 2026-11-04: the edges of X6 and X7.** + +- **Organization routes are gated.** They check roles without `requireAdmin`, so a device child read member emails and changed a member to owner with no step-up, while `/admin/users` answered 403. Every `/organizations` route now needs the fresh step-up, reads included, whether the child acts through a global admin role or as an ordinary member or owner. `POST /organizations/:id/switch` stays open: it keeps the same session and grant, and X7 still applies afterwards. +- **Signing out other sessions stays open.** `DELETE /sessions/:id`, `DELETE /sessions` and `DELETE /logout/all` take no step-up on a device child. They gain no access, and revoking the parent or everything also ends the child. The known cost is that someone holding a phished device session could sign the owner out of other sessions, which could slow the owner's response. +- **A passkey step-up is allowed.** `/step-up/webauthn/*` accepts a device child, because a real assertion on the relying party's domain is the user's own factor, which a phisher cannot produce. An assertion from another origin or another key is refused. The CLI itself uses TOTP. + ## Security properties | Property | Provided by | @@ -214,6 +220,7 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re | Device sign-ins never borrow the approving browser's assurance | X5, ADR 0001 D10 | | A phished device approval cannot read or change admin data without the user's own factor | X6, X7 | | A phished device approval cannot add, replace or remove a factor, or the account's phone | X7 | +| A phished device approval cannot read or change organizations or their members | X7 | | Ending the provider sign-in ends the CLI's admin access at once | X4 | | Ending the CLI's session does not end the user's browser session | X4 | | A local program cannot silently obtain an admin session through the CLI's client id | X8 | @@ -231,7 +238,8 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re - Three `sessions` columns, one client flag, one route and a signing record, plus a read-time parent lookup on every request from an exchanged session. - A narrow, documented exception to D11: one route accepts a provider token, to mint a first-party session. - Until first-party DPoP exists, the CLI's refresh token is a bearer credential, as it is today. -- Device sign-ins without TOTP can read nothing on `/admin/*` and cannot enroll TOTP (X7). The user enrolls from a browser, or uses the loopback flow or email code sign-in. +- Device sign-ins without TOTP can read nothing on `/admin/*` or `/organizations`, except switching, and cannot enroll TOTP (X7). The user enrolls from a browser, or uses the loopback flow or email code sign-in. +- A phished device session can sign the owner out of other sessions, which could slow their response. It gains no access by doing so. ## Issue map diff --git a/docs/api-contract.md b/docs/api-contract.md index 04682e2..554acf9 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -60,7 +60,7 @@ failures, `500` server error). A few carry branch-significant meaning that clien | `POST /refresh` | `401` | Missing, invalid, expired, or already-rotated refresh token. | | `POST /refresh` | `405` | Method other than POST. | | `POST /sessions/exchange` | `400` | `invalid_request`. With `token_already_used`, a replay: see below. | -| Admin and factor routes | `403` | `step_up_required`, even on a read, for a device-grant child session. | +| Admin, org and factor routes | `403` | `step_up_required`, even on a read, for a device-grant child session. | The SDK adapters branch on these exact codes (for example, the magic-link poll treats `204` as "pending"). Changing a branch-significant status code is a contract change; see the ripple @@ -167,13 +167,17 @@ user their sign-in may have been intercepted. **Step-up.** A session from an authorization code grant starts with the parent's step-up as it stood at the exchange, with the five minute window still running from the original step-up, so a -stale one stays stale. A session from a device grant starts with none. Until it completes -`POST /totp/verify-mfa`, it answers `403 step_up_required`, with the same body as `requireStepUp`, on -every route behind `requireAdmin`, reads included, and on every route that changes how the account -signs in or recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, -`POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, -`/registration/phone/verify` and `DELETE /recovery/requests/:id`. As for any session, a refresh does -not carry a step-up to the new row. +stale one stays stale. A session from a device grant starts with none. It steps up with the user's own factor: +`POST /totp/verify-mfa`, which is what a CLI uses, or a real passkey assertion on the relying +party's domain through `/step-up/webauthn/*`. Until then it answers `403 step_up_required`, with the +same body as `requireStepUp`, on every route behind `requireAdmin`, on every `/organizations` route +except `POST /organizations/:id/switch`, reads included, whether it acts through a global admin +role or as an ordinary member or owner, and on every route that changes how the account signs in or +recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, +`DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and +`DELETE /recovery/requests/:id`. `DELETE /sessions/:id`, `DELETE /sessions` and `DELETE /logout/all` +stay open: they gain no access, though they could slow the owner's response. As for any session, a +refresh does not carry a step-up to the new row. **A device sign-in with no TOTP cannot enroll one.** The user enrolls from a browser, then signs in on the device again. Without this, someone who phished a device approval could enroll their own From 2d0e788747200d0ab7884a805215663374bc915e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 08:03:03 -0500 Subject: [PATCH 20/20] chore: depend on @seamless-auth/types 0.36.0 Refs #544 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96d5880..cc0c52f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.36.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -2927,9 +2927,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.35.0.tgz", - "integrity": "sha512-T/1E72d43M+Bzivgo9k8yxaSBRpaXO9XJYlsenCwYVPb71Qnis+w10nXS7X/Qh/zKp7otOjRys2T3jMgP1L6Eg==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.36.0.tgz", + "integrity": "sha512-AxIPhwRegsKEtqi92BnOFv2TVpLsv/xGaJcQx4BUV7JlQSBMa0UUa8xyjJyTQP9m8YlI5O1gM09N2DRo9Ck/sQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index d8c8060..7977dd5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.36.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2",