560 lines
21 KiB
TypeScript
560 lines
21 KiB
TypeScript
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
|
|
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
|
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
|
|
import { rolesToPermissions } from "./config.js";
|
|
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
|
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
|
import { deriveCsrfToken } from "./csrf.js";
|
|
import { verifyWithDummy } from "./password.js";
|
|
import type { OidcProtocol } from "./oidc-client.js";
|
|
|
|
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
|
const REMEMBER_COOKIE_SECONDS = 2_592_000;
|
|
const MAX_USERNAME_LENGTH = 64;
|
|
const MAX_PASSWORD_LENGTH = 1024;
|
|
const MAX_LIMIT_ENTRIES = 10_000;
|
|
const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20;
|
|
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
|
|
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
|
|
const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx";
|
|
const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000;
|
|
const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
|
|
export interface AuthRouteDependencies {
|
|
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
|
|
authentication?: AuthenticationConfigProvider;
|
|
sessionStore?: AuthSessionStore;
|
|
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
|
|
localUserRegistry?: LocalUserRegistry;
|
|
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
|
|
oidcProtocol?: OidcProtocol;
|
|
resolveOidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
|
|
}
|
|
|
|
interface LoginPayload {
|
|
username: string;
|
|
password: string;
|
|
remember: boolean;
|
|
}
|
|
|
|
function countActiveAttempts(
|
|
bucket: ReadonlyMap<string, readonly number[]>,
|
|
key: string,
|
|
now: number,
|
|
): number {
|
|
const attempts = bucket.get(key);
|
|
if (!attempts) return 0;
|
|
const earliest = now - TEN_MINUTES_MS;
|
|
let count = 0;
|
|
for (const timestamp of attempts) {
|
|
if (timestamp > earliest) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function pruneExpiredAttempts(bucket: Map<string, number[]>, now: number): void {
|
|
const earliest = now - TEN_MINUTES_MS;
|
|
for (const [key, attempts] of bucket) {
|
|
const active = attempts.filter((timestamp) => timestamp > earliest);
|
|
if (active.length === 0) bucket.delete(key);
|
|
else if (active.length !== attempts.length) bucket.set(key, active);
|
|
}
|
|
}
|
|
|
|
function canRecordAttempt(
|
|
bucket: ReadonlyMap<string, readonly number[]>,
|
|
key: string,
|
|
limit: number,
|
|
maximumEntries: number,
|
|
): boolean {
|
|
return (bucket.get(key)?.length ?? 0) < limit
|
|
&& (bucket.has(key) || bucket.size < maximumEntries);
|
|
}
|
|
|
|
function appendAttempt(bucket: Map<string, number[]>, key: string, now: number): void {
|
|
bucket.set(key, [...(bucket.get(key) ?? []), now]);
|
|
}
|
|
|
|
export class LoginFailureLimiter {
|
|
private readonly usernames = new Map<string, number[]>();
|
|
private readonly addresses = new Map<string, number[]>();
|
|
private readonly maximumEntries: number;
|
|
|
|
constructor(options: { maximumEntries?: number } = {}) {
|
|
this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES;
|
|
}
|
|
|
|
isLimited(username: string, address: string, now = Date.now()): boolean {
|
|
return countActiveAttempts(this.usernames, username, now) >= 10
|
|
|| countActiveAttempts(this.addresses, address, now) >= 20;
|
|
}
|
|
|
|
recordFailure(username: string, address: string, now = Date.now()): boolean {
|
|
pruneExpiredAttempts(this.usernames, now);
|
|
pruneExpiredAttempts(this.addresses, now);
|
|
if (!canRecordAttempt(this.usernames, username, 10, this.maximumEntries)
|
|
|| !canRecordAttempt(this.addresses, address, 20, this.maximumEntries)) return false;
|
|
appendAttempt(this.usernames, username, now);
|
|
appendAttempt(this.addresses, address, now);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class OidcInitiationLimiter {
|
|
private readonly addresses = new Map<string, number[]>();
|
|
|
|
consume(address: string, now = Date.now()): boolean {
|
|
pruneExpiredAttempts(this.addresses, now);
|
|
if (!canRecordAttempt(
|
|
this.addresses,
|
|
address,
|
|
MAX_OIDC_INITIATIONS_PER_ADDRESS,
|
|
MAX_LIMIT_ENTRIES,
|
|
)) return false;
|
|
appendAttempt(this.addresses, address, now);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class VerificationGate {
|
|
private active = 0;
|
|
|
|
async run(operation: () => Promise<boolean>): Promise<boolean | undefined> {
|
|
if (this.active >= 2) return undefined;
|
|
this.active += 1;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
this.active -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function unavailableAfterDummy(
|
|
gate: VerificationGate,
|
|
password: string,
|
|
reply: FastifyReply,
|
|
): Promise<FastifyReply> {
|
|
try {
|
|
const completed = await gate.run(async () => {
|
|
await verifyWithDummy(password);
|
|
return true;
|
|
});
|
|
if (completed === undefined) return loginLimited(reply);
|
|
} catch {
|
|
// Preserve the sanitized operational outcome below.
|
|
}
|
|
return unavailable(reply);
|
|
}
|
|
|
|
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
|
|
const limiter = new LoginFailureLimiter();
|
|
const oidcInitiationLimiter = new OidcInitiationLimiter();
|
|
const verificationGate = new VerificationGate();
|
|
|
|
app.get("/auth/config", async (request, reply) => {
|
|
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
|
if (!snapshot) return unavailable(reply);
|
|
const mode = snapshot.value.mode;
|
|
return reply.send({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" });
|
|
});
|
|
|
|
app.post("/auth/local/login", async (request, reply) => {
|
|
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
|
const configured = currentLocalConfig(snapshot, deps);
|
|
if (configured.kind === "unavailable") {
|
|
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
|
|
}
|
|
if (configured.kind !== "local" || !deps.sessionStore) return unavailable(reply);
|
|
const originCheck = requireExactOrigin(request, reply, configured.origin);
|
|
if (originCheck !== true) return originCheck;
|
|
|
|
const payload = loginPayload(request);
|
|
const safePassword = argon2SafePassword(payload.password);
|
|
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
const sourceAddress = boundedAddress(request.ip);
|
|
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
|
|
|
let user: LocalUserRecord | undefined;
|
|
try {
|
|
if (payload.username.length > 0) user = await configured.registry.findByUsername(payload.username);
|
|
} catch {
|
|
return unavailableAfterDummy(verificationGate, safePassword, reply);
|
|
}
|
|
let verified: boolean | undefined;
|
|
try {
|
|
verified = await verificationGate.run(async () =>
|
|
configured.registry.verify(user, safePassword));
|
|
} catch {
|
|
return unavailable(reply);
|
|
}
|
|
if (verified === undefined) return loginLimited(reply);
|
|
if (!verified || !user || !user.enabled) {
|
|
if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
|
return invalidCredentials(reply);
|
|
}
|
|
|
|
try {
|
|
const created = await deps.sessionStore.create({
|
|
principal: {
|
|
issuer: "local",
|
|
subject: user.id,
|
|
displayName: user.displayName ?? user.username,
|
|
roles: user.roles,
|
|
permissions: rolesToPermissions(user.roles),
|
|
isAdmin: user.roles.includes("admin"),
|
|
},
|
|
method: "local",
|
|
remembered: payload.remember,
|
|
userAuthRevision: user.authRevision,
|
|
authConfigRevision: configured.revision,
|
|
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
|
|
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
|
|
});
|
|
reply.setCookie(sessionCookieName(), created.token, cookieOptions(snapshot, payload.remember));
|
|
return reply.send({});
|
|
} catch {
|
|
return unavailable(reply);
|
|
}
|
|
});
|
|
|
|
app.get("/auth/oidc/login", async (request, reply) => {
|
|
if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply);
|
|
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
|
|
const configured = currentOidcConfig(loaded, deps);
|
|
if (!configured || !deps.sessionStore) {
|
|
clearOidcTransactionCookie(reply);
|
|
return unavailable(reply);
|
|
}
|
|
const nonce = randomOidcValue();
|
|
const codeVerifier = randomOidcValue();
|
|
const browserTransaction = randomOidcValue();
|
|
try {
|
|
const created = await deps.sessionStore.createOidcState({
|
|
nonce,
|
|
codeVerifier,
|
|
returnTo: "/",
|
|
authConfigRevision: configured.loaded.revision,
|
|
issuer: configured.config.oidc.issuer,
|
|
browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"),
|
|
});
|
|
try {
|
|
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
|
|
reply.setCookie(OIDC_TRANSACTION_COOKIE, browserTransaction, oidcTransactionCookieOptions());
|
|
return reply.redirect(location.href);
|
|
} catch {
|
|
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
|
|
clearOidcTransactionCookie(reply);
|
|
return unavailable(reply);
|
|
}
|
|
} catch (error) {
|
|
clearOidcTransactionCookie(reply);
|
|
if (error instanceof OidcStateCapacityError) return loginLimited(reply);
|
|
return unavailable(reply);
|
|
}
|
|
});
|
|
|
|
app.get("/auth/oidc/callback", async (request, reply) => {
|
|
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
|
|
clearOidcTransactionCookie(reply);
|
|
const callback = oidcCallbackUrl(request, loaded?.value.publicUrl);
|
|
if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply);
|
|
let state;
|
|
try {
|
|
state = await deps.sessionStore.consumeOidcState(callback.state);
|
|
} catch {
|
|
return oidcCallbackFailed(reply);
|
|
}
|
|
const configured = currentOidcConfig(loaded, deps);
|
|
if (!callback.currentUrl || !configured || !state || state.returnTo !== "/"
|
|
|| !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest)
|
|
|| state.authConfigRevision !== configured.loaded.revision
|
|
|| state.issuer !== configured.config.oidc.issuer) {
|
|
return oidcCallbackFailed(reply);
|
|
}
|
|
try {
|
|
const identity = await configured.protocol.callback({
|
|
currentUrl: callback.currentUrl,
|
|
state: callback.state,
|
|
nonce: state.nonce,
|
|
codeVerifier: state.codeVerifier,
|
|
});
|
|
if (identity.issuer !== configured.config.oidc.issuer || !Array.isArray(identity.groups)
|
|
|| identity.groups.length === 0) return oidcCallbackFailed(reply);
|
|
const roles = oidcRoles(identity.groups, configured.config);
|
|
const now = new Date();
|
|
const absoluteTtlMs = Math.min(
|
|
configured.config.session.oidcTtlSeconds * 1000,
|
|
identity.tokenExpiresAt.getTime() - now.getTime(),
|
|
);
|
|
if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply);
|
|
const created = await deps.sessionStore.create({
|
|
principal: {
|
|
issuer: identity.issuer,
|
|
subject: identity.subject,
|
|
...(identity.displayName === undefined ? {} : { displayName: identity.displayName }),
|
|
roles,
|
|
permissions: rolesToPermissions(roles),
|
|
isAdmin: roles.includes("admin"),
|
|
},
|
|
method: "oidc",
|
|
remembered: false,
|
|
authConfigRevision: configured.loaded.revision,
|
|
idleTtlMs: configured.config.session.regularIdleSeconds * 1000,
|
|
absoluteTtlMs,
|
|
}, now);
|
|
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false));
|
|
return reply.redirect(state.returnTo);
|
|
} catch {
|
|
return oidcCallbackFailed(reply);
|
|
}
|
|
});
|
|
|
|
app.post("/auth/logout", async (request, reply) => {
|
|
const token = request.authSessionToken;
|
|
if (!token || !deps.sessionStore) return unavailable(reply);
|
|
try {
|
|
await deps.sessionStore.revoke(token);
|
|
reply.clearCookie(sessionCookieName(), cookieOptions(request.authConfigSnapshot, false));
|
|
return reply.code(204).send();
|
|
} catch {
|
|
return unavailable(reply);
|
|
}
|
|
});
|
|
|
|
app.get("/me", async (request, reply) => {
|
|
const principal = requirePermission(request, reply, "session.use");
|
|
if (!isPrincipalContext(principal)) return principal;
|
|
const session = request.authSession;
|
|
const token = request.authSessionToken;
|
|
if (!session || !token) {
|
|
return {
|
|
issuer: principal.issuer,
|
|
subject: principal.subject,
|
|
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
|
|
roles: principal.roles,
|
|
permissions: principal.permissions,
|
|
isAdmin: principal.isAdmin,
|
|
csrfToken: null,
|
|
session: null,
|
|
};
|
|
}
|
|
try {
|
|
return {
|
|
issuer: principal.issuer,
|
|
subject: principal.subject,
|
|
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
|
|
roles: principal.roles,
|
|
permissions: principal.permissions,
|
|
isAdmin: principal.isAdmin,
|
|
csrfToken: deriveCsrfToken(token),
|
|
session: {
|
|
method: session.method,
|
|
remembered: session.remembered,
|
|
idleExpiresAt: session.idleExpiresAt,
|
|
absoluteExpiresAt: session.absoluteExpiresAt,
|
|
},
|
|
};
|
|
} catch {
|
|
return unavailable(reply);
|
|
}
|
|
});
|
|
}
|
|
|
|
function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
|
|
| {
|
|
revision: string;
|
|
origin: string;
|
|
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
|
|
registry: LocalUserRegistry;
|
|
kind: "local";
|
|
}
|
|
| { kind: "not_local" }
|
|
| { kind: "unavailable" } {
|
|
try {
|
|
if (!loaded) return { kind: "unavailable" };
|
|
if (loaded.value.mode !== "local") return { kind: "not_local" };
|
|
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
|
|
if (!registry) return { kind: "unavailable" };
|
|
const url = new URL(loaded.value.publicUrl);
|
|
return {
|
|
kind: "local",
|
|
revision: loaded.revision,
|
|
origin: url.origin,
|
|
session: loaded.value.session,
|
|
registry,
|
|
};
|
|
} catch {
|
|
return { kind: "unavailable" };
|
|
}
|
|
}
|
|
|
|
function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boolean) {
|
|
let secure = false;
|
|
try {
|
|
secure = snapshot !== undefined && new URL(snapshot.value.publicUrl).protocol === "https:";
|
|
} catch {
|
|
// Invalid auth configurations are rejected before they can reach this route.
|
|
}
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
path: "/",
|
|
secure,
|
|
...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}),
|
|
};
|
|
}
|
|
|
|
function currentOidcConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
|
|
| { loaded: LoadedAuthConfig; config: OidcAuthenticationConfig; protocol: OidcProtocol }
|
|
| undefined {
|
|
if (!loaded || loaded.value.mode !== "oidc") return undefined;
|
|
const protocol = deps.resolveOidcProtocol?.(loaded) ?? deps.oidcProtocol;
|
|
return protocol ? { loaded, config: loaded.value, protocol } : undefined;
|
|
}
|
|
|
|
function randomOidcValue(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function oidcTransactionDigest(value: string): Buffer {
|
|
return createHash("sha256").update(value, "utf8").digest();
|
|
}
|
|
|
|
function oidcTransactionMatches(value: string | undefined, expectedDigest: string): boolean {
|
|
const canonical = typeof value === "string" && OIDC_VALUE_PATTERN.test(value);
|
|
const expectedCanonical = /^[a-f0-9]{64}$/.test(expectedDigest);
|
|
const supplied = oidcTransactionDigest(canonical ? value : "");
|
|
const expected = expectedCanonical ? Buffer.from(expectedDigest, "hex") : Buffer.alloc(32);
|
|
const matches = timingSafeEqual(supplied, expected);
|
|
return canonical && expectedCanonical && matches;
|
|
}
|
|
|
|
function oidcTransactionCookieOptions() {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
path: "/",
|
|
secure: true,
|
|
maxAge: OIDC_TRANSACTION_COOKIE_SECONDS,
|
|
};
|
|
}
|
|
|
|
function clearOidcTransactionCookie(reply: FastifyReply): void {
|
|
reply.clearCookie(OIDC_TRANSACTION_COOKIE, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
secure: true,
|
|
});
|
|
}
|
|
|
|
function oidcCallbackUrl(
|
|
request: FastifyRequest,
|
|
publicUrl: string | undefined,
|
|
): { currentUrl?: URL; state?: string } {
|
|
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) {
|
|
return { state: oversizedOidcCallbackState(request.url) };
|
|
}
|
|
let supplied: URL;
|
|
try {
|
|
supplied = new URL(request.url, "http://callback.invalid");
|
|
} catch {
|
|
return {};
|
|
}
|
|
if (supplied.pathname !== "/auth/oidc/callback") return {};
|
|
const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
|
|
const copied = new URLSearchParams();
|
|
let state: string | undefined;
|
|
let valid = true;
|
|
for (const [key, value] of supplied.searchParams) {
|
|
if (key === "state" && state === undefined && OIDC_VALUE_PATTERN.test(value)) state = value;
|
|
if (!allowed.has(key) || value.length > 2048 || /\p{Cc}/u.test(value) || copied.has(key)) {
|
|
valid = false;
|
|
continue;
|
|
}
|
|
copied.set(key, value);
|
|
}
|
|
if (!state || !valid || copied.get("state") !== state || publicUrl === undefined) return { state };
|
|
let target: URL;
|
|
try {
|
|
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
|
|
} catch {
|
|
return { state };
|
|
}
|
|
target.search = copied.toString();
|
|
return { currentUrl: target, state };
|
|
}
|
|
|
|
function oversizedOidcCallbackState(rawUrl: string): string | undefined {
|
|
const prefix = "/auth/oidc/callback?";
|
|
if (!rawUrl.startsWith(prefix)) return undefined;
|
|
const boundedQuery = rawUrl.slice(prefix.length, MAX_OIDC_CALLBACK_QUERY_LENGTH);
|
|
let offset = 0;
|
|
while (offset < boundedQuery.length) {
|
|
const separator = boundedQuery.indexOf("&", offset);
|
|
const end = separator === -1 ? boundedQuery.length : separator;
|
|
const parameter = boundedQuery.slice(offset, end);
|
|
if (parameter.startsWith("state=")) {
|
|
const value = parameter.slice("state=".length);
|
|
if (OIDC_VALUE_PATTERN.test(value)) return value;
|
|
}
|
|
if (separator === -1) break;
|
|
offset = separator + 1;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function oidcCallbackFailed(reply: FastifyReply) {
|
|
return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" });
|
|
}
|
|
|
|
function oidcRoles(groups: readonly string[], config: OidcAuthenticationConfig): Role[] {
|
|
const roles = new Set<Role>();
|
|
for (const group of groups) {
|
|
for (const role of config.authorization.groupRoles[group] ?? []) roles.add(role);
|
|
}
|
|
return [...roles];
|
|
}
|
|
|
|
function loginPayload(request: FastifyRequest): LoginPayload {
|
|
const body = request.body;
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
|
|
const input = body as Record<string, unknown>;
|
|
return {
|
|
username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "",
|
|
password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "",
|
|
remember: input.remember === true,
|
|
};
|
|
}
|
|
|
|
function boundedAddress(address: string): string {
|
|
return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown";
|
|
}
|
|
|
|
function argon2SafePassword(value: string): string {
|
|
const typed = value as string & { isWellFormed?: () => boolean };
|
|
const wellFormed = typeof typed.isWellFormed === "function"
|
|
? typed.isWellFormed()
|
|
: !/[\uD800-\uDFFF]/.test(value);
|
|
const bytes = Buffer.byteLength(value, "utf8");
|
|
if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value;
|
|
// A per-attempt random value preserves the Argon2 work without turning an invalid input into
|
|
// a reusable password that could happen to match a user's configured secret.
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function invalidCredentials(reply: FastifyReply): FastifyReply {
|
|
return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" });
|
|
}
|
|
|
|
function loginLimited(reply: FastifyReply): FastifyReply {
|
|
return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" });
|
|
}
|
|
|
|
function unavailable(reply: FastifyReply): FastifyReply {
|
|
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
|
}
|