diff --git a/.changeset/verify-oauth-cutover.md b/.changeset/verify-oauth-cutover.md new file mode 100644 index 0000000..fdd7ec1 --- /dev/null +++ b/.changeset/verify-oauth-cutover.md @@ -0,0 +1,14 @@ +--- +"seamless-cli": minor +--- + +`seamless verify` covers the OAuth migration cutover path (fells-code/seamless-auth-api#337) at the API layer. The spec: + +1. Imports a user from a directory, then signs them in through that directory's OIDC provider. The link is made on the ID token's `oid`, not the email. +2. Checks that the sign-in answers `nextStep: 'enroll_passkey'`, enrolls a passkey, and checks the prompt stops. +3. Retires the provider for the user's organization. It checks that the user's sessions are revoked and that the next sign-in is refused with `oauth_provider_retired`. +4. Restores the provider and checks the user can sign in again. + +The mock OIDC provider now issues signed ID tokens, serves `/jwks`, and can sign in as a named user. The existing `mock` provider is unaffected. + +Needs an auth API that includes fells-code/seamless-auth-api#348 (session revocation on retirement). diff --git a/verify/harness/api/oauthCutover.spec.ts b/verify/harness/api/oauthCutover.spec.ts new file mode 100644 index 0000000..6b856c2 --- /dev/null +++ b/verify/harness/api/oauthCutover.spec.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'crypto'; + +import { + bearer, + createLegacyProvider, + enrollPasskey, + legacySignIn, + ownerAdminToken, +} from '../lib/cutoverFlows'; +import { uniqueEmail } from '../lib/env'; +import { expect, test } from '../lib/fixtures'; +import { refresh } from '../lib/flows'; + +// The migration cutover path from fells-code/seamless-auth-api#337, end to end: +// import a user from a directory, sign them in through that directory's OIDC provider +// (linked on the directory id, not the email), send them into passkey enrollment, then +// retire the provider for their organization and roll the retirement back. +test.describe('OAuth migration cutover (api)', () => { + test('links an imported user, enrolls a passkey, then retires and restores the provider', async ({ + actor, + }) => { + const { ctx } = actor; + const admin = await ownerAdminToken(ctx); + const source = `verify-${randomUUID().slice(0, 8)}`; + const providerId = await createLegacyProvider(ctx, admin, source); + + const organization = await ctx.post('/admin/organizations', { + headers: bearer(admin), + data: { name: `Cutover ${source}` }, + }); + expect(organization.status(), await organization.text()).toBe(201); + const organizationId = (await organization.json()).organization.id as string; + + // The directory reports a different address than the one imported, so a link can + // only have come from the directory id. + const oid = randomUUID(); + const importedEmail = uniqueEmail('imported'); + const directory = { sub: `legacy-${oid}`, oid, email: uniqueEmail('directory') }; + + const imported = await ctx.post('/admin/users/import', { + headers: bearer(admin), + data: { + source, + users: [{ externalId: oid, email: importedEmail, organizations: [{ organizationId }] }], + }, + }); + expect(imported.ok(), `import -> ${imported.status()} ${await imported.text()}`).toBeTruthy(); + const [row] = (await imported.json()).results; + expect(row.status, 'the user is imported').toBe('created'); + + await test.step('the first sign-in links the imported user and asks for a passkey', async () => { + const res = await legacySignIn(ctx, providerId, directory); + expect(res.status(), await res.text()).toBe(200); + const body = await res.json(); + + expect(body.sub, 'signed in as the imported user').toBe(row.userId); + expect(body.email, 'the imported address is kept').toBe(importedEmail); + expect(body.nextStep).toBe('enroll_passkey'); + + await enrollPasskey(ctx, body.token); + }); + + const enrolled = await test.step('with a passkey, the prompt stops', async () => { + const res = await legacySignIn(ctx, providerId, directory); + expect(res.status(), await res.text()).toBe(200); + const body = await res.json(); + + expect(body.nextStep, 'no further step once a passkey exists').toBeUndefined(); + return body as { token: string; refreshToken: string }; + }); + + await test.step('retiring the provider revokes sessions and refuses sign-in', async () => { + const retired = await ctx.put( + `/admin/organizations/${organizationId}/oauth-providers/${providerId}/retirement`, + { headers: bearer(admin) }, + ); + expect(retired.status(), await retired.text()).toBe(200); + expect((await retired.json()).organization.retiredOAuthProviders).toContain(providerId); + + const me = await ctx.get('/users/me', { headers: bearer(enrolled.token) }); + expect(me.status(), 'the access token stops working').toBe(401); + const refreshed = await refresh(ctx, enrolled.refreshToken); + expect(refreshed.status(), 'the refresh token stops working').toBe(401); + + const res = await legacySignIn(ctx, providerId, directory); + expect(res.status(), await res.text()).toBe(403); + expect((await res.json()).code).toBe('oauth_provider_retired'); + }); + + await test.step('restoring the provider lets the user back in', async () => { + // Creating the organization made the owner a member, so the retirement revoked + // the owner's session too. + const stale = await ctx.get('/users/me', { headers: bearer(admin) }); + expect(stale.status(), 'the retiring admin, a member, was signed out too').toBe(401); + const freshAdmin = await ownerAdminToken(ctx); + + const restored = await ctx.delete( + `/admin/organizations/${organizationId}/oauth-providers/${providerId}/retirement`, + { headers: bearer(freshAdmin) }, + ); + expect(restored.status(), await restored.text()).toBe(200); + + const res = await legacySignIn(ctx, providerId, directory); + expect(res.status(), await res.text()).toBe(200); + expect((await res.json()).sub).toBe(row.userId); + }); + }); +}); diff --git a/verify/harness/lib/cutoverFlows.ts b/verify/harness/lib/cutoverFlows.ts new file mode 100644 index 0000000..781ab87 --- /dev/null +++ b/verify/harness/lib/cutoverFlows.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'crypto'; + +import { APIRequestContext, expect } from '@playwright/test'; + +import { MOCK_OIDC_ISSUER } from '../mock-oidc'; +import { MOCK_OIDC_PORT, OWNER_EMAIL } from './env'; +import { registerEmail, requestEmailOtp, verifyEmailOtp } from './flows'; +import { SoftwareAuthenticator } from './softwareAuthenticator'; + +// Helpers for the migration cutover path (fells-code/seamless-auth-api#337): a legacy +// OIDC provider that links imported users by their directory id, prompts them into +// passkey enrollment, and can be retired per organization. + +// The browser origin the stack accepts for WebAuthn (ORIGINS / RPID in the compose file). +const WEBAUTHN_ORIGIN = 'http://localhost:5173'; +const REDIRECT_URI = 'http://localhost:5173/oauth/callback'; + +export function bearer(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +/** + * An admin access token. Only OWNER_EMAIL is granted admin at signup, so this signs + * in as the owner; registering an address that already has an account continues + * that account, so it works whether or not the owner spec has run first. + */ +export async function ownerAdminToken(ctx: APIRequestContext): Promise { + const ephemeral = await registerEmail(ctx, OWNER_EMAIL); + const code = await requestEmailOtp(ctx, ephemeral); + const res = await verifyEmailOtp(ctx, ephemeral, code); + expect(res.ok(), `owner sign-in -> ${res.status()} ${await res.text()}`).toBeTruthy(); + return (await res.json()).token as string; +} + +/** Adds an OIDC provider that verifies the mock's ID tokens and links on `oid`. */ +export async function createLegacyProvider( + ctx: APIRequestContext, + adminToken: string, + externalIdSource: string, +): Promise { + const id = `legacy-${randomUUID().slice(0, 8)}`; + const res = await ctx.post('/system-config/oauth-providers', { + headers: bearer(adminToken), + data: { + id, + name: 'Legacy IdP', + enabled: true, + clientId: `${id}-client`, + clientSecretEnv: 'MOCK_CLIENT_SECRET', + authorizationUrl: `http://localhost:${MOCK_OIDC_PORT}/authorize`, + tokenUrl: `http://host.docker.internal:${MOCK_OIDC_PORT}/token`, + userInfoUrl: `http://host.docker.internal:${MOCK_OIDC_PORT}/userinfo`, + scopes: ['openid', 'email'], + redirectUri: REDIRECT_URI, + redirectUris: [REDIRECT_URI], + // Only imported users get in: a sign-in that matches nobody is refused. + allowSignup: false, + accountLinking: 'email', + issuer: MOCK_OIDC_ISSUER, + jwksUri: `http://host.docker.internal:${MOCK_OIDC_PORT}/jwks`, + externalIdSource, + externalIdJsonPath: 'oid', + promptPasskeyEnrollment: true, + }, + }); + expect(res.status(), `create provider -> ${res.status()} ${await res.text()}`).toBe(201); + return id; +} + +export interface LegacyProfile { + sub: string; + oid: string; + email: string; +} + +/** + * Signs in through the legacy provider as a given directory user, returning the raw + * callback response so a spec can assert refusals as well as sessions. + */ +export async function legacySignIn( + ctx: APIRequestContext, + providerId: string, + profile: LegacyProfile, +) { + const start = await ctx.post(`/oauth/${providerId}/start`, { + data: { redirectUri: REDIRECT_URI }, + }); + expect(start.ok(), `oauth start -> ${start.status()} ${await start.text()}`).toBeTruthy(); + + const authorizationUrl = new URL((await start.json()).authorizationUrl); + authorizationUrl.searchParams.set('mock_sub', profile.sub); + authorizationUrl.searchParams.set('mock_oid', profile.oid); + authorizationUrl.searchParams.set('mock_email', profile.email); + + const authorize = await ctx.get(authorizationUrl.toString(), { maxRedirects: 0 }); + expect(authorize.status(), 'authorize redirects with a code').toBe(302); + const redirected = new URL(authorize.headers()['location']); + + return ctx.post(`/oauth/${providerId}/callback`, { + data: { + code: redirected.searchParams.get('code'), + state: redirected.searchParams.get('state'), + }, + }); +} + +/** Enrolls a passkey on the account the access token belongs to. */ +export async function enrollPasskey(ctx: APIRequestContext, accessToken: string): Promise { + const start = await ctx.get('/webAuthn/register/start', { headers: bearer(accessToken) }); + expect(start.ok(), `register start -> ${start.status()} ${await start.text()}`).toBeTruthy(); + const options = await start.json(); + + const attestationResponse = new SoftwareAuthenticator().register({ + challenge: options.challenge, + origin: WEBAUTHN_ORIGIN, + rpId: options.rp.id, + }); + + const finish = await ctx.post('/webAuthn/register/finish', { + headers: bearer(accessToken), + data: { + attestationResponse, + metadata: { + friendlyName: 'verify harness', + platform: 'node', + browser: 'none', + deviceInfo: 'software authenticator', + }, + }, + }); + expect(finish.ok(), `register finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); +} diff --git a/verify/harness/lib/softwareAuthenticator.ts b/verify/harness/lib/softwareAuthenticator.ts new file mode 100644 index 0000000..774f584 --- /dev/null +++ b/verify/harness/lib/softwareAuthenticator.ts @@ -0,0 +1,102 @@ +import { createHash, generateKeyPairSync, KeyObject, randomBytes } from 'crypto'; + +// A minimal ES256 authenticator with `fmt: 'none'` attestation, enough to enroll a +// passkey through the API without a browser. The browser layer covers the real +// navigator.credentials path with Playwright's virtual authenticator. + +type Cbor = number | Uint8Array | string | Map; + +function head(major: number, length: number): Buffer { + if (length < 24) return Buffer.from([(major << 5) | length]); + if (length < 0x100) return Buffer.from([(major << 5) | 24, length]); + const out = Buffer.alloc(3); + out[0] = (major << 5) | 25; + out.writeUInt16BE(length, 1); + return out; +} + +// Covers only what an attestation object and a COSE key need: small integers, byte +// strings, text strings and maps. +function cbor(value: Cbor): Buffer { + if (typeof value === 'number') { + return value >= 0 ? head(0, value) : head(1, -1 - value); + } + if (typeof value === 'string') { + const bytes = Buffer.from(value, 'utf8'); + return Buffer.concat([head(3, bytes.length), bytes]); + } + if (value instanceof Uint8Array) { + return Buffer.concat([head(2, value.length), Buffer.from(value)]); + } + const entries = [...value.entries()].flatMap(([key, item]) => [cbor(key), cbor(item)]); + return Buffer.concat([head(5, value.size), ...entries]); +} + +function clientData(type: string, challenge: string, origin: string): Buffer { + return Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false })); +} + +export class SoftwareAuthenticator { + private readonly credentialId = randomBytes(32); + private readonly publicKey: KeyObject; + private counter = 0; + + constructor() { + this.publicKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).publicKey; + } + + register(params: { challenge: string; origin: string; rpId: string }) { + const id = this.credentialId.toString('base64url'); + const attestationObject = cbor( + new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', this.authData(params.rpId)], + ]), + ); + + return { + id, + rawId: id, + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: clientData('webauthn.create', params.challenge, params.origin).toString( + 'base64url', + ), + attestationObject: attestationObject.toString('base64url'), + transports: ['internal'], + }, + }; + } + + private authData(rpId: string): Buffer { + const counter = Buffer.alloc(4); + counter.writeUInt32BE(++this.counter); + const idLength = Buffer.alloc(2); + idLength.writeUInt16BE(this.credentialId.length); + + return Buffer.concat([ + createHash('sha256').update(rpId).digest(), + Buffer.from([0x45]), // UP | UV | AT + counter, + Buffer.alloc(16), // AAGUID: all zeroes, as an unattested authenticator reports + idLength, + this.credentialId, + this.coseKey(), + ]); + } + + private coseKey(): Buffer { + const { x, y } = this.publicKey.export({ format: 'jwk' }) as { x: string; y: string }; + return cbor( + new Map([ + [1, 2], // kty: EC2 + [3, -7], // alg: ES256 + [-1, 1], // crv: P-256 + [-2, Buffer.from(x, 'base64url')], + [-3, Buffer.from(y, 'base64url')], + ]), + ); + } +} diff --git a/verify/harness/mock-oidc.ts b/verify/harness/mock-oidc.ts index a74ceab..2f973f1 100644 --- a/verify/harness/mock-oidc.ts +++ b/verify/harness/mock-oidc.ts @@ -1,14 +1,29 @@ -import { createHash, randomUUID } from 'crypto'; +import { createECDH, createHash, createPrivateKey, randomUUID, sign } from 'crypto'; import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; -// Minimal OIDC identity provider for the OAuth conformance flow. The API only uses -// the authorization code, the token exchange (PKCE), and userinfo (it does not -// validate id_token signatures), so /authorize, /token, /userinfo is all we need. +// Minimal OIDC identity provider for the OAuth conformance flows: /authorize, /token +// (PKCE), /userinfo, and /jwks. The token response always carries a signed ID token, +// which only a provider configured with `issuer` and `jwksUri` makes the API verify +// and read; the plain `mock` provider ignores it and calls /userinfo. +// +// /authorize mints a fresh user unless the caller names one with `mock_sub`, +// `mock_email` and `mock_oid`. The harness appends those to the authorization URL the +// API returned, so a spec can sign in as a user it imported beforehand. + +export const MOCK_OIDC_ISSUER = 'http://mock-oidc.verify'; + +interface Profile { + sub: string; + email: string; + oid: string; +} interface PendingCode { codeChallenge?: string; redirectUri: string; - profile: { sub: string; email: string }; + clientId: string; + nonce?: string; + profile: Profile; } const sha256Base64Url = (value: string): string => @@ -27,15 +42,65 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(JSON.stringify(body)); } +// The signing key is derived from a fixed seed so every run serves the same key. The +// API caches a provider's JWKS and will not refetch for an unknown key id within its +// cooldown, so a fresh key per run made a quick local re-run fail verification. It is +// a test key for a mock provider and signs nothing else. +function signingKey() { + const d = createHash('sha256').update('seamless-verify-mock-oidc').digest(); + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(d); + const point = ecdh.getPublicKey(); + const coordinates = { + kty: 'EC', + crv: 'P-256', + x: point.subarray(1, 33).toString('base64url'), + y: point.subarray(33, 65).toString('base64url'), + }; + return { + privateKey: createPrivateKey({ + key: { ...coordinates, d: d.toString('base64url') }, + format: 'jwk', + }), + jwk: { ...coordinates, kid: 'mock-oidc-es256', alg: 'ES256', use: 'sig' }, + }; +} + export function startMockOidc(port: number): Server { const codes = new Map(); - const tokens = new Map(); + const tokens = new Map(); + const { privateKey, jwk } = signingKey(); + + const idToken = (pending: PendingCode): string => { + const now = Math.floor(Date.now() / 1000); + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const data = `${encode({ alg: 'ES256', typ: 'JWT', kid: jwk.kid })}.${encode({ + iss: MOCK_OIDC_ISSUER, + aud: pending.clientId, + sub: pending.profile.sub, + oid: pending.profile.oid, + email: pending.profile.email, + email_verified: true, + name: 'OAuth User', + iat: now, + exp: now + 600, + ...(pending.nonce ? { nonce: pending.nonce } : {}), + })}`; + // JWS wants the raw r || s signature, not DER. + const signature = sign('sha256', Buffer.from(data), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + return `${data}.${signature.toString('base64url')}`; + }; const server = createServer((req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`); - // Authorization endpoint: mint a code bound to the PKCE challenge + a fresh - // user, then redirect back to the app's redirect_uri with code + state. + if (req.method === 'GET' && url.pathname === '/jwks') { + sendJson(res, 200, { keys: [jwk] }); + return; + } + + // Authorization endpoint: mint a code bound to the PKCE challenge, the nonce and + // the user, then redirect back to the app's redirect_uri with code + state. if (req.method === 'GET' && url.pathname === '/authorize') { const redirectUri = url.searchParams.get('redirect_uri'); if (!redirectUri) { @@ -46,9 +111,13 @@ export function startMockOidc(port: number): Server { codes.set(code, { codeChallenge: url.searchParams.get('code_challenge') ?? undefined, redirectUri, + clientId: url.searchParams.get('client_id') ?? '', + nonce: url.searchParams.get('nonce') ?? undefined, profile: { - sub: `mock-${randomUUID()}`, - email: `oauth.${randomUUID().slice(0, 12)}@example.test`, + sub: url.searchParams.get('mock_sub') ?? `mock-${randomUUID()}`, + email: + url.searchParams.get('mock_email') ?? `oauth.${randomUUID().slice(0, 12)}@example.test`, + oid: url.searchParams.get('mock_oid') ?? randomUUID(), }, }); const location = new URL(redirectUri); @@ -59,7 +128,8 @@ export function startMockOidc(port: number): Server { return; } - // Token endpoint: validate PKCE (S256), consume the code, issue an opaque token. + // Token endpoint: validate PKCE (S256), consume the code, issue an opaque access + // token and a signed ID token. if (req.method === 'POST' && url.pathname === '/token') { void readBody(req).then((raw) => { const params = new URLSearchParams(raw); @@ -79,7 +149,12 @@ export function startMockOidc(port: number): Server { } const accessToken = randomUUID(); tokens.set(accessToken, pending.profile); - sendJson(res, 200, { access_token: accessToken, token_type: 'Bearer', expires_in: 3600 }); + sendJson(res, 200, { + access_token: accessToken, + id_token: idToken(pending), + token_type: 'Bearer', + expires_in: 3600, + }); }); return; }