feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { credentials } from "./tables.js";
import { createCredentialRepository } from "./repository.js";
import { createPasskeyService } from "./passkeys.js";
import { PUBLIC_PATHS, registerPasskeyRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(credentials);
const repository = createCredentialRepository(ctx.db, table);
const passkeys = createPasskeyService(ctx, repository);
// No describe: a passkey only helps a user who already registered one, so
// it never counts for the password lockout guard.
ctx.auth.registerLoginMethod({
id: "passkey",
labelKey: "signIn",
icon: "fingerprint",
kind: "form",
verify: (request) => passkeys.verifyLogin(request.body ?? {}),
});
registerPasskeyRoutes(
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
ctx,
repository,
passkeys,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+298
View File
@@ -0,0 +1,298 @@
import { randomUUID } from "node:crypto";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
Base64URLString,
RegistrationResponseJSON,
WebAuthnCredential,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import {
LoginMethodError,
type PluginContext,
type PluginVerifiedIdentity,
} from "@termix/plugin-sdk/backend";
import type { CredentialRecord, CredentialRepository } from "./repository.js";
export type UserVerification = "discouraged" | "preferred" | "required";
interface ChallengeRecord {
challenge: string;
userId?: string;
rpID: string;
origin: string;
userVerification: UserVerification;
createdAt: number;
}
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export function normalizeUserVerification(value: unknown): UserVerification {
return value === "discouraged" || value === "required" ? value : "preferred";
}
function header(
headers: Record<string, unknown>,
name: string,
): string | undefined {
const value = headers[name];
if (Array.isArray(value)) return value[0];
return typeof value === "string" ? value : undefined;
}
/** The browser's origin, or the one a proxy reports. */
export function requestOrigin(headers: Record<string, unknown>): string {
const origin = header(headers, "origin");
if (origin) return origin;
const proto = header(headers, "x-forwarded-proto") || "http";
const host =
header(headers, "x-forwarded-host") ||
header(headers, "host") ||
"localhost";
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
}
export function parseTransports(
value: string | null,
): AuthenticatorTransportFuture[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toCredential(record: CredentialRecord): WebAuthnCredential {
return {
id: record.credentialId as Base64URLString,
publicKey: Uint8Array.from(
Buffer.from(record.publicKey, "base64url"),
) as WebAuthnCredential["publicKey"],
counter: record.counter,
transports: parseTransports(record.transports),
};
}
export type PasskeyService = ReturnType<typeof createPasskeyService>;
/**
* Registration and sign-in ceremonies. Challenges live in memory for five
* minutes and are made in activate, so a disabled plugin forgets them.
*/
export function createPasskeyService(
ctx: PluginContext,
repository: CredentialRepository,
) {
const registrations = new Map<string, ChallengeRecord>();
const authentications = new Map<string, ChallengeRecord>();
const prune = (map: Map<string, ChallengeRecord>) => {
const now = Date.now();
for (const [id, record] of map) {
if (now - record.createdAt > CHALLENGE_TTL_MS) map.delete(id);
}
};
const put = (
map: Map<string, ChallengeRecord>,
record: Omit<ChallengeRecord, "createdAt">,
) => {
prune(map);
const id = randomUUID();
map.set(id, { ...record, createdAt: Date.now() });
return id;
};
const take = (map: Map<string, ChallengeRecord>, id: unknown) => {
if (typeof id !== "string") return null;
prune(map);
const record = map.get(id) ?? null;
map.delete(id);
return record;
};
async function rpName(): Promise<string> {
try {
return (await ctx.settings.readCore("app_name")) || "Termix";
} catch {
return "Termix";
}
}
return {
async registrationOptions(input: {
userId: string;
username: string;
origin: string;
userVerification: UserVerification;
}) {
const rpID = new URL(input.origin).hostname;
const existing = await repository.listByUserId(input.userId);
const options = await generateRegistrationOptions({
rpName: await rpName(),
rpID,
userID: Buffer.from(input.userId, "utf8"),
userName: input.username,
userDisplayName: input.username,
attestationType: "none",
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
})),
authenticatorSelection: {
residentKey: "required",
userVerification: input.userVerification,
},
});
const challengeId = put(registrations, {
challenge: options.challenge,
userId: input.userId,
rpID,
origin: input.origin,
userVerification: input.userVerification,
});
return { options, challengeId };
},
/** Stores the passkey. False when the challenge or response is bad. */
async register(input: {
userId: string;
challengeId: unknown;
response: RegistrationResponseJSON | undefined;
name: unknown;
}): Promise<boolean | "expired"> {
const challenge = take(registrations, input.challengeId);
if (!challenge || challenge.userId !== input.userId) return "expired";
if (!input.response) return false;
const verification = await verifyRegistrationResponse({
response: input.response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
requireUserVerification: challenge.userVerification === "required",
});
if (!verification.verified) return false;
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim().slice(0, 80)
: "Passkey";
await repository.create({
id: randomUUID(),
userId: input.userId,
name,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: JSON.stringify(input.response.response?.transports ?? []),
userVerification: challenge.userVerification,
});
return true;
},
async authenticationOptions(input: {
userId?: string;
origin: string;
userVerification: UserVerification;
}) {
const rpID = new URL(input.origin).hostname;
let allowCredentials:
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
| undefined;
if (input.userId) {
const credentials = await repository.listByUserId(input.userId);
if (credentials.length === 0) return null;
allowCredentials = credentials.map((credential) => ({
id: credential.credentialId as Base64URLString,
transports: parseTransports(credential.transports),
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: input.userVerification,
});
const challengeId = put(authentications, {
challenge: options.challenge,
userId: input.userId,
rpID,
origin: input.origin,
userVerification: input.userVerification,
});
return { options, challengeId };
},
/**
* Checks an assertion and says who signed in. A passkey that verified
* the user (PIN or biometric) counts as the second factor too.
*/
async verifyLogin(
body: Record<string, unknown>,
): Promise<PluginVerifiedIdentity> {
const challenge = take(authentications, body.challengeId);
if (!challenge) {
throw new LoginMethodError("Authentication challenge expired", 400);
}
const response = body.response as AuthenticationResponseJSON | undefined;
if (!response?.id) {
throw new LoginMethodError("Invalid passkey response", 400);
}
const credential = await repository.findByCredentialId(response.id);
if (
!credential ||
(challenge.userId && challenge.userId !== credential.userId)
) {
throw new LoginMethodError("Passkey not recognized", 401);
}
let verification: Awaited<
ReturnType<typeof verifyAuthenticationResponse>
>;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
credential: toCredential(credential),
requireUserVerification: challenge.userVerification === "required",
advancedFIDOConfig: { userVerification: challenge.userVerification },
});
} catch (error) {
ctx.log.warn(
`Passkey authentication failed for ${credential.userId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new LoginMethodError("Passkey authentication failed", 401);
}
if (!verification.verified) {
throw new LoginMethodError("Passkey authentication failed", 401);
}
await repository.updateAuthState(credential.id, {
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
});
return {
kind: "user",
userId: credential.userId,
mfaSatisfied: verification.authenticationInfo.userVerified === true,
rememberMe: !!body.rememberMe,
};
},
};
}
@@ -0,0 +1,83 @@
import { and, eq } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface CredentialRecord {
id: string;
userId: string;
name: string;
credentialId: string;
publicKey: string;
counter: number;
deviceType: string | null;
backedUp: boolean;
transports: string | null;
userVerification: string;
createdAt: string;
lastUsedAt: string | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type CredentialRepository = ReturnType<
typeof createCredentialRepository
>;
export function createCredentialRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
return {
async listByUserId(userId: string): Promise<CredentialRecord[]> {
const drizzle = await client();
return drizzle.select().from(table).where(eq(table.userId, userId));
},
async findByCredentialId(
credentialId: string,
): Promise<CredentialRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(eq(table.credentialId, credentialId))
.limit(1);
return (rows[0] as CredentialRecord) ?? null;
},
async create(
input: Omit<CredentialRecord, "lastUsedAt" | "createdAt">,
): Promise<void> {
const drizzle = await client();
await drizzle
.insert(table)
.values({ ...input, createdAt: new Date().toISOString() });
await db.persist();
},
async updateAuthState(
id: string,
state: {
counter: number;
backedUp: boolean;
deviceType: string;
lastUsedAt: string;
},
): Promise<void> {
const drizzle = await client();
await drizzle.update(table).set(state).where(eq(table.id, id));
await db.persist();
},
async deleteForUser(userId: string, id: string): Promise<void> {
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
},
};
}
+193
View File
@@ -0,0 +1,193 @@
import type { Request, Response, Router } from "express";
import { eq } from "drizzle-orm";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeUserVerification,
parseTransports,
requestOrigin,
type PasskeyService,
} from "./passkeys.js";
import type { CredentialRepository } from "./repository.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export const PUBLIC_PATHS = ["/authenticate/options"];
export function registerPasskeyRoutes(
router: Router,
ctx: PluginContext,
repository: CredentialRepository,
passkeys: PasskeyService,
): void {
async function findUser(
column: "id" | "username",
value: string,
): Promise<{ id: string; username: string } | null> {
const { users } = await ctx.db.refs<{ users: Table }>();
const drizzle = await ctx.db.client<Drizzle>();
const rows = await drizzle
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users[column], value))
.limit(1);
return rows[0] ?? null;
}
/**
* @openapi
* /plugin-api/webauthn/credentials:
* get:
* summary: List passkeys
* description: Lists the caller's registered passkeys.
* tags:
* - Passkeys
* responses:
* 200:
* description: List of passkeys.
*/
router.get("/credentials", async (_req: Request, res: Response) => {
const credentials = await repository.listByUserId(ctx.currentActor()!);
res.json({
credentials: credentials.map((credential) => ({
id: credential.id,
name: credential.name,
deviceType: credential.deviceType,
backedUp: !!credential.backedUp,
transports: parseTransports(credential.transports),
userVerification: credential.userVerification,
createdAt: credential.createdAt,
lastUsedAt: credential.lastUsedAt,
})),
});
});
/**
* @openapi
* /plugin-api/webauthn/register/options:
* post:
* summary: Start passkey registration
* tags:
* - Passkeys
* responses:
* 200:
* description: Registration options and challenge id.
* 404:
* description: User not found.
*/
router.post("/register/options", async (req: Request, res: Response) => {
const user = await findUser("id", ctx.currentActor()!);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(
await passkeys.registrationOptions({
userId: user.id,
username: user.username,
origin: requestOrigin(req.headers),
userVerification: normalizeUserVerification(req.body?.userVerification),
}),
);
});
/**
* @openapi
* /plugin-api/webauthn/register/verify:
* post:
* summary: Finish passkey registration
* tags:
* - Passkeys
* responses:
* 200:
* description: Passkey registered.
* 400:
* description: Registration failed or challenge expired.
*/
router.post("/register/verify", async (req: Request, res: Response) => {
const userId = ctx.currentActor()!;
try {
const result = await passkeys.register({
userId,
challengeId: req.body?.challengeId,
response: req.body?.response as RegistrationResponseJSON | undefined,
name: req.body?.name,
});
if (result === "expired") {
return res
.status(400)
.json({ error: "Registration challenge expired" });
}
if (!result) {
return res.status(400).json({ error: "Passkey registration failed" });
}
res.json({ success: true });
} catch (error) {
ctx.log.warn(
`Passkey registration failed for ${userId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
res.status(400).json({ error: "Passkey registration failed" });
}
});
/**
* @openapi
* /plugin-api/webauthn/authenticate/options:
* post:
* summary: Start passkey login
* description: Public. Authentication options, optionally scoped to a username. The assertion is then sent to /users/auth/passkey/verify.
* tags:
* - Passkeys
* responses:
* 200:
* description: Authentication options and challenge id.
* 404:
* description: No passkeys found for the user.
*/
router.post("/authenticate/options", async (req: Request, res: Response) => {
const username =
typeof req.body?.username === "string" ? req.body.username.trim() : "";
let userId: string | undefined;
if (username) {
const user = await findUser("username", username);
if (!user) return res.status(404).json({ error: "No passkeys found" });
userId = user.id;
}
const result = await passkeys.authenticationOptions({
userId,
origin: requestOrigin(req.headers),
userVerification: normalizeUserVerification(req.body?.userVerification),
});
if (!result) return res.status(404).json({ error: "No passkeys found" });
res.json(result);
});
/**
* @openapi
* /plugin-api/webauthn/credentials/{credentialId}:
* delete:
* summary: Delete a passkey
* tags:
* - Passkeys
* parameters:
* - in: path
* name: credentialId
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Passkey deleted.
*/
router.delete(
"/credentials/:credentialId",
async (req: Request, res: Response) => {
await repository.deleteForUser(
ctx.currentActor()!,
String(req.params.credentialId),
);
res.json({ success: true });
},
);
}
+34
View File
@@ -0,0 +1,34 @@
import {
adoptLegacyTable,
boolean,
defineTable,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Adopted from core's webauthn_credentials, so the column names are the
* legacy ones and the rename carries every passkey across.
*/
export const credentials = adoptLegacyTable(
"webauthn_credentials",
defineTable("credentials", {
id: varchar().primaryKey(),
userId: refUser(),
name: text().notNull(),
credentialId: varchar().notNull().unique(),
publicKey: text().notNull(),
counter: integer().notNull().default(0),
deviceType: text(),
backedUp: boolean().notNull().default(false),
transports: text(),
userVerification: text().notNull().default("preferred"),
createdAt: timestamp().notNull().defaultNow(),
lastUsedAt: timestamp(),
}),
);
export const tables = [credentials];