mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The allowed-users list an external login method passes with its identity:
|
||||
* one pattern per line or comma, "*" for anyone, "@example.com" for a
|
||||
* domain, "*" as a wildcard inside a pattern, or an exact name. Empty allows
|
||||
* everyone.
|
||||
*/
|
||||
export function isExternalUserAllowed(
|
||||
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;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
|
||||
import { registerLegacyLoginProviders } from "./legacy-providers.js";
|
||||
import { listLoginMethods } from "./registry.js";
|
||||
import { LoginMethodError } from "./types.js";
|
||||
|
||||
@@ -17,7 +16,6 @@ export function ensureCoreLoginProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerPasswordLoginMethod();
|
||||
registerLegacyLoginProviders();
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
|
||||
const OPKSSH_REQUIRED_MESSAGE =
|
||||
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
|
||||
@@ -263,77 +262,7 @@ export function registerLegacySshAuthProviders(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Login methods and second factors -------------------------------------------
|
||||
|
||||
async function listPublicProviders(types: string[]) {
|
||||
const { listPublicSsoProviders } =
|
||||
await import("../database/routes/sso-provider-routes.js");
|
||||
return (await listPublicSsoProviders()).filter((provider) =>
|
||||
types.includes(provider.type),
|
||||
);
|
||||
}
|
||||
|
||||
const oidcLoginMethod: LoginMethod = {
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["oidc", "github", "google"])).map(
|
||||
(provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
start: async (request, instanceId) => {
|
||||
const { startOidcLogin } = await import("./legacy/oidc-login.js");
|
||||
// Instance "0" is the provider synthesized from environment variables.
|
||||
const result = await startOidcLogin(request as never, {
|
||||
providerId: instanceId && instanceId !== "0" ? instanceId : undefined,
|
||||
});
|
||||
return { redirectUrl: result.auth_url };
|
||||
},
|
||||
callback: async (request) => {
|
||||
const { handleOidcCallback } = await import("./legacy/oidc-login.js");
|
||||
return handleOidcCallback(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
const ldapLoginMethod: LoginMethod = {
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["ldap"])).map((provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
})),
|
||||
verify: async (request, instanceId) => {
|
||||
const { verifyLdapLogin } = await import("./legacy/ldap-login.js");
|
||||
const body = { ...request.body };
|
||||
if (instanceId && !body.providerId) body.providerId = Number(instanceId);
|
||||
return verifyLdapLogin({ body, ip: request.ip });
|
||||
},
|
||||
};
|
||||
|
||||
let loginRegistered = false;
|
||||
|
||||
export function registerLegacyLoginProviders(): void {
|
||||
if (loginRegistered) return;
|
||||
loginRegistered = true;
|
||||
registerLoginMethod(oidcLoginMethod);
|
||||
registerLoginMethod(ldapLoginMethod);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyProvidersForTests(): void {
|
||||
sshRegistered = false;
|
||||
loginRegistered = false;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method. Moved out of the LDAP route so the
|
||||
* pipeline issues the session; Phase C moves it into the LDAP plugin.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js";
|
||||
import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export async function verifyLdapLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}): Promise<VerifiedIdentity> {
|
||||
const { providerId, username, password, rememberMe } = request.body as {
|
||||
providerId?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `ldap:${providerId}:${username}`;
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey);
|
||||
if (lockStatus.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (!provider || provider.type !== "ldap" || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
],
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
const ldapIdentifier =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerId),
|
||||
subject: ldapIdentifier,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`,
|
||||
ssoProviderId: providerId,
|
||||
rememberMe: !!rememberMe,
|
||||
rateLimitUsername: rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
ldapUnbind(serviceClient);
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
/**
|
||||
* OIDC, GitHub and Google sign-in, moved out of the users routes so they can
|
||||
* register as a login method. Phase C moves this into the OIDC plugin.
|
||||
*
|
||||
* start builds the provider's authorize URL; callback turns the provider's
|
||||
* answer into a verified identity. Neither touches sessions.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
||||
import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
OIDCTokenFormatError,
|
||||
buildFetchOptions,
|
||||
extractOidcGroupsFromSources,
|
||||
loadProviderConfig,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
} from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
/** An error raised after the return address is known redirects there. */
|
||||
export class RedirectLoginError extends LoginMethodError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly returnTo: string,
|
||||
status = 400,
|
||||
code?: string,
|
||||
) {
|
||||
super(message, status, code);
|
||||
this.name = "RedirectLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider id used for OIDC identities that came from env configuration. */
|
||||
export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
export async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.delete(`oidc_state_${state}`);
|
||||
await settingsRepository.delete(`oidc_backend_callback_${state}`);
|
||||
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
|
||||
await settingsRepository.delete(`oidc_remember_me_${state}`);
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
|
||||
}
|
||||
|
||||
export async function startOidcLogin(
|
||||
req: Request,
|
||||
overrides: { providerId?: string | null } = {},
|
||||
): Promise<{ auth_url: string; state: string; nonce: string }> {
|
||||
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
||||
const providerIdStr = overrides.providerId ?? req.query.providerId;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, "");
|
||||
const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`;
|
||||
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
resolvedProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 404);
|
||||
}
|
||||
const { config, providerDbId } = providerResult;
|
||||
const state = nanoid();
|
||||
const nonce = nanoid();
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin: string;
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
||||
if (!frontendOrigin) {
|
||||
throw new LoginMethodError("Invalid desktop callback port", 400);
|
||||
}
|
||||
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = new URL(appCallbackUrl);
|
||||
} catch {
|
||||
throw new LoginMethodError("Invalid app callback URL", 400);
|
||||
}
|
||||
if (callbackUrl.protocol !== "termix-mobile:") {
|
||||
throw new LoginMethodError("Unsupported app callback URL", 400);
|
||||
}
|
||||
frontendOrigin = callbackUrl.toString();
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
frontendOrigin = origin;
|
||||
}
|
||||
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.set(`oidc_state_${state}`, nonce);
|
||||
await settingsRepository.set(
|
||||
`oidc_backend_callback_${state}`,
|
||||
backendCallbackUri,
|
||||
);
|
||||
await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin);
|
||||
await settingsRepository.set(
|
||||
`oidc_remember_me_${state}`,
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
|
||||
if (providerDbId != null) {
|
||||
await settingsRepository.set(
|
||||
`oidc_provider_${state}`,
|
||||
String(providerDbId),
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = new URL(config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return { auth_url: authUrl.toString(), state, nonce };
|
||||
}
|
||||
|
||||
type ProviderConfig = NonNullable<
|
||||
Awaited<ReturnType<typeof loadProviderConfig>>
|
||||
>["config"];
|
||||
|
||||
async function githubIdentity(
|
||||
code: string,
|
||||
state: string,
|
||||
config: ProviderConfig,
|
||||
providerDbId: number | null,
|
||||
backendCallbackUri: string,
|
||||
frontendOrigin: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const fetchOptions = buildFetchOptions(config.ca_cert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("GitHub token exchange failed", {
|
||||
operation: "github_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
const userInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new LoginMethodError("Failed to get GitHub user information", 400);
|
||||
}
|
||||
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
const emailResponse = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
if (emailResponse.ok) {
|
||||
const emails = (await emailResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) userInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const subject = String(userInfo.id ?? userInfo.login);
|
||||
const legacyIdentifier = `github:${providerDbId}:${subject}`;
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier,
|
||||
ssoProviderId: providerDbId,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!path || !obj) return null;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(current, key) => (current as Record<string, unknown>)?.[key],
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's callback. Throws RedirectLoginError once the return address
|
||||
* is known, and a plain LoginMethodError (answered as JSON) before that.
|
||||
*/
|
||||
export async function handleOidcCallback(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const { code, state } = req.query;
|
||||
if (!nonEmpty(code) || !nonEmpty(state)) {
|
||||
throw new LoginMethodError("Code and state are required", 400);
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const backendCallbackUri = await settingsRepository.get(
|
||||
`oidc_backend_callback_${state}`,
|
||||
);
|
||||
const frontendOrigin = await settingsRepository.get(
|
||||
`oidc_frontend_origin_${state}`,
|
||||
);
|
||||
const rememberMe =
|
||||
(await settingsRepository.get(`oidc_remember_me_${state}`)) === "true";
|
||||
const codeVerifier = await settingsRepository.get(
|
||||
`oidc_pkce_verifier_${state}`,
|
||||
);
|
||||
|
||||
if (!backendCallbackUri || !frontendOrigin) {
|
||||
throw new LoginMethodError(
|
||||
"Invalid state parameter - redirect URIs not found",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const storedNonce = await settingsRepository.get(`oidc_state_${state}`);
|
||||
if (!storedNonce) {
|
||||
throw new LoginMethodError("Invalid state parameter", 400);
|
||||
}
|
||||
|
||||
const storedProviderId = await settingsRepository.get(
|
||||
`oidc_provider_${state}`,
|
||||
);
|
||||
const callbackProviderId = storedProviderId
|
||||
? parseInt(storedProviderId, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
callbackProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 500);
|
||||
}
|
||||
const { config, providerType, providerDbId } = providerResult;
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
|
||||
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
||||
if (providerType === "github") {
|
||||
return await githubIdentity(
|
||||
code,
|
||||
state,
|
||||
config,
|
||||
providerDbId ?? null,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
const caCert = config.ca_cert;
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("OIDC token exchange failed", {
|
||||
operation: "oidc_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
let userInfo: Record<string, unknown> | null = null;
|
||||
const claimSources: Record<string, unknown>[] = [];
|
||||
const userInfoUrls: string[] = [];
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
||||
? config.issuer_url.slice(0, -1)
|
||||
: config.issuer_url;
|
||||
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(
|
||||
`${normalizedIssuerUrl}/.well-known/openid-configuration`,
|
||||
fetchOptions,
|
||||
);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (discovery.userinfo_endpoint) {
|
||||
userInfoUrls.push(discovery.userinfo_endpoint as string);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||
}
|
||||
|
||||
if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url);
|
||||
userInfoUrls.push(
|
||||
`${baseUrl}/userinfo/`,
|
||||
`${baseUrl}/userinfo`,
|
||||
`${normalizedIssuerUrl}/userinfo/`,
|
||||
`${normalizedIssuerUrl}/userinfo`,
|
||||
`${baseUrl}/oauth2/userinfo/`,
|
||||
`${baseUrl}/oauth2/userinfo`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo/`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo`,
|
||||
);
|
||||
|
||||
if (tokenData.id_token) {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(
|
||||
tokenData.id_token as string,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
caCert,
|
||||
);
|
||||
if (userInfo.nonce !== storedNonce) {
|
||||
authLogger.warn("OIDC ID token nonce mismatch", {
|
||||
operation: "oidc_nonce_mismatch",
|
||||
providerId: callbackProviderId,
|
||||
});
|
||||
throw new LoginMethodError("Invalid OIDC token nonce", 401);
|
||||
}
|
||||
claimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token that is not a JWS carries no claims we could trust; fall
|
||||
// through to userinfo. Signature and claim failures still reject.
|
||||
if (!(error instanceof OIDCTokenFormatError)) throw error;
|
||||
userInfo = null;
|
||||
authLogger.warn(
|
||||
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
|
||||
{
|
||||
operation: "oidc_id_token_unverifiable",
|
||||
providerId: callbackProviderId,
|
||||
reason: error.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData.access_token) {
|
||||
for (const userInfoUrl of userInfoUrls) {
|
||||
try {
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
...fetchOptions,
|
||||
});
|
||||
if (response.ok) {
|
||||
const fetched = (await response.json()) as Record<string, unknown>;
|
||||
claimSources.push(fetched);
|
||||
userInfo = { ...userInfo, ...fetched };
|
||||
break;
|
||||
}
|
||||
authLogger.error(
|
||||
`Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
authLogger.error("Failed to get user information from all sources");
|
||||
throw new LoginMethodError("Failed to get user information", 400);
|
||||
}
|
||||
|
||||
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username) as string;
|
||||
const name = (getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier) as string;
|
||||
|
||||
if (!identifier) {
|
||||
throw new LoginMethodError(
|
||||
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.admin_group) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
isAdmin = groups.includes(config.admin_group);
|
||||
}
|
||||
|
||||
let roleSync: { desired: string[]; managed: string[] } | undefined;
|
||||
try {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
||||
);
|
||||
if (roleMap.size > 0) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
roleSync = { desired: [...desired], managed: [...managed] };
|
||||
}
|
||||
} catch (roleMapError) {
|
||||
authLogger.error("Failed to read the OIDC role map", roleMapError, {
|
||||
operation: "oidc_role_map_sync_failed",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject: identifier,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier: identifier,
|
||||
ssoProviderId: providerDbId ?? null,
|
||||
oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
||||
oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
||||
roleSync,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) throw error;
|
||||
authLogger.error("OIDC callback failed", error);
|
||||
throw new RedirectLoginError(
|
||||
"OIDC authentication failed",
|
||||
frontendOrigin,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js";
|
||||
import { isExternalUserAllowed } from "./allowed-users.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
@@ -64,9 +64,9 @@ function isAllowed(identity: ExternalIdentity): boolean {
|
||||
if (!identity.allowedUsers) return true;
|
||||
const email = identity.email ?? undefined;
|
||||
return (
|
||||
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
isExternalUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
(!!identity.legacyIdentifier &&
|
||||
isOIDCUserAllowed(
|
||||
isExternalUserAllowed(
|
||||
identity.allowedUsers,
|
||||
identity.legacyIdentifier,
|
||||
email,
|
||||
|
||||
Reference in new issue
Block a user