Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/phishing-resistant-only.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
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,
Expand Down
1 change: 1 addition & 0 deletions src/config/systemConfig.envMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 41 additions & 4 deletions src/controllers/decoyResponders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/deco
import {
getLoginPolicy,
isLoginMethodEnabled,
isPasskeyRequired,
LoginMethod,
} from '../services/loginPolicyService.js';
import {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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' });
Expand Down
20 changes: 14 additions & 6 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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' },
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -294,6 +301,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
});

await issueSessionAndRespond({
method: 'magic_link',
user: {
id: user.id,
email: user.email,
Expand Down
1 change: 1 addition & 0 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 23 additions & 5 deletions src/controllers/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,13 +37,15 @@ async function rejectDisabledLoginMethod(
res: Response,
): Promise<boolean> {
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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -472,6 +488,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => {
});

await issueSessionAndRespond({
method: 'phone_otp',
user: {
id: user.id,
email: user.email,
Expand Down Expand Up @@ -579,6 +596,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => {
});

await issueSessionAndRespond({
method: 'email_otp',
user: {
id: user.id,
email: user.email,
Expand Down
Loading
Loading