diff --git a/.changeset/phishing-resistant-only.md b/.changeset/phishing-resistant-only.md new file mode 100644 index 0000000..4541f0a --- /dev/null +++ b/.changeset/phishing-resistant-only.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': minor +--- + +Add a phishing-resistant-only login mode and enforce the passkey fallback rule on every continuation endpoint. + +- New `phishing_resistant_only` system config key (env `PHISHING_RESISTANT_ONLY`, default `false`). When on, a session starts only from a passkey: email and phone codes, magic links, TOTP and OAuth are refused with `403 login_method_disabled` (OAuth providers are hidden), whatever `login_methods` says, and the public config reports `loginMethods: ["passkey"]`. The email code that verifies a new account's address still starts one session so the first passkey can be enrolled. Session issuance refuses a non-passkey factor in this mode as a backstop. Requires `@seamless-auth/types` 0.27.0. +- `passkey_login_fallback_enabled: false` now binds on the continuation endpoints themselves, not only on the method list `/login` returns. A user who holds a passkey gets `403 login_method_disabled` from the email and phone code, magic link, TOTP login and email verification endpoints. Previously those endpoints checked only whether the method was enabled for the deployment. +- `POST /totp/verify-login` can now answer `403 login_method_disabled`. +- Decoy responses for unknown identifiers mirror both rules, so the refusals do not reveal whether an account exists. diff --git a/README.md b/README.md index 9246758..2d230fa 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ session. `LOGIN_METHODS` accepts any of `passkey`, `magic_link`, `email_otp`, `p `oauth`, and defaults to `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 offer only the allowed continuations for that user and device. +Set `PHISHING_RESISTANT_ONLY=true` to accept passkeys only, for every account, whatever +`LOGIN_METHODS` says. See [docs/configuration.md](./docs/configuration.md). diff --git a/docs/architecture.md b/docs/architecture.md index 2093a40..0f87841 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,10 @@ Supported login methods are controlled by `login_methods` system config: - `oauth` Accounts holding a passkey can be restricted to passkey-only continuation by disabling -`passkey_login_fallback_enabled`. The client sends a `passkeyAvailable` capability hint on +`passkey_login_fallback_enabled`. `phishing_resistant_only` goes further and restricts every +account to passkeys, apart from the one session that verifies a new account's address. Both +are enforced on each continuation endpoint as well as in the `/login` method list, and session +issuance refuses a non-passkey factor in phishing-resistant-only mode as a backstop. The client sends a `passkeyAvailable` capability hint on `POST /login`, but it is advisory: it can remove passkey from a set the policy already permits, never add a weaker method to a passkey-only one. diff --git a/docs/configuration.md b/docs/configuration.md index b4d7682..e09d0b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,7 +74,8 @@ first boot. | `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. | | `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. | | `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. | -| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. | +| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. The rule binds on every continuation endpoint, not only on the list `/login` returns, so a passkey holder calling an email code, phone code, magic link, TOTP or email verification endpoint directly gets `403 login_method_disabled`. | +| `PHISHING_RESISTANT_ONLY` | No | `false` | `phishing_resistant_only` | When `true`, a session starts only from a passkey. Email and phone codes, magic links, TOTP and OAuth are refused (`403 login_method_disabled`, and OAuth providers are hidden) whatever `LOGIN_METHODS` and `PASSKEY_LOGIN_FALLBACK_ENABLED` say, and `GET /system-config/public` reports `loginMethods: ["passkey"]`. The one exception is a new account: the email code that verifies its address starts one session so its first passkey can be enrolled. An account that never enrolls one cannot sign in again on its own and needs admin recovery. Off by default, so existing deployments are unaffected. | | `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. | | `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`, measured from sign-in and not extended by refresh; this only binds while it is the shorter of the two. | | `MAX_CONCURRENT_SESSIONS` | No | unlimited | `max_concurrent_sessions` | How many sessions one user may hold at once. Unset, empty, `null`, `none` or `unlimited` all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a `session_evicted` auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10. | @@ -317,6 +318,7 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig | `available_roles` | string[] | `AVAILABLE_ROLES` | - | | `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | | `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | +| `phishing_resistant_only` | boolean | `PHISHING_RESISTANT_ONLY` | `false` | | `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | | `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | | `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false,syncedPasskeys:"allow",aaguidAllowList:[],aaguidDenyList:[]}` | diff --git a/openapi.json b/openapi.json index 8341ca7..6f49f78 100644 --- a/openapi.json +++ b/openapi.json @@ -11083,6 +11083,7 @@ "login_methods": [null], "passkey_login_fallback_enabled": true, "prompt_passkey_enrollment": null, + "phishing_resistant_only": null, "oauth_providers": null, "lockout_policy": null, "authenticator_policy": null, @@ -11128,6 +11129,7 @@ }, "passkey_login_fallback_enabled": { "type": "boolean" }, "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "phishing_resistant_only": { "type": "boolean", "default": false }, "oauth_providers": { "type": "array", "items": { @@ -11459,6 +11461,7 @@ }, "passkey_login_fallback_enabled": { "type": "boolean" }, "prompt_passkey_enrollment": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean" }, "oauth_providers": { "type": "array", "items": { @@ -12966,6 +12969,19 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { diff --git a/package-lock.json b/package-lock.json index f256994..4082f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3082,15 +3082,15 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.26.0.tgz", - "integrity": "sha512-O1a6LO/NGE8IYQEc5fTg+54PLzOvmqMmqnKe20OQ0/++0kLjvH7a9xodghzXCMnxiR/76UV2zOvNcy7r0kQ9ZQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.27.0.tgz", + "integrity": "sha512-kIrPtdnSOxhDKdxI7NfoRO7pHT23jgpT3pgB8Aa2asGMnasU6pPeBceN1CngUgKpU8ZkOv10mRN44bfgVZGNSA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" }, "engines": { - "node": ">=24 <25" + "node": ">=22" } }, "node_modules/@simple-libs/child-process-utils": { diff --git a/package.json b/package.json index 2077d75..cb58ab5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88..322cf29 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99% + + coverage: 99.1% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99% - 99% + 99.1% + 99.1% diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index b491129..ddb8a35 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -34,6 +34,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { magic_link_redirect_uris: [], passkey_login_fallback_enabled: true, prompt_passkey_enrollment: false, + phishing_resistant_only: false, // The constants the flow limiters carried in code before the key existed, so an // instance that predates it keeps the limits it had. flow_rate_limits: DefaultFlowRateLimits, diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 2c7f84f..ae29887 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -13,6 +13,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { authenticator_policy: 'AUTHENTICATOR_POLICY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', + phishing_resistant_only: 'PHISHING_RESISTANT_ONLY', access_token_ttl: 'ACCESS_TOKEN_TTL', session_idle_ttl: 'SESSION_IDLE_TTL', max_concurrent_sessions: 'MAX_CONCURRENT_SESSIONS', diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index ed7992b..30f0d74 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -15,6 +15,7 @@ import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/deco import { getLoginPolicy, isLoginMethodEnabled, + isPasskeyRequired, LoginMethod, } from '../services/loginPolicyService.js'; import { @@ -79,13 +80,17 @@ async function logDecoy(req: Request, endpoint: string) { /** * Mirrors `rejectDisabledLoginMethod` in the OTP controller and `rejectDisabledMagicLink` - * in the magic link controller. Both answer 403 before looking at the account at all, so - * a decoy has to reach the same answer from the same policy read. + * in the magic link controller. Both answer 403 from the policy plus whether the account + * holds a passkey, so a decoy reaches the same answer from the same policy read and the + * passkey its shape was given, which is the one `/login` advertised for it. */ async function rejectDisabledMethod(method: LoginMethod, req: Request, res: Response) { const policy = await getLoginPolicy(); - if (isLoginMethodEnabled(policy, method)) { + if ( + isLoginMethodEnabled(policy, method) && + !isPasskeyRequired(policy, decoyPrincipal(req).hasPasskey) + ) { return false; } @@ -151,7 +156,35 @@ async function respondOtpVerifyFailed(req: Request, res: Response) { return res.status(401).json({ error: 'Not allowed' }); } -export const decoyVerifyEmailOtp = respondOtpVerifyFailed; +/** + * Mirrors the passkey rule on the paths a real verified account cannot use once a passkey + * is required: email verification, which signs such an account in, and TOTP login. + */ +async function rejectPasskeyRequired(method: string, req: Request, res: Response) { + const policy = await getLoginPolicy(); + + if (!isPasskeyRequired(policy, decoyPrincipal(req).hasPasskey)) { + return false; + } + + await AuthEventService.log({ + userId: null, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method }, + }); + + res.status(403).json({ error: 'login_method_disabled' }); + return true; +} + +export const decoyVerifyEmailOtp = async (req: Request, res: Response) => { + if (await rejectPasskeyRequired('email_otp', req, res)) { + return; + } + + return respondOtpVerifyFailed(req, res); +}; export const decoyVerifyPhoneOtp = respondOtpVerifyFailed; export const decoyVerifyLoginEmailOtp = async (req: Request, res: Response) => { @@ -273,6 +306,10 @@ export const decoyFinishWebAuthnLogin = async (req: Request, res: Response) => { }; export const decoyVerifyTotpLogin = async (req: Request, res: Response) => { + if (await rejectPasskeyRequired('totp', req, res)) { + return; + } + await logDecoy(req, 'totp:verify_login'); return res.status(401).json({ error: 'totp_verification_failed' }); diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 7c1dd19..1330dcb 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -15,7 +15,11 @@ import { User } from '../models/users.js'; import { MagicLinkRequestQuerySchema } from '../schemas/magiclink.requests.js'; import { AuthEventService } from '../services/authEventService.js'; import { passkeyEnrollmentPrompt } from '../services/enrollmentService.js'; -import { getLoginPolicy, isLoginMethodEnabled } from '../services/loginPolicyService.js'; +import { + getLoginPolicy, + isLoginMethodEnabled, + isPasskeyRequiredForUser, +} from '../services/loginPolicyService.js'; import { MagicLinkRedirectNotAllowedError, resolveMagicLinkUrl, @@ -31,15 +35,18 @@ const logger = getLogger('magic-links'); const TTL_MINUTES = 15; -async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) { +async function rejectDisabledMagicLink(req: Request, res: Response, user?: { id: string } | null) { const policy = await getLoginPolicy(); - if (isLoginMethodEnabled(policy, 'magic_link')) { + if ( + isLoginMethodEnabled(policy, 'magic_link') && + !(user?.id && (await isPasskeyRequiredForUser(user.id, policy))) + ) { return false; } await AuthEventService.log({ - userId: userId ?? null, + userId: user?.id ?? null, type: 'login_failed', req, metadata: { reason: 'Login method disabled', method: 'magic_link' }, @@ -72,7 +79,7 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { const preAuthUser = authReq.user; const useExternalDelivery = await canReturnExternalDelivery(req); - if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + if (await rejectDisabledMagicLink(req, res, preAuthUser)) { return; } @@ -237,7 +244,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; - if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + if (await rejectDisabledMagicLink(req, res, preAuthUser)) { return; } @@ -294,6 +301,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { }); await issueSessionAndRespond({ + method: 'magic_link', user: { id: user.id, email: user.email, diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 9f46054..18bfe70 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -159,6 +159,7 @@ export async function finishOAuthLogin(req: RouteRequest, res: Response) { (await Credential.count({ where: { userId: user.id } })) === 0; return issueSessionAndRespond({ + method: 'oauth', user: { id: user.id, email: user.email, diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index e5bab02..1fd7aee 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -15,6 +15,7 @@ import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { getLoginPolicy, isLoginMethodEnabled, + isPasskeyRequiredForUser, type LoginMethod, } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; @@ -36,13 +37,15 @@ async function rejectDisabledLoginMethod( res: Response, ): Promise { const policy = await getLoginPolicy(); + const user = (req as AuthenticatedRequest).user; - if (isLoginMethodEnabled(policy, method)) { + if ( + isLoginMethodEnabled(policy, method) && + !(user?.id && (await isPasskeyRequiredForUser(user.id, policy))) + ) { return false; } - const user = (req as AuthenticatedRequest).user; - await AuthEventService.log({ userId: user?.id ?? null, type: 'login_failed', @@ -311,13 +314,24 @@ export const verifyEmail = async (req: Request, res: Response) => { // This endpoint issues a session for an already-verified account, so it is a login // whatever its name says, and the lockout policy has to bind here too. Without it, // an account locked out of /otp/verify-login-email-otp could still authenticate - // through this one. The login-method policy deliberately does not gate it: email OTP + // through this one. The login-method list deliberately does not gate it: email OTP // is how registration proves an address, whether or not the deployment offers it as - // a way to sign in. + // a way to sign in. A rule that requires a passkey does, once the account is already + // verified, or this endpoint would be the way around it. if (await rejectIfUserLocked({ userId: user.id, req, res })) { return; } + if (user.verified && (await isPasskeyRequiredForUser(user.id))) { + await AuthEventService.log({ + userId: user.id, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method: 'email_otp' }, + }); + return res.status(403).json({ error: 'login_method_disabled' }); + } + logger.info('Verifying email'); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { @@ -378,6 +392,8 @@ export const verifyEmail = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'email_otp', + accountVerification: true, user: { id: user.id, email: user.email, @@ -472,6 +488,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'phone_otp', user: { id: user.id, email: user.email, @@ -579,6 +596,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'email_otp', user: { id: user.id, email: user.email, diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index 2b3f8f5..42968e2 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; +import { isPasskeyRequiredForUser } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js'; import { @@ -166,6 +167,16 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { return; } + if (await isPasskeyRequiredForUser(user.id)) { + await AuthEventService.log({ + userId: user.id, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method: 'totp' }, + }); + return res.status(403).json({ error: 'login_method_disabled' }); + } + const result = await verifyEnabledTotp(user.id, code); if (!result.verified) { @@ -186,6 +197,7 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'totp', user: { id: user.id, email: user.email, diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index fc204cd..8f2015a 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -595,6 +595,7 @@ const verifyWebAuthn = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'passkey', user: { id: user.id, email: user.email, diff --git a/src/generated/api.ts b/src/generated/api.ts index 357dde9..64410a4 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -11403,6 +11403,7 @@ export interface paths { * ], * "passkey_login_fallback_enabled": true, * "prompt_passkey_enrollment": null, + * "phishing_resistant_only": null, * "oauth_providers": null, * "lockout_policy": null, * "authenticator_policy": null, @@ -11429,6 +11430,8 @@ export interface paths { passkey_login_fallback_enabled: boolean; /** @default false */ prompt_passkey_enrollment: boolean; + /** @default false */ + phishing_resistant_only: boolean; /** @default [] */ oauth_providers: { id: string; @@ -11684,6 +11687,7 @@ export interface paths { login_methods?: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; passkey_login_fallback_enabled?: boolean; prompt_passkey_enrollment?: boolean; + phishing_resistant_only?: boolean; oauth_providers?: { id: string; name: string; @@ -13283,6 +13287,24 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index 45a149f..ab6dea8 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -117,6 +117,7 @@ totpRouter.post( response: { 200: WebAuthnTokenSuccessSchema, 401: ErrorSchema, + 403: ErrorSchema, }, }, }, diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts index 0152427..e0399a5 100644 --- a/src/services/loginPolicyService.ts +++ b/src/services/loginPolicyService.ts @@ -6,6 +6,7 @@ import { getSystemConfig } from '../config/getSystemConfig.js'; import { SYSTEM_CONFIG_DEFAULTS } from '../config/systemConfig.defaults.js'; +import { Credential } from '../models/credentials.js'; import { LoginMethodSchema } from '../schemas/systemConfig.schema.js'; export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth'; @@ -13,6 +14,7 @@ export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | export interface LoginPolicy { loginMethods: LoginMethod[]; passkeyFallbackEnabled: boolean; + phishingResistantOnly: boolean; } type LoginMethodUser = { @@ -48,6 +50,16 @@ export function normalizeLoginPolicy(config: Record | null | un const loginMethods = LOGIN_METHOD_ORDER.filter((method) => validConfiguredMethods.has(method)); + // Strictly true, so a malformed value leaves the mode off the way the schema default + // would rather than turning it on by accident. + if (config?.phishing_resistant_only === true) { + return { + loginMethods: ['passkey'] satisfies LoginMethod[], + passkeyFallbackEnabled: false, + phishingResistantOnly: true, + }; + } + return { loginMethods: loginMethods.length ? loginMethods @@ -56,6 +68,7 @@ export function normalizeLoginPolicy(config: Record | null | un typeof config?.passkey_login_fallback_enabled === 'boolean' ? config.passkey_login_fallback_enabled : SYSTEM_CONFIG_DEFAULTS.passkey_login_fallback_enabled!, + phishingResistantOnly: false, }; } @@ -116,3 +129,32 @@ export function resolveAvailableLoginMethods({ return hasValue(user.phone); }); } + +/** + * Whether a sign-in has to be a passkey, whatever other method the deployment enables. + * True in phishing-resistant-only mode, and for an account holding a passkey when + * fallback is off. + * + * The continuation endpoints check this as well as the method list. `/login` already + * leaves a fallback out of what it offers such an account, but the fallback's endpoint + * was still callable directly with the ephemeral token `/login` handed out. + */ +export function isPasskeyRequired(policy: LoginPolicy, hasPasskeyCredential: boolean) { + if (policy.phishingResistantOnly) return true; + + return ( + hasPasskeyCredential && + !policy.passkeyFallbackEnabled && + isLoginMethodEnabled(policy, 'passkey') + ); +} + +export async function isPasskeyRequiredForUser(userId: string, policy?: LoginPolicy) { + const resolvedPolicy = policy ?? (await getLoginPolicy()); + + // Settled without a query whenever the answer does not depend on the account. + if (resolvedPolicy.phishingResistantOnly) return true; + if (!isPasskeyRequired(resolvedPolicy, true)) return false; + + return isPasskeyRequired(resolvedPolicy, (await Credential.count({ where: { userId } })) > 0); +} diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index 4e0cb17..2ce6931 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -15,6 +15,7 @@ import { UserExternalId } from '../models/userExternalIds.js'; import { User } from '../models/users.js'; import type { OAuthProviderConfig } from '../schemas/systemConfig.schema.js'; import getLogger from '../utils/logger.js'; +import { normalizeLoginPolicy } from './loginPolicyService.js'; import { findOrganizationsRetiringOAuthProvider } from './organizationService.js'; const logger = getLogger('oauthService'); @@ -168,7 +169,13 @@ function providerRedirectAllowlist(provider: OAuthProviderConfig) { export async function getEnabledOAuthProviders() { const config = await getSystemConfig(); - if (!config.login_methods.includes('oauth')) { + // Through the normalized policy rather than the raw list, so phishing-resistant-only + // mode switches every provider off along with the other non-passkey methods. + if ( + !normalizeLoginPolicy(config as unknown as Record).loginMethods.includes( + 'oauth', + ) + ) { return []; } diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 7920154..4e571e5 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -11,9 +11,27 @@ import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from import { Session } from '../models/sessions.js'; import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; import { enforceConcurrentSessionLimit } from './concurrentSessionPolicy.js'; +import { getLoginPolicy } from './loginPolicyService.js'; import { getDefaultOrganizationIdForUser } from './organizationService.js'; +/** The factor that proved the user, as far as session issuance needs to know it. */ +export type SessionMethod = 'passkey' | 'email_otp' | 'phone_otp' | 'magic_link' | 'totp' | 'oauth'; + +export class PasskeyRequiredError extends Error { + constructor(method: SessionMethod) { + super(`Refused to issue a session from ${method} in phishing-resistant-only mode`); + this.name = 'PasskeyRequiredError'; + } +} + type IssueSessionParams = { + method: SessionMethod; + /** + * The session that completes a new account's address verification. The one session a + * non-passkey factor may start in phishing-resistant-only mode, because the account has + * nothing else to sign in with until its first passkey is enrolled. + */ + accountVerification?: boolean; user: { id: string; email: string; @@ -34,7 +52,16 @@ type IssueSessionParams = { }; export async function issueSessionAndRespond(params: IssueSessionParams): Promise { - const { user, req, res, extraFields } = params; + const { user, req, res, extraFields, method, accountVerification } = params; + + // Every endpoint that reaches here with another factor refuses first. This is the + // backstop for one that does not, so it fails closed rather than trusting the gates. + if (method !== 'passkey' && !accountVerification) { + const { phishingResistantOnly } = await getLoginPolicy(); + if (phishingResistantOnly) { + throw new PasskeyRequiredError(method); + } + } const refreshToken = generateRefreshToken(); const refreshTokenLookup = createRefreshTokenLookup(refreshToken); diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 0829e6e..e8863f0 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -49,6 +49,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA case 'passkey_login_fallback_enabled': case 'prompt_passkey_enrollment': + case 'phishing_resistant_only': return raw.trim().toLowerCase() === 'true'; case 'access_token_ttl': diff --git a/tests/integration/authentication/decoyContinuation.spec.ts b/tests/integration/authentication/decoyContinuation.spec.ts index ed45347..74d9bd1 100644 --- a/tests/integration/authentication/decoyContinuation.spec.ts +++ b/tests/integration/authentication/decoyContinuation.spec.ts @@ -294,3 +294,71 @@ describe('decoy continuation: TOTP', () => { expect(res.body).toEqual({ error: 'totp_verification_failed' }); }); }); + +// The real continuation endpoints refuse a passkey holder's fallback when fallback is +// off, and everything but a passkey in phishing-resistant-only mode. A decoy has to +// answer the same way for the shape `/login` gave it, or the refusal is the oracle. +describe('decoy continuation: passkey required', () => { + const PASSKEY_ONLY_PATHS: [string, 'get' | 'post'][] = [ + ['/otp/generate-login-email-otp', 'get'], + ['/otp/verify-login-email-otp', 'post'], + ['/otp/verify-email-otp', 'post'], + ['/magic-link', 'get'], + ['/totp/verify-login', 'post'], + ]; + + function call(path: string, method: 'get' | 'post') { + return method === 'get' + ? request(app).get(path) + : request(app).post(path).send({ verificationToken: '123456', code: '123456' }); + } + + it.each(PASSKEY_ONLY_PATHS)( + 'refuses %s for a decoy holding a passkey when fallback is off', + async (path, method) => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + const res = await call(path, method); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + }, + ); + + it.each(PASSKEY_ONLY_PATHS)( + 'answers %s as usual for a passkeyless decoy when fallback is off', + async (path, method) => { + principal = PASSKEYLESS_DECOY; + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + const res = await call(path, method); + + expect(res.status).not.toBe(403); + }, + ); + + it.each(PASSKEY_ONLY_PATHS)( + 'refuses %s for every decoy in phishing-resistant-only mode', + async (path, method) => { + principal = PASSKEYLESS_DECOY; + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + phishing_resistant_only: true, + }); + + const res = await call(path, method); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + }, + ); +}); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 3158ced..93ff78f 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -4,6 +4,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Application } from 'express'; import { User } from '../../../src/models/users.js'; +import { Credential } from '../../../src/models/credentials.js'; import { MagicLinkToken } from '../../../src/models/magicLinks.js'; import { Session } from '../../../src/models/sessions.js'; @@ -193,6 +194,37 @@ describe('GET /magic-link', () => { expect(MagicLinkToken.create).not.toHaveBeenCalled(); }); + it('refuses a magic link to a passkey holder when fallback is off', async () => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5174'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: false, + }); + (Credential.count as any).mockResolvedValue(1); + + const requested = await request(app).get('/magic-link'); + const polled = await request(app).get('/magic-link/check'); + + expect(requested.status).toBe(403); + expect(requested.body.error).toBe('login_method_disabled'); + expect(polled.status).toBe(403); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); + + it('refuses magic links in phishing-resistant-only mode', async () => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5174'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }); + + const res = await request(app).get('/magic-link'); + + expect(res.status).toBe(403); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); + it('rejects magic link requests when the method is disabled', async () => { (getSystemConfig as any).mockResolvedValue({ origins: ['http://localhost:5174'], diff --git a/tests/unit/config/systemConfigEnvMap.spec.ts b/tests/unit/config/systemConfigEnvMap.spec.ts index ee767a8..f9800bf 100644 --- a/tests/unit/config/systemConfigEnvMap.spec.ts +++ b/tests/unit/config/systemConfigEnvMap.spec.ts @@ -21,6 +21,7 @@ describe('SYSTEM_CONFIG_ENV_MAP', () => { authenticator_policy: 'AUTHENTICATOR_POLICY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', + phishing_resistant_only: 'PHISHING_RESISTANT_ONLY', access_token_ttl: 'ACCESS_TOKEN_TTL', refresh_token_ttl: 'REFRESH_TOKEN_TTL', session_idle_ttl: 'SESSION_IDLE_TTL', diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index 1c5ce32..ee77972 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -300,6 +300,7 @@ describe('otp controller', () => { await verifyLoginPhoneNumber(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'phone_otp', user: { id: verifiedUser.id, email: verifiedUser.email, @@ -375,6 +376,8 @@ describe('otp controller', () => { await verifyEmail(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'email_otp', + accountVerification: true, user: { id: verifiedUser.id, email: verifiedUser.email, @@ -492,6 +495,7 @@ describe('otp controller', () => { await verifyLoginEmail(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'email_otp', user: { id: verifiedUser.id, email: verifiedUser.email, @@ -979,4 +983,122 @@ describe('otp controller', () => { expect(res.status).toHaveBeenCalledWith(500); }); }); + + describe('when a passkey is required', () => { + async function loadWithPasskeyHolder(credentialCount = 1) { + const controller = await loadOtpController(); + const { Credential } = await import('../../../src/models/credentials.js'); + (Credential.count as any).mockResolvedValue(credentialCount); + return controller; + } + + const fallbackOff = { + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }; + + // The bypass this closes: `/login` offered such an account only a passkey, but the + // OTP endpoints checked the deployment-wide method list alone and signed it in. + it('refuses login email OTP to a passkey holder when fallback is off', async () => { + const { sendLoginEmailOTP, verifyLoginEmail } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + + const sendRes = buildRes(); + await sendLoginEmailOTP(buildReq(buildUser()), sendRes); + + const verifyRes = buildRes(); + await verifyLoginEmail( + buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), + verifyRes, + ); + + expect(generateEmailOTPMock).not.toHaveBeenCalled(); + expect(verifyEmailOTPMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(sendRes.status).toHaveBeenCalledWith(403); + expect(verifyRes.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses login phone OTP to a passkey holder when fallback is off', async () => { + const { verifyLoginPhoneNumber } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await verifyLoginPhoneNumber( + buildReq(buildUser(), { body: { verificationToken: '123456' } }), + res, + ); + + expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('still lets an account without a passkey use the fallback', async () => { + const { sendLoginEmailOTP } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await sendLoginEmailOTP(buildReq(buildUser()), res); + + expect(generateEmailOTPMock).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + it('refuses to sign an already verified passkey holder in through email verification', async () => { + const { verifyEmail } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await verifyEmail(buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), res); + + expect(verifyEmailOTPMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses every code sign-in in phishing-resistant-only mode', async () => { + const { sendLoginEmailOTP, verifyEmail } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }); + + const sendRes = buildRes(); + await sendLoginEmailOTP(buildReq(buildUser()), sendRes); + const verifyRes = buildRes(); + await verifyEmail( + buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), + verifyRes, + ); + + expect(sendRes.status).toHaveBeenCalledWith(403); + expect(verifyRes.status).toHaveBeenCalledWith(403); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + }); + + it('still verifies a new account in phishing-resistant-only mode, once', async () => { + const { verifyEmail } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, + phishing_resistant_only: true, + }); + const newcomer = buildUser({ verified: false, emailVerified: false }); + verifyEmailOTPMock.mockResolvedValue({ + user: buildUser({ verified: true, emailVerified: true }), + verified: true, + }); + + await verifyEmail( + buildReq(newcomer, { body: { verificationToken: 'EMAILOTP' } }), + buildRes(), + ); + + expect(issueSessionAndRespondMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'email_otp', accountVerification: true }), + ); + }); + }); }); diff --git a/tests/unit/controllers/totp.spec.ts b/tests/unit/controllers/totp.spec.ts index 9dc3e94..a3d978e 100644 --- a/tests/unit/controllers/totp.spec.ts +++ b/tests/unit/controllers/totp.spec.ts @@ -329,6 +329,38 @@ describe('totp controller', () => { expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); }); + it('refuses TOTP sign-in in phishing-resistant-only mode', async () => { + const { verifyTotpLogin } = await loadTotpController(); + const res = buildRes(); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey'], + phishing_resistant_only: true, + }); + + await verifyTotpLogin(buildReq(buildUser(), { body: { code: '123456' } }), res); + + expect(verifyEnabledTotpMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses TOTP sign-in to a passkey holder when fallback is off', async () => { + const { verifyTotpLogin } = await loadTotpController(); + const { Credential } = await import('../../../src/models/credentials.js'); + (Credential.count as any).mockResolvedValue(1); + const res = buildRes(); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + }); + + await verifyTotpLogin(buildReq(buildUser(), { body: { code: '123456' } }), res); + + expect(verifyEnabledTotpMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + it('returns 401 and logs failure when the code is invalid', async () => { const { verifyTotpLogin } = await loadTotpController(); const res = buildRes(); @@ -357,6 +389,7 @@ describe('totp controller', () => { await verifyTotpLogin(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'totp', user: { id: 'user-1', email: 'test@example.com', diff --git a/tests/unit/services/loginPolicyService.spec.ts b/tests/unit/services/loginPolicyService.spec.ts index c7ccad7..d0e99c6 100644 --- a/tests/unit/services/loginPolicyService.spec.ts +++ b/tests/unit/services/loginPolicyService.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Credential } from '../../../src/models/credentials.js'; import { getLoginPolicy, + isPasskeyRequired, + isPasskeyRequiredForUser, normalizeLoginPolicy, resolveAvailableLoginMethods, } from '../../../src/services/loginPolicyService.js'; @@ -20,6 +23,7 @@ describe('loginPolicyService', () => { await expect(getLoginPolicy()).resolves.toEqual({ loginMethods: ['passkey', 'email_otp'], passkeyFallbackEnabled: false, + phishingResistantOnly: false, }); vi.clearAllMocks(); @@ -29,6 +33,7 @@ describe('loginPolicyService', () => { expect(normalizeLoginPolicy(null)).toEqual({ loginMethods: ['passkey', 'magic_link'], passkeyFallbackEnabled: true, + phishingResistantOnly: false, }); }); @@ -180,4 +185,98 @@ describe('loginPolicyService', () => { ).toEqual(['magic_link', 'phone_otp']); }); }); + + describe('phishing-resistant-only mode', () => { + it('narrows sign-in to passkeys whatever the method list and fallback say', () => { + expect( + normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp', 'oauth'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }), + ).toEqual({ + loginMethods: ['passkey'], + passkeyFallbackEnabled: false, + phishingResistantOnly: true, + }); + }); + + it('only turns on for a literal true', () => { + expect(normalizeLoginPolicy({ phishing_resistant_only: 'true' }).phishingResistantOnly).toBe( + false, + ); + expect(normalizeLoginPolicy({ phishing_resistant_only: 1 }).phishingResistantOnly).toBe( + false, + ); + }); + + it('offers no fallback to an account without a passkey', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link'], + phishing_resistant_only: true, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'a@example.com', phone: '+15555555555' }, + hasPasskeyCredential: false, + }), + ).toEqual([]); + }); + }); + + describe('isPasskeyRequired', () => { + const fallbackOff = normalizeLoginPolicy({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + }); + const fallbackOn = normalizeLoginPolicy({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: true, + }); + + it('requires a passkey of everyone in phishing-resistant-only mode', () => { + const strict = normalizeLoginPolicy({ phishing_resistant_only: true }); + + expect(isPasskeyRequired(strict, false)).toBe(true); + expect(isPasskeyRequired(strict, true)).toBe(true); + }); + + it('requires one of a passkey holder when fallback is off', () => { + expect(isPasskeyRequired(fallbackOff, true)).toBe(true); + expect(isPasskeyRequired(fallbackOff, false)).toBe(false); + }); + + it('requires nothing when fallback is on', () => { + expect(isPasskeyRequired(fallbackOn, true)).toBe(false); + }); + + it('requires nothing when passkeys are not a sign-in method at all', () => { + const noPasskeys = normalizeLoginPolicy({ + login_methods: ['email_otp'], + passkey_login_fallback_enabled: false, + }); + + expect(isPasskeyRequired(noPasskeys, true)).toBe(false); + }); + + it('looks the account up only when the answer depends on it', async () => { + (Credential.count as any).mockResolvedValue(1); + + await expect( + isPasskeyRequiredForUser('user-1', normalizeLoginPolicy({ phishing_resistant_only: true })), + ).resolves.toBe(true); + await expect(isPasskeyRequiredForUser('user-1', fallbackOn)).resolves.toBe(false); + expect(Credential.count).not.toHaveBeenCalled(); + + await expect(isPasskeyRequiredForUser('user-1', fallbackOff)).resolves.toBe(true); + expect(Credential.count).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + + (Credential.count as any).mockResolvedValue(0); + await expect(isPasskeyRequiredForUser('user-1', fallbackOff)).resolves.toBe(false); + + vi.clearAllMocks(); + }); + }); }); diff --git a/tests/unit/services/oauthService.spec.ts b/tests/unit/services/oauthService.spec.ts index 7b47055..9576fd2 100644 --- a/tests/unit/services/oauthService.spec.ts +++ b/tests/unit/services/oauthService.spec.ts @@ -435,6 +435,19 @@ describe('oauthService', () => { expect(OAuthIdentity.findOrCreate).not.toHaveBeenCalled(); }); + it('returns no providers in phishing-resistant-only mode', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'oauth'], + oauth_providers: [provider], + phishing_resistant_only: true, + }), + ); + + await expect(getEnabledOAuthProviders()).resolves.toEqual([]); + await expect(getOAuthProvider(provider.id)).resolves.toBeNull(); + }); + it('returns no providers when oauth login is not enabled', async () => { (getSystemConfig as any).mockResolvedValue( buildSystemConfig({ login_methods: ['passkey'], oauth_providers: [provider] }), diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 45a48fd..872bdda 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { issueSessionAndRespond } from '../../../src/services/sessionIssuance.js'; +import { + issueSessionAndRespond, + PasskeyRequiredError, +} from '../../../src/services/sessionIssuance.js'; vi.mock('../../../src/lib/token.js', () => ({ generateRefreshToken: vi.fn(), @@ -149,6 +152,52 @@ describe('issueSessionAndRespond', () => { ); }); + describe('in phishing-resistant-only mode', () => { + beforeEach(() => { + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + login_methods: ['passkey'], + phishing_resistant_only: true, + }); + }); + + it.each(['email_otp', 'phone_otp', 'magic_link', 'totp', 'oauth'] as const)( + 'refuses to start a session from %s even if an endpoint let it through', + async (method) => { + await expect( + issueSessionAndRespond({ method, user: mockUser, req: mockReq(), res: mockRes() }), + ).rejects.toBeInstanceOf(PasskeyRequiredError); + + expect(Session.create).not.toHaveBeenCalled(); + }, + ); + + it('starts a session from a passkey', async () => { + await issueSessionAndRespond({ + method: 'passkey', + user: mockUser, + req: mockReq(), + res: mockRes(), + }); + + expect(Session.create).toHaveBeenCalled(); + }); + + it("starts the one session that completes a new account's address verification", async () => { + await issueSessionAndRespond({ + method: 'email_otp', + accountVerification: true, + user: mockUser, + req: mockReq(), + res: mockRes(), + }); + + expect(Session.create).toHaveBeenCalled(); + }); + }); + it('throws if token generation fails', async () => { const req = mockReq(); const res = mockRes();