mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
/**
|
|
* The OIDC, GitHub and Google sign-in. start builds the provider's authorize
|
|
* URL; callback turns the provider's answer into a verified identity. Core
|
|
* finds or provisions the user and issues the session.
|
|
*/
|
|
|
|
import crypto from "node:crypto";
|
|
import {
|
|
LoginMethodError,
|
|
type PluginContext,
|
|
type PluginLoginRequest,
|
|
type PluginVerifiedIdentity,
|
|
} from "@termix/plugin-sdk/backend";
|
|
import {
|
|
OIDCTokenFormatError,
|
|
buildFetchOptions,
|
|
extractOidcGroupsFromSources,
|
|
generatePkceCodeChallenge,
|
|
generatePkceCodeVerifier,
|
|
getDesktopCallbackUrl,
|
|
getNestedValue,
|
|
parseOidcRoleMap,
|
|
resolveOidcMappedRoles,
|
|
verifyOIDCToken,
|
|
} from "./oidc-protocol.js";
|
|
import {
|
|
ENV_INSTANCE_ID,
|
|
ENV_PROVIDER_ID,
|
|
type ProviderStore,
|
|
} from "./providers.js";
|
|
import type { OidcConfig, ResolvedProvider } from "./types.js";
|
|
|
|
export const METHOD_ID = "oidc";
|
|
export const CALLBACK_PATH = "/plugin-api/sso/callback";
|
|
/** The redirect URI identity providers were set up with before 2.9. */
|
|
export const LEGACY_CALLBACK_PATH = "/users/oidc/callback";
|
|
|
|
const STATE_TTL_MS = 10 * 60 * 1000;
|
|
const STATE_PREFIX = "state:";
|
|
|
|
/** 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";
|
|
}
|
|
}
|
|
|
|
interface PendingState {
|
|
nonce: string;
|
|
backendCallback: string;
|
|
frontendOrigin: string;
|
|
rememberMe: boolean;
|
|
providerId: number | null;
|
|
codeVerifier: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
function nonEmpty(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function header(request: PluginLoginRequest, name: string): string {
|
|
const value = request.headers[name];
|
|
return typeof (Array.isArray(value) ? value[0] : value) === "string"
|
|
? String(Array.isArray(value) ? value[0] : value)
|
|
: "";
|
|
}
|
|
|
|
export function redirectUriFor(
|
|
baseUrl: string,
|
|
provider: Pick<ResolvedProvider, "legacyCallback">,
|
|
): string {
|
|
return `${baseUrl}${provider.legacyCallback ? LEGACY_CALLBACK_PATH : CALLBACK_PATH}`;
|
|
}
|
|
|
|
export type SsoLogin = ReturnType<typeof createSsoLogin>;
|
|
|
|
export function createSsoLogin(ctx: PluginContext, store: ProviderStore) {
|
|
async function saveState(state: string, pending: PendingState) {
|
|
// Drop anything abandoned so the store does not grow.
|
|
const now = Date.now();
|
|
for (const key of await ctx.kv.list()) {
|
|
if (!key.startsWith(STATE_PREFIX)) continue;
|
|
const entry = (await ctx.kv.get(key)) as PendingState | undefined;
|
|
if (!entry || now - entry.createdAt > STATE_TTL_MS) {
|
|
await ctx.kv.delete(key);
|
|
}
|
|
}
|
|
await ctx.kv.set(`${STATE_PREFIX}${state}`, pending);
|
|
}
|
|
|
|
async function takeState(state: string): Promise<PendingState | null> {
|
|
const key = `${STATE_PREFIX}${state}`;
|
|
const entry = (await ctx.kv.get(key)) as PendingState | undefined;
|
|
if (!entry) return null;
|
|
await ctx.kv.delete(key);
|
|
return Date.now() - entry.createdAt > STATE_TTL_MS ? null : entry;
|
|
}
|
|
|
|
function frontendOriginFor(
|
|
request: PluginLoginRequest,
|
|
baseUrl: string,
|
|
): string {
|
|
const { desktopCallbackPort, appCallbackUrl } = request.query;
|
|
if (desktopCallbackPort !== undefined && desktopCallbackPort !== "") {
|
|
const url = getDesktopCallbackUrl(desktopCallbackPort);
|
|
if (!url) {
|
|
throw new LoginMethodError("Invalid desktop callback port", 400);
|
|
}
|
|
return url;
|
|
}
|
|
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);
|
|
}
|
|
return callbackUrl.toString();
|
|
}
|
|
const referer = header(request, "referer");
|
|
if (referer) {
|
|
try {
|
|
const refererUrl = new URL(referer);
|
|
return `${refererUrl.protocol}//${refererUrl.host}`;
|
|
} catch {
|
|
// fall through to the request's own origin
|
|
}
|
|
}
|
|
return new URL(baseUrl).origin;
|
|
}
|
|
|
|
async function start(
|
|
request: PluginLoginRequest,
|
|
instanceId: string | null,
|
|
): Promise<{ redirectUrl: string }> {
|
|
const requested =
|
|
instanceId ??
|
|
(typeof request.query.providerId === "string"
|
|
? request.query.providerId
|
|
: null);
|
|
const rowId =
|
|
requested && requested !== ENV_INSTANCE_ID
|
|
? Number.parseInt(requested, 10)
|
|
: null;
|
|
if (rowId !== null && !Number.isInteger(rowId)) {
|
|
throw new LoginMethodError("OIDC not configured", 404);
|
|
}
|
|
const provider = await store.resolve(rowId);
|
|
if (!provider) throw new LoginMethodError("OIDC not configured", 404);
|
|
|
|
const baseUrl = ctx.http.baseUrl(request);
|
|
const backendCallback = redirectUriFor(baseUrl, provider);
|
|
const frontendOrigin = frontendOriginFor(request, baseUrl);
|
|
const state = crypto.randomUUID();
|
|
const nonce = crypto.randomUUID();
|
|
const codeVerifier = generatePkceCodeVerifier();
|
|
|
|
await saveState(state, {
|
|
nonce,
|
|
backendCallback,
|
|
frontendOrigin,
|
|
rememberMe: String(request.query.rememberMe) === "true",
|
|
providerId: provider.rowId,
|
|
codeVerifier,
|
|
createdAt: Date.now(),
|
|
});
|
|
|
|
const authUrl = new URL(provider.config.authorization_url);
|
|
authUrl.searchParams.set("client_id", provider.config.client_id);
|
|
authUrl.searchParams.set("redirect_uri", backendCallback);
|
|
authUrl.searchParams.set("response_type", "code");
|
|
authUrl.searchParams.set("scope", provider.config.scopes);
|
|
authUrl.searchParams.set("state", state);
|
|
authUrl.searchParams.set("nonce", nonce);
|
|
authUrl.searchParams.set(
|
|
"code_challenge",
|
|
generatePkceCodeChallenge(codeVerifier),
|
|
);
|
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
return { redirectUrl: authUrl.toString() };
|
|
}
|
|
|
|
async function exchangeCode(
|
|
config: OidcConfig,
|
|
code: string,
|
|
pending: PendingState,
|
|
): Promise<Record<string, unknown>> {
|
|
const response = 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: pending.backendCallback,
|
|
code_verifier: pending.codeVerifier,
|
|
}),
|
|
...buildFetchOptions(config.ca_cert),
|
|
} as RequestInit);
|
|
if (!response.ok) {
|
|
ctx.log.error(
|
|
"Token exchange failed",
|
|
new Error(`HTTP ${response.status}: ${await response.text()}`),
|
|
);
|
|
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
|
}
|
|
return (await response.json()) as Record<string, unknown>;
|
|
}
|
|
|
|
function providerKey(provider: ResolvedProvider): string {
|
|
return provider.rowId === null ? ENV_PROVIDER_ID : String(provider.rowId);
|
|
}
|
|
|
|
async function githubIdentity(
|
|
provider: ResolvedProvider,
|
|
code: string,
|
|
pending: PendingState,
|
|
): Promise<PluginVerifiedIdentity> {
|
|
const tokenData = await exchangeCode(provider.config, code, pending);
|
|
const fetchOptions = buildFetchOptions(provider.config.ca_cert);
|
|
const headers = {
|
|
Authorization: `Bearer ${tokenData.access_token}`,
|
|
Accept: "application/json",
|
|
"User-Agent": "Termix",
|
|
};
|
|
const userInfoResponse = await fetch("https://api.github.com/user", {
|
|
headers,
|
|
...fetchOptions,
|
|
} as RequestInit);
|
|
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,
|
|
});
|
|
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:${provider.rowId}:${subject}`;
|
|
return {
|
|
kind: "external",
|
|
provider: providerKey(provider),
|
|
subject,
|
|
email: (userInfo.email as string | undefined) ?? null,
|
|
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
|
allowedUsers: provider.config.allowed_users ?? null,
|
|
legacy: { identifier: legacyIdentifier, providerRowId: provider.rowId },
|
|
returnTo: pending.frontendOrigin,
|
|
rememberMe: pending.rememberMe,
|
|
};
|
|
}
|
|
|
|
async function fetchUserInfo(
|
|
config: OidcConfig,
|
|
accessToken: unknown,
|
|
userInfo: Record<string, unknown> | null,
|
|
claimSources: Record<string, unknown>[],
|
|
): Promise<Record<string, unknown> | null> {
|
|
const fetchOptions = buildFetchOptions(config.ca_cert);
|
|
const issuer = config.issuer_url.endsWith("/")
|
|
? config.issuer_url.slice(0, -1)
|
|
: config.issuer_url;
|
|
const base = issuer.replace(/\/application\/o\/[^/]+$/, "");
|
|
const urls: string[] = [];
|
|
|
|
try {
|
|
const discovery = await fetch(
|
|
`${issuer}/.well-known/openid-configuration`,
|
|
fetchOptions as RequestInit,
|
|
);
|
|
if (discovery.ok) {
|
|
const document = (await discovery.json()) as Record<string, unknown>;
|
|
if (typeof document.userinfo_endpoint === "string") {
|
|
urls.push(document.userinfo_endpoint);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
ctx.log.warn(`OIDC discovery failed: ${String(error)}`);
|
|
}
|
|
|
|
if (config.userinfo_url) urls.unshift(config.userinfo_url);
|
|
urls.push(
|
|
`${base}/userinfo/`,
|
|
`${base}/userinfo`,
|
|
`${issuer}/userinfo/`,
|
|
`${issuer}/userinfo`,
|
|
`${base}/oauth2/userinfo/`,
|
|
`${base}/oauth2/userinfo`,
|
|
`${issuer}/oauth2/userinfo/`,
|
|
`${issuer}/oauth2/userinfo`,
|
|
);
|
|
|
|
if (!accessToken) return userInfo;
|
|
for (const url of urls) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
...fetchOptions,
|
|
} as RequestInit);
|
|
if (response.ok) {
|
|
const fetched = (await response.json()) as Record<string, unknown>;
|
|
claimSources.push(fetched);
|
|
return { ...userInfo, ...fetched };
|
|
}
|
|
ctx.log.warn(`Userinfo endpoint ${url} answered ${response.status}`);
|
|
} catch (error) {
|
|
ctx.log.warn(`Userinfo endpoint ${url} failed: ${String(error)}`);
|
|
}
|
|
}
|
|
return userInfo;
|
|
}
|
|
|
|
async function oidcIdentity(
|
|
provider: ResolvedProvider,
|
|
code: string,
|
|
pending: PendingState,
|
|
): Promise<PluginVerifiedIdentity> {
|
|
const { config } = provider;
|
|
const tokenData = await exchangeCode(config, code, pending);
|
|
|
|
let userInfo: Record<string, unknown> | null = null;
|
|
const claimSources: Record<string, unknown>[] = [];
|
|
|
|
if (typeof tokenData.id_token === "string") {
|
|
try {
|
|
userInfo = await verifyOIDCToken(
|
|
tokenData.id_token,
|
|
config.issuer_url,
|
|
config.client_id,
|
|
config.ca_cert,
|
|
);
|
|
if (userInfo.nonce !== pending.nonce) {
|
|
ctx.log.warn("OIDC ID token nonce mismatch");
|
|
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;
|
|
ctx.log.warn(
|
|
`OIDC ID token cannot be verified, using userinfo: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
userInfo = await fetchUserInfo(
|
|
config,
|
|
tokenData.access_token,
|
|
userInfo,
|
|
claimSources,
|
|
);
|
|
if (!userInfo) {
|
|
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,
|
|
);
|
|
}
|
|
|
|
const groups = extractOidcGroupsFromSources(
|
|
claimSources,
|
|
config.group_claim,
|
|
);
|
|
const isAdmin = config.admin_group
|
|
? groups.includes(config.admin_group)
|
|
: undefined;
|
|
|
|
let roles: { desired: string[]; managed: string[] } | undefined;
|
|
const roleMap = parseOidcRoleMap(
|
|
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
|
);
|
|
if (roleMap.size > 0) {
|
|
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
|
roles = { desired: [...desired], managed: [...managed] };
|
|
}
|
|
|
|
return {
|
|
kind: "external",
|
|
provider: providerKey(provider),
|
|
subject: String(identifier),
|
|
// An address the provider says it has not verified does not count
|
|
// toward the allowed list.
|
|
email:
|
|
userInfo.email_verified === false || userInfo.email_verified === "false"
|
|
? null
|
|
: ((userInfo.email as string | undefined) ?? null),
|
|
name,
|
|
groups,
|
|
isAdmin,
|
|
allowedUsers: config.allowed_users ?? null,
|
|
roles,
|
|
logoutClaims: {
|
|
providerId: provider.rowId,
|
|
sub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
|
sid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
|
},
|
|
legacy: { identifier: String(identifier), providerRowId: provider.rowId },
|
|
returnTo: pending.frontendOrigin,
|
|
rememberMe: pending.rememberMe,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The provider's callback. Throws RedirectLoginError once the return
|
|
* address is known, and a plain LoginMethodError (answered as JSON) before.
|
|
*/
|
|
async function callback(
|
|
request: PluginLoginRequest,
|
|
): Promise<PluginVerifiedIdentity> {
|
|
const source = { ...request.query, ...(request.body ?? {}) };
|
|
const { code, state } = source as Record<string, unknown>;
|
|
if (!nonEmpty(code) || !nonEmpty(state)) {
|
|
throw new LoginMethodError("Code and state are required", 400);
|
|
}
|
|
const pending = await takeState(state);
|
|
if (!pending) throw new LoginMethodError("Invalid state parameter", 400);
|
|
|
|
try {
|
|
const provider = await store.resolve(pending.providerId);
|
|
if (!provider) throw new LoginMethodError("OIDC not configured", 500);
|
|
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
|
return provider.type === "github"
|
|
? await githubIdentity(provider, code, pending)
|
|
: await oidcIdentity(provider, code, pending);
|
|
} catch (error) {
|
|
if (error instanceof LoginMethodError) {
|
|
throw new RedirectLoginError(
|
|
error.message,
|
|
pending.frontendOrigin,
|
|
error.status,
|
|
error.code,
|
|
);
|
|
}
|
|
ctx.log.error(
|
|
"OIDC callback failed",
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
throw new RedirectLoginError(
|
|
"OIDC authentication failed",
|
|
pending.frontendOrigin,
|
|
500,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { start, callback };
|
|
}
|