From eda79bd1c00070f0b7566e88bb1048a83c0b2b16 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Tue, 7 Jul 2026 07:10:32 +0200 Subject: [PATCH 1/3] feat(stack): typed external OAuth providers and redirect allow list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the classic CLI's [auth.external.*] and additional_redirect_urls surfaces to the stack — the auth config the Go CLI has that the stack didn't. AuthConfig gains `external`, a record of typed per-provider configs (enabled default-true, clientId, optional secret, redirectUri defaulted to the issuer-derived /auth/v1/callback, url, skipNonceCheck, emailOptional), translated to GOTRUE_EXTERNAL__* env in the auth member exactly like the classic CLI's start path — GoTrue itself validates provider ids — and `additionalRedirectUrls`, translated to GOTRUE_URI_ALLOW_LIST the same way. Booleans and the allow list emit explicitly, defaults included: native-mode spawns extend the parent environment, so an omitted variable would let the shell override the typed config (start.go shadows the same way with %t and an always-appended allow list). Typed config over a raw env valve deliberately: the stack's config surfaces are typed camelCase interfaces, and a caller-wins env record could silently override core wiring (DB URL, JWT secret) — the rejected shape. Contract tests cover both factory shapes, the issuer-derived redirect default, the secretless-provider case, the allow-list translation and its empty-but-present baseline, and the explicit boolean defaults. Co-Authored-By: Claude Fable 5 --- packages/stack/src/Stack.unit.test.ts | 2 + packages/stack/src/StackBuilder.ts | 37 +++++++ packages/stack/src/StackBuilder.unit.test.ts | 2 + packages/stack/src/createStack.ts | 2 + packages/stack/src/services/auth.ts | 36 ++++++ packages/stack/src/services/auth.unit.test.ts | 104 ++++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 packages/stack/src/services/auth.unit.test.ts diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index ea98797efe..c2ee26ad08 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -73,6 +73,8 @@ const defaultConfig: ResolvedStackConfig = { jwtExpiry: 3600, externalUrl: "http://127.0.0.1:54321", version: DEFAULT_VERSIONS.auth, + external: {}, + additionalRedirectUrls: [], }, edgeRuntime: false, realtime: false, diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 9975ca4fb0..761dcd7f16 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -60,12 +60,43 @@ export interface PostgrestConfig { readonly version?: string; } +/** One external OAuth provider for the auth (GoTrue) member, keyed in + * `AuthConfig.external` by the provider id GoTrue knows (google, github, + * azure, …) — the same set the classic CLI's `[auth.external.*]` config + * drives, translated to `GOTRUE_EXTERNAL__*` env the same way. + * Provider ids are validated by GoTrue itself. */ +export interface AuthExternalProviderConfig { + /** Default true: a declared provider is one you mean to use; keep + * credentials wired but inactive with an explicit false. */ + readonly enabled?: boolean; + readonly clientId: string; + /** Optional for providers GoTrue accepts without one (e.g. apple, + * google one-tap); emitted empty when absent, like the classic CLI. */ + readonly secret?: string; + /** Defaults to `/auth/v1/callback`, the classic CLI's + * issuer-derived default. */ + readonly redirectUri?: string; + /** Base URL for self-hosted providers (gitlab, azure, keycloak). */ + readonly url?: string; + /** Default false — emitted explicitly so a shell variable can never + * override the typed config (native spawns extend the parent env). */ + readonly skipNonceCheck?: boolean; + /** Default false — emitted explicitly, like skipNonceCheck. */ + readonly emailOptional?: boolean; +} + export interface AuthConfig { readonly port?: number; readonly siteUrl?: string; readonly jwtExpiry?: number; readonly externalUrl?: string; readonly version?: string; + readonly external?: Readonly>; + /** Extra redirect targets GoTrue accepts beyond siteUrl — the classic + * CLI's [auth] additional_redirect_urls, translated to + * GOTRUE_URI_ALLOW_LIST the same way (always emitted, empty when none, + * so a shell variable can never supply the allow list). */ + readonly additionalRedirectUrls?: ReadonlyArray; } export interface RealtimeConfig { @@ -190,6 +221,8 @@ export interface ResolvedAuthConfig { readonly jwtExpiry: number; readonly externalUrl: string; readonly version: string; + readonly external: Readonly>; + readonly additionalRedirectUrls: ReadonlyArray; } export interface ResolvedRealtimeConfig { @@ -640,6 +673,8 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + external: config.auth.external, + additionalRedirectUrls: config.auth.additionalRedirectUrls, dependencies: postgresDeps, }) : makeAuthServiceDocker({ @@ -655,6 +690,8 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + external: config.auth.external, + additionalRedirectUrls: config.auth.additionalRedirectUrls, networkArgs: dockerNetworkArgs(platform.os, [config.auth.port]), apiPort: config.apiPort, dependencies: postgresDeps, diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 261ccd277c..96fcde8685 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -72,6 +72,8 @@ const baseConfig: ResolvedStackConfig = { jwtExpiry: 3600, externalUrl: "http://localhost:9999", version: DEFAULT_VERSIONS.auth, + external: {}, + additionalRedirectUrls: [], }, edgeRuntime: false, realtime: false, diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 0684ac867b..8959efabff 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -299,6 +299,8 @@ function resolveAuthConfig( jwtExpiry: cfg.jwtExpiry ?? 3600, externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, version: cfg.version ?? DEFAULT_VERSIONS.auth, + external: cfg.external ?? {}, + additionalRedirectUrls: cfg.additionalRedirectUrls ?? [], }; } diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index b3e0360c83..140776e8e3 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import type { AuthExternalProviderConfig } from "../StackBuilder.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; interface AuthServiceOptions { @@ -12,6 +13,13 @@ interface AuthServiceOptions { readonly smtpPort?: number; readonly smtpAdminEmail?: string; readonly smtpSenderName?: string; + /** External OAuth providers by GoTrue provider id, translated to + * `GOTRUE_EXTERNAL__*` env the way the classic CLI translates + * `[auth.external.*]`. */ + readonly external?: Readonly>; + /** Extra allowed redirect targets, translated to GOTRUE_URI_ALLOW_LIST + * like the classic CLI's [auth] additional_redirect_urls. */ + readonly additionalRedirectUrls?: ReadonlyArray; readonly dependencies: ReadonlyArray<{ readonly service: string; readonly condition: "healthy" | "completed"; @@ -29,6 +37,29 @@ interface DockerAuthOptions extends AuthServiceOptions { readonly apiPort: number; } +/** Mirrors the classic CLI's [auth.external.*] → GOTRUE_EXTERNAL_* env + * translation. Every field except url emits explicitly, defaults + * included: native-mode spawns extend the parent environment, so an + * unset variable would inherit whatever the shell carries — the classic + * CLI shadows the same way (start.go emits the booleans with %t). url + * stays conditional, matching start.go. */ +const externalProviderEnv = (opts: AuthServiceOptions): Record => { + const env: Record = {}; + for (const [id, provider] of Object.entries(opts.external ?? {})) { + const prefix = `GOTRUE_EXTERNAL_${id.toUpperCase()}`; + env[`${prefix}_ENABLED`] = String(provider.enabled ?? true); + env[`${prefix}_CLIENT_ID`] = provider.clientId; + env[`${prefix}_SECRET`] = provider.secret ?? ""; + env[`${prefix}_REDIRECT_URI`] = provider.redirectUri ?? `${opts.externalUrl}/auth/v1/callback`; + env[`${prefix}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck ?? false); + env[`${prefix}_EMAIL_OPTIONAL`] = String(provider.emailOptional ?? false); + if (provider.url !== undefined) { + env[`${prefix}_URL`] = provider.url; + } + } + return env; +}; + const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record => ({ GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${dbHost}:${opts.dbPort}/postgres`, GOTRUE_DB_DRIVER: "postgres", @@ -56,6 +87,11 @@ const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record ({ diff --git a/packages/stack/src/services/auth.unit.test.ts b/packages/stack/src/services/auth.unit.test.ts new file mode 100644 index 0000000000..651a856632 --- /dev/null +++ b/packages/stack/src/services/auth.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { makeAuthServiceDocker, makeAuthServiceNative } from "./auth.ts"; + +const baseOptions = { + dbPort: 54322, + authPort: 54324, + siteUrl: "http://localhost:3000", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + externalUrl: "http://127.0.0.1:54321", + dependencies: [], +}; + +describe("auth external providers", () => { + // The contract: `external` is the typed surface for GoTrue OAuth + // providers, translated to GOTRUE_EXTERNAL__* env the way the + // classic CLI translates [auth.external.*]. + test("native: a declared provider enables with the issuer-derived redirect", () => { + const def = makeAuthServiceNative({ + ...baseOptions, + binPath: "/opt/supabase", + external: { google: { clientId: "client-id", secret: "client-secret" } }, + }); + + expect(def.env).toMatchObject({ + GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true", + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "client-id", + GOTRUE_EXTERNAL_GOOGLE_SECRET: "client-secret", + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "http://127.0.0.1:54321/auth/v1/callback", + // Defaults survive beside the provider translation. + GOTRUE_SITE_URL: "http://localhost:3000", + }); + expect(def.env).not.toHaveProperty("GOTRUE_EXTERNAL_GOOGLE_URL"); + // Boolean defaults emit explicitly: native spawns extend the parent + // env, so an omitted var would inherit the shell's value. + expect(def.env).toMatchObject({ + GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: "false", + GOTRUE_EXTERNAL_GOOGLE_EMAIL_OPTIONAL: "false", + }); + }); + + test("docker: explicit fields reach the container args; enabled=false stays declared", () => { + const def = makeAuthServiceDocker({ + ...baseOptions, + image: "supabase/gotrue:test", + dbHost: "127.0.0.1", + networkArgs: [], + apiPort: 54321, + external: { + github: { + enabled: false, + clientId: "gh-id", + secret: "gh-secret", + redirectUri: "http://example.test/cb", + url: "https://ghe.example.test", + skipNonceCheck: true, + }, + }, + }); + + const args = def.args ?? []; + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_ENABLED=false"); + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=gh-id"); + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_SECRET=gh-secret"); + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=http://example.test/cb"); + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_URL=https://ghe.example.test"); + expect(args).toContain("GOTRUE_EXTERNAL_GITHUB_SKIP_NONCE_CHECK=true"); + }); + + test("a secretless provider emits an empty secret, like the classic CLI", () => { + const def = makeAuthServiceNative({ + ...baseOptions, + binPath: "/opt/supabase", + external: { apple: { clientId: "apple-id" } }, + }); + + expect(def.env).toMatchObject({ + GOTRUE_EXTERNAL_APPLE_ENABLED: "true", + GOTRUE_EXTERNAL_APPLE_SECRET: "", + }); + }); + + test("no external config adds no provider env", () => { + const def = makeAuthServiceNative({ ...baseOptions, binPath: "/opt/supabase" }); + const providerKeys = Object.keys(def.env ?? {}).filter( + (key) => key.startsWith("GOTRUE_EXTERNAL_") && key !== "GOTRUE_EXTERNAL_EMAIL_ENABLED", + ); + expect(providerKeys).toEqual([]); + // Empty but present: [] must mean "no extra redirects", not "whatever + // the parent shell says". + expect(def.env).toMatchObject({ GOTRUE_URI_ALLOW_LIST: "" }); + }); + + test("additional redirect urls translate to the comma-joined allow list", () => { + const def = makeAuthServiceNative({ + ...baseOptions, + binPath: "/opt/supabase", + additionalRedirectUrls: ["http://localhost:3000", "https://app.example.test"], + }); + expect(def.env).toMatchObject({ + GOTRUE_URI_ALLOW_LIST: "http://localhost:3000,https://app.example.test", + }); + }); +}); From 5ee3902c97cdb9600da88a9c4eee1c11e43d9178 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Wed, 8 Jul 2026 04:04:11 +0200 Subject: [PATCH 2/3] fix(stack): treat empty external redirect_uri as unset, always emit URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic surface can't say "absent": TOML strings decode to "" — the generated config template ships redirect_uri = "" and url = "", and the config package's provider schema defaults both keys to "" — so start.go substitutes the issuer-derived callback when RedirectUri is empty. The stack translation coalesced only null/undefined, letting an empty redirectUri emit GOTRUE_EXTERNAL__REDIRECT_URI= and break that provider's OAuth flow (GoTrue's ValidateOAuth refuses with "missing redirect URI"). Empty now falls back to the derived callback, like start.go. url goes the other way: it now always emits, empty included, instead of matching start.go's skip-when-empty. GoTrue reads an empty URL as unset (chooseHost falls back to each provider's default), so the emitted empty is behaviorally identical to the skip — and native-mode spawns extend the parent environment, so omission would let a shell GOTRUE_EXTERNAL__URL supply the provider URL. start.go can afford the skip: its containers never inherit a shell. Addresses the Codex review findings on the PR thread. Co-Authored-By: Claude Fable 5 --- packages/stack/src/StackBuilder.ts | 8 ++++-- packages/stack/src/services/auth.ts | 23 +++++++++------- packages/stack/src/services/auth.unit.test.ts | 27 ++++++++++++++++--- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 761dcd7f16..fc15293aeb 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -74,9 +74,13 @@ export interface AuthExternalProviderConfig { * google one-tap); emitted empty when absent, like the classic CLI. */ readonly secret?: string; /** Defaults to `/auth/v1/callback`, the classic CLI's - * issuer-derived default. */ + * issuer-derived default. Empty means unset, like the classic surface + * (TOML strings can't be absent). */ readonly redirectUri?: string; - /** Base URL for self-hosted providers (gitlab, azure, keycloak). */ + /** Base URL for self-hosted providers (gitlab, azure, keycloak). + * Empty means unset, like the classic surface — GoTrue falls back to + * the provider's default; always emitted (empty included) so a shell + * variable can never supply it. */ readonly url?: string; /** Default false — emitted explicitly so a shell variable can never * override the typed config (native spawns extend the parent env). */ diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 140776e8e3..ec6f8073cb 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -38,11 +38,18 @@ interface DockerAuthOptions extends AuthServiceOptions { } /** Mirrors the classic CLI's [auth.external.*] → GOTRUE_EXTERNAL_* env - * translation. Every field except url emits explicitly, defaults - * included: native-mode spawns extend the parent environment, so an - * unset variable would inherit whatever the shell carries — the classic - * CLI shadows the same way (start.go emits the booleans with %t). url - * stays conditional, matching start.go. */ + * translation. Every field emits explicitly, defaults included: + * native-mode spawns extend the parent environment, so an unset + * variable would inherit whatever the shell carries — the classic CLI + * shadows the same way (start.go emits the booleans with %t). The + * empty string is the classic surface's unset (TOML strings can't be + * absent; the generated template ships redirect_uri = "" and + * url = ""), so an empty redirectUri falls back to the derived + * callback like start.go. url emits even when empty — GoTrue reads an + * empty URL as unset (chooseHost falls back to each provider's + * default), so this matches start.go's skip-when-empty behaviorally + * while still shadowing the parent env; start.go can afford to skip + * because its containers never inherit a shell. */ const externalProviderEnv = (opts: AuthServiceOptions): Record => { const env: Record = {}; for (const [id, provider] of Object.entries(opts.external ?? {})) { @@ -50,12 +57,10 @@ const externalProviderEnv = (opts: AuthServiceOptions): Record = env[`${prefix}_ENABLED`] = String(provider.enabled ?? true); env[`${prefix}_CLIENT_ID`] = provider.clientId; env[`${prefix}_SECRET`] = provider.secret ?? ""; - env[`${prefix}_REDIRECT_URI`] = provider.redirectUri ?? `${opts.externalUrl}/auth/v1/callback`; + env[`${prefix}_REDIRECT_URI`] = provider.redirectUri || `${opts.externalUrl}/auth/v1/callback`; env[`${prefix}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck ?? false); env[`${prefix}_EMAIL_OPTIONAL`] = String(provider.emailOptional ?? false); - if (provider.url !== undefined) { - env[`${prefix}_URL`] = provider.url; - } + env[`${prefix}_URL`] = provider.url ?? ""; } return env; }; diff --git a/packages/stack/src/services/auth.unit.test.ts b/packages/stack/src/services/auth.unit.test.ts index 651a856632..42e41e616b 100644 --- a/packages/stack/src/services/auth.unit.test.ts +++ b/packages/stack/src/services/auth.unit.test.ts @@ -30,10 +30,11 @@ describe("auth external providers", () => { // Defaults survive beside the provider translation. GOTRUE_SITE_URL: "http://localhost:3000", }); - expect(def.env).not.toHaveProperty("GOTRUE_EXTERNAL_GOOGLE_URL"); - // Boolean defaults emit explicitly: native spawns extend the parent - // env, so an omitted var would inherit the shell's value. + // Defaults emit explicitly, url's empty string included: native + // spawns extend the parent env, so an omitted var would inherit the + // shell's value (GoTrue reads an empty URL as its provider default). expect(def.env).toMatchObject({ + GOTRUE_EXTERNAL_GOOGLE_URL: "", GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: "false", GOTRUE_EXTERNAL_GOOGLE_EMAIL_OPTIONAL: "false", }); @@ -80,6 +81,26 @@ describe("auth external providers", () => { }); }); + test("empty redirectUri and url mean unset, like the classic surface", () => { + // The generated config.toml template ships redirect_uri = "" and + // url = "" — TOML strings can't be absent — and start.go substitutes + // the derived callback for an empty redirect_uri. The URL var still + // emits (empty = GoTrue's provider default) so a shell variable can + // never supply it under native-mode env extension. + const def = makeAuthServiceNative({ + ...baseOptions, + binPath: "/opt/supabase", + external: { + gitlab: { clientId: "gl-id", secret: "gl-secret", redirectUri: "", url: "" }, + }, + }); + + expect(def.env).toMatchObject({ + GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI: "http://127.0.0.1:54321/auth/v1/callback", + GOTRUE_EXTERNAL_GITLAB_URL: "", + }); + }); + test("no external config adds no provider env", () => { const def = makeAuthServiceNative({ ...baseOptions, binPath: "/opt/supabase" }); const providerKeys = Object.keys(def.env ?? {}).filter( From 8aa9ad2c1b0a82d42c5cb572b26cd34d7e19abc1 Mon Sep 17 00:00:00 2001 From: Martijn Walraven Date: Wed, 8 Jul 2026 09:02:14 +0200 Subject: [PATCH 3/3] fix(stack): pin auth externalUrl to the gateway path, derive callback from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR review surfaced a double-append: with externalUrl set to the auth URL itself, the provider redirect default emitted /auth/v1/auth/v1/callback. The root cause was a two-faced field, not the derivation: externalUrl is named and documented as the classic CLI's auth.external_url — the WITH-path URL (GetExternalURL derives /auth/v1) — but defaulted to the bare API root, and it feeds API_EXTERNAL_URL, which GoTrue treats as the external host it builds outgoing URLs against (mail links via middleware.go's external-host fallback, custom-provider callbacks in loadCustomProvider). The bare root there was a latent defect: the stack's proxy only routes /auth/v1/*, so GoTrue-built absolute URLs pointed at paths the proxy never serves. externalUrl now means what its name says: the auth service's public URL through the gateway, path included, defaulting to /auth/v1. The provider callback derives as ${externalUrl}/callback — start.go's JwtIssuer + "/callback" exactly — so the emitted REDIRECT_URI at defaults is unchanged, and API_EXTERNAL_URL gains the classic-parity value. Also sharpens the provider secret doc: the id-token flow (google one-tap, apple sign-in) needs no secret — GoTrue's id_token grant never calls ValidateOAuth — while /authorize refuses without one at request time, which is why the config schema exempts exactly apple and google from its enabled-requires-secret check. Documents the new AuthConfig fields in the README alongside the re-pinned externalUrl row. Co-Authored-By: Claude Fable 5 --- packages/stack/README.md | 16 +++++++++------- packages/stack/src/Stack.unit.test.ts | 2 +- packages/stack/src/StackBuilder.ts | 13 ++++++++++--- packages/stack/src/StackBuilder.unit.test.ts | 2 +- packages/stack/src/createStack.ts | 2 +- packages/stack/src/services/auth.ts | 7 ++++++- packages/stack/src/services/auth.unit.test.ts | 2 +- .../stack/src/services/services.unit.test.ts | 4 ++-- 8 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/stack/README.md b/packages/stack/README.md index 2a2bc42ac6..6981d7c78b 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -108,13 +108,15 @@ Optional. Omit to include with defaults, set to `false` to exclude. Optional. Omit to include with defaults, set to `false` to exclude. -| Field | Type | Default | Description | -| ------------- | -------- | -------------------------- | ---------------------------------- | -| `port` | `number` | auto | Auth service port | -| `siteUrl` | `string` | `http://localhost:3000` | Auth redirect URL (your app's URL) | -| `jwtExpiry` | `number` | `3600` | JWT expiry in seconds | -| `externalUrl` | `string` | `http://127.0.0.1:${port}` | Auth external URL | -| `version` | `string` | `2.188.0-rc.15` | Auth version | +| Field | Type | Default | Description | +| ------------------------ | ---------- | ---------------------------------- | -------------------------------------------------------- | +| `port` | `number` | auto | Auth service port | +| `siteUrl` | `string` | `http://localhost:3000` | Auth redirect URL (your app's URL) | +| `jwtExpiry` | `number` | `3600` | JWT expiry in seconds | +| `externalUrl` | `string` | `http://127.0.0.1:${port}/auth/v1` | Auth external URL (through the API gateway, path included) | +| `version` | `string` | `2.188.0-rc.15` | Auth version | +| `external` | `object` | `{}` | External OAuth providers by GoTrue provider id | +| `additionalRedirectUrls` | `string[]` | `[]` | Extra allowed redirect URLs (`GOTRUE_URI_ALLOW_LIST`) | ### Full config example diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index c2ee26ad08..445c519b42 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -71,7 +71,7 @@ const defaultConfig: ResolvedStackConfig = { port: 9999, siteUrl: "http://localhost:3000", jwtExpiry: 3600, - externalUrl: "http://127.0.0.1:54321", + externalUrl: "http://127.0.0.1:54321/auth/v1", version: DEFAULT_VERSIONS.auth, external: {}, additionalRedirectUrls: [], diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index fc15293aeb..b606403ab4 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -70,10 +70,12 @@ export interface AuthExternalProviderConfig { * credentials wired but inactive with an explicit false. */ readonly enabled?: boolean; readonly clientId: string; - /** Optional for providers GoTrue accepts without one (e.g. apple, - * google one-tap); emitted empty when absent, like the classic CLI. */ + /** Optional: the id-token flow (google one-tap, apple sign-in) + * reads provider config without a secret; the /authorize OAuth flow + * still requires one and GoTrue refuses it at request time. Emitted + * empty when absent, like the classic CLI. */ readonly secret?: string; - /** Defaults to `/auth/v1/callback`, the classic CLI's + /** Defaults to `/callback`, the classic CLI's * issuer-derived default. Empty means unset, like the classic surface * (TOML strings can't be absent). */ readonly redirectUri?: string; @@ -93,6 +95,11 @@ export interface AuthConfig { readonly port?: number; readonly siteUrl?: string; readonly jwtExpiry?: number; + /** The auth service's public URL through the API gateway, path + * included — the classic CLI's auth.external_url. Defaults to + * `http://127.0.0.1:/auth/v1`, matching the proxy's + * /auth/v1 routing; GoTrue receives it as API_EXTERNAL_URL and + * builds outgoing URLs (mail links, OAuth callbacks) against it. */ readonly externalUrl?: string; readonly version?: string; readonly external?: Readonly>; diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 96fcde8685..a66a81f125 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -70,7 +70,7 @@ const baseConfig: ResolvedStackConfig = { port: 9999, siteUrl: "http://localhost:3000", jwtExpiry: 3600, - externalUrl: "http://localhost:9999", + externalUrl: "http://localhost:9999/auth/v1", version: DEFAULT_VERSIONS.auth, external: {}, additionalRedirectUrls: [], diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 8959efabff..591d5c507c 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -297,7 +297,7 @@ function resolveAuthConfig( port: ports.authPort, siteUrl: cfg.siteUrl ?? "http://localhost:3000", jwtExpiry: cfg.jwtExpiry ?? 3600, - externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, + externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}/auth/v1`, version: cfg.version ?? DEFAULT_VERSIONS.auth, external: cfg.external ?? {}, additionalRedirectUrls: cfg.additionalRedirectUrls ?? [], diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index ec6f8073cb..2b3b8facaa 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -8,6 +8,11 @@ interface AuthServiceOptions { readonly siteUrl: string; readonly jwtSecret: string; readonly jwtExpiry: number; + /** The auth service's public URL through the API gateway, path + * included (`/auth/v1`) — the classic CLI's + * auth.external_url. Emitted as API_EXTERNAL_URL (GoTrue builds + * outgoing URLs against it) and the base for the derived provider + * callback. */ readonly externalUrl: string; readonly smtpHost?: string; readonly smtpPort?: number; @@ -57,7 +62,7 @@ const externalProviderEnv = (opts: AuthServiceOptions): Record = env[`${prefix}_ENABLED`] = String(provider.enabled ?? true); env[`${prefix}_CLIENT_ID`] = provider.clientId; env[`${prefix}_SECRET`] = provider.secret ?? ""; - env[`${prefix}_REDIRECT_URI`] = provider.redirectUri || `${opts.externalUrl}/auth/v1/callback`; + env[`${prefix}_REDIRECT_URI`] = provider.redirectUri || `${opts.externalUrl}/callback`; env[`${prefix}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck ?? false); env[`${prefix}_EMAIL_OPTIONAL`] = String(provider.emailOptional ?? false); env[`${prefix}_URL`] = provider.url ?? ""; diff --git a/packages/stack/src/services/auth.unit.test.ts b/packages/stack/src/services/auth.unit.test.ts index 42e41e616b..0cee6a813e 100644 --- a/packages/stack/src/services/auth.unit.test.ts +++ b/packages/stack/src/services/auth.unit.test.ts @@ -7,7 +7,7 @@ const baseOptions = { siteUrl: "http://localhost:3000", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", jwtExpiry: 3600, - externalUrl: "http://127.0.0.1:54321", + externalUrl: "http://127.0.0.1:54321/auth/v1", dependencies: [], }; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 2db96bb9af..71aff9e6a2 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -267,7 +267,7 @@ describe("makeAuthServiceNative", () => { siteUrl: "http://localhost:3000", jwtSecret: JWT_SECRET, jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, + externalUrl: `http://127.0.0.1:${API_PORT}/auth/v1`, dependencies: [{ service: "postgres-init", condition: "completed" }], }); @@ -297,7 +297,7 @@ describe("makeAuthServiceDocker", () => { siteUrl: "http://localhost:3000", jwtSecret: JWT_SECRET, jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, + externalUrl: `http://127.0.0.1:${API_PORT}/auth/v1`, dbHost: "127.0.0.1", networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "9999:9999"], apiPort: API_PORT,