test(core): Add real-crypto integration tests for WebAuthn registration

Drives `POST /mfa/webauthn/registration-verify` end-to-end through the real
`@simplewebauthn/server` verifier with a small Node-side helper that builds
valid `RegistrationResponseJSON` objects from a freshly generated ECDSA P-256
keypair. Hand-rolled CBOR for the COSE_Key + `fmt:'none'` attestation; no
new npm deps.

Six cases: platform→passkey, cross-platform→security_key, idempotent recovery
codes on a second registration, rpIdHash mismatch (canary that proves the
crypto path runs), missing UP flag, and stale-challenge replay.

First of several chunks — authentication assertion, passwordless login, and
kind-filtered options land in follow-up commits.
This commit is contained in:
Ricardo Espinoza 2026-05-13 17:16:17 -04:00
parent 65a9397e0a
commit 8d340c8eb0
No known key found for this signature in database
2 changed files with 461 additions and 0 deletions

View File

@ -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: <x>, -3: <y>}
* 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:<bytes>}` */
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');
}

View File

@ -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<string> {
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);
});
});