mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
703 lines
22 KiB
TypeScript
703 lines
22 KiB
TypeScript
/**
|
|
* Every login runs through here:
|
|
*
|
|
* method -> verified identity -> find or provision the user -> unlock the
|
|
* data key -> second factors (fail closed) -> session
|
|
*
|
|
* A method never mints a session. It says who the user is and core does the
|
|
* rest, so password, passkey, OIDC, LDAP and any plugin method get the same
|
|
* second factors, the same cookie and the same audit line.
|
|
*/
|
|
|
|
import crypto from "crypto";
|
|
import type { Request, Response } from "express";
|
|
import { AuthManager } from "../utils/auth-manager.js";
|
|
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
|
import { authLogger } from "../utils/logger.js";
|
|
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
|
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
|
import {
|
|
createCurrentUserAuthRepository,
|
|
createCurrentUserRepository,
|
|
} from "../database/repositories/factory.js";
|
|
import type { UserRecord } from "../database/repositories/user-repository.js";
|
|
import {
|
|
ensureCoreLoginProviders,
|
|
isSecondFactorAfterExternalLoginEnabled,
|
|
} from "./core-auth.js";
|
|
import { findOrProvisionExternalUser } from "./provisioning.js";
|
|
import {
|
|
SECOND_FACTOR_SETUP,
|
|
evaluateAuthPolicy,
|
|
hasAuthPolicies,
|
|
rememberDeviceAllowed,
|
|
} from "./policy.js";
|
|
import {
|
|
getLoginMethod,
|
|
getSecondFactor,
|
|
listSecondFactors,
|
|
type SecondFactor,
|
|
} from "./registry.js";
|
|
import {
|
|
isNativeAppRequest,
|
|
issueSession,
|
|
sendSession,
|
|
syncSharedCredentialsForUserRoles,
|
|
} from "./session-issuer.js";
|
|
import {
|
|
LoginMethodError,
|
|
type PendingLogin,
|
|
type VerifiedIdentity,
|
|
} from "./types.js";
|
|
|
|
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
const PENDING_LOGIN_COOKIE = "termix_pending_login";
|
|
|
|
const pendingLogins = new Map<string, PendingLogin>();
|
|
|
|
function prunePending(now = Date.now()): void {
|
|
for (const [key, pending] of pendingLogins) {
|
|
if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key);
|
|
}
|
|
}
|
|
|
|
function pendingKey(token: string): string {
|
|
return crypto.createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
/** Test helper. */
|
|
export function resetPendingLoginsForTests(): void {
|
|
pendingLogins.clear();
|
|
}
|
|
|
|
export interface SecondFactorState {
|
|
/** Factors the user must pass, all with an active provider. */
|
|
required: SecondFactor[];
|
|
/** Enrolled in a factor whose plugin is disabled or missing. */
|
|
blocked: { pluginId: string; factorId: string }[];
|
|
}
|
|
|
|
/**
|
|
* Which second factors apply to a user. A factor recorded in
|
|
* user_second_factors whose plugin is not running blocks the login: skipping
|
|
* it would turn "disable the plugin" into "remove everyone's 2FA".
|
|
*/
|
|
export async function evaluateSecondFactors(
|
|
userId: string,
|
|
): Promise<SecondFactorState> {
|
|
ensureCoreLoginProviders();
|
|
const rows =
|
|
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
|
const required = new Map<string, SecondFactor>();
|
|
const blocked: SecondFactorState["blocked"] = [];
|
|
|
|
for (const row of rows) {
|
|
const factor = getSecondFactor(row.pluginId, row.factorId);
|
|
if (!factor) {
|
|
blocked.push({ pluginId: row.pluginId, factorId: row.factorId });
|
|
continue;
|
|
}
|
|
required.set(`${factor.pluginId}:${factor.id}`, factor);
|
|
}
|
|
|
|
// A factor can also report enrolment itself, for data from before
|
|
// user_second_factors existed.
|
|
for (const factor of listSecondFactors()) {
|
|
const key = `${factor.pluginId}:${factor.id}`;
|
|
if (required.has(key)) continue;
|
|
try {
|
|
if (await factor.isEnrolled(userId)) required.set(key, factor);
|
|
} catch (error) {
|
|
// Unknown is not "not enrolled": the login stops instead of skipping
|
|
// a factor the user may well have.
|
|
blocked.push({ pluginId: factor.pluginId, factorId: factor.id });
|
|
authLogger.warn("Second factor enrolment check failed", {
|
|
operation: "second_factor_enrolment_check",
|
|
factorId: factor.id,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { required: [...required.values()], blocked };
|
|
}
|
|
|
|
/**
|
|
* Whether second factors should even be asked for this login. Off, they only
|
|
* run for password and other local form logins, matching 2.8: the setting
|
|
* governs external methods (SSO, LDAP) alone.
|
|
*/
|
|
function shouldRunSecondFactors(methodId: string): boolean {
|
|
if (!isExternalMethod(methodId)) return true;
|
|
return isSecondFactorAfterExternalLoginEnabled();
|
|
}
|
|
|
|
function isExternalMethod(methodId: string): boolean {
|
|
ensureCoreLoginProviders();
|
|
return !!getLoginMethod(methodId)?.external;
|
|
}
|
|
|
|
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
|
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* The remember-this-device token the server handed out after a full login.
|
|
* Browsers carry it in an HttpOnly cookie; the desktop app, which talks to
|
|
* the server cross-origin without cookies, sends it back as a header.
|
|
*/
|
|
function readTrustToken(req: Request): string | null {
|
|
const cookies = (req as Request & { cookies?: Record<string, string> })
|
|
.cookies;
|
|
const value =
|
|
cookies?.[TRUST_DEVICE_COOKIE] || req.get("x-termix-trust-token");
|
|
return typeof value === "string" && TRUST_TOKEN_PATTERN.test(value)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
export function hashTrustToken(token: string): string {
|
|
return crypto.createHash("sha256").update(`trust-v2|${token}`).digest("hex");
|
|
}
|
|
|
|
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
|
const token = readTrustToken(req);
|
|
if (!token) return false;
|
|
const fingerprint = hashTrustToken(token);
|
|
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
|
userId,
|
|
fingerprint,
|
|
);
|
|
if (trusted) {
|
|
authLogger.info("Second factor bypassed for trusted device", {
|
|
operation: "totp_bypass",
|
|
userId,
|
|
deviceFingerprint: fingerprint,
|
|
});
|
|
}
|
|
return trusted;
|
|
}
|
|
|
|
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
|
|
|
/**
|
|
* An account that only signs in through SSO or LDAP must keep doing so. A
|
|
* passkey or any other local method would skip the identity provider, so a
|
|
* user disabled there could still get in.
|
|
*/
|
|
export function assertMethodAllowedFor(
|
|
user: Pick<UserRecord, "isExternal" | "passwordHash">,
|
|
identity: VerifiedIdentity,
|
|
methodId: string,
|
|
): void {
|
|
if (identity.kind === "external" || !user.isExternal) return;
|
|
if (user.passwordHash && user.passwordHash.trim() !== "") return;
|
|
ensureCoreLoginProviders();
|
|
if (getLoginMethod(methodId)?.external) return;
|
|
throw new LoginMethodError(
|
|
"This account signs in through its identity provider",
|
|
403,
|
|
"external_login_required",
|
|
);
|
|
}
|
|
|
|
async function resolveUser(
|
|
identity: VerifiedIdentity,
|
|
deviceType: DeviceType,
|
|
): Promise<UserRecord> {
|
|
if (identity.kind === "external") {
|
|
return findOrProvisionExternalUser(identity, deviceType);
|
|
}
|
|
const user = await createCurrentUserRepository().findById(identity.userId);
|
|
if (!user)
|
|
throw new LoginMethodError("User not found", 404, "user_not_found");
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Unlocks the user's data key. A password login migrates and opens the key
|
|
* with the password; everything else uses the server-held wrapping.
|
|
*/
|
|
async function unlockUser(
|
|
user: UserRecord,
|
|
identity: VerifiedIdentity,
|
|
deviceType: DeviceType,
|
|
): Promise<void> {
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
if (identity.kind === "external" || user.isExternal) {
|
|
try {
|
|
await authManager.authenticateExternalUser(user.id, deviceType);
|
|
} catch (error) {
|
|
authLogger.error("Failed to set up external user encryption", error, {
|
|
operation: "external_user_encryption_setup_failed",
|
|
userId: user.id,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const ok = identity.password
|
|
? await authManager.authenticateUser(user.id, identity.password, deviceType)
|
|
: await authManager.unlockWithSystemKey(user.id, deviceType);
|
|
if (!ok) {
|
|
throw new LoginMethodError(
|
|
identity.password
|
|
? "Incorrect password"
|
|
: (identity.unlockError ?? "This account could not be unlocked"),
|
|
401,
|
|
"unlock_failed",
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface LoginContext {
|
|
methodId: string;
|
|
rememberMe: boolean;
|
|
/** Username the rate limiter counted, cleared on success. */
|
|
rateLimitUsername?: string;
|
|
}
|
|
|
|
export type LoginResult =
|
|
| {
|
|
kind: "session";
|
|
token: string;
|
|
maxAge: number;
|
|
body: Record<string, unknown>;
|
|
}
|
|
| {
|
|
kind: "second-factor";
|
|
tempToken: string;
|
|
factors: Array<{ id: string; pluginId: string; labelKey: string }>;
|
|
rememberMe: boolean;
|
|
};
|
|
|
|
/**
|
|
* Takes a verified identity to either a session or a second-factor step.
|
|
* Throws LoginMethodError for anything the user should be told.
|
|
*/
|
|
export async function runLogin(
|
|
req: Request,
|
|
identity: VerifiedIdentity,
|
|
context: LoginContext,
|
|
): Promise<LoginResult> {
|
|
const deviceType = parseUserAgent(req).type;
|
|
const user = await resolveUser(identity, deviceType);
|
|
assertMethodAllowedFor(user, identity, context.methodId);
|
|
|
|
await unlockUser(user, identity, deviceType);
|
|
if ("password" in identity && identity.password) {
|
|
// A 2.8 key still wrapped by the password only opens here, so this is the
|
|
// first chance to move that user's TOTP secret and channel configs.
|
|
const { runTotpMigration } = await import("../upgrade/totp-migration.js");
|
|
await runTotpMigration(user.id);
|
|
const { runNotificationChannelMigration } =
|
|
await import("../upgrade/notification-channel-migration.js");
|
|
await runNotificationChannelMigration(user.id);
|
|
const { runProtocolAuthMigration } =
|
|
await import("../upgrade/protocol-auth-migration.js");
|
|
await runProtocolAuthMigration(user.id);
|
|
}
|
|
await syncSharedCredentialsForUserRoles(
|
|
user.id,
|
|
`${context.methodId}_role_shared_credentials`,
|
|
);
|
|
|
|
// A login policy may want a factor where core would skip it, so it always
|
|
// sees the full picture.
|
|
const runFactors = shouldRunSecondFactors(context.methodId);
|
|
const factors =
|
|
runFactors || hasAuthPolicies()
|
|
? await evaluateSecondFactors(user.id)
|
|
: { required: [], blocked: [] };
|
|
const trusted =
|
|
factors.required.length > 0 || hasAuthPolicies()
|
|
? await isTrustedDevice(req, user.id)
|
|
: false;
|
|
const policy = await evaluateAuthPolicy({
|
|
userId: user.id,
|
|
methodId: context.methodId,
|
|
external: isExternalMethod(context.methodId),
|
|
deviceType,
|
|
trustedDevice: trusted,
|
|
mfaSatisfied: !!identity.mfaSatisfied,
|
|
secondFactors: listSecondFactors().map((factor) => ({
|
|
id: factor.id,
|
|
pluginId: factor.pluginId,
|
|
enrolled: factors.required.some(
|
|
(required) =>
|
|
required.id === factor.id && required.pluginId === factor.pluginId,
|
|
),
|
|
})),
|
|
});
|
|
if (!runFactors && !policy.secondFactor) {
|
|
factors.required = [];
|
|
factors.blocked = [];
|
|
}
|
|
const rememberMe = context.rememberMe && policy.allowRememberDevice;
|
|
const skipByDevice =
|
|
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
|
|
if (factors.blocked.length > 0) {
|
|
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
|
operation: "second_factor_unavailable",
|
|
userId: user.id,
|
|
factors: factors.blocked,
|
|
});
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId: user.id,
|
|
username: user.username,
|
|
action: "login_blocked_second_factor",
|
|
resourceType: "session",
|
|
details: JSON.stringify({ unavailable: factors.blocked }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: false,
|
|
errorMessage: "Enrolled second factor unavailable",
|
|
});
|
|
throw new LoginMethodError(
|
|
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
|
403,
|
|
"second_factor_unavailable",
|
|
);
|
|
}
|
|
|
|
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
|
|
|
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
|
|
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
|
user.id,
|
|
{ pendingTOTP: true, expiresIn: "10m" },
|
|
);
|
|
prunePending();
|
|
pendingLogins.set(pendingKey(tempToken), {
|
|
userId: user.id,
|
|
methodId: context.methodId,
|
|
rememberMe,
|
|
allowRememberDevice: policy.allowRememberDevice,
|
|
maxSessionSeconds: policy.maxSessionSeconds,
|
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
|
...ssoClaims,
|
|
createdAt: Date.now(),
|
|
});
|
|
return {
|
|
kind: "second-factor",
|
|
tempToken,
|
|
rememberMe,
|
|
factors: factors.required.map((factor) => ({
|
|
id: factor.id,
|
|
pluginId: factor.pluginId,
|
|
labelKey: factor.labelKey,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// Required but nothing set up yet: a session that can only set one up.
|
|
const needsSetup =
|
|
!!policy.secondFactor &&
|
|
factors.required.length === 0 &&
|
|
!identity.mfaSatisfied &&
|
|
!skipByDevice;
|
|
|
|
const session = await issueSession(req, user, {
|
|
methodId: context.methodId,
|
|
rememberMe,
|
|
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
|
longLivedForApps:
|
|
identity.kind === "external" && policy.allowRememberDevice,
|
|
maxAgeSeconds: policy.maxSessionSeconds,
|
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
|
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
|
|
...ssoClaims,
|
|
});
|
|
return { kind: "session", ...session };
|
|
}
|
|
|
|
/** JSON form of a login result, the shape the login screen expects. */
|
|
function secondFactorBody(
|
|
result: Extract<LoginResult, { kind: "second-factor" }>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
success: true,
|
|
requires_totp: true,
|
|
requires_second_factor: true,
|
|
temp_token: result.tempToken,
|
|
rememberMe: result.rememberMe,
|
|
second_factors: result.factors,
|
|
};
|
|
}
|
|
|
|
/** Runs a form login and writes the response. */
|
|
export async function respondWithLogin(
|
|
req: Request,
|
|
res: Response,
|
|
identity: VerifiedIdentity,
|
|
context: LoginContext,
|
|
): Promise<Response> {
|
|
const result = await runLogin(req, identity, context);
|
|
if (result.kind === "second-factor") {
|
|
return res.json(secondFactorBody(result));
|
|
}
|
|
return sendSession(req, res, result);
|
|
}
|
|
|
|
export function sendLoginError(res: Response, error: unknown): Response {
|
|
if (error instanceof LoginMethodError) {
|
|
const remainingTime = (error as { remainingTime?: number }).remainingTime;
|
|
return res.status(error.status).json({
|
|
error: error.message,
|
|
...(error.code ? { code: error.code } : {}),
|
|
...(remainingTime !== undefined ? { remainingTime } : {}),
|
|
});
|
|
}
|
|
authLogger.error("Login failed", error);
|
|
return res.status(500).json({ error: "Login failed" });
|
|
}
|
|
|
|
export interface PendingLoginLookup {
|
|
userId: string;
|
|
token: string;
|
|
pending: PendingLogin | null;
|
|
}
|
|
|
|
/** Checks a pending token from the body or the redirect cookie. */
|
|
export async function readPendingLogin(
|
|
req: Request,
|
|
): Promise<PendingLoginLookup | null> {
|
|
const token =
|
|
(typeof req.body?.temp_token === "string" && req.body.temp_token) ||
|
|
(req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined);
|
|
if (!token) return null;
|
|
const decoded = await AuthManager.getInstance().verifyJWTToken(token);
|
|
if (!decoded || !decoded.pendingTOTP) return null;
|
|
prunePending();
|
|
const pending = pendingLogins.get(pendingKey(token)) ?? null;
|
|
return { userId: decoded.userId, token, pending };
|
|
}
|
|
|
|
function consumePendingLogin(token: string): void {
|
|
pendingLogins.delete(pendingKey(token));
|
|
}
|
|
|
|
/**
|
|
* Finishes a login that stopped for a second factor. The pending token comes
|
|
* from the body, or from the cookie a redirect method set. Without a factor
|
|
* id the user's first required factor answers, for the 2.8 route.
|
|
*/
|
|
export async function verifySecondFactorAndRespond(
|
|
req: Request,
|
|
res: Response,
|
|
factorId?: string,
|
|
): Promise<Response> {
|
|
const lookup = await readPendingLogin(req);
|
|
if (!lookup) {
|
|
return res.status(401).json({ error: "Invalid temporary token" });
|
|
}
|
|
|
|
const user = await createCurrentUserRepository().findById(lookup.userId);
|
|
if (!user) return res.status(404).json({ error: "User not found" });
|
|
|
|
const lockStatus = loginRateLimiter.isTOTPLocked(user.id);
|
|
if (lockStatus.locked) {
|
|
authLogger.warn("Second factor blocked due to rate limiting", {
|
|
operation: "totp_verify_blocked",
|
|
userId: user.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(user.id);
|
|
|
|
const factors = await evaluateSecondFactors(user.id);
|
|
if (factors.blocked.length > 0) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
|
code: "second_factor_unavailable",
|
|
});
|
|
}
|
|
const factor = factorId
|
|
? factors.required.find((candidate) => candidate.id === factorId)
|
|
: factors.required[0];
|
|
if (!factor) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "That second factor is not enabled for this user" });
|
|
}
|
|
|
|
const result = await factor.verify(user.id, req.body ?? {});
|
|
if (result !== true) {
|
|
if (result && typeof result === "object") {
|
|
return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({
|
|
error: result.error,
|
|
...(result.code ? { code: result.code } : {}),
|
|
});
|
|
}
|
|
authLogger.warn("Second factor verification failed", {
|
|
operation: "totp_verify_failed",
|
|
userId: user.id,
|
|
factorId: factor.id,
|
|
});
|
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId: user.id,
|
|
username: user.username,
|
|
action: "login_second_factor_failed",
|
|
resourceType: "session",
|
|
details: JSON.stringify({ factor: factor.id }),
|
|
ipAddress,
|
|
userAgent,
|
|
success: false,
|
|
});
|
|
return res.status(401).json({
|
|
error: "Invalid TOTP code",
|
|
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id),
|
|
});
|
|
}
|
|
loginRateLimiter.resetTOTPAttempts(user.id);
|
|
|
|
// A pending entry lost to a restart falls back to the current policy.
|
|
const allowRemember = lookup.pending
|
|
? lookup.pending.allowRememberDevice !== false
|
|
: rememberDeviceAllowed();
|
|
const rememberMe =
|
|
allowRemember &&
|
|
(typeof req.body?.rememberMe === "boolean"
|
|
? req.body.rememberMe
|
|
: (lookup.pending?.rememberMe ?? false));
|
|
|
|
let trustToken: string | null = null;
|
|
if (rememberMe) {
|
|
const deviceInfo = parseUserAgent(req);
|
|
trustToken = crypto.randomBytes(32).toString("hex");
|
|
await AuthManager.getInstance().addTrustedDevice(
|
|
user.id,
|
|
hashTrustToken(trustToken),
|
|
deviceInfo.type,
|
|
deviceInfo.deviceInfo,
|
|
);
|
|
res.cookie(
|
|
TRUST_DEVICE_COOKIE,
|
|
trustToken,
|
|
AuthManager.getInstance().getSecureCookieOptions(
|
|
req,
|
|
TRUST_TOKEN_MAX_AGE_MS,
|
|
),
|
|
);
|
|
authLogger.info("Device automatically trusted via Remember Me", {
|
|
operation: "totp_auto_trust",
|
|
userId: user.id,
|
|
deviceType: deviceInfo.type,
|
|
});
|
|
}
|
|
|
|
const pending = lookup.pending;
|
|
const session = await issueSession(req, user, {
|
|
methodId: pending?.methodId ?? "password",
|
|
rememberMe,
|
|
externalSession: pending?.externalSession ?? null,
|
|
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
|
|
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
|
|
});
|
|
|
|
if (trustToken && isNativeAppRequest(req)) {
|
|
session.body.trustToken = trustToken;
|
|
}
|
|
|
|
consumePendingLogin(lookup.token);
|
|
res.clearCookie(
|
|
PENDING_LOGIN_COOKIE,
|
|
AuthManager.getInstance().getClearCookieOptions(req),
|
|
);
|
|
return sendSession(req, res, session);
|
|
}
|
|
|
|
/**
|
|
* Finishes a redirect login (OIDC and friends): back to where the browser
|
|
* came from, with a cookie, or with the token in the URL for the desktop and
|
|
* mobile app callbacks that cannot read one.
|
|
*/
|
|
export async function respondWithRedirectLogin(
|
|
req: Request,
|
|
res: Response,
|
|
identity: VerifiedIdentity,
|
|
context: LoginContext,
|
|
isTokenCallback: (url: string) => boolean,
|
|
): Promise<void> {
|
|
const returnTo = identity.returnTo;
|
|
if (!returnTo) {
|
|
res.status(500).json({ error: "Login method did not say where to return" });
|
|
return;
|
|
}
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
let result: LoginResult;
|
|
try {
|
|
result = await runLogin(req, identity, context);
|
|
} catch (error) {
|
|
redirectWithLoginError(res, returnTo, error);
|
|
return;
|
|
}
|
|
|
|
const url = new URL(returnTo);
|
|
const tokenCallback = isTokenCallback(returnTo);
|
|
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
|
|
|
if (result.kind === "second-factor") {
|
|
url.searchParams.set("second_factor", "1");
|
|
url.searchParams.set(
|
|
"second_factors",
|
|
result.factors.map((factor) => factor.id).join(","),
|
|
);
|
|
if (tokenCallback) {
|
|
url.searchParams.set("temp_token", result.tempToken);
|
|
res.redirect(url.toString());
|
|
return;
|
|
}
|
|
res
|
|
.cookie(
|
|
PENDING_LOGIN_COOKIE,
|
|
result.tempToken,
|
|
authManager.getSecureCookieOptions(req, PENDING_TTL_MS),
|
|
)
|
|
.redirect(url.toString());
|
|
return;
|
|
}
|
|
|
|
url.searchParams.set("success", "true");
|
|
if (tokenCallback) {
|
|
url.searchParams.set("token", result.token);
|
|
res.redirect(url.toString());
|
|
return;
|
|
}
|
|
res
|
|
.cookie(
|
|
"jwt",
|
|
result.token,
|
|
authManager.getSecureCookieOptions(req, result.maxAge),
|
|
)
|
|
.redirect(url.toString());
|
|
}
|
|
|
|
export function redirectWithLoginError(
|
|
res: Response,
|
|
returnTo: string,
|
|
error: unknown,
|
|
): void {
|
|
if (!(error instanceof LoginMethodError)) {
|
|
authLogger.error("Redirect login failed", error);
|
|
}
|
|
const url = new URL(returnTo);
|
|
url.searchParams.set(
|
|
"error",
|
|
error instanceof LoginMethodError
|
|
? (error.code ?? error.message)
|
|
: "OIDC authentication failed",
|
|
);
|
|
res.redirect(url.toString());
|
|
}
|