Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harness-admin-step-up.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/types-0-30-0.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion src/core/systemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof SystemConfigPatch, string>
>;

Expand Down
33 changes: 6 additions & 27 deletions verify/harness/lib/cutoverFlows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,20 @@ 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) {
return { Authorization: `Bearer ${token}` };
}

/**
* 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.
*/
Expand All @@ -29,7 +28,9 @@ export async function ownerAdminToken(ctx: APIRequestContext): Promise<string> {
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`. */
Expand Down Expand Up @@ -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<void> {
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());
}
15 changes: 10 additions & 5 deletions verify/harness/lib/flows.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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. */
Expand Down
91 changes: 84 additions & 7 deletions verify/harness/lib/softwareAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -37,16 +46,49 @@ 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<Cbor, Cbor>([
['fmt', 'none'],
Expand All @@ -70,9 +112,44 @@ 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, 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(++this.counter);
counter.writeUInt32BE(this.counter);
return counter;
}

private authData(rpId: string): Buffer {
const counter = this.signCount();
const idLength = Buffer.alloc(2);
idLength.writeUInt16BE(this.credentialId.length);

Expand Down
82 changes: 82 additions & 0 deletions verify/harness/lib/stepUp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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';

// 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}` };
}

/** Enrolls `authenticator` as a passkey on the signed-in account. */
export async function registerPasskey(
ctx: APIRequestContext,
accessToken: string,
authenticator: SoftwareAuthenticator,
): Promise<void> {
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<void> {
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 === ownerAuthenticator.id,
);
return enrolled ? options : null;
};

let options = await begin();
if (!options) {
await registerPasskey(ctx, accessToken, ownerAuthenticator);
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: ownerAuthenticator.authenticate({
challenge: options.challenge,
origin: WEBAUTHN_ORIGIN,
rpId: options.rpId,
}),
},
});
expect(finish.ok(), `step-up finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy();
}
Loading