mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -675,17 +675,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
// Expired open tabs are dropped when the tab list is read, against the
|
||||
@@ -1879,40 +1868,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||
if (!migrationDone) {
|
||||
const providerCount = (
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c;
|
||||
if (providerCount === 0) {
|
||||
const legacyConfig = getRawSettingValue("oidc_config");
|
||||
if (legacyConfig) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||
)
|
||||
.run("OIDC", legacyConfig);
|
||||
databaseLogger.info(
|
||||
"Migrated legacy oidc_config into sso_providers table",
|
||||
{
|
||||
operation: "sso_migration_v1",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
setRawSettingValue("sso_migration_v1", "true");
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||
operation: "sso_migration_v1",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
// --- alerts begin ---
|
||||
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
||||
// here, never declared in schema.ts, so they never existed on Postgres or
|
||||
|
||||
@@ -49,21 +49,6 @@ export const settings = mysqlTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = mysqlTable("sso_providers", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: int("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const sessions = mysqlTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -50,21 +50,6 @@ export const settings = pgTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = pgTable("sso_providers", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -40,21 +40,6 @@ export const settings = sqliteTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = sqliteTable("sso_providers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -37,7 +37,6 @@ import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-re
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -360,13 +359,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
|
||||
return new SsoProviderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
|
||||
return new TermixIdentityCaRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { ssoProviders, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
|
||||
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
||||
export type SsoProviderUpdate = Partial<
|
||||
Pick<
|
||||
NewSsoProviderRecord,
|
||||
"name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type PublicSsoProviderRecord = Pick<
|
||||
SsoProviderRecord,
|
||||
"id" | "name" | "type" | "displayOrder"
|
||||
>;
|
||||
|
||||
export class SsoProviderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listEnabledPublic(): Promise<PublicSsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: ssoProviders.id,
|
||||
name: ssoProviders.name,
|
||||
type: ssoProviders.type,
|
||||
displayOrder: ssoProviders.displayOrder,
|
||||
})
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listEnabled(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listAll(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findFirstEnabledOidcLike(): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
|
||||
return (
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.type === "oidc" || row.type === "github" || row.type === "google",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
||||
const rows = await insertReturning(this.context, ssoProviders, provider);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
update: SsoProviderUpdate,
|
||||
): Promise<SsoProviderRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
ssoProviders,
|
||||
update,
|
||||
eq(ssoProviders.id, id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async countUsersByProviderId(providerId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.ssoProviderId, providerId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ export class UserAuthRepository {
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async countUsersForProvider(providerId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userExternalIdentities.userId })
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.providerId, providerId));
|
||||
return new Set(rows.map((row) => row.userId)).size;
|
||||
}
|
||||
|
||||
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle.select().from(userExternalIdentities);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The 2.8 login URLs that something outside Termix still depends on:
|
||||
* identity providers configured with the old callback and back-channel
|
||||
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
|
||||
* forwards to the login method or plugin route that replaced it, so none of
|
||||
* them can sign anyone in on its own.
|
||||
*/
|
||||
|
||||
import type { Request, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
import {
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} from "./auth-routes.js";
|
||||
|
||||
/**
|
||||
* A path on this install, keeping the query string. Relative, so the browser
|
||||
* stays on the scheme and host it used, whatever a proxy told us.
|
||||
*/
|
||||
function pluginUrl(req: Request, path: string): string {
|
||||
const query = req.originalUrl.indexOf("?");
|
||||
return `${getRequestBasePath(req)}${path}${
|
||||
query >= 0 ? req.originalUrl.slice(query) : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function registerAuthCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* get:
|
||||
* summary: Get the default SSO provider's public configuration (2.8 route)
|
||||
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.get("/oidc-config", (req, res) => {
|
||||
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/authorize:
|
||||
* get:
|
||||
* summary: Start an SSO login (2.8 route)
|
||||
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: providerId
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: rememberMe
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The authorization URL in auth_url.
|
||||
* 404:
|
||||
* description: SSO is not available.
|
||||
*/
|
||||
router.get("/oidc/authorize", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
"oidc",
|
||||
typeof req.query.providerId === "string" && req.query.providerId
|
||||
? req.query.providerId
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/callback:
|
||||
* get:
|
||||
* summary: SSO callback (2.8 URL)
|
||||
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
* post:
|
||||
* summary: SSO callback, form post (2.8 URL)
|
||||
* description: Same as the GET form.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
*/
|
||||
router.all("/oidc/callback", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/backchannel-logout:
|
||||
* post:
|
||||
* summary: OIDC back-channel logout (2.8 URL)
|
||||
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.post("/oidc/backchannel-logout", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List sign-in providers (2.8 route)
|
||||
* description: Kept for 2.8 clients. Every enabled instance of every external login method, in the 2.8 shape. /users/auth/methods is the current route.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Providers.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
res.json(await listLegacySsoProviders());
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list SSO providers", error);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login (2.8 route)
|
||||
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 404:
|
||||
* description: LDAP is not available.
|
||||
*/
|
||||
router.post("/ldap/login", (req, res) =>
|
||||
verifyFormLogin(
|
||||
req,
|
||||
res,
|
||||
"ldap",
|
||||
req.body?.providerId != null ? String(req.body.providerId) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
verifySecondFactorAndRespond,
|
||||
evaluateSecondFactors,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import { getLoginMethod, listLoginMethods } from "../../auth/registry.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
listLoginMethods,
|
||||
type LoginMethod,
|
||||
} from "../../auth/registry.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
@@ -30,6 +35,59 @@ export interface PublicLoginMethod {
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
async function enabledInstances(method: LoginMethod) {
|
||||
return (await method.describe!()).filter((instance) => instance.enabled);
|
||||
}
|
||||
|
||||
/** External methods with at least one enabled instance. */
|
||||
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const found: LoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
if (!method.external) continue;
|
||||
if (!method.describe) {
|
||||
found.push(method);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await enabledInstances(method)).length > 0) found.push(method);
|
||||
} catch {
|
||||
// A method that cannot describe itself signs nobody in.
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
|
||||
export async function listLegacySsoProviders(): Promise<
|
||||
Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}>
|
||||
> {
|
||||
const providers: Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}> = [];
|
||||
for (const method of await listExternalLoginMethods()) {
|
||||
if (!method.describe) continue;
|
||||
for (const instance of await enabledInstances(method)) {
|
||||
const numeric = Number(instance.id);
|
||||
providers.push({
|
||||
id: Number.isInteger(numeric) ? numeric : instance.id,
|
||||
name: instance.label,
|
||||
type: instance.type ?? method.id,
|
||||
displayOrder: providers.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/** What the login screen may know: ids, labels and enabled instances. */
|
||||
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
@@ -68,16 +126,111 @@ function instanceParam(req: Request): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response) {
|
||||
/**
|
||||
* Trusted proxy login and external methods don't mix: the proxy already
|
||||
* decided who the user is.
|
||||
*/
|
||||
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
|
||||
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
|
||||
res.status(409).json({
|
||||
error:
|
||||
"External login is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response, id?: string) {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(String(req.params.methodId));
|
||||
const method = getLoginMethod(id ?? String(req.params.methodId));
|
||||
if (!method) {
|
||||
res.status(404).json({ error: "Unknown login method" });
|
||||
return null;
|
||||
}
|
||||
if (refusedByTrustedProxy(method, res)) return null;
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect method's callback, for core's route and for a plugin's own
|
||||
* public route through ctx.auth.completeRedirectLogin.
|
||||
*/
|
||||
export async function handleRedirectCallback(
|
||||
method: LoginMethod,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (refusedByTrustedProxy(method, res)) return;
|
||||
if (!method.callback) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
sendLoginError(res, error);
|
||||
return;
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts a redirect method, answering with its URL. */
|
||||
export async function startRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await method.start(req as never, instanceId);
|
||||
// auth_url is the name 2.8 clients read.
|
||||
res.json({ ...result, auth_url: result.redirectUrl });
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a form method and answers with a session or a second-factor step. */
|
||||
export async function verifyFormLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
res.status(400).json({ error: "Not a form login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceId);
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
@@ -243,40 +396,19 @@ export function registerAuthRoutes(router: Router): void {
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.get("/auth/:methodId/start", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
try {
|
||||
res.json(await method.start(req as never, instanceParam(req)));
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
router.get("/auth/:methodId/start", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
String(req.params.methodId),
|
||||
instanceParam(req),
|
||||
),
|
||||
);
|
||||
|
||||
const callback = async (req: Request, res: Response) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (!method.callback) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
return sendLoginError(res, error);
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
await handleRedirectCallback(method, req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -336,23 +468,7 @@ export function registerAuthRoutes(router: Router): void {
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.post("/auth/:methodId/verify", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
return res.status(400).json({ error: "Not a form login method" });
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceParam(req));
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
router.post("/auth/:methodId/verify", (req, res) =>
|
||||
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
|
||||
export function registerLDAPAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login
|
||||
* description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerId:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 403:
|
||||
* description: User not allowed or registration disabled.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
* 429:
|
||||
* description: Too many login attempts.
|
||||
*/
|
||||
router.post("/ldap/login", async (req, res) => {
|
||||
try {
|
||||
const identity = await verifyLdapLogin({ body: req.body, ip: req.ip });
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
OIDCProviderConfig,
|
||||
} from "../../../types/index.js";
|
||||
import type { Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} from "./user-oidc-utils.js";
|
||||
import {
|
||||
decryptSsoConfigSecrets,
|
||||
encryptSsoConfigSecrets,
|
||||
} from "../../utils/system-secret-crypto.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
function isOidcLike(type: SSOProviderType): boolean {
|
||||
return type === "oidc" || type === "github" || type === "google";
|
||||
}
|
||||
|
||||
export function isValidOidcIssuer(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
!/\/userinfo\/?$/i.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
/**
|
||||
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
|
||||
* userId and the values must be readable during login, before anyone is
|
||||
* authenticated. They are encrypted with the system key rather than a user DEK.
|
||||
* Values written by the previous base64 scheme still decode, and are upgraded
|
||||
* the next time the provider is saved.
|
||||
*/
|
||||
async function decryptProviderConfig(
|
||||
configJson: string,
|
||||
_userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(configJson);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return decryptSsoConfigSecrets(config);
|
||||
}
|
||||
|
||||
async function encryptProviderConfig(
|
||||
config: Record<string, unknown>,
|
||||
_userId: string,
|
||||
_providerId: string,
|
||||
): Promise<string> {
|
||||
return JSON.stringify(await encryptSsoConfigSecrets(config));
|
||||
}
|
||||
|
||||
function applyProviderDefaults(
|
||||
type: SSOProviderType,
|
||||
config: Partial<OIDCProviderConfig>,
|
||||
): Partial<OIDCProviderConfig> {
|
||||
if (type === "github") {
|
||||
return {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://github.com",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
if (type === "google") {
|
||||
return {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Enabled providers as the login screen sees them: no secrets. */
|
||||
export async function listPublicSsoProviders(): Promise<
|
||||
Array<{ id: number; name: string; type: string; displayOrder: number }>
|
||||
> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }];
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// With no provider rows, synthesize one from env vars so SSO login stays
|
||||
// available when it is configured purely through the environment.
|
||||
if (providers.length === 0 && envConfig) {
|
||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function registerSSOProviderRoutes(router: Router): void {
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List enabled SSO providers (public)
|
||||
* description: Returns public info for all enabled SSO providers for the login page.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of public SSO provider objects.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
res.json(await listPublicSsoProviders());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/admin:
|
||||
* get:
|
||||
* summary: List all SSO providers (admin)
|
||||
* description: Returns full SSO provider list with decrypted configs for the admin panel.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of full SSO provider objects with decrypted config.
|
||||
*/
|
||||
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await createCurrentSsoProviderRepository().listAll();
|
||||
|
||||
const result = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
...row,
|
||||
config: await decryptProviderConfig(row.config, userId),
|
||||
})),
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers (admin)", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* post:
|
||||
* summary: Create SSO provider
|
||||
* description: Creates a new SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Provider created.
|
||||
* 400:
|
||||
* description: Validation error.
|
||||
*/
|
||||
router.post("/sso-providers", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config: rawConfig = {},
|
||||
} = req.body as {
|
||||
name: string;
|
||||
type: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "Provider name is required" });
|
||||
}
|
||||
const validTypes: SSOProviderType[] = [
|
||||
"oidc",
|
||||
"ldap",
|
||||
"github",
|
||||
"google",
|
||||
];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid provider type" });
|
||||
}
|
||||
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
const configWithDefaults =
|
||||
type === "github" || type === "google"
|
||||
? applyProviderDefaults(
|
||||
type,
|
||||
rawConfig as Partial<OIDCProviderConfig>,
|
||||
)
|
||||
: rawConfig;
|
||||
|
||||
if (type === "oidc" || type === "github" || type === "google") {
|
||||
const c = configWithDefaults as Partial<OIDCProviderConfig>;
|
||||
const missing = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
].filter((f) => !c[f as keyof OIDCProviderConfig]);
|
||||
if (missing.length > 0 && type === "oidc") {
|
||||
return res.status(400).json({
|
||||
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
if (
|
||||
(type === "github" || type === "google") &&
|
||||
(!c.client_id || !c.client_secret)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Client ID and Client Secret are required" });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "ldap") {
|
||||
const c = configWithDefaults as Record<string, unknown>;
|
||||
const missing = [
|
||||
"host",
|
||||
"port",
|
||||
"bindDN",
|
||||
"bindPassword",
|
||||
"userSearchBase",
|
||||
"userSearchFilter",
|
||||
"usernameAttribute",
|
||||
].filter((f) => !c[f]);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tempId = `new-${Date.now()}`;
|
||||
const encryptedConfig = await encryptProviderConfig(
|
||||
configWithDefaults as Record<string, unknown>,
|
||||
userId,
|
||||
tempId,
|
||||
);
|
||||
|
||||
const inserted = await createCurrentSsoProviderRepository().create({
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: encryptedConfig,
|
||||
});
|
||||
|
||||
authLogger.info("SSO provider created", {
|
||||
operation: "sso_provider_create",
|
||||
userId,
|
||||
type,
|
||||
providerId: inserted.id,
|
||||
});
|
||||
res.status(201).json({
|
||||
...inserted,
|
||||
config: await decryptProviderConfig(inserted.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to create SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* put:
|
||||
* summary: Update SSO provider
|
||||
* description: Updates an existing SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider updated.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const providerRepository = createCurrentSsoProviderRepository();
|
||||
const existing = await providerRepository.findById(providerId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: rawConfig,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
type?: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const effectiveType = type ?? (existing.type as SSOProviderType);
|
||||
const effectiveEnabled = enabled ?? existing.enabled;
|
||||
if (
|
||||
isTrustedProxyAuthEnabled() &&
|
||||
effectiveEnabled &&
|
||||
isOidcLike(effectiveType)
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
let encryptedConfig = existing.config;
|
||||
if (rawConfig !== undefined) {
|
||||
const existingDecrypted = await decryptProviderConfig(
|
||||
existing.config,
|
||||
userId,
|
||||
);
|
||||
const mergedConfig = {
|
||||
...JSON.parse(
|
||||
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
|
||||
),
|
||||
...rawConfig,
|
||||
};
|
||||
if (
|
||||
isOidcLike(effectiveType) &&
|
||||
mergedConfig.issuer_url &&
|
||||
!isValidOidcIssuer(mergedConfig.issuer_url)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
encryptedConfig = await encryptProviderConfig(
|
||||
mergedConfig,
|
||||
userId,
|
||||
String(providerId),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await providerRepository.update(providerId, {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(displayOrder !== undefined ? { displayOrder } : {}),
|
||||
config: encryptedConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
authLogger.info("SSO provider updated", {
|
||||
operation: "sso_provider_update",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({
|
||||
...updated,
|
||||
config: await decryptProviderConfig(updated.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to update SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete SSO provider
|
||||
* description: Deletes an SSO provider. Blocked if users are associated.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider deleted.
|
||||
* 409:
|
||||
* description: Users are associated with this provider.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const providerRepository = createCurrentSsoProviderRepository();
|
||||
const existing = await providerRepository.findById(providerId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const associatedUserCount =
|
||||
await providerRepository.countUsersByProviderId(providerId);
|
||||
if (associatedUserCount > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
|
||||
});
|
||||
}
|
||||
|
||||
await providerRepository.delete(providerId);
|
||||
authLogger.info("SSO provider deleted", {
|
||||
operation: "sso_provider_delete",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({ message: "SSO provider deleted" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to delete SSO provider" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { decryptProviderConfig, encryptProviderConfig };
|
||||
@@ -1,648 +0,0 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
|
||||
import { Agent } from "undici";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
/**
|
||||
* Raised when a token cannot be verified because it is not a compact JWS,
|
||||
* as opposed to a signature or claim check that actually failed.
|
||||
*/
|
||||
export class OIDCTokenFormatError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OIDCTokenFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIssuer(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/\.well-known\/openid-configuration$/, "");
|
||||
}
|
||||
|
||||
export type OIDCConfig = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim?: string;
|
||||
role_map?: string;
|
||||
ca_cert?: string;
|
||||
};
|
||||
|
||||
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
|
||||
if (!caCert || !caCert.trim()) return {};
|
||||
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders why a fetch failed in a form an administrator can act on.
|
||||
*
|
||||
* undici reports every transport failure as the same "fetch failed" message
|
||||
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
|
||||
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
|
||||
* the outer message says nothing at all.
|
||||
*/
|
||||
export function describeFetchFailure(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause instanceof Error) {
|
||||
const code = (cause as { code?: unknown }).code;
|
||||
return code
|
||||
? `${error.message}: ${cause.message} (${code})`
|
||||
: `${error.message}: ${cause.message}`;
|
||||
}
|
||||
return cause ? `${error.message}: ${String(cause)}` : error.message;
|
||||
}
|
||||
|
||||
export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer_url = process.env.OIDC_ISSUER_URL;
|
||||
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
|
||||
const token_url = process.env.OIDC_TOKEN_URL;
|
||||
|
||||
if (
|
||||
!client_id ||
|
||||
!client_secret ||
|
||||
!issuer_url ||
|
||||
!authorization_url ||
|
||||
!token_url
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: process.env.OIDC_USERINFO_URL || "",
|
||||
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
|
||||
name_path: process.env.OIDC_NAME_PATH || "name",
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
group_claim: process.env.OIDC_GROUP_CLAIM || "",
|
||||
role_map: process.env.OIDC_ROLE_MAP || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||
* whether they emit bare names (`devops-interns`) or full paths
|
||||
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
|
||||
* are stripped and case is ignored.
|
||||
*/
|
||||
function normalizeGroupName(group: string): string {
|
||||
return group.trim().replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
|
||||
*
|
||||
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
|
||||
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
|
||||
* normalized via {@link normalizeGroupName}; role names are passed through
|
||||
* verbatim because they must match `roles.name` exactly.
|
||||
*
|
||||
* Malformed entries are skipped rather than throwing — a typo in one pair must
|
||||
* not lock every user out of login.
|
||||
*/
|
||||
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!raw || !raw.trim()) return map;
|
||||
|
||||
for (const entry of raw.split(/[\n,]/)) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// rsplit on the last ":" so group names containing a colon still work.
|
||||
const separator = trimmed.lastIndexOf(":");
|
||||
if (separator <= 0 || separator === trimmed.length - 1) continue;
|
||||
|
||||
const group = normalizeGroupName(trimmed.slice(0, separator));
|
||||
const roleName = trimmed.slice(separator + 1).trim();
|
||||
if (!group || !roleName) continue;
|
||||
|
||||
map.set(group, roleName);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which mapped roles a user should hold, given their provider groups.
|
||||
*
|
||||
* Returns both the `desired` roles (mapped groups the user is actually in) and
|
||||
* the full set of `managed` roles (every role named in the map). Callers must
|
||||
* only ever add/remove roles within `managed` — roles assigned by hand in
|
||||
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
|
||||
* deliberately left alone.
|
||||
*/
|
||||
export function resolveOidcMappedRoles(
|
||||
groups: string[],
|
||||
roleMap: Map<string, string>,
|
||||
): { desired: Set<string>; managed: Set<string> } {
|
||||
const managed = new Set(roleMap.values());
|
||||
const desired = new Set<string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const roleName = roleMap.get(normalizeGroupName(group));
|
||||
if (roleName) desired.add(roleName);
|
||||
}
|
||||
|
||||
return { desired, managed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the list of group/role names from an OIDC userInfo payload.
|
||||
*
|
||||
* When `groupClaim` is set, that claim is read first (useful for providers like
|
||||
* Zitadel that nest roles under a custom path such as
|
||||
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
|
||||
* and `group` claims are tried. Values may be an array, a comma-separated
|
||||
* string, or an object whose keys are the group names.
|
||||
*/
|
||||
export function extractOidcGroups(
|
||||
userInfo: Record<string, unknown>,
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
let raw: unknown;
|
||||
if (groupClaim && groupClaim.trim()) {
|
||||
raw = userInfo[groupClaim.trim()];
|
||||
}
|
||||
if (raw === undefined || raw === null) {
|
||||
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(String);
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.keys(raw as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC providers may return group claims in the ID token, userinfo response,
|
||||
* or both. Keep every verified source authoritative instead of letting a
|
||||
* sparse userinfo payload overwrite claims from the ID token.
|
||||
*/
|
||||
export function extractOidcGroupsFromSources(
|
||||
sources: Record<string, unknown>[],
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function isOIDCUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
email?: string,
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(/[\n,]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
const values = [
|
||||
identifier,
|
||||
...(email && email !== identifier ? [email] : []),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.includes("*")) {
|
||||
const escaped = pattern
|
||||
.toLowerCase()
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*");
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
|
||||
continue;
|
||||
}
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
|
||||
} else {
|
||||
if (value.toLowerCase() === pattern.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
issuerUrl: string,
|
||||
clientId: string,
|
||||
caCert?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const segments = idToken.split(".");
|
||||
if (segments.length !== 3) {
|
||||
throw new OIDCTokenFormatError(
|
||||
segments.length === 5
|
||||
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
|
||||
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
|
||||
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
|
||||
const possibleIssuers = [
|
||||
normalizedIssuerUrl,
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
|
||||
];
|
||||
|
||||
const jwksUrls = [
|
||||
`${normalizedIssuerUrl}/.well-known/jwks.json`,
|
||||
`${normalizedIssuerUrl}/jwks/`,
|
||||
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
|
||||
];
|
||||
|
||||
// Every attempt records why it failed. Without this the only thing an
|
||||
// administrator ever sees is "Failed to fetch JWKS from any URL", which
|
||||
// does not distinguish an issuer URL typo from a proxy, a private CA, or
|
||||
// a provider outage.
|
||||
const attempts: string[] = [];
|
||||
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
try {
|
||||
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||
if (!discoveryResponse.ok) {
|
||||
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
|
||||
} else {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
|
||||
jwksUrls.unshift(discovery.jwks_uri);
|
||||
} else {
|
||||
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
|
||||
}
|
||||
|
||||
let jwks: Record<string, unknown> | null = null;
|
||||
|
||||
for (const url of jwksUrls) {
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
attempts.push(`${url}: HTTP ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||
if (jwksData && Array.isArray(jwksData.keys)) {
|
||||
jwks = jwksData;
|
||||
break;
|
||||
}
|
||||
attempts.push(`${url}: response contains no "keys" array`);
|
||||
} catch (error) {
|
||||
attempts.push(`${url}: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!jwks) {
|
||||
throw new Error(
|
||||
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
||||
throw new Error(
|
||||
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
|
||||
const header = decodeProtectedHeader(idToken);
|
||||
const keyId = header.kid;
|
||||
|
||||
const publicKey = jwks.keys.find(
|
||||
(key: Record<string, unknown>) => key.kid === keyId,
|
||||
);
|
||||
if (!publicKey) {
|
||||
throw new Error(
|
||||
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const algorithm =
|
||||
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
|
||||
const key = await importJWK(publicKey, algorithm);
|
||||
|
||||
const { payload } = await jwtVerify(idToken, key, {
|
||||
issuer: possibleIssuers,
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const GOOGLE_DEFAULTS = {
|
||||
issuer_url: "https://accounts.google.com",
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
};
|
||||
|
||||
const GITHUB_DEFAULTS = {
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
};
|
||||
|
||||
function applyProviderDefaults(
|
||||
config: OIDCConfig,
|
||||
providerType: string,
|
||||
): OIDCConfig {
|
||||
const defaults =
|
||||
providerType === "google"
|
||||
? GOOGLE_DEFAULTS
|
||||
: providerType === "github"
|
||||
? GITHUB_DEFAULTS
|
||||
: null;
|
||||
if (!defaults) return config;
|
||||
return {
|
||||
...config,
|
||||
issuer_url: config.issuer_url || defaults.issuer_url,
|
||||
authorization_url: config.authorization_url || defaults.authorization_url,
|
||||
token_url: config.token_url || defaults.token_url,
|
||||
userinfo_url: config.userinfo_url || defaults.userinfo_url,
|
||||
identifier_path: config.identifier_path || defaults.identifier_path,
|
||||
name_path: config.name_path || defaults.name_path,
|
||||
scopes: config.scopes || defaults.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the provider secrets. System-key encrypted values are decrypted;
|
||||
* values still carrying a legacy base64 prefix are decoded so login keeps
|
||||
* working until the provider is next saved.
|
||||
*/
|
||||
async function decryptConfigSecret(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return decryptSsoConfigSecrets(config);
|
||||
}
|
||||
|
||||
export async function loadProviderConfig(
|
||||
providerId: number | null | undefined,
|
||||
adminUserId?: string,
|
||||
): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
if (providerId != null) {
|
||||
try {
|
||||
const row =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (row) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
if (adminUserId) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
|
||||
if (adminDataKey) {
|
||||
parsed = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
parsed,
|
||||
adminUserId,
|
||||
adminDataKey,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
}
|
||||
} else {
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
}
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
return {
|
||||
config,
|
||||
providerType,
|
||||
providerDbId: row.id,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to load SSO provider config by id", err, {
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: env vars
|
||||
if (envConfig) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
// Fallback: first enabled OIDC-type provider in ssoProviders table
|
||||
try {
|
||||
const oidcRow =
|
||||
await createCurrentSsoProviderRepository().findFirstEnabledOidcLike();
|
||||
if (oidcRow) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(oidcRow.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
const oidcProviderType = oidcRow.type as SSOProviderType;
|
||||
return {
|
||||
config: applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
oidcProviderType,
|
||||
),
|
||||
providerType: oidcProviderType,
|
||||
providerDbId: oidcRow.id,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy
|
||||
}
|
||||
|
||||
// Fallback: legacy settings blob
|
||||
try {
|
||||
const legacyValue =
|
||||
await createCurrentSettingsRepository().get("oidc_config");
|
||||
if (legacyValue) {
|
||||
let config = JSON.parse(legacyValue) as Record<string, unknown>;
|
||||
config = await decryptConfigSecret(config);
|
||||
return {
|
||||
config: config as unknown as OIDCConfig,
|
||||
providerType: "oidc",
|
||||
providerDbId: null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// no legacy config
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const target = normalizeIssuer(issuer);
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
isOIDCEnvOverrideEnabled() &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
||||
for (const row of rows) {
|
||||
if (!["oidc", "github", "google"].includes(row.type)) continue;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
|
||||
return { config, providerType, providerDbId: row.id };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to resolve SSO provider by issuer", err, {
|
||||
issuer,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type LogoutTokenClaims = {
|
||||
sub: string | null;
|
||||
sid: string | null;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export function validateLogoutTokenClaims(
|
||||
payload: Record<string, unknown>,
|
||||
): LogoutTokenClaims {
|
||||
if ("nonce" in payload) {
|
||||
throw new Error("logout_token must not contain a nonce claim");
|
||||
}
|
||||
|
||||
const event = (payload.events as Record<string, unknown> | undefined)?.[
|
||||
BACKCHANNEL_LOGOUT_EVENT
|
||||
];
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
throw new Error("logout_token missing back-channel logout event");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.iat)) {
|
||||
throw new Error("logout_token missing iat claim");
|
||||
}
|
||||
|
||||
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
|
||||
if (!jti) {
|
||||
throw new Error("logout_token missing jti claim");
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
||||
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
||||
if (!sub && !sid) {
|
||||
throw new Error("logout_token must contain sub and/or sid");
|
||||
}
|
||||
|
||||
return { sub, sid, jti };
|
||||
}
|
||||
|
||||
export async function validateLogoutToken(
|
||||
logoutToken: string,
|
||||
config: OIDCConfig,
|
||||
): Promise<LogoutTokenClaims> {
|
||||
const payload = await verifyOIDCToken(
|
||||
logoutToken,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
config.ca_cert,
|
||||
);
|
||||
|
||||
return validateLogoutTokenClaims(payload);
|
||||
}
|
||||
@@ -5,9 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
isLoopbackRequest,
|
||||
@@ -17,12 +15,6 @@ import {
|
||||
} from "./desktop-auto-session.js";
|
||||
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
loadProviderConfig,
|
||||
resolveProviderByIssuer,
|
||||
validateLogoutToken,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
@@ -32,15 +24,13 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
|
||||
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
|
||||
import { registerAuthRoutes } from "./auth-routes.js";
|
||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentSettingValue,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
@@ -54,18 +44,7 @@ import {
|
||||
|
||||
import { getPasswordLoginStatus } from "../../auth/core-auth.js";
|
||||
import { verifyPasswordLogin } from "../../auth/builtin-login-methods.js";
|
||||
import {
|
||||
redirectWithLoginError,
|
||||
respondWithLogin,
|
||||
respondWithRedirectLogin,
|
||||
sendLoginError,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import {
|
||||
handleOidcCallback,
|
||||
RedirectLoginError,
|
||||
startOidcLogin,
|
||||
} from "../../auth/legacy/oidc-login.js";
|
||||
import { LoginMethodError } from "../../auth/types.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
import {
|
||||
isNativeAppRequest,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
@@ -297,365 +276,6 @@ router.post("/create", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* post:
|
||||
* summary: Configure OIDC provider
|
||||
* description: Creates or updates the OIDC provider configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC configuration updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update OIDC config.
|
||||
*/
|
||||
router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url,
|
||||
identifier_path,
|
||||
name_path,
|
||||
scopes,
|
||||
allowed_users,
|
||||
admin_group,
|
||||
group_claim,
|
||||
} = req.body;
|
||||
|
||||
const isDisableRequest =
|
||||
(client_id === "" || client_id === null || client_id === undefined) &&
|
||||
(client_secret === "" ||
|
||||
client_secret === null ||
|
||||
client_secret === undefined) &&
|
||||
(issuer_url === "" || issuer_url === null || issuer_url === undefined) &&
|
||||
(authorization_url === "" ||
|
||||
authorization_url === null ||
|
||||
authorization_url === undefined) &&
|
||||
(token_url === "" || token_url === null || token_url === undefined);
|
||||
|
||||
const isEnableRequest =
|
||||
isNonEmptyString(client_id) &&
|
||||
isNonEmptyString(client_secret) &&
|
||||
isNonEmptyString(issuer_url) &&
|
||||
isNonEmptyString(authorization_url) &&
|
||||
isNonEmptyString(token_url) &&
|
||||
isNonEmptyString(identifier_path) &&
|
||||
isNonEmptyString(name_path);
|
||||
|
||||
if (!isDisableRequest && !isEnableRequest) {
|
||||
authLogger.warn(
|
||||
"OIDC validation failed - neither disable nor enable request",
|
||||
{
|
||||
operation: "oidc_config_update",
|
||||
userId,
|
||||
isDisableRequest,
|
||||
isEnableRequest,
|
||||
},
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "All OIDC configuration fields are required" });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
if (isDisableRequest) {
|
||||
await settingsRepository.delete("oidc_config");
|
||||
authLogger.info("OIDC configuration disabled", {
|
||||
operation: "oidc_disable",
|
||||
userId,
|
||||
});
|
||||
res.json({ message: "OIDC configuration disabled" });
|
||||
} else {
|
||||
const config = {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: userinfo_url || "",
|
||||
identifier_path,
|
||||
name_path,
|
||||
scopes: scopes || "openid email profile",
|
||||
allowed_users: allowed_users || "",
|
||||
admin_group: admin_group || "",
|
||||
group_claim: group_claim || "",
|
||||
};
|
||||
|
||||
let encryptedConfig;
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
||||
encryptedConfig = DataCrypto.encryptRecord(
|
||||
"settings",
|
||||
configWithId,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
} else {
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
authLogger.warn(
|
||||
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||
{
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (encryptError) {
|
||||
authLogger.error(
|
||||
"Failed to encrypt OIDC configuration, storing with basic encoding",
|
||||
encryptError,
|
||||
{
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
await settingsRepository.set(
|
||||
"oidc_config",
|
||||
JSON.stringify(encryptedConfig),
|
||||
);
|
||||
authLogger.info("OIDC configuration updated", {
|
||||
operation: "oidc_update",
|
||||
userId,
|
||||
hasUserinfoUrl: !!userinfo_url,
|
||||
});
|
||||
res.json({ message: "OIDC configuration updated" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to update OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* delete:
|
||||
* summary: Disable OIDC configuration
|
||||
* description: Disables the OIDC provider configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC configuration disabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to disable OIDC config.
|
||||
*/
|
||||
router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
await createCurrentSettingsRepository().delete("oidc_config");
|
||||
authLogger.success("OIDC configuration disabled", {
|
||||
operation: "oidc_disable",
|
||||
userId,
|
||||
});
|
||||
res.json({ message: "OIDC configuration disabled" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to disable OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to disable OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* get:
|
||||
* summary: Get OIDC configuration
|
||||
* description: Returns the public OIDC configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Public OIDC configuration.
|
||||
* 500:
|
||||
* description: Failed to get OIDC config.
|
||||
*/
|
||||
router.get("/oidc-config", async (_req, res) => {
|
||||
try {
|
||||
const providerResult = await loadProviderConfig(undefined);
|
||||
if (!providerResult) {
|
||||
return res.json(null);
|
||||
}
|
||||
const { config } = providerResult;
|
||||
return res.json({
|
||||
client_id: config.client_id,
|
||||
issuer_url: config.issuer_url,
|
||||
authorization_url: config.authorization_url,
|
||||
scopes: config.scopes,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config/admin:
|
||||
* get:
|
||||
* summary: Get OIDC configuration for admin
|
||||
* description: Returns the full OIDC configuration for an admin.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Full OIDC configuration.
|
||||
* 500:
|
||||
* description: Failed to get OIDC config for admin.
|
||||
*/
|
||||
router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const value = await createCurrentSettingsRepository().get("oidc_config");
|
||||
if (!value) {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
return res.json(envConfig);
|
||||
}
|
||||
|
||||
let config = JSON.parse(value);
|
||||
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
config = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
config,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||
}
|
||||
} catch {
|
||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||
operation: "oidc_config_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||
}
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
try {
|
||||
const decoded = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
config.client_secret = decoded;
|
||||
} catch {
|
||||
authLogger.warn("Failed to decode OIDC config for admin", {
|
||||
operation: "oidc_config_decode_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config for admin", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config for admin" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/authorize:
|
||||
* get:
|
||||
* summary: Get OIDC authorization URL
|
||||
* description: Returns the OIDC authorization URL.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: rememberMe
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: Whether to extend the session to 30 days instead of 2 hours.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC authorization URL.
|
||||
* 404:
|
||||
* description: OIDC not configured.
|
||||
* 500:
|
||||
* description: Failed to generate authorization URL.
|
||||
*/
|
||||
router.get("/oidc/authorize", async (req, res) => {
|
||||
try {
|
||||
res.json(await startOidcLogin(req));
|
||||
} catch (err) {
|
||||
if (err instanceof LoginMethodError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
authLogger.error("Failed to generate OIDC auth URL", err);
|
||||
res.status(500).json({ error: "Failed to generate authorization URL" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/callback:
|
||||
* get:
|
||||
* summary: OIDC callback
|
||||
* description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirects to the frontend with a success or error message.
|
||||
* 400:
|
||||
* description: Code and state are required.
|
||||
*/
|
||||
router.get("/oidc/callback", async (req, res) => {
|
||||
let identity;
|
||||
try {
|
||||
identity = await handleOidcCallback(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RedirectLoginError) {
|
||||
return redirectWithLoginError(res, error.returnTo, error);
|
||||
}
|
||||
return sendLoginError(res, error);
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: "oidc", rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/proxy-login:
|
||||
@@ -728,17 +348,11 @@ router.post("/proxy-login", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [legacyOidc, enabledProviders, userRecord] = await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
const [externalMethods, userRecord] = await Promise.all([
|
||||
listExternalLoginMethods(),
|
||||
createCurrentUserRepository().findByUsername(username),
|
||||
]);
|
||||
const hasOidc =
|
||||
Boolean(getOIDCConfigFromEnv() || legacyOidc) ||
|
||||
enabledProviders.some((provider) =>
|
||||
["oidc", "github", "google"].includes(provider.type),
|
||||
);
|
||||
if (hasOidc) {
|
||||
if (externalMethods.length > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Proxy authentication cannot be used with OIDC" });
|
||||
@@ -931,84 +545,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const seenLogoutJti = new Map<string, number>();
|
||||
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function pruneLogoutJti(now: number): void {
|
||||
for (const [key, expiry] of seenLogoutJti) {
|
||||
if (expiry <= now) seenLogoutJti.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isReplayedJti(jti: string): boolean {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
return seenLogoutJti.has(jti);
|
||||
}
|
||||
|
||||
function markLogoutJti(jti: string): void {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS);
|
||||
}
|
||||
|
||||
router.post("/oidc/backchannel-logout", async (req, res) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const logoutToken = (req.body as Record<string, unknown> | undefined)
|
||||
?.logout_token;
|
||||
if (typeof logoutToken !== "string" || !logoutToken) {
|
||||
return res.status(400).json({ error: "missing logout_token" });
|
||||
}
|
||||
|
||||
let issuer: string | null = null;
|
||||
try {
|
||||
const parts = logoutToken.split(".");
|
||||
if (parts.length === 3) {
|
||||
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
||||
issuer = typeof claims.iss === "string" ? claims.iss : null;
|
||||
}
|
||||
} catch {
|
||||
issuer = null;
|
||||
}
|
||||
|
||||
if (!issuer) {
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
|
||||
const provider = await resolveProviderByIssuer(issuer);
|
||||
if (!provider) {
|
||||
authLogger.warn("Back-channel logout for unknown issuer", { issuer });
|
||||
return res.status(400).json({ error: "unknown issuer" });
|
||||
}
|
||||
|
||||
const claims = await validateLogoutToken(logoutToken, provider.config);
|
||||
|
||||
if (claims.jti && isReplayedJti(claims.jti)) {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: provider.providerDbId,
|
||||
sub: claims.sub,
|
||||
sid: claims.sid,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel session revocation failed", err);
|
||||
return res.status(500).json({ error: "logout processing failed" });
|
||||
}
|
||||
|
||||
markLogoutJti(claims.jti);
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel logout failed", err);
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
@@ -2117,8 +1653,7 @@ registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router, requireAdmin);
|
||||
|
||||
registerSSOProviderRoutes(router);
|
||||
registerLDAPAuthRoutes(router);
|
||||
registerAuthRoutes(router);
|
||||
registerAuthCompatRoutes(router);
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user