From 6c682022220a1ab0a7731ff5a1502492fe516699 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 16 Aug 2026 23:35:47 +0200 Subject: [PATCH] fix(api): let SSO accounts without a password satisfy sudo verification (#1690) --- .../auth/services/SudoVerificationService.ts | 13 ++++- .../tests/SudoVerificationCredentials.test.ts | 54 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 fluxer_api/src/api/auth/tests/SudoVerificationCredentials.test.ts diff --git a/fluxer_api/src/api/auth/services/SudoVerificationService.ts b/fluxer_api/src/api/auth/services/SudoVerificationService.ts index 3d7dc1a0d..28cb79aac 100644 --- a/fluxer_api/src/api/auth/services/SudoVerificationService.ts +++ b/fluxer_api/src/api/auth/services/SudoVerificationService.ts @@ -31,6 +31,16 @@ export function userHasMfa(user: {authenticatorTypes?: Set | null}): boo ); } +export function hasNoVerifiableCredential( + user: {passwordHash: string | null; isBot: boolean}, + hasMfa: boolean, +): boolean { + if (user.isBot || hasMfa) { + return false; + } + return user.passwordHash === null; +} + export function deriveSudoMethods(user: { totpSecret?: string | null; authenticatorTypes?: Set | null; @@ -86,8 +96,7 @@ async function verifySudoMode( const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined; return {verified: true, sudoToken, method: 'mfa'}; } - const isUnclaimedAccount = user.isUnclaimedAccount(); - if (isUnclaimedAccount && !hasMfa) { + if (hasNoVerifiableCredential(user, hasMfa)) { return {verified: true, method: 'password'}; } if (body.password && !hasMfa) { diff --git a/fluxer_api/src/api/auth/tests/SudoVerificationCredentials.test.ts b/fluxer_api/src/api/auth/tests/SudoVerificationCredentials.test.ts new file mode 100644 index 000000000..f6473923f --- /dev/null +++ b/fluxer_api/src/api/auth/tests/SudoVerificationCredentials.test.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants'; +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'; + +function createUser(overrides: Partial = {}): User { + return new User({ + ...EMPTY_USER_ROW, + user_id: createUserID(1n), + username: 'test_user', + discriminator: 1, + bot: false, + password_hash: 'hash', + traits: new Set(), + ...overrides, + }); +} + +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(['sso'])}); + expect(user.isUnclaimedAccount()).toBe(false); + expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true); + }); + + it('still lets an unclaimed account satisfy sudo mode', () => { + const user = createUser({password_hash: null}); + expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true); + }); + + it('still requires a password from accounts that have one', () => { + const user = createUser({password_hash: 'hash', traits: new Set(['sso'])}); + expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false); + }); + + it('still requires MFA from an SSO account that enrolled a second factor', () => { + const user = createUser({ + password_hash: null, + traits: new Set(['sso']), + authenticator_types: new Set([UserAuthenticatorTypes.TOTP]), + }); + expect(userHasMfa(user)).toBe(true); + expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false); + }); + + it('never applies to bots', () => { + const user = createUser({password_hash: null, bot: true}); + expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false); + }); +});