feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import type {
PluginContext,
PluginProcessHandle,
} from "@termix/plugin-sdk/backend";
import {
CONFIG_FILE,
checkConfig,
DOCS_URL,
OPKSSH_DOCS_URL,
validateRedirectUris,
} from "./config.js";
import type { CertificateIdentity, TokenStore } from "./token-store.js";
/** How long OPKSSH may wait for the browser sign-in. */
export const AUTH_TIMEOUT_MS = 60 * 1000;
/** Where the identity provider sends the browser back, before 2.9. */
export const LEGACY_CALLBACK_PATH = "/host/opkssh-callback";
/** The plugin's own routes, relative to the install's base URL. */
export const PLUGIN_PATH = "/plugin-api/opkssh";
export const CALLBACK_PATH = `${PLUGIN_PATH}/callback`;
/** The terminal socket, as far as a sign-in needs it. */
export interface SignInSocket {
send: (data: string) => void;
on: (event: "close", listener: () => void) => void;
}
export interface AuthSession {
requestId: string;
userId: string;
hostId: number;
/** OPKSSH's chooser listener on this server. */
localPort: number;
/** OPKSSH's login-callback listener on this server. */
callbackPort: number;
remoteRedirectUri: string;
providers: Array<{ alias: string; issuer: string }>;
status: "starting" | "waiting_for_auth" | "authenticating" | "completed";
socket: SignInSocket;
output: string;
privateKey: string;
sshCert: string;
identity: CertificateIdentity;
process: PluginProcessHandle | null;
timeout: ReturnType<typeof setTimeout> | null;
closed: boolean;
}
export interface StartRequest {
userId: string;
hostId: number;
socket: SignInSocket;
requestOrigin: string;
}
export type AuthSessions = ReturnType<typeof createAuthSessions>;
function send(socket: SignInSocket, message: Record<string, unknown>): void {
try {
socket.send(JSON.stringify(message));
} catch {
// socket already gone
}
}
const CHOOSER_PATTERN =
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/;
const CALLBACK_PORT_PATTERN =
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//;
const PRIVATE_KEY_PATTERN =
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/;
const CERT_PATTERN =
/((?:ecdsa-sha2-nistp256|ssh-rsa|ssh-ed25519)-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/;
const IDENTITY_PATTERN =
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/;
const GENERIC_REDIRECT_ERRORS = [
"redirect_uri",
"redirect uri",
"invalid redirect",
"no matching redirect",
"allowed redirect",
"mismatching redirection",
];
/**
* OPKSSH sign-ins in flight: one `opkssh login` process per session, whose
* chooser and callback listeners the routes proxy. Created per activation.
*/
export function createAuthSessions(
ctx: PluginContext,
deps: {
tokens: TokenStore;
binaryPath: () => Promise<string>;
},
) {
const sessions = new Map<string, AuthSession>();
const oauthStates = new Map<string, string>();
async function configPath(): Promise<string> {
return path.join(await ctx.files.dataDir(), CONFIG_FILE);
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
async function end(requestId: string): Promise<void> {
const session = sessions.get(requestId);
if (!session || session.closed) return;
session.closed = true;
if (session.timeout) clearTimeout(session.timeout);
for (const [state, id] of oauthStates) {
if (id === requestId) oauthStates.delete(state);
}
const child = session.process;
if (child) {
child.kill("SIGTERM");
const forced = setTimeout(() => child.kill("SIGKILL"), 3000);
await child.exited.catch(() => undefined);
clearTimeout(forced);
}
sessions.delete(requestId);
}
async function complete(session: AuthSession): Promise<void> {
if (!session.privateKey.includes("BEGIN OPENSSH PRIVATE KEY")) {
send(session.socket, {
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to extract valid private key from OPKSSH output",
});
return;
}
if (!/-cert-v01@openssh\.com/.test(session.sshCert)) {
send(session.socket, {
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to extract valid SSH certificate from OPKSSH output",
});
return;
}
session.status = "completed";
try {
const expiresAt = await deps.tokens.save(
session.userId,
session.hostId,
{ sshCert: session.sshCert, privateKey: session.privateKey },
session.identity,
);
send(session.socket, {
type: "opkssh_completed",
requestId: session.requestId,
expiresAt,
});
} catch (error) {
ctx.log.error(
`Failed to store OPKSSH certificate: ${error instanceof Error ? error.message : String(error)}`,
);
send(session.socket, {
type: "opkssh_error",
requestId: session.requestId,
error: "Failed to store authentication token",
});
}
await end(session.requestId);
}
function handleOutput(session: AuthSession, chunk: string): void {
if (session.closed) return;
session.output += chunk;
const chooser = session.output.match(CHOOSER_PATTERN);
if (chooser && session.status === "starting") {
session.localPort = parseInt(chooser[1], 10);
session.status = "waiting_for_auth";
const origin = session.remoteRedirectUri.slice(
0,
session.remoteRedirectUri.length -
(session.remoteRedirectUri.endsWith(LEGACY_CALLBACK_PATH)
? LEGACY_CALLBACK_PATH.length
: CALLBACK_PATH.length),
);
send(session.socket, {
type: "opkssh_status",
requestId: session.requestId,
stage: "chooser",
url: `${origin}${PLUGIN_PATH}/chooser/${session.requestId}`,
providers: session.providers,
message: "Please authenticate in your browser",
});
}
const callbackPort = session.output.match(CALLBACK_PORT_PATTERN);
if (callbackPort && !session.callbackPort) {
session.callbackPort = parseInt(callbackPort[1], 10);
}
if (chunk.includes("BEGIN OPENSSH PRIVATE KEY")) {
session.status = "authenticating";
send(session.socket, {
type: "opkssh_status",
requestId: session.requestId,
stage: "authenticating",
message: "Processing authentication...",
});
}
const key = session.output.match(PRIVATE_KEY_PATTERN);
if (key) session.privateKey = key[1].trim();
const cert = session.output.match(CERT_PATTERN);
if (cert) session.sshCert = cert[1].trim();
const identity = session.output.match(IDENTITY_PATTERN);
if (identity) {
session.identity = {
email: identity[1],
sub: identity[2],
issuer: identity[3],
audience: identity[4],
};
}
if (
session.privateKey &&
session.sshCert &&
session.status !== "completed"
) {
void complete(session);
}
}
function handleStderr(session: AuthSession, stderr: string): void {
if (
stderr.includes("Opening browser to") ||
stderr.includes("Open your browser to:") ||
stderr.includes("listening on")
) {
handleOutput(session, stderr);
}
const lower = stderr.toLowerCase();
const configError = (error: string, instructions: string) => {
send(session.socket, {
type: "opkssh_config_error",
requestId: session.requestId,
error,
instructions,
});
void end(session.requestId);
};
if (lower.includes("redirecturi must be localhost")) {
ctx.log.warn("OPKSSH rejected a non-localhost entry in redirect_uris");
configError(
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`The 'redirect_uris' config field is OPKSSH's LOCAL listener, not the public Termix callback. ` +
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
`See documentation: ${DOCS_URL}`,
);
return;
}
if (GENERIC_REDIRECT_ERRORS.some((needle) => lower.includes(needle))) {
ctx.log.warn("OPKSSH reported a redirect_uri error");
configError(
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
`Computed Termix callback URI (sent to provider): ${session.remoteRedirectUri}\n\n` +
`OPKSSH output:\n${stderr.trim()}\n\n` +
`Register '${session.remoteRedirectUri}' as an authorized redirect URI with your OAuth provider. ` +
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
`See documentation: ${DOCS_URL}`,
);
return;
}
if (
stderr.includes("provider not found") ||
stderr.includes("config error") ||
stderr.includes("invalid config") ||
stderr.includes("config not found")
) {
configError(
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
`See documentation: ${OPKSSH_DOCS_URL}`,
);
return;
}
if (
!stderr.includes('exec: "xdg-open"') &&
(stderr.includes("bind: address already in use") ||
stderr.includes("error logging in") ||
stderr.includes("failed to start"))
) {
void end(session.requestId);
}
}
return {
get: (requestId: string) => sessions.get(requestId),
registerOAuthState(state: string, requestId: string): void {
oauthStates.set(state, requestId);
},
/** Finds the session an identity provider callback belongs to. */
takeByOAuthState(state: string): AuthSession | undefined {
const requestId = oauthStates.get(state);
if (!requestId) return undefined;
oauthStates.delete(state);
return sessions.get(requestId);
},
callbackPath,
async start(request: StartRequest): Promise<string> {
const { socket } = request;
let config;
try {
config = await checkConfig(await configPath());
} catch (error) {
send(socket, {
type: "opkssh_error",
error: `OPKSSH directory initialization failed: ${error instanceof Error ? error.message : String(error)}`,
});
return "";
}
if (!config.ok) {
send(socket, {
type: "opkssh_config_error",
requestId: "",
error: config.error,
instructions: config.error,
});
return "";
}
const remoteRedirectUri = `${request.requestOrigin}${await callbackPath()}`;
const redirects = validateRedirectUris(
config.providers,
remoteRedirectUri,
);
if (redirects.ok === false) {
send(socket, {
type: "opkssh_config_error",
requestId: "",
error: redirects.message,
instructions: redirects.message,
});
return "";
}
const requestId = randomUUID();
const session: AuthSession = {
requestId,
userId: request.userId,
hostId: request.hostId,
localPort: 0,
callbackPort: 0,
remoteRedirectUri,
providers: config.providers.map(({ alias, issuer }) => ({
alias,
issuer,
})),
status: "starting",
socket,
output: "",
privateKey: "",
sshCert: "",
identity: {},
process: null,
timeout: null,
closed: false,
};
sessions.set(requestId, session);
try {
const binary = await deps.binaryPath();
const child = await ctx.process.run(binary, [
"login",
"--print-key",
"--disable-browser-open",
`--config-path=${config.configPath}`,
`--remote-redirect-uri=${remoteRedirectUri}`,
]);
session.process = child;
session.timeout = setTimeout(() => {
send(socket, { type: "opkssh_timeout", requestId });
void end(requestId);
}, AUTH_TIMEOUT_MS);
socket.on("close", () => void end(requestId));
child.onStdout((chunk) => handleOutput(session, chunk));
child.onStderr((chunk) => handleStderr(session, chunk));
child.exited.then(
({ code }) => {
if (
code !== 0 &&
session.status !== "completed" &&
!session.closed
) {
send(socket, {
type: "opkssh_error",
requestId,
error: `OPKSSH process exited with code ${code}`,
});
}
void end(requestId);
},
(error: unknown) => {
send(socket, {
type: "opkssh_error",
requestId,
error: `OPKSSH process error: ${error instanceof Error ? error.message : String(error)}`,
});
void end(requestId);
},
);
return requestId;
} catch (error) {
sessions.delete(requestId);
ctx.log.error(
`Failed to start OPKSSH sign-in: ${error instanceof Error ? error.message : String(error)}`,
);
send(socket, {
type: "opkssh_error",
requestId,
error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : String(error)}`,
});
return "";
}
},
cancel: (requestId: string) => end(requestId),
/** Ends every session, for deactivate. */
async closeAll(): Promise<void> {
await Promise.all([...sessions.keys()].map((id) => end(id)));
},
};
}
+77
View File
@@ -0,0 +1,77 @@
import path from "node:path";
import type { PluginBinarySpec } from "@termix/plugin-sdk/backend";
export const DEFAULT_VERSION = "v0.16.0";
const DEFAULT_CHECKSUMS: Record<string, string> = {
"opkssh-linux-amd64":
"c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf",
"opkssh-linux-arm64":
"9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44",
"opkssh-osx-amd64":
"e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146",
"opkssh-osx-arm64":
"be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd",
"opkssh-windows-amd64.exe":
"db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07",
"opkssh-windows-arm64.exe":
"c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af",
};
const OS_NAMES: Record<string, string> = {
linux: "linux",
darwin: "osx",
win32: "windows",
};
const ARCH_NAMES: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
/** The release asset name for this server, e.g. "opkssh-linux-amd64". */
export function binaryName(
platform: string = process.platform,
arch: string = process.arch,
): string {
const os = OS_NAMES[platform] ?? platform;
const cpu = ARCH_NAMES[arch] ?? arch;
return `opkssh-${os}-${cpu}${platform === "win32" ? ".exe" : ""}`;
}
/**
* What ctx.process.ensureBinary needs for OPKSSH.
*
* The pinned version is trusted by its built-in checksums. OPKSSH_VERSION
* picks another release, and then OPKSSH_SHA256 must give its checksum.
*
* The Docker image bakes the binary into /app/opkssh-bundled (the
* opkssh-downloader stage), which is checked first so an offline install
* never downloads. OPKSSH_BUNDLED_DIR points somewhere else.
*/
export function binarySpec(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform,
arch: string = process.arch,
cwd: string = process.cwd(),
): PluginBinarySpec {
const name = binaryName(platform, arch);
const version = env.OPKSSH_VERSION || DEFAULT_VERSION;
const sha256 =
version === DEFAULT_VERSION
? DEFAULT_CHECKSUMS[name]
: env.OPKSSH_SHA256?.trim().toLowerCase();
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) {
throw new Error(
`OPKSSH ${version} has no trusted SHA-256 checksum for ${name}`,
);
}
const bundledDir = env.OPKSSH_BUNDLED_DIR || path.join(cwd, "opkssh-bundled");
return {
name,
version,
sha256,
url: `https://github.com/openpubkey/opkssh/releases/download/${version}/${name}`,
prebuilt: [path.join(bundledDir, name)],
};
}
+161
View File
@@ -0,0 +1,161 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { load as loadYaml } from "js-yaml";
export const CONFIG_FILE = "config.yml";
const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh";
const OPKSSH_DOCS_URL =
"https://github.com/openpubkey/opkssh/blob/main/docs/config.md";
const TEMPLATE = `
# OPKSSH Configuration
# OPKSSH Documentation: ${OPKSSH_DOCS_URL}
# Termix Documentation: ${DOCS_URL}
`;
export interface ProviderInfo {
alias: string;
issuer: string;
redirectUris: string[];
}
export type ConfigCheck =
| { ok: true; configPath: string; providers: ProviderInfo[] }
| { ok: false; configPath: string; error: string };
function dockerHint(): string {
const dataDir = process.env.DATA_DIR;
return dataDir && dataDir.startsWith("/app")
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
: "";
}
/** Providers with an alias and issuer; issuers lose their scheme. */
export function parseProviders(content: string): ProviderInfo[] {
const parsed = loadYaml(content) as {
providers?: Array<{
alias?: unknown;
issuer?: unknown;
redirect_uris?: unknown;
}>;
} | null;
if (!parsed?.providers || !Array.isArray(parsed.providers)) return [];
return parsed.providers
.filter(
(p) => typeof p?.alias === "string" && typeof p?.issuer === "string",
)
.map((p) => ({
alias: p.alias as string,
issuer: (p.issuer as string).replace(/^https?:\/\//, ""),
redirectUris: Array.isArray(p.redirect_uris)
? p.redirect_uris.filter((u): u is string => typeof u === "string")
: [],
}));
}
/**
* Reads the config, writing a template when there is none. The file must
* have a providers section with at least one active provider.
*/
export async function checkConfig(configPath: string): Promise<ConfigCheck> {
let content: string;
try {
content = await fs.readFile(configPath, "utf8");
} catch {
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, TEMPLATE, "utf8");
} catch {
// Reported below either way.
}
return {
ok: false,
configPath,
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`,
};
}
if (!content.includes("providers:")) {
return {
ok: false,
configPath,
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}`,
};
}
const hasActiveProvider = content.split("\n").some((line) => {
const trimmed = line.trim();
return trimmed.startsWith("- alias:") || trimmed.startsWith("issuer:");
});
if (!hasActiveProvider) {
return {
ok: false,
configPath,
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`,
};
}
let providers: ProviderInfo[] = [];
try {
providers = parseProviders(content);
} catch {
// OPKSSH reports a broken file itself when it starts.
}
return { ok: true, configPath, providers };
}
function isLocalHost(host: string): boolean {
const bare = host.replace(/^\[|\]$/g, "");
return (
bare === "localhost" ||
bare === "127.0.0.1" ||
bare === "::1" ||
bare === "0:0:0:0:0:0:0:1" ||
bare.startsWith("localhost:") ||
bare.startsWith("127.0.0.1:")
);
}
/**
* OPKSSH's redirect_uris are the candidate ports for its own callback
* listener on this server, so they must be localhost. The public URL the
* identity provider redirects to is passed separately as
* --remote-redirect-uri; it never goes in this field.
*/
export function validateRedirectUris(
providers: ProviderInfo[],
callbackUrl: string,
): { ok: true } | { ok: false; message: string } {
const issues: string[] = [];
for (const provider of providers) {
const nonLocal = provider.redirectUris.filter((uri) => {
try {
return !isLocalHost(new URL(uri).hostname);
} catch {
return true;
}
});
if (nonLocal.length > 0) {
issues.push(
`Provider '${provider.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
);
}
}
if (issues.length === 0) return { ok: true };
return {
ok: false,
message:
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
`${issues.join("\n")}\n\n` +
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
`you do not put it here. Register the public Termix URL with your OAuth provider instead ` +
`(${callbackUrl}).\n\n` +
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
`Docs: ${DOCS_URL}`,
};
}
export { DOCS_URL, OPKSSH_DOCS_URL };
+251
View File
@@ -0,0 +1,251 @@
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
// `alias` field in config.yml. We need the same mapping so we can normalize any
// `op=` query param we receive (which can be alias, issuer with protocol, or
// issuer without protocol depending on client version) to what OPKSSH expects.
function opksshIssuerToName(issuer: string): string | null {
if (!issuer) return null;
const withScheme =
issuer.startsWith("http://") || issuer.startsWith("https://")
? issuer
: `https://${issuer}`;
if (withScheme.startsWith("https://accounts.google.com")) return "google";
if (withScheme.startsWith("https://login.microsoftonline.com"))
return "azure";
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
if (withScheme.startsWith("https://")) {
const host = withScheme.slice("https://".length).split("/")[0];
return host || null;
}
return null;
}
export function normalizeSelectOpParam(
rawOp: string,
providers: Array<{ alias: string; issuer: string }>,
): string {
if (!rawOp) return rawOp;
const knownNames = new Set(
providers
.map((p) => opksshIssuerToName(p.issuer))
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
if (knownNames.has(rawOp)) return rawOp;
const derivedFromRaw = opksshIssuerToName(rawOp);
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
const matchByAlias = providers.find((p) => p.alias === rawOp);
if (matchByAlias) {
const name = opksshIssuerToName(matchByAlias.issuer);
if (name) return name;
}
return rawOp;
}
type OpksshErrorPageOptions = {
title: string;
heading: string;
message: string;
details?: string;
requestId?: string;
statusCode?: number;
};
export function renderErrorPage(opts: OpksshErrorPageOptions): string {
const title = escapeHtml(opts.title);
const heading = escapeHtml(opts.heading);
const message = escapeHtml(opts.message);
const detailsBlock = opts.details
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
: "";
const requestIdBlock = opts.requestId
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
: "";
return `<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #18181b;
color: #fafafa;
padding: 1rem;
}
.container {
text-align: center;
background: #27272a;
padding: 3rem 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 720px;
width: 100%;
}
h1 {
color: #fafafa;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
p {
color: #9ca3af;
font-size: 0.95rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
p + p { margin-top: 0.5rem; }
.details {
color: #d4d4d8;
text-align: left;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.45;
margin-top: 1.25rem;
padding: 0.875rem 1rem;
background: #0f0f11;
border: 1px solid rgba(255, 255, 255, 0.08);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.request-id {
color: #6b7280;
font-size: 0.75rem;
margin-top: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>${heading}</h1>
<p>${message}</p>
${detailsBlock}
${requestIdBlock}
</div>
</body>
</html>`;
}
/**
* Points every URL in a page OPKSSH served at its proxied path, so the
* browser keeps talking to Termix instead of this server's localhost.
* `basePath` is the proxied prefix, e.g. "/plugin-api/opkssh/chooser/<id>".
*/
export function rewriteOpksshHtml(html: string, basePath: string): string {
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
const attrPatterns = ["action", "href", "src", "formaction"];
for (const attr of attrPatterns) {
html = html.replace(
new RegExp(`${attr}="(/[^"]*)`, "g"),
`${attr}="${basePath}$1`,
);
html = html.replace(
new RegExp(`${attr}='(/[^']*)`, "g"),
`${attr}='${basePath}$1`,
);
}
for (const attr of ["href", "action", "src", "formaction"]) {
html = html.replace(
new RegExp(
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
"g",
),
`${attr}="${basePath}/$1`,
);
}
html = html.replace(
new RegExp(
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
"g",
),
`$1${basePath}/$2$3`,
);
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
html = html.replace(
new RegExp(
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
"gi",
),
`$1${basePath}/$2$3`,
);
html = html.replace(
new RegExp(
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
"g",
),
`$1${basePath}/$2$3`,
);
const baseTag = `<base href="${basePath}/">`;
if (html.includes("<base")) {
html = html.replace(/<base[^>]*>/i, baseTag);
} else if (html.includes("<head>")) {
html = html.replace(/<head>/i, `<head>${baseTag}`);
} else {
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
}
return html;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { tokens as tokensTable } from "./tables.js";
import { createTokenStore } from "./token-store.js";
import { createAuthSessions } from "./auth-session.js";
import { binarySpec } from "./binary.js";
import { createOpksshProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(tokensTable);
const tokens = createTokenStore(ctx, table);
// Fetched once per activation, in the background: the Docker image ships a
// copy, anywhere else the first boot downloads it. A sign-in waits for it.
let binary: Promise<string> | null = null;
const binaryPath = () => {
if (!binary) {
binary = ctx.process.ensureBinary(binarySpec()).catch((error) => {
binary = null;
throw error;
});
}
return binary;
};
binaryPath().catch((error) =>
ctx.log.warn(
`OPKSSH binary is not available yet: ${error instanceof Error ? error.message : String(error)}`,
),
);
const sessions = createAuthSessions(ctx, { tokens, binaryPath });
ctx.disposables.add(() => sessions.closeAll());
ctx.auth.registerSshAuthProvider(createOpksshProvider(ctx, tokens, sessions));
registerRoutes(
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
ctx,
sessions,
tokens,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+88
View File
@@ -0,0 +1,88 @@
import type {
PluginContext,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import type { TokenStore } from "./token-store.js";
export const AUTH_TYPE = "opkssh";
const 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.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/**
* Hosts with auth type "opkssh" connect with a cached certificate. Without
* one, the terminal is asked for a browser sign-in ("opkssh_auth_required"),
* which startInteraction runs.
*/
export function createOpksshProvider(
ctx: PluginContext,
tokens: TokenStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthOpkssh",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host, env) => {
const certificate = await tokens.get(env.userId, env.hostId);
if (!certificate) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
flag: "requiresOPKSSHAuth",
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{
privateKey: certificate.privateKey,
certificate: certificate.sshCert,
},
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"OPKSSH authentication failed: " +
(error instanceof Error ? error.message : String(error)),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
ctx.log.warn("OPKSSH authentication failed, forgetting the certificate");
void tokens.remove(env.userId, env.hostId).catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"OPKSSH authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
await sessions.start({
userId: request.userId,
hostId: request.hostId,
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: async (request) => {
if (request.requestId) await sessions.cancel(request.requestId);
},
};
}
+622
View File
@@ -0,0 +1,622 @@
import axios from "axios";
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeSelectOpParam,
renderErrorPage,
rewriteOpksshHtml,
} from "./html.js";
import {
PLUGIN_PATH,
type AuthSession,
type AuthSessions,
} from "./auth-session.js";
import type { TokenStore } from "./token-store.js";
/**
* The browser reaches these without a Termix session: the sign-in may finish
* in a different browser, and the identity provider redirects to /callback.
* Each is matched to its sign-in by the request id, the OAuth state or the
* opkssh_request_id cookie.
*/
export const PUBLIC_PATHS = [
"/chooser/:requestId",
"/chooser/:requestId/*",
"/callback",
"/callback/:requestId",
"/callback/:requestId/*",
];
const REQUEST_COOKIE = "opkssh_request_id";
const MAX_HOPS = 4;
function isLocalHostname(host: string): boolean {
const bare = host.split(":")[0];
return bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]";
}
function readCookie(req: Request, name: string): string | undefined {
const header = req.headers.cookie;
if (!header) return undefined;
for (const part of header.split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === name) return decodeURIComponent(rest.join("="));
}
return undefined;
}
function param(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
export function registerRoutes(
router: Router,
ctx: PluginContext,
sessions: AuthSessions,
tokens: TokenStore,
): void {
/** The plugin's public path prefix on this install, base path included. */
const prefix = (req: Request): string => {
const base = new URL(ctx.http.baseUrl(req)).pathname.replace(/\/$/, "");
return `${base}${PLUGIN_PATH}`;
};
const pathAfter = (req: Request, marker: string): string => {
const full = req.originalUrl || req.url;
const index = full.indexOf(marker);
return index === -1 ? "" : full.slice(index + marker.length);
};
const sessionNotFound = (res: Response, requestId: string) => {
res.status(404).send(
renderErrorPage({
title: "Session Not Found",
heading: "Session Not Found",
message:
"This authentication session has expired or is invalid. Please close this window and try again.",
requestId,
}),
);
};
async function actingUserWithAccess(
req: Request,
res: Response,
): Promise<{ userId: string; hostId: number } | null> {
const userId = ctx.currentActor();
const hostId = Number(param(req.params.hostId));
if (!userId) {
res.status(401).json({ error: "Authentication required" });
return null;
}
if (!Number.isInteger(hostId) || hostId <= 0) {
res.status(400).json({ error: "Invalid host id" });
return null;
}
const access = await ctx.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) {
res.status(404).json({ error: "Host not found" });
return null;
}
return { userId, hostId };
}
/**
* @openapi
* /plugin-api/opkssh/token/{hostId}:
* get:
* summary: Get the cached OPKSSH certificate status for a host
* description: Whether the current user has a valid cached certificate for this host, and when it expires.
* tags: [OPKSSH]
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Certificate status.
* 400:
* description: Invalid host id.
* 404:
* description: Host not found or not accessible.
*/
router.get("/token/:hostId", async (req: Request, res: Response) => {
const target = await actingUserWithAccess(req, res);
if (!target) return;
res.json(await tokens.status(target.userId, target.hostId));
});
/**
* @openapi
* /plugin-api/opkssh/token/{hostId}:
* delete:
* summary: Forget the cached OPKSSH certificate for a host
* description: The next connection to the host asks for a browser sign-in again.
* tags: [OPKSSH]
* parameters:
* - name: hostId
* in: path
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Certificate removed, or there was none.
* 400:
* description: Invalid host id.
* 404:
* description: Host not found or not accessible.
*/
router.delete("/token/:hostId", async (req: Request, res: Response) => {
const target = await actingUserWithAccess(req, res);
if (!target) return;
await tokens.remove(target.userId, target.hostId);
res.json({ success: true });
});
/**
* Follows OPKSSH's /select hops until it names the identity provider:
* /select to /select/ on the chooser, then /login on the callback listener,
* then the provider's authorize URL.
*/
async function proxySelect(
req: Request,
res: Response,
session: AuthSession,
targetPath: string,
): Promise<void> {
const requestId = session.requestId;
const rawQs = targetPath.includes("?")
? targetPath.slice(targetPath.indexOf("?"))
: "";
let qs = rawQs;
if (rawQs) {
try {
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
const rawOp = params.get("op");
if (rawOp) {
const mapped = normalizeSelectOpParam(rawOp, session.providers);
if (mapped !== rawOp) {
params.set("op", mapped);
qs = `?${params.toString()}`;
}
}
} catch {
// keep the raw query
}
}
const chooserHost = `127.0.0.1:${session.localPort}`;
const startUrl = `http://${chooserHost}/select/${qs}`;
const fetchUpstream = async (url: string) => {
let hostHeader = chooserHost;
try {
hostHeader = new URL(url).host;
} catch {
// keep the chooser host
}
const response = await axios({
method: "GET",
url,
maxRedirects: 0,
validateStatus: () => true,
timeout: 10000,
responseType: "text",
transformResponse: (value) => value,
headers: { host: hostHeader },
});
const location = response.headers["location"];
const contentType = response.headers["content-type"];
return {
status: response.status,
location: typeof location === "string" ? location : undefined,
contentType: typeof contentType === "string" ? contentType : "",
body:
typeof response.data === "string"
? response.data
: String(response.data ?? ""),
targetUrl: url,
};
};
try {
let response = await fetchUpstream(startUrl);
for (let hop = 0; hop < MAX_HOPS; hop++) {
if (response.status < 300 || response.status >= 400) break;
const location = response.location;
if (!location) break;
if (location.startsWith("/")) {
let currentHost = chooserHost;
try {
currentHost = new URL(response.targetUrl).host;
} catch {
// keep the chooser host
}
response = await fetchUpstream(`http://${currentHost}${location}`);
continue;
}
if (/^https?:\/\//i.test(location)) {
const parsed = new URL(location);
if (isLocalHostname(parsed.host)) {
if (!session.callbackPort) {
const port = parseInt(parsed.port, 10);
if (!Number.isNaN(port)) session.callbackPort = port;
}
// Send the browser through the proxy so it gets the state cookie
// OPKSSH sets on /login; following it here would swallow it.
res.redirect(
302,
`${prefix(req)}/chooser/${requestId}${parsed.pathname}${parsed.search}`,
);
return;
}
}
break;
}
let external: URL | null = null;
if (
response.status >= 300 &&
response.status < 400 &&
response.location &&
/^https?:\/\//i.test(response.location)
) {
try {
const parsed = new URL(response.location);
if (!isLocalHostname(parsed.host)) external = parsed;
} catch {
external = null;
}
}
if (external) {
const state = external.searchParams.get("state");
if (state) sessions.registerOAuthState(state, requestId);
res.redirect(302, external.toString());
return;
}
const bodyPreview = response.body.slice(0, 512);
ctx.log.error(
`OPKSSH /select did not produce an OAuth redirect (status ${response.status})`,
);
res.status(502).send(
renderErrorPage({
title: "OPKSSH error",
heading: "Failed to get OAuth redirect",
message:
"OPKSSH did not return an external OAuth provider URL. " +
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
"and the Termix callback path. Check the server log for the OPKSSH response body.",
details: [
`Upstream: ${response.targetUrl}`,
`Status: ${response.status}`,
response.location ? `Location: ${response.location}` : "",
`Content-Type: ${response.contentType || "(none)"}`,
"",
bodyPreview
? `Body (first 512 chars):\n${bodyPreview}`
: "Body: (empty)",
]
.filter((line, index) => line || index === 4)
.join("\n"),
requestId,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.log.error(`Error proxying OPKSSH /select: ${message}`);
res.status(502).send(
renderErrorPage({
title: "OPKSSH error",
heading: "Failed to reach OPKSSH service",
message:
"Termix could not connect to the local OPKSSH authentication service. " +
"The OPKSSH process may have exited or is not listening yet.",
details: `Upstream: ${startUrl}\nError: ${message}`,
requestId,
}),
);
}
}
/**
* @openapi
* /plugin-api/opkssh/chooser/{requestId}:
* get:
* summary: Proxy the OPKSSH provider chooser page and its resources
* description: Public. Serves OPKSSH's local chooser for one sign-in, rewriting its URLs to this path. Any subpath is proxied too.
* tags: [OPKSSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Chooser page content.
* 302:
* description: Redirect to the identity provider or the callback listener.
* 404:
* description: Sign-in not found.
* 502:
* description: OPKSSH did not answer as expected.
*/
router.use("/chooser/:requestId", async (req: Request, res: Response) => {
const requestId = param(req.params.requestId);
const session = sessions.get(requestId);
if (!session) {
sessionNotFound(res, requestId);
return;
}
if (!session.localPort) {
res.status(500).send(
renderErrorPage({
title: "Error",
heading: "Authentication Error",
message:
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
requestId,
}),
);
return;
}
const base = prefix(req);
const chooserBase = `${base}/chooser/${requestId}`;
const callbackBase = `${base}/callback/${requestId}`;
const targetPath = pathAfter(req, `/chooser/${requestId}`) || "/chooser";
if (targetPath.startsWith("/select")) {
await proxySelect(req, res, session, targetPath);
return;
}
// The browser is sent here for OPKSSH's login listener so it receives
// the listener's cookies.
const onCallbackListener =
/^\/login(?:\?|$)/.test(targetPath) ||
/^\/login-callback(?:\?|$)/.test(targetPath);
const upstreamPort =
onCallbackListener && session.callbackPort
? session.callbackPort
: session.localPort;
try {
const response = await axios({
method: req.method,
url: `http://127.0.0.1:${upstreamPort}${targetPath}`,
headers: { ...req.headers, host: `127.0.0.1:${upstreamPort}` },
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
for (const [key, value] of Object.entries(response.headers)) {
const lower = key.toLowerCase();
if (lower === "transfer-encoding") continue;
if (lower === "location") {
const location = String(value);
if (location.startsWith("/")) {
res.setHeader(key, `${chooserBase}${location}`);
continue;
}
const local = location.match(
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
);
if (local) {
const port = parseInt(local[1], 10);
const path = local[2] || "/";
const toCallback =
(session.callbackPort && port === session.callbackPort) ||
(port !== session.localPort &&
(path.includes("login") || path.includes("callback")));
res.setHeader(
key,
`${toCallback ? callbackBase : chooserBase}${path}`,
);
continue;
}
try {
const state = new URL(location).searchParams.get("state");
if (state) sessions.registerOAuthState(state, requestId);
} catch {
// not a URL
}
res.setHeader(key, location);
} else if (lower === "set-cookie") {
// Scope OPKSSH's cookies to the proxied callback, where the state
// cookie set by /login has to arrive.
const cookies = Array.isArray(value) ? value : [String(value)];
res.setHeader(
key,
cookies.map((cookie) =>
/;\s*path=/i.test(cookie)
? cookie
.replace(/;\s*domain=[^;]*/gi, "")
.replace(/;\s*path=[^;]*/gi, `; Path=${base}/callback/`)
: `${cookie.replace(/;\s*domain=[^;]*/gi, "")}; Path=${base}/callback/`,
),
);
} else if (value !== undefined) {
res.setHeader(key, value as string | string[]);
}
}
// Survives the identity provider round trip, for when OPKSSH
// redirects with script and no state was captured.
res.cookie(REQUEST_COOKIE, requestId, {
path: `${base}/`,
httpOnly: true,
sameSite: "lax",
maxAge: 5 * 60 * 1000,
});
const contentType = String(response.headers["content-type"] || "");
const data = Buffer.from(response.data as ArrayBuffer);
if (contentType.includes("text/html")) {
res
.status(response.status)
.send(rewriteOpksshHtml(data.toString("utf-8"), chooserBase));
} else {
res.status(response.status).send(data);
}
} catch (error) {
ctx.log.error(
`Error proxying OPKSSH chooser: ${error instanceof Error ? error.message : String(error)}`,
);
res.status(500).send(
renderErrorPage({
title: "Error",
heading: "Error",
message: "Failed to load authentication page. Please try again.",
requestId,
}),
);
}
});
/**
* @openapi
* /plugin-api/opkssh/callback:
* get:
* summary: OAuth callback from the identity provider for an OPKSSH sign-in
* description: Public. The redirect URI registered with the identity provider. Finds the sign-in by OAuth state or the opkssh_request_id cookie and forwards to its proxied callback listener.
* tags: [OPKSSH]
* responses:
* 302:
* description: Forwarded to the sign-in's callback listener.
* 401:
* description: No sign-in matches this callback.
* 503:
* description: OPKSSH's callback listener is not ready yet.
*/
router.get("/callback", (req: Request, res: Response) => {
const base = prefix(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
let session = state ? sessions.takeByOAuthState(state) : undefined;
if (!session) {
const cookieId = readCookie(req, REQUEST_COOKIE);
if (cookieId) {
session = sessions.get(cookieId);
if (session) res.clearCookie(REQUEST_COOKIE, { path: `${base}/` });
}
}
if (!session) {
ctx.log.warn("OPKSSH callback matched no sign-in");
res
.status(401)
.send("Authentication callback failed: unable to identify session");
return;
}
if (!session.callbackPort) {
res.status(503).send("OPKSSH callback listener not ready yet");
return;
}
const query = req.url.includes("?")
? req.url.slice(req.url.indexOf("?"))
: "";
res.redirect(302, `${base}/callback/${session.requestId}${query}`);
});
/**
* @openapi
* /plugin-api/opkssh/callback/{requestId}:
* get:
* summary: Proxy OPKSSH's local callback listener for one sign-in
* description: Public. Forwards to OPKSSH's login-callback listener, which prints the key and certificate once the code exchange succeeds. Any subpath is proxied too.
* tags: [OPKSSH]
* parameters:
* - name: requestId
* in: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The listener's page.
* 404:
* description: Sign-in not found.
* 500:
* description: The listener is not ready.
*/
router.use("/callback/:requestId", async (req: Request, res: Response) => {
const requestId = param(req.params.requestId);
const session = sessions.get(requestId);
if (!session) {
sessionNotFound(res, requestId);
return;
}
if (!session.callbackPort) {
res.status(500).send(
renderErrorPage({
title: "Error",
heading: "Callback Error",
message:
"OPKSSH callback listener not ready. Please try authenticating again.",
requestId,
}),
);
return;
}
const callbackBase = `${prefix(req)}/callback/${requestId}`;
const after = pathAfter(req, `/callback/${requestId}`);
// The listener serves /login-callback whatever the public path was.
const targetPath =
after === "" || after.startsWith("?") ? `/login-callback${after}` : after;
try {
const response = await axios({
method: req.method,
url: `http://127.0.0.1:${session.callbackPort}${targetPath}`,
headers: { ...req.headers, host: `127.0.0.1:${session.callbackPort}` },
data: req.body,
timeout: 10000,
validateStatus: () => true,
maxRedirects: 0,
responseType: "arraybuffer",
});
for (const [key, value] of Object.entries(response.headers)) {
const lower = key.toLowerCase();
if (lower === "transfer-encoding" || value === undefined) continue;
if (lower === "location" && String(value).startsWith("/")) {
res.setHeader(key, `${callbackBase}${String(value)}`);
} else {
res.setHeader(key, value as string | string[]);
}
}
const contentType = String(response.headers["content-type"] || "");
const data = Buffer.from(response.data as ArrayBuffer);
if (contentType.includes("text/html")) {
res
.status(response.status)
.send(rewriteOpksshHtml(data.toString("utf-8"), callbackBase));
} else {
res.status(response.status).send(data);
}
} catch (error) {
ctx.log.error(
`Error handling OPKSSH callback: ${error instanceof Error ? error.message : String(error)}`,
);
res.status(500).send(
renderErrorPage({
title: "Error",
heading: "Error",
message: "An unexpected error occurred. Please try again.",
requestId,
}),
);
}
});
}
+48
View File
@@ -0,0 +1,48 @@
import {
adoptLegacyTable,
defineTable,
id,
refHost,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* One cached certificate per user and host. Adopted from core's
* opkssh_tokens, so the column and index names are the legacy ones.
*
* sshCert and privateKey are sealed with ctx.secrets.seal. Rows written by
* 2.8 were encrypted with the user's data key instead; they do not unseal and
* are treated as expired, which costs one browser sign-in.
*/
export const tokens = adoptLegacyTable(
"opkssh_tokens",
defineTable(
"tokens",
{
id: id(),
userId: refUser(),
hostId: refHost(),
sshCert: text().notNull(),
privateKey: text().notNull(),
email: text(),
sub: text(),
issuer: text(),
audience: text(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
lastUsed: text(),
},
{
uniques: [
{
name: "idx_opkssh_tokens_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const tables = [tokens];
+142
View File
@@ -0,0 +1,142 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
/** How long a certificate is cached after sign-in. */
export const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
export interface StoredCertificate {
sshCert: string;
privateKey: string;
}
export interface CertificateIdentity {
email?: string;
sub?: string;
issuer?: string;
audience?: string;
}
export interface TokenStatus {
exists: boolean;
expiresAt?: string;
email?: string | null;
}
export type TokenStore = ReturnType<typeof createTokenStore>;
export function createTokenStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const byUserAndHost = (userId: string, hostId: number) =>
and(eq(table.userId, userId), eq(table.hostId, hostId));
async function find(userId: string, hostId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(byUserAndHost(userId, hostId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, hostId: number): Promise<boolean> {
const existing = await find(userId, hostId);
if (!existing) return false;
await (await client()).delete(table).where(byUserAndHost(userId, hostId));
await ctx.db.persist();
return true;
}
function expired(row: { expiresAt: string }): boolean {
const expiresAt = new Date(row.expiresAt).getTime();
return !Number.isFinite(expiresAt) || expiresAt <= Date.now();
}
return {
async save(
userId: string,
hostId: number,
certificate: StoredCertificate,
identity: CertificateIdentity,
): Promise<string> {
const expiresAt = new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString();
const values = {
sshCert: await ctx.secrets.seal(certificate.sshCert),
privateKey: await ctx.secrets.seal(certificate.privateKey),
email: identity.email ?? null,
sub: identity.sub ?? null,
issuer: identity.issuer ?? null,
audience: identity.audience ?? null,
createdAt: new Date().toISOString(),
expiresAt,
lastUsed: null,
};
if (await find(userId, hostId)) {
await (
await client()
)
.update(table)
.set(values)
.where(byUserAndHost(userId, hostId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, hostId, ...values });
}
await ctx.db.persist();
return expiresAt;
},
/**
* The usable certificate, or null. An expired row, or one that does not
* unseal (written by 2.8), is deleted.
*/
async get(
userId: string,
hostId: number,
): Promise<StoredCertificate | null> {
const row = await find(userId, hostId);
if (!row) return null;
if (expired(row)) {
await remove(userId, hostId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, hostId);
return null;
}
await (
await client()
)
.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(byUserAndHost(userId, hostId));
await ctx.db.persist({ lazy: true });
return { sshCert, privateKey };
},
async status(userId: string, hostId: number): Promise<TokenStatus> {
const row = await find(userId, hostId);
if (!row) return { exists: false };
if (expired(row)) {
await remove(userId, hostId);
return { exists: false };
}
return { exists: true, expiresAt: row.expiresAt, email: row.email };
},
remove,
};
}
@@ -0,0 +1,28 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh";
/** The host editor's "opkssh" auth method: nothing to fill in, just a pointer. */
export function OpksshAuthEditor() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.label")}
</span>
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("editor.description")}
</p>
</div>
);
}
@@ -0,0 +1,150 @@
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
export type SignInStage =
"chooser" | "waiting" | "authenticating" | "completed" | "error";
interface OpksshDialogProps {
authUrl: string;
stage: SignInStage;
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
onCancel: () => void;
onOpenUrl: () => void;
onSelectProvider?: (alias: string) => void;
backgroundColor?: string;
}
/** The browser sign-in the terminal shows while OPKSSH waits. */
export function OpksshDialog({
authUrl,
stage,
error,
providers,
onCancel,
onOpenUrl,
onSelectProvider,
backgroundColor,
}: OpksshDialogProps) {
const { t } = useTranslation();
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("dialog.title")}
</h3>
</div>
{stage === "chooser" && (
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{t("dialog.description")}
</p>
)}
</div>
<div className="p-4 flex flex-col gap-4">
{stage === "chooser" && (
<>
{providers && providers.length > 0 && onSelectProvider ? (
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<Button
key={provider.alias}
type="button"
variant="outline"
onClick={() => onSelectProvider(provider.alias)}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
>
<ExternalLink className="size-3.5" />
{t("dialog.signInWith", {
provider:
provider.alias.charAt(0).toUpperCase() +
provider.alias.slice(1),
})}
</Button>
))}
</div>
) : authUrl ? (
<Button
type="button"
variant="outline"
onClick={onOpenUrl}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
>
<ExternalLink className="size-3.5" />
{t("dialog.openBrowser")}
</Button>
) : null}
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
</div>
</>
)}
{(stage === "waiting" || stage === "authenticating") && (
<>
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{stage === "waiting"
? t("dialog.waiting")
: t("dialog.authenticating")}
</p>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
</div>
</>
)}
{stage === "error" && error && (
<>
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
{t("common.error")}
</p>
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
{error}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.close")}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { OpksshDialog, type SignInStage } from "./OpksshDialog";
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
export interface TerminalOverlayProps {
host: { id: number | string; [key: string]: unknown } | undefined;
backgroundColor?: string;
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
holdConnectTimeout: (held: boolean) => void;
fail: (message: string) => void;
disconnect: () => void;
send: (type: string, data?: unknown) => void;
connectPayload: () => Record<string, unknown>;
}
interface DialogState {
authUrl: string;
requestId: string;
stage: SignInStage;
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
}
/** How long the sign-in dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
/**
* Runs the OPKSSH browser sign-in for a terminal: asks the server to start
* it, shows the provider chooser, and reconnects once the certificate is
* stored.
*/
export function OpksshOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [dialog, setDialog] = useState<DialogState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const heldRef = useRef(false);
// A second "auth required" after a sign-in means the certificate did not work.
const failedRef = useRef(false);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const showError = (requestId: unknown, error: string) => {
clearTimer();
setDialog((current) =>
current
? { ...current, stage: "error", error }
: {
authUrl: "",
requestId: typeof requestId === "string" ? requestId : "",
stage: "error",
error,
},
);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "opkssh_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
setDialog(null);
hold(false);
fail(t("errors.authFailed"));
} else {
failedRef.current = true;
send("opkssh_start_auth", { hostId: message.hostId });
}
break;
case "opkssh_status":
if (message.stage === "chooser") {
setDialog({
authUrl: typeof message.url === "string" ? message.url : "",
requestId:
typeof message.requestId === "string" ? message.requestId : "",
stage: "chooser",
providers: Array.isArray(message.providers)
? (message.providers as DialogState["providers"])
: undefined,
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
} else {
setDialog((current) =>
current
? { ...current, stage: message.stage as SignInStage }
: null,
);
}
break;
case "opkssh_completed":
clearTimer();
setDialog(null);
send("opkssh_auth_completed", connectPayload());
break;
case "opkssh_error":
failedRef.current = true;
showError(message.requestId, String(message.error ?? ""));
break;
case "opkssh_timeout":
failedRef.current = true;
showError(message.requestId, t("errors.timeout"));
break;
case "opkssh_config_error":
showError(
message.requestId,
String(message.instructions || message.error || ""),
);
break;
case "connected":
case "sessionAttached":
failedRef.current = false;
hold(false);
break;
case "session_closed":
clearTimer();
setDialog(null);
hold(false);
break;
}
});
return () => {
unsubscribe();
clearTimer();
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
}, [
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
t,
]);
if (!dialog) return null;
const close = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (dialog.requestId) {
send("opkssh_cancel", { requestId: dialog.requestId });
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<OpksshDialog
authUrl={dialog.authUrl}
stage={dialog.stage}
error={dialog.error}
providers={dialog.providers}
onCancel={close}
onOpenUrl={() => {
window.open(dialog.authUrl, "_blank");
}}
onSelectProvider={(alias) => {
if (!dialog.authUrl) return;
window.open(
`${dialog.authUrl}/select?op=${encodeURIComponent(alias)}`,
"_blank",
);
setDialog((current) =>
current ? { ...current, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
);
}
@@ -0,0 +1,54 @@
import { toast } from "sonner";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
const CALLBACK_PATH = "plugin-api/opkssh/callback";
const LEGACY_CALLBACK_PATH = "host/opkssh-callback";
/** The public callback URI to register with the identity provider. */
export function redirectUri(
legacy: boolean,
baseUri = document.baseURI,
): string {
return new URL(
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
baseUri,
).toString();
}
export function RedirectUriSetting({ values }: SettingsComponentProps) {
const { t } = useTranslation();
const uri = redirectUri(values.legacyCallback === true);
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium">
{t("settings.redirectUri.label")}
</span>
<div className="flex gap-2">
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
/>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={async () => {
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
else toast.error(t("common.copyFailed"));
}}
>
{t("common.copy")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("settings.redirectUri.description")}
</p>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { OpksshAuthEditor } from "./OpksshAuthEditor";
import { OpksshOverlay } from "./OpksshOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "opkssh",
titleKey: "hosts.filterAuthOpkssh",
component: OpksshAuthEditor,
});
// The browser sign-in while a terminal connects to an OPKSSH host.
app.registerSlotContribution("terminal.overlay", {
actionId: "opkssh.signIn",
titleKey: "dialog.title",
kind: "component",
component: OpksshOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}