Shoraky commited on
Commit
f496327
·
1 Parent(s): ce4b4fb

Fix credentialed Space requests and isolate Google PKCE verifiers

Browse files
Files changed (3) hide show
  1. README.md +8 -0
  2. src/server.ts +22 -5
  3. src/supabase.ts +23 -1
README.md CHANGED
@@ -104,3 +104,11 @@ When a payment provider is added, the server-side webhook flow should:
104
  3. Call `assignPaidSubscription` from `src/subscriptions.ts` with the paid period dates.
105
 
106
  That helper upserts the user's single current plan in `users_subscriptions`. The existing access check then opens Labs, session details, and analysis automatically. The browser return page must not assign access by itself. Store provider payment references/idempotency records when the provider integration is implemented.
 
 
 
 
 
 
 
 
 
104
  3. Call `assignPaidSubscription` from `src/subscriptions.ts` with the paid period dates.
105
 
106
  That helper upserts the user's single current plan in `users_subscriptions`. The existing access check then opens Labs, session details, and analysis automatically. The browser return page must not assign access by itself. Store provider payment references/idempotency records when the provider integration is implemented.
107
+
108
+ ## Browser requests through the Space edge
109
+
110
+ The frontend sends JSON as `text/plain` and carries PUT/PATCH/DELETE through POST with an `_method` query parameter because the Space edge can omit credential headers on preflight responses. Express checks the frontend Origin before restoring the method, parses the JSON with the same body-size limit, and retains route authentication. Internal AI requests remain standard JSON. Do not remove the Origin check or use this transport for internal routes.
111
+
112
+ Google PKCE verifiers are stored in a short-lived HttpOnly, SameSite=Lax cookie tied to the initiating browser. Allow the backend callback and its `?next=**` variant in Supabase's redirect list. Cross-site session cookies still require the browser to allow cookies for the Space.
113
+
114
+ Cross-origin video uploads do not attach upload-progress listeners, since those also trigger preflight. Uploads still go directly to the Space and can be cancelled; processing progress remains available after acceptance.
src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { z } from "zod";
9
  import { env } from "./config.js";
10
  import { analysisRouter } from "./analysis.js";
11
  import { authenticatedUser, clearSessionCookies, safeRedirect, setSessionCookies } from "./auth.js";
12
- import { hashPassword, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
13
 
14
  const app = express();
15
  app.disable("x-powered-by");
@@ -21,7 +21,7 @@ app.use(cors({
21
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
22
  }));
23
  app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
24
- app.use(express.json({ limit: "32kb" }));
25
  app.use(cookieParser());
26
  app.use((req, res, next) => {
27
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
@@ -29,6 +29,21 @@ app.use((req, res, next) => {
29
  return next();
30
  });
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
33
  const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
34
  const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
@@ -303,7 +318,7 @@ app.post("/api/auth/login", authLimiter, async (req, res, next) => {
303
  app.get("/api/auth/google", authLimiter, async (req, res, next) => {
304
  try {
305
  const redirect = typeof req.query.redirect === "string" ? req.query.redirect : "/labs";
306
- const { data, error } = await supabaseAuth.auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${env.BACKEND_URL}/api/auth/callback?next=${encodeURIComponent(redirect)}` } });
307
  if (error || !data.url) return res.status(502).json({ error: "Google sign-in is currently unavailable." });
308
  return res.redirect(data.url);
309
  } catch (error) { next(error); }
@@ -315,7 +330,7 @@ app.get("/api/auth/callback", async (req, res, next) => {
315
  // Explicitly terminate any fragment from an unexpected implicit-flow
316
  // response so access tokens cannot be inherited by this redirect URL.
317
  if (!code) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed#`);
318
- const { data, error } = await supabaseAuth.auth.exchangeCodeForSession(code);
319
  if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
320
  await syncProfile(data.user);
321
  setSessionCookies(res, data.session);
@@ -447,7 +462,9 @@ async function proxyAnalysisRequest(req: Request, res: Response) {
447
  }
448
 
449
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
450
- const body = !hasBody ? undefined : req.is("application/json")
 
 
451
  ? JSON.stringify(req.body ?? {})
452
  : req as unknown as BodyInit;
453
  const init: RequestInit & { duplex?: "half" } = {
 
9
  import { env } from "./config.js";
10
  import { analysisRouter } from "./analysis.js";
11
  import { authenticatedUser, clearSessionCookies, safeRedirect, setSessionCookies } from "./auth.js";
12
+ import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
13
 
14
  const app = express();
15
  app.disable("x-powered-by");
 
21
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
22
  }));
23
  app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
24
+ app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
25
  app.use(cookieParser());
26
  app.use((req, res, next) => {
27
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
 
29
  return next();
30
  });
31
 
32
+ // Browser POST transport avoids HF edge preflight handling. Origin checks above
33
+ // still apply before restoring the method, and every route retains its auth gate.
34
+ app.use((req, res, next) => {
35
+ if (req.query._method === undefined) return next();
36
+ if (req.method !== "POST" || !req.path.startsWith("/api/") ||
37
+ typeof req.query._method !== "string" || !["PUT", "PATCH", "DELETE"].includes(req.query._method)) {
38
+ return res.status(400).json({ error: "Invalid request method." });
39
+ }
40
+ req.method = req.query._method;
41
+ const target = new URL(req.originalUrl, "http://localhost");
42
+ target.searchParams.delete("_method");
43
+ req.url = req.originalUrl = target.pathname + target.search;
44
+ return next();
45
+ });
46
+
47
  const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
48
  const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
49
  const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
 
318
  app.get("/api/auth/google", authLimiter, async (req, res, next) => {
319
  try {
320
  const redirect = typeof req.query.redirect === "string" ? req.query.redirect : "/labs";
321
+ const { data, error } = await oauthClient(req, res).auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${env.BACKEND_URL}/api/auth/callback?next=${encodeURIComponent(redirect)}` } });
322
  if (error || !data.url) return res.status(502).json({ error: "Google sign-in is currently unavailable." });
323
  return res.redirect(data.url);
324
  } catch (error) { next(error); }
 
330
  // Explicitly terminate any fragment from an unexpected implicit-flow
331
  // response so access tokens cannot be inherited by this redirect URL.
332
  if (!code) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed#`);
333
+ const { data, error } = await oauthClient(req, res).auth.exchangeCodeForSession(code);
334
  if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
335
  await syncProfile(data.user);
336
  setSessionCookies(res, data.session);
 
462
  }
463
 
464
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
465
+ const isJson = Boolean(req.is(["application/json", "text/plain"]));
466
+ if (isJson) headers.set("content-type", "application/json");
467
+ const body = !hasBody ? undefined : isJson
468
  ? JSON.stringify(req.body ?? {})
469
  : req as unknown as BodyInit;
470
  const init: RequestInit & { duplex?: "half" } = {
src/supabase.ts CHANGED
@@ -1,7 +1,29 @@
1
  import { createClient, type User } from "@supabase/supabase-js";
2
  import bcrypt from "bcryptjs";
3
  import { randomBytes } from "node:crypto";
4
- import { env } from "./config.js";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
7
  // OAuth returns a one-time code for the backend callback to exchange, rather
 
1
  import { createClient, type User } from "@supabase/supabase-js";
2
  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
+
7
+ // Bind Google's PKCE verifier to the initiating browser, not a shared client's
8
+ // in-memory store (which concurrent logins can overwrite or a restart can lose).
9
+ export function oauthClient(req: Request, res: Response) {
10
+ const cookieName = "sp_oauth_verifier";
11
+ const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
12
+ return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
13
+ auth: {
14
+ autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
15
+ storage: {
16
+ getItem: (key) => key.endsWith("-code-verifier") ? req.cookies?.[cookieName] ?? null : null,
17
+ setItem: (key, value) => {
18
+ if (key.endsWith("-code-verifier")) res.cookie(cookieName, value, { ...options, maxAge: 10 * 60 * 1000 });
19
+ },
20
+ removeItem: (key) => {
21
+ if (key.endsWith("-code-verifier")) res.clearCookie(cookieName, options);
22
+ },
23
+ },
24
+ },
25
+ });
26
+ }
27
 
28
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
29
  // OAuth returns a one-time code for the backend callback to exchange, rather