feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

+4 -2
View File
@@ -9,6 +9,7 @@ import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
@@ -27,7 +28,7 @@ import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import pluginApiRoutes from "./routes/plugin-api-routes.js";
import { mountPluginApi } from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
@@ -1762,6 +1763,7 @@ app.use("/users", userRoutes);
app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
@@ -1785,7 +1787,7 @@ app.use(
"/plugin-assets",
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
);
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
mountPluginApi(app);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
+23
View File
@@ -269,6 +269,29 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (provider_id, subject),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_external_identities_user ON user_external_identities (user_id);
CREATE TABLE IF NOT EXISTS user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, plugin_id, factor_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
+54
View File
@@ -120,6 +120,60 @@ export const trustedDevices = mysqlTable(
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
);
/**
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
*/
export const userExternalIdentities = mysqlTable(
"user_external_identities",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerId: varchar("provider_id", { length: 255 }).notNull(),
/** The provider's id for the user, at most 255 characters. */
subject: varchar("subject", { length: 255 }).notNull(),
email: text("email"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_user_external_identities_provider_subject").on(
table.providerId,
table.subject,
),
index("idx_user_external_identities_user").on(table.userId),
],
);
/**
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
* disabled or removed, so login fails closed instead of skipping the factor.
*/
export const userSecondFactors = mysqlTable(
"user_second_factors",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
factorId: varchar("factor_id", { length: 255 }).notNull(),
enrolledAt: text("enrolled_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_user_second_factors_user_factor").on(
table.userId,
table.pluginId,
table.factorId,
),
],
);
export const webauthnCredentials = mysqlTable("webauthn_credentials", {
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
+54
View File
@@ -121,6 +121,60 @@ export const trustedDevices = pgTable(
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
);
/**
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
*/
export const userExternalIdentities = pgTable(
"user_external_identities",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerId: varchar("provider_id", { length: 255 }).notNull(),
/** The provider's id for the user, at most 255 characters. */
subject: varchar("subject", { length: 255 }).notNull(),
email: text("email"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_user_external_identities_provider_subject").on(
table.providerId,
table.subject,
),
index("idx_user_external_identities_user").on(table.userId),
],
);
/**
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
* disabled or removed, so login fails closed instead of skipping the factor.
*/
export const userSecondFactors = pgTable(
"user_second_factors",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
factorId: varchar("factor_id", { length: 255 }).notNull(),
enrolledAt: text("enrolled_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_user_second_factors_user_factor").on(
table.userId,
table.pluginId,
table.factorId,
),
],
);
export const webauthnCredentials = pgTable("webauthn_credentials", {
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
+54
View File
@@ -111,6 +111,60 @@ export const trustedDevices = sqliteTable(
(table) => [index("idx_trusted_devices_user_id").on(table.userId)],
);
/**
* A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...).
* Replaces identifier strings like "ldap:<provider>:<id>" on users.
*/
export const userExternalIdentities = sqliteTable(
"user_external_identities",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull(),
/** The provider's id for the user, at most 255 characters. */
subject: text("subject").notNull(),
email: text("email"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_user_external_identities_provider_subject").on(
table.providerId,
table.subject,
),
index("idx_user_external_identities_user").on(table.userId),
],
);
/**
* Which second factors a user enrolled in, by plugin. Kept when the plugin is
* disabled or removed, so login fails closed instead of skipping the factor.
*/
export const userSecondFactors = sqliteTable(
"user_second_factors",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
pluginId: text("plugin_id").notNull(),
factorId: text("factor_id").notNull(),
enrolledAt: text("enrolled_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_user_second_factors_user_factor").on(
table.userId,
table.pluginId,
table.factorId,
),
],
);
export const webauthnCredentials = sqliteTable("webauthn_credentials", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -37,6 +37,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
@@ -380,6 +381,13 @@ export function createCurrentPluginRepository(): PluginRepository {
);
}
export function createCurrentUserAuthRepository(): UserAuthRepository {
return new UserAuthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("user_auth_repository_write"),
);
}
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
return new PluginPermissionGrantRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,169 @@
import crypto from "crypto";
import { and, eq } from "drizzle-orm";
import { userExternalIdentities, userSecondFactors } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type UserExternalIdentityRecord =
typeof userExternalIdentities.$inferSelect;
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
/** Subjects are indexed, so they stay within the portable key length. */
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
/**
* Keeps a subject within the index length. Longer ones are hashed, which is
* stable, so the same provider answer always finds the same row.
*/
export function normalizeExternalSubject(subject: string): string {
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
}
/** External sign-in identities and second-factor enrolments. */
export class UserAuthRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findIdentity(
providerId: string,
rawSubject: string,
): Promise<UserExternalIdentityRecord | null> {
const subject = normalizeExternalSubject(rawSubject);
const rows = await this.context.drizzle
.select()
.from(userExternalIdentities)
.where(
and(
eq(userExternalIdentities.providerId, providerId),
eq(userExternalIdentities.subject, subject),
),
)
.limit(1);
return rows[0] ?? null;
}
async listIdentitiesForUser(
userId: string,
): Promise<UserExternalIdentityRecord[]> {
return this.context.drizzle
.select()
.from(userExternalIdentities)
.where(eq(userExternalIdentities.userId, userId));
}
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
return this.context.drizzle.select().from(userExternalIdentities);
}
/** Links or moves an identity to a user. Idempotent. */
async linkIdentity(input: {
userId: string;
providerId: string;
subject: string;
email?: string | null;
}): Promise<void> {
const subject = normalizeExternalSubject(input.subject);
const existing = await this.findIdentity(input.providerId, subject);
if (existing) {
if (existing.userId === input.userId) return;
await this.context.drizzle
.update(userExternalIdentities)
.set({ userId: input.userId, email: input.email ?? existing.email })
.where(eq(userExternalIdentities.id, existing.id));
} else {
await this.context.drizzle.insert(userExternalIdentities).values({
userId: input.userId,
providerId: input.providerId,
subject,
email: input.email ?? null,
createdAt: new Date().toISOString(),
});
}
await this.onWrite?.();
}
async unlinkIdentitiesForUser(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(userExternalIdentities)
.where(eq(userExternalIdentities.userId, userId));
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
async moveIdentities(fromUserId: string, toUserId: string): Promise<void> {
await this.context.drizzle
.update(userExternalIdentities)
.set({ userId: toUserId })
.where(eq(userExternalIdentities.userId, fromUserId));
await this.onWrite?.();
}
async listSecondFactors(userId: string): Promise<UserSecondFactorRecord[]> {
return this.context.drizzle
.select()
.from(userSecondFactors)
.where(eq(userSecondFactors.userId, userId));
}
async listAllSecondFactors(): Promise<UserSecondFactorRecord[]> {
return this.context.drizzle.select().from(userSecondFactors);
}
async recordSecondFactor(
userId: string,
pluginId: string,
factorId: string,
): Promise<void> {
const rows = await this.context.drizzle
.select()
.from(userSecondFactors)
.where(
and(
eq(userSecondFactors.userId, userId),
eq(userSecondFactors.pluginId, pluginId),
eq(userSecondFactors.factorId, factorId),
),
)
.limit(1);
if (rows[0]) return;
await this.context.drizzle.insert(userSecondFactors).values({
userId,
pluginId,
factorId,
enrolledAt: new Date().toISOString(),
});
await this.onWrite?.();
}
async removeSecondFactor(
userId: string,
pluginId: string,
factorId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(userSecondFactors)
.where(
and(
eq(userSecondFactors.userId, userId),
eq(userSecondFactors.pluginId, pluginId),
eq(userSecondFactors.factorId, factorId),
),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.onWrite?.();
return affected;
}
async clearSecondFactors(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(userSecondFactors)
.where(eq(userSecondFactors.userId, userId));
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* The generic login surface: every registered login method and second factor
* is reachable here, core's and plugins' alike. The older per-method routes
* (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers
* so existing clients keep working.
*/
import type { Request, Response, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
import {
readPendingLogin,
redirectWithLoginError,
respondWithLogin,
respondWithRedirectLogin,
sendLoginError,
verifySecondFactorAndRespond,
evaluateSecondFactors,
} from "../../auth/login-pipeline.js";
import { getLoginMethod, listLoginMethods } from "../../auth/registry.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
export interface PublicLoginMethod {
id: string;
pluginId: string;
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string }>;
}
/** What the login screen may know: ids, labels and enabled instances. */
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
ensureCoreLoginProviders();
const methods: PublicLoginMethod[] = [];
for (const method of listLoginMethods()) {
let instances: Array<{ id: string; label: string }> = [];
if (method.describe) {
try {
instances = (await method.describe())
.filter((instance) => instance.enabled)
.map((instance) => ({ id: instance.id, label: instance.label }));
} catch (error) {
authLogger.warn("Login method could not describe itself", {
operation: "login_method_describe",
methodId: method.id,
error,
});
continue;
}
if (instances.length === 0) continue;
}
methods.push({
id: method.id,
pluginId: method.pluginId,
kind: method.kind,
labelKey: method.labelKey,
icon: method.icon,
instances,
});
}
return methods;
}
function instanceParam(req: Request): string | null {
const value = req.query.instance ?? req.body?.instanceId;
return typeof value === "string" && value ? value : null;
}
function methodOr404(req: Request, res: Response) {
ensureCoreLoginProviders();
const method = getLoginMethod(String(req.params.methodId));
if (!method) {
res.status(404).json({ error: "Unknown login method" });
return null;
}
return method;
}
export function registerAuthRoutes(router: Router): void {
/**
* @openapi
* /users/auth/methods:
* get:
* summary: List login methods
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
* tags:
* - Auth
* responses:
* 200:
* description: Login methods.
*/
router.get("/auth/methods", async (_req, res) => {
try {
res.json({ methods: await listPublicLoginMethods() });
} catch (error) {
authLogger.error("Failed to list login methods", error);
res.status(500).json({ error: "Failed to list login methods" });
}
});
/**
* @openapi
* /users/auth/second-factor/{factorId}/challenge:
* post:
* summary: Start a second factor
* description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor.
* tags:
* - Auth
* parameters:
* - in: path
* name: factorId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Challenge data.
* 401:
* description: No pending login.
* 404:
* description: Factor not enabled for this user.
*/
router.post("/auth/second-factor/:factorId/challenge", async (req, res) => {
try {
const lookup = await readPendingLogin(req);
if (!lookup) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const { required } = await evaluateSecondFactors(lookup.userId);
const factor = required.find(
(candidate) => candidate.id === req.params.factorId,
);
if (!factor) {
return res.status(404).json({ error: "Second factor not enabled" });
}
res.json({
challenge: (await factor.challenge?.(lookup.userId)) ?? null,
});
} catch (error) {
sendLoginError(res, error);
}
});
/**
* @openapi
* /users/auth/second-factor/{factorId}/verify:
* post:
* summary: Finish a second factor
* description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie.
* tags:
* - Auth
* parameters:
* - in: path
* name: factorId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful.
* 401:
* description: Wrong answer or no pending login.
* 403:
* description: An enrolled factor is unavailable.
* 429:
* description: Too many attempts.
*/
router.post("/auth/second-factor/:factorId/verify", async (req, res) => {
try {
await verifySecondFactorAndRespond(req, res, String(req.params.factorId));
} catch (error) {
sendLoginError(res, error);
}
});
/**
* @openapi
* /users/auth/{methodId}/start:
* get:
* summary: Start a redirect login
* description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* - in: query
* name: instance
* schema:
* type: string
* responses:
* 200:
* description: Redirect URL.
* 404:
* description: Unknown login method.
*/
router.get("/auth/:methodId/start", async (req, res) => {
const method = methodOr404(req, res);
if (!method) return;
if (method.kind !== "redirect" || !method.start) {
return res.status(400).json({ error: "Not a redirect login method" });
}
try {
res.json(await method.start(req as never, instanceParam(req)));
} catch (error) {
sendLoginError(res, error);
}
});
const callback = async (req: Request, res: Response) => {
const method = methodOr404(req, res);
if (!method) return;
if (!method.callback) {
return res.status(400).json({ error: "Not a redirect login method" });
}
let identity: VerifiedIdentity;
try {
identity = await method.callback(req as never);
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
if (returnTo) return redirectWithLoginError(res, returnTo, error);
return sendLoginError(res, error);
}
await respondWithRedirectLogin(
req,
res,
identity,
{ methodId: method.id, rememberMe: !!identity.rememberMe },
isOidcTokenCallback,
);
};
/**
* @openapi
* /users/auth/{methodId}/callback:
* get:
* summary: Redirect login callback
* description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 302:
* description: Back to the app.
* post:
* summary: Redirect login callback (form post)
* description: Same as the GET form, for providers that post their answer.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 302:
* description: Back to the app.
*/
router.get("/auth/:methodId/callback", callback);
router.post("/auth/:methodId/callback", callback);
/**
* @openapi
* /users/auth/{methodId}/verify:
* post:
* summary: Form login
* description: Checks what the user typed for a form login method, then issues a session or asks for a second factor.
* tags:
* - Auth
* parameters:
* - in: path
* name: methodId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Login successful, or a second factor is required.
* 401:
* description: Wrong credentials.
* 404:
* description: Unknown login method.
*/
router.post("/auth/:methodId/verify", async (req, res) => {
const method = methodOr404(req, res);
if (!method) return;
if (method.kind !== "form" || !method.verify) {
return res.status(400).json({ error: "Not a form login method" });
}
try {
const identity = await method.verify(req as never, instanceParam(req));
await respondWithLogin(req, res, identity, {
methodId: method.id,
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
});
} catch (error) {
if (!(error instanceof LoginMethodError)) {
authLogger.error("Form login failed", error, { methodId: method.id });
}
sendLoginError(res, error);
}
});
}
@@ -4,28 +4,56 @@ import type {
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import ssh2Pkg from "ssh2";
import type { Client as SshClient } from "ssh2";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
const { Client } = ssh2Pkg;
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
if (message.includes("All configured authentication methods failed")) {
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
}
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
return "Could not resolve hostname or connect to server.";
}
if (message.includes("ECONNREFUSED")) {
return "Connection refused. The server may not be running or the port may be incorrect.";
}
if (message.includes("ETIMEDOUT")) {
return "Connection timed out. Check your network connection and server availability.";
}
if (
message.includes("authentication failed") ||
message.includes("Permission denied")
) {
return "Authentication failed. Please check your username and password/key.";
}
if (message === "SSH connection timeout") return "Connection timeout";
return message;
}
async function deploySSHKeyToHost(
hostConfig: Record<string, unknown>,
host: SshConnectHost,
userId: string,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
const publicKey = credData.publicKey as string;
let conn: SshClient;
try {
({ client: conn } = await connectHost(host, {
userId,
purpose: "credential-deploy",
timeoutMs: 120000,
}));
} catch (error) {
return { success: false, error: describeDeployError(error) };
}
return new Promise((resolve) => {
const conn = new Client();
const connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 120000);
conn.on("ready", async () => {
clearTimeout(connectionTimeout);
void (async () => {
try {
await new Promise<void>((resolveCmd, rejectCmd) => {
const cmdTimeout = setTimeout(() => {
@@ -238,121 +266,7 @@ async function deploySSHKeyToHost(
error: getErrorMessage(error, "Deployment failed"),
});
}
});
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
let errorMessage = err.message;
if (
err.message.includes("All configured authentication methods failed")
) {
errorMessage =
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage = "Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"Authentication failed. Please check your username and password/key.";
}
resolve({ success: false, error: errorMessage });
});
try {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
connectionConfig.privateKey = preparePrivateKeyForSSH2(
privateKey,
hostConfig.keyPassword as string | undefined,
);
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} catch (keyError) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid SSH key format: ${getErrorMessage(keyError)}`,
});
return;
}
} else {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
});
return;
}
conn.connect(connectionConfig);
} catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: getErrorMessage(error, "Connection failed"),
});
}
})();
});
}
@@ -456,6 +370,8 @@ export function registerCredentialDeployRoutes(
error: "Public key is required for deployment",
});
}
// Writing authorized_keys is an owner action, so ownership is checked
// before the shared resolver fills in the credentials.
const hostData = await repository.findHostByIdForUser(
targetHostId,
userId,
@@ -468,57 +384,19 @@ export function registerCredentialDeployRoutes(
});
}
const hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
};
if (hostData.authType === "credential" && hostData.credentialId) {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(400).json({
success: false,
error: "Authentication required for credential resolution",
});
}
try {
const hostCredential = await repository.findCredentialByIdForUser(
hostData.credentialId as number,
userId,
);
if (hostCredential) {
hostConfig.authType = hostCredential.authType;
hostConfig.username = hostCredential.username;
if (hostCredential.authType === "password") {
hostConfig.password = hostCredential.password;
} else if (hostCredential.authType === "key") {
hostConfig.privateKey =
hostCredential.privateKey || hostCredential.key;
hostConfig.keyPassword = hostCredential.keyPassword;
}
} else {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
} catch {
return res.status(500).json({
success: false,
error: "Failed to resolve host credentials",
});
}
const resolvedHost = await resolveHostById(targetHostId, userId);
if (!resolvedHost) {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
const deployResult = await deploySSHKeyToHost(
resolvedHost as unknown as SshConnectHost,
userId,
credData,
);
if (deployResult.success) {
res.json({
+20 -5
View File
@@ -1,5 +1,7 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -23,6 +25,12 @@ import {
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
/** Built-in password and key, plus any type a plugin offers for credentials. */
function getCredentialTypes(): string[] {
ensureCoreSshAuthProviders();
return listCredentialTypes();
}
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
@@ -110,16 +118,17 @@ router.post(
return res.status(400).json({ error: "Name is required" });
}
if (!["password", "key"].includes(authType)) {
const credentialTypes = getCredentialTypes();
if (!credentialTypes.includes(authType)) {
authLogger.warn("Invalid auth type provided", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: 'Auth type must be "password" or "key"' });
return res.status(400).json({
error: `Auth type must be one of: ${credentialTypes.join(", ")}`,
});
}
try {
@@ -659,8 +668,14 @@ router.put(
}
if (updateData.username !== undefined)
updateFields.username = updateData.username?.trim() || null;
if (updateData.authType !== undefined)
if (updateData.authType !== undefined) {
if (!getCredentialTypes().includes(updateData.authType)) {
return res.status(400).json({
error: `Auth type must be one of: ${getCredentialTypes().join(", ")}`,
});
}
updateFields.authType = updateData.authType;
}
if (updateData.keyType !== undefined)
updateFields.keyType = updateData.keyType;
+13 -368
View File
@@ -1,76 +1,6 @@
import type { Router } from "express";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
import ldap from "ldapjs";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const authManager = AuthManager.getInstance();
function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
function createLDAPClient(
host: string,
port: number,
useTLS: boolean,
): ldap.Client {
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function ldapBind(
client: ldap.Client,
dn: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
client.bind(dn, password, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function ldapSearch(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function ldapUnbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
/* best effort */
}
}
import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js";
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
export function registerLDAPAuthRoutes(router: Router): void {
/**
@@ -78,7 +8,7 @@ export function registerLDAPAuthRoutes(router: Router): void {
* /users/ldap/login:
* post:
* summary: LDAP login
* description: Authenticates a user against an LDAP server.
* description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login.
* tags:
* - SSO
* requestBody:
@@ -98,312 +28,27 @@ export function registerLDAPAuthRoutes(router: Router): void {
* type: boolean
* responses:
* 200:
* description: Login successful.
* description: Login successful, or a second factor is required.
* 400:
* description: Missing fields.
* 401:
* description: Invalid credentials.
* 403:
* description: User not allowed.
* description: User not allowed or registration disabled.
* 404:
* description: Provider not found.
* 429:
* description: Too many login attempts.
*/
router.post("/ldap/login", async (req, res) => {
const { providerId, username, password, rememberMe } = req.body as {
providerId: number;
username: string;
password: string;
rememberMe?: boolean;
};
if (!providerId || !username || !password) {
return res
.status(400)
.json({ error: "providerId, username, and password are required" });
}
try {
const provider =
await createCurrentSsoProviderRepository().findById(providerId);
if (!provider || provider.type !== "ldap" || !provider.enabled) {
return res.status(404).json({ error: "LDAP provider not found" });
}
} catch (err) {
authLogger.error("Failed to load LDAP provider", err);
return res.status(500).json({ error: "Failed to load LDAP provider" });
}
const providerResult = await loadProviderConfig(providerId);
if (!providerResult) {
return res.status(404).json({ error: "LDAP provider not found" });
}
const config = providerResult.config as unknown as LDAPProviderConfig;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
return res.status(500).json({ error: "LDAP provider is misconfigured" });
}
const serviceClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const attrList = [
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
];
const entries = await ldapSearch(
serviceClient,
config.userSearchBase,
filter,
attrList,
);
if (entries.length === 0) {
authLogger.warn("LDAP user not found", {
operation: "ldap_login",
username,
});
return res.status(401).json({ error: "Invalid username or password" });
}
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const getAttr = (key: string): string => {
const attr = userEntry.attributes.find((a) => a.type === key);
return attr
? Array.isArray(attr.values)
? attr.values[0]
: String(attr.values)
: "";
};
const ldapIdentifier =
getAttr(config.usernameAttribute || "uid") || username;
const displayName =
getAttr(config.displayNameAttribute || "cn") || username;
const email = getAttr("mail") || getAttr("email") || "";
if (config.allowedUsers) {
if (
!isOIDCUserAllowed(
config.allowedUsers,
ldapIdentifier,
email || undefined,
)
) {
authLogger.warn("LDAP user not in allowed list", {
operation: "ldap_user_not_allowed",
ldapIdentifier,
});
return res.status(403).json({ error: "User not allowed" });
}
}
// Re-bind with user's own credentials to verify password
const userClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(userClient, userDN, password);
} catch {
authLogger.warn("LDAP bind failed - wrong password", {
operation: "ldap_login",
ldapIdentifier,
});
return res.status(401).json({ error: "Invalid username or password" });
} finally {
ldapUnbind(userClient);
}
// Admin group check
let isAdmin = false;
if (config.adminGroup && config.groupSearchBase) {
try {
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
const groupEntries = await ldapSearch(
serviceClient,
config.groupSearchBase,
groupFilter,
["cn", "dn"],
);
isAdmin = groupEntries.some((g) => {
const cn = g.attributes.find((a) => a.type === "cn");
const cnVal = cn
? Array.isArray(cn.values)
? cn.values[0]
: String(cn.values)
: "";
return (
cnVal === config.adminGroup ||
g.dn.toString() === config.adminGroup
);
});
} catch (groupErr) {
authLogger.warn("LDAP group check failed", {
operation: "ldap_group_check",
error: groupErr,
});
}
}
ldapUnbind(serviceClient);
const deviceInfo = parseUserAgent(req);
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
const userRepository = createCurrentUserRepository();
let existingUser =
await userRepository.findByOidcIdentifier(oidcIdentifier);
let userId: string;
if (!existingUser) {
let autoProvision = false;
try {
autoProvision = await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
false,
);
} catch {
/* */
}
if (!autoProvision)
autoProvision =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
const isFirst = (await userRepository.countAll()) === 0;
if (!isFirst && !autoProvision) {
return res.status(403).json({ error: "Registration is disabled" });
}
userId = nanoid();
const createdUser = await userRepository.createFirstSsoUser({
id: userId,
username: displayName,
passwordHash: "",
isAdmin,
isOidc: true,
oidcIdentifier,
ssoProviderId: providerId,
});
const isFirstFinal = createdUser.isFirstUser;
try {
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
await createCurrentRoleRepository().assignRoleNameToUser({
userId,
roleName: defaultRoleName,
grantedBy: userId,
});
} catch {
/* */
}
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(userId, sessionDurationMs);
} catch (encryptionError) {
await userRepository.delete(userId);
authLogger.error(
"Failed to setup LDAP user encryption",
encryptionError,
);
return res
.status(500)
.json({ error: "Failed to setup user security" });
}
existingUser = await userRepository.findById(userId);
if (!existingUser) {
throw new Error("Created LDAP user could not be loaded");
}
} else {
userId = existingUser.id;
// Sync admin status from group membership
if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) {
existingUser =
(await userRepository.update(userId, { isAdmin })) ?? existingUser;
try {
const newRoleName = isAdmin ? "admin" : "user";
const oldRoleName = isAdmin ? "user" : "admin";
await createCurrentRoleRepository().switchUserRoleName({
userId,
addRoleName: newRoleName,
removeRoleName: oldRoleName,
grantedBy: userId,
});
} catch {
/* non-fatal */
}
}
// Update display name if not dual-auth
const isDualAuth =
existingUser.passwordHash && existingUser.passwordHash.trim() !== "";
if (!isDualAuth && existingUser.username !== displayName) {
existingUser =
(await userRepository.update(userId, { username: displayName })) ??
existingUser;
}
}
const userRecord = existingUser;
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch {
/* */
}
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: rememberMe || false,
const identity = await verifyLdapLogin({ body: req.body, ip: req.ip });
await respondWithLogin(req, res, identity, {
methodId: "ldap",
rememberMe: !!req.body?.rememberMe,
});
authLogger.success("LDAP login successful", {
operation: "ldap_login_complete",
userId: userRecord.id,
username: userRecord.username,
});
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: rememberMe
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json({ success: true, message: "Login successful" });
} catch (err) {
ldapUnbind(serviceClient);
authLogger.error("LDAP login failed", err);
return res.status(500).json({ error: "LDAP authentication failed" });
} catch (error) {
sendLoginError(res, error);
}
});
}
@@ -69,4 +69,13 @@ router.use("/:pluginId", (req: Request, res: Response, next) => {
pluginRouter(req, res, next);
});
/**
* Mounts the dispatcher. No auth in front of it: each plugin router runs
* core auth itself and skips it only for the paths it declared public, so a
* webhook or OIDC callback a plugin serves can actually be reached.
*/
export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
export default router;
+66
View File
@@ -104,6 +104,72 @@ router.get("/public", async (_req: Request, res: Response) => {
}
});
/**
* @openapi
* /plugins/public-manifest:
* get:
* summary: List the plugin frontends the login screen needs
* description: >
* No auth. The login screen draws login methods and second-factor steps
* that plugins provide, before anyone has signed in. Returns only
* enabled plugins that contribute login methods or second factors, with
* the fields the browser loader needs and the ids they contribute.
* Nothing operational: no capabilities, grants, settings or errors.
* tags:
* - Plugins
* responses:
* 200:
* description: Login-capable plugins.
*/
router.get("/public-manifest", async (_req: Request, res: Response) => {
try {
res.json(await listPreLoginPlugins());
} catch (error) {
databaseLogger.error(
"Failed to list login plugins",
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_list_public_manifest" },
);
res.status(500).json({ error: "Failed to list plugins" });
}
});
/** Enabled plugins with login or second-factor UI, without admin data. */
export async function listPreLoginPlugins(): Promise<
Array<Record<string, unknown>>
> {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
return records.flatMap((record) => {
if (record.state !== "enabled") return [];
const loaded = loader.get(record.id);
if (loaded?.state !== "active") return [];
let manifest: PluginManifest;
try {
manifest = JSON.parse(record.manifestJson) as PluginManifest;
} catch {
return [];
}
const auth = manifest?.contributes?.auth;
const loginMethods = auth?.loginMethods ?? [];
const secondFactors = auth?.secondFactors ?? [];
if (loginMethods.length === 0 && secondFactors.length === 0) return [];
return [
{
id: record.id,
name: record.name,
version: record.version,
enabled: true,
state: loaded.state,
contributes: { auth: { loginMethods, secondFactors } },
dependencies: manifest.dependencies ?? {},
optionalDependencies: manifest.optionalDependencies ?? {},
...describePluginFrontend(loaded),
},
];
});
}
/**
* @openapi
* /plugins:
+38 -128
View File
@@ -4,7 +4,8 @@ import express, { type Request, type Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import {
createSnippetExecutionResult,
@@ -586,22 +587,12 @@ router.post(
.json({ error: "Notes cannot be executed on a host" });
}
const { Client } = await import("ssh2");
const host = await resolveHostById(parseInt(hostId), userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const password = host.password;
const privateKey = host.key;
const passphrase = host.keyPassword;
const authType = host.authType;
const conn = new Client();
let output = "";
let errorOutput = "";
const resolvedCommand = resolveSnippetCommand(
snippet.content,
{
@@ -613,6 +604,13 @@ router.post(
inputValues && typeof inputValues === "object" ? inputValues : {},
);
const { client: conn } = await connectHost(
host as unknown as SshConnectHost,
{ userId, purpose: "snippet", timeoutMs: 30000 },
);
let output = "";
let errorOutput = "";
const executePromise = new Promise<{
success: boolean;
output: string;
@@ -621,130 +619,42 @@ router.post(
const timeoutMs = getSnippetExecutionTimeoutMs();
let timeout: NodeJS.Timeout | undefined;
conn.on("ready", () => {
conn.exec(resolvedCommand, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
return reject(err);
}
if (timeoutMs) {
timeout = setTimeout(() => {
conn.end();
reject(
new Error(`Command execution timeout (${timeoutMs / 1000}s)`),
);
}, timeoutMs);
}
stream.on("close", (exitCode: number | null) => {
clearTimeout(timeout);
conn.end();
resolve(
createSnippetExecutionResult(exitCode, output, errorOutput),
);
});
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
});
});
conn.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const config: Record<string, unknown> = {
host: host.ip,
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
timeout: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (authType === "password" && password) {
config.password = password;
} else if (authType === "key" && privateKey) {
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
conn.exec(resolvedCommand, (err, stream) => {
if (err) {
conn.end();
return reject(err);
}
} else if (password) {
config.password = password;
} else if (privateKey) {
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
}
}
conn.connect(config);
if (timeoutMs) {
timeout = setTimeout(() => {
conn.end();
reject(
new Error(`Command execution timeout (${timeoutMs / 1000}s)`),
);
}, timeoutMs);
}
stream.on("close", (exitCode: number | null) => {
clearTimeout(timeout);
conn.end();
resolve(
createSnippetExecutionResult(exitCode, output, errorOutput),
);
});
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
});
});
const result = await executePromise;
@@ -0,0 +1,78 @@
import express, { type Request, type Response } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
export interface SshAuthProviderSummary {
type: string;
labelKey: string;
descriptionKey?: string;
pluginId: string;
fields: unknown[];
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
available: boolean;
/** Set when the type belongs to a plugin that is disabled or missing. */
missingPlugin?: { id: string; name: string };
}
export function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
ensureCoreSshAuthProviders();
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
(provider) => ({
type: provider.type,
labelKey: provider.labelKey,
descriptionKey: provider.descriptionKey,
pluginId: provider.pluginId,
fields: provider.fields ?? [],
credentialType: !!provider.credentialType,
needsUserInteraction: !!provider.needsUserInteraction,
supportsBackground: provider.supportsBackground !== false,
available: true,
}),
);
const known = new Set(summaries.map((summary) => summary.type));
for (const owner of listSshAuthTypeOwners()) {
if (known.has(owner.type)) continue;
summaries.push({
type: owner.type,
labelKey: "",
pluginId: owner.pluginId,
fields: [],
credentialType: false,
needsUserInteraction: false,
supportsBackground: false,
available: false,
missingPlugin: { id: owner.pluginId, name: owner.pluginName },
});
}
return summaries;
}
/**
* @openapi
* /ssh-auth/providers:
* get:
* summary: List SSH auth types
* description: Every SSH auth type the server can connect with, from core and from plugins, plus types declared by plugins that are disabled so the host editor can explain them.
* tags:
* - SSH Auth
* responses:
* 200:
* description: Auth type list.
* 401:
* description: Not authenticated.
*/
router.get("/providers", authenticateJWT, (_req: Request, res: Response) => {
res.json({ providers: summarizeSshAuthProviders() });
});
export default router;
@@ -94,6 +94,26 @@ function applyProviderDefaults(
return config;
}
/** Enabled providers as the login screen sees them: no secrets. */
export async function listPublicSsoProviders(): Promise<
Array<{ id: number; name: string; type: string; displayOrder: number }>
> {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }];
}
const providers =
await createCurrentSsoProviderRepository().listEnabledPublic();
// With no provider rows, synthesize one from env vars so SSO login stays
// available when it is configured purely through the environment.
if (providers.length === 0 && envConfig) {
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
}
return providers;
}
export function registerSSOProviderRoutes(router: Router): void {
const requireAdmin = authManager.createAdminMiddleware();
@@ -111,25 +131,7 @@ export function registerSSOProviderRoutes(router: Router): void {
*/
router.get("/sso-providers", async (_req, res) => {
try {
const envConfig = getOIDCConfigFromEnv();
if (envConfig && isOIDCEnvOverrideEnabled()) {
return res.json([
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
]);
}
const providers =
await createCurrentSsoProviderRepository().listEnabledPublic();
// If no DB providers exist, synthesize one from env vars so SSO login
// remains available when configured purely via environment variables.
if (providers.length === 0) {
if (envConfig) {
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
}
}
res.json(providers);
res.json(await listPublicSsoProviders());
} catch (err) {
authLogger.error("Failed to list SSO providers", err);
res.status(500).json({ error: "Failed to list SSO providers" });
@@ -9,8 +9,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentRoleRepository,
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import {
listUserSecondFactors,
resetUserSecondFactors,
} from "../../auth/second-factor-admin.js";
import type {
UserRecord,
UserRepository,
@@ -730,6 +735,11 @@ export function registerUserAdminRoutes(
totpEnabled: false,
totpBackupCodes: null,
});
await createCurrentUserAuthRepository().removeSecondFactor(
targetUser.id,
"core",
"totp",
);
try {
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
@@ -760,6 +770,126 @@ export function registerUserAdminRoutes(
}
});
/**
* @openapi
* /users/admin/{userId}/second-factors:
* get:
* summary: List a user's second factors (admin only)
* description: Every second factor the user is enrolled in, and whether the plugin behind it is running.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Enrolled factors.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
*/
router.get(
"/admin/:userId/second-factors",
authenticateJWT,
async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(
String(req.params.userId),
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
res.json({ factors: await listUserSecondFactors(targetUser.id) });
} catch (err) {
authLogger.error("Failed to list second factors", err);
res.status(500).json({ error: "Failed to list second factors" });
}
},
);
/**
* @openapi
* /users/admin/{userId}/second-factors:
* delete:
* summary: Reset a user's second factors (admin only)
* description: Removes every second factor the user enrolled in, including ones whose plugin is disabled or missing, and forgets their trusted devices. The user can sign in with their first factor and enrol again.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Second factors reset.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
*/
router.delete(
"/admin/:userId/second-factors",
authenticateJWT,
async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(
String(req.params.userId),
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
const removed = await resetUserSecondFactors(targetUser.id);
try {
await DatabaseSaveTrigger.forceSave("admin_reset_second_factors");
} catch (saveError) {
authLogger.error("Failed to persist second factor reset", saveError, {
operation: "admin_reset_second_factors_save_failed",
userId: targetUser.id,
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_reset_second_factors",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
details: JSON.stringify({ removed }),
ipAddress,
userAgent,
success: true,
});
res.json({ message: "Second factors reset", removed });
} catch (err) {
authLogger.error("Failed to reset second factors", err);
res.status(500).json({ error: "Failed to reset second factors" });
}
},
);
/**
* @openapi
* /users/admin/export/{userId}:
@@ -4,7 +4,10 @@ import type { RequestHandler, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import {
createCurrentUserRepository,
createCurrentUserAuthRepository,
} from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
type UserOidcAccountRoutesDeps = {
@@ -120,6 +123,12 @@ export function registerUserOidcAccountRoutes(
scopes: oidcUser.scopes || "openid email profile",
});
// Identities hang off the user row and would go with it.
await createCurrentUserAuthRepository().moveIdentities(
oidcUserId,
targetUser.id,
);
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
@@ -265,6 +274,9 @@ export function registerUserOidcAccountRoutes(
namePath: "",
scopes: "openid email profile",
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
try {
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
+32 -187
View File
@@ -1,6 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -8,16 +9,14 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { isPasswordLoginSettingOn } from "../../auth/core-auth.js";
import { verifySecondFactorAndRespond } from "../../auth/login-pipeline.js";
import { TOTP_FACTOR_ID } from "../../auth/legacy/totp-factor.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import {
createCurrentUserAuthRepository,
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -31,6 +30,17 @@ interface UserTotpRoutesDeps {
isNativeAppRequest: NativeAppRequestChecker;
}
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
export function generateBackupCode(): string {
let code = "";
for (let i = 0; i < 8; i++) {
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
}
return code;
}
export async function verifyTotpReauth(
userRecord: UserRecord,
credential: string,
@@ -245,11 +255,7 @@ export function registerUserTotpRoutes(
}
try {
const passwordLoginAllowed =
await createCurrentSettingsRepository().getBoolean(
"allow_password_login",
true,
);
const passwordLoginAllowed = isPasswordLoginSettingOn();
if (!passwordLoginAllowed) {
return res.status(409).json({
error:
@@ -291,9 +297,7 @@ export function registerUserTotpRoutes(
return res.status(401).json({ error: "Invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
@@ -309,6 +313,11 @@ export function registerUserTotpRoutes(
totpEnabled: true,
totpBackupCodes: storedBackupCodes,
});
await createCurrentUserAuthRepository().recordSecondFactor(
userId,
"core",
TOTP_FACTOR_ID,
);
await createCurrentSessionRepository().revokeAllForUser(
userId,
@@ -419,6 +428,11 @@ export function registerUserTotpRoutes(
totpSecret: null,
totpBackupCodes: null,
});
await createCurrentUserAuthRepository().removeSecondFactor(
userId,
"core",
TOTP_FACTOR_ID,
);
authLogger.info("Two-factor authentication disabled", {
operation: "totp_disable",
userId,
@@ -503,9 +517,7 @@ export function registerUserTotpRoutes(
.json({ error: "Incorrect password or invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase(),
);
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
const backupCodesJson = JSON.stringify(backupCodes);
const storedBackupCodes = userDataKey
@@ -560,180 +572,13 @@ export function registerUserTotpRoutes(
* description: TOTP verification failed.
*/
router.post("/totp/verify-login", async (req, res) => {
const { temp_token, totp_code, rememberMe } = req.body;
if (!temp_token || !totp_code) {
if (!req.body?.temp_token || !req.body?.totp_code) {
return res
.status(400)
.json({ error: "Token and TOTP code are required" });
}
try {
const decoded = await authManager.verifyJWTToken(temp_token);
if (!decoded || !decoded.pendingTOTP) {
return res.status(401).json({ error: "Invalid temporary token" });
}
const userRecord = await createCurrentUserRepository().findById(
decoded.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
if (lockStatus.locked) {
authLogger.warn("TOTP verification blocked due to rate limiting", {
operation: "totp_verify_blocked",
userId: userRecord.id,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
remainingTime: lockStatus.remainingTime,
code: "TOTP_RATE_LIMITED",
});
}
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
if (!userRecord.totpEnabled || !userRecord.totpSecret) {
return res
.status(400)
.json({ error: "TOTP not enabled for this user" });
}
const userDataKey = authManager.getUserDataKey(userRecord.id);
if (!userDataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
userRecord.totpSecret,
userDataKey,
userRecord.id,
"totp_secret",
);
if (!totpSecret) {
await createCurrentUserRepository().update(userRecord.id, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
return res.status(400).json({
error:
"TOTP has been disabled due to password reset. Please set up TOTP again.",
});
}
const verified = speakeasy.totp.verify({
secret: totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
});
if (!verified) {
let backupCodes = [];
try {
backupCodes = userRecord.totpBackupCodes
? JSON.parse(userRecord.totpBackupCodes)
: [];
} catch {
backupCodes = [];
}
if (!Array.isArray(backupCodes)) {
backupCodes = [];
}
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
authLogger.warn("TOTP verification failed - invalid code", {
operation: "totp_verify_failed",
userId: userRecord.id,
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
return res.status(401).json({
error: "Invalid TOTP code",
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
userRecord.id,
),
});
}
backupCodes.splice(backupIndex, 1);
await createCurrentUserRepository().update(userRecord.id, {
totpBackupCodes: JSON.stringify(backupCodes),
});
}
loginRateLimiter.resetTOTPAttempts(userRecord.id);
const deviceInfo = parseUserAgent(req);
if (rememberMe) {
const deviceFingerprint = generateDeviceFingerprint(
deviceInfo,
getDeviceId(req),
);
if (deviceFingerprint) {
await authManager.addTrustedDevice(
userRecord.id,
deviceFingerprint,
deviceInfo.type,
deviceInfo.deviceInfo,
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: userRecord.id,
deviceType: deviceInfo.type,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
rememberMe: !!rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
authLogger.success("TOTP verification successful", {
operation: "totp_verify_success",
userId: userRecord.id,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const response: Record<string, unknown> = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
};
const timeoutValue = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24;
const maxAge = rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json(response);
await verifySecondFactorAndRespond(req, res, TOTP_FACTOR_ID);
} catch (err) {
authLogger.error("TOTP verification failed", err);
return res.status(500).json({ error: "TOTP verification failed" });
@@ -17,17 +17,14 @@ import { nanoid } from "nanoid";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import {
createCurrentUserRepository,
createCurrentWebauthnCredentialRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
type UserVerification = "discouraged" | "preferred" | "required";
type NativeAppRequestChecker = (req: Request) => boolean;
@@ -130,6 +127,82 @@ function getCredentialForVerification(
};
}
/**
* Checks a passkey assertion and says who signed in. A passkey that verified
* the user (PIN or biometric) counts as the second factor too.
*/
export async function verifyPasskeyLogin(
req: Request,
): Promise<VerifiedIdentity> {
const challenge = takeChallenge(
authenticationChallenges,
req.body?.challengeId,
);
if (!challenge) {
throw new LoginMethodError("Authentication challenge expired", 400);
}
const response = req.body?.response as AuthenticationResponseJSON | undefined;
if (!response?.id) {
throw new LoginMethodError("Invalid passkey response", 400);
}
const credential =
await createCurrentWebauthnCredentialRepository().findByCredentialId(
response.id,
);
if (!credential) {
throw new LoginMethodError("Passkey not recognized", 401);
}
if (challenge.userId && challenge.userId !== credential.userId) {
throw new LoginMethodError("Passkey not recognized", 401);
}
let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
credential: getCredentialForVerification(credential),
requireUserVerification: challenge.userVerification === "required",
advancedFIDOConfig: { userVerification: challenge.userVerification },
});
} catch (error) {
authLogger.warn("WebAuthn authentication failed", {
operation: "webauthn_auth_verify",
credentialId: credential.id,
userId: credential.userId,
error: getErrorMessage(error, "Unknown"),
});
throw new LoginMethodError("Passkey authentication failed", 401);
}
if (!verification.verified) {
throw new LoginMethodError("Passkey authentication failed", 401);
}
await createCurrentWebauthnCredentialRepository().updateAuthState(
credential.id,
{
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
},
);
return {
kind: "user",
userId: credential.userId,
mfaSatisfied: verification.authenticationInfo.userVerified === true,
rememberMe: !!req.body?.rememberMe,
unlockError:
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
};
}
export function registerUserWebAuthnRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
@@ -412,140 +485,14 @@ export function registerUserWebAuthnRoutes(
* description: Passkey not recognized or authentication failed.
*/
router.post("/webauthn/authenticate/verify", async (req, res) => {
const challenge = takeChallenge(
authenticationChallenges,
req.body?.challengeId,
);
if (!challenge) {
return res
.status(400)
.json({ error: "Authentication challenge expired" });
}
const response = req.body?.response as
AuthenticationResponseJSON | undefined;
if (!response?.id) {
return res.status(400).json({ error: "Invalid passkey response" });
}
const credential =
await createCurrentWebauthnCredentialRepository().findByCredentialId(
response.id,
);
if (!credential) {
return res.status(401).json({ error: "Passkey not recognized" });
}
if (challenge.userId && challenge.userId !== credential.userId) {
return res.status(401).json({ error: "Passkey not recognized" });
}
try {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: challenge.origin,
expectedRPID: challenge.rpID,
credential: getCredentialForVerification(credential),
requireUserVerification: challenge.userVerification === "required",
advancedFIDOConfig: {
userVerification: challenge.userVerification,
},
});
if (!verification.verified) {
return res.status(401).json({ error: "Passkey authentication failed" });
}
const userRecord = await createCurrentUserRepository().findById(
credential.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const deviceInfo = parseUserAgent(req);
const authenticated = await authManager.authenticateWebAuthnUser(
userRecord.id,
deviceInfo.type,
);
if (!authenticated) {
return res.status(401).json({
error:
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
});
}
await createCurrentWebauthnCredentialRepository().updateAuthState(
credential.id,
{
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
},
);
if (
userRecord.totpEnabled &&
verification.authenticationInfo.userVerified !== true
) {
const deviceFingerprint = generateDeviceFingerprint(
deviceInfo,
getDeviceId(req),
);
const isTrusted = deviceFingerprint
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
: false;
if (!isTrusted) {
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
expiresIn: "10m",
});
return res.json({
success: true,
requires_totp: true,
temp_token: tempToken,
rememberMe: !!req.body?.rememberMe,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
const identity = await verifyPasskeyLogin(req);
await respondWithLogin(req, res, identity, {
methodId: "passkey",
rememberMe: !!req.body?.rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
const timeoutSetting = getCurrentSettingValue("session_timeout_hours");
const timeoutHours = timeoutSetting
? parseInt(timeoutSetting, 10) || 24
: 24;
const maxAge = req.body?.rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge));
res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
...(isNativeAppRequest(req) ? { token } : {}),
});
} catch (error) {
authLogger.warn("WebAuthn authentication failed", {
operation: "webauthn_auth_verify",
credentialId: credential.id,
userId: credential.userId,
error: getErrorMessage(error, "Unknown"),
});
res.status(401).json({ error: "Passkey authentication failed" });
sendLoginError(res, error);
}
});
File diff suppressed because it is too large. Load diff