diff --git a/src/lib/utils/auth-methods.test.ts b/src/lib/utils/auth-methods.test.ts new file mode 100644 index 0000000..007ff57 --- /dev/null +++ b/src/lib/utils/auth-methods.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { canResetPassword, passwordResetBlockedReason } from './auth-methods.js'; + +const NON_PASSWORD_PROVIDERS = ['none', 'google', 'github', 'oidc']; + +describe('canResetPassword', () => { + it('allows a reset for a password account', () => { + expect(canResetPassword({ authProvider: 'password' })).toBe(true); + }); + + it('blocks a reset for every non-password provider', () => { + // verifyPassword() filters on authProvider === 'password', so a hash + // written for any other provider could never be used to sign in. + for (const authProvider of NON_PASSWORD_PROVIDERS) { + expect(canResetPassword({ authProvider }), authProvider).toBe(false); + } + }); + + it('blocks a reset for an unenumerated provider', () => { + expect(canResetPassword({ authProvider: 'saml' })).toBe(false); + expect(canResetPassword({ authProvider: '' })).toBe(false); + }); +}); + +describe('passwordResetBlockedReason', () => { + it('returns null for a password account', () => { + expect(passwordResetBlockedReason({ authProvider: 'password' })).toBeNull(); + }); + + it('names the provider in the reason', () => { + expect(passwordResetBlockedReason({ authProvider: 'none' })).toBe( + 'This account has no password — it signs in via OAuth.' + ); + expect(passwordResetBlockedReason({ authProvider: 'google' })).toBe( + 'Managed by Google — password login is disabled for this account.' + ); + expect(passwordResetBlockedReason({ authProvider: 'github' })).toBe( + 'Managed by GitHub — password login is disabled for this account.' + ); + expect(passwordResetBlockedReason({ authProvider: 'oidc' })).toBe( + 'Managed by SSO — password login is disabled for this account.' + ); + }); + + it('falls back to a generic reason for an unenumerated provider', () => { + expect(passwordResetBlockedReason({ authProvider: 'saml' })).toBe( + 'Password login is not available for this account.' + ); + }); + + it('always explains a blocked reset', () => { + // authProvider is typed `string`, so no missing branch can be caught by + // the compiler. A null reason here would render a disabled control with + // no explanation — the confusion issue #77 reported. + // + // The Object.prototype keys are the interesting cases: with a plain object + // literal as the lookup table they resolve to inherited members, so a + // nullish fallback never fires and the function returns a function or an + // object in place of its declared string. Asserting typeof rather than + // truthiness is what catches that. + const providers = [ + ...NON_PASSWORD_PROVIDERS, + 'saml', + '', + 'PASSWORD', + 'ldap', + 'unknown', + '__proto__', + 'toString', + 'constructor', + 'hasOwnProperty', + 'valueOf' + ]; + for (const authProvider of providers) { + const user = { authProvider }; + expect(canResetPassword(user), authProvider).toBe(false); + const reason = passwordResetBlockedReason(user); + expect(typeof reason, authProvider).toBe('string'); + expect(reason, authProvider).not.toBe(''); + } + }); +}); + +describe('auth-methods module', () => { + it('does not use Math.random', () => { + // The reset-eligibility rule must be pure and deterministic. + const source = readFileSync(new URL('./auth-methods.ts', import.meta.url), 'utf8'); + expect(source).not.toContain('Math.random'); + }); +}); diff --git a/src/lib/utils/auth-methods.ts b/src/lib/utils/auth-methods.ts new file mode 100644 index 0000000..f314c07 --- /dev/null +++ b/src/lib/utils/auth-methods.ts @@ -0,0 +1,44 @@ +import type { User } from '$lib/types/index.js'; + +/** + * Human-readable reason a password reset is unavailable, keyed by auth provider. + * Anything absent from this map falls back to BLOCKED_REASON_FALLBACK, so the + * blocked path can never be left unexplained. + * + * A Map, not an object literal: an object inherits Object.prototype, so a lookup + * for 'toString' or '__proto__' would resolve to an inherited member instead of + * missing, and the fallback below would never fire. A Map has no such keys, so + * totality holds by construction rather than by a guard that could be dropped. + */ +const BLOCKED_REASONS = new Map([ + ['none', 'This account has no password — it signs in via OAuth.'], + ['google', 'Managed by Google — password login is disabled for this account.'], + ['github', 'Managed by GitHub — password login is disabled for this account.'], + ['oidc', 'Managed by SSO — password login is disabled for this account.'] +]); + +const BLOCKED_REASON_FALLBACK = 'Password login is not available for this account.'; + +/** + * Whether an admin may set a new password for this account. + * + * Password login is gated on `authProvider` — verifyPassword() only matches rows + * where it equals 'password' — so writing a hash for any other provider stores a + * credential that can never be used to sign in. + */ +export function canResetPassword(user: Pick): boolean { + return user.authProvider === 'password'; +} + +/** + * Why a password reset is blocked, or null when it is allowed. + * + * `authProvider` is typed `string`, so the compiler cannot enforce exhaustiveness + * here. The fallback guarantees the invariant this module exists for: whenever + * `canResetPassword` is false, this returns a non-empty explanation, so the UI + * never shows a disabled control without saying why. + */ +export function passwordResetBlockedReason(user: Pick): string | null { + if (canResetPassword(user)) return null; + return BLOCKED_REASONS.get(user.authProvider) ?? BLOCKED_REASON_FALLBACK; +} diff --git a/src/lib/utils/password.test.ts b/src/lib/utils/password.test.ts new file mode 100644 index 0000000..89baf0f --- /dev/null +++ b/src/lib/utils/password.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { generatePassword, PASSWORD_ALPHABET } from './password.js'; + +const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/; +const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/; +const DIGIT = /[23456789]/; +const SYMBOL = /[!@#$%^&*]/; + +/** Visually ambiguous characters the charset deliberately excludes. */ +const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I']; + +describe('PASSWORD_ALPHABET', () => { + it('is exactly 64 distinct characters', () => { + // 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform. + // A charset edit that breaks this silently biases every password. + expect(PASSWORD_ALPHABET).toHaveLength(64); + expect(new Set(PASSWORD_ALPHABET).size).toBe(64); + }); + + it('excludes every visually ambiguous character', () => { + for (const char of AMBIGUOUS) { + expect(PASSWORD_ALPHABET.includes(char), char).toBe(false); + } + }); +}); + +describe('generatePassword', () => { + it('returns a 20-character password by default', () => { + expect(generatePassword()).toHaveLength(20); + }); + + it('honours an explicit length', () => { + expect(generatePassword(8)).toHaveLength(8); + expect(generatePassword(32)).toHaveLength(32); + expect(generatePassword(64)).toHaveLength(64); + }); + + it('always includes at least one character from each of the four groups', () => { + // Not a statistical bet: the function only ever returns a class-complete + // password or throws, so every sample must pass. + for (let i = 0; i < 20; i++) { + const password = generatePassword(); + expect(password, `sample ${i}`).toMatch(LOWERCASE); + expect(password, `sample ${i}`).toMatch(UPPERCASE); + expect(password, `sample ${i}`).toMatch(DIGIT); + expect(password, `sample ${i}`).toMatch(SYMBOL); + } + }); + + it('never emits a visually ambiguous character', () => { + // 0 O o 1 l I survive neither being read aloud nor retyped. + for (let i = 0; i < 20; i++) { + const password = generatePassword(); + for (const char of AMBIGUOUS) { + expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false); + } + } + }); + + it('draws only from the pinned 64-character charset', () => { + const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/; + for (let i = 0; i < 20; i++) { + expect(generatePassword(), `sample ${i}`).toMatch(charset); + } + }); + + it('produces a different password on each call', () => { + const passwords = new Set(Array.from({ length: 20 }, () => generatePassword())); + expect(passwords.size).toBe(20); + }); + + it('throws below the 8-character server minimum', () => { + expect(() => generatePassword(7)).toThrow(/8/); + expect(() => generatePassword(0)).toThrow(/8/); + expect(() => generatePassword(-1)).toThrow(/8/); + }); + + it('does not use Math.random anywhere in the module', () => { + // Randomness must come from nanoid's CSPRNG, not Math.random. + const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8'); + expect(source).not.toContain('Math.random'); + }); +}); diff --git a/src/lib/utils/password.ts b/src/lib/utils/password.ts new file mode 100644 index 0000000..ea6665c --- /dev/null +++ b/src/lib/utils/password.ts @@ -0,0 +1,59 @@ +import { customAlphabet } from 'nanoid'; + +// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated +// password survives being read aloud or retyped from a screenshot. +const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o +const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O +const DIGITS = '23456789'; // no 0, no 1 +const SYMBOLS = '!@#$%^&*'; + +/** + * Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking + * takes its exactly-uniform fast path (256 % 64 === 0) and no character is + * favoured over another. At 64 symbols a 20-character password carries 120 bits + * of entropy — 119.79 once conditioned on the class-completeness retry below. + * Do not change the size without understanding both consequences. + */ +export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS; + +const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS]; + +/** The server rejects anything shorter. */ +const MIN_LENGTH = 8; + +/** + * Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the + * minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21 + * respectively — unreachable in practice. The bound exists so a future charset + * edit cannot turn the retry into an infinite loop. + */ +const MAX_ATTEMPTS = 100; + +const randomString = customAlphabet(PASSWORD_ALPHABET); + +function containsAnyOf(password: string, group: string): boolean { + return [...password].some((char) => group.includes(char)); +} + +/** + * Generates a random password containing at least one lowercase letter, one + * uppercase letter, one digit and one symbol. + * + * A password missing a group is discarded whole and regenerated, which keeps the + * result uniform over exactly the set of valid passwords. Forcing characters + * into chosen positions and shuffling would need its own unbiased index source. + */ +export function generatePassword(length = 20): string { + if (length < MIN_LENGTH) { + throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`); + } + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const password = randomString(length); + if (GROUPS.every((group) => containsAnyOf(password, group))) return password; + } + + throw new Error( + `Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts` + ); +}