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
15 changes: 15 additions & 0 deletions .changeset/enrollment-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'seamless-auth-api': minor
---

Track and invite passkey enrollment, for moving an organization onto passkeys after importing its users (#338).

- `GET /admin/enrollment` lists active users with their WebAuthn credential count and status (`none`, `one`, `two_or_more`), filterable by organization, status, imported users and email, with a per-status summary.
- `POST /admin/enrollment/invites` emails users a notice to sign in and add a passkey. The link is the tenant's sign-in page (`signInUrl`, default `<frontend_url>/login`) and carries no credential.
- Targets are `userIds` (up to 200) or an `organizationId`, whose unenrolled members are invited 200 at a time. Anyone invited in the last day is skipped, and the response reports what `remaining` is left.
- With `x-seamless-auth-delivery-mode: external`, each result carries the delivery for the caller to send.
- Answers 409 when no sign-in method other than passkey is enabled.
- Each invite is logged as `admin_enrollment_invite_sent`.
- New `prompt_passkey_enrollment` setting (default `false`, env `PROMPT_PASSKEY_ENROLLMENT`). With it on, email and phone code sign-ins and magic link sign-ins carry `nextStep: 'enroll_passkey'` for a user with no passkey.

Requires a database migration, `@seamless-auth/types` 0.26.0 and `@seamless-auth/messaging` 0.2.0.
615 changes: 603 additions & 12 deletions openapi.json

Large diffs are not rendered by default.

44 changes: 22 additions & 22 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@
"homepage": "https://github.com/fells-code/seamless-auth-api#readme",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^9.1.0",
"@seamless-auth/messaging": "^0.1.0",
"@seamless-auth/messaging-aws": "^0.1.0",
"@seamless-auth/messaging-twilio": "^0.1.0",
"@seamless-auth/types": "^0.25.0",
"@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",
"@simplewebauthn/server": "^13.3.3",
"base64url": "^3.0.1",
"bcrypt-ts": "^9.0.2",
Expand Down
1 change: 1 addition & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
// left absent so the key is settable from the environment like every other one.
magic_link_redirect_uris: [],
passkey_login_fallback_enabled: true,
prompt_passkey_enrollment: 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 @@ -12,6 +12,7 @@ export const SYSTEM_CONFIG_ENV_MAP = {
lockout_policy: 'LOCKOUT_POLICY',
authenticator_policy: 'AUTHENTICATOR_POLICY',
passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED',
prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT',
access_token_ttl: 'ACCESS_TOKEN_TTL',
session_idle_ttl: 'SESSION_IDLE_TTL',
max_concurrent_sessions: 'MAX_CONCURRENT_SESSIONS',
Expand Down
62 changes: 62 additions & 0 deletions src/controllers/enrollment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

import { Request, Response } from 'express';

import { wantsExternalDelivery } from '../lib/externalDelivery.js';
import {
AdminEnrollmentQuerySchema,
EnrollmentInviteRequestSchema,
} from '../schemas/enrollment.js';
import { AuthEventService } from '../services/authEventService.js';
import {
EnrollmentInviteError,
inviteToEnroll,
listEnrollment,
} from '../services/enrollmentService.js';
import { AuthenticatedRequest } from '../types/types.js';

export async function getEnrollment(req: Request, res: Response) {
// Re-parsed so the coerced numbers and booleans recover their types; `defineRoute`
// has already validated the query, so this cannot fail.
const query = AdminEnrollmentQuerySchema.parse(req.query);

return res.json(await listEnrollment(query));
}

export async function sendEnrollmentInvites(req: Request, res: Response) {
const request = EnrollmentInviteRequestSchema.parse(req.body);

// The invite carries no secret (it links to the sign-in page), so external delivery
// needs no service token here, unlike a code or a magic link. That is what lets an
// organization send invites from its own mail system.
const external = wantsExternalDelivery(req);

try {
const { invitedUserIds, ...response } = await inviteToEnroll(request, { external });
const actorUserId = (req as AuthenticatedRequest).user?.id ?? null;

for (const userId of invitedUserIds) {
await AuthEventService.log({
userId,
actorUserId,
type: 'admin_enrollment_invite_sent',
req,
metadata: {
external,
...(request.organizationId ? { organizationId: request.organizationId } : {}),
},
});
}

return res.json(response);
} catch (error) {
if (error instanceof EnrollmentInviteError) {
return res.status(error.status).json({ error: error.message });
}
throw error;
}
}
2 changes: 2 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 { 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 {
MagicLinkRedirectNotAllowedError,
Expand Down Expand Up @@ -301,6 +302,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
},
req,
res,
extraFields: await passkeyEnrollmentPrompt(user.id),
});

await user.update({
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { canReturnExternalDelivery } from '../lib/externalDelivery.js';
import { signEphemeralToken } from '../lib/token.js';
import { AuthEventService } from '../services/authEventService.js';
import { DeliveryError } from '../services/deliveryError.js';
import { passkeyEnrollmentPrompt } from '../services/enrollmentService.js';
import { rejectIfUserLocked } from '../services/lockoutPolicyService.js';
import {
getLoginPolicy,
Expand Down Expand Up @@ -385,6 +386,7 @@ export const verifyEmail = async (req: Request, res: Response) => {
},
req,
res,
extraFields: await passkeyEnrollmentPrompt(user.id),
});

await user.update({
Expand Down Expand Up @@ -478,6 +480,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => {
},
req,
res,
extraFields: await passkeyEnrollmentPrompt(user.id),
});

await user.update({
Expand Down Expand Up @@ -584,6 +587,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => {
},
req,
res,
extraFields: await passkeyEnrollmentPrompt(user.id),
});

await user.update({
Expand Down
Loading
Loading