fix(auth): correct mfa errors, sudo methods and birth dates (#2498)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:27:30 +02:00
1 parent 150115cc0c
commit f392636857
12 files changed
+193 -32

No files matched your search

@@ -2,8 +2,10 @@
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {createTestAccount, createTotpSecret, generateTotpCode, type TestAccount} from './AuthTestUtils';
import {
createAuthenticationResponse,
@@ -27,6 +29,21 @@ interface LoginMfaResponse {
webauthn: boolean;
}
interface SudoMfaMethodsResponse {
totp: boolean;
webauthn: boolean;
has_mfa: boolean;
}
interface SudoModeRequiredResponse {
code: string;
has_mfa?: boolean;
methods?: {
totp?: boolean;
webauthn?: boolean;
};
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
@@ -291,6 +308,50 @@ describe('MFA Consistency Tests', () => {
.execute();
});
});
describe('Sudo MFA method availability', () => {
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an orphaned TOTP secret', async () => {
const account = await createTestAccount(harness);
const userRepository = new UserRepository();
const userId = createUserID(BigInt(account.userId));
const existing = await userRepository.findUnique(userId);
await userRepository.patchUpsert(userId, {totp_secret: createTotpSecret()}, existing!.toRow());
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});
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});
});
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an enrolled TOTP user', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
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});
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});
});
});
describe('MFA requirement propagates to sensitive operations', () => {
test('Account disable requires sudo for all users', async () => {
const account = await createTestAccount(harness);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
@@ -12,6 +13,15 @@ import {
type UserMeResponse,
} from './AuthTestUtils';
interface ValidationErrorResponse {
code: string;
errors: Array<{
path: string;
code: string;
message: string;
}>;
}
describe('Auth MFA endpoints', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -83,4 +93,30 @@ describe('Auth MFA endpoints', () => {
.expect(204)
.execute();
});
it('expired MFA ticket reports SESSION_TIMEOUT on ticket for both completion routes', async () => {
const totpError = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/login/mfa/totp')
.body({code: '000000', ticket: 'unknown'})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(totpError.errors[0]?.path).toBe('ticket');
expect(totpError.errors[0]?.code).toBe(ValidationErrorCodes.SESSION_TIMEOUT);
const webauthnError = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: {
id: 'unknown',
rawId: 'unknown',
type: 'public-key',
response: {clientDataJSON: '', authenticatorData: '', signature: ''},
clientExtensionResults: {},
},
challenge: 'unknown',
ticket: 'unknown',
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(webauthnError.errors[0]?.path).toBe('ticket');
expect(webauthnError.errors[0]?.code).toBe(ValidationErrorCodes.SESSION_TIMEOUT);
});
});
@@ -120,6 +120,56 @@ describe('Registration validation', () => {
.expect(400)
.execute();
});
it('rejects an impossible calendar date with INVALID_DATE_OF_BIRTH_FORMAT', async () => {
for (const dateOfBirth of ['2000-02-30', '2000-13-45']) {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('impossible-dob'),
username: createUniqueUsername('impossible'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: dateOfBirth,
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const dateOfBirthError = json.errors?.find((e) => e.path === 'date_of_birth');
expect(dateOfBirthError?.code).toBe('INVALID_DATE_OF_BIRTH_FORMAT');
}
});
it('rejects a real calendar date below the minimum age with MUST_BE_MINIMUM_AGE', async () => {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('underage'),
username: createUniqueUsername('underage'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2020-01-01',
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const dateOfBirthError = json.errors?.find((e) => e.path === 'date_of_birth');
expect(dateOfBirthError?.code).toBe('MUST_BE_MINIMUM_AGE');
});
it('accepts a real calendar date above the minimum age', async () => {
const reg = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('valid-dob'),
username: createUniqueUsername('validdob'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
expect(reg.token).toBeTruthy();
});
it('allows emoji in global name', async () => {
const globalName = '🌻 Sunflower';
const reg = await createBuilderWithoutAuth<{
@@ -5,7 +5,8 @@ import {describe, expect, it} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
import {User} from '../../models/User';
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
import {userHasMfa} from '../services/SudoMethods';
import {hasNoVerifiableCredential} from '../services/SudoVerificationService';
function createUser(overrides: Partial<UserRow> = {}): User {
return new User({