feat(auth): make passkey two-factor authentication opt-in (#2857)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-20 05:06:50 +02:00
1 parent 3256af8d92
commit ba7d8781cf
94 files changed
+7322 -2747

No files matched your search

@@ -9,6 +9,8 @@ import {
generateKeyPairSync,
randomBytes,
} from 'node:crypto';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
export interface WebAuthnDevice {
@@ -54,6 +56,22 @@ export interface WebAuthnCredentialMetadata {
name: string;
}
export type SudoVerificationBody = Record<string, unknown>;
export type SudoVerificationBodyFactory = () => SudoVerificationBody;
export interface WebAuthnTwoFactorResult {
user: {
id: string;
mfa_enabled: boolean;
authenticator_types: Array<number>;
};
backup_codes: Array<{
code: string;
consumed: boolean;
}> | null;
}
interface AuthenticatorAttestationResponse {
clientDataJSON: string;
attestationObject: string;
@@ -415,3 +433,80 @@ export function createAuthenticationResponseWithoutUV(
},
};
}
export async function registerWebAuthnCredential(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
createSudoBody: SudoVerificationBodyFactory,
name = 'Test Passkey',
): Promise<void> {
const options = await createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body(createSudoBody())
.execute();
if (options.rp.id) {
device.rpId = options.rp.id;
}
await createBuilder(harness, token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: createRegistrationResponse(device, options, name),
challenge: options.challenge,
name,
...createSudoBody(),
})
.expect(204)
.execute();
}
export async function createSudoWebAuthnBody(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
): Promise<SudoVerificationBody> {
const options = await createBuilder<WebAuthnAuthenticationOptions>(harness, token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
if (options.rpId) {
device.rpId = options.rpId;
}
return {
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(device, options),
webauthn_challenge: options.challenge,
};
}
export async function setWebAuthnTwoFactor(
harness: ApiTestHarness,
token: string,
enabled: boolean,
sudo: SudoVerificationBody,
): Promise<WebAuthnTwoFactorResult> {
return createBuilder<WebAuthnTwoFactorResult>(harness, token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled, ...sudo})
.execute();
}
export async function loginWithDiscoverablePasskey(harness: ApiTestHarness, device: WebAuthnDevice): Promise<string> {
const options = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
if (options.rpId) {
device.rpId = options.rpId;
}
const login = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: createAuthenticationResponse(device, options),
challenge: options.challenge,
})
.execute();
return login.token;
}