mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move vault ssh signing into a plugin (C5)
This commit is contained in:
1 parent
b027717c46
commit
6e56c90aac
198 files changed
+19717
-3445
No files matched your search
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Auth methods that still live in core for 2.9.0, registered through the same
|
||||
* interfaces a plugin uses. Phase C moves each one into its own plugin by
|
||||
* relocating its block from here; D1 deletes this file once it is empty.
|
||||
*
|
||||
* Nothing outside this file should branch on these type names.
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
|
||||
import { registerSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
|
||||
import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
|
||||
const VAULT_REQUIRED_MESSAGE =
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
function vaultProfileId(host: SshConnectHost): number | undefined {
|
||||
return host.vaultProfile?.id ?? undefined;
|
||||
}
|
||||
|
||||
const vaultProvider: SshAuthProvider = {
|
||||
type: "vault",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthVault",
|
||||
needsUserInteraction: true,
|
||||
interaction: "vault",
|
||||
prepare: async (config, host, env) => {
|
||||
const profileId = vaultProfileId(host);
|
||||
if (!profileId) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "Host has no Vault signer profile configured",
|
||||
};
|
||||
}
|
||||
const { getVaultCert } = await import("../hosts/vault-signer-auth.js");
|
||||
const cert = await getVaultCert(env.userId, profileId);
|
||||
if (!cert) {
|
||||
env.log(
|
||||
"info",
|
||||
"No valid Vault certificate found, requesting authentication",
|
||||
);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message: VAULT_REQUIRED_MESSAGE,
|
||||
flag: "requiresVaultAuth",
|
||||
};
|
||||
}
|
||||
try {
|
||||
await applyCertificateAuth(
|
||||
config,
|
||||
env.client,
|
||||
{ privateKey: cert.privateKey, certificate: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message:
|
||||
"Vault SSH signer authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached Vault-signed certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
sshLogger.warn("Vault certificate authentication failed", {
|
||||
operation: "vault_auth_failed",
|
||||
hostId: env.hostId,
|
||||
userId: env.userId,
|
||||
});
|
||||
const profileId = vaultProfileId(host);
|
||||
if (profileId) {
|
||||
void import("../hosts/vault-signer-auth.js")
|
||||
.then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId))
|
||||
.catch(() => {});
|
||||
}
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message:
|
||||
"Vault authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
const { loadVaultProfileForHost, startVaultAuth } =
|
||||
await import("../hosts/vault-oidc-auth.js");
|
||||
const profile = await loadVaultProfileForHost(
|
||||
request.hostId,
|
||||
request.userId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error("No Vault signer profile configured for this host");
|
||||
}
|
||||
await startVaultAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
profile,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (request.hostId === undefined) return;
|
||||
const { cancelVaultAuthByHost } =
|
||||
await import("../hosts/vault-oidc-auth.js");
|
||||
cancelVaultAuthByHost(request.userId, request.hostId);
|
||||
},
|
||||
};
|
||||
|
||||
let sshRegistered = false;
|
||||
|
||||
export function registerLegacySshAuthProviders(): void {
|
||||
if (sshRegistered) return;
|
||||
sshRegistered = true;
|
||||
|
||||
registerSshAuthProvider(vaultProvider);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyProvidersForTests(): void {
|
||||
sshRegistered = false;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* Login methods and second factors, from core and from plugins.
|
||||
*
|
||||
* Password login is registered by core and always present. Everything else
|
||||
* (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through
|
||||
* ctx.auth or, in 2.9.0, from legacy-providers.ts.
|
||||
* (OIDC, LDAP, passkeys, TOTP, ...) registers here from a plugin through
|
||||
* ctx.auth.
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
||||
@@ -7,6 +7,7 @@ import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import { registerVaultCompatRoutes } from "./routes/host-compat-routes.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
@@ -18,7 +19,6 @@ import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-pref
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
@@ -1795,7 +1795,9 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
const vaultCompatRoutes = express.Router();
|
||||
registerVaultCompatRoutes(vaultCompatRoutes);
|
||||
app.use("/vault", vaultCompatRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
|
||||
@@ -978,11 +978,6 @@ const migrateSchema = () => {
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"vault_profile_id",
|
||||
"INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
@@ -1579,67 +1574,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
vault_addr TEXT NOT NULL,
|
||||
vault_namespace TEXT,
|
||||
oidc_mount TEXT,
|
||||
oidc_role TEXT,
|
||||
ssh_mount TEXT,
|
||||
ssh_role TEXT NOT NULL,
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create vault_profiles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create vault_tokens table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
@@ -1878,7 +1812,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippets", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
|
||||
|
||||
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
||||
// grants record whether a capability came from an admin or from bundling.
|
||||
@@ -1896,7 +1829,6 @@ const migrateSchema = () => {
|
||||
"ssh_folders",
|
||||
"snippets",
|
||||
"snippet_folders",
|
||||
"vault_profiles",
|
||||
];
|
||||
|
||||
for (const table of syncIdTables) {
|
||||
|
||||
@@ -194,13 +194,6 @@ export const hosts = mysqlTable(
|
||||
|
||||
credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
overrideCredentialUsername: boolean("override_credential_username"),
|
||||
// When authType is "vault", the host authenticates via a Vault SSH signer
|
||||
// profile (shared settings, no secrets). The signing certificate is obtained
|
||||
// per-user at connect time via an interactive Vault OIDC flow.
|
||||
vaultProfileId: int("vault_profile_id").references(
|
||||
() => vaultProfiles.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
enableTerminal: boolean("enable_terminal")
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -660,71 +653,6 @@ export const auditLogs = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||
// are intended to be shared across users (shared === true makes a profile
|
||||
// visible to every user on the server). Each user authenticates to Vault via an
|
||||
// interactive OIDC flow at connect time; no tokens or keys are stored here.
|
||||
export const vaultProfiles = mysqlTable("vault_profiles", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
vaultNamespace: text("vault_namespace"),
|
||||
// OIDC auth method mount + role used to obtain a Vault token interactively
|
||||
oidcMount: text("oidc_mount"),
|
||||
oidcRole: text("oidc_role"),
|
||||
// SSH secrets engine mount + signer role used to sign the ephemeral key
|
||||
sshMount: text("ssh_mount"),
|
||||
sshRole: text("ssh_role").notNull(),
|
||||
validPrincipals: text("valid_principals"),
|
||||
// Ephemeral keypair algorithm to generate per connection
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||
// Transient: rows live only until the certificate expires. Secret fields are
|
||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||
export const vaultTokens = mysqlTable(
|
||||
"vault_tokens",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
profileId: int("profile_id")
|
||||
.notNull()
|
||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
(table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)],
|
||||
);
|
||||
|
||||
export const apiKeys = mysqlTable(
|
||||
"api_keys",
|
||||
{
|
||||
|
||||
@@ -195,13 +195,6 @@ export const hosts = pgTable(
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
overrideCredentialUsername: boolean("override_credential_username"),
|
||||
// When authType is "vault", the host authenticates via a Vault SSH signer
|
||||
// profile (shared settings, no secrets). The signing certificate is obtained
|
||||
// per-user at connect time via an interactive Vault OIDC flow.
|
||||
vaultProfileId: integer("vault_profile_id").references(
|
||||
() => vaultProfiles.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
enableTerminal: boolean("enable_terminal")
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -661,71 +654,6 @@ export const auditLogs = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||
// are intended to be shared across users (shared === true makes a profile
|
||||
// visible to every user on the server). Each user authenticates to Vault via an
|
||||
// interactive OIDC flow at connect time; no tokens or keys are stored here.
|
||||
export const vaultProfiles = pgTable("vault_profiles", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
vaultNamespace: text("vault_namespace"),
|
||||
// OIDC auth method mount + role used to obtain a Vault token interactively
|
||||
oidcMount: text("oidc_mount"),
|
||||
oidcRole: text("oidc_role"),
|
||||
// SSH secrets engine mount + signer role used to sign the ephemeral key
|
||||
sshMount: text("ssh_mount"),
|
||||
sshRole: text("ssh_role").notNull(),
|
||||
validPrincipals: text("valid_principals"),
|
||||
// Ephemeral keypair algorithm to generate per connection
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||
// Transient: rows live only until the certificate expires. Secret fields are
|
||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||
export const vaultTokens = pgTable(
|
||||
"vault_tokens",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
profileId: integer("profile_id")
|
||||
.notNull()
|
||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
(table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)],
|
||||
);
|
||||
|
||||
export const apiKeys = pgTable(
|
||||
"api_keys",
|
||||
{
|
||||
|
||||
@@ -187,13 +187,6 @@ export const hosts = sqliteTable(
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
// When authType is "vault", the host authenticates via a Vault SSH signer
|
||||
// profile (shared settings, no secrets). The signing certificate is obtained
|
||||
// per-user at connect time via an interactive Vault OIDC flow.
|
||||
vaultProfileId: integer("vault_profile_id").references(
|
||||
() => vaultProfiles.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -653,71 +646,6 @@ export const auditLogs = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||
// are intended to be shared across users (shared === true makes a profile
|
||||
// visible to every user on the server). Each user authenticates to Vault via an
|
||||
// interactive OIDC flow at connect time; no tokens or keys are stored here.
|
||||
export const vaultProfiles = sqliteTable("vault_profiles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
vaultNamespace: text("vault_namespace"),
|
||||
// OIDC auth method mount + role used to obtain a Vault token interactively
|
||||
oidcMount: text("oidc_mount"),
|
||||
oidcRole: text("oidc_role"),
|
||||
// SSH secrets engine mount + signer role used to sign the ephemeral key
|
||||
sshMount: text("ssh_mount"),
|
||||
sshRole: text("ssh_role").notNull(),
|
||||
validPrincipals: text("valid_principals"),
|
||||
// Ephemeral keypair algorithm to generate per connection
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||
// Transient: rows live only until the certificate expires. Secret fields are
|
||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||
export const vaultTokens = sqliteTable(
|
||||
"vault_tokens",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
profileId: integer("profile_id")
|
||||
.notNull()
|
||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
(table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)],
|
||||
);
|
||||
|
||||
export const apiKeys = sqliteTable(
|
||||
"api_keys",
|
||||
{
|
||||
|
||||
@@ -42,8 +42,6 @@ import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
import { UserDataExportRepository } from "./user-data-export-repository.js";
|
||||
import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
import { UserRepository } from "./user-repository.js";
|
||||
import { VaultProfileRepository } from "./vault-profile-repository.js";
|
||||
import { VaultTokenRepository } from "./vault-token-repository.js";
|
||||
|
||||
/**
|
||||
* The context every repository runs against.
|
||||
@@ -416,20 +414,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_profile_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
return new VaultTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_token_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the settings cache. Must run during startup on engines without a
|
||||
* synchronous read, before anything calls getCurrentSettingValue.
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface HostUpdateStateRecord {
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
vaultProfileId: number | null;
|
||||
authType: string;
|
||||
parentHostId: number | null;
|
||||
folder: string | null;
|
||||
@@ -146,7 +145,6 @@ export class HostResolutionRepository {
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
authType: hosts.authType,
|
||||
parentHostId: hosts.parentHostId,
|
||||
folder: hosts.folder,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
|
||||
|
||||
export interface VaultProfileCreateInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string | null;
|
||||
vaultAddr: string;
|
||||
vaultNamespace?: string | null;
|
||||
oidcMount?: string | null;
|
||||
oidcRole?: string | null;
|
||||
sshMount?: string | null;
|
||||
sshRole: string;
|
||||
validPrincipals?: string | null;
|
||||
keyType?: string | null;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export type VaultProfileUpdateInput = Partial<
|
||||
Omit<VaultProfileCreateInput, "userId">
|
||||
> & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export class VaultProfileRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<VaultProfileRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
|
||||
)
|
||||
.orderBy(desc(vaultProfiles.updatedAt));
|
||||
}
|
||||
|
||||
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
|
||||
const [created] = await insertReturning(this.context, vaultProfiles, {
|
||||
syncId: randomUUID(),
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
folder: input.folder,
|
||||
tags: input.tags,
|
||||
vaultAddr: input.vaultAddr,
|
||||
vaultNamespace: input.vaultNamespace,
|
||||
oidcMount: input.oidcMount,
|
||||
oidcRole: input.oidcRole,
|
||||
sshMount: input.sshMount,
|
||||
sshRole: input.sshRole,
|
||||
validPrincipals: input.validPrincipals,
|
||||
keyType: input.keyType,
|
||||
shared: input.shared ?? false,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<VaultProfileRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateById(
|
||||
id: number,
|
||||
input: VaultProfileUpdateInput,
|
||||
): Promise<VaultProfileRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
{
|
||||
...input,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
},
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { vaultTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { upsert } from "./returning.js";
|
||||
|
||||
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
|
||||
|
||||
export interface VaultTokenUpsertInput {
|
||||
userId: string;
|
||||
profileId: number;
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
expiresAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class VaultTokenRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(input: VaultTokenUpsertInput): Promise<void> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
|
||||
await upsert(
|
||||
this.context,
|
||||
vaultTokens,
|
||||
{
|
||||
userId: input.userId,
|
||||
profileId: input.profileId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
{
|
||||
target: [vaultTokens.userId, vaultTokens.profileId],
|
||||
set: {
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<VaultTokenRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsed(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(vaultTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(eq(vaultTokens.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentSharedCredentialSecretsRepository,
|
||||
createCurrentCredentialAccessRepository,
|
||||
createCurrentVaultTokenRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export async function deleteUserAndRelatedData(
|
||||
@@ -96,11 +94,9 @@ export async function deleteUserAndRelatedData(
|
||||
// secret_sources cascade on the user's refUser() foreign key, as the
|
||||
// homepage and secret-sources plugins' adopted tables. The secret
|
||||
// source's token in ctx.secrets is cleaned up generically below, with
|
||||
// every other plugin_settings row for this user. The opkssh plugin's
|
||||
// cached certificates cascade the same way.
|
||||
// every other plugin_settings row for this user. The opkssh and vault
|
||||
// plugins' tables cascade the same way.
|
||||
|
||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
|
||||
await createCurrentOpenTabRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The 2.8 URLs under /host that something outside Termix still depends on:
|
||||
* the OPKSSH and Step CA redirect URIs registered with identity providers.
|
||||
* The 2.8 auth callback URLs something outside Termix still depends on: the
|
||||
* OPKSSH and Step CA redirect URIs registered with identity providers, and
|
||||
* the Vault one listed in Vault OIDC roles.
|
||||
* Each forwards to the plugin route that replaced it, so with the plugin off
|
||||
* it signs nobody in.
|
||||
*/
|
||||
@@ -50,3 +51,21 @@ export function registerHostAuthCompatRoutes(router: Router): void {
|
||||
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerVaultCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/oidc/callback:
|
||||
* get:
|
||||
* summary: Vault OIDC callback (2.8 URL)
|
||||
* description: The redirect URI Vault OIDC roles allowed before 2.9. Redirects to /plugin-api/vault/oidc/callback with the same query, so existing Vault roles keep working.
|
||||
* tags:
|
||||
* - Vault
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the vault plugin's callback.
|
||||
*/
|
||||
router.get("/oidc/callback", (req, res) => {
|
||||
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,6 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
|
||||
"authType",
|
||||
"authMethod",
|
||||
"credentialId",
|
||||
"vaultProfileId",
|
||||
"overrideCredentialUsername",
|
||||
"shareSshAuth",
|
||||
"password",
|
||||
|
||||
@@ -183,7 +183,6 @@ router.post(
|
||||
authType,
|
||||
shareSshAuth,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -305,8 +304,6 @@ router.post(
|
||||
authType: effectiveAuthType,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId:
|
||||
effectiveAuthType === "vault" ? vaultProfileId || null : null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -858,7 +855,6 @@ router.put(
|
||||
authType,
|
||||
shareSshAuth,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -981,8 +977,6 @@ router.put(
|
||||
authType: effectiveAuthType,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId:
|
||||
effectiveAuthType === "vault" ? vaultProfileId || null : null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
vaultProfiles,
|
||||
userPreferences,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
@@ -47,11 +46,6 @@ const HOST_REFERENCES = [
|
||||
syncField: "telnetCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vaultProfileId",
|
||||
syncField: "vaultProfileSyncId",
|
||||
entityType: "vaultProfiles",
|
||||
},
|
||||
{
|
||||
field: "parentHostId",
|
||||
syncField: "parentHostSyncId",
|
||||
@@ -78,11 +72,8 @@ export function registerCoreSyncEntities(): void {
|
||||
order: 10,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "vaultProfiles",
|
||||
table: vaultProfiles,
|
||||
order: 20,
|
||||
});
|
||||
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
|
||||
// reference it: a host's profile is the plugin's profileId host setting.
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "sshFolders",
|
||||
|
||||
@@ -7,8 +7,7 @@ import { getEntity } from "../../plugins/sync-registry.js";
|
||||
* A plain string rather than a union: the set is whatever is registered, and a
|
||||
* plugin can both register an entity and reference one. sync.ts resolves the
|
||||
* name through the registry, so an unknown one is an error there rather than a
|
||||
* silent fallthrough - which is what the old three-way if/else did, routing
|
||||
* anything that was not hosts or sshCredentials to vaultProfiles.
|
||||
* silent fallthrough.
|
||||
*/
|
||||
export type SyncReferenceEntity = string;
|
||||
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { completeVaultAuth } from "../../hosts/vault-oidc-auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
async function userIsAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return !!user?.isAdmin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProfile(
|
||||
row: Record<string, unknown>,
|
||||
currentUserId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
folder: row.folder,
|
||||
tags:
|
||||
typeof row.tags === "string"
|
||||
? row.tags
|
||||
? (row.tags as string).split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
vaultAddr: row.vaultAddr,
|
||||
vaultNamespace: row.vaultNamespace,
|
||||
oidcMount: row.oidcMount,
|
||||
oidcRole: row.oidcRole,
|
||||
sshMount: row.sshMount,
|
||||
sshRole: row.sshRole,
|
||||
validPrincipals: row.validPrincipals,
|
||||
keyType: row.keyType,
|
||||
shared: !!row.shared,
|
||||
owned: row.userId === currentUserId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/oidc/callback:
|
||||
* get:
|
||||
* summary: Vault OIDC callback
|
||||
* description: Unauthenticated endpoint the IdP redirects to after login. Correlates the authorization code to a pending session via the Vault-issued state parameter.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: state
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: error
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: HTML page confirming sign-in success or failure.
|
||||
* 400:
|
||||
* description: Missing parameters or authentication failure.
|
||||
*/
|
||||
router.get("/oidc/callback", async (req: Request, res: Response) => {
|
||||
const state = String(req.query.state || "");
|
||||
const code = String(req.query.code || "");
|
||||
const oidcError = req.query.error ? String(req.query.error) : "";
|
||||
|
||||
const esc = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const html = (title: string, message: string) =>
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;background:#0b0b0c;color:#e5e5e5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.card{max-width:420px;text-align:center;padding:24px;border:1px solid #2a2a2e;border-radius:8px}</style></head>
|
||||
<body><div class="card"><h2>${esc(title)}</h2><p>${esc(message)}</p>
|
||||
<script>setTimeout(function(){window.close()},1500)</script></div></body></html>`;
|
||||
|
||||
if (oidcError) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(html("Vault sign-in failed", `Vault returned: ${oidcError}`));
|
||||
}
|
||||
if (!state || !code) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(html("Vault sign-in failed", "Missing state or code."));
|
||||
}
|
||||
|
||||
const result = await completeVaultAuth(state, code);
|
||||
if (result.ok) {
|
||||
return res.send(
|
||||
html(
|
||||
"Vault sign-in complete",
|
||||
"You can close this window and return to Termix.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
html("Vault sign-in failed", result.error || "Authentication failed."),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles:
|
||||
* get:
|
||||
* summary: List Vault profiles
|
||||
* description: Returns all Vault signer profiles owned by the authenticated user or marked as shared.
|
||||
* tags:
|
||||
* - Vault
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of Vault profile objects.
|
||||
* 500:
|
||||
* description: Failed to list vault profiles.
|
||||
*/
|
||||
router.get(
|
||||
"/profiles",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentVaultProfileRepository().listVisibleToUser(userId);
|
||||
res.json(
|
||||
rows.map((r) => formatProfile(r as Record<string, unknown>, userId)),
|
||||
);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list vault profiles", err);
|
||||
res.status(500).json({ error: "Failed to list vault profiles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles:
|
||||
* post:
|
||||
* summary: Create a Vault profile
|
||||
* description: Creates a new Vault signer profile owned by the authenticated user. The shared flag requires admin privileges.
|
||||
* tags:
|
||||
* - Vault
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - vaultAddr
|
||||
* - sshRole
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* vaultAddr:
|
||||
* type: string
|
||||
* vaultNamespace:
|
||||
* type: string
|
||||
* oidcMount:
|
||||
* type: string
|
||||
* oidcRole:
|
||||
* type: string
|
||||
* sshMount:
|
||||
* type: string
|
||||
* sshRole:
|
||||
* type: string
|
||||
* validPrincipals:
|
||||
* type: string
|
||||
* keyType:
|
||||
* type: string
|
||||
* shared:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created Vault profile object.
|
||||
* 400:
|
||||
* description: Missing required fields.
|
||||
* 403:
|
||||
* description: Non-admin attempted to create a shared profile.
|
||||
* 500:
|
||||
* description: Failed to create vault profile.
|
||||
*/
|
||||
router.post(
|
||||
"/profiles",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
vaultAddr,
|
||||
vaultNamespace,
|
||||
oidcMount,
|
||||
oidcRole,
|
||||
sshMount,
|
||||
sshRole,
|
||||
validPrincipals,
|
||||
keyType,
|
||||
shared,
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(name) ||
|
||||
!isNonEmptyString(vaultAddr) ||
|
||||
!isNonEmptyString(sshRole)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "name, vaultAddr and sshRole are required" });
|
||||
}
|
||||
|
||||
const wantShared = !!shared;
|
||||
if (wantShared && !(await userIsAdmin(userId))) {
|
||||
return res.status(403).json({
|
||||
error: "Only administrators can create shared Vault profiles",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await createCurrentVaultProfileRepository().create({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
vaultAddr: vaultAddr.trim(),
|
||||
vaultNamespace: vaultNamespace?.trim() || null,
|
||||
oidcMount: oidcMount?.trim() || null,
|
||||
oidcRole: oidcRole?.trim() || null,
|
||||
sshMount: sshMount?.trim() || null,
|
||||
sshRole: sshRole.trim(),
|
||||
validPrincipals: validPrincipals?.trim() || null,
|
||||
keyType: keyType?.trim() || null,
|
||||
shared: wantShared,
|
||||
});
|
||||
res
|
||||
.status(201)
|
||||
.json(formatProfile(inserted as Record<string, unknown>, userId));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create vault profile", err);
|
||||
res.status(500).json({ error: "Failed to create vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles/{id}:
|
||||
* put:
|
||||
* summary: Update a Vault profile
|
||||
* description: Updates a Vault signer profile. Only the owner may edit; toggling shared to true requires admin privileges.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated Vault profile object.
|
||||
* 400:
|
||||
* description: Invalid profile id.
|
||||
* 403:
|
||||
* description: Non-owner attempted to edit, or non-admin attempted to share.
|
||||
* 404:
|
||||
* description: Profile not found.
|
||||
* 500:
|
||||
* description: Failed to update vault profile.
|
||||
*/
|
||||
router.put(
|
||||
"/profiles/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return res.status(400).json({ error: "Invalid profile id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentVaultProfileRepository();
|
||||
const existing = await repository.findById(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can edit this profile" });
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
const fields: VaultProfileUpdateInput = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (body.name !== undefined) fields.name = body.name?.trim();
|
||||
if (body.description !== undefined)
|
||||
fields.description = body.description?.trim() || null;
|
||||
if (body.folder !== undefined)
|
||||
fields.folder = body.folder?.trim() || null;
|
||||
if (body.tags !== undefined)
|
||||
fields.tags = Array.isArray(body.tags)
|
||||
? body.tags.join(",")
|
||||
: body.tags || "";
|
||||
if (body.vaultAddr !== undefined && isNonEmptyString(body.vaultAddr))
|
||||
fields.vaultAddr = body.vaultAddr.trim();
|
||||
if (body.vaultNamespace !== undefined)
|
||||
fields.vaultNamespace = body.vaultNamespace?.trim() || null;
|
||||
if (body.oidcMount !== undefined)
|
||||
fields.oidcMount = body.oidcMount?.trim() || null;
|
||||
if (body.oidcRole !== undefined)
|
||||
fields.oidcRole = body.oidcRole?.trim() || null;
|
||||
if (body.sshMount !== undefined)
|
||||
fields.sshMount = body.sshMount?.trim() || null;
|
||||
if (body.sshRole !== undefined && isNonEmptyString(body.sshRole))
|
||||
fields.sshRole = body.sshRole.trim();
|
||||
if (body.validPrincipals !== undefined)
|
||||
fields.validPrincipals = body.validPrincipals?.trim() || null;
|
||||
if (body.keyType !== undefined)
|
||||
fields.keyType = body.keyType?.trim() || null;
|
||||
if (body.shared !== undefined) {
|
||||
if (!!body.shared && !(await userIsAdmin(userId))) {
|
||||
return res.status(403).json({
|
||||
error: "Only administrators can share Vault profiles",
|
||||
});
|
||||
}
|
||||
fields.shared = !!body.shared;
|
||||
}
|
||||
|
||||
const updated = await repository.updateById(id, fields);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
res.json(formatProfile(updated as Record<string, unknown>, userId));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update vault profile", err);
|
||||
res.status(500).json({ error: "Failed to update vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles/{id}:
|
||||
* delete:
|
||||
* summary: Delete a Vault profile
|
||||
* description: Permanently deletes a Vault signer profile. Only the owner may delete it.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deletion confirmed.
|
||||
* 400:
|
||||
* description: Invalid profile id.
|
||||
* 403:
|
||||
* description: Non-owner attempted to delete.
|
||||
* 404:
|
||||
* description: Profile not found.
|
||||
* 500:
|
||||
* description: Failed to delete vault profile.
|
||||
*/
|
||||
router.delete(
|
||||
"/profiles/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return res.status(400).json({ error: "Invalid profile id" });
|
||||
}
|
||||
try {
|
||||
const repository = createCurrentVaultProfileRepository();
|
||||
const existing = await repository.findById(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can delete this profile" });
|
||||
}
|
||||
const deleted = await repository.deleteById(id);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"vaultProfiles",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete vault profile", err);
|
||||
res.status(500).json({ error: "Failed to delete vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -2,8 +2,7 @@
|
||||
* Every SSH auth type the server knows how to connect with.
|
||||
*
|
||||
* Core registers password, key, credential, agent and none. The rest come from
|
||||
* plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from
|
||||
* src/backend/auth/legacy-providers.ts. A type with no provider fails the
|
||||
* plugins through ctx.auth.registerSshAuthProvider. A type with no provider fails the
|
||||
* connect with a message naming the plugin that would provide it.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js";
|
||||
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
|
||||
|
||||
let registered = false;
|
||||
@@ -11,7 +10,6 @@ export function ensureCoreSshAuthProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerBuiltinSshAuthProviders();
|
||||
registerLegacySshAuthProviders();
|
||||
}
|
||||
|
||||
/** Test helper, pairs with resetSshAuthRegistryForTests. */
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface SshConnectHost {
|
||||
*/
|
||||
pluginSettings?: Record<string, Record<string, unknown>> | null;
|
||||
forceKeyboardInteractive?: boolean | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
useSocks5?: boolean | null;
|
||||
|
||||
@@ -600,7 +600,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
let resolvedSocks5Username = socks5Username;
|
||||
let resolvedSocks5Password = socks5Password;
|
||||
let resolvedSocks5ProxyChain = socks5ProxyChain;
|
||||
let resolvedVaultProfileId: number | null = null;
|
||||
if (hostId && userId && !password && !sshKey) {
|
||||
try {
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
@@ -625,9 +624,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined;
|
||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||
resolvedVaultProfileId =
|
||||
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
|
||||
null;
|
||||
if (resolvedHost.useSocks5) {
|
||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||
@@ -695,9 +691,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined;
|
||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||
resolvedVaultProfileId =
|
||||
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ??
|
||||
null;
|
||||
if (resolvedHost.useSocks5) {
|
||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||
@@ -787,9 +780,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
keyPassword: resolvedCredentials.keyPassword,
|
||||
certPublicKey: resolvedCredentials.certPublicKey,
|
||||
terminalConfig: resolvedTerminalConfig ?? null,
|
||||
vaultProfile: resolvedVaultProfileId
|
||||
? { id: resolvedVaultProfileId }
|
||||
: null,
|
||||
jumpHosts: resolvedJumpHosts,
|
||||
useSocks5: resolvedUseSocks5,
|
||||
socks5Host: resolvedSocks5Host,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { findUsableCredential } from "./usable-credential.js";
|
||||
import { resolveExternalSecretRefs } from "./external-secrets.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
|
||||
@@ -232,26 +231,6 @@ export async function resolveHostById(
|
||||
sharedAuthResolution === "recipient-override" ? userId : ownerId,
|
||||
);
|
||||
|
||||
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
|
||||
// certificate itself is obtained per-user at connect time via Vault OIDC.
|
||||
if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") {
|
||||
try {
|
||||
const profile = await createCurrentVaultProfileRepository().findById(
|
||||
host.vaultProfileId as number,
|
||||
);
|
||||
if (profile) {
|
||||
(host as Record<string, unknown>).vaultProfile = profile;
|
||||
host.authType = "vault";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve vault profile for host", {
|
||||
operation: "host_resolver_vault_profile",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard-interactive handlers run synchronously mid-handshake and can
|
||||
// only read their host settings from here.
|
||||
try {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
// Orchestrates the interactive Vault OIDC login that yields a signed,
|
||||
// short-lived SSH certificate. Mirrors the OPKSSH session manager but is
|
||||
// driven entirely over Vault's HTTP API (no external binary).
|
||||
//
|
||||
// Lifecycle:
|
||||
// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC
|
||||
// auth_url, stash a pending session keyed by the Vault-issued `state`,
|
||||
// and send the auth_url to the browser over the terminal WebSocket.
|
||||
// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH.
|
||||
// - completeVaultAuth(): exchange the code for a Vault token, sign the
|
||||
// ephemeral key, cache the cert, and notify the browser to reconnect.
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import {
|
||||
type VaultProfileConfig,
|
||||
generateEphemeralKeyPair,
|
||||
startVaultOidc,
|
||||
completeVaultOidc,
|
||||
signWithVault,
|
||||
storeVaultCert,
|
||||
} from "./vault-signer-auth.js";
|
||||
|
||||
export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback";
|
||||
|
||||
const AUTH_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
interface VaultAuthSession {
|
||||
state: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
profile: VaultProfileConfig;
|
||||
clientNonce: string;
|
||||
ephemeralPrivateKey: string;
|
||||
ephemeralPublicKey: string;
|
||||
ws: WebSocket;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback
|
||||
// can correlate back to the originating session.
|
||||
const sessions = new Map<string, VaultAuthSession>();
|
||||
|
||||
function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
|
||||
return {
|
||||
id: row.id as number,
|
||||
vaultAddr: row.vaultAddr as string,
|
||||
vaultNamespace: (row.vaultNamespace as string | null) ?? null,
|
||||
oidcMount: (row.oidcMount as string | null) ?? null,
|
||||
oidcRole: (row.oidcRole as string | null) ?? null,
|
||||
sshMount: (row.sshMount as string | null) ?? null,
|
||||
sshRole: row.sshRole as string,
|
||||
validPrincipals: (row.validPrincipals as string | null) ?? null,
|
||||
keyType: (row.keyType as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load the Vault profile referenced by a host, or null if not configured. */
|
||||
export async function loadVaultProfileForHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<VaultProfileConfig | null> {
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host || host.vaultProfileId == null) return null;
|
||||
|
||||
const profile = await createCurrentVaultProfileRepository().findById(
|
||||
host.vaultProfileId as number,
|
||||
);
|
||||
if (!profile) return null;
|
||||
|
||||
return rowToProfileConfig(profile as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin an interactive Vault OIDC login and send the auth URL to the browser.
|
||||
*/
|
||||
export async function startVaultAuth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
profile: VaultProfileConfig,
|
||||
ws: WebSocket,
|
||||
requestOrigin: string,
|
||||
): Promise<void> {
|
||||
const ephemeral = generateEphemeralKeyPair(profile.keyType);
|
||||
const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`;
|
||||
|
||||
const { authUrl, state, clientNonce } = await startVaultOidc(
|
||||
profile,
|
||||
redirectUri,
|
||||
);
|
||||
|
||||
const existing = sessions.get(state);
|
||||
if (existing) clearTimeout(existing.timeout);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
sessions.delete(state);
|
||||
}, AUTH_TIMEOUT);
|
||||
|
||||
sessions.set(state, {
|
||||
state,
|
||||
userId,
|
||||
hostId,
|
||||
profile,
|
||||
clientNonce,
|
||||
ephemeralPrivateKey: ephemeral.privateKey,
|
||||
ephemeralPublicKey: ephemeral.publicKey,
|
||||
ws,
|
||||
timeout,
|
||||
});
|
||||
|
||||
sshLogger.info("Started Vault OIDC auth session", {
|
||||
operation: "vault_oidc_start",
|
||||
userId,
|
||||
hostId,
|
||||
profileId: profile.id,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "vault_auth_url",
|
||||
hostId,
|
||||
url: authUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a Vault OIDC login from the browser callback: exchange the code for a
|
||||
* Vault token, sign the ephemeral key, cache the certificate, and notify the
|
||||
* browser to resume the SSH connection.
|
||||
*/
|
||||
export async function completeVaultAuth(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = sessions.get(state);
|
||||
if (!session) {
|
||||
return { ok: false, error: "No matching Vault authentication session" };
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await completeVaultOidc(session.profile, {
|
||||
state,
|
||||
code,
|
||||
clientNonce: session.clientNonce,
|
||||
});
|
||||
const signedCert = await signWithVault(
|
||||
session.profile,
|
||||
token,
|
||||
session.ephemeralPublicKey,
|
||||
);
|
||||
const expiresAt = await storeVaultCert(
|
||||
session.userId,
|
||||
session.profile.id,
|
||||
session.ephemeralPrivateKey,
|
||||
signedCert,
|
||||
);
|
||||
|
||||
sshLogger.success("Completed Vault OIDC auth and cached certificate", {
|
||||
operation: "vault_oidc_complete",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
profileId: session.profile.id,
|
||||
});
|
||||
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "vault_completed",
|
||||
hostId: session.hostId,
|
||||
expiresAt,
|
||||
}),
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
sshLogger.error("Vault OIDC completion failed", e, {
|
||||
operation: "vault_oidc_complete_error",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
try {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "vault_error",
|
||||
hostId: session.hostId,
|
||||
error: msg,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ws may be closed
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
} finally {
|
||||
clearTimeout(session.timeout);
|
||||
sessions.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */
|
||||
export function cancelVaultAuthByHost(userId: string, hostId: number): void {
|
||||
for (const [state, s] of sessions) {
|
||||
if (s.userId === userId && s.hostId === hostId) {
|
||||
clearTimeout(s.timeout);
|
||||
sessions.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// HashiCorp Vault SSH signer authentication.
|
||||
//
|
||||
// Flow (Vault-driven over HTTP):
|
||||
// 1. Generate an ephemeral SSH keypair (never persisted long-term).
|
||||
// 2. The user authenticates to Vault via an interactive OIDC flow
|
||||
// (auth/<mount>/oidc/auth_url -> browser -> auth/<mount>/oidc/callback),
|
||||
// yielding a short-lived Vault token.
|
||||
// 3. Vault's SSH secrets engine signs the ephemeral public key
|
||||
// (<sshMount>/sign/<role>) -> short-lived OpenSSH certificate.
|
||||
// 4. The ephemeral private key + certificate are cached per-user (encrypted)
|
||||
// until the certificate expires, then used to connect via applyCertificateAuth.
|
||||
//
|
||||
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
|
||||
// Vault connection SETTINGS live in shareable vault_profiles rows.
|
||||
//
|
||||
// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and
|
||||
// is re-exported here so callers have a single import surface.
|
||||
|
||||
import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { parseCertValidBefore } from "./vault-signer-core.js";
|
||||
|
||||
export type {
|
||||
VaultProfileConfig,
|
||||
EphemeralKeyPair,
|
||||
} from "./vault-signer-core.js";
|
||||
export {
|
||||
generateEphemeralKeyPair,
|
||||
startVaultOidc,
|
||||
completeVaultOidc,
|
||||
signWithVault,
|
||||
parseCertValidBefore,
|
||||
} from "./vault-signer-core.js";
|
||||
|
||||
// Re-sign once we're within this many seconds of expiry.
|
||||
const EXPIRY_SKEW_SECONDS = 60;
|
||||
|
||||
function cacheRecordId(userId: string, profileId: number): string {
|
||||
return `vault-${userId}-${profileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an ephemeral private key + signed certificate for a user/profile.
|
||||
* Returns the expiry as an ISO string.
|
||||
*/
|
||||
export async function storeVaultCert(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
privateKey: string,
|
||||
signedCert: string,
|
||||
): Promise<string> {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data key not found");
|
||||
}
|
||||
|
||||
const validBefore = parseCertValidBefore(signedCert);
|
||||
const expiresMs =
|
||||
validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000;
|
||||
const expiresAt = new Date(expiresMs).toISOString();
|
||||
|
||||
const recordId = cacheRecordId(userId, profileId);
|
||||
const encryptedCert = FieldCrypto.encryptField(
|
||||
signedCert,
|
||||
userDataKey,
|
||||
recordId,
|
||||
"ssh_cert",
|
||||
);
|
||||
const encryptedKey = FieldCrypto.encryptField(
|
||||
privateKey,
|
||||
userDataKey,
|
||||
recordId,
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await createCurrentVaultTokenRepository().upsert({
|
||||
userId,
|
||||
profileId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cached, still-valid ephemeral key + certificate for a user/profile,
|
||||
* or null if none exists or it has (nearly) expired.
|
||||
*/
|
||||
export async function getVaultCert(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<{ privateKey: string; sshCert: string } | null> {
|
||||
const repository = createCurrentVaultTokenRepository();
|
||||
const row = await repository.findByUserAndProfile(userId, profileId);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const expiresMs = new Date(row.expiresAt).getTime();
|
||||
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
|
||||
await deleteVaultCert(userId, profileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data key not found");
|
||||
}
|
||||
|
||||
const recordId = cacheRecordId(userId, profileId);
|
||||
const sshCert = FieldCrypto.decryptField(
|
||||
row.sshCert,
|
||||
userDataKey,
|
||||
recordId,
|
||||
"ssh_cert",
|
||||
);
|
||||
const privateKey = FieldCrypto.decryptField(
|
||||
row.privateKey,
|
||||
userDataKey,
|
||||
recordId,
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await repository.updateLastUsed(userId, profileId);
|
||||
|
||||
return { privateKey, sshCert };
|
||||
}
|
||||
|
||||
export async function deleteVaultCert(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<void> {
|
||||
await createCurrentVaultTokenRepository().deleteByUserAndProfile(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation,
|
||||
// the Vault OIDC HTTP flow, certificate signing, and certificate parsing.
|
||||
//
|
||||
// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user
|
||||
// certificate cache) so this layer can be unit-tested without the native SQLite
|
||||
// dependency.
|
||||
|
||||
import crypto from "crypto";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
export interface VaultProfileConfig {
|
||||
id: number;
|
||||
vaultAddr: string;
|
||||
vaultNamespace?: string | null;
|
||||
oidcMount?: string | null;
|
||||
oidcRole?: string | null;
|
||||
sshMount?: string | null;
|
||||
sshRole: string;
|
||||
validPrincipals?: string | null;
|
||||
keyType?: string | null;
|
||||
}
|
||||
|
||||
export interface EphemeralKeyPair {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export function normalizeAddr(addr: string): string {
|
||||
return addr.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function trimMount(
|
||||
mount: string | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (profile.vaultNamespace?.trim()) {
|
||||
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function vaultRequest(
|
||||
url: string,
|
||||
method: "GET" | "POST" | "PUT",
|
||||
headers: Record<string, string>,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<any> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to reach Vault at ${url}: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let json: any;
|
||||
if (text) {
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// leave undefined for non-JSON bodies
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errs =
|
||||
json && Array.isArray(json.errors) && json.errors.length
|
||||
? json.errors.join("; ")
|
||||
: text || `HTTP ${response.status}`;
|
||||
throw new Error(`Vault request failed (${response.status}): ${errs}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Generate an ephemeral SSH keypair in OpenSSH format. */
|
||||
export function generateEphemeralKeyPair(
|
||||
keyType?: string | null,
|
||||
): EphemeralKeyPair {
|
||||
let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
|
||||
const options: { bits?: number } = {};
|
||||
switch ((keyType || "ssh-ed25519").trim()) {
|
||||
case "ssh-rsa":
|
||||
ssh2Type = "rsa";
|
||||
options.bits = 4096;
|
||||
break;
|
||||
case "ecdsa-sha2-nistp256":
|
||||
ssh2Type = "ecdsa";
|
||||
options.bits = 256;
|
||||
break;
|
||||
case "ssh-ed25519":
|
||||
default:
|
||||
ssh2Type = "ed25519";
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||
return { privateKey: pair.private, publicKey: pair.public };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce
|
||||
* needed to complete the callback. `redirectUri` must be allowed both by the
|
||||
* Vault role's allowed_redirect_uris and by the IdP.
|
||||
*/
|
||||
export async function startVaultOidc(
|
||||
profile: VaultProfileConfig,
|
||||
redirectUri: string,
|
||||
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
|
||||
const addr = normalizeAddr(profile.vaultAddr);
|
||||
const mount = trimMount(profile.oidcMount, "oidc");
|
||||
const clientNonce = crypto.randomBytes(20).toString("hex");
|
||||
|
||||
const json = await vaultRequest(
|
||||
`${addr}/v1/auth/${mount}/oidc/auth_url`,
|
||||
"POST",
|
||||
vaultHeaders(profile),
|
||||
{
|
||||
role: profile.oidcRole?.trim() || "",
|
||||
redirect_uri: redirectUri,
|
||||
client_nonce: clientNonce,
|
||||
},
|
||||
);
|
||||
|
||||
const authUrl: string | undefined = json?.data?.auth_url;
|
||||
if (!authUrl) {
|
||||
throw new Error("Vault did not return an OIDC auth_url");
|
||||
}
|
||||
|
||||
// Vault embeds the state it generated in the auth_url; we need it to correlate
|
||||
// the browser callback back to this login attempt.
|
||||
let state = "";
|
||||
try {
|
||||
state = new URL(authUrl).searchParams.get("state") || "";
|
||||
} catch {
|
||||
const m = authUrl.match(/[?&]state=([^&]+)/);
|
||||
state = m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
if (!state) {
|
||||
throw new Error("Could not determine OIDC state from Vault auth_url");
|
||||
}
|
||||
|
||||
return { authUrl, state, clientNonce };
|
||||
}
|
||||
|
||||
/** Complete the Vault OIDC callback and return a short-lived Vault token. */
|
||||
export async function completeVaultOidc(
|
||||
profile: VaultProfileConfig,
|
||||
params: { state: string; code: string; clientNonce: string },
|
||||
): Promise<string> {
|
||||
const addr = normalizeAddr(profile.vaultAddr);
|
||||
const mount = trimMount(profile.oidcMount, "oidc");
|
||||
|
||||
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("code", params.code);
|
||||
url.searchParams.set("client_nonce", params.clientNonce);
|
||||
|
||||
const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile));
|
||||
const token: string | undefined = json?.auth?.client_token;
|
||||
if (!token) {
|
||||
throw new Error("Vault OIDC callback did not return a client token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */
|
||||
export async function signWithVault(
|
||||
profile: VaultProfileConfig,
|
||||
vaultToken: string,
|
||||
publicKey: string,
|
||||
): Promise<string> {
|
||||
const addr = normalizeAddr(profile.vaultAddr);
|
||||
const mount = trimMount(profile.sshMount, "ssh-client-signer");
|
||||
|
||||
const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken };
|
||||
const body: Record<string, unknown> = {
|
||||
public_key: publicKey.trim(),
|
||||
cert_type: "user",
|
||||
};
|
||||
if (profile.validPrincipals?.trim()) {
|
||||
body.valid_principals = profile.validPrincipals.trim();
|
||||
}
|
||||
|
||||
const json = await vaultRequest(
|
||||
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
|
||||
"POST",
|
||||
headers,
|
||||
body,
|
||||
);
|
||||
const signedKey: string | undefined = json?.data?.signed_key;
|
||||
if (!signedKey) {
|
||||
throw new Error("Vault sign response did not include a signed_key");
|
||||
}
|
||||
return signedKey.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the "valid before" Unix timestamp out of an OpenSSH certificate.
|
||||
* Returns 0 if it can't be parsed (caller falls back to a conservative TTL).
|
||||
*/
|
||||
export function parseCertValidBefore(signedKey: string): number {
|
||||
try {
|
||||
const parts = signedKey.trim().split(/\s+/);
|
||||
if (parts.length < 2) return 0;
|
||||
const certType = parts[0];
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
|
||||
let pos = 0;
|
||||
const readString = (): void => {
|
||||
const len = blob.readUInt32BE(pos);
|
||||
pos += 4 + len;
|
||||
};
|
||||
const readUint64 = (): number => {
|
||||
const hi = blob.readUInt32BE(pos);
|
||||
const lo = blob.readUInt32BE(pos + 4);
|
||||
pos += 8;
|
||||
return hi * 0x100000000 + lo;
|
||||
};
|
||||
|
||||
readString(); // format id
|
||||
readString(); // nonce
|
||||
|
||||
let keyFields: number;
|
||||
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
|
||||
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
|
||||
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
|
||||
else if (certType.startsWith("ssh-dss")) keyFields = 4;
|
||||
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
|
||||
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
|
||||
else return 0;
|
||||
for (let i = 0; i < keyFields; i++) readString();
|
||||
|
||||
readUint64(); // serial
|
||||
pos += 4; // type (uint32)
|
||||
readString(); // key id
|
||||
readString(); // valid principals
|
||||
readUint64(); // valid after
|
||||
return readUint64(); // valid before
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to parse Vault-signed certificate expiry", {
|
||||
operation: "vault_cert_parse",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/step-ca-settings-migration.js");
|
||||
await runStepCaSettingsMigration();
|
||||
|
||||
const { runVaultSettingsMigration } =
|
||||
await import("./utils/crypto-migration/vault-settings-migration.js");
|
||||
await runVaultSettingsMigration();
|
||||
|
||||
const { runSecretSourcesTokenMigration } =
|
||||
await import("./utils/crypto-migration/secret-sources-token-migration.js");
|
||||
await runSecretSourcesTokenMigration();
|
||||
|
||||
@@ -195,7 +195,6 @@ describe("HostResolutionRepository", () => {
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: null,
|
||||
authType: "password",
|
||||
parentHostId: null,
|
||||
folder: null,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { VaultProfileRepository } from "../../../database/repositories/vault-profile-repository.js";
|
||||
|
||||
describe("VaultProfileRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<VaultProfileRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (
|
||||
id, user_id, name, vault_addr, ssh_role, shared, updated_at
|
||||
)
|
||||
VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'),
|
||||
(2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'),
|
||||
(3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new VaultProfileRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("lists profiles owned by or shared with the user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const rows = await repo.listVisibleToUser("user-1");
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it("creates and reads a profile", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const created = await repo.create({
|
||||
userId: "user-1",
|
||||
name: "new",
|
||||
description: "desc",
|
||||
folder: "folder",
|
||||
tags: "prod,ssh",
|
||||
vaultAddr: "https://vault.new",
|
||||
vaultNamespace: "ns",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "oidc-role",
|
||||
sshMount: "ssh",
|
||||
sshRole: "ssh-role",
|
||||
validPrincipals: "root",
|
||||
keyType: "ed25519",
|
||||
shared: true,
|
||||
});
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "new",
|
||||
vaultAddr: "https://vault.new",
|
||||
sshRole: "ssh-role",
|
||||
shared: true,
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates and deletes profiles", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.updateById(1, {
|
||||
name: "renamed",
|
||||
shared: true,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: 1,
|
||||
name: "renamed",
|
||||
shared: true,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
|
||||
expect(await repo.deleteById(1)).toEqual({ syncId: null });
|
||||
expect(await repo.deleteById(1)).toBeNull();
|
||||
expect(await repo.findById(1)).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all profiles for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-2")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findById(2)).toBeNull();
|
||||
expect(await repo.findById(3)).toBeNull();
|
||||
expect((await repo.findById(1))?.userId).toBe("user-1");
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { VaultTokenRepository } from "../../../database/repositories/vault-token-repository.js";
|
||||
|
||||
describe("VaultTokenRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<VaultTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role)
|
||||
VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r');
|
||||
INSERT INTO vault_tokens (
|
||||
user_id, profile_id, ssh_cert, private_key, expires_at
|
||||
)
|
||||
VALUES
|
||||
('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'),
|
||||
('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new VaultTokenRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts a token by user and profile", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findByUserAndProfile("user-1", 1);
|
||||
expect(existing?.sshCert).toBe("cert-1");
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
profileId: 1,
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.findByUserAndProfile("user-1", 1);
|
||||
expect(updated).toMatchObject({
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates last-used and deletes one token", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
|
||||
).toBe(true);
|
||||
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
|
||||
expect((await repo.findByUserAndProfile("user-1", 1))?.lastUsed).toBe(
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true);
|
||||
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false);
|
||||
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all tokens for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
profileId: 2,
|
||||
sshCert: "cert-extra",
|
||||
privateKey: "key-extra",
|
||||
expiresAt: "2099-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull();
|
||||
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The OPKSSH and Step CA redirect URIs identity providers were registered with before
|
||||
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
|
||||
* 2.9 must keep reaching the plugin's callback, query intact.
|
||||
*/
|
||||
|
||||
@@ -62,3 +62,29 @@ describe("the old Step CA callback", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Vault callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const { registerVaultCompatRoutes } =
|
||||
await import("../../../database/routes/host-compat-routes.js");
|
||||
const router = express.Router();
|
||||
registerVaultCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/vault", router);
|
||||
const vaultServer = http.createServer(app);
|
||||
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
|
||||
const port = (vaultServer.address() as AddressInfo).port;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ describe("isValidEntityType", () => {
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"vaultProfiles",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("sync references", () => {
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 4,
|
||||
parentHostId: 3,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
@@ -34,17 +33,14 @@ describe("sync references", () => {
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vaultProfiles-4",
|
||||
parentHostSyncId: "hosts-3",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
expect(row).not.toHaveProperty("vaultProfileId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["vaultProfiles:vault-sync", 37],
|
||||
["hosts:parent-sync", 52],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
@@ -55,7 +51,6 @@ describe("sync references", () => {
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vault-sync",
|
||||
parentHostSyncId: "parent-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
@@ -66,7 +61,6 @@ describe("sync references", () => {
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 37,
|
||||
parentHostId: 52,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
|
||||
@@ -19,8 +19,7 @@ const mocks = vi.hoisted(() => ({
|
||||
verifier: () => {},
|
||||
createHostVerifier: vi.fn(),
|
||||
applyCertificateAuth: vi.fn(),
|
||||
getVaultCert: vi.fn(),
|
||||
deleteVaultCert: vi.fn(),
|
||||
forgetCert: vi.fn(),
|
||||
applyAgentAuth: vi.fn(),
|
||||
performPortKnocking: vi.fn(),
|
||||
createJumpHostChain: vi.fn(),
|
||||
@@ -41,10 +40,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
||||
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
|
||||
applyCertificateAuth: mocks.applyCertificateAuth,
|
||||
}));
|
||||
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
|
||||
getVaultCert: mocks.getVaultCert,
|
||||
deleteVaultCert: mocks.deleteVaultCert,
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
|
||||
applyAgentAuth: mocks.applyAgentAuth,
|
||||
performPortKnocking: mocks.performPortKnocking,
|
||||
@@ -107,6 +102,39 @@ function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin-style provider that needs a browser sign-in until it has a
|
||||
* certificate, and forgets it when the host rejects it.
|
||||
*/
|
||||
function registerSignInProvider(hasCert: boolean) {
|
||||
return registerSshAuthProvider({
|
||||
type: "signin",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "fixture.label",
|
||||
needsUserInteraction: true,
|
||||
interaction: "signin",
|
||||
prepare: async (config) => {
|
||||
if (!hasCert) {
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "signin",
|
||||
message: "Sign in first",
|
||||
};
|
||||
}
|
||||
config.password = "cert";
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env) => {
|
||||
mocks.forgetCert(env.userId, env.hostId);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "signin",
|
||||
message: "Sign in again",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function build(
|
||||
target: SshConnectHost,
|
||||
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
|
||||
@@ -286,30 +314,20 @@ describe("buildConnectConfig per auth type", () => {
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
it("vault needs a profile, then a cached certificate", async () => {
|
||||
const noProfile = await build(host({ authType: "vault" }));
|
||||
expect(noProfile.outcome).toMatchObject({ status: "error" });
|
||||
// Vault is the vault plugin's provider: see
|
||||
// plugins/vault/tests/backend/vault.test.ts.
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce(null);
|
||||
const noCert = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
// The file manager turns the flag into requiresVaultAuth in its 401.
|
||||
expect(noCert.outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
flag: "requiresVaultAuth",
|
||||
it("a vault host without the vault plugin names it", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "vault" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses vault, which needs the HashiCorp Vault plugin",
|
||||
});
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const ready = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
expect(ready.outcome.status).toBe("ready");
|
||||
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
// Tailscale is a plugin-registered SSH auth provider now: see
|
||||
@@ -454,20 +472,19 @@ describe("connectHost", () => {
|
||||
});
|
||||
|
||||
it("throws the auth outcome instead of connecting", async () => {
|
||||
mocks.getVaultCert.mockResolvedValue(null);
|
||||
const vaultHost = () =>
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } });
|
||||
const attempt = connectHost(vaultHost(), {
|
||||
const dispose = registerSignInProvider(false);
|
||||
const attempt = connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(vaultHost(), {
|
||||
connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("vault-required");
|
||||
).resolves.toBe("signin-required");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("goes through the jump chain and forwards to the target", async () => {
|
||||
@@ -619,19 +636,17 @@ describe("connectHost", () => {
|
||||
|
||||
it("lets the provider react to an auth failure", async () => {
|
||||
FakeSshClient.nextBehaviour = ["auth-fail"];
|
||||
mocks.getVaultCert.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const dispose = registerSignInProvider(true);
|
||||
await expect(
|
||||
connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
|
||||
connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
|
||||
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("pools under the old key shape", async () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
findHostOwnerId,
|
||||
findHostById,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({}),
|
||||
createCurrentUserRepository: () => ({ findById: vi.fn() }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const state = vi.hoisted(() => ({
|
||||
isAdminBypass: false,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
vaultProfile: null as Record<string, unknown> | null,
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
folderCredentialId: null as number | null,
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
@@ -26,9 +25,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.overrideCredentialId,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
findById: async () => state.vaultProfile,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (userId: string) => ({ id: userId, username: userId }),
|
||||
}),
|
||||
@@ -115,7 +111,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
vaultProfileId: null,
|
||||
sudoPassword: "owner-sudo",
|
||||
autostartPassword: "auto-pass",
|
||||
autostartKey: null,
|
||||
@@ -136,7 +131,6 @@ beforeEach(() => {
|
||||
state.isAdminBypass = false;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.vaultProfile = null;
|
||||
state.auditCalls = [];
|
||||
state.folderCredentialId = null;
|
||||
state.sharedSecret = null;
|
||||
@@ -372,13 +366,11 @@ describe("resolveHostById", () => {
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
});
|
||||
|
||||
it("fully replaces Vault authentication with the recipient override", async () => {
|
||||
it("fully replaces a plugin auth type with the recipient override", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "vault",
|
||||
password: null,
|
||||
vaultProfileId: 7,
|
||||
});
|
||||
state.vaultProfile = { id: 7 };
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
@@ -399,7 +391,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
|
||||
expect(host.vaultProfile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the host username when the override credential has none", async () => {
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import {
|
||||
generateEphemeralKeyPair,
|
||||
parseCertValidBefore,
|
||||
startVaultOidc,
|
||||
completeVaultOidc,
|
||||
signWithVault,
|
||||
type VaultProfileConfig,
|
||||
} from "../../hosts/vault-signer-core.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
describe("generateEphemeralKeyPair", () => {
|
||||
for (const keyType of [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ssh-rsa",
|
||||
] as const) {
|
||||
it(`generates a parseable ${keyType} keypair`, () => {
|
||||
const pair = generateEphemeralKeyPair(keyType);
|
||||
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
|
||||
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
|
||||
|
||||
// Both halves must be parseable by the same library that signs/connects.
|
||||
const priv = ssh2Utils.parseKey(pair.privateKey);
|
||||
expect(priv instanceof Error).toBe(false);
|
||||
const pub = ssh2Utils.parseKey(pair.publicKey);
|
||||
expect(pub instanceof Error).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("defaults to ed25519 for unknown key types", () => {
|
||||
const pair = generateEphemeralKeyPair("nonsense");
|
||||
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCertValidBefore", () => {
|
||||
let cert = "";
|
||||
let signedAt = 0;
|
||||
let haveSshKeygen = true;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
|
||||
try {
|
||||
execFileSync("ssh-keygen", [
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
`${dir}/ca`,
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
]);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
`${dir}/user`,
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
]);
|
||||
signedAt = Math.floor(Date.now() / 1000);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-s",
|
||||
`${dir}/ca`,
|
||||
"-I",
|
||||
"test-id",
|
||||
"-n",
|
||||
"root",
|
||||
"-V",
|
||||
"+60m",
|
||||
`${dir}/user.pub`,
|
||||
]);
|
||||
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
|
||||
} catch {
|
||||
haveSshKeygen = false;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads valid_before from a real ssh-keygen certificate", () => {
|
||||
if (!haveSshKeygen) {
|
||||
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
|
||||
return;
|
||||
}
|
||||
const validBefore = parseCertValidBefore(cert);
|
||||
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
|
||||
expect(validBefore).toBeGreaterThan(signedAt + 3300);
|
||||
expect(validBefore).toBeLessThan(signedAt + 3900);
|
||||
});
|
||||
|
||||
it("returns 0 for malformed input", () => {
|
||||
expect(parseCertValidBefore("")).toBe(0);
|
||||
expect(parseCertValidBefore("not-a-cert")).toBe(0);
|
||||
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vault HTTP flow (mocked fetch)", () => {
|
||||
const profile: VaultProfileConfig = {
|
||||
id: 1,
|
||||
vaultAddr: "https://vault.example.com:8200/",
|
||||
vaultNamespace: "team-a",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "developer",
|
||||
sshMount: "ssh-client-signer",
|
||||
sshRole: "my-role",
|
||||
validPrincipals: "root,deploy",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(
|
||||
impl: (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
) => { status?: number; body: unknown },
|
||||
) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
const { status = 200, body } = impl(url, init);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response;
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
it("startVaultOidc posts auth_url and extracts state", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: {
|
||||
data: {
|
||||
auth_url:
|
||||
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await startVaultOidc(
|
||||
profile,
|
||||
"https://termix/vault/oidc/callback",
|
||||
);
|
||||
|
||||
expect(result.state).toBe("ST-abc123");
|
||||
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
|
||||
);
|
||||
expect(calls[0].init.method).toBe("POST");
|
||||
const headers = calls[0].init.headers as Record<string, string>;
|
||||
expect(headers["X-Vault-Namespace"]).toBe("team-a");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
role: "developer",
|
||||
redirect_uri: "https://termix/vault/oidc/callback",
|
||||
});
|
||||
expect(body.client_nonce).toBe(result.clientNonce);
|
||||
});
|
||||
|
||||
it("completeVaultOidc returns the client token", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: { auth: { client_token: "hvs.TESTTOKEN" } },
|
||||
}));
|
||||
|
||||
const token = await completeVaultOidc(profile, {
|
||||
state: "ST-abc123",
|
||||
code: "auth-code",
|
||||
clientNonce: "nonce123",
|
||||
});
|
||||
|
||||
expect(token).toBe("hvs.TESTTOKEN");
|
||||
const url = new URL(calls[0].url);
|
||||
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
|
||||
expect(url.searchParams.get("state")).toBe("ST-abc123");
|
||||
expect(url.searchParams.get("code")).toBe("auth-code");
|
||||
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
|
||||
expect(calls[0].init.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("signWithVault posts the public key and returns signed_key", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: {
|
||||
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
|
||||
},
|
||||
}));
|
||||
|
||||
const cert = await signWithVault(
|
||||
profile,
|
||||
"hvs.TESTTOKEN",
|
||||
"ssh-ed25519 AAAApub comment",
|
||||
);
|
||||
|
||||
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
|
||||
);
|
||||
const headers = calls[0].init.headers as Record<string, string>;
|
||||
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
|
||||
expect(headers["X-Vault-Namespace"]).toBe("team-a");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
public_key: "ssh-ed25519 AAAApub comment",
|
||||
cert_type: "user",
|
||||
valid_principals: "root,deploy",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces Vault error messages", async () => {
|
||||
mockFetch(() => ({
|
||||
status: 400,
|
||||
body: { errors: ["role not found", "permission denied"] },
|
||||
}));
|
||||
|
||||
await expect(
|
||||
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
|
||||
).rejects.toThrow(/role not found; permission denied/);
|
||||
});
|
||||
});
|
||||
|
||||
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
|
||||
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
|
||||
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
|
||||
describe("Vault live signing", () => {
|
||||
const addr = process.env.VAULT_ADDR;
|
||||
const token = process.env.VAULT_TOKEN;
|
||||
const run = !!addr && !!token;
|
||||
|
||||
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
|
||||
const profile: VaultProfileConfig = {
|
||||
id: 99,
|
||||
vaultAddr: addr!,
|
||||
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
|
||||
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
|
||||
validPrincipals: "root",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
const pair = generateEphemeralKeyPair(profile.keyType);
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const cert = await signWithVault(profile, token!, pair.publicKey);
|
||||
|
||||
expect(cert).toMatch(/-cert-v01@openssh\.com /);
|
||||
// The signed cert must parse with the same library used to connect.
|
||||
const parsed = ssh2Utils.parseKey(cert);
|
||||
expect(parsed instanceof Error).toBe(false);
|
||||
|
||||
const validBefore = parseCertValidBefore(cert);
|
||||
expect(validBefore).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -96,15 +96,16 @@ describe("core sync entities", () => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders and
|
||||
// dashboardServiceLinks/homepageItems, now registered by the
|
||||
// network-topology, snippets and homepage plugins rather than core.
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders,
|
||||
// dashboardServiceLinks/homepageItems and vaultProfiles, now registered by
|
||||
// the network-topology, snippets, homepage and vault plugins rather than core.
|
||||
const PLUGIN_OWNED_TYPES = new Set([
|
||||
"networkTopology",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"vaultProfiles",
|
||||
]);
|
||||
|
||||
it("registers every core-owned entity the Electron client knows about", () => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The Vault host settings migration.
|
||||
*
|
||||
* Every host's ssh_data.vault_profile_id must come through into the vault
|
||||
* plugin's profileId host setting, and an install that used Vault keeps the
|
||||
* 2.8 redirect URI. Running it twice must change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
legacyProfiles: 0,
|
||||
adoptedProfiles: 0,
|
||||
pluginInstalled: true,
|
||||
columnGone: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "vault" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: unknown) => {
|
||||
const text = JSON.stringify(query);
|
||||
if (text.includes("vault_profile_id")) {
|
||||
if (state.columnGone) throw new Error("no such column");
|
||||
return state.hostRows.filter((row) => row.vault_profile_id != null);
|
||||
}
|
||||
const count = text.includes("p_vault_profiles")
|
||||
? state.adoptedProfiles
|
||||
: state.legacyProfiles;
|
||||
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
|
||||
},
|
||||
runStatement: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
|
||||
|
||||
const setting = (scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
|
||||
?.value;
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.hostRows = [];
|
||||
state.legacyProfiles = 0;
|
||||
state.adoptedProfiles = 0;
|
||||
state.pluginInstalled = true;
|
||||
state.columnGone = false;
|
||||
});
|
||||
|
||||
describe("runVaultSettingsMigration", () => {
|
||||
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, vault_profile_id: 7 },
|
||||
{ id: 2, vault_profile_id: null },
|
||||
{ id: 3, vault_profile_id: "9" },
|
||||
];
|
||||
state.adoptedProfiles = 2;
|
||||
|
||||
const first = await runVaultSettingsMigration();
|
||||
expect(first).toEqual({
|
||||
hostsMoved: 2,
|
||||
hostsSkipped: 0,
|
||||
legacyCallback: true,
|
||||
});
|
||||
expect(setting("1", "profileId")).toBe("7");
|
||||
expect(setting("2", "profileId")).toBeUndefined();
|
||||
expect(setting("3", "profileId")).toBe("9");
|
||||
expect(setting(null, "legacyCallback")).toBe("true");
|
||||
|
||||
const rows = state.pluginRows.length;
|
||||
const second = await runVaultSettingsMigration();
|
||||
expect(second).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 2,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(state.pluginRows).toHaveLength(rows);
|
||||
});
|
||||
|
||||
it("keeps a host's value already set in the plugin", async () => {
|
||||
state.pluginRows.push({
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "profileId",
|
||||
value: "4",
|
||||
});
|
||||
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
|
||||
await runVaultSettingsMigration();
|
||||
expect(setting("1", "profileId")).toBe("4");
|
||||
});
|
||||
|
||||
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
|
||||
state.legacyProfiles = 1;
|
||||
const result = await runVaultSettingsMigration();
|
||||
expect(result.legacyCallback).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
|
||||
state.columnGone = true;
|
||||
const first = await runVaultSettingsMigration();
|
||||
expect(first.legacyCallback).toBe(false);
|
||||
expect(setting(null, "legacyCallback")).toBe("false");
|
||||
|
||||
state.adoptedProfiles = 3;
|
||||
await runVaultSettingsMigration();
|
||||
expect(setting(null, "legacyCallback")).toBe("false");
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
|
||||
await runVaultSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Moves each host's Vault signer profile out of ssh_data and into the vault
|
||||
* plugin's profileId host setting.
|
||||
*
|
||||
* An install that used Vault keeps sending the pre-2.9 redirect URI
|
||||
* (/vault/oidc/callback), because that is the one its Vault OIDC roles
|
||||
* allow: the plugin's legacyCallback setting is turned on.
|
||||
*
|
||||
* The column stays in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping it there would lose the data first.
|
||||
*
|
||||
* Idempotent: a host or setting that already has a value is skipped, so this
|
||||
* is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { selectRows } from "./raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "vault";
|
||||
const SETTING_KEY = "profileId";
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
vault_profile_id: number | string | null;
|
||||
}
|
||||
|
||||
export interface VaultSettingsMigrationResult {
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
async function legacyRows(): Promise<LegacyHostRow[]> {
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares vault_profile_id.
|
||||
return await selectRows<LegacyHostRow>(
|
||||
sql`SELECT id, vault_profile_id FROM ssh_data WHERE vault_profile_id IS NOT NULL`,
|
||||
);
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether 2.8 had any Vault profile, before or after the plugin adopted them. */
|
||||
async function hadProfiles(): Promise<boolean> {
|
||||
for (const table of ["vault_profiles", "p_vault_profiles"]) {
|
||||
try {
|
||||
const rows = await selectRows<unknown>(
|
||||
sql`SELECT 1 FROM ${sql.identifier(table)} LIMIT 1`,
|
||||
);
|
||||
if (rows.length > 0) return true;
|
||||
} catch {
|
||||
// table not there
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runVaultSettingsMigration(): Promise<VaultSettingsMigrationResult> {
|
||||
const result: VaultSettingsMigrationResult = {
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
legacyCallback: false,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const rows = await legacyRows();
|
||||
|
||||
for (const row of rows) {
|
||||
const profileId = Number(row.vault_profile_id);
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
try {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
);
|
||||
if (existing) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"host",
|
||||
scopeId,
|
||||
SETTING_KEY,
|
||||
JSON.stringify(profileId),
|
||||
);
|
||||
result.hostsMoved++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Vault host settings migration failed for a host", {
|
||||
operation: "vault_settings_migration",
|
||||
hostId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decided once, on the first boot with the plugin: written either way, so
|
||||
// profiles created on a fresh install later never turn it on.
|
||||
try {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
);
|
||||
if (!existing || existing.value === null) {
|
||||
result.legacyCallback = rows.length > 0 || (await hadProfiles());
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"legacyCallback",
|
||||
JSON.stringify(result.legacyCallback),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Vault legacy callback migration failed", {
|
||||
operation: "vault_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved the Vault profile for ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "vault_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -40,7 +40,6 @@ class FieldCrypto {
|
||||
// webhook URLs, which are credentials like any other.
|
||||
notification_channels: new Set(["config"]),
|
||||
termix_identity_ca: new Set(["privateKey"]),
|
||||
vault_tokens: new Set(["sshCert", "privateKey"]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
|
||||
Reference in new issue
Block a user