mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -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.
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createWebAuthnApi,
|
||||
type WebAuthnCredentialSummary,
|
||||
type WebAuthnUserVerification,
|
||||
} from "./webauthn-api";
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
return typeof data?.error === "string" && data.error ? data.error : fallback;
|
||||
}
|
||||
|
||||
/** Settings > Security: register and remove passkeys. */
|
||||
export function PasskeyEnrollment() {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
|
||||
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [userVerification, setUserVerification] =
|
||||
useState<WebAuthnUserVerification>("preferred");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.list()
|
||||
.then(setPasskeys)
|
||||
.catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
async function add() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.register(name || t("defaultName"), userVerification);
|
||||
setPasskeys(await api.list());
|
||||
setName("");
|
||||
toast.success(t("added"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("addFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.remove(id);
|
||||
setPasskeys((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success(t("deleted"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("deleteFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("description")}
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<Input
|
||||
placeholder={t("namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Select2
|
||||
value={userVerification}
|
||||
onChange={(e) =>
|
||||
setUserVerification(e.target.value as WebAuthnUserVerification)
|
||||
}
|
||||
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="preferred">{t("uv.preferred")}</option>
|
||||
<option value="required">{t("uv.required")}</option>
|
||||
<option value="discouraged">{t("uv.discouraged")}</option>
|
||||
</Select2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={add}
|
||||
disabled={loading}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border border border-border">
|
||||
{passkeys.length === 0 ? (
|
||||
<div className="py-4 text-center text-[10px] text-muted-foreground">
|
||||
{t("none")}
|
||||
</div>
|
||||
) : (
|
||||
passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{passkey.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{passkey.deviceType || t("unknownDevice")}
|
||||
{passkey.backedUp ? ` / ${t("synced")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(passkey.id)}
|
||||
disabled={loading}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type LoginMethodUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { createWebAuthnApi, isPasskeySupported } from "./webauthn-api";
|
||||
|
||||
export function PasskeyLoginButton({
|
||||
disabled,
|
||||
submit,
|
||||
username,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supported] = useState(isPasskeySupported);
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
await api.passkeyAssertion(username?.trim() || undefined),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
name?: string;
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
// Closing the browser prompt is not a failure worth a toast.
|
||||
if (error?.name === "NotAllowedError" || error?.name === "AbortError")
|
||||
return;
|
||||
toast.error(
|
||||
error?.response?.data?.error || error?.message || t("loginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
className="w-full h-10 font-bold"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4" />
|
||||
{t("signIn")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { PasskeyLoginButton } from "./PasskeyLoginButton";
|
||||
import { PasskeyEnrollment } from "./PasskeyEnrollment";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerLoginMethod({
|
||||
id: "passkey",
|
||||
titleKey: "title",
|
||||
placement: "inline",
|
||||
component: PasskeyLoginButton,
|
||||
enrollment: PasskeyEnrollment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
export type WebAuthnCredentialSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceType?: string | null;
|
||||
backedUp: boolean;
|
||||
transports: string[];
|
||||
userVerification: WebAuthnUserVerification;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
};
|
||||
|
||||
type OptionsResponse<T> = { options: T; challengeId: string };
|
||||
|
||||
export function isPasskeySupported(): boolean {
|
||||
try {
|
||||
return browserSupportsWebAuthn();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebAuthnApi(api: PluginApiClient) {
|
||||
return {
|
||||
/**
|
||||
* Runs the browser ceremony and returns the body for the login method's
|
||||
* verify step. Without a username, discoverable passkeys still work.
|
||||
*/
|
||||
async passkeyAssertion(username?: string) {
|
||||
const { data } = await api.post<
|
||||
OptionsResponse<PublicKeyCredentialRequestOptionsJSON>
|
||||
>("authenticate/options", username ? { username } : {});
|
||||
const response = await startAuthentication({
|
||||
optionsJSON: data.options,
|
||||
});
|
||||
return {
|
||||
challengeId: data.challengeId,
|
||||
response: response as AuthenticationResponseJSON,
|
||||
};
|
||||
},
|
||||
|
||||
async list(): Promise<WebAuthnCredentialSummary[]> {
|
||||
const { data } = await api.get<{
|
||||
credentials?: WebAuthnCredentialSummary[];
|
||||
}>("credentials");
|
||||
return data.credentials ?? [];
|
||||
},
|
||||
|
||||
async register(
|
||||
name: string,
|
||||
userVerification: WebAuthnUserVerification,
|
||||
): Promise<void> {
|
||||
const { data } = await api.post<
|
||||
OptionsResponse<PublicKeyCredentialCreationOptionsJSON>
|
||||
>("register/options", { userVerification });
|
||||
const response = await startRegistration({ optionsJSON: data.options });
|
||||
await api.post("register/verify", {
|
||||
challengeId: data.challengeId,
|
||||
name,
|
||||
response: response as RegistrationResponseJSON,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await api.delete(`credentials/${encodeURIComponent(id)}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user