Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion packages/stack/src/Stack.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ 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: [],
},
edgeRuntime: false,
realtime: false,
Expand Down
48 changes: 48 additions & 0 deletions packages/stack/src/StackBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,54 @@ 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_<ID>_*` 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: 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 `<externalUrl>/callback`, the classic CLI's
* 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).
* 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). */
readonly skipNonceCheck?: boolean;
/** Default false — emitted explicitly, like skipNonceCheck. */
readonly emailOptional?: boolean;
}

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:<apiPort>/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<Record<string, AuthExternalProviderConfig>>;
/** 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<string>;
}

export interface RealtimeConfig {
Expand Down Expand Up @@ -190,6 +232,8 @@ export interface ResolvedAuthConfig {
readonly jwtExpiry: number;
readonly externalUrl: string;
readonly version: string;
readonly external: Readonly<Record<string, AuthExternalProviderConfig>>;
readonly additionalRedirectUrls: ReadonlyArray<string>;
}

export interface ResolvedRealtimeConfig {
Expand Down Expand Up @@ -640,6 +684,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({
Expand All @@ -655,6 +701,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,
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/src/StackBuilder.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ 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: [],
},
edgeRuntime: false,
realtime: false,
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/src/createStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,10 @@ 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 ?? [],
};
}

Expand Down
46 changes: 46 additions & 0 deletions packages/stack/src/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,11 +8,23 @@ interface AuthServiceOptions {
readonly siteUrl: string;
readonly jwtSecret: string;
readonly jwtExpiry: number;
/** The auth service's public URL through the API gateway, path
* included (`<api-root>/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;
readonly smtpAdminEmail?: string;
readonly smtpSenderName?: string;
/** External OAuth providers by GoTrue provider id, translated to
* `GOTRUE_EXTERNAL_<ID>_*` env the way the classic CLI translates
* `[auth.external.*]`. */
readonly external?: Readonly<Record<string, AuthExternalProviderConfig>>;
/** Extra allowed redirect targets, translated to GOTRUE_URI_ALLOW_LIST
* like the classic CLI's [auth] additional_redirect_urls. */
readonly additionalRedirectUrls?: ReadonlyArray<string>;
readonly dependencies: ReadonlyArray<{
readonly service: string;
readonly condition: "healthy" | "completed";
Expand All @@ -29,6 +42,34 @@ interface DockerAuthOptions extends AuthServiceOptions {
readonly apiPort: number;
}

/** Mirrors the classic CLI's [auth.external.*] → GOTRUE_EXTERNAL_* env
* 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<string, string> => {
const env: Record<string, string> = {};
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 ?? "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require secrets before enabling OAuth providers

When callers rely on the new optional secret field, enabled still defaults to true, so auth: { external: { google: { clientId } } } starts GoTrue with GOTRUE_EXTERNAL_GOOGLE_ENABLED=true and an empty secret. The current auth service used by the stack rejects enabled OAuth providers with an empty secret in ValidateOAuth, so the advertised secretless provider path fails at /authorize; require a secret for enabled providers or avoid enabling them when it is omitted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one matches GoTrue's intended model, so the surface stays as is (with a sharper doc comment in 8aa9ad2): the secretless path isn't /authorize — it's the id-token grant (google one-tap, sign-in with apple), which reads the provider config directly in token_oidc.go and never calls ValidateOAuth, so no secret is needed there. That's also why the config package's own schema exempts exactly apple and google from its enabled-requires-secret check, and the classic CLI emits an empty secret the same way. For flows that do need a secret, GoTrue refuses loudly at /authorize ("missing OAuth secret") — identical to classic behavior. Requiring a secret here would break id-token-only configurations upstream itself supports, and auto-disabling would silently turn off a provider the caller declared; provider-specific validation stays with the config layer (which has it) and GoTrue (which enforces it at request time).

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 ?? "";
}
return env;
};

const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record<string, string> => ({
GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${dbHost}:${opts.dbPort}/postgres`,
GOTRUE_DB_DRIVER: "postgres",
Expand Down Expand Up @@ -56,6 +97,11 @@ const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record<string,
? {}
: { GOTRUE_SMTP_SENDER_NAME: opts.smtpSenderName }),
}),
// Always emitted, empty when none: native spawns extend the parent env,
// so omission would let a shell GOTRUE_URI_ALLOW_LIST become the allow
// list (start.go always appends it, empty included).
GOTRUE_URI_ALLOW_LIST: (opts.additionalRedirectUrls ?? []).join(","),
...externalProviderEnv(opts),
});

const authHealthCheck = (port: number) => ({
Expand Down
125 changes: 125 additions & 0 deletions packages/stack/src/services/auth.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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/auth/v1",
dependencies: [],
};

describe("auth external providers", () => {
// The contract: `external` is the typed surface for GoTrue OAuth
// providers, translated to GOTRUE_EXTERNAL_<ID>_* 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",
});
// 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",
});
});

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("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(
(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",
});
});
});
4 changes: 2 additions & 2 deletions packages/stack/src/services/services.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
});

Expand Down Expand Up @@ -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,
Expand Down