Spaces:
Running
Running
Fix credentialed Space requests and isolate Google PKCE verifiers
Browse files- README.md +8 -0
- src/server.ts +22 -5
- 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
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|