From 9e6a2cf2837f4f74f116df056ada98007d1e494c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 13:13:34 -0400 Subject: [PATCH 1/2] feat: let adopters specify login methods --- .env.example | 7 ++ .gitignore | 1 + README.md | 10 ++ package-lock.json | 4 +- package.json | 2 +- resources/coverage-badge.svg | 8 +- src/config/bootstrapSystemConfig.ts | 22 ++++- src/config/systemConfig.defaults.ts | 12 +++ src/config/systemConfig.envMap.ts | 2 + src/controllers/authentication.ts | 26 +++-- src/controllers/magicLinks.ts | 31 ++++++ src/controllers/otp.ts | 53 ++++++++++ ...7130000-add-login-policy-system-config.cjs | 20 ++++ src/routes/magicLink.routes.ts | 3 + src/routes/otp.routes.ts | 10 +- src/schemas/auth.requests.ts | 1 + src/schemas/auth.responses.ts | 3 + src/schemas/systemConfig.schema.ts | 4 + src/services/loginPolicyService.ts | 99 +++++++++++++++++++ src/utils/parseEnvConfigs.ts | 4 + tests/factories/systemConfigFactory.ts | 2 + .../authentication/authentication.spec.ts | 51 +++++++++- tests/integration/magicLink/magicLink.spec.ts | 31 ++++++ tests/unit/controllers/otp.spec.ts | 46 +++++++++ .../unit/services/loginPolicyService.spec.ts | 63 ++++++++++++ .../utils/parseSystemConfigEnvValue.spec.ts | 16 +++ 26 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 src/config/systemConfig.defaults.ts create mode 100644 src/migrations/20260517130000-add-login-policy-system-config.cjs create mode 100644 src/services/loginPolicyService.ts create mode 100644 tests/unit/services/loginPolicyService.spec.ts diff --git a/.env.example b/.env.example index e6ca2ba..d6facd4 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ DEFAULT_ROLES=user,betaUser # Roles that are allowed in the system AVAILABLE_ROLES=user,admin,betaUser,team +# Login methods administrators allow after /login creates a pre-auth session. +# Supported values: passkey,magic_link,email_otp,phone_otp +LOGIN_METHODS=passkey,magic_link +# When true, magic_link/email_otp/phone_otp can appear alongside passkey when allowed. +# When false, passkey-capable sessions continue with passkey only. +PASSKEY_LOGIN_FALLBACK_ENABLED=true + # DATABASE # Prefer DATABASE_URL in containers and hosted environments if you already have one. # DATABASE_URL=postgres://myuser:mypassword@localhost:5432/seamless_auth diff --git a/.gitignore b/.gitignore index e4fa024..4115147 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ docker-data/ # Lint / tooling cache .eslintcache +docs/ diff --git a/README.md b/README.md index 74e76ca..d2ed431 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,16 @@ SeamlessAuth can request PRF-capable passkeys and PRF assertions without ever re See [docs/webauthn-prf.md](./docs/webauthn-prf.md) for API usage, browser limitations, SDK contract guidance, and Seamless Secrets consumption rules. +### Login Method Policy + +Administrators can control which methods may continue after `/login` creates a pre-authenticated +session. Configure `LOGIN_METHODS` with any of `passkey`, `magic_link`, `email_otp`, or +`phone_otp`. The default is `passkey,magic_link`. + +Set `PASSKEY_LOGIN_FALLBACK_ENABLED=false` when passkey-capable sessions should continue with +passkeys only. When fallback is enabled, `/login` returns `loginMethods` so clients can show only +the allowed continuations for that user and device. + ### Install & run ``` diff --git a/package-lock.json b/package-lock.json index bfd0e22..aa6fb43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "seamless-auth-api", - "version": "0.1.14", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-auth-api", - "version": "0.1.14", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", diff --git a/package.json b/package.json index 0a1aa4c..309459e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seamless-auth-api", - "version": "0.1.14", + "version": "0.2.0", "description": "Seamless Auth API - A web application server for supporting a Seamless Auth server instance.", "main": "index.js", "type": "module", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index cfaca7c..ca9d3d6 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 82.1% + + coverage: 82.3% @@ -17,7 +17,7 @@ coverage coverage - 82.1% - 82.1% + 82.3% + 82.3% diff --git a/src/config/bootstrapSystemConfig.ts b/src/config/bootstrapSystemConfig.ts index 336a740..3d23780 100644 --- a/src/config/bootstrapSystemConfig.ts +++ b/src/config/bootstrapSystemConfig.ts @@ -7,6 +7,7 @@ import { SystemConfig } from '../models/systemConfig.js'; import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; import { parseSystemConfigEnvValue } from '../utils/parseEnvConfigs.js'; +import { SYSTEM_CONFIG_DEFAULTS } from './systemConfig.defaults.js'; import { SYSTEM_CONFIG_ENV_MAP } from './systemConfig.envMap.js'; export async function bootstrapSystemConfig() { @@ -22,10 +23,23 @@ export async function bootstrapSystemConfig() { const envValue = process.env[envVar]; if (!envValue) { - throw new Error( - `Missing required system config "${key}". ` + - `Provide ENV ${envVar} or seed system_config.`, - ); + const defaultValue = SYSTEM_CONFIG_DEFAULTS[key as keyof typeof SYSTEM_CONFIG_DEFAULTS]; + + if (defaultValue === undefined) { + throw new Error( + `Missing required system config "${key}". ` + + `Provide ENV ${envVar} or seed system_config.`, + ); + } + + await SystemConfig.create({ + key, + value: defaultValue, + updatedBy: null, + }); + + resolvedConfig[key] = defaultValue; + continue; } const parsed = parseSystemConfigEnvValue(key as keyof typeof SYSTEM_CONFIG_ENV_MAP, envValue); diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts new file mode 100644 index 0000000..7dbdcf8 --- /dev/null +++ b/src/config/systemConfig.defaults.ts @@ -0,0 +1,12 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import type { SystemConfig } from '../schemas/systemConfig.schema.js'; + +export const SYSTEM_CONFIG_DEFAULTS: Partial = { + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, +}; diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 8cee0ab..fcd4f4e 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -7,6 +7,8 @@ export const SYSTEM_CONFIG_ENV_MAP = { default_roles: 'DEFAULT_ROLES', available_roles: 'AVAILABLE_ROLES', + login_methods: 'LOGIN_METHODS', + passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', access_token_ttl: 'ACCESS_TOKEN_TTL', refresh_token_ttl: 'REFRESH_TOKEN_TTL', rate_limit: 'RATE_LIMIT', diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 49f5464..3767b20 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -20,6 +20,10 @@ import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { + getLoginPolicy, + resolveAvailableLoginMethods, +} from '../services/loginPolicyService.js'; import { findRefreshSessionByToken, hardRevokeSession, @@ -148,18 +152,27 @@ export const login = async (req: Request, res: Response) => { return res.status(401).json({ error: 'Login failed. Need to verify.' }); } - const credential = await Credential.findOne({ where: { userId: user.id } }); + const [credential, loginPolicy] = await Promise.all([ + Credential.findOne({ where: { userId: user.id } }), + getLoginPolicy(), + ]); + const loginMethods = resolveAvailableLoginMethods({ + policy: loginPolicy, + user, + hasPasskeyCredential: Boolean(credential), + passkeyAvailable, + }); - if (passkeyAvailable && !credential) { - logger.error(`Login attempt for a verified users, but no passkey. ${identifier}`); + if (loginMethods.length === 0) { + logger.error(`Login attempt had no allowed continuation methods. ${identifier}`); await AuthEvent.create({ user_id: user.id, type: 'login_failed', ip_address: req.ip, user_agent: req.headers['user-agent'], - metadata: { reason: `No credentials ${identifier}` }, + metadata: { reason: 'No allowed login methods available' }, }); - return res.status(401).json({ error: 'Need to re-register and create passkey' }); + return res.status(401).json({ error: 'No available login methods' }); } if (token) { @@ -173,7 +186,7 @@ export const login = async (req: Request, res: Response) => { if (AUTH_MODE === 'web') { await setAuthCookies(res, { ephemeralToken: token }); - res.status(200).json({ message: 'Success' }); + res.status(200).json({ message: 'Success', loginMethods }); return; } @@ -183,6 +196,7 @@ export const login = async (req: Request, res: Response) => { sub: user.id, token, identifierType, + loginMethods, ttl: parseDurationToSeconds(access_token_ttl || '15m'), }); } diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index f5e0d76..d9f093b 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -14,6 +14,7 @@ import { MagicLinkToken } from '../models/magicLinks.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; import { maybePromoteBootstrapAdmin } from '../services/bootstrapPromotionService.js'; +import { getLoginPolicy, isLoginMethodEnabled } from '../services/loginPolicyService.js'; import { sendMagicLinkEmail } from '../services/messagingService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -30,11 +31,33 @@ function wantsExternalDelivery(req: Request) { return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; } +async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) { + const policy = await getLoginPolicy(); + + if (isLoginMethodEnabled(policy, 'magic_link')) { + return false; + } + + await AuthEventService.log({ + userId: userId ?? null, + type: 'login_failed', + req, + metadata: { reason: 'Login method disabled', method: 'magic_link' }, + }); + + res.status(403).json({ error: 'login_method_disabled' }); + return true; +} + export async function requestMagicLink(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; const useExternalDelivery = wantsExternalDelivery(req); + if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + return; + } + const user = await User.findOne({ where: { email: preAuthUser.email } }); if (!user) { @@ -103,6 +126,10 @@ export async function verifyMagicLink(req: Request, res: Response) { logger.debug('Verifying magic link'); const { token } = req.params; + if (await rejectDisabledMagicLink(req, res)) { + return; + } + if (!token) { return res.status(400).json({ error: 'Missing verification token' }); } @@ -170,6 +197,10 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; + if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + return; + } + const user = await User.findOne({ where: { email: preAuthUser.email } }); if (!user) { diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index c5410a3..9ab3dc0 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -9,6 +9,11 @@ import { Request, Response } from 'express'; import { setAuthCookies } from '../lib/cookie.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; +import { + getLoginPolicy, + isLoginMethodEnabled, + type LoginMethod, +} from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -28,6 +33,30 @@ function wantsExternalDelivery(req: Request) { return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; } +async function rejectDisabledLoginMethod( + method: LoginMethod, + req: Request, + res: Response, +): Promise { + const policy = await getLoginPolicy(); + + if (isLoginMethodEnabled(policy, method)) { + return false; + } + + const user = (req as AuthenticatedRequest).user; + + await AuthEventService.log({ + userId: user?.id ?? null, + type: 'login_failed', + req, + metadata: { reason: 'Login method disabled', method }, + }); + + res.status(403).json({ error: 'login_method_disabled' }); + return true; +} + export const sendPhoneOTP = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const user = authReq.user; @@ -218,6 +247,22 @@ export const sendEmailOTP = async (req: Request, res: Response) => { } }; +export const sendLoginPhoneOTP = async (req: Request, res: Response) => { + if (await rejectDisabledLoginMethod('phone_otp', req, res)) { + return; + } + + return sendPhoneOTP(req, res); +}; + +export const sendLoginEmailOTP = async (req: Request, res: Response) => { + if (await rejectDisabledLoginMethod('email_otp', req, res)) { + return; + } + + return sendEmailOTP(req, res); +}; + export const verifyPhoneNumber = async (req: Request, res: Response) => { const { verificationToken } = req.body; @@ -396,6 +441,10 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { const email = user.email; const phone = user.phone; + if (await rejectDisabledLoginMethod('phone_otp', req, res)) { + return; + } + logger.info(`Verifying login phone number: ${phone}`); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { @@ -490,6 +539,10 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { const email = user.email; const phone = user.phone; + if (await rejectDisabledLoginMethod('email_otp', req, res)) { + return; + } + logger.info(`Verifying login email: ${email}`); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { diff --git a/src/migrations/20260517130000-add-login-policy-system-config.cjs b/src/migrations/20260517130000-add-login-policy-system-config.cjs new file mode 100644 index 0000000..bb9d1e1 --- /dev/null +++ b/src/migrations/20260517130000-add-login-policy-system-config.cjs @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + INSERT INTO public.system_config (key, value, "updatedBy", "createdAt", "updatedAt") + VALUES + ('login_methods', '["passkey","magic_link"]'::jsonb, NULL, NOW(), NOW()), + ('passkey_login_fallback_enabled', 'true'::jsonb, NULL, NOW(), NOW()) + ON CONFLICT (key) DO NOTHING; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DELETE FROM public.system_config + WHERE key IN ('login_methods', 'passkey_login_fallback_enabled'); + `); + }, +}; diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index c68f0d6..bd46a7a 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -28,6 +28,7 @@ magicLinkRouter.get( schemas: { response: { 200: MessageSchema, + 403: ErrorSchema, }, }, }, @@ -45,6 +46,7 @@ magicLinkRouter.get( response: { 200: MagicLinkPollSuccessSchema, 204: MessageSchema, + 403: ErrorSchema, 404: ErrorSchema, 500: InternalErrorSchema, }, @@ -64,6 +66,7 @@ magicLinkRouter.get( response: { 200: MessageSchema, + 403: ErrorSchema, 400: ErrorSchema, 500: InternalErrorSchema, }, diff --git a/src/routes/otp.routes.ts b/src/routes/otp.routes.ts index 624f9e4..60f85ac 100644 --- a/src/routes/otp.routes.ts +++ b/src/routes/otp.routes.ts @@ -6,6 +6,8 @@ import { sendEmailOTP, + sendLoginEmailOTP, + sendLoginPhoneOTP, sendPhoneOTP, verifyEmail, verifyLoginEmail, @@ -65,10 +67,11 @@ otpRouter.get( schemas: { response: { 200: MessageSchema, + 403: ErrorSchema, }, }, }, - sendEmailOTP, + sendLoginEmailOTP, ); otpRouter.get( @@ -81,10 +84,11 @@ otpRouter.get( schemas: { response: { 200: MessageSchema, + 403: ErrorSchema, }, }, }, - sendPhoneOTP, + sendLoginPhoneOTP, ); otpRouter.post( @@ -99,6 +103,7 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, + 403: ErrorSchema, 401: ErrorSchema, 500: ErrorSchema, }, @@ -119,6 +124,7 @@ otpRouter.post( response: { 200: OTPVerifyTokenSuccessSchema, + 403: ErrorSchema, 401: ErrorSchema, 500: ErrorSchema, }, diff --git a/src/schemas/auth.requests.ts b/src/schemas/auth.requests.ts index 8cb896f..780bf50 100644 --- a/src/schemas/auth.requests.ts +++ b/src/schemas/auth.requests.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; export const LoginRequestSchema = z.object({ identifier: z.string(), + passkeyAvailable: z.boolean().optional(), }); export const RefreshRequestSchema = z.object({}); diff --git a/src/schemas/auth.responses.ts b/src/schemas/auth.responses.ts index 5c292c5..e4b7413 100644 --- a/src/schemas/auth.responses.ts +++ b/src/schemas/auth.responses.ts @@ -6,11 +6,14 @@ import { z } from 'zod'; +import { LoginMethodSchema } from './systemConfig.schema.js'; + export const LoginSuccessResponseSchema = z.object({ message: z.string(), token: z.string().optional(), sub: z.string().optional(), identifierType: z.enum(['email', 'phone']).optional(), + loginMethods: z.array(LoginMethodSchema).optional(), ttl: z.number().optional(), }); diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index f4b6fe3..2cd45a8 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -6,10 +6,14 @@ import { z } from 'zod'; +export const LoginMethodSchema = z.enum(['passkey', 'magic_link', 'email_otp', 'phone_otp']); + export const SystemConfigSchema = z.object({ app_name: z.string().min(3), default_roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), available_roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), + login_methods: z.array(LoginMethodSchema).min(1), + passkey_login_fallback_enabled: z.boolean(), access_token_ttl: z.string().regex(/^\d+[smhd]$/), refresh_token_ttl: z.string().regex(/^\d+[smhd]$/), diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts new file mode 100644 index 0000000..8900511 --- /dev/null +++ b/src/services/loginPolicyService.ts @@ -0,0 +1,99 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { SYSTEM_CONFIG_DEFAULTS } from '../config/systemConfig.defaults.js'; +import { LoginMethodSchema } from '../schemas/systemConfig.schema.js'; + +export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp'; + +export interface LoginPolicy { + loginMethods: LoginMethod[]; + passkeyFallbackEnabled: boolean; +} + +type LoginMethodUser = { + email?: string | null; + phone?: string | null; +}; + +const LOGIN_METHOD_ORDER: LoginMethod[] = ['passkey', 'magic_link', 'email_otp', 'phone_otp']; + +function hasValue(value: string | null | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function normalizeLoginPolicy(config: Record | null | undefined) { + const configuredMethods = Array.isArray(config?.login_methods) + ? config.login_methods + : SYSTEM_CONFIG_DEFAULTS.login_methods; + const validConfiguredMethods = new Set(); + + for (const method of configuredMethods ?? []) { + const parsed = LoginMethodSchema.safeParse(method); + + if (parsed.success) { + validConfiguredMethods.add(parsed.data); + } + } + + const loginMethods = LOGIN_METHOD_ORDER.filter((method) => validConfiguredMethods.has(method)); + + return { + loginMethods: loginMethods.length + ? loginMethods + : (SYSTEM_CONFIG_DEFAULTS.login_methods as LoginMethod[]), + passkeyFallbackEnabled: + typeof config?.passkey_login_fallback_enabled === 'boolean' + ? config.passkey_login_fallback_enabled + : SYSTEM_CONFIG_DEFAULTS.passkey_login_fallback_enabled!, + }; +} + +export async function getLoginPolicy(): Promise { + return normalizeLoginPolicy((await getSystemConfig()) as unknown as Record); +} + +export function isLoginMethodEnabled(policy: LoginPolicy, method: LoginMethod) { + return policy.loginMethods.includes(method); +} + +export function resolveAvailableLoginMethods({ + policy, + user, + hasPasskeyCredential, + passkeyAvailable, +}: { + policy: LoginPolicy; + user: LoginMethodUser; + hasPasskeyCredential: boolean; + passkeyAvailable?: boolean; +}) { + const passkeyUsable = + passkeyAvailable !== false && + hasPasskeyCredential && + isLoginMethodEnabled(policy, 'passkey'); + + if (passkeyUsable && !policy.passkeyFallbackEnabled) { + return ['passkey'] satisfies LoginMethod[]; + } + + return LOGIN_METHOD_ORDER.filter((method) => { + if (!isLoginMethodEnabled(policy, method)) { + return false; + } + + if (method === 'passkey') { + return passkeyUsable; + } + + if (method === 'magic_link' || method === 'email_otp') { + return hasValue(user.email); + } + + return hasValue(user.phone); + }); +} diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 4a246ff..64a1354 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -10,6 +10,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA switch (key) { case 'default_roles': case 'available_roles': + case 'login_methods': case 'origins': return raw .split(',') @@ -20,6 +21,9 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA case 'delay_after': return Number(raw); + case 'passkey_login_fallback_enabled': + return raw.trim().toLowerCase() === 'true'; + case 'access_token_ttl': case 'refresh_token_ttl': case 'rpid': diff --git a/tests/factories/systemConfigFactory.ts b/tests/factories/systemConfigFactory.ts index d3cb5d9..185a824 100644 --- a/tests/factories/systemConfigFactory.ts +++ b/tests/factories/systemConfigFactory.ts @@ -3,6 +3,8 @@ export function buildSystemConfig(overrides: any = {}) { app_name: 'SeamlessAuth', default_roles: ['user'], available_roles: ['user', 'admin'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, access_token_ttl: '15m', refresh_token_ttl: '7d', rate_limit: 100, diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index f030a9a..d6cabe9 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -72,8 +72,13 @@ describe('POST /login', () => { }); it('rejects passkey required but missing credential', async () => { - (User.findOne as any).mockResolvedValue(buildUser()); + (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); (Credential.findOne as any).mockResolvedValue(null); + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, + }); const res = await request(app).post('/login').send({ identifier: 'test@example.com', @@ -81,6 +86,7 @@ describe('POST /login', () => { }); expect(res.status).toBe(401); + expect(res.body.error).toBe('No available login methods'); }); it('logs in successfully', async () => { @@ -98,6 +104,49 @@ describe('POST /login', () => { }); expect(res.status).toBe(200); + expect(res.body.loginMethods).toEqual(['passkey', 'magic_link']); + }); + + it('returns administrator-enabled OTP login methods', async () => { + (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); + (Credential.findOne as any).mockResolvedValue(null); + + (signEphemeralToken as any).mockResolvedValue('token'); + + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + login_methods: ['email_otp', 'phone_otp'], + passkey_login_fallback_enabled: true, + }); + + const res = await request(app).post('/login').send({ + identifier: 'test@example.com', + passkeyAvailable: true, + }); + + expect(res.status).toBe(200); + expect(res.body.loginMethods).toEqual(['email_otp', 'phone_otp']); + }); + + it('hides fallback methods when passkey fallback is disabled and passkey is available', async () => { + (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); + (Credential.findOne as any).mockResolvedValue({}); + + (signEphemeralToken as any).mockResolvedValue('token'); + + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + const res = await request(app).post('/login').send({ + identifier: 'test@example.com', + passkeyAvailable: true, + }); + + expect(res.status).toBe(200); + expect(res.body.loginMethods).toEqual(['passkey']); }); }); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 8a6c20f..85c4359 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -30,6 +30,8 @@ beforeAll(async () => { }); beforeEach(() => { + vi.clearAllMocks(); + (User.findOne as any).mockResolvedValue({ id: 'user-1', email: 'test@example.com', @@ -41,6 +43,8 @@ beforeEach(() => { access_token_ttl: '15m', refresh_token_ttl: '1h', origins: ['http://localhost:5174'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, }); }); @@ -68,6 +72,20 @@ describe('GET /magic-link', () => { expect(res.status).toBe(200); expect(MagicLinkToken.create).toHaveBeenCalled(); }); + + it('rejects magic link requests when the method is disabled', async () => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5174'], + login_methods: ['passkey'], + passkey_login_fallback_enabled: true, + }); + + const res = await request(app).get('/magic-link'); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('login_method_disabled'); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); }); describe('GET /magic-link/verify/:token', () => { @@ -112,6 +130,19 @@ describe('GET /magic-link/verify/:token', () => { expect(res.status).toBe(200); }); + + it('rejects token verification when magic links are disabled', async () => { + (getSystemConfig as any).mockResolvedValue({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: true, + }); + + const res = await request(app).get('/magic-link/verify/token'); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('login_method_disabled'); + expect(MagicLinkToken.findOne).not.toHaveBeenCalled(); + }); }); describe('GET /magic-link/check', () => { diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index ed59952..b74a69d 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -8,6 +8,7 @@ const generateEmailOTPMock = vi.fn(); const generatePhoneOTPMock = vi.fn(); const verifyEmailOTPMock = vi.fn(); const verifyPhoneOTPMock = vi.fn(); +const getSystemConfigMock = vi.fn(); const isValidEmailMock = vi.fn(); const isValidPhoneNumberMock = vi.fn(); const normalizePhoneNumberMock = vi.fn(); @@ -39,6 +40,10 @@ vi.mock('../../../src/services/sessionIssuance.js', () => ({ issueSessionAndRespond: issueSessionAndRespondMock, })); +vi.mock('../../../src/config/getSystemConfig.js', () => ({ + getSystemConfig: getSystemConfigMock, +})); + vi.mock('../../../src/utils/otp.js', () => ({ generateEmailOTP: generateEmailOTPMock, generatePhoneOTP: generatePhoneOTPMock, @@ -123,6 +128,10 @@ beforeEach(() => { signEphemeralTokenMock.mockResolvedValue('ephemeral-token'); generatePhoneOTPMock.mockResolvedValue('654321'); generateEmailOTPMock.mockResolvedValue('ABCDEF'); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: true, + }); isValidPhoneNumberMock.mockReturnValue(true); normalizePhoneNumberMock.mockReturnValue('+14155552671'); isValidEmailMock.mockReturnValue(true); @@ -209,6 +218,24 @@ describe('otp controller', () => { }); }); + it('rejects disabled login email OTP generation', async () => { + const { sendLoginEmailOTP } = await loadOtpController('server'); + const user = buildUser(); + const req = buildReq(user); + const res = buildRes(); + + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, + }); + + await sendLoginEmailOTP(req, res); + + expect(generateEmailOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + it('returns 401 when phone verification data is missing', async () => { const { verifyPhoneNumber } = await loadOtpController('server'); const user = buildUser({ @@ -280,6 +307,25 @@ describe('otp controller', () => { }); }); + it('rejects disabled login phone OTP verification', async () => { + const { verifyLoginPhoneNumber } = await loadOtpController('server'); + const req = buildReq(buildUser(), { + body: { verificationToken: '123456' }, + }); + const res = buildRes(); + + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, + }); + + await verifyLoginPhoneNumber(req, res); + + expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + it('returns 401 when login phone verification fails', async () => { const { verifyLoginPhoneNumber } = await loadOtpController('server'); const failingUser = buildUser(); diff --git a/tests/unit/services/loginPolicyService.spec.ts b/tests/unit/services/loginPolicyService.spec.ts new file mode 100644 index 0000000..d7b7ced --- /dev/null +++ b/tests/unit/services/loginPolicyService.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeLoginPolicy, + resolveAvailableLoginMethods, +} from '../../../src/services/loginPolicyService.js'; + +describe('loginPolicyService', () => { + it('uses passkey plus magic-link fallback defaults', () => { + expect(normalizeLoginPolicy(null)).toEqual({ + loginMethods: ['passkey', 'magic_link'], + passkeyFallbackEnabled: true, + }); + }); + + it('filters available methods by policy and user contact fields', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: true, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'test@example.com', phone: null }, + hasPasskeyCredential: true, + passkeyAvailable: true, + }), + ).toEqual(['passkey', 'magic_link', 'email_otp']); + }); + + it('returns passkey only when fallback is disabled and passkey is usable', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'test@example.com', phone: '+14155552671' }, + hasPasskeyCredential: true, + passkeyAvailable: true, + }), + ).toEqual(['passkey']); + }); + + it('allows configured fallback methods when passkey is unavailable on the client', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'test@example.com', phone: '+14155552671' }, + hasPasskeyCredential: true, + passkeyAvailable: false, + }), + ).toEqual(['magic_link', 'phone_otp']); + }); +}); diff --git a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts index 5dcb5fa..623ead1 100644 --- a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts +++ b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts @@ -14,6 +14,15 @@ describe('parseSystemConfigEnvValue', () => { expect(result).toEqual(['http://a.com', 'http://b.com']); }); + + it('parses login methods', () => { + const result = parseSystemConfigEnvValue( + 'login_methods', + 'passkey, magic_link, email_otp', + ); + + expect(result).toEqual(['passkey', 'magic_link', 'email_otp']); + }); }); describe('number parsing', () => { @@ -50,6 +59,13 @@ describe('parseSystemConfigEnvValue', () => { }); }); + describe('boolean parsing', () => { + it('parses passkey_login_fallback_enabled', () => { + expect(parseSystemConfigEnvValue('passkey_login_fallback_enabled', 'true')).toBe(true); + expect(parseSystemConfigEnvValue('passkey_login_fallback_enabled', 'false')).toBe(false); + }); + }); + describe('invalid key', () => { it('throws for unknown key', () => { expect(() => parseSystemConfigEnvValue('invalid_key' as any, 'value')).toThrow( From 75428a7d03f332780316cc14f2f7e947611c3b7a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 13:13:53 -0400 Subject: [PATCH 2/2] ci: linting --- src/controllers/authentication.ts | 5 +---- src/services/loginPolicyService.ts | 4 +--- tests/unit/utils/parseSystemConfigEnvValue.spec.ts | 5 +---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 3767b20..68f689a 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -20,10 +20,7 @@ import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; -import { - getLoginPolicy, - resolveAvailableLoginMethods, -} from '../services/loginPolicyService.js'; +import { getLoginPolicy, resolveAvailableLoginMethods } from '../services/loginPolicyService.js'; import { findRefreshSessionByToken, hardRevokeSession, diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts index 8900511..1577850 100644 --- a/src/services/loginPolicyService.ts +++ b/src/services/loginPolicyService.ts @@ -73,9 +73,7 @@ export function resolveAvailableLoginMethods({ passkeyAvailable?: boolean; }) { const passkeyUsable = - passkeyAvailable !== false && - hasPasskeyCredential && - isLoginMethodEnabled(policy, 'passkey'); + passkeyAvailable !== false && hasPasskeyCredential && isLoginMethodEnabled(policy, 'passkey'); if (passkeyUsable && !policy.passkeyFallbackEnabled) { return ['passkey'] satisfies LoginMethod[]; diff --git a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts index 623ead1..631238a 100644 --- a/tests/unit/utils/parseSystemConfigEnvValue.spec.ts +++ b/tests/unit/utils/parseSystemConfigEnvValue.spec.ts @@ -16,10 +16,7 @@ describe('parseSystemConfigEnvValue', () => { }); it('parses login methods', () => { - const result = parseSystemConfigEnvValue( - 'login_methods', - 'passkey, magic_link, email_otp', - ); + const result = parseSystemConfigEnvValue('login_methods', 'passkey, magic_link, email_otp'); expect(result).toEqual(['passkey', 'magic_link', 'email_otp']); });