mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
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:
parent
65a9397e0a
commit
8d340c8eb0
188
packages/cli/test/integration/mfa/webauthn-test-helper.ts
Normal file
188
packages/cli/test/integration/mfa/webauthn-test-helper.ts
Normal 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');
|
||||
}
|
||||
273
packages/cli/test/integration/mfa/webauthn.api.test.ts
Normal file
273
packages/cli/test/integration/mfa/webauthn.api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user