From 525ed2cb4d296a46c163a645aab119f5e0e2292a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 09:04:19 -0400 Subject: [PATCH 1/3] test(verify): step up the owner session before admin writes Admin write routes require a fresh step-up from seamless-auth-api#490. The owner signs in with an email OTP, which does not count, so the harness now steps up with a software passkey derived from OWNER_EMAIL, enrolled the first time it is needed. --- .changeset/harness-admin-step-up.md | 5 ++ verify/harness/lib/cutoverFlows.ts | 33 ++------ verify/harness/lib/flows.ts | 15 ++-- verify/harness/lib/softwareAuthenticator.ts | 90 +++++++++++++++++++-- verify/harness/lib/stepUp.ts | 81 +++++++++++++++++++ 5 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 .changeset/harness-admin-step-up.md create mode 100644 verify/harness/lib/stepUp.ts diff --git a/.changeset/harness-admin-step-up.md b/.changeset/harness-admin-step-up.md new file mode 100644 index 0000000..683eace --- /dev/null +++ b/.changeset/harness-admin-step-up.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +`seamless verify` steps up the owner's admin session with a software passkey before admin writes, which the auth server requires from v0.20 (fells-code/seamless-auth-api#490). diff --git a/verify/harness/lib/cutoverFlows.ts b/verify/harness/lib/cutoverFlows.ts index 117fc39..5578ab4 100644 --- a/verify/harness/lib/cutoverFlows.ts +++ b/verify/harness/lib/cutoverFlows.ts @@ -6,13 +6,12 @@ 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'; +import { registerPasskey, stepUpOwner } from './stepUp'; // 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) { @@ -20,7 +19,7 @@ export function bearer(token: string) { } /** - * An admin access token. Only OWNER_EMAIL is granted admin at signup, so this signs + * An admin access token, stepped up for admin writes. 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. */ @@ -29,7 +28,9 @@ export async function ownerAdminToken(ctx: APIRequestContext): Promise { 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; + const { token } = (await res.json()) as { token: string }; + await stepUpOwner(ctx, token); + return token; } /** Adds an OIDC provider that verifies the mock's ID tokens and links on `oid`. */ @@ -108,27 +109,5 @@ export async function legacySignIn( /** 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(); + await registerPasskey(ctx, accessToken, new SoftwareAuthenticator()); } diff --git a/verify/harness/lib/flows.ts b/verify/harness/lib/flows.ts index 78d9e92..8ec9e6f 100644 --- a/verify/harness/lib/flows.ts +++ b/verify/harness/lib/flows.ts @@ -1,6 +1,7 @@ import { APIRequestContext, expect } from '@playwright/test'; import { EXTERNAL_DELIVERY } from './env'; +import { stepUpOwner } from './stepUp'; export interface SessionTokens { token: string; // signed access token @@ -169,9 +170,9 @@ export async function oauthLogin( } /** - * An access token for the tenant owner (OWNER_EMAIL, admin at signup), for the - * admin API. Signs the owner in when the account exists (the api layer usually - * registered it earlier in the run) and registers it otherwise. An unknown + * An access token for the tenant owner (OWNER_EMAIL, admin at signup), stepped up + * for the admin API. Signs the owner in when the account exists (the api layer + * usually registered it earlier in the run) and registers it otherwise. An unknown * identifier gets a decoy OTP send that delivers nothing, which is how the two * cases are told apart. */ @@ -189,10 +190,14 @@ export async function ownerAccessToken(ctx: APIRequestContext, ownerEmail: strin data: { verificationToken: String(code) }, }); expect(res.ok(), `verify-login-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy(); - return (await res.json()).token as string; + const { token } = (await res.json()) as { token: string }; + await stepUpOwner(ctx, token); + return token; } - return (await registerAndVerifyEmail(ctx, ownerEmail)).token; + const { token } = await registerAndVerifyEmail(ctx, ownerEmail); + await stepUpOwner(ctx, token); + return token; } /** Replace a user's roles through the admin API. */ diff --git a/verify/harness/lib/softwareAuthenticator.ts b/verify/harness/lib/softwareAuthenticator.ts index 774f584..c6a26b4 100644 --- a/verify/harness/lib/softwareAuthenticator.ts +++ b/verify/harness/lib/softwareAuthenticator.ts @@ -1,4 +1,13 @@ -import { createHash, generateKeyPairSync, KeyObject, randomBytes } from 'crypto'; +import { + createECDH, + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + KeyObject, + randomBytes, + sign, +} 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 @@ -37,16 +46,48 @@ function clientData(type: string, challenge: string, origin: string): Buffer { } export class SoftwareAuthenticator { - private readonly credentialId = randomBytes(32); + private readonly credentialId: Buffer; + private readonly privateKey: KeyObject; private readonly publicKey: KeyObject; - private counter = 0; - constructor() { - this.publicKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).publicKey; + /** + * With a seed, the key and credential id are derived from it, so a later process + * (a Playwright worker restarted after a failure) can still sign for a passkey an + * earlier one enrolled. + */ + constructor(seed?: string) { + if (!seed) { + this.credentialId = randomBytes(32); + ({ privateKey: this.privateKey, publicKey: this.publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + })); + return; + } + + this.credentialId = createHash('sha256').update(`credential:${seed}`).digest(); + const d = createHash('sha256').update(`key:${seed}`).digest(); + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(d); + const point = ecdh.getPublicKey(); + this.privateKey = createPrivateKey({ + key: { + kty: 'EC', + crv: 'P-256', + d: d.toString('base64url'), + x: point.subarray(1, 33).toString('base64url'), + y: point.subarray(33).toString('base64url'), + }, + format: 'jwk', + }); + this.publicKey = createPublicKey(this.privateKey); + } + + get id() { + return this.credentialId.toString('base64url'); } register(params: { challenge: string; origin: string; rpId: string }) { - const id = this.credentialId.toString('base64url'); + const id = this.id; const attestationObject = cbor( new Map([ ['fmt', 'none'], @@ -70,9 +111,42 @@ export class SoftwareAuthenticator { }; } - private authData(rpId: string): Buffer { + /** An assertion for `navigator.credentials.get`, signed with this credential's key. */ + authenticate(params: { challenge: string; origin: string; rpId: string }) { + const authenticatorData = Buffer.concat([ + createHash('sha256').update(params.rpId).digest(), + Buffer.from([0x05]), // UP | UV + this.signCount(), + ]); + const clientDataJSON = clientData('webauthn.get', params.challenge, params.origin); + const signature = sign( + 'sha256', + Buffer.concat([authenticatorData, createHash('sha256').update(clientDataJSON).digest()]), + this.privateKey, + ); + + return { + id: this.id, + rawId: this.id, + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: clientDataJSON.toString('base64url'), + authenticatorData: authenticatorData.toString('base64url'), + signature: signature.toString('base64url'), + }, + }; + } + + // Seconds since the epoch: always increasing, even across processes sharing a seed. + private signCount(): Buffer { const counter = Buffer.alloc(4); - counter.writeUInt32BE(++this.counter); + counter.writeUInt32BE(Math.floor(Date.now() / 1000)); + return counter; + } + + private authData(rpId: string): Buffer { + const counter = this.signCount(); const idLength = Buffer.alloc(2); idLength.writeUInt16BE(this.credentialId.length); diff --git a/verify/harness/lib/stepUp.ts b/verify/harness/lib/stepUp.ts new file mode 100644 index 0000000..d0762d7 --- /dev/null +++ b/verify/harness/lib/stepUp.ts @@ -0,0 +1,81 @@ +import { APIRequestContext, expect } from '@playwright/test'; + +import { OWNER_EMAIL } from './env'; +import { SoftwareAuthenticator } from './softwareAuthenticator'; + +// The browser origin the stack accepts for WebAuthn (ORIGINS / RPID in the compose file). +export const WEBAUTHN_ORIGIN = 'http://localhost:5173'; + +function bearer(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +/** Enrolls `authenticator` as a passkey on the signed-in account. */ +export async function registerPasskey( + ctx: APIRequestContext, + accessToken: string, + authenticator: SoftwareAuthenticator, +): 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 finish = await ctx.post('/webAuthn/register/finish', { + headers: bearer(accessToken), + data: { + attestationResponse: authenticator.register({ + challenge: options.challenge, + origin: WEBAUTHN_ORIGIN, + rpId: options.rp.id, + }), + metadata: { + friendlyName: 'verify harness', + platform: 'node', + browser: 'none', + deviceInfo: 'software authenticator', + }, + }, + }); + expect(finish.ok(), `register finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); +} + +/** + * Freshens the owner session's step-up, which admin writes require. Uses one passkey + * derived from OWNER_EMAIL, enrolled the first time it is needed. + */ +export async function stepUpOwner(ctx: APIRequestContext, accessToken: string): Promise { + const authenticator = new SoftwareAuthenticator(`verify-owner:${OWNER_EMAIL}`); + + const begin = async () => { + const res = await ctx.post('/step-up/webauthn/start', { + headers: bearer(accessToken), + data: {}, + }); + if (res.status() === 401) return null; + expect(res.ok(), `step-up start -> ${res.status()} ${await res.text()}`).toBeTruthy(); + const options = await res.json(); + const enrolled = (options.allowCredentials ?? []).some( + (credential: { id: string }) => credential.id === authenticator.id, + ); + return enrolled ? options : null; + }; + + let options = await begin(); + if (!options) { + await registerPasskey(ctx, accessToken, authenticator); + options = await begin(); + } + expect(options, 'step-up offers the owner passkey').toBeTruthy(); + + const finish = await ctx.post('/step-up/webauthn/finish', { + headers: bearer(accessToken), + data: { + assertionResponse: authenticator.authenticate({ + challenge: options.challenge, + origin: WEBAUTHN_ORIGIN, + rpId: options.rpId, + }), + }, + }); + expect(finish.ok(), `step-up finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); +} From b37778f80036b487ceb6884885915205d39d9667 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 09:19:17 -0400 Subject: [PATCH 2/3] test(verify): keep the owner passkey sign count growing between uses Enrollment and step-up run in the same second, and the API refuses an assertion whose count does not exceed the stored one. Count from the last use as well as the clock, and keep one owner authenticator per worker. --- verify/harness/lib/softwareAuthenticator.ts | 7 +++++-- verify/harness/lib/stepUp.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/verify/harness/lib/softwareAuthenticator.ts b/verify/harness/lib/softwareAuthenticator.ts index c6a26b4..733da95 100644 --- a/verify/harness/lib/softwareAuthenticator.ts +++ b/verify/harness/lib/softwareAuthenticator.ts @@ -49,6 +49,7 @@ export class SoftwareAuthenticator { private readonly credentialId: Buffer; private readonly privateKey: KeyObject; private readonly publicKey: KeyObject; + private counter = 0; /** * With a seed, the key and credential id are derived from it, so a later process @@ -138,10 +139,12 @@ export class SoftwareAuthenticator { }; } - // Seconds since the epoch: always increasing, even across processes sharing a seed. + // Seconds since the epoch, so it keeps increasing across processes sharing a seed, + // and at least one past the last use, since the API refuses a count that does not grow. private signCount(): Buffer { + this.counter = Math.max(Math.floor(Date.now() / 1000), this.counter + 1); const counter = Buffer.alloc(4); - counter.writeUInt32BE(Math.floor(Date.now() / 1000)); + counter.writeUInt32BE(this.counter); return counter; } diff --git a/verify/harness/lib/stepUp.ts b/verify/harness/lib/stepUp.ts index d0762d7..1fc3a14 100644 --- a/verify/harness/lib/stepUp.ts +++ b/verify/harness/lib/stepUp.ts @@ -6,6 +6,9 @@ import { SoftwareAuthenticator } from './softwareAuthenticator'; // The browser origin the stack accepts for WebAuthn (ORIGINS / RPID in the compose file). export const WEBAUTHN_ORIGIN = 'http://localhost:5173'; +// One instance per worker, so its sign count keeps growing between owner sign-ins. +const ownerAuthenticator = new SoftwareAuthenticator(`verify-owner:${OWNER_EMAIL}`); + function bearer(token: string) { return { Authorization: `Bearer ${token}` }; } @@ -44,8 +47,6 @@ export async function registerPasskey( * derived from OWNER_EMAIL, enrolled the first time it is needed. */ export async function stepUpOwner(ctx: APIRequestContext, accessToken: string): Promise { - const authenticator = new SoftwareAuthenticator(`verify-owner:${OWNER_EMAIL}`); - const begin = async () => { const res = await ctx.post('/step-up/webauthn/start', { headers: bearer(accessToken), @@ -55,14 +56,14 @@ export async function stepUpOwner(ctx: APIRequestContext, accessToken: string): expect(res.ok(), `step-up start -> ${res.status()} ${await res.text()}`).toBeTruthy(); const options = await res.json(); const enrolled = (options.allowCredentials ?? []).some( - (credential: { id: string }) => credential.id === authenticator.id, + (credential: { id: string }) => credential.id === ownerAuthenticator.id, ); return enrolled ? options : null; }; let options = await begin(); if (!options) { - await registerPasskey(ctx, accessToken, authenticator); + await registerPasskey(ctx, accessToken, ownerAuthenticator); options = await begin(); } expect(options, 'step-up offers the owner passkey').toBeTruthy(); @@ -70,7 +71,7 @@ export async function stepUpOwner(ctx: APIRequestContext, accessToken: string): const finish = await ctx.post('/step-up/webauthn/finish', { headers: bearer(accessToken), data: { - assertionResponse: authenticator.authenticate({ + assertionResponse: ownerAuthenticator.authenticate({ challenge: options.challenge, origin: WEBAUTHN_ORIGIN, rpId: options.rpId, From 3bd0e2d56c00dd539cfbe118ad1c9fb3b5fdabf4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 09:19:25 -0400 Subject: [PATCH 3/3] chore(deps): update @seamless-auth/types to 0.30.0 --- .changeset/types-0-30-0.md | 5 +++++ package-lock.json | 12 ++++++------ package.json | 2 +- src/core/systemConfig.ts | 5 ++++- 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .changeset/types-0-30-0.md diff --git a/.changeset/types-0-30-0.md b/.changeset/types-0-30-0.md new file mode 100644 index 0000000..01105aa --- /dev/null +++ b/.changeset/types-0-30-0.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +Update `@seamless-auth/types` to 0.30.0. Its new `recovery_codes` and `account_recovery` config keys are not sent by `config set` or `config apply` until auth server v0.20 accepts them (#310). diff --git a/package-lock.json b/package-lock.json index fcd24f2..c098195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "seamless-cli", - "version": "0.24.0", + "version": "0.25.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-cli", - "version": "0.24.0", + "version": "0.25.1", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "adm-zip": "^0.6.1", "kleur": "^4.1.5", "zod": "^4.6.5" @@ -1541,9 +1541,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", - "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.30.0.tgz", + "integrity": "sha512-1MM/jBSG901HS35pLCNhEcOjA1KYLcBIxQZHYyexOOQ/7R8VNjxlmxG0L26VPV+FhA7Rkq5sBIifCLbR+g+0zA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 4df52b1..b13dfff 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "adm-zip": "^0.6.1", "kleur": "^4.1.5", "zod": "^4.6.5" diff --git a/src/core/systemConfig.ts b/src/core/systemConfig.ts index fce31d6..f350648 100644 --- a/src/core/systemConfig.ts +++ b/src/core/systemConfig.ts @@ -56,7 +56,10 @@ export const WRITABLE_KEYS = [ // In the shared patch schema but not yet accepted by a released auth API this CLI // can rely on, each with the ticket that tracks it. Move a key to WRITABLE_KEYS // once it is. -export const NOT_YET_WRITABLE = {} as const satisfies Partial< +export const NOT_YET_WRITABLE = { + recovery_codes: "fells-code/seamless-cli#310", + account_recovery: "fells-code/seamless-cli#310", +} as const satisfies Partial< Record >;