Sporalize-api / src /auth.ts
Shoraky's picture
Bound auth upstream waits and preserve sessions during outages
35243f1
Raw History Blame Contribute Delete
4.37 kB
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Request, Response } from "express";
import type { Session, User } from "@supabase/supabase-js";
import { env, isProduction } from "./config.js";
import { supabaseAuth } from "./supabase.js";
import { singleFlight } from "./single-flight.js";
// Several page, image and polling requests arrive together. Validate once for
// that burst, without extending a revoked token's lifetime through a TTL cache.
const verifyAccess = singleFlight((token: string) => supabaseAuth.auth.getUser(token));
const refreshAccess = singleFlight((token: string) => supabaseAuth.auth.refreshSession({ refresh_token: token }));
const accessCookie = "sp_access";
const refreshCookie = "sp_refresh";
const passwordRecoveryCookie = "sp_password_recovery";
const passwordRecoveryWindowMs = 15 * 60 * 1000;
const sameSite = "lax" as const;
const cookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/" };
const passwordRecoveryCookieOptions = {
httpOnly: true, secure: isProduction, sameSite, path: "/api/auth/password/recovery",
};
function passwordRecoverySignature(userId: string, expiresAt: number) {
return createHmac("sha256", env.SUPABASE_SERVICE_ROLE_KEY)
.update(`sporalize-password-recovery:${userId}:${expiresAt}`)
.digest("base64url");
}
export function setPasswordRecoveryCookie(res: Response, userId: string) {
const expiresAt = Date.now() + passwordRecoveryWindowMs;
res.cookie(passwordRecoveryCookie, `${expiresAt}.${passwordRecoverySignature(userId, expiresAt)}`, {
...passwordRecoveryCookieOptions, maxAge: passwordRecoveryWindowMs,
});
}
export function hasPasswordRecoveryCookie(req: Request, userId: string) {
const value = req.cookies?.[passwordRecoveryCookie];
if (typeof value !== "string") return false;
const [expiresText, suppliedSignature, extra] = value.split(".");
const expiresAt = Number(expiresText);
if (extra !== undefined || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now() ||
expiresAt > Date.now() + passwordRecoveryWindowMs + 5_000 || !suppliedSignature) return false;
const expected = Buffer.from(passwordRecoverySignature(userId, expiresAt));
const supplied = Buffer.from(suppliedSignature);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
}
export function clearPasswordRecoveryCookie(res: Response) {
res.clearCookie(passwordRecoveryCookie, passwordRecoveryCookieOptions);
}
export function setSessionCookies(res: Response, session: Session) {
res.cookie(accessCookie, session.access_token, { ...cookieOptions, maxAge: Math.max(session.expires_in * 1000, 60_000) });
res.cookie(refreshCookie, session.refresh_token, { ...cookieOptions, maxAge: 1000 * 60 * 60 * 24 * 30 });
}
export function clearSessionCookies(res: Response) {
res.clearCookie(accessCookie, cookieOptions);
res.clearCookie(refreshCookie, cookieOptions);
}
export async function authenticatedUser(req: Request, res: Response): Promise<{ user: User; session?: Session } | null> {
const accessToken = req.cookies?.[accessCookie] as string | undefined;
const refreshToken = req.cookies?.[refreshCookie] as string | undefined;
if (!accessToken && !refreshToken) return null;
if (accessToken) {
const { data, error } = await verifyAccess(accessToken);
if (data.user && !error) return { user: data.user };
// An outage is not evidence that credentials expired. Do not rotate or clear cookies.
if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error;
}
if (!refreshToken) {
clearSessionCookies(res);
return null;
}
const { data, error } = await refreshAccess(refreshToken);
if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error;
if (error || !data.user || !data.session) {
clearSessionCookies(res);
return null;
}
setSessionCookies(res, data.session);
return { user: data.user, session: data.session };
}
export function safeRedirect(path: unknown) {
const frontend = new URL(env.FRONTEND_URL);
const fallback = new URL("/labs", frontend).toString();
if (typeof path !== "string" || !path.startsWith("/")) return fallback;
try {
const target = new URL(path, frontend);
return target.origin === frontend.origin ? target.toString() : fallback;
} catch {
return fallback;
}
}