From f586aebe5bc61af22e8aefa85dde0fac190ace6d Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Thu, 18 Jun 2026 14:17:28 +0530 Subject: [PATCH] feat: enable stack auth config from backend --- api/constants.py | 6 ++ api/routes/main.py | 12 +++ docs/deployment/authentication.mdx | 99 +++++++++++++++++++ docs/developer/environment-variables.mdx | 15 ++- docs/docs.json | 1 + ui/Dockerfile | 9 -- ui/src/app/api/config/auth/route.ts | 11 ++- ui/src/app/impersonate/route.ts | 11 ++- ui/src/lib/auth/config.ts | 60 +++++++++-- ui/src/lib/auth/providers/AuthProvider.tsx | 40 ++++++-- .../auth/providers/StackProviderWrapper.tsx | 16 ++- ui/src/lib/auth/server.ts | 15 ++- ui/src/lib/utils.ts | 19 ---- 13 files changed, 261 insertions(+), 53 deletions(-) create mode 100644 docs/deployment/authentication.mdx diff --git a/api/constants.py b/api/constants.py index e5d9d7f5..b7bc9f74 100644 --- a/api/constants.py +++ b/api/constants.py @@ -30,6 +30,12 @@ CORS_ALLOWED_ORIGINS = [ o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip() ] AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local") +# Stack Auth public client config. These are safe to expose to the browser (the +# publishable client key is public by design, and the project id is non-sensitive), +# and are served to the UI at runtime via /api/v1/health so the frontend no longer +# needs them baked into the bundle at build time. +STACK_AUTH_PROJECT_ID = os.getenv("STACK_AUTH_PROJECT_ID") +STACK_PUBLISHABLE_CLIENT_KEY = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY") DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None) MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com") diff --git a/api/routes/main.py b/api/routes/main.py index 6de59b1d..067f2ab9 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -72,6 +72,11 @@ class HealthResponse(BaseModel): auth_provider: str turn_enabled: bool force_turn_relay: bool + # Public Stack Auth client config — only populated when auth_provider == "stack". + # The UI reads these at runtime to initialize Stack, so they no longer need to + # be baked into the browser bundle at build time. Both are public values. + stack_project_id: str | None = None + stack_publishable_client_key: str | None = None @router.get("/health", response_model=HealthResponse) @@ -81,12 +86,15 @@ async def health() -> HealthResponse: AUTH_PROVIDER, DEPLOYMENT_MODE, FORCE_TURN_RELAY, + STACK_AUTH_PROJECT_ID, + STACK_PUBLISHABLE_CLIENT_KEY, TURN_SECRET, ) from api.utils.common import get_backend_endpoints logger.debug("Health endpoint called") backend_endpoint, _ = await get_backend_endpoints() + is_stack = AUTH_PROVIDER == "stack" return HealthResponse( status="ok", version=APP_VERSION, @@ -95,4 +103,8 @@ async def health() -> HealthResponse: auth_provider=AUTH_PROVIDER, turn_enabled=bool(TURN_SECRET), force_turn_relay=FORCE_TURN_RELAY, + stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None, + stack_publishable_client_key=( + STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None + ), ) diff --git a/docs/deployment/authentication.mdx b/docs/deployment/authentication.mdx new file mode 100644 index 00000000..47d05ed0 --- /dev/null +++ b/docs/deployment/authentication.mdx @@ -0,0 +1,99 @@ +--- +title: "Authentication" +description: "Configure how self-hosted Dograh authenticates users — the default local provider, or Stack Auth for social login" +--- + +Self-hosted Dograh ships with a built-in **local** authentication provider (email + password, backed by a signed JWT). This is the default and needs no external service. + +To offer social logins (Google, GitHub, and others), you can delegate sign-in to **[Stack Auth](https://stack-auth.com)**. Enabling it is a **runtime** configuration change — set a few environment variables and restart. The prebuilt `dograhai/dograh-api` and `dograhai/dograh-ui` images work as-is; you do **not** need to rebuild or build from source. + + +The active provider is controlled by the backend `AUTH_PROVIDER` variable (`local` by default). The frontend discovers the provider — and, for Stack, its public client config — at runtime from the backend's `/api/v1/health` response, so the browser bundle never needs Stack values baked in at build time. + + +## How it works + +1. The backend reads `AUTH_PROVIDER` and the Stack settings from its environment. +2. When `AUTH_PROVIDER=stack`, `/api/v1/health` returns the **public** Stack client config (project id + publishable client key). +3. The UI fetches that at runtime and initializes the Stack SDK in the browser. +4. The **secret server key** is used only server-side (by the backend and the UI's server runtime) and is never sent to the browser. + +## Prerequisites + +A Stack Auth project. Create one in the [Stack Auth dashboard](https://app.stack-auth.com) and configure the social login providers you want to offer. + +## Step 1 — Collect your Stack credentials + +From your project in the [Stack Auth dashboard](https://app.stack-auth.com), gather: + +| Value | Sensitivity | +|---|---| +| **Project ID** | Public | +| **Publishable client key** | Public (safe to expose in the browser) | +| **Secret server key** | Secret — keep server-side only | +| **API base URL** | Public. For Stack's hosted service this is `https://api.stack-auth.com` | + +## Step 2 — Configure the backend (`api`) + +Set these on the `api` service. Add them to the `environment:` block of the `api` service in your `docker-compose.yaml`: + +```yaml docker-compose.yaml +services: + api: + environment: + AUTH_PROVIDER: "stack" + STACK_AUTH_PROJECT_ID: "" + STACK_PUBLISHABLE_CLIENT_KEY: "" + STACK_SECRET_SERVER_KEY: "" + STACK_AUTH_API_URL: "https://api.stack-auth.com" +``` + +## Step 3 — Configure the UI (`ui`) + +The UI runs server-side code (SSR pages and the `/handler/*` auth routes) that calls Stack with the secret server key, so the `ui` service needs that one value too: + +```yaml docker-compose.yaml +services: + ui: + environment: + STACK_SECRET_SERVER_KEY: "" +``` + + +The `ui` service does **not** need the project id or publishable client key — it receives those from the backend at runtime via `/api/v1/health`. Only the secret server key (used server-side) is set here. + + +## Step 4 — Restart and verify + +Recreate the containers so they pick up the new environment: + +```bash +docker compose up -d +``` + +Confirm the backend reports the active provider and the public client config: + +```bash +curl -s http://localhost:8000/api/v1/health +# expect: "auth_provider":"stack", plus "stack_project_id" and "stack_publishable_client_key" +``` + +Then open the UI. The sign-in page should now present your configured Stack Auth social login options instead of the local email/password form. + +## Environment variable reference + +| Variable | Service | Secret | Notes | +|---|---|---|---| +| `AUTH_PROVIDER` | `api` | — | Set to `stack` (default `local`) | +| `STACK_AUTH_PROJECT_ID` | `api` | No | Stack project ID; served to the UI at runtime | +| `STACK_PUBLISHABLE_CLIENT_KEY` | `api` | No | Publishable key; served to the UI at runtime | +| `STACK_SECRET_SERVER_KEY` | `api` + `ui` | **Yes** | Server-side only — never exposed to the browser | +| `STACK_AUTH_API_URL` | `api` | No | Stack REST API base URL | + + +`STACK_SECRET_SERVER_KEY` is the only secret here. Keep it out of any client-visible config and never bake it into an image. The project ID and publishable client key are public by design — the backend deliberately serves them to the browser so Stack can initialize at runtime. + + +## Reverting to local auth + +Remove the variables above (or set `AUTH_PROVIDER=local`) and restart. The UI detects `local` from the backend at runtime and falls back to the built-in email/password flow — no rebuild required. diff --git a/docs/developer/environment-variables.mdx b/docs/developer/environment-variables.mdx index d7f480bf..559bc30b 100644 --- a/docs/developer/environment-variables.mdx +++ b/docs/developer/environment-variables.mdx @@ -22,7 +22,7 @@ The relevant required variables for each mode are noted in the descriptions belo |---|---|---| | `ENVIRONMENT` | `local` | Runtime environment. Affects logging and behaviour. One of `local`, `production`, `test` | | `DEPLOYMENT_MODE` | `oss` | Deployment mode. Use `oss` for self-hosted | -| `AUTH_PROVIDER` | `local` | Authentication provider. Use `local` for OSS | +| `AUTH_PROVIDER` | `local` | Authentication provider. `local` (default) uses the built-in email/password flow. Set to `stack` to delegate to Stack Auth for social login — see [Authentication](/deployment/authentication) for the full setup | --- @@ -48,6 +48,19 @@ Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate --- +## Authentication (Stack Auth) + +Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https://stack-auth.com) for social login. The project id and publishable client key are public and are served to the browser at runtime via `/api/v1/health`; the secret server key stays server-side. See [Authentication](/deployment/authentication) for the full walkthrough. + +| Variable | Default | Description | +|---|---|---| +| `STACK_AUTH_PROJECT_ID` | `null` | **Required for `stack`.** Stack project ID (public) | +| `STACK_PUBLISHABLE_CLIENT_KEY` | `null` | **Required for `stack`.** Stack publishable client key (public) | +| `STACK_SECRET_SERVER_KEY` | `null` | **Required for `stack`.** Stack secret server key — server-side only, also set on the `ui` service. Keep secret | +| `STACK_AUTH_API_URL` | `null` | **Required for `stack`.** Stack REST API base URL (e.g. `https://api.stack-auth.com`) | + +--- + ## URLs | Variable | Default | Description | diff --git a/docs/docs.json b/docs/docs.json index 2bb9924e..21f2225f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -157,6 +157,7 @@ "pages": [ "deployment/introduction", "deployment/docker", + "deployment/authentication", "deployment/custom-domain", "deployment/scaling", "deployment/update", diff --git a/ui/Dockerfile b/ui/Dockerfile index e6ab02da..80dac3fb 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -42,15 +42,6 @@ ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com" ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X" ENV BACKEND_URL="http://api:8000" -# Stack Auth (optional, for self-hosted social login). NEXT_PUBLIC_* values must -# be set at build time so Next.js inlines them into the browser bundle; setting -# them as runtime container env has no effect. Unset by default (empty strings), -# which leaves the official image on the built-in local auth flow. -ARG NEXT_PUBLIC_STACK_PROJECT_ID -ARG NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY -ENV NEXT_PUBLIC_STACK_PROJECT_ID=$NEXT_PUBLIC_STACK_PROJECT_ID -ENV NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=$NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY - # Build the application with standalone mode # Increase Node.js heap size to prevent out-of-memory errors during build ENV NODE_OPTIONS="--max-old-space-size=4096" diff --git a/ui/src/app/api/config/auth/route.ts b/ui/src/app/api/config/auth/route.ts index a3b33477..cf6553f3 100644 --- a/ui/src/app/api/config/auth/route.ts +++ b/ui/src/app/api/config/auth/route.ts @@ -1,10 +1,17 @@ import { NextResponse } from 'next/server'; -import { getAuthProvider } from '@/lib/auth/config'; +import { getAuthProvider, getStackConfig } from '@/lib/auth/config'; import logger from '@/lib/logger'; export async function GET() { const provider = await getAuthProvider(); + // When using Stack, hand the public client config to the browser so it can + // initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed). + const stackConfig = provider === 'stack' ? await getStackConfig() : null; logger.debug(`Got provider ${provider} from getAuthProvider`) - return NextResponse.json({ provider }); + return NextResponse.json({ + provider, + stackProjectId: stackConfig?.projectId ?? null, + stackPublishableClientKey: stackConfig?.publishableClientKey ?? null, + }); } diff --git a/ui/src/app/impersonate/route.ts b/ui/src/app/impersonate/route.ts index c1119d29..421d995b 100644 --- a/ui/src/app/impersonate/route.ts +++ b/ui/src/app/impersonate/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { getStackConfig } from "@/lib/auth/config"; + /** * Helper route that receives a refresh token via query parameters, stores it as * the regular Stack cookie *for the current sub-domain only* and finally @@ -18,6 +20,13 @@ export async function GET(request: NextRequest) { return new Response("Missing refresh_token", { status: 400 }); } + // The Stack session cookie is named `stack-refresh-`. The project + // id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed. + const stackConfig = await getStackConfig(); + if (!stackConfig) { + return new Response("Stack auth is not configured", { status: 400 }); + } + // Prepare redirect – if the supplied redirect path is an absolute URL we use // it as-is, otherwise we resolve it relative to the current request. const redirectUrl = redirectPath.startsWith("http") @@ -32,7 +41,7 @@ export async function GET(request: NextRequest) { // Store the refresh token cookie without an explicit domain so that it is // scoped to the current (sub-)domain. This avoids collisions between the // admin (superadmin.*) and the regular app (app.*) domains. - response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, { + response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, { path: "/", maxAge, secure: true, diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts index 1958297d..70d03bfb 100644 --- a/ui/src/lib/auth/config.ts +++ b/ui/src/lib/auth/config.ts @@ -2,15 +2,29 @@ import "server-only"; import { getServerBackendUrl } from "@/lib/apiClient"; -let cachedAuthProvider: string | null = null; +export interface StackConfig { + projectId: string; + publishableClientKey: string; +} + +interface ResolvedAuthConfig { + authProvider: string; + stackConfig: StackConfig | null; +} + +let cachedConfig: ResolvedAuthConfig | null = null; /** - * Fetches the auth provider from the backend health endpoint and caches it. - * Falls back to 'local' on error. + * Fetches the auth configuration from the backend health endpoint and caches it. + * + * The backend reports the active auth provider and — when it is `stack` — the + * public Stack client config (project id + publishable client key). The UI uses + * these at runtime to initialize Stack Auth, so they no longer need to be baked + * into the browser bundle at build time. Falls back to local auth on error. */ -export async function getAuthProvider(): Promise { - if (cachedAuthProvider) { - return cachedAuthProvider; +async function resolveAuthConfig(): Promise { + if (cachedConfig) { + return cachedConfig; } try { @@ -20,13 +34,39 @@ export async function getAuthProvider(): Promise { }); if (res.ok) { const data = await res.json(); - cachedAuthProvider = (data.auth_provider as string) || "local"; - return cachedAuthProvider; + const authProvider = (data.auth_provider as string) || "local"; + const stackConfig = + authProvider === "stack" && + data.stack_project_id && + data.stack_publishable_client_key + ? { + projectId: data.stack_project_id as string, + publishableClientKey: + data.stack_publishable_client_key as string, + } + : null; + cachedConfig = { authProvider, stackConfig }; + return cachedConfig; } } catch { // Backend not reachable — fall back to local } - cachedAuthProvider = "local"; - return cachedAuthProvider; + cachedConfig = { authProvider: "local", stackConfig: null }; + return cachedConfig; +} + +/** + * Returns the active auth provider ('local' or 'stack'). Falls back to 'local'. + */ +export async function getAuthProvider(): Promise { + return (await resolveAuthConfig()).authProvider; +} + +/** + * Returns the public Stack client config when the active provider is `stack`, + * otherwise null. Server-only — the browser receives these via /api/config/auth. + */ +export async function getStackConfig(): Promise { + return (await resolveAuthConfig()).stackConfig; } diff --git a/ui/src/lib/auth/providers/AuthProvider.tsx b/ui/src/lib/auth/providers/AuthProvider.tsx index 2c83f709..765bb0d6 100644 --- a/ui/src/lib/auth/providers/AuthProvider.tsx +++ b/ui/src/lib/auth/providers/AuthProvider.tsx @@ -42,31 +42,57 @@ const LoadingFallback = ( ); +interface ResolvedAuthConfig { + provider: string; + // Public Stack client config, fetched from the backend at runtime. Null unless + // the provider is 'stack' and the backend supplied both values. + stack: { projectId: string; publishableClientKey: string } | null; +} + export function AuthProvider({ children }: { children: React.ReactNode }) { - const [authProvider, setAuthProvider] = useState(null); + const [config, setConfig] = useState(null); useEffect(() => { fetch('/api/config/auth') .then((res) => res.json()) .then((data) => { logger.debug(`Setting auth provider as ${data.provider}`) - setAuthProvider(data.provider || 'stack') - }) + setConfig({ + provider: data.provider || 'local', + stack: + data.stackProjectId && data.stackPublishableClientKey + ? { + projectId: data.stackProjectId, + publishableClientKey: data.stackPublishableClientKey, + } + : null, + }) + }) .catch((e) => { logger.error(`Got error ${e} while setting auth provider`) - setAuthProvider('local') + setConfig({ provider: 'local', stack: null }) }); }, []); - if (!authProvider) { + if (!config) { return LoadingFallback; } // For Stack provider, use the dedicated wrapper - if (authProvider === 'stack') { + if (config.provider === 'stack') { + if (!config.stack) { + logger.error( + 'Auth provider is "stack" but the backend returned no Stack client config. ' + + 'Ensure STACK_AUTH_PROJECT_ID and STACK_PUBLISHABLE_CLIENT_KEY are set on the API service.' + ); + return LoadingFallback; + } return ( - + {children} diff --git a/ui/src/lib/auth/providers/StackProviderWrapper.tsx b/ui/src/lib/auth/providers/StackProviderWrapper.tsx index 7c6e865e..128ecbc4 100644 --- a/ui/src/lib/auth/providers/StackProviderWrapper.tsx +++ b/ui/src/lib/auth/providers/StackProviderWrapper.tsx @@ -9,10 +9,18 @@ import { AuthContext } from './AuthProvider'; // Create a singleton StackClientApp instance to prevent multiple initializations let stackClientAppInstance: StackClientApp | null = null; -function getStackClientApp(): StackClientApp { +function getStackClientApp( + projectId: string, + publishableClientKey: string, +): StackClientApp { if (!stackClientAppInstance) { + // projectId / publishableClientKey are passed explicitly (fetched from the + // backend at runtime) instead of being read from inlined NEXT_PUBLIC_* env, + // so the prebuilt image works without build-time configuration. stackClientAppInstance = new StackClientApp({ tokenStore: "nextjs-cookie", + projectId, + publishableClientKey, urls: { afterSignIn: "/after-sign-in" } @@ -23,6 +31,8 @@ function getStackClientApp(): StackClientApp { interface StackProviderWrapperProps { children: React.ReactNode; + projectId: string; + publishableClientKey: string; } // Simple context provider that uses Stack's useUser directly @@ -114,8 +124,8 @@ const translationOverrides = { "Sign up with {provider}": "Sign up with {provider} Business", }; -export function StackProviderWrapper({ children }: StackProviderWrapperProps) { - const stackClientApp = getStackClientApp(); +export function StackProviderWrapper({ children, projectId, publishableClientKey }: StackProviderWrapperProps) { + const stackClientApp = getStackClientApp(projectId, publishableClientKey); return ( diff --git a/ui/src/lib/auth/server.ts b/ui/src/lib/auth/server.ts index c27f595f..2fd881ee 100644 --- a/ui/src/lib/auth/server.ts +++ b/ui/src/lib/auth/server.ts @@ -5,7 +5,7 @@ import { cookies } from 'next/headers'; import logger from '@/lib/logger'; -import { getAuthProvider } from './config'; +import { getAuthProvider, getStackConfig } from './config'; import type { LocalUser } from './types'; // Server-side auth utilities for SSR pages @@ -21,10 +21,23 @@ export async function getStackServerApp(): Promise