The three-mode auth boundary
Follow one request from token to authorized handler across all three providers, then into the Entra-only tenant/role gate and the data edge — doc to code and back.
backend/src/lib/supabase.ts130 lines · createServerSupabase L66–99
Outline 4 symbols
- getAuthProvider function
- postgrestFetchWrapper function
- createServerSupabase function export
- getUserIdFromRequest function export
1import { createClient } from "@supabase/supabase-js";
2
3// Historical naming note:
4// the upstream app used hosted Supabase directly. In this fork the backend still
5// uses supabase-js as a PostgREST query client, but SUPABASE_URL may point to
6// PostgREST directly (local dev or Azure deployment) rather than hosted Supabase.
7// Treat this module as the current data-client boundary, not as a platform
8// decision to keep Supabase services.
9
10function getAuthProvider() {
11 return (process.env.AUTH_PROVIDER ?? "supabase").toLowerCase();
12}
13
14// supabase-js hard-codes `${url}/rest/v1` as the REST base — that prefix
15// matches hosted Supabase but PostgREST serves tables at root. This
16// wrapper rewrites the path back to root. Used in both local and entra
17// modes so the unmodified supabase-js client can talk to PostgREST
18// directly (no Caddy or other reverse proxy required).
19//
20// In entra mode we additionally strip the Authorization and apikey
21// headers — the deployed PostgREST has no JWT validation configured
22// (PGRST_DB_ANON_ROLE = service_role) and refuses requests carrying
23// Authorization when no jwt-secret is set. Trust comes from network
24// isolation: nothing outside the Container Apps Environment can reach
25// PostgREST. In local mode the headers are kept because PostgREST
26// validates the JWT against PGRST_JWT_SECRET.
27//
28// Built around `new Request(input, init)` so it handles both call shapes
29// supabase-js uses internally (string URL + init, or Request as input).
30function postgrestFetchWrapper(opts: { stripAuth: boolean }): typeof fetch {
31 return async (input, init) => {
32 const request = new Request(input, init);
33 const url = new URL(request.url);
34 if (url.pathname.startsWith("/rest/v1/")) {
35 url.pathname = url.pathname.slice("/rest/v1".length);
36 }
37 if (opts.stripAuth) {
38 request.headers.delete("Authorization");
39 request.headers.delete("apikey");
40 }
41 return fetch(url.toString(), {
42 method: request.method,
43 headers: request.headers,
44 body:
45 request.method === "GET" || request.method === "HEAD"
46 ? undefined
47 : await request.arrayBuffer(),
48 redirect: request.redirect,
49 signal: request.signal,
50 });
51 };
52}
53
54/**
55 * Server-side PostgREST client implemented with supabase-js.
56 * - supabase mode: service-role JWT via SUPABASE_SECRET_KEY; default
57 * supabase-js fetch (sends to /rest/v1/<table>, which is what hosted
58 * Supabase serves).
59 * - local mode: service-role JWT via SUPABASE_SECRET_KEY; URL-rewrite
60 * wrapper so SUPABASE_URL can point at PostgREST directly (no proxy).
61 * - entra mode: no JWT. Headers stripped; PostgREST treats every request
62 * as anonymous and uses its anon-role default (service_role). See the
63 * long comment in infra/modules/containerapp-postgrest.bicep for the
64 * full trust-model rationale.
65 */
66export function createServerSupabase() {
67 const url = process.env.SUPABASE_URL || "";
68 if (!url) {
69 throw new Error("SUPABASE_URL is required");
70 }
71
72 const provider = getAuthProvider();
73
74 if (provider === "entra") {
75 // The "key" arg is required by supabase-js but never reaches PostgREST
76 // — the fetch wrapper deletes the Authorization and apikey headers
77 // before the request leaves the process.
78 return createClient(url, "unused-entra-mode-no-auth", {
79 auth: { persistSession: false },
80 global: { fetch: postgrestFetchWrapper({ stripAuth: true }) },
81 });
82 }
83
84 if (provider === "local") {
85 const key = process.env.SUPABASE_SECRET_KEY || "";
86 return createClient(url, key, {
87 auth: { persistSession: false },
88 global: { fetch: postgrestFetchWrapper({ stripAuth: false }) },
89 });
90 }
91
92 // supabase mode — default supabase-js behavior, including the /rest/v1
93 // prefix that hosted Supabase actually serves.
94 const key = process.env.SUPABASE_SECRET_KEY || "";
95 if (!url || !key) {
96 throw new Error("SUPABASE_URL and SUPABASE_SECRET_KEY must be set");
97 }
98 return createClient(url, key, { auth: { persistSession: false } });
99}
100
101/**
102 * Extract and verify the Supabase JWT from the Authorization header.
103 * Returns the user's UUID string, or throws a Response with 401.
104 */
105export async function getUserIdFromRequest(req: Request): Promise<string> {
106 const auth = req.headers.get("authorization") ?? "";
107 if (!auth.startsWith("Bearer ")) {
108 throw new Response("Missing or invalid Authorization header", {
109 status: 401,
110 });
111 }
112 const token = auth.slice(7).trim();
113
114 const supabaseUrl = process.env.SUPABASE_URL || "";
115 const serviceKey = process.env.SUPABASE_SECRET_KEY || "";
116
117 if (!supabaseUrl || !serviceKey) {
118 throw new Response("Server auth is not configured", { status: 500 });
119 }
120
121 const admin = createClient(supabaseUrl, serviceKey, {
122 auth: { persistSession: false },
123 });
124 const { data } = await admin.auth.getUser(token);
125 if (!data.user) {
126 throw new Response("Invalid or expired token", { status: 401 });
127 }
128 return data.user.id;
129}
130