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

+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: {