File size: 4,366 Bytes
d579c21
ce4b4fb
 
 
 
82b44e7
 
 
 
 
 
ce4b4fb
 
 
d579c21
 
523ba8d
ce4b4fb
d579c21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce4b4fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82b44e7
ce4b4fb
35243f1
 
ce4b4fb
 
 
 
 
 
82b44e7
35243f1
ce4b4fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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;
  }
}