diff --git a/.github/workflows/database-tests.yml b/.github/workflows/database-tests.yml index 882c0b856c..86e3fbfd6d 100644 --- a/.github/workflows/database-tests.yml +++ b/.github/workflows/database-tests.yml @@ -20,6 +20,5 @@ jobs: - uses: supabase/setup-cli@v1 with: version: 2.72.7 - - run: printf '[]' > supabase/signing_keys.json && supabase gen signing-key --append --yes - run: supabase db start - run: supabase test db diff --git a/editor/.env.example b/editor/.env.example index 65bdc98852..7d18eae790 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -14,14 +14,6 @@ SUPABASE_SECRET_KEY="sb_secret_..." # NEXT_PUBLIC_URL="canary.grida.co" NEXT_PUBLIC_URL="grida.co" -# supabase (additional) (only required when using grida_ciam) (see //supabase/README.md) -SUPABASE_SIGNING_KEY_JSON='{"kty":"EC","kid":"...","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext": true,"d": "...","crv":"P-256","x":"...","y":"..."}' - -# [optional] -# Read Replicas (if you have any) (add with NEXT_PUBLIC_SUPABASE_URL_RR_) -NEXT_PUBLIC_SUPABASE_URL_RR_US_WEST_1=... -NEXT_PUBLIC_SUPABASE_URL_RR_AP_NORTHEAST_2=... - # [insiders config] # flag to use unsafe sandbox (set 1 to enable) NEXT_PUBLIC_GRIDA_UNSAFE_DEVELOPER_SANDBOX='0' diff --git a/editor/env.ts b/editor/env.ts index 3685663676..9bcc07b6b3 100644 --- a/editor/env.ts +++ b/editor/env.ts @@ -67,325 +67,4 @@ export namespace Env { "https://" + process.env.NEXT_PUBLIC_URL : "http://localhost:3000"; } - - export namespace vercel { - /** - * @see https://vercel.com/docs/edge-network/regions - */ - export const regions = [ - ["arn1", "eu-north-1", "Stockholm, Sweden"], - ["bom1", "ap-south-1", "Mumbai, India"], - ["cdg1", "eu-west-3", "Paris, France"], - ["cle1", "us-east-2", "Cleveland, USA"], - ["cpt1", "af-south-1", "Cape Town, South Africa"], - ["dub1", "eu-west-1", "Dublin, Ireland"], - ["fra1", "eu-central-1", "Frankfurt, Germany"], - ["gru1", "sa-east-1", "São Paulo, Brazil"], - ["hkg1", "ap-east-1", "Hong Kong"], - ["hnd1", "ap-northeast-1", "Tokyo, Japan"], - ["iad1", "us-east-1", "Washington, D.C., USA"], - ["icn1", "ap-northeast-2", "Seoul, South Korea"], - ["kix1", "ap-northeast-3", "Osaka, Japan"], - ["lhr1", "eu-west-2", "London, United Kingdom"], - ["pdx1", "us-west-2", "Portland, USA"], - ["sfo1", "us-west-1", "San Francisco, USA"], - ["sin1", "ap-southeast-1", "Singapore"], - ["syd1", "ap-southeast-2", "Sydney, Australia"], - ] as const; - - export type VercelRegionCode = (typeof regions)[number][0]; - export type VercelRegionName = (typeof regions)[number][1]; - - /** - * Resolves a Vercel region code (e.g., "sfo1", "icn1") to a known AWS-style region name - * used internally for Supabase routing and infrastructure selection. - * - * If the region code is not recognized, it falls back to "localhost". - * - * @param region - Vercel edge region code, typically from `geolocation().region` - * @returns A Supabase-compatible AWS region name or "localhost" - * - * @see https://vercel.com/docs/edge-network/regions - */ - export function region( - region: VercelRegionCode | "dev1" | undefined | (string & {}) - ): VercelRegionName | "localhost" | undefined { - if (!region) return undefined; - if (region === "dev1") return "localhost"; - const match = regions.find(([code]) => code === region); - return match?.[1] ?? undefined; - } - } - - /** - * supabase infra - envs are available for bothe server and client - * - * @see https://supabase.com/docs/guides/platform/regions - * - * - * @example if your main region is us-west-1, have it also set as rr. - * ```txt - * NEXT_PUBLIC_SUPABASE_URL="https://primary-all.supabase.co" - * NEXT_PUBLIC_SUPABASE_URL_RR_US_WEST_1="https://primary.supabase.co" # set as rr, even if it is primary - * NEXT_PUBLIC_SUPABASE_URL_RR_AP_NORTHEAST_2="https://primary-rr-ap-northeast-2-xyz.supabase.co" - * NEXT_PUBLIC_SUPABASE_URL_RR_... - * ``` - * - * @remark - * set `NEXT_PUBLIC_GRIDA_LOCALHOST_REGION` to the region you want to use for localhost - */ - export namespace supabase { - /** - * [Primary] - */ - export const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; - - /** - * [Replica] - us-west-1 - West US (North California) - */ - export const SUPABASE_URL_RR_US_WEST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_US_WEST_1; - - /** - * [Replica] - us-east-1 - East US (North Virginia) - */ - export const SUPABASE_URL_RR_US_EAST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_US_EAST_1; - - /** - * [Replica] - us-east-2 - East US (Ohio) - */ - export const SUPABASE_URL_RR_US_EAST_2 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_US_EAST_2; - - /** - * [Replica] - ca-central-1 - Canada (Central) - */ - export const SUPABASE_URL_RR_CA_CENTRAL_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_CA_CENTRAL_1; - - /** - * [Replica] - eu-west-1 - West EU (Ireland) - */ - export const SUPABASE_URL_RR_EU_WEST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_WEST_1; - - /** - * [Replica] - eu-west-2 - West Europe (London) - */ - export const SUPABASE_URL_RR_EU_WEST_2 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_WEST_2; - - /** - * [Replica] - eu-west-3 - West EU (Paris) - */ - export const SUPABASE_URL_RR_EU_WEST_3 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_WEST_3; - - /** - * [Replica] - eu-central-1 - Central EU (Frankfurt) - */ - export const SUPABASE_URL_RR_EU_CENTRAL_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_CENTRAL_1; - - /** - * [Replica] - eu-central-2 - Central Europe (Zurich) - */ - export const SUPABASE_URL_RR_EU_CENTRAL_2 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_CENTRAL_2; - - /** - * [Replica] - eu-north-1 - North EU (Stockholm) - */ - export const SUPABASE_URL_RR_EU_NORTH_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_EU_NORTH_1; - - /** - * [Replica] - ap-south-1 - South Asia (Mumbai) - */ - export const SUPABASE_URL_RR_AP_SOUTH_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_AP_SOUTH_1; - - /** - * [Replica] - ap-southeast-1 - Southeast Asia (Singapore) - */ - export const SUPABASE_URL_RR_AP_SOUTHEAST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_AP_SOUTHEAST_1; - - /** - * [Replica] - ap-northeast-1 - Northeast Asia (Tokyo) - */ - export const SUPABASE_URL_RR_AP_NORTHEAST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_AP_NORTHEAST_1; - - /** - * [Replica] - ap-northeast-2 - Northeast Asia (Seoul) - */ - export const SUPABASE_URL_RR_AP_NORTHEAST_2 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_AP_NORTHEAST_2; - - /** - * [Replica] - ap-southeast-2 - Oceania (Sydney) - */ - export const SUPABASE_URL_RR_AP_SOUTHEAST_2 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_AP_SOUTHEAST_2; - - /** - * [Replica] - sa-east-1 - South America (São Paulo) - */ - export const SUPABASE_URL_RR_SA_EAST_1 = - process.env.NEXT_PUBLIC_SUPABASE_URL_RR_SA_EAST_1; - - export type SupabaseRegion = - | "us-west-1" - | "us-east-1" - | "us-east-2" - | "ca-central-1" - | "eu-west-1" - | "eu-west-2" - | "eu-west-3" - | "eu-central-1" - | "eu-central-2" - | "eu-north-1" - | "ap-south-1" - | "ap-southeast-1" - | "ap-northeast-1" - | "ap-northeast-2" - | "ap-southeast-2" - | "sa-east-1"; - - /** - * [rr] - read replica mapping - */ - export const SUPABASE_READ_REPLICAL_URLS: Record< - SupabaseRegion, - string | undefined - > = { - "us-west-1": SUPABASE_URL_RR_US_WEST_1, - "us-east-1": SUPABASE_URL_RR_US_EAST_1, - "us-east-2": SUPABASE_URL_RR_US_EAST_2, - "ca-central-1": SUPABASE_URL_RR_CA_CENTRAL_1, - "eu-west-1": SUPABASE_URL_RR_EU_WEST_1, - "eu-west-2": SUPABASE_URL_RR_EU_WEST_2, - "eu-west-3": SUPABASE_URL_RR_EU_WEST_3, - "eu-central-1": SUPABASE_URL_RR_EU_CENTRAL_1, - "eu-central-2": SUPABASE_URL_RR_EU_CENTRAL_2, - "eu-north-1": SUPABASE_URL_RR_EU_NORTH_1, - "ap-south-1": SUPABASE_URL_RR_AP_SOUTH_1, - "ap-southeast-1": SUPABASE_URL_RR_AP_SOUTHEAST_1, - "ap-northeast-1": SUPABASE_URL_RR_AP_NORTHEAST_1, - "ap-northeast-2": SUPABASE_URL_RR_AP_NORTHEAST_2, - "ap-southeast-2": SUPABASE_URL_RR_AP_SOUTHEAST_2, - "sa-east-1": SUPABASE_URL_RR_SA_EAST_1, - } as const; - - /** - * Static fallback mapping for each supabase region in case a specific Supabase read replica - * is not configured or unavailable. This allows graceful degradation to the next - * geographically closest or lowest-latency region. - * - * The keys are supabase region codes, and the values are ordered fallback preferences. - * At runtime, the application can iterate this list to find the nearest working replica. - * - * @example - * // If ap-northeast-2 (Seoul) is unavailable, fallback to Tokyo, then Singapore - * physical_fallback_regions["ap-northeast-2"] === ["ap-northeast-1", "ap-southeast-1"] - * - * supabase supports partial region compared to aws. this map only holds the supabase region - * - * @see https://supabase.com/docs/guides/platform/regions - */ - // prettier-ignore - export const supabase_region_to_fallback_region: Record = { - "us-west-1": [ "us-east-1", "us-east-2", "ca-central-1", "sa-east-1"], - "us-east-1": ["us-east-2", "us-west-1", "ca-central-1", "sa-east-1", "eu-west-1"], - "us-east-2": ["us-east-1", "us-west-1", "ca-central-1", "sa-east-1", "eu-west-1"], - "ca-central-1": ["us-east-1", "us-east-2", "us-west-1", "eu-west-1", "sa-east-1"], - "eu-west-1": ["eu-west-2", "eu-central-1", "eu-west-3", "eu-north-1", "ca-central-1"], - "eu-west-2": ["eu-west-1", "eu-central-1", "eu-west-3", "eu-north-1", "ca-central-1"], - "eu-west-3": ["eu-west-1", "eu-central-1", "eu-west-2", "eu-north-1", "ca-central-1"], - "eu-central-1": ["eu-west-1", "eu-central-2", "eu-west-2", "eu-north-1", "ca-central-1"], - "eu-central-2": ["eu-central-1", "eu-west-1", "eu-west-2", "eu-north-1", "ca-central-1"], - "eu-north-1": ["eu-central-1", "eu-west-1", "eu-west-2", "eu-central-2", "ca-central-1"], - "ap-south-1": ["ap-southeast-1", "ap-northeast-1", "ap-northeast-2", "ap-southeast-2", "eu-central-1"], - "ap-southeast-1": ["ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1", "eu-central-1"], - "ap-northeast-1": ["ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "eu-central-1"], - "ap-northeast-2": ["ap-northeast-1", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "eu-central-1"], - "ap-southeast-2": ["ap-southeast-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", "eu-central-1"], - "sa-east-1": ["us-east-1", "us-east-2", "us-west-1", "ca-central-1", "eu-west-1"], - }; - - /** - * Maps full AWS regions to the nearest Supabase-supported region. - * This is useful when a runtime environment (e.g., Vercel) reports a region - * where Supabase does not operate, allowing graceful alignment to the closest valid region. - * - * @example - * // Maps Vercel's us-west-2 (Oregon) to Supabase's us-west-1 (California) - */ - export const aws_to_supabase_region: Record = { - "us-west-1": "us-west-1", - "us-west-2": "us-west-1", // Oregon → California - "us-east-1": "us-east-1", - "us-east-2": "us-east-2", - "ca-central-1": "ca-central-1", - "eu-west-1": "eu-west-1", - "eu-west-2": "eu-west-2", - "eu-west-3": "eu-west-3", - "eu-central-1": "eu-central-1", - "eu-central-2": "eu-central-2", - "eu-north-1": "eu-north-1", - "ap-south-1": "ap-south-1", - "ap-northeast-1": "ap-northeast-1", - "ap-northeast-2": "ap-northeast-2", - "ap-southeast-1": "ap-southeast-1", - "ap-southeast-2": "ap-southeast-2", - "sa-east-1": "sa-east-1", - - // Unavailable AWS regions mapped to closest Supabase-supported ones - "af-south-1": "eu-west-3", // Cape Town → Paris - "me-south-1": "eu-central-1", // Bahrain → Frankfurt - "me-central-1": "eu-central-1", // UAE → Frankfurt - "ap-east-1": "ap-northeast-2", // Hong Kong → Seoul - "eu-south-1": "eu-central-1", // Milan → Frankfurt - "eu-south-2": "eu-central-1", // Spain → Frankfurt - "eu-central-3": "eu-central-1", // Zurich fallback - "ap-southeast-3": "ap-southeast-1", // Jakarta → Singapore - }; - - /** - * Resolves the best Supabase read replica URL based on the provided AWS region. - * - * If the region is not defined, not recognized, or no replica is configured for it, - * the function will fall back to the nearest geographically relevant regions - * as defined in `supabase_region_to_fallback_region`. - * - * If no configured replica is found from the fallback list, the function defaults to the primary Supabase URL. - * - * @param region - AWS region (e.g., "us-west-2" from Vercel's `req.geo.region`) - * @returns Supabase URL to use for read operations - */ - export function rr(region?: string | null | undefined): string { - if (region === "localhost") - region = process.env.NEXT_PUBLIC_GRIDA_LOCALHOST_REGION; - if (!region) return SUPABASE_URL!; - - const sbregion = aws_to_supabase_region[region] as - | SupabaseRegion - | undefined; - if (!sbregion) return SUPABASE_URL!; - - const candidates = [ - sbregion, - ...(supabase_region_to_fallback_region[sbregion] ?? []), - ]; - - for (const candidate of candidates) { - const url = SUPABASE_READ_REPLICAL_URLS[candidate]; - if (url) return url; - } - - return SUPABASE_URL!; - } - } } diff --git a/editor/lib/ciam/server/jwt.ts b/editor/lib/ciam/server/jwt.ts deleted file mode 100644 index dcc09901af..0000000000 --- a/editor/lib/ciam/server/jwt.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { SignJWT, importJWK } from "jose"; - -const SUPABASE_URL = - process.env.NEXT_PUBLIC_SUPABASE_URL || - process.env.SUPABASE_URL || - "http://127.0.0.1:54321"; - -type SigningKeyJwk = { - kty: string; - kid?: string; - alg?: string; - use?: string; - key_ops?: string[]; - crv?: string; - x?: string; - y?: string; - d?: string; // present only in private JWK - [k: string]: unknown; -}; - -function load_jwt_signing_key(): SigningKeyJwk { - // `SUPABASE_SIGNING_KEY_JSON` must be a **private** JWK (must include `d`). - // Note: Supabase JWKS (`/auth/v1/.well-known/jwks.json`) is public-only and cannot be used for signing. - const envJwk = process.env.SUPABASE_SIGNING_KEY_JSON; - - if (!envJwk) { - throw new Error( - "SUPABASE_SIGNING_KEY_JSON is required (private JWK, must include `d`)." - ); - } - - const jwk = JSON.parse(envJwk) as SigningKeyJwk; - - if (!jwk?.d) { - throw new Error( - "SUPABASE_SIGNING_KEY_JSON does not include private key material (missing `d`). " + - "Provide the private JWK JSON (e.g. from your generated signing key)." - ); - } - - return jwk; -} - -/** - * Signs a customer session JWT token using the same signing keys as Supabase Auth - * - * Uses ES256 algorithm with keys from signing_keys.json, so PostgREST accepts the JWT seamlessly - * - * @param sessionId - The customer session ID from grida_ciam.customer_session - * @param expiresAt - Session expiration timestamp - * @returns Signed JWT token string - */ -export async function signCustomerSessionToken( - sessionId: string, - expiresAt: Date -): Promise { - const signingJwk = load_jwt_signing_key(); - - // jose doesn't need/like these for importing to a sign-capable CryptoKey in Node/WebCrypto - const keyWithoutMetadata = { ...signingJwk } as SigningKeyJwk; - delete keyWithoutMetadata.use; - delete keyWithoutMetadata.key_ops; - const privateKey = await importJWK(keyWithoutMetadata, "ES256"); - - const jwt = await new SignJWT({ - sid: sessionId, - role: "authenticated", // Required for Supabase to assign authenticated role - }) - .setProtectedHeader({ alg: "ES256", kid: signingJwk.kid }) - .setIssuedAt() - .setExpirationTime(Math.floor(expiresAt.getTime() / 1000)) - .setSubject(sessionId) - // IMPORTANT: - // We intentionally set `iss` to the Supabase project Auth issuer so hosted PostgREST accepts the JWT. - // Local PostgREST may appear to accept arbitrary `iss`, but hosted Supabase can enforce issuer validation. - // - // TODO(grida_ciam): consider migrating to a true third-party / OIDC issuer setup for CIAM JWTs - // (CIAM as its own issuer + JWKS discovery). This adds non-trivial operational complexity, - // especially if we want issuer scoping per-tenant (per project). - .setIssuer(`${SUPABASE_URL}/auth/v1`) - .setAudience("authenticated") - .sign(privateKey); - - return jwt; -} diff --git a/supabase/.gitignore b/supabase/.gitignore index 1ff36843ca..6ee14a0ccd 100644 --- a/supabase/.gitignore +++ b/supabase/.gitignore @@ -7,5 +7,6 @@ .env.local .env.*.local -# jwt signing keys +# legacy jwt signing keys — no longer used by config, kept ignored so a +# pre-existing local signing_keys.json (private JWK) is never committed signing_keys.json \ No newline at end of file diff --git a/supabase/README.md b/supabase/README.md index a219856376..8d24e1410e 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -50,59 +50,6 @@ Local development uses **Supabase CLI + Docker** and runs a fully local stack (P - Supabase CLI (`supabase`) - From the repo root, work inside `./supabase` (commands below assume `cd supabase`). -### JWT signing keys (one-time local setup) - -This repo intentionally does **not** commit `signing_keys.json` (it’s gitignored). You may need to generate it once for local setup. - -```bash -cd supabase -# NOTE: local Auth currently expects a single signing key. -# This command prints a private JWK to stdout — write it to `signing_keys.json`. -supabase gen signing-key --algorithm ES256 > signing_keys.json -``` - -### JWT Signing Keys - Infra (required by `grida_ciam`) - -When using the **`grida_ciam` customer CIAM** flow (email OTP → customer session → JWT → RLS), the backend needs to **mint JWTs that PostgREST can verify**. - -On **hosted Supabase**, the platform-managed signing keys are **not extractable** (you cannot download the private key). This is intentional for security. As a result, `grida_ciam` **cannot** rely on the Supabase-infra-provided private key to mint JWTs. - -Instead, you must **bring your own signing key** (ES256 recommended) and **import it into your Supabase project**, so: - -- Your backend (self-hosted Grida) can **sign** JWTs using the private key. -- Supabase / PostgREST can **verify** those JWTs using the corresponding public key (via JWKS). - -This does **not** meaningfully disadvantage you. It is the standard approach for “custom / third-party JWTs”. - -#### Setup (hosted Supabase) - -1. Generate your own private key (use CLI or any secure tooling): - -```bash -supabase gen signing-key --algorithm ES256 -``` - -Important: - -- **Do not** commit production keys. -- Store the production private key in a secure secret manager (or generate it in a secure environment and never persist it outside your secret store). - -2. In Supabase dashboard (hosted production), go to JWT signing keys: - -- `https://supabase.com/dashboard/project/_/settings/jwt` -- Create a new **Standby Key** -- Choose **Import an existing private key** -- Paste the private JWK JSON and save (UI labels may change) -- Click **Rotate** (optional: revoke older keys later if you want to keep only one trusted key) - -This makes Supabase trust JWTs signed by your imported key (via the project’s `.../auth/v1/.well-known/jwks.json`). - -3. Configure your Grida backend environment with the private key: - -- Set `SUPABASE_SIGNING_KEY_JSON='{...}'` to the **private JWK JSON** (must include `d`). - -`grida_ciam` uses this to sign customer session JWTs for the customer portal flow (we chose this to avoid coupling / polluting `auth.users` while enabling strict per-tenant dedup). - ### Day-to-day commands (local only) Start / stop / inspect local stack: diff --git a/supabase/config.toml b/supabase/config.toml index eacd92406f..d5317a7748 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -161,8 +161,6 @@ site_url = "http://127.0.0.1:3000" additional_redirect_urls = ["https://127.0.0.1:3000"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 -# Path to JWT signing key. DO NOT commit your signing keys file to git. -signing_keys_path = "./signing_keys.json" # If disabled, the refresh token will never expire. enable_refresh_token_rotation = true # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. diff --git a/supabase/schemas/grida_ciam.md b/supabase/schemas/grida_ciam.md index 59785b6927..f8bbe8fc10 100644 --- a/supabase/schemas/grida_ciam.md +++ b/supabase/schemas/grida_ciam.md @@ -85,63 +85,48 @@ As `grida_ciam` evolves, keep this section up to date, and add new sections belo - If valid and not expired, create a `grida_ciam.customer_session`. - Mark `public.customer.is_email_verified = true` and sync email on success. -3. **Mint JWT** - - Backend mints a JWT containing a session identifier (`sid`). - - Client uses Supabase client `accessToken: async () => jwt`. +3. **Create portal session token** + - Backend issues an opaque URL-safe token via `create_customer_portal_session` + and stores only its `sha256` hash; the raw token is returned once. + - The token is redeemed server-side via `redeem_customer_portal_session`. -4. **RLS** - - DB helpers read `request.jwt.claims` and resolve: - - `customer_uid()` - - `project_id()` - - Policies can reference these helpers to enforce row access. +4. **Identity resolution** + - _Current_: resolved **server-side using `service_role`**, filtering by + `customer_uid` / `project_id` (DB requests do not carry customer-session claims). + - _Future_: DB helpers (`customer_uid()` / `project_id()`) read `request.jwt.claims` + so RLS policies can enforce row access directly. ---- - -### Cryptography & JWT signing keys +See [Session model & cryptography](#session-model--cryptography) below for details. -#### Why ES256 (signing keys) over HS256 (legacy secret)? +--- -- ES256 enables safe rotation and fast verification via public key discovery (JWKS). -- HS256 shared secrets are discouraged for production and complicate rotation. +### Session model & cryptography -See Supabase docs: +#### Current: opaque portal-session tokens -- [`JWT Signing Keys`](https://supabase.com/docs/guides/auth/signing-keys) -- [`JWTs` / verification + `accessToken`](https://supabase.com/docs/guides/auth/jwts#verifying-a-jwt-from-supabase) -- [`Third-party auth` overview (limitations)](https://supabase.com/docs/guides/auth/third-party/overview#limitations) +The customer portal flow (email OTP → customer session → portal session) is **opaque +URL-token based**, not JWT based: -#### First-party custom auth (not a third-party provider) +- `grida_ciam_public.create_customer_portal_session` mints a URL-safe random token + (`grida_ciam.make_url_token`) and stores only its `sha256` hash + (`customer_portal_session.token_hash`); the raw token is returned **once**. +- The token is redeemed server-side via `grida_ciam_public.redeem_customer_portal_session`. +- Identity is resolved **server-side using `service_role`**, filtering by `customer_uid` / + `project_id`. Customer-session claims are **not** attached to DB requests today, so + customer-facing reads do not yet rely on customer-scoped RLS (see the `TODO(ciam)` in + `app/(tenant)/~/[tenant]/(p)/p/session/[token]/page.tsx`). This system intentionally behaves as **first-party custom auth** for the customer portal: +customers are **not** created / managed as Supabase Auth users (`auth.users`). -- We **do not** create / manage customers as Supabase Auth users (`auth.users`). -- We **do** mint JWTs that Supabase/PostgREST can verify so RLS can enforce access. - -There is an alternative design: treat `grida_ciam` as a **third-party auth provider** and have Supabase verify JWTs via an OIDC issuer discovery + JWKS setup. - -While doable, it would likely require: - -- OIDC issuer discovery endpoints and JWKS hosting, and -- a per-tenant (per `project_id`) issuer configuration to preserve strict tenant scoping, - -which is operationally heavier and offers no clear benefits for our current goals. - -As a result, we require `grida_ciam` to sign JWTs using a signing key that Supabase trusts (ES256 signing keys system), while still keeping the customer identity model separate from Supabase Auth. - -#### Hosted Supabase constraint (important) - -Hosted Supabase **does not allow extracting** the platform-managed private signing key. - -Therefore, `grida_ciam` requires you to **bring/import your own private key**: - -- Import your ES256 private key into Supabase Auth as a signing key. -- Store the same private key in your backend secret manager. -- Configure the backend to use `SUPABASE_SIGNING_KEY_JSON` (private JWK; must include `d`). - -This allows: +#### Future direction: customer-session-oriented RLS -- Backend to **sign** JWTs. -- Supabase/PostgREST to **verify** JWTs using JWKS (public-only). +A later iteration may attach a per-customer authorization context to DB requests so RLS +can enforce access directly (rather than going through `service_role`). One candidate is to +mint JWTs that Supabase/PostgREST verifies via JWKS (ES256 signing keys), keeping the +customer identity model separate from Supabase Auth. This is **not implemented** — it would +require a signing-key setup (e.g. bring-your-own key on hosted Supabase, since +platform-managed private keys are not extractable) and is tracked as future work. --- @@ -176,8 +161,6 @@ We keep the public surface RPC-based so internal tables aren’t directly expose ### Operational notes / pitfalls -- **JWKS is public-only**: `.../auth/v1/.well-known/jwks.json` does not include `d` and cannot be used to sign. -- **Local Supabase Auth key file**: the local Auth container expects a compatible signing key setup; keep `signing_keys.json` consistent with your local stack. - **OTP attempt counting**: PL/pgSQL exceptions roll back changes in the same transaction; track attempts accordingly if you need strict accounting. ---