mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(auth): make passkey two-factor authentication opt-in (#2857)
This commit is contained in:
1 parent
3256af8d92
commit
ba7d8781cf
94 files changed
+7322
-2747
No files matched your search
@@ -22,20 +22,10 @@ export interface LoginMfaResponse {
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
type LoginResponse =
|
||||
| {
|
||||
user_id: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
};
|
||||
type LoginResponse = LoginSuccessResponse | LoginMfaResponse;
|
||||
|
||||
export interface UserMeResponse {
|
||||
id: string;
|
||||
|
||||
@@ -627,6 +627,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
@@ -634,13 +635,14 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(true);
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const passwordOnlyResp = await createBuilder<{
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
@@ -648,7 +650,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(passwordOnlyResp.has_mfa).toBe(true);
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
.body({
|
||||
@@ -712,6 +714,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -719,13 +722,14 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(true);
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const passwordOnlyResp = await createBuilder<{
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -733,7 +737,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(passwordOnlyResp.has_mfa).toBe(true);
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({
|
||||
@@ -776,6 +780,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -783,7 +788,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(false);
|
||||
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false, backup_codes: false});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({email_token: emailToken, password: account.password})
|
||||
@@ -887,6 +892,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -894,7 +900,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(discovery.has_mfa).toBe(true);
|
||||
expect(discovery.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(discovery.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const applied = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
@@ -37,6 +38,7 @@ interface LoginMfaResponse {
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ interface SudoModeRequiredResponse {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +76,7 @@ async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secr
|
||||
async function setupWebAuthnOnlyUser(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
twoFactorEnabled: boolean,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
device: WebAuthnDevice;
|
||||
@@ -124,6 +128,12 @@ async function setupWebAuthnOnlyUser(
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, updatedAccount.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[5]!.code,
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, updatedAccount.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -162,9 +172,9 @@ describe('MFA Consistency Tests', () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('WebAuthn sudo verification flow', () => {
|
||||
test('WebAuthn user can complete sudo verification with passkey', async () => {
|
||||
test('WebAuthn user with two-factor on can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
@@ -180,9 +190,27 @@ describe('MFA Consistency Tests', () => {
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn-only user cannot use password for sudo verification', async () => {
|
||||
test('WebAuthn user with two-factor off can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn-only user with two-factor on cannot use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, webauthnAccount.token)
|
||||
@@ -194,6 +222,17 @@ describe('MFA Consistency Tests', () => {
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('WebAuthn-only user with two-factor off can use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Password-only sudo flow for non-MFA users', () => {
|
||||
test('Non-MFA user can use password for sudo verification', async () => {
|
||||
@@ -323,14 +362,37 @@ describe('MFA Consistency Tests', () => {
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: false, has_mfa: false});
|
||||
expect(methods).toEqual({totp: false, webauthn: false, backup_codes: false, has_mfa: false});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
test('mfa-methods reports webauthn for a passkey user with two-factor off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, webauthnAccount.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an enrolled TOTP user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -347,14 +409,18 @@ describe('MFA Consistency Tests', () => {
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, loggedIn.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: true, webauthn: false, has_mfa: true});
|
||||
expect(methods).toEqual({totp: true, webauthn: false, backup_codes: true, has_mfa: true});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('MFA requirement propagates to sensitive operations', () => {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
seedMfaTicket,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
@@ -41,7 +41,7 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.execute();
|
||||
expect(login.code).toBe('INVALID_FORM_BODY');
|
||||
});
|
||||
it('rejects TOTP login when only WebAuthn is enabled', async () => {
|
||||
it('rejects TOTP login when passkey two-factor is on and no TOTP secret remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -53,25 +53,14 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -105,4 +94,39 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.execute();
|
||||
expect(bypassAttempt.code).toBe('INVALID_FORM_BODY');
|
||||
});
|
||||
it('issues a session token when passkey two-factor is off and no TOTP secret remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const totpData = await createBuilder<{
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
}>;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: totpData.backup_codes[0]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<{
|
||||
mfa?: true;
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(login.mfa).toBeUndefined();
|
||||
expect(login.token).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,25 @@ import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
findLastTestEmail,
|
||||
type LoginSuccessResponse,
|
||||
listTestEmails,
|
||||
type TestAccount,
|
||||
type TestEmailRecord,
|
||||
totpCodeNow,
|
||||
unclaimAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface MfaRequiredResponse {
|
||||
@@ -19,6 +31,7 @@ interface MfaRequiredResponse {
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
async function waitForEmail(harness: ApiTestHarness, type: string, recipient: string): Promise<TestEmailRecord> {
|
||||
@@ -34,6 +47,34 @@ async function waitForEmail(harness: ApiTestHarness, type: string, recipient: st
|
||||
throw new Error(`Email not found: type=${type}, recipient=${recipient}`);
|
||||
}
|
||||
|
||||
async function claimEmailWithoutPassword(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const start = await createBuilder<{ticket: string; original_proof?: string}>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const email = createUniqueEmail('passwordless-reset');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/email-change/request-new')
|
||||
.body({ticket: start.ticket, new_email: email, original_proof: start.original_proof})
|
||||
.execute();
|
||||
const newEmail = await waitForEmail(harness, 'email_change_new', email);
|
||||
const verify = await createBuilder<{email_token: string}>(harness, account.token)
|
||||
.post('/users/@me/email-change/verify-new')
|
||||
.body({ticket: start.ticket, code: newEmail.metadata['code'], original_proof: start.original_proof})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token).patch('/users/@me').body({email_token: verify.email_token}).execute();
|
||||
return {...account, email};
|
||||
}
|
||||
|
||||
async function requestPasswordReset(harness: ApiTestHarness, email: string): Promise<string> {
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email}).expect(204).execute();
|
||||
const mail = await waitForEmail(harness, 'password_reset', email);
|
||||
const token = mail.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
return token!;
|
||||
}
|
||||
describe('Auth reset password requires MFA', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -66,7 +107,8 @@ describe('Auth reset password requires MFA', () => {
|
||||
expect(resetResp.ticket).toBeDefined();
|
||||
expect(resetResp.totp).toBe(true);
|
||||
expect(resetResp.webauthn).toBe(false);
|
||||
expect(resetResp.allowed_methods).toEqual(['totp']);
|
||||
expect(resetResp.backup_codes).toBe(true);
|
||||
expect(resetResp.allowed_methods).toEqual(['totp', 'backup_codes']);
|
||||
const mfaResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
@@ -86,4 +128,112 @@ describe('Auth reset password requires MFA', () => {
|
||||
expect(login.totp).toBe(true);
|
||||
expect(login.webauthn).toBe(false);
|
||||
});
|
||||
it('returns a session after password reset for a passkey user who left two-factor off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
||||
const email = await waitForEmail(harness, 'password_reset', account.email);
|
||||
const token = email.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse | MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect('mfa' in resetResp).toBe(false);
|
||||
expect((resetResp as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
it('returns an MFA ticket after password reset for a passkey user who turned two-factor on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
||||
const email = await waitForEmail(harness, 'password_reset', account.email);
|
||||
const token = email.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect(resetResp.mfa).toBe(true);
|
||||
expect(resetResp.totp).toBe(false);
|
||||
expect(resetResp.webauthn).toBe(true);
|
||||
expect(resetResp.allowed_methods).toContain('webauthn');
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: resetResp.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
const mfaResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: resetResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(mfaResp.token).toBeDefined();
|
||||
});
|
||||
it('returns an MFA ticket after password reset for an account with no password that holds a passkey', async () => {
|
||||
const base = await createTestAccount(harness);
|
||||
const account = await claimEmailWithoutPassword(harness, base);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
|
||||
const token = await requestPasswordReset(harness, account.email);
|
||||
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect(resetResp.mfa).toBe(true);
|
||||
expect(resetResp.totp).toBe(false);
|
||||
expect(resetResp.webauthn).toBe(true);
|
||||
expect(resetResp.allowed_methods).toContain('webauthn');
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: resetResp.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
const mfaResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: resetResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(mfaResp.token).toBeTruthy();
|
||||
const me = await createBuilder<{id: string; authenticator_types: Array<number>}>(harness, mfaResp.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('keeps demanding the passkey on a second password reset for an account that started with no password', async () => {
|
||||
const base = await createTestAccount(harness);
|
||||
const account = await claimEmailWithoutPassword(harness, base);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
|
||||
const firstToken = await requestPasswordReset(harness, account.email);
|
||||
await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: firstToken, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
const secondToken = await requestPasswordReset(harness, account.email);
|
||||
const secondReset = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: secondToken, password: 'another-strong-password-456'})
|
||||
.execute();
|
||||
expect(secondReset.mfa).toBe(true);
|
||||
expect(secondReset.webauthn).toBe(true);
|
||||
expect(secondReset.allowed_methods).toContain('webauthn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, unclaimAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
describe('Sudo mode for passwordless accounts holding a passkey', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('still waves through a passwordless account that holds no credential at all', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
it('challenges a passwordless account that holds a passkey it never made a second factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
it('lets the passwordless passkey holder clear the challenge with an assertion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body(sudoBody)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {hasNoVerifiableCredential} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
|
||||
@@ -25,17 +25,17 @@ describe('sudo verification credential capability', () => {
|
||||
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(user.isUnclaimedAccount()).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets an unclaimed account satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('still requires a password from accounts that have one', () => {
|
||||
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an SSO account that enrolled a second factor', () => {
|
||||
@@ -45,11 +45,36 @@ describe('sudo verification credential capability', () => {
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasMfa(user)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('never applies to bots', () => {
|
||||
const user = createUser({password_hash: null, bot: true});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from a passwordless account holding a passkey it never made a second factor', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(userHasMfa(user)).toBe(false);
|
||||
expect(userHasSudoCapability(user, true)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an unclaimed account holding a passkey', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no sudo capability for an account with a TOTP secret that was never enrolled', () => {
|
||||
const user = createUser({totp_secret: 'JBSWY3DPEHPK3PXP'});
|
||||
expect(userHasSudoCapability(user, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports sudo capability from an enrolled TOTP secret without any passkey', () => {
|
||||
const user = createUser({
|
||||
totp_secret: 'JBSWY3DPEHPK3PXP',
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasSudoCapability(user, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
registerWebAuthnCredential,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn credential registration', () => {
|
||||
@@ -64,4 +66,23 @@ describe('WebAuthn credential registration', () => {
|
||||
expect(credentials[0].name).toBe('Test Passkey');
|
||||
expect(credentials[0].id).toBe(device.credentialId.toString('base64url'));
|
||||
});
|
||||
it('does not turn passkeys into a second factor when a credential is registered', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const me = await createBuilder<{
|
||||
authenticator_types: Array<number>;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.TOTP]);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, createTotpSecret, generateTotpCode} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
loginWithDiscoverablePasskey,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface BackupCodesResponse {
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LoginMfaResponse {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptions {
|
||||
challenge: string;
|
||||
rp: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
authenticatorSelection?: {
|
||||
residentKey?: string;
|
||||
requireResidentKey?: boolean;
|
||||
userVerification?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptions {
|
||||
challenge: string;
|
||||
rpId: string;
|
||||
allowCredentials?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
}>;
|
||||
userVerification: string;
|
||||
async function setupPasskeyOnlyAccount(
|
||||
harness: ApiTestHarness,
|
||||
twoFactorEnabled: boolean,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
device: WebAuthnDevice;
|
||||
}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const token = await loginWithDiscoverablePasskey(harness, device);
|
||||
return {account: {...account, token}, device};
|
||||
}
|
||||
|
||||
describe('WebAuthn MFA Consistency Tests', () => {
|
||||
@@ -56,84 +67,8 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
test('WebAuthn-only user cannot use password for sudo - password rejected with 403', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
.execute();
|
||||
expect(registrationOptions.authenticatorSelection).toMatchObject({
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
|
||||
const passkeyLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: discoverableAssertion,
|
||||
challenge: discoverableOptions.challenge,
|
||||
})
|
||||
.execute();
|
||||
account.token = passkeyLogin.token;
|
||||
test('passkey user with two-factor on cannot use password for sudo - password rejected with 403', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const {json: errorResp} = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
@@ -145,18 +80,34 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
.executeWithResponse();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('WebAuthn-only user can use WebAuthn for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
test('passkey user with two-factor off can still use password for sudo', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, false);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('passkey user with two-factor on can use WebAuthn for sudo verification', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const {response: disableResp} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.executeWithResponse();
|
||||
const sudoToken = disableResp.headers.get('x-sudo-mode-token');
|
||||
expect(sudoToken).toBeNull();
|
||||
});
|
||||
test('passkey user with two-factor off can use WebAuthn for sudo verification', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token).post('/users/@me/disable').body(sudoBody).expect(204).execute();
|
||||
});
|
||||
test('passkey user with two-factor on requires MFA when logging in with password', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
@@ -165,144 +116,35 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
expect(login.ticket).toBeTruthy();
|
||||
expect(login.webauthn).toBe(true);
|
||||
});
|
||||
test('passkey user with two-factor off logs in with password and receives a session token', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const login = await createBuilderWithoutAuth<LoginSuccessResponse | LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
const userInfo = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, (login as LoginSuccessResponse).token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
|
||||
const passkeyLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: discoverableAssertion,
|
||||
challenge: discoverableOptions.challenge,
|
||||
})
|
||||
.execute();
|
||||
account.token = passkeyLogin.token;
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
test('sudo WebAuthn options stay available to a passkey user with two-factor off', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(sudoOptions.userVerification).toBe('discouraged');
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
const {response: disableResp2} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.executeWithResponse();
|
||||
const sudoToken = disableResp2.headers.get('x-sudo-mode-token');
|
||||
expect(sudoToken).toBeNull();
|
||||
});
|
||||
test('WebAuthn-only user requires MFA when logging in with password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
.execute();
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const login2 = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login2.mfa).toBe(true);
|
||||
expect(login2.ticket).toBeTruthy();
|
||||
expect(login2.webauthn).toBe(true);
|
||||
expect(sudoOptions.allowCredentials?.length).toBeGreaterThan(0);
|
||||
expect(device.credentialId.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -4,16 +4,17 @@ import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
@@ -30,7 +31,7 @@ describe('WebAuthn MFA login', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates the WebAuthn MFA login flow', async () => {
|
||||
it('validates the WebAuthn MFA login flow when passkey two-factor is turned on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -38,25 +39,17 @@ describe('WebAuthn MFA login', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'MFA Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'MFA Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'MFA Passkey',
|
||||
);
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in loginResp && loginResp.mfa).toBe(true);
|
||||
const loginMfaResp = loginResp as LoginMfaResponse;
|
||||
@@ -83,7 +76,7 @@ describe('WebAuthn MFA login', () => {
|
||||
.body({
|
||||
response: mfaAssertion,
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: (loginResp as LoginMfaResponse).ticket,
|
||||
ticket: loginMfaResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(webauthnMfaLogin.token).toBeTruthy();
|
||||
@@ -94,4 +87,39 @@ describe('WebAuthn MFA login', () => {
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'MFA Passkey',
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in loginResp).toBe(false);
|
||||
const loginSuccessResp = loginResp as LoginSuccessResponse;
|
||||
expect(loginSuccessResp.token).toBeTruthy();
|
||||
const userInfo = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, loginSuccessResp.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
loginWithDiscoverablePasskey,
|
||||
registerWebAuthnCredential,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn opt-in login', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('does not offer webauthn at login to a TOTP user whose passkey is opted out', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
expect(login.mfa).toBe(true);
|
||||
expect(login.totp).toBe(true);
|
||||
expect(login.webauthn).toBe(false);
|
||||
expect(login.allowed_methods).not.toContain('webauthn');
|
||||
expect(login.allowed_methods).toContain('totp');
|
||||
});
|
||||
it('rejects the WebAuthn MFA login route for a user who never turned passkey two-factor on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: login.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
const totpLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({ticket: login.ticket, code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
expect(totpLogin.token).toBeTruthy();
|
||||
});
|
||||
it('completes the passwordless journey for an account that never enrolled TOTP or the toggle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const me = await createBuilder<{
|
||||
authenticator_types: Array<number>;
|
||||
mfa_enabled: boolean;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
const passwordLogin = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in passwordLogin).toBe(false);
|
||||
expect((passwordLogin as LoginSuccessResponse).token).toBeTruthy();
|
||||
const passkeyToken = await loginWithDiscoverablePasskey(harness, device);
|
||||
expect(passkeyToken).toBeTruthy();
|
||||
const passkeyMe = await createBuilder<{
|
||||
id: string;
|
||||
authenticator_types: Array<number>;
|
||||
}>(harness, passkeyToken)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(passkeyMe.id).toBe(account.userId);
|
||||
expect(passkeyMe.authenticator_types).not.toContain(UserAuthenticatorTypes.WEBAUTHN);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
type SudoVerificationBody,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BackupCode {
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
async function readBackupCodes(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
sudo: SudoVerificationBody,
|
||||
): Promise<Array<BackupCode>> {
|
||||
const response = await createBuilder<{
|
||||
backup_codes: Array<BackupCode>;
|
||||
}>(harness, token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({regenerate: false, ...sudo})
|
||||
.execute();
|
||||
return response.backup_codes;
|
||||
}
|
||||
|
||||
describe('WebAuthn two-factor backup codes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('mints backup codes when passkey two-factor is turned on for an account with no TOTP', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.backup_codes).not.toBeNull();
|
||||
expect(enabled.backup_codes!.length).toBeGreaterThan(0);
|
||||
expect(enabled.backup_codes!.every((backupCode) => !backupCode.consumed)).toBe(true);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const stored = await readBackupCodes(harness, account.token, sudoBody);
|
||||
expect(stored.map((backupCode) => backupCode.code).sort()).toEqual(
|
||||
enabled.backup_codes!.map((backupCode) => backupCode.code).sort(),
|
||||
);
|
||||
});
|
||||
it('mints nothing when the account already holds backup codes from TOTP', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
expect(enabled.backup_codes).toBeNull();
|
||||
});
|
||||
it('accepts a minted backup code at login when the passkey is unavailable', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
expect(login.mfa).toBe(true);
|
||||
expect(login.totp).toBe(false);
|
||||
expect(login.webauthn).toBe(true);
|
||||
expect(login.backup_codes).toBe(true);
|
||||
expect(login.allowed_methods).toContain('backup_codes');
|
||||
const backupLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({ticket: login.ticket, code: enabled.backup_codes![0]!.code})
|
||||
.execute();
|
||||
expect(backupLogin.token).toBeTruthy();
|
||||
const me = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, backupLogin.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
});
|
||||
it('keeps backup codes when TOTP is disabled while passkey two-factor stays on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const stored = await readBackupCodes(harness, account.token, sudoBody);
|
||||
expect(stored.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('keeps backup codes when passkey two-factor is turned off while TOTP stays on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
const stored = await readBackupCodes(harness, account.token, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
expect(stored.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('clears backup codes only once no second factor remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const stored = await readBackupCodes(harness, account.token, {password: account.password});
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
it('clears backup codes when the last passkey is deleted and nothing else remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const stored = await readBackupCodes(harness, account.token, {password: account.password});
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginUser, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
id: string;
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
interface SudoModeRequiredResponse {
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string}>;
|
||||
}
|
||||
|
||||
interface BackupCode {
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
async function withTotpReplayProtection<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.dev.testModeEnabled = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPasskeyOnlyTwoFactorAccount(
|
||||
harness: ApiTestHarness,
|
||||
): Promise<{account: TestAccount; device: WebAuthnDevice; backupCodes: Array<string>}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
expect(enabled.backup_codes).not.toBeNull();
|
||||
return {account, device, backupCodes: enabled.backup_codes!.map((backupCode) => backupCode.code)};
|
||||
}
|
||||
|
||||
async function fetchMe(harness: ApiTestHarness, token: string): Promise<PrivateUserResponse> {
|
||||
return createBuilder<PrivateUserResponse>(harness, token).get('/users/@me').execute();
|
||||
}
|
||||
|
||||
describe('Sudo mode recovery for passkey two-factor accounts without TOTP', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('turns passkey two-factor off with a backup code when the passkey cannot be used', async () => {
|
||||
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
await withTotpReplayProtection(async () => {
|
||||
const disabled = await createBuilder<{user: PrivateUserResponse}>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: backupCodes[0]!})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
expect(disabled.user.mfa_enabled).toBe(false);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
const login = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in login).toBe(false);
|
||||
});
|
||||
it('advertises the backup code option in the sudo methods endpoint and the sudo mode challenge alike', async () => {
|
||||
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: true, has_mfa: true});
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(methods.has_mfa);
|
||||
expect(challenge.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
it('spends a backup code accepted as a sudo proof and refuses the same code afterwards', async () => {
|
||||
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
const spent = backupCodes[0]!;
|
||||
await withTotpReplayProtection(async () => {
|
||||
const stored = await createBuilder<{backup_codes: Array<BackupCode>}>(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({regenerate: false, mfa_method: 'totp', mfa_code: spent})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stored.backup_codes.find((backupCode) => backupCode.code === spent)?.consumed).toBe(true);
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: spent})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('rejects a backup code that was never minted and leaves passkey two-factor on', async () => {
|
||||
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
await withTotpReplayProtection(async () => {
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('rejects a code-entry sudo proof for a passkey account that holds neither TOTP nor backup codes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
await withTotpReplayProtection(async () => {
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnTwoFactorResult,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
id: string;
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
describe('WebAuthn two-factor toggle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('reports an empty authenticator types array for an account with no second factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
it('rejects enabling passkey two-factor when the account has no registered credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: true, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PASSKEYS_REGISTERED')
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('round trips enabling and disabling passkey two-factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
expect(enabled.user.mfa_enabled).toBe(true);
|
||||
const afterEnable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(afterEnable.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, sudoBody);
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
expect(disabled.user.mfa_enabled).toBe(false);
|
||||
const afterDisable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(afterDisable.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('refuses to disable passkey two-factor without a sudo proof', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('accepts a passkey assertion as the sudo proof for disabling passkey two-factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const disabled = await createBuilder<WebAuthnTwoFactorResult>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, ...sudoBody})
|
||||
.execute();
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('leaves the account untouched when the requested state already matches', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const firstEnable = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(firstEnable.backup_codes).not.toBeNull();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const secondEnable = await setWebAuthnTwoFactor(harness, account.token, true, sudoBody);
|
||||
expect(secondEnable.backup_codes).toBeNull();
|
||||
expect(secondEnable.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('drops the passkey second factor when the last credential is deleted', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user