Spaces:
Running
Running
Bound auth upstream waits and preserve sessions during outages
Browse files- src/auth.ts +3 -0
- src/request-timing.ts +7 -0
- src/supabase.ts +3 -3
- tests/workspace-performance.test.ts +8 -0
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:
|
| 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:
|
| 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);
|