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

+75 -10
View File
@@ -454,7 +454,7 @@ boundary and Suspense.
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
@@ -575,7 +575,9 @@ popover, scroll-area, select, select2, separator, skeleton, switch, textarea,
tooltip, sheet, section-card, metric-card, charts, the card grid,
`ConnectionScreen` and the connection status helpers,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`TOTPDialog` (its `MFAPromptMode` type lives in
`@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`),
`SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
@@ -980,7 +982,11 @@ and activates it again that way.
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
the shell's real restore rules, `openedTabs()` lists what it opened, and
`renderOpenedTab(i)` renders one the way the shell would, placeholder included. `renderWithApp` activates the plugin against core's real registries,
`renderOpenedTab(i)` renders one the way the shell would, placeholder included.
**C1** added `registered.loginMethods()` / `.secondFactors()` and
`renderLoginMethod`, `renderSecondFactor` and `renderEnrollment`, and the
backend doubles take `refuseEnrollment` to make `recordEnrollment` refuse the
way core's policy does. `renderWithApp` activates the plugin against core's real registries,
records what it registered and the shell calls it made, renders any tab,
panel, card, host editor section, settings component or slot, and
`deactivate()` disposes it all. Every bundled plugin has a
@@ -993,13 +999,50 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, OIDC (and GitHub, Google), LDAP, passkeys, TOTP, OPKSSH, Step-CA,
Vault, Tailscale and Warpgate still live in core. They register through the
same interfaces from `src/backend/auth/legacy-providers.ts` and
In 2.9.0, OIDC (and GitHub, Google), LDAP, OPKSSH, Step-CA, Vault, Tailscale
and Warpgate still live in core. They register through the same interfaces
from `src/backend/auth/legacy-providers.ts` and
`src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each
one into its plugin by moving its block out of those two files; D1 deletes them.
Nothing else in core branches on those type names.
**C1** moved TOTP and passkeys out:
- `totp` registers the `totp` second factor. It owns `p_totp_enrollments`
(one row per user: the active secret, an unfinished setup's secret and the
backup codes, all sealed with `ctx.secrets.seal`), so `verify` reads them
during login with no acting user. Its routes at `/plugin-api/totp/`
(`status`, `setup`, `enable`, `disable`, `backup-codes`) re-authenticate
with a TOTP or backup code, never the account password, which a plugin
cannot see. `enable` calls `ctx.auth.recordEnrollment` before it turns
anything on, so core's policy can refuse it cleanly.
- `webauthn` registers the `passkey` login method (inline on the login
screen) and adopts `webauthn_credentials`. A passkey is a login method only,
as in 2.8: an assertion whose authenticator verified the user returns
`mfaSatisfied`, which skips core's second-factor step; without it the user
still gets their factors. The relying party id and origin come from the
request exactly as before (no core setting holds them); the relying party
name is `ctx.settings.readCore("app_name")`.
`POST /plugin-api/webauthn/authenticate/options` is public; the assertion
goes to core's `/users/auth/passkey/verify`.
- `src/backend/utils/crypto-migration/totp-migration.ts` takes a 2.8
database across: every `core`/`totp` enrolment row (A8) becomes
`totp`/`totp`, every user with `users.totp_enabled` gets one, and once
the plugin's table exists each secret and its backup codes are decrypted
with the user's data key, sealed into it, and the `users` columns cleared.
The rows move even while the plugin is off, so those users fail closed. The
`users` TOTP columns left `schema.ts`, and the drizzle migration that would
drop them is a no-op, like an adopted table.
- Core keeps no TOTP knowledge. `totp_enabled` in the login body and
`/users/me` is still sent under that name for 2.8 clients (Termix-Mobile,
older remote-sync peers) but means "has any second factor"; the admin user
list sends `second_factor_enabled`. Trusted proxy login, "disable password
login" and a password reset that wipes data go through
`user_second_factors` and `resetUserSecondFactors`.
`/users/admin/totp/disable` is gone; the generic admin reset covers it.
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
the data key for any identity that carries no password.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1130,8 +1173,18 @@ Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
A redirect method's provider must send the browser back to
`/users/auth/<methodId>/callback`. The older routes (`/users/login`,
`/users/totp/verify-login`, `/users/oidc/*`, `/users/ldap/login`,
`/users/webauthn/authenticate/verify`) are thin wrappers over the same pipeline.
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
pipeline. `/users/totp/verify-login` stays for 2.8 clients: it answers with
the factor named in `factor`, or the user's first required factor, and core
never names one itself.
**Enrolment policy (C1).** `ctx.auth.recordEnrollment` refuses with a
`LoginMethodError` (409) while trusted proxy login is on or the password
login setting is off, the rules the 2.8 TOTP route had, now applied to every
factor. When it records one it also revokes the user's other sessions and
forgets their trusted devices, so nothing that skipped the factor before
keeps skipping it. The session to keep is the request's: the HTTP middleware
puts the session id next to the actor (`getActorSessionId`).
An admin clears a user's factors, including orphaned ones, with
`DELETE /users/admin/:userId/second-factors` (audited as
@@ -1152,7 +1205,11 @@ lists (props: `instances`, `rememberMe`, `submit`, `startRedirect`,
`complete`); a redirect method with no UI gets one button per instance.
`app.registerSecondFactorUI` draws the challenge (props: `verify`,
`challenge`, `cancel`) and, with `enrollment`, a section in Settings >
Security. At activation before sign-in `app.api` calls fail with 401, so do
Security. **C1** gave `app.registerLoginMethod` the same `enrollment` (a
passkey is registered there) and `placement: "inline"`, which also draws
the method under the password form; the login screen no longer knows the
passkey method by name. A server that sends a second-factor step without
naming its factors gets every factor UI registered on the screen. At activation before sign-in `app.api` calls fail with 401, so do
nothing there but register.
### 13. Where plugins live
@@ -1497,6 +1554,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
@@ -1756,6 +1814,13 @@ notification)` delivers to the ones among them that are theirs and
`ctx.db.persist`, because a plugin reads its keys on every request that
uses them. The AI assistant keeps each provider's key under
`provider:<id>` instead of in its providers table.
- **C1** added `ctx.secrets.seal(value)` and `.unseal(sealed)`: the same
installation-key encryption, returned to the plugin instead of stored, for
a secret that lives in the plugin's own table and has to be read without
an acting user (a second factor checked during login). Both need
`secrets:own`; neither needs an actor or writes an audit line. `unseal`
answers null for anything `seal` did not write. The totp plugin keeps its
secrets and backup codes this way.
- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body;
`timeoutMs` still only covers the wait for the headers, so a long model
stream is not cut.
@@ -2296,7 +2361,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18) and homepage (B19), which import
(B17), ai (B18), homepage (B19), totp and webauthn (C1), which import
nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
+19
View File
@@ -453,6 +453,18 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
by stashing B20's changes and re-running). Owner: D0.
- **C1 (totp):** `totp-migration.ts` moves a user's TOTP secret only when
their data key opens at boot. A 2.8 user whose key is still a pre-2.5.1
password wrap keeps their enrolment row (fail closed) but has no secret in
`p_totp_enrollments` until a boot after their next password login migrates
the key, so that one login fails at the TOTP step. Likewise, enabling the
totp plugin for the first time without a restart does not move secrets
until the next boot. Decide whether either needs handling (a migration run
on plugin activation, or an admin reset message). Owner: D0.
- **C1 (totp/webauthn):** the `users` columns `totp_secret`, `totp_enabled`
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
migration has run everywhere. Owner: 3.0.0.
## Manual checks after 2.9.0
@@ -520,3 +532,10 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
plugin disabled; the AI assistant lists homepage items and says
unavailable with homepage off; disable homepage in admin (the tab, its
dashboard cards and the toggle disappear) then enable it again.
- TOTP and passkeys: on a 2.8 database a TOTP user is still asked for a
code (and a backup code works once); set up TOTP from Settings > Security,
add a second device, regenerate backup codes, disable it; enabling signs
out other sessions; "remember me" skips it next time; disable the totp
plugin and a TOTP user's login is refused until an admin resets their
factors; register a passkey, sign in with it with and without a PIN (no
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
+8
View File
@@ -232,6 +232,14 @@ export interface PluginSecrets {
set: (key: string, value: string | null) => Promise<void>;
/** Removes the acting user's secret. Needs secrets:own. Audited. */
delete: (key: string) => Promise<void>;
/**
* Encrypts a value with the installation key, for a plugin that keeps a
* secret in its own table and has to read it without an acting user (a
* second factor checked during login). Needs secrets:own. Not audited.
*/
seal: (value: string) => Promise<string>;
/** Reverses seal(). Null when the value cannot be decrypted. */
unseal: (sealed: string) => Promise<string | null>;
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
+14
View File
@@ -466,8 +466,22 @@ export interface LoginMethodContribution {
titleKey: string;
icon?: IconComponent;
component: ComponentType<LoginMethodUIProps>;
/**
* "inline" also draws the method under the password form, for a local
* method people use instead of a password (a passkey). Otherwise it only
* shows in the list of other sign-in methods.
*/
placement?: "inline";
/** Enrolment, shown in Settings > Security (registering a passkey). */
enrollment?: ComponentType<Record<string, unknown>>;
}
/**
* How an SSH keyboard-interactive prompt is shown by TOTPDialog: a code field
* (the default), a password, a menu choice, or a push approval.
*/
export type MFAPromptMode = "totp" | "password" | "menu" | "push";
/** What the second-factor step hands a factor's UI. */
export interface SecondFactorUIProps {
factorId: string;
+40 -1
View File
@@ -8,7 +8,7 @@
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
*/
import { PluginCapabilityError } from "./backend.js";
import { LoginMethodError, PluginCapabilityError } from "./backend.js";
import type {
PluginContext,
PluginDisposables,
@@ -118,6 +118,11 @@ export interface FakeContextOptions {
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/**
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
* proxy login on, password login off), with this message and a 409.
*/
refuseEnrollment?: string;
}
export interface FakeAuthRegistrations {
@@ -508,6 +513,12 @@ export function createFakeContext(
delete: async (key) => {
secretStore.delete(`${actor ?? ""}:${key}`);
},
seal: async (value) =>
`sealed:${Buffer.from(value, "utf8").toString("base64")}`,
unseal: async (sealed) =>
sealed?.startsWith("sealed:")
? Buffer.from(sealed.slice(7), "base64").toString("utf8")
: null,
offer: () => {},
withdraw: () => false,
getShared: async () => null,
@@ -782,6 +793,9 @@ export function createFakeContext(
auth.secondFactors.push(factor);
},
recordEnrollment: async (userId, factorId) => {
if (options.refuseEnrollment) {
throw new LoginMethodError(options.refuseEnrollment, 409);
}
auth.enrollments.add(`${userId}:${factorId}`);
},
removeEnrollment: async (userId, factorId) => {
@@ -927,6 +941,8 @@ export interface MockContextOptions {
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */
refuseEnrollment?: string;
}
export interface MockPluginContext extends FakePluginContext {
@@ -969,6 +985,7 @@ export function createMockCtx(
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
refuseEnrollment: options.refuseEnrollment,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1271,6 +1288,14 @@ export function createMockCtx(
require("secrets:own");
return ctx.secrets.delete(key);
},
seal: async (value) => {
require("secrets:own");
return ctx.secrets.seal(value);
},
unseal: async (sealed) => {
require("secrets:own");
return ctx.secrets.unseal(sealed);
},
},
capabilities: {
@@ -1485,6 +1510,8 @@ export interface RenderedPluginApp {
settingsComponents: () => string[];
slot: (slotId: string) => string[];
actions: () => string[];
loginMethods: () => string[];
secondFactors: () => string[];
};
/** Shell calls made by the plugin's code, applyLayout included. */
shellCalls: ShellCall[];
@@ -1510,6 +1537,18 @@ export interface RenderedPluginApp {
componentId: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a login method's UI with login screen props, overridable. */
renderLoginMethod: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a second factor's challenge with overridable props. */
renderSecondFactor: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders the Settings > Security section a factor or method brought. */
renderEnrollment: (id: string) => HTMLElement;
/** Renders a slot the way its owner would. */
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
/** Runs deactivate and every disposer, as disabling the plugin does. */