feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+73 -10
View File
@@ -999,11 +999,11 @@ 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, 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.
In 2.9.0, 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:
@@ -1043,6 +1043,52 @@ Nothing else in core branches on those type names.
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
the data key for any identity that carries no password.
**C2** moved external logins out:
- `sso` registers the `oidc` login method: OIDC, GitHub and Google, one
button per enabled provider (the method's instances), plus the provider
configured through the `OIDC_*` environment variables as instance `0`. It
adopts `sso_providers` as `p_sso_providers`, ignoring any `ldap` rows left
in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
the provider), `/callback` (GET and POST), `/backchannel-logout` and
`/config`; provider management is `/plugin-api/sso/providers`, gated on
`sso.manage`, and renders as the custom `providers` field on the plugin's
admin settings page. Login state (nonce, PKCE verifier, return address)
lives in `ctx.kv` for ten minutes.
- The redirect URI is `<base>/plugin-api/sso/callback`. Providers that
existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their
identity provider was registered with, because the sso plugin's second
migration sets `legacy_callback` on every adopted row; the env provider
always does. Core answers that old URL with a 308 to the plugin's callback,
and the token exchange sends the same redirect URI the authorize request
did, so strict providers accept it. The admin page shows each provider's
URI and switches it to the new one once the admin has registered that.
`/users/oidc/backchannel-logout` is a 308 to the plugin as well.
- `ldap` registers the `ldap` form method, one form per directory, in its own
`p_ldap_providers`. `utils/crypto-migration/ldap-provider-migration.ts`
copies the 2.8 LDAP rows out of `sso_providers` (adopted or not) with their
ids and deletes them there. It runs from the loader whenever a plugin's
migrations applied something (`plugin-data-moves.ts`), so the rows are in
place before the ldap plugin first activates, at boot or when it is enabled
later. LDAP identities are stored under provider `ldap:<id>` so their ids
can never clash with SSO ones; `external-identity-migration.ts` writes them
that way and the LDAP migration renames any bare ones.
- Both return 2.8's `users.oidc_identifier` value in `legacy`, so pre-2.9
accounts are found and new ones keep the column filled. The sso plugin
passes its group role map as `roles` and the id token's `sub`/`sid` as
`logoutClaims`; `/backchannel-logout` ends those sessions with
`ctx.auth.revokeSessions`. LDAP counts failed binds with
`ctx.auth.loginRateLimit`, the limiter password login uses.
- Core keeps provisioning (the allowed-users matcher is
`auth/allowed-users.ts`), `oidc_auto_provision`, silent sign-in (it starts
the first redirect method), account linking and the trusted proxy rule:
every `external` method is refused while trusted proxy login is on, and
`/users/proxy-login` refuses while any external method lists an instance.
The 2.8 client routes `/users/oidc/authorize`, `/users/oidc-config` (307 to
`/plugin-api/sso/config`), `/users/sso-providers` and `/users/ldap/login`
live in `database/routes/auth-compat-routes.ts` and only forward to the
registered methods, so with the plugins off they answer 404.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1171,10 +1217,25 @@ or an external identity. Core then:
Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
`GET|POST :methodId/callback`, `POST :methodId/verify`,
`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`,
A redirect method's provider sends the browser back to
`/users/auth/<methodId>/callback`, or to a public route of the plugin's own
that calls `ctx.auth.completeRedirectLogin(methodId, req, res)`, which runs
the same callback and pipeline (**C2**, for a URL that has to stay stable in
the provider's configuration). The older routes (`/users/login`,
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
pipeline. `/users/totp/verify-login` stays for 2.8 clients: it answers with
pipeline.
**Identity extras (C2).** An external identity may also carry `roles`
(`{ desired, managed }`: core adds the desired roles and removes managed ones
the user no longer maps to), `logoutClaims` (`providerId`, `sub`, `sid`,
stored on the session for `ctx.auth.revokeSessions`), `legacy` (the 2.8
`oidc_identifier` and `sso_provider_id`, only for a plugin that took over a
2.8 method) and `rateLimitKey` (cleared on success; keys are kept apart per
plugin). Core copies only these documented fields, so a plugin cannot set
core-only ones. `ctx.auth.countLinkedUsers(provider)` answers how many users
sign in through a provider, for refusing to delete one still in use.
`ctx.http.baseUrl(req)` is the install's public URL (origin plus base path,
honouring `OIDC_FORCE_HTTPS`) for building callback URLs. `/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.
@@ -1562,6 +1623,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.http.baseUrl(req)` | none | **C2** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
@@ -1570,6 +1632,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
@@ -2361,8 +2424,8 @@ 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), homepage (B19), totp and webauthn (C1), which import
nothing from core. The others still reach core by relative
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
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
and rewrites to the compiled output path (`../../../backend/backend/...`, or
+33
View File
@@ -465,6 +465,27 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
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.
- **C2 (sso/ldap):** `users.is_oidc`, `oidc_identifier`, `sso_provider_id`
and the 2.8 per-user OIDC columns (`client_id`, `issuer_url`, ...), and
`sessions.sso_provider_id`, `oidc_sub`, `oidc_sid`, stay in core. Core
still reads `is_oidc`/`oidc_identifier` for account linking, `/users/me` and
the `$oidc.preferred_username` placeholder, and still writes them from an
identity's `legacy` field so a downgrade works. Rename or drop them once
nothing needs the 2.8 shape. Owner: 3.0.0.
- **C2 (sso/ldap):** `database/routes/auth-compat-routes.ts` keeps the 2.8
URLs (`/users/oidc/*`, `/users/oidc-config`, `/users/sso-providers`,
`/users/ldap/login`) and so names the `oidc`/`ldap` method ids and the sso
plugin's `/plugin-api/sso/` paths. Keep until identity providers have moved
to the new redirect URI and Termix-Mobile uses `/users/auth/*`. Owner:
3.0.0.
- **C2 (sso):** the `oidc_auto_provision` and `oidc_silent_login_default`
settings, their routes and admin toggles are generic (every external
method, every redirect method) but still carry OIDC in their names and
i18n. Rename with a settings migration if wanted. Owner: D0.
- **Unrelated, found in C2:** `node scripts/check-shell-plugin-ids.cjs`
fails on `src/ui/lib/host-to-ssh-host.ts` naming `"web-endpoint"`
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
C2. Owner: D0.
## Manual checks after 2.9.0
@@ -539,3 +560,15 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
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.
- SSO and LDAP: on a 2.8 database every SSO provider and LDAP directory is
there, in Settings > Plugins > Single sign-on and > LDAP; an existing
provider still signs in through its identity provider without changing
anything there (its redirect URI shows `/users/oidc/callback`); add the
new redirect URI at the provider, switch it over and sign in again; a new
provider, GitHub and Google; an existing SSO user and an existing LDAP
user land in their old accounts; the env-configured provider; admin group
and role map; back-channel logout from Keycloak ends the session; the
desktop app's system browser login and Termix-Mobile's login; silent
sign-in; a second factor after an external login with the admin setting
on and off; disable each plugin and its buttons go away and its URLs stop
signing anyone in, then enable it again.
+78
View File
@@ -313,6 +313,12 @@ export interface PluginHttp {
* on deactivate.
*/
router: <T = unknown>(options?: PluginRouterOptions) => T;
/**
* The public base URL a request came in on: origin plus the install's base
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
* BASE_PATH and OIDC_FORCE_HTTPS. For building absolute callback URLs.
*/
baseUrl: (req: unknown) => string;
}
/**
@@ -1102,6 +1108,33 @@ export type PluginVerifiedIdentity =
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
/**
* Provider groups mapped to Termix roles. Core adds the desired roles
* and removes managed roles the user no longer maps to, on every login.
* Roles outside `managed` are never touched.
*/
roles?: { desired: string[]; managed: string[] };
/**
* Stored on the session so `ctx.auth.revokeSessions` can find it later,
* for an identity provider's back-channel logout.
*/
logoutClaims?: {
providerId?: number | null;
sub?: string | null;
sid?: string | null;
};
/**
* The values a 2.8 install kept on the user row (`oidc_identifier`,
* `sso_provider_id`). Only for a plugin that took over a 2.8 login
* method: core finds pre-2.9 accounts by `identifier` and keeps writing
* both for new users so a downgrade still works.
*/
legacy?: { identifier: string; providerRowId?: number | null };
/**
* The key this attempt was counted under with
* `ctx.auth.loginRateLimit.recordFailure`. Cleared on success.
*/
rateLimitKey?: string;
};
/** Request shape a login method sees. Structural, like PluginRequestLike. */
@@ -1118,6 +1151,11 @@ export interface PluginLoginInstance {
id: string;
label: string;
enabled: boolean;
/**
* Provider type shown to 2.8 clients by the old `GET /users/sso-providers`
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
*/
type?: string;
}
export interface PluginLoginMethod {
@@ -1184,6 +1222,46 @@ export interface PluginAuth {
*/
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
/**
* Finishes a redirect login from one of this plugin's own public routes:
* runs the registered method's `callback`, then core finds or provisions
* the user, runs second factors and redirects the browser back with a
* session, exactly like `/users/auth/<methodId>/callback`. The method must
* be one this plugin registered.
*/
completeRedirectLogin: (
methodId: string,
req: unknown,
res: unknown,
) => Promise<void>;
/**
* Revokes sessions whose login carried matching `logoutClaims`, for a
* back-channel logout. Needs `sub` or `sid`; `providerId` narrows it.
* Returns how many sessions ended.
*/
revokeSessions: (match: {
providerId?: number | null;
sub?: string | null;
sid?: string | null;
}) => Promise<number>;
/**
* Core's login rate limiter, the one password login uses. Keys are kept
* apart per plugin. Put the key in the identity's `rateLimitKey` so a
* successful login clears it.
*/
loginRateLimit: {
isLocked: (
ip: string,
key: string,
) => Promise<{ locked: boolean; remainingTime?: number }>;
recordFailure: (ip: string, key: string) => Promise<void>;
};
/**
* How many users are linked to an external identity from `provider` (the
* `provider` of the identities this plugin returns). For refusing to
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
}
export interface PluginOpenIsolatedWindowRequest {
+114
View File
@@ -25,6 +25,13 @@ import type {
PluginWebSocketOptions,
} from "./backend.js";
/** The bits of an express Response the fake completeRedirectLogin uses. */
interface FakeResponse {
status: (code: number) => FakeResponse;
json: (body: unknown) => unknown;
redirect: (url: string) => unknown;
}
/** A ctx.ws registration as the test doubles record it. */
export interface FakeWsRoute {
path: string;
@@ -37,6 +44,8 @@ export interface FakeWsRoute {
}
import type {
PluginLoginMethod,
PluginLoginRequest,
PluginVerifiedIdentity,
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshConnectOptions,
@@ -123,6 +132,12 @@ export interface FakeContextOptions {
* proxy login on, password login off), with this message and a 409.
*/
refuseEnrollment?: string;
/** What ctx.http.baseUrl answers. Defaults to "https://termix.test". */
baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
}
export interface FakeAuthRegistrations {
@@ -136,6 +151,19 @@ export interface FakeAuthRegistrations {
string,
(userId: string, reference: string) => Promise<string>
>;
/** Identities handed to ctx.auth.completeRedirectLogin, in order. */
completedLogins: Array<{
methodId: string;
identity: PluginVerifiedIdentity;
}>;
/** Every ctx.auth.revokeSessions call, in order. */
revokedSessions: Array<{
providerId?: number | null;
sub?: string | null;
sid?: string | null;
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
}
export interface FakePluginContext {
@@ -347,6 +375,9 @@ export function createFakeContext(
secondFactors: [],
enrollments: new Set(),
secretResolvers: new Map(),
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -531,6 +562,7 @@ export function createFakeContext(
httpRouters.push(routerOptions);
return (options.router ? options.router() : undefined) as never;
},
baseUrl: () => options.baseUrl ?? "https://termix.test",
},
ws: {
@@ -801,6 +833,56 @@ export function createFakeContext(
removeEnrollment: async (userId, factorId) => {
auth.enrollments.delete(`${userId}:${factorId}`);
},
// Stands in for core's pipeline: answers with the identity as JSON, or
// redirects back with the error the way core does.
completeRedirectLogin: async (methodId, req, res) => {
const response = res as FakeResponse;
const method = auth.loginMethods.find(
(candidate) => candidate.id === methodId,
);
if (!method?.callback) {
throw new Error(`No registered redirect method "${methodId}"`);
}
try {
const identity = await method.callback(req as PluginLoginRequest);
auth.completedLogins.push({ methodId, identity });
response.status(200).json({ completed: methodId, identity });
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
const message = (error as Error).message;
if (returnTo) {
const url = new URL(returnTo);
url.searchParams.set(
"error",
(error as { code?: string }).code ?? message,
);
response.redirect(url.toString());
return;
}
response
.status((error as { status?: number }).status ?? 500)
.json({ error: message });
}
},
revokeSessions: async (match) => {
if (!match.sub && !match.sid) return 0;
auth.revokedSessions.push(match);
return 1;
},
loginRateLimit: {
isLocked: async (ip, key) => {
const failures = auth.loginFailures.get(`${ip}|${key}`) ?? 0;
return failures >= (options.loginAttemptLimit ?? 5)
? { locked: true, remainingTime: 60 }
: { locked: false };
},
recordFailure: async (ip, key) => {
const id = `${ip}|${key}`;
auth.loginFailures.set(id, (auth.loginFailures.get(id) ?? 0) + 1);
},
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
},
desktop: {
@@ -943,6 +1025,12 @@ export interface MockContextOptions {
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */
refuseEnrollment?: string;
/** See FakeContextOptions. */
baseUrl?: string;
/** See FakeContextOptions. */
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
}
export interface MockPluginContext extends FakePluginContext {
@@ -986,6 +1074,9 @@ export function createMockCtx(
notificationChannels: options.notificationChannels,
fetch: options.fetch,
refuseEnrollment: options.refuseEnrollment,
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1078,6 +1169,7 @@ export function createMockCtx(
require("network:serve");
return ctx.http.router(routerOptions);
},
baseUrl: (req) => ctx.http.baseUrl(req),
},
ws: {
@@ -1233,6 +1325,28 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.removeEnrollment(userId, factorId);
},
completeRedirectLogin: async (methodId, req, res) => {
require("auth:provide");
return ctx.auth.completeRedirectLogin(methodId, req, res);
},
revokeSessions: async (match) => {
require("auth:provide");
return ctx.auth.revokeSessions(match);
},
loginRateLimit: {
isLocked: async (ip, key) => {
require("auth:provide");
return ctx.auth.loginRateLimit.isLocked(ip, key);
},
recordFailure: async (ip, key) => {
require("auth:provide");
return ctx.auth.loginRateLimit.recordFailure(ip, key);
},
},
countLinkedUsers: async (provider) => {
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
},
desktop: {