mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
||||
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
|
||||
import {
|
||||
readPendingLogin,
|
||||
@@ -32,7 +32,7 @@ export interface PublicLoginMethod {
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
|
||||
}
|
||||
|
||||
async function enabledInstances(method: LoginMethod) {
|
||||
@@ -93,12 +93,18 @@ export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const methods: PublicLoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
let instances: Array<{ id: string; label: string }> = [];
|
||||
let instances: PublicLoginMethod["instances"] = [];
|
||||
if (method.describe) {
|
||||
try {
|
||||
instances = (await method.describe())
|
||||
.filter((instance) => instance.enabled)
|
||||
.map((instance) => ({ id: instance.id, label: instance.label }));
|
||||
.map((instance) => ({
|
||||
id: instance.id,
|
||||
label: instance.label,
|
||||
...(instance.autoStart && method.kind === "redirect"
|
||||
? { autoStart: true }
|
||||
: {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
authLogger.warn("Login method could not describe itself", {
|
||||
operation: "login_method_describe",
|
||||
@@ -178,7 +184,7 @@ export async function handleRedirectCallback(
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
isExternalTokenCallback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2303,19 +2303,6 @@ registerHostNetworkRoutes(router, {
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* A config field arriving as a JSON string (an import, or a client that
|
||||
* stringified it) must still reach storage as an object. Malformed input
|
||||
* becomes null rather than throwing inside a host save.
|
||||
*/
|
||||
function safeParseJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds between status checks, or null to follow the global setting. */
|
||||
function normalizeStatusInterval(value: unknown): number | null {
|
||||
const seconds = Number(value);
|
||||
|
||||
@@ -283,9 +283,8 @@ router.get(
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: >
|
||||
* Returns the live terminal sessions the caller owns. Sessions other
|
||||
* users shared with the caller come from the session sharing plugin.
|
||||
* Used by the Active Connections panel and tab restore logic.
|
||||
* Returns the live terminal sessions the caller owns. Used by the
|
||||
* Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
@@ -310,17 +309,6 @@ router.get(
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
* isOwnSession:
|
||||
* type: boolean
|
||||
* sharedByUsername:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* shareId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
@@ -336,10 +324,6 @@ router.get(
|
||||
tabInstanceId: s.tabInstanceId,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
sharedByUsername: null as string | null,
|
||||
permissionLevel: null as string | null,
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
return res.json(result);
|
||||
|
||||
@@ -25,13 +25,25 @@ export interface SshAuthProviderSummary {
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin names its keys relative to its own locales, so they are sent
|
||||
* qualified with its namespace. Core never has to hold a plugin's strings.
|
||||
*/
|
||||
function qualifyKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
if (!key || pluginId === "core" || key.includes(":")) return key;
|
||||
return `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
|
||||
(provider) => ({
|
||||
type: provider.type,
|
||||
labelKey: provider.labelKey,
|
||||
descriptionKey: provider.descriptionKey,
|
||||
labelKey: qualifyKey(provider.pluginId, provider.labelKey) ?? "",
|
||||
descriptionKey: qualifyKey(provider.pluginId, provider.descriptionKey),
|
||||
pluginId: provider.pluginId,
|
||||
fields: provider.fields ?? [],
|
||||
credentialType: !!provider.credentialType,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -121,6 +122,7 @@ export function registerUserAdminRoutes(
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
is_admin: u.isAdmin,
|
||||
is_external: isExternalAccount(u),
|
||||
is_oidc: u.isOidc,
|
||||
password_hash: u.passwordHash ? "set" : null,
|
||||
// Management-only details stay admin-eyes-only; regular users hit
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, 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,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
|
||||
type UserExternalAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merging an account that only signs in externally (SSO, LDAP or any login
|
||||
* plugin) into a password account, and taking the external sign-in away
|
||||
* again. The identities themselves live in user_external_identities.
|
||||
*/
|
||||
export function registerUserExternalAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserExternalAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-external-to-password:
|
||||
* post:
|
||||
* summary: Merge an external account into a password account
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* externalUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
const link = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
|
||||
const targetUsername = req.body?.targetUsername;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(externalUserId) ||
|
||||
!isNonEmptyString(targetUsername)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "External user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const externalUser = await userRepository.findById(externalUserId);
|
||||
if (!externalUser) {
|
||||
return res.status(404).json({ error: "External user not found" });
|
||||
}
|
||||
if (!isExternalAccount(externalUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Source user does not sign in externally" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findByUsername(targetUsername);
|
||||
if (!targetUser) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has an external sign-in",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking an external account to a password account", {
|
||||
operation: "link_external_to_password",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: externalUser.oidcIdentifier,
|
||||
});
|
||||
await identities.moveIdentities(externalUserId, targetUser.id);
|
||||
|
||||
await authManager.revokeAllUserSessions(externalUserId);
|
||||
authManager.logoutUser(externalUserId);
|
||||
await deleteUserAndRelatedData(externalUserId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("link_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_external_save_failed",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`External account ${externalUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_external_to_password_success",
|
||||
externalUserId,
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link an external account", err, {
|
||||
operation: "link_external_to_password_failed",
|
||||
externalUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-external-from-password:
|
||||
* post:
|
||||
* summary: Remove external sign-in from a password account
|
||||
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: External sign-in removed.
|
||||
* 400:
|
||||
* description: Invalid request, or the user has no external sign-in or no password.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink.
|
||||
*/
|
||||
const unlink = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body ?? {};
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to remove an external sign-in", {
|
||||
operation: "unlink_external_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({ error: "Admin privileges required" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
if (!isExternalAccount(targetUser)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User does not have an external sign-in" });
|
||||
}
|
||||
if (!targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.",
|
||||
});
|
||||
}
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after removing an external sign-in",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_external_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("External sign-in removed from password account", {
|
||||
operation: "unlink_external_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove an external sign-in", err, {
|
||||
operation: "unlink_external_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/link-external-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-external-from-password", authenticateJWT, unlink);
|
||||
// 2.8 paths, kept until 3.0.0.
|
||||
router.post("/link-oidc-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
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,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
type UserOidcAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserOidcAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-oidc-to-password:
|
||||
* post:
|
||||
* summary: Link OIDC user to password account
|
||||
* description: Merges an OIDC-only account into a password-based account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oidcUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { oidcUserId, targetUsername } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
|
||||
return res.status(400).json({
|
||||
error: "OIDC user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const oidcUser = await userRepository.findById(oidcUserId);
|
||||
if (!oidcUser) {
|
||||
return res.status(404).json({ error: "OIDC user not found" });
|
||||
}
|
||||
|
||||
if (!oidcUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findByUsername(targetUsername);
|
||||
if (!targetUser) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
|
||||
if (targetUser.isOidc || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.clientId && targetUser.oidcIdentifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking OIDC user to password account", {
|
||||
operation: "link_oidc_to_password",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: oidcUser.oidcIdentifier,
|
||||
clientId: oidcUser.clientId,
|
||||
clientSecret: oidcUser.clientSecret,
|
||||
issuerUrl: oidcUser.issuerUrl,
|
||||
authorizationUrl: oidcUser.authorizationUrl,
|
||||
tokenUrl: oidcUser.tokenUrl,
|
||||
identifierPath: oidcUser.identifierPath,
|
||||
namePath: oidcUser.namePath,
|
||||
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);
|
||||
|
||||
await deleteUserAndRelatedData(oidcUserId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_oidc_save_failed",
|
||||
oidcUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_oidc_to_password_success",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link OIDC user to password account", err, {
|
||||
operation: "link_oidc_to_password_failed",
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-oidc-from-password:
|
||||
* post:
|
||||
* summary: Unlink OIDC from password account
|
||||
* description: Removes OIDC authentication from a dual-auth account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC unlinked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or user doesn't have OIDC.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink OIDC.
|
||||
*/
|
||||
router.post(
|
||||
"/unlink-oidc-from-password",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
error: "User ID is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminUserId);
|
||||
|
||||
if (!adminUser?.isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
|
||||
operation: "unlink_oidc_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Admin privileges required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "User does not have OIDC authentication enabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Unlinking OIDC from password account", {
|
||||
operation: "unlink_oidc_from_password_start",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after unlinking OIDC",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_oidc_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("OIDC unlinked from password account successfully", {
|
||||
operation: "unlink_oidc_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to unlink OIDC from password account", err, {
|
||||
operation: "unlink_oidc_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink OIDC",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
accentColor: row?.accentColor ?? null,
|
||||
language: row?.language ?? null,
|
||||
storageMode: row?.storageMode ?? "cloud",
|
||||
commandAutocomplete: row?.commandAutocomplete ?? null,
|
||||
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
|
||||
showHostTags: row?.showHostTags ?? null,
|
||||
hostTrayOnClick: row?.hostTrayOnClick ?? null,
|
||||
@@ -32,23 +31,9 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
terminalDefaults: row?.terminalDefaults ?? null,
|
||||
});
|
||||
|
||||
const connectionDefaultFields = ["terminalDefaults"] as const;
|
||||
|
||||
export function validateDefaultsJson(value: string): boolean {
|
||||
if (value.length > 32_768) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -82,9 +67,6 @@ export function validateDefaultsJson(value: string): boolean {
|
||||
* storageMode:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
@@ -118,10 +100,6 @@ export function validateDefaultsJson(value: string): boolean {
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* customThemes:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* nullable: true
|
||||
@@ -170,8 +148,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: string
|
||||
* storageMode:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* pinAppRail:
|
||||
@@ -186,9 +162,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* hiddenRailTabs:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
||||
@@ -205,7 +178,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
accentColor,
|
||||
language,
|
||||
storageMode,
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
@@ -213,9 +185,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -223,7 +193,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
@@ -231,9 +200,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
@@ -262,55 +229,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
language,
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
const connectionDefaults = {
|
||||
terminalDefaults,
|
||||
};
|
||||
for (const key of connectionDefaultFields) {
|
||||
const value = connectionDefaults[key];
|
||||
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
|
||||
return res.status(400).json({
|
||||
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
parsedThemes = JSON.parse(customThemes);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: "customThemes must be a JSON array of at most 100 themes",
|
||||
});
|
||||
}
|
||||
const isValidTheme = (entry: unknown): boolean =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string" &&
|
||||
!!(entry as { colors?: unknown }).colors &&
|
||||
typeof (entry as { colors?: unknown }).colors === "object";
|
||||
if (!parsedThemes.every(isValidTheme)) {
|
||||
return res.status(400).json({
|
||||
error: "Each custom theme must have an id, name, and colors object",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customKeybindings !== undefined && customKeybindings !== null) {
|
||||
let parsedKeybindings: unknown;
|
||||
try {
|
||||
@@ -334,7 +259,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
@@ -354,8 +278,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (language !== undefined) updates.language = language;
|
||||
if (storageMode !== undefined) updates.storageMode = storageMode;
|
||||
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
|
||||
if (commandAutocomplete !== undefined)
|
||||
updates.commandAutocomplete = commandAutocomplete;
|
||||
if (commandPaletteEnabled !== undefined)
|
||||
updates.commandPaletteEnabled = commandPaletteEnabled;
|
||||
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
|
||||
@@ -366,11 +288,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (disableUpdateCheck !== undefined)
|
||||
updates.disableUpdateCheck = disableUpdateCheck;
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (terminalDefaults !== undefined)
|
||||
updates.terminalDefaults = terminalDefaults;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -341,25 +341,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* get:
|
||||
* summary: Get session sharing globally enabled setting
|
||||
* description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session sharing enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
/**
|
||||
* @openapi
|
||||
* /users/audit-forwarding:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { isExternalAccount } from "../../auth/external-account.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import express, {
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -21,7 +26,7 @@ import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTlsRoutes } from "./tls-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
@@ -90,12 +95,6 @@ function isPasswordResetAllowed(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function getOidcSilentLoginDefaultFromEnv(): boolean | undefined {
|
||||
const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT;
|
||||
if (envVal === undefined) return undefined;
|
||||
return envVal.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
async function findCurrentUser(userId: string): Promise<UserRecord | null> {
|
||||
return createCurrentUserRepository().findById(userId);
|
||||
}
|
||||
@@ -601,8 +600,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const hasOidc = user.isOidc && user.oidcIdentifier;
|
||||
const isDualAuth = hasPassword && hasOidc;
|
||||
const isDualAuth =
|
||||
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
|
||||
|
||||
const showDonationModal = shouldShowDonationModal(
|
||||
user.registeredAt,
|
||||
@@ -613,6 +612,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
is_admin: !!user.isAdmin,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 3.0.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
@@ -940,17 +941,17 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* /users/external-auto-provision:
|
||||
* get:
|
||||
* summary: Get OIDC auto-provision status
|
||||
* description: Whether a new user account is created automatically on first SSO sign-in.
|
||||
* summary: Get the external account auto-create setting
|
||||
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status.
|
||||
* description: Auto-create setting.
|
||||
*/
|
||||
router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
const getExternalAutoProvision: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
@@ -959,19 +960,19 @@ router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC auto-provision setting", err);
|
||||
authLogger.error("Failed to get external auto-provision setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get OIDC auto-provision setting" });
|
||||
.json({ error: "Failed to get external auto-provision setting" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-auto-provision:
|
||||
* /users/external-auto-provision:
|
||||
* patch:
|
||||
* summary: Set OIDC auto-provision status
|
||||
* description: Enables or disables automatic account creation on first SSO sign-in.
|
||||
* summary: Set the external account auto-create setting
|
||||
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -985,15 +986,15 @@ router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC auto-provision status updated.
|
||||
* description: Setting updated.
|
||||
* 400:
|
||||
* description: Invalid value for enabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to set OIDC auto-provision setting.
|
||||
* description: Failed to save the setting.
|
||||
*/
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
const setExternalAutoProvision: RequestHandler = async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
@@ -1010,10 +1011,20 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set OIDC auto-provision", err);
|
||||
res.status(500).json({ error: "Failed to set OIDC auto-provision" });
|
||||
authLogger.error("Failed to set external auto-provision", err);
|
||||
res.status(500).json({ error: "Failed to set external auto-provision" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
router.get("/external-auto-provision", getExternalAutoProvision);
|
||||
router.patch(
|
||||
"/external-auto-provision",
|
||||
authenticateJWT,
|
||||
setExternalAutoProvision,
|
||||
);
|
||||
// 2.8 paths, kept until 3.0.0.
|
||||
router.get("/oidc-auto-provision", getExternalAutoProvision);
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -1104,103 +1115,6 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-silent-login-default:
|
||||
* get:
|
||||
* summary: Get OIDC silent login default setting
|
||||
* description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Silent login default setting.
|
||||
* 500:
|
||||
* description: Failed to get setting.
|
||||
*/
|
||||
router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||
try {
|
||||
const envVal = getOidcSilentLoginDefaultFromEnv();
|
||||
if (envVal !== undefined) {
|
||||
res.json({ enabled: envVal, locked: true });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_silent_login_default",
|
||||
false,
|
||||
),
|
||||
locked: false,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC silent login default", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC silent login default" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-silent-login-default:
|
||||
* patch:
|
||||
* summary: Set OIDC silent login default setting
|
||||
* description: Enables or disables silent OIDC login as the default behavior on the login page.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 400:
|
||||
* description: Invalid value.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 409:
|
||||
* description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/oidc-silent-login-default",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
if (getOidcSilentLoginDefaultFromEnv() !== undefined) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here",
|
||||
});
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"oidc_silent_login_default",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set OIDC silent login default", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to set OIDC silent login default" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/password-login-allowed:
|
||||
@@ -1668,7 +1582,7 @@ registerUserSessionRoutes(router, {
|
||||
authManager,
|
||||
});
|
||||
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
registerUserExternalAccountRoutes(router, {
|
||||
authenticateJWT,
|
||||
authManager,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user