Shoraky commited on
Commit
35243f1
·
1 Parent(s): 57b63b8

Bound auth upstream waits and preserve sessions during outages

Browse files
src/auth.ts CHANGED
@@ -67,6 +67,8 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
67
  if (accessToken) {
68
  const { data, error } = await verifyAccess(accessToken);
69
  if (data.user && !error) return { user: data.user };
 
 
70
  }
71
 
72
  if (!refreshToken) {
@@ -74,6 +76,7 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
74
  return null;
75
  }
76
  const { data, error } = await refreshAccess(refreshToken);
 
77
  if (error || !data.user || !data.session) {
78
  clearSessionCookies(res);
79
  return null;
 
67
  if (accessToken) {
68
  const { data, error } = await verifyAccess(accessToken);
69
  if (data.user && !error) return { user: data.user };
70
+ // An outage is not evidence that credentials expired. Do not rotate or clear cookies.
71
+ if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error;
72
  }
73
 
74
  if (!refreshToken) {
 
76
  return null;
77
  }
78
  const { data, error } = await refreshAccess(refreshToken);
79
+ if (error && ![400, 401, 403].includes(error.status ?? 0)) throw error;
80
  if (error || !data.user || !data.session) {
81
  clearSessionCookies(res);
82
  return null;
src/request-timing.ts CHANGED
@@ -17,6 +17,13 @@ export const timedFetch: typeof fetch = async (input, init) => {
17
  }
18
  };
19
 
 
 
 
 
 
 
 
20
  export const requestTiming: RequestHandler = (req, res, next) => {
21
  const timing = { upstreamMs: 0, calls: 0 };
22
  const started = performance.now();
 
17
  }
18
  };
19
 
20
+ // Bound identity-service stalls so a shared token check cannot stay pending forever.
21
+ export const timedAuthFetch: typeof fetch = (input, init) => {
22
+ const deadline = AbortSignal.timeout(8_000);
23
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
24
+ return timedFetch(input, { ...init, signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
25
+ };
26
+
27
  export const requestTiming: RequestHandler = (req, res, next) => {
28
  const timing = { upstreamMs: 0, calls: 0 };
29
  const started = performance.now();
src/supabase.ts CHANGED
@@ -3,7 +3,7 @@ import bcrypt from "bcryptjs";
3
  import { randomBytes } from "node:crypto";
4
  import type { Request, Response } from "express";
5
  import { env, isProduction } from "./config.js";
6
- import { timedFetch } from "./request-timing.js";
7
 
8
  // Bind Google's PKCE verifier to the initiating browser, not a shared client's
9
  // in-memory store (which concurrent logins can overwrite or a restart can lose).
@@ -11,7 +11,7 @@ export function oauthClient(req: Request, res: Response) {
11
  const cookieName = "sp_oauth_verifier";
12
  const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
13
  return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
14
- global: { fetch: timedFetch },
15
  auth: {
16
  autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
17
  storage: {
@@ -28,7 +28,7 @@ export function oauthClient(req: Request, res: Response) {
28
  }
29
 
30
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
31
- global: { fetch: timedFetch },
32
  // OAuth returns a one-time code for the backend callback to exchange, rather
33
  // than placing session tokens in the browser URL fragment.
34
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
 
3
  import { randomBytes } from "node:crypto";
4
  import type { Request, Response } from "express";
5
  import { env, isProduction } from "./config.js";
6
+ import { timedFetch, timedAuthFetch } from "./request-timing.js";
7
 
8
  // Bind Google's PKCE verifier to the initiating browser, not a shared client's
9
  // in-memory store (which concurrent logins can overwrite or a restart can lose).
 
11
  const cookieName = "sp_oauth_verifier";
12
  const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
13
  return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
14
+ global: { fetch: timedAuthFetch },
15
  auth: {
16
  autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
17
  storage: {
 
28
  }
29
 
30
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
31
+ global: { fetch: timedAuthFetch },
32
  // OAuth returns a one-time code for the backend callback to exchange, rather
33
  // than placing session tokens in the browser URL fragment.
34
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
tests/workspace-performance.test.ts CHANGED
@@ -13,6 +13,7 @@ test("optimized workspace reads preserve authorization, scores and fresh permiss
13
  const calls: { method: string; url: URL }[] = [];
14
  let role = "user";
15
  let revoked = false;
 
16
  let failActions = false;
17
  const user = { id: owner, email: "test@example.com", email_confirmed_at: new Date().toISOString(), user_metadata: {} };
18
  const plan = { subscription_id: "plan", plan: "Pro", price: 10, player_limit: 5, session_limit: 10 };
@@ -31,6 +32,7 @@ test("optimized workspace reads preserve authorization, scores and fresh permiss
31
  calls.push({ method: req.method!, url });
32
  res.setHeader("Content-Type", "application/json");
33
  if (url.pathname === "/auth/v1/user") {
 
34
  res.statusCode = revoked ? 401 : 200;
35
  return res.end(JSON.stringify(revoked ? { message: "Revoked" } : user));
36
  }
@@ -81,6 +83,12 @@ test("optimized workspace reads preserve authorization, scores and fresh permiss
81
  ...init, signal: AbortSignal.timeout(10000), headers: { Cookie: "sp_access=access", Origin: origin, ...init.headers },
82
  }).catch((error) => { throw new Error(`Request failed: ${path}`, { cause: error }); });
83
  assert.equal((await fetch(`${origin}/api/players-overview`)).status, 401);
 
 
 
 
 
 
84
  const meStart = calls.length;
85
  const me = await (await request("/api/auth/me")).json();
86
  assert.equal(me.access.active, true);
 
13
  const calls: { method: string; url: URL }[] = [];
14
  let role = "user";
15
  let revoked = false;
16
+ let authUnavailable = false;
17
  let failActions = false;
18
  const user = { id: owner, email: "test@example.com", email_confirmed_at: new Date().toISOString(), user_metadata: {} };
19
  const plan = { subscription_id: "plan", plan: "Pro", price: 10, player_limit: 5, session_limit: 10 };
 
32
  calls.push({ method: req.method!, url });
33
  res.setHeader("Content-Type", "application/json");
34
  if (url.pathname === "/auth/v1/user") {
35
+ if (authUnavailable) { res.statusCode = 503; return res.end(JSON.stringify({ message: "Temporarily unavailable" })); }
36
  res.statusCode = revoked ? 401 : 200;
37
  return res.end(JSON.stringify(revoked ? { message: "Revoked" } : user));
38
  }
 
83
  ...init, signal: AbortSignal.timeout(10000), headers: { Cookie: "sp_access=access", Origin: origin, ...init.headers },
84
  }).catch((error) => { throw new Error(`Request failed: ${path}`, { cause: error }); });
85
  assert.equal((await fetch(`${origin}/api/players-overview`)).status, 401);
86
+ authUnavailable = true;
87
+ const outage = await request("/api/auth/me", { headers: { Cookie: "sp_access=access; sp_refresh=refresh" } });
88
+ assert.equal(outage.status, 500);
89
+ assert.equal(outage.headers.get("set-cookie"), null, "an outage must preserve session cookies");
90
+ assert.equal(calls.some((call) => call.url.pathname === "/auth/v1/token"), false, "an outage must not rotate refresh tokens");
91
+ authUnavailable = false;
92
  const meStart = calls.length;
93
  const me = await (await request("/api/auth/me")).json();
94
  assert.equal(me.access.active, true);