From 882eadc5af6bb010c7dfed0688f016f98da322a0 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 08:09:53 +0100 Subject: [PATCH] feat(auth): fetch the Supabase key from the API instead of compiling it in Every release so far compiled the Supabase anon key in, so the key could not be rotated without breaking pinned CLI versions (dcd#1112). The CLI now fetches its publishable key from the API's public GET /cli-config, once per process, so a rotation is an env change on the API. - The key is kept in memory only. A stale on-disk copy would make a session refresh fail with a 4xx, which drops the user's login. - A failed key fetch during refresh is a retryable error, never a definitive one, so the stored session survives. - Supabase URLs stay pinned (prod uses the cloud.devicecloud.dev custom domain); only the key comes from the API. - Raw Storage/Auth requests send an sb_publishable_ key as `apikey` only, and a legacy JWT key as a Bearer too. - Realtime keeps a synchronous subscribe() and connects once the key arrives; until then the backstop poll covers it. - The anon keys stay in the gitleaks allowlist, relabelled: they remain in history, which CI scans in full. Co-Authored-By: Claude Opus 5.5 --- .gitleaks.toml | 27 ++--- .husky/pre-commit | 2 +- src/commands/login.ts | 18 ++-- src/commands/logout.ts | 4 +- src/config/environments.ts | 19 ++-- src/config/supabase-key.ts | 60 +++++++++++ src/gateways/cli-auth-gateway.ts | 25 +++-- src/gateways/realtime-gateway.ts | 170 +++++++++++++++++-------------- src/gateways/supabase-gateway.ts | 15 +-- src/utils/auth.ts | 4 +- test/unit/supabase-key.test.ts | 96 +++++++++++++++++ 11 files changed, 309 insertions(+), 131 deletions(-) create mode 100644 src/config/supabase-key.ts create mode 100644 test/unit/supabase-key.test.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index 555ff26..2bff356 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,20 +1,22 @@ # Gitleaks configuration — extends the default ruleset. # # The only allowlisted secrets are PUBLIC key material committed in -# src/config/environments.ts: -# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in -# client code and gated by RLS (see the doc comment in that file). -# - the two KEK public keys (prod + dev) for client-side binary envelope -# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only -# *encrypt*; the private halves live solely on the API, never in this repo. -# Both are intentionally not secret; gitleaks flags them on entropy alone +# src/config/environments.ts: the two KEK public keys (prod + dev) for +# client-side binary envelope encryption — base64 of the raw 32-byte X25519 +# PUBLIC half. They can only *encrypt*; the private halves live solely on the +# API, never in this repo. gitleaks flags them on entropy alone # (generic-api-key), not because it recognizes them as credentials. # +# The two legacy Supabase anon keys (prod + dev) are also listed, but only +# because they remain in git history and CI scans the full history. The source +# no longer contains them: the CLI fetches its publishable key from the API's +# GET /cli-config at runtime (dcd#1112). Do not add a Supabase key back. +# # They are allowlisted by EXACT VALUE, deliberately not by file path or by the -# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a -# JWT, and a KEK *private* key is the same shape as its public half, so a -# path/rule allowlist would let a genuinely sensitive key pasted into the same -# file slip through. Matching exact values keeps that detection intact. +# whole `generic-api-key` rule: a KEK *private* key is the same shape as its +# public half, so a path/rule allowlist would let a genuinely sensitive key +# pasted into the same file slip through. Matching exact values keeps that +# detection intact. # # When a KEK is rotated, replace the corresponding value below — do not simply # append, or the retired key stops being distinguishable from a live secret. @@ -22,8 +24,9 @@ useDefault = true [allowlist] -description = "Public Supabase anon keys and KEK public keys (safe to commit)" +description = "KEK public keys, and retired Supabase anon keys left in history (safe)" regexes = [ + # retired legacy anon keys (prod, dev), history only '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''', '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''', # prod kekPublicKey v1 diff --git a/.husky/pre-commit b/.husky/pre-commit index aa604fd..0386007 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh # Scan staged changes for secrets before they ever enter git history. # Mirrors the gitleaks CI gate (.github/workflows/cli-ci.yml) and shares the -# same allowlist (.gitleaks.toml), so the public Supabase anon keys won't trip. +# same allowlist (.gitleaks.toml), so the public KEK keys won't trip. # # gitleaks is NOT a dependency of this package — contributors install the binary # themselves. If it's missing we skip (exit 0) rather than block: CI is the diff --git a/src/commands/login.ts b/src/commands/login.ts index fe15f7a..b438ec2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -36,7 +36,13 @@ import { defineCommand } from 'citty'; import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { + type DcdEnvName, + ENVIRONMENTS, + inferEnvFromApiUrl, + resolveFrontendUrl, +} from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; @@ -200,11 +206,7 @@ export const loginCommand = defineCommand({ // rejects it, the token itself is bad; if Supabase accepts, the // api's JwtService is misconfigured. const iss = decodeJwtIssuer(payload.access_token); - const supabaseUser = await probeSupabaseUser( - iss, - ENVIRONMENTS[env].supabase.anonKey, - payload.access_token, - ); + const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token); const parts = [ iss ? `token iss: ${iss}` : null, `supabase /auth/v1/user: ${supabaseUser}`, @@ -335,7 +337,7 @@ function base64url(buf: Buffer): string { async function probeSupabaseUser( iss: string | null, - anonKey: string, + env: DcdEnvName, token: string, ): Promise { if (!iss) return 'skipped (no iss)'; @@ -343,7 +345,7 @@ async function probeSupabaseUser( const res = await fetch(`${iss}/user`, { headers: { authorization: `Bearer ${token}`, - apikey: anonKey, + apikey: await getSupabaseKey(env), }, }); const bodyText = await res.text().catch(() => ''); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 8072b31..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,6 @@ */ import { defineCommand } from 'citty'; -import { ENVIRONMENTS } from '../config/environments.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; import { logger } from '../utils/cli.js'; @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({ return; } - const { anonKey } = ENVIRONMENTS[config.env].supabase; - await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); + await CliAuthGateway.signOut(config.supabase_url, config.env, config.session); clearConfig(); if (json) { logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email }); diff --git a/src/config/environments.ts b/src/config/environments.ts index 22a16c8..477ce53 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -1,12 +1,10 @@ /** * Per-env devicecloud.dev coordinates — single source of truth for the URLs - * and Supabase credentials the CLI uses. + * the CLI uses. * - * **These `anonKey` values are public.** Supabase anon keys are JWTs designed - * to be embedded in client code (web apps, mobile apps, CLIs). They only - * grant whatever the project's RLS policies expose. Don't worry about seeing - * them here — every Supabase-based product ships its anon key the same way. - * https://supabase.com/docs/guides/api/api-keys + * There is deliberately no Supabase key here. A compiled-in key can never be + * rotated without breaking every pinned CLI version, so it is fetched from the + * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). */ export type DcdEnvName = 'dev' | 'prod'; @@ -17,13 +15,12 @@ export interface DcdEnvironment { supabase: { url: string; projectRef: string; - anonKey: string; }; /** * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed** (like the anon - * key above): it can only *encrypt*; the private half lives solely on the API. + * KEK the platform API unwraps with. **Public — safe to embed**: it can only + * *encrypt*; the private half lives solely on the API. * * Left `null` until the KEK is generated and provisioned. Generate a keypair * with, e.g.: @@ -42,8 +39,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, @@ -53,8 +48,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts new file mode 100644 index 0000000..5f3ca42 --- /dev/null +++ b/src/config/supabase-key.ts @@ -0,0 +1,60 @@ +/** + * The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112). + * + * Releases up to this one compiled the anon key in, so the key could never be + * rotated without breaking every CLI version pinned in customer CI. The API now + * serves it from the public `GET /cli-config`, and rotating it is an env change + * on the API. + * + * Fetched once per process and kept in memory only. It is deliberately not + * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which + * `resolveAuth` treats as a dead session and deletes the user's login. + * + * Only the key comes from the API. The Supabase URLs stay pinned in + * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, + * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. + */ +import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; + +const FETCH_TIMEOUT_MS = 10_000; + +const cache = new Map>(); + +async function fetchSupabaseKey(env: DcdEnvName): Promise { + const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body = (await res.json()) as { supabase_publishable_key?: unknown }; + const key = body.supabase_publishable_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + } + return key; +} + +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export function getSupabaseKey(env: DcdEnvName): Promise { + let pending = cache.get(env); + if (!pending) { + pending = fetchSupabaseKey(env); + cache.set(env, pending); + pending.catch(() => cache.delete(env)); + } + return pending; +} + +/** + * Headers that authenticate a raw Storage/Auth request with `key` alone. A + * legacy anon key is a JWT and also goes in `Authorization`; an + * `sb_publishable_` key is not a JWT and must only be sent as `apikey`. + */ +export function supabaseKeyHeaders(key: string): Record { + return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` }; +} + +/** Test seam: forget fetched keys. */ +export function resetSupabaseKeyCache(): void { + cache.clear(); +} diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 35b10cf..18caf9a 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -7,6 +7,8 @@ */ import { createClient, isAuthApiError } from '@supabase/supabase-js'; +import type { DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import type { StoredSession } from '../utils/config-store.js'; /** @@ -34,8 +36,8 @@ export interface RefreshedSession { user_id: string; } -function client(supabaseUrl: string, supabaseAnonKey: string) { - return createClient(supabaseUrl, supabaseAnonKey, { +function client(supabaseUrl: string, supabaseKey: string) { + return createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, }); } @@ -43,10 +45,21 @@ function client(supabaseUrl: string, supabaseAnonKey: string) { export const CliAuthGateway = { async refresh( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { - const sb = client(supabaseUrl, supabaseAnonKey); + let supabaseKey: string; + try { + supabaseKey = await getSupabaseKey(env); + } catch (error) { + // Never definitive: the refresh token was not even tried, so the stored + // session must survive for the next attempt. + throw new SessionRefreshError( + `Failed to refresh session: ${(error as Error).message}`, + false, + ); + } + const sb = client(supabaseUrl, supabaseKey); const { data, error } = await sb.auth.refreshSession({ refresh_token: session.refresh_token, }); @@ -78,7 +91,7 @@ export const CliAuthGateway = { async signOut( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { // `scope: 'local'` clears this client's SDK state only — no server call, @@ -86,8 +99,8 @@ export const CliAuthGateway = { // EVERY session for the user (browser, mobile, other CLIs), so // `dcd logout` would kick the user out of the web app. We don't want // that — logging out locally should be local. - const sb = client(supabaseUrl, supabaseAnonKey); try { + const sb = client(supabaseUrl, await getSupabaseKey(env)); await sb.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token, diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 49035c5..2ede962 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -16,10 +16,12 @@ import { createClient, REALTIME_SUBSCRIBE_STATES, + type RealtimeChannel, type SupabaseClient, } from '@supabase/supabase-js'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; export interface RealtimeResultsSubscription { /** @@ -83,87 +85,99 @@ export class RealtimeResultsGateway { }; let client: SupabaseClient | undefined; - try { - const { url, anonKey } = ENVIRONMENTS[env].supabase; - client = createClient(url, anonKey, { - // Match SupabaseClientGateway / the frontend; no session persistence — - // we set the token explicitly below. - auth: { autoRefreshToken: false, persistSession: false }, - realtime: { params: { eventsPerSecond: 10 } }, - }); - - // Attach the user's JWT to the socket so RLS is enforced on the channel. - client.realtime.setAuth(accessToken); + let channel: RealtimeChannel | undefined; + let closed = false; + // The latest JWT, so a refresh that lands while the key is still being + // fetched is the one the socket joins with. + let token = accessToken; - const channel = client - .channel(`results-cli-${uploadId}`) - .on( - // The typings don't cover the postgres_changes overload cleanly. - 'postgres_changes' as never, - { - event: '*', - schema: 'public', - table: 'results', - filter: `org_id=eq.${orgId}`, - } as never, - (payload: ResultChangePayload) => { - if (payload.new?.test_upload_id === uploadId) { - dbg(`change for upload ${uploadId}`); - onChange(); - } - }, - ) - .subscribe((status) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - dbg('subscribed'); - setConnected(true); - } else if ( - status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || - status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || - status === REALTIME_SUBSCRIBE_STATES.CLOSED - ) { - // Don't try to recover — the backstop poll covers us. Surface in - // debug so a network-blocked websocket is diagnosable. - dbg(`channel ${status}; relying on backstop poll`); - setConnected(false); - } + // The publishable key comes from the API (dcd#1112), so the socket opens + // asynchronously. `subscribe` stays synchronous for the caller: until it + // connects, `isConnected` is false and the backstop poll covers the gap. + const ready = getSupabaseKey(env) + .then((supabaseKey) => { + if (closed) return; + const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, { + // Match SupabaseClientGateway / the frontend; no session persistence — + // we set the token explicitly below. + auth: { autoRefreshToken: false, persistSession: false }, + realtime: { params: { eventsPerSecond: 10 } }, }); + client = sb; - const activeClient = client; - return { - isConnected: () => connected, - async unsubscribe() { - setConnected(false); - try { - await activeClient.removeChannel(channel); - await activeClient.realtime.disconnect(); - } catch { - /* best effort — process is exiting anyway */ - } - }, - updateAccessToken(nextToken: string) { - // setAuth updates the join payload and pushes the token to joined - // channels. A failure only costs realtime; the backstop poll stays. - activeClient.realtime.setAuth(nextToken).catch((error: unknown) => { - dbg( - `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, - ); + // Attach the user's JWT to the socket so RLS is enforced on the channel. + sb.realtime.setAuth(token); + + channel = sb + .channel(`results-cli-${uploadId}`) + .on( + // The typings don't cover the postgres_changes overload cleanly. + 'postgres_changes' as never, + { + event: '*', + schema: 'public', + table: 'results', + filter: `org_id=eq.${orgId}`, + } as never, + (payload: ResultChangePayload) => { + if (payload.new?.test_upload_id === uploadId) { + dbg(`change for upload ${uploadId}`); + onChange(); + } + }, + ) + .subscribe((status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + dbg('subscribed'); + setConnected(true); + } else if ( + status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || + status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || + status === REALTIME_SUBSCRIBE_STATES.CLOSED + ) { + // Don't try to recover — the backstop poll covers us. Surface in + // debug so a network-blocked websocket is diagnosable. + dbg(`channel ${status}; relying on backstop poll`); + setConnected(false); + } }); - }, - }; - } catch (error) { - dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); - // Best-effort cleanup of a half-built client, then degrade to polling. - try { - client?.realtime.disconnect(); - } catch { - /* ignore */ - } - return { - isConnected: () => false, - async unsubscribe() {}, - updateAccessToken() {}, - }; - } + }) + .catch((error: unknown) => { + dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); + // Best-effort cleanup of a half-built client, then degrade to polling. + try { + client?.realtime.disconnect(); + } catch { + /* ignore */ + } + client = undefined; + channel = undefined; + }); + + return { + isConnected: () => connected, + async unsubscribe() { + closed = true; + setConnected(false); + await ready; + if (!client) return; + try { + if (channel) await client.removeChannel(channel); + await client.realtime.disconnect(); + } catch { + /* best effort — process is exiting anyway */ + } + }, + updateAccessToken(nextToken: string) { + token = nextToken; + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + client?.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + }; } } diff --git a/src/gateways/supabase-gateway.ts b/src/gateways/supabase-gateway.ts index 67d0478..c37efa7 100644 --- a/src/gateways/supabase-gateway.ts +++ b/src/gateways/supabase-gateway.ts @@ -4,6 +4,7 @@ import { createClient } from '@supabase/supabase-js'; import * as tus from 'tus-js-client'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js'; /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */ export interface ResumableUploadSource { @@ -16,7 +17,7 @@ export interface ResumableUploadSource { export class SupabaseGateway { /** * Upload to Supabase using resumable uploads (TUS protocol) - * Uploads to staging location (uploads/{id}/) using anon key + * Uploads to staging location (uploads/{id}/) using the publishable key * File is later moved to final location by API after finalization * @param env - Environment (dev or prod) * @param path - Staging storage path (uploads/{id}/file.ext) @@ -32,8 +33,8 @@ export class SupabaseGateway { debug = false, onProgress?: (bytesUploaded: number, bytesTotal: number) => void, ): Promise { - const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = - ENVIRONMENTS[env].supabase; + const { projectRef } = ENVIRONMENTS[env].supabase; + const supabaseKey = await getSupabaseKey(env); const storageUrl = `https://${projectRef}.storage.supabase.co`; if (debug) { @@ -58,9 +59,9 @@ export class SupabaseGateway { retryDelays: [0, 3000, 5000, 10_000, 20_000], // Authentication and headers - // Use anon key for staging uploads to uploads/* folder + // Publishable key for staging uploads to uploads/* folder headers: { - authorization: `Bearer ${SUPABASE_PUBLIC_KEY}`, + ...supabaseKeyHeaders(supabaseKey), 'x-upsert': 'true', // Allow overwriting existing files }, @@ -137,7 +138,7 @@ export class SupabaseGateway { file: File, debug = false, ) { - const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase; + const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase; if (debug) { console.log(`[DEBUG] Supabase upload starting...`); @@ -148,7 +149,7 @@ export class SupabaseGateway { } try { - const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY); + const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env)); const uploadToUrl = await supabase.storage .from('organizations') .uploadToSignedUrl(path, token, file); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2b4c146..5e338b7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,6 @@ */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; -import { ENVIRONMENTS } from '../config/environments.js'; import { CliAuthGateway, SessionRefreshError, @@ -196,12 +195,11 @@ async function refreshSessionWithLock( return { config: current, session }; } - const { anonKey } = ENVIRONMENTS[current.env].supabase; let refreshed; try { refreshed = await CliAuthGateway.refresh( current.supabase_url, - anonKey, + current.env, session, ); } catch (error) { diff --git a/test/unit/supabase-key.test.ts b/test/unit/supabase-key.test.ts new file mode 100644 index 0000000..20bd390 --- /dev/null +++ b/test/unit/supabase-key.test.ts @@ -0,0 +1,96 @@ +import { expect } from 'chai'; + +import { + getSupabaseKey, + resetSupabaseKeyCache, + supabaseKeyHeaders, +} from '../../src/config/supabase-key.js'; +import { CliAuthGateway, SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; + +/** + * The Supabase key is fetched from the API's `GET /cli-config` (dcd#1112) + * rather than compiled in, so it can be rotated without a CLI release. + */ +describe('Supabase key from /cli-config', () => { + const realFetch = globalThis.fetch; + let requested: string[]; + + function stubFetch(respond: () => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => resetSupabaseKeyCache()); + afterEach(() => { + globalThis.fetch = realFetch; + resetSupabaseKeyCache(); + }); + + it('fetches the key from the env API once per process', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_abc' })); + + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('does not cache a failure, so the next call retries', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await getSupabaseKey('dev'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_dev' })); + expect(await getSupabaseKey('dev')).to.equal('sb_publishable_dev'); + }); + + it('rejects a response with no key', async () => { + stubFetch(() => json({ supabase_url: 'https://x.supabase.co' })); + let message = ''; + try { + await getSupabaseKey('prod'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('no supabase_publishable_key'); + }); + + it('sends a publishable key as apikey only, and a legacy JWT key as a Bearer too', () => { + expect(supabaseKeyHeaders('sb_publishable_abc')).to.deep.equal({ apikey: 'sb_publishable_abc' }); + expect(supabaseKeyHeaders('eyJhbGciOi.x.y')).to.deep.equal({ + apikey: 'eyJhbGciOi.x.y', + authorization: 'Bearer eyJhbGciOi.x.y', + }); + }); + + it('keeps the stored session when the key cannot be fetched during a refresh', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + let caught: unknown; + try { + await CliAuthGateway.refresh('https://cloud.devicecloud.dev', 'prod', { + access_token: 'a', + refresh_token: 'r', + expires_at: 0, + user_email: 'u@example.com', + user_id: 'u1', + }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); + // Not definitive, so resolveAuth will not delete the user's login. + expect((caught as SessionRefreshError).definitive).to.equal(false); + }); +});