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
14 changes: 14 additions & 0 deletions .changeset/verify-oauth-cutover.md
Original file line number Diff line number Diff line change
@@ -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).
108 changes: 108 additions & 0 deletions verify/harness/api/oauthCutover.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
132 changes: 132 additions & 0 deletions verify/harness/lib/cutoverFlows.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<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();
}
102 changes: 102 additions & 0 deletions verify/harness/lib/softwareAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -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<Cbor, Cbor>;

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<Cbor, Cbor>([
['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<Cbor, Cbor>([
[1, 2], // kty: EC2
[3, -7], // alg: ES256
[-1, 1], // crv: P-256
[-2, Buffer.from(x, 'base64url')],
[-3, Buffer.from(y, 'base64url')],
]),
);
}
}
Loading
Loading