mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -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)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,251 @@
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user