diff --git a/packages/cli/test/integration/mfa/webauthn-test-helper.ts b/packages/cli/test/integration/mfa/webauthn-test-helper.ts new file mode 100644 index 00000000000..87fd201ea3b --- /dev/null +++ b/packages/cli/test/integration/mfa/webauthn-test-helper.ts @@ -0,0 +1,188 @@ +/** + * Node-side helper for crafting valid WebAuthn responses in integration + * tests, exercising the real `@simplewebauthn/server` verifier rather than + * mocking it. Uses only built-in `crypto` + a tiny hand-rolled CBOR encoder + * for the COSE_Key — no new npm deps. + * + * Chunk 1 ships only `buildRegistrationResponse`. `buildAuthenticationResponse` + * lands with the authentication-ceremony chunk. + * + * Spec references: WebAuthn L3 §6.1 (authenticator data layout), §6.5 + * (attested credential data), and RFC 8152 (COSE_Key for ES256). + */ + +import { createHash, generateKeyPairSync, randomBytes, type KeyObject } from 'crypto'; + +export type WebAuthnAuthenticator = { + credentialId: Uint8Array; + privateKey: KeyObject; + publicKey: KeyObject; + /** CBOR-encoded COSE_Key for ES256 (P-256). */ + cosePublicKey: Buffer; + /** All-zero for self-attested credentials with `fmt: 'none'`. */ + aaguid: Uint8Array; +}; + +export function generateAuthenticator(): WebAuthnAuthenticator { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + + const jwk = publicKey.export({ format: 'jwk' }); + if (!jwk.x || !jwk.y) { + throw new Error('Generated EC key is missing x/y components'); + } + const x = Buffer.from(jwk.x, 'base64url'); + const y = Buffer.from(jwk.y, 'base64url'); + if (x.length !== 32 || y.length !== 32) { + throw new Error(`Unexpected P-256 coordinate length: x=${x.length} y=${y.length}`); + } + + const cosePublicKey = encodeCosePublicKey(x, y); + + // 16-byte credentialId — same size real authenticators use; arbitrary + // content, just needs to be unique per authenticator. + const credentialId = new Uint8Array(randomBytes(16)); + const aaguid = new Uint8Array(16); + + return { credentialId, privateKey, publicKey, cosePublicKey, aaguid }; +} + +export type BuildRegistrationOptions = { + authenticator: WebAuthnAuthenticator; + /** Base64url, exactly as returned by `/mfa/webauthn/registration-options`. */ + challenge: string; + origin: string; + rpId: string; + attachment: 'platform' | 'cross-platform'; + /** + * Overrides for the authenticator data flag byte, used by negative tests. + * `up` defaults to true; `uv` defaults to true for platform. + */ + flagOverrides?: { up?: boolean; uv?: boolean }; + /** Override the rpIdHash before signing — used by the "mismatched RP" test. */ + rpIdHashOverride?: string; +}; + +/** + * Build a `RegistrationResponseJSON` with `fmt: 'none'` attestation — + * `@simplewebauthn/server` parses the binary layout, verifies the + * rpIdHash + challenge round-trip + flag bits, and extracts the public key. + * No signature verification is performed for `fmt: 'none'`, so we don't + * need to sign the attestation statement. + */ +export function buildRegistrationResponse(opts: BuildRegistrationOptions) { + const { authenticator, challenge, origin, rpId, attachment } = opts; + + const clientData = JSON.stringify({ + type: 'webauthn.create', + challenge, + origin, + crossOrigin: false, + }); + const clientDataJSON = Buffer.from(clientData, 'utf-8'); + + const rpIdHash = createHash('sha256') + .update(opts.rpIdHashOverride ?? rpId) + .digest(); + + const up = opts.flagOverrides?.up ?? true; + // Platform authenticators (passkeys) require user verification; roaming + // security keys may register without UV (no FIDO2 PIN set). Mirrors the + // service's `requireUserVerification` decision. + const uv = opts.flagOverrides?.uv ?? attachment === 'platform'; + const at = true; // attested credential data is always present on registration + const flags = (up ? 0x01 : 0) | (uv ? 0x04 : 0) | (at ? 0x40 : 0); + + const counter = Buffer.alloc(4); // 0 + + const credIdLen = Buffer.alloc(2); + credIdLen.writeUInt16BE(authenticator.credentialId.length, 0); + const attestedCredentialData = Buffer.concat([ + Buffer.from(authenticator.aaguid), + credIdLen, + Buffer.from(authenticator.credentialId), + authenticator.cosePublicKey, + ]); + + const authData = Buffer.concat([rpIdHash, Buffer.from([flags]), counter, attestedCredentialData]); + + const attestationObject = encodeAttestationObjectNone(authData); + + const transports = attachment === 'platform' ? ['internal'] : ['usb']; + + return { + id: toBase64Url(authenticator.credentialId), + rawId: toBase64Url(authenticator.credentialId), + response: { + clientDataJSON: toBase64Url(clientDataJSON), + attestationObject: toBase64Url(attestationObject), + transports, + }, + authenticatorAttachment: attachment, + clientExtensionResults: {}, + type: 'public-key' as const, + }; +} + +// --- CBOR encoders (minimal, only what we need) --- + +/** + * Encode the public key as a COSE_Key for ES256 (RFC 8152 §13.1): + * {1: 2, 3: -7, -1: 1, -2: , -3: } + * That's a 5-entry CBOR map with mixed positive/negative integer keys. + */ +function encodeCosePublicKey(x: Buffer, y: Buffer): Buffer { + return Buffer.concat([ + Buffer.from([0xa5]), // map(5) + cborInt(1), + cborInt(2), // kty: EC2 + cborInt(3), + cborInt(-7), // alg: ES256 + cborInt(-1), + cborInt(1), // crv: P-256 + cborInt(-2), + cborBytes(x), + cborInt(-3), + cborBytes(y), + ]); +} + +/** `{fmt:'none', attStmt:{}, authData:}` */ +function encodeAttestationObjectNone(authData: Buffer): Buffer { + return Buffer.concat([ + Buffer.from([0xa3]), // map(3) + cborText('fmt'), + cborText('none'), + cborText('attStmt'), + Buffer.from([0xa0]), // map(0) — empty attStmt + cborText('authData'), + cborBytes(authData), + ]); +} + +function cborInt(n: number): Buffer { + if (n >= 0 && n <= 23) return Buffer.from([n]); + if (n < 0 && n >= -24) return Buffer.from([0x20 + (-1 - n)]); + if (n >= 0 && n <= 0xff) return Buffer.from([0x18, n]); + throw new Error(`cborInt: ${n} not supported (kept tiny on purpose)`); +} + +function cborBytes(buf: Buffer): Buffer { + if (buf.length <= 23) return Buffer.concat([Buffer.from([0x40 + buf.length]), buf]); + if (buf.length <= 0xff) return Buffer.concat([Buffer.from([0x58, buf.length]), buf]); + if (buf.length <= 0xffff) { + const len = Buffer.alloc(2); + len.writeUInt16BE(buf.length, 0); + return Buffer.concat([Buffer.from([0x59]), len, buf]); + } + throw new Error(`cborBytes: ${buf.length} too large`); +} + +function cborText(s: string): Buffer { + const buf = Buffer.from(s, 'utf-8'); + if (buf.length <= 23) return Buffer.concat([Buffer.from([0x60 + buf.length]), buf]); + throw new Error(`cborText: ${s} too long`); +} + +function toBase64Url(buf: Buffer | Uint8Array): string { + return Buffer.from(buf).toString('base64url'); +} diff --git a/packages/cli/test/integration/mfa/webauthn.api.test.ts b/packages/cli/test/integration/mfa/webauthn.api.test.ts new file mode 100644 index 00000000000..81483f8d39f --- /dev/null +++ b/packages/cli/test/integration/mfa/webauthn.api.test.ts @@ -0,0 +1,273 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import { UserRepository, WebauthnCredentialRepository, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { AUTH_COOKIE_NAME } from '@/constants'; + +import { buildRegistrationResponse, generateAuthenticator } from './webauthn-test-helper'; +import { createOwner } from '../shared/db/users'; +import * as utils from '../shared/utils'; + +jest.mock('@/telemetry'); + +const testServer = utils.setupTestServer({ endpointGroups: ['mfa', 'auth'] }); + +let owner: User; +let credentialRepo: WebauthnCredentialRepository; +let userRepo: UserRepository; + +// `WebAuthnService` derives `rpId` and `origin` from `UrlService` which reads +// `globalConfig.host`, `port`, `protocol`. The test server doesn't override +// these, so the defaults (`localhost`, `5678`, `http`) apply. +const RP_ID = 'localhost'; +const ORIGIN = 'http://localhost:5678'; + +beforeAll(() => { + credentialRepo = Container.get(WebauthnCredentialRepository); + userRepo = Container.get(UserRepository); + // Make sure no rpId/origin override is in effect from a prior test file. + const cfg = Container.get(GlobalConfig); + cfg.mfa.webauthn.rpId = ''; + cfg.mfa.webauthn.origin = ''; +}); + +beforeEach(async () => { + // Challenge cache entries are keyed by userId; truncating users gets us a + // fresh user per test, so cached challenges from prior tests can't collide. + await testDb.truncate(['WebauthnCredential', 'User']); + owner = await createOwner(); + owner = await userRepo.findOneOrFail({ where: { id: owner.id }, relations: ['role'] }); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +async function fetchRegistrationChallenge( + user: User, + attachment: 'platform' | 'cross-platform', +): Promise { + const response = await testServer + .authAgentFor(user) + .get(`/mfa/webauthn/registration-options?attachment=${attachment}`) + .expect(200); + return response.body.data.challenge as string; +} + +describe('POST /mfa/webauthn/registration-verify (real crypto)', () => { + test('registers a passkey for platform attachment and rotates the session', async () => { + const authenticator = generateAuthenticator(); + const challenge = await fetchRegistrationChallenge(owner, 'platform'); + + const response = buildRegistrationResponse({ + authenticator, + challenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + }); + + const httpRes = await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ label: 'My Mac', response, attachment: 'platform' }) + .expect(200); + + expect(httpRes.body.data).toMatchObject({ + label: 'My Mac', + method: 'passkey', + }); + expect(typeof httpRes.body.data.id).toBe('string'); + expect(typeof httpRes.body.data.credentialId).toBe('string'); + // First credential → recovery codes returned to the user. + expect(Array.isArray(httpRes.body.data.recoveryCodes)).toBe(true); + expect(httpRes.body.data.recoveryCodes.length).toBeGreaterThan(0); + + // Persisted as we expect. + const credentials = await credentialRepo.find({ where: { userId: owner.id } }); + expect(credentials).toHaveLength(1); + expect(credentials[0]).toMatchObject({ + label: 'My Mac', + counter: 0, + transports: ['internal'], + }); + expect(credentials[0].publicKey).toBeInstanceOf(Buffer); + expect(credentials[0].publicKey.length).toBeGreaterThan(0); + + // MFA enabled on the user. + const updatedUser = await userRepo.findOneByOrFail({ id: owner.id }); + expect(updatedUser.mfaEnabled).toBe(true); + + // Session rotated → fresh auth cookie + bumped tokensValidAfter. + const setCookie = httpRes.headers['set-cookie']; + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + expect(cookies.some((c) => typeof c === 'string' && c.startsWith(`${AUTH_COOKIE_NAME}=`))).toBe( + true, + ); + expect(updatedUser.tokensValidAfter).toBeInstanceOf(Date); + }); + + test('does not regenerate recovery codes when the user already has them', async () => { + // First registration generates recovery codes. + const firstAuth = generateAuthenticator(); + const firstChallenge = await fetchRegistrationChallenge(owner, 'platform'); + await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ + label: 'First', + response: buildRegistrationResponse({ + authenticator: firstAuth, + challenge: firstChallenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + }), + attachment: 'platform', + }) + .expect(200); + + // Second registration should NOT return recovery codes. + const secondAuth = generateAuthenticator(); + const secondChallenge = await fetchRegistrationChallenge(owner, 'platform'); + const secondRes = await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ + label: 'Second', + response: buildRegistrationResponse({ + authenticator: secondAuth, + challenge: secondChallenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + }), + attachment: 'platform', + }) + .expect(200); + + expect(secondRes.body.data.recoveryCodes).toBeUndefined(); + + const credentials = await credentialRepo.find({ where: { userId: owner.id } }); + expect(credentials).toHaveLength(2); + }); + + test('registers a security key for cross-platform attachment', async () => { + const authenticator = generateAuthenticator(); + const challenge = await fetchRegistrationChallenge(owner, 'cross-platform'); + + const httpRes = await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ + label: 'YubiKey', + response: buildRegistrationResponse({ + authenticator, + challenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'cross-platform', + }), + attachment: 'cross-platform', + }) + .expect(200); + + expect(httpRes.body.data.method).toBe('security_key'); + + const credentials = await credentialRepo.find({ where: { userId: owner.id } }); + expect(credentials).toHaveLength(1); + expect(credentials[0].transports).toEqual(['usb']); + }); + + // The two negative-path tests below assert 500 rather than 400 because + // `@simplewebauthn/server` throws a plain `Error` (not a `BadRequestError`) + // on adversarial input and the controller currently surfaces it as a 500. + // Mapping those throws to 400 is a separate code-quality fix — these tests + // pin current behaviour so the regression is visible if either side changes. + test('rejects a response whose rpIdHash does not match the configured rpId', async () => { + const authenticator = generateAuthenticator(); + const challenge = await fetchRegistrationChallenge(owner, 'platform'); + + const tampered = buildRegistrationResponse({ + authenticator, + challenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + // authData hashes `evil.example` rather than `localhost`, so the + // library rejects it as an RP mismatch. Real-crypto canary: proves + // we're exercising the full binary parser, not silently passing. + rpIdHashOverride: 'evil.example', + }); + + await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ label: 'spoofed', response: tampered, attachment: 'platform' }) + .expect(500); + + expect(await credentialRepo.count({ where: { userId: owner.id } })).toBe(0); + const user = await userRepo.findOneByOrFail({ id: owner.id }); + expect(user.mfaEnabled).toBe(false); + }); + + test('rejects a response without the user-presence flag set', async () => { + const authenticator = generateAuthenticator(); + const challenge = await fetchRegistrationChallenge(owner, 'platform'); + + const noUp = buildRegistrationResponse({ + authenticator, + challenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + flagOverrides: { up: false }, + }); + + await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ label: 'no-up', response: noUp, attachment: 'platform' }) + .expect(500); + + expect(await credentialRepo.count({ where: { userId: owner.id } })).toBe(0); + }); + + test('rejects a registration whose challenge is no longer in the cache', async () => { + const authenticator = generateAuthenticator(); + const challenge = await fetchRegistrationChallenge(owner, 'platform'); + + const response = buildRegistrationResponse({ + authenticator, + challenge, + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + }); + + // First verify consumes the cached challenge. + await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ label: 'first', response, attachment: 'platform' }) + .expect(200); + + // Replaying the same response without a fresh `/registration-options` call + // must fail — the cache entry has been deleted by the first verify. + const second = buildRegistrationResponse({ + authenticator: generateAuthenticator(), + challenge, // same stale challenge + origin: ORIGIN, + rpId: RP_ID, + attachment: 'platform', + }); + await testServer + .authAgentFor(owner) + .post('/mfa/webauthn/registration-verify') + .send({ label: 'replay', response: second, attachment: 'platform' }) + .expect(500); + + expect(await credentialRepo.count({ where: { userId: owner.id } })).toBe(1); + }); +});