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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ docker-data/

# Lint / tooling cache
.eslintcache
docs/
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
4 changes: 2 additions & 2 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
@@ -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",
Expand Down
8 changes: 4 additions & 4 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.
22 changes: 18 additions & 4 deletions src/config/bootstrapSystemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
@@ -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<SystemConfig> = {
login_methods: ['passkey', 'magic_link'],
passkey_login_fallback_enabled: true,
};
2 changes: 2 additions & 0 deletions src/config/systemConfig.envMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
23 changes: 17 additions & 6 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +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 {
findRefreshSessionByToken,
hardRevokeSession,
Expand Down Expand Up @@ -148,18 +149,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) {
Expand All @@ -173,7 +183,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;
}

Expand All @@ -183,6 +193,7 @@ export const login = async (req: Request, res: Response) => {
sub: user.id,
token,
identifierType,
loginMethods,
ttl: parseDurationToSeconds(access_token_ttl || '15m'),
});
}
Expand Down
31 changes: 31 additions & 0 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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' });
}
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions src/controllers/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<boolean> {
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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions src/migrations/20260517130000-add-login-policy-system-config.cjs
Original file line number Diff line number Diff line change
@@ -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');
`);
},
};
3 changes: 3 additions & 0 deletions src/routes/magicLink.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ magicLinkRouter.get(
schemas: {
response: {
200: MessageSchema,
403: ErrorSchema,
},
},
},
Expand All @@ -45,6 +46,7 @@ magicLinkRouter.get(
response: {
200: MagicLinkPollSuccessSchema,
204: MessageSchema,
403: ErrorSchema,
404: ErrorSchema,
500: InternalErrorSchema,
},
Expand All @@ -64,6 +66,7 @@ magicLinkRouter.get(

response: {
200: MessageSchema,
403: ErrorSchema,
400: ErrorSchema,
500: InternalErrorSchema,
},
Expand Down
Loading
Loading