diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index bc66a4316..372bcc42a 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -447,16 +447,15 @@ When set to `"true"`, displays the Pass@k metrics table on the Insights page. By ## Portal Authentication (Microsoft Entra ID / MSAL) -Build-time (`VITE_*`) configuration for Portal sign-in via MSAL. These are -inlined into the bundle at build time (retargeting the IdP is a rebuild, not a -runtime change), matching the auth spec's "hardcoded per build" intent -(`docs/architecture/auth-rbac.md` §8, subtask 10). +Portal sign-in uses public MSAL settings from runtime `SCOPE_AUTH_*` variables +when supplied. Build-time `VITE_AUTH_*` settings remain as a fallback for local +development and direct builds; no API config fetch is involved. In **dev** builds (`import.meta.env.DEV`) every value defaults to the seeded [entra-local](https://github.com/cmaneu/entra-local) emulator (Docker tag `0.0.3`), so sign-in works out of the box once the one-time local setup below is done. In **production** builds the config is only considered valid when -`VITE_AUTH_CLIENT_ID` and `VITE_AUTH_AUTHORITY` are present; otherwise the Portal +client ID, authority, and API scopes are present; otherwise the Portal renders a "not configured" screen instead of silently pointing at `localhost`. **Docker builds:** the Portal Dockerfile accepts `VITE_AUTH_CLIENT_ID`, @@ -464,7 +463,8 @@ renders a "not configured" screen instead of silently pointing at `localhost`. `VITE_AUTH_PROTOCOL_MODE`, `VITE_AUTH_REDIRECT_URI`, `VITE_AUTH_POST_LOGOUT_REDIRECT_URI`, and `VITE_AUTH_CACHE_LOCATION` as build arguments in its `builder` stage. Vite embeds them during `pnpm --filter portal -build`; setting these variables only on the final nginx container has no effect. +build`; the runtime `SCOPE_AUTH_CLIENT_ID`, `SCOPE_AUTH_AUTHORITY`, and +`SCOPE_AUTH_SCOPES` override the corresponding embedded settings. These are public client settings, not secrets; never pass client secrets or access tokens as Portal build arguments. @@ -486,9 +486,13 @@ access tokens as Portal build arguments. --build-arg VITE_AUTH_SCOPES="api:///access_as_user" . ``` -Rebuild and recreate the Portal after changing IdP settings. A single image -promoted between environments retains the same IdP settings; only -`SCOPE_AUTH_ENABLED` remains a runtime auth switch. Empty optional redirect +Set `SCOPE_AUTH_CLIENT_ID`, `SCOPE_AUTH_AUTHORITY`, and `SCOPE_AUTH_SCOPES` on +the final nginx container to retarget a promoted image without rebuilding it. +The entrypoint writes these into `/config.js` as public client metadata; unset +values fall back to the build only when all three runtime settings are absent. +When any is supplied, provide the complete triplet; missing or empty members +trigger the not-configured screen. Scopes use the same comma-separated +format as `VITE_AUTH_SCOPES`. Empty optional redirect arguments retain the current Portal origin, while empty protocol/cache settings retain their documented defaults. For Entra cloud through local Compose, also set `VITE_AUTH_PROTOCOL_MODE=AAD` and `VITE_AUTH_KNOWN_AUTHORITIES=` to override @@ -634,6 +638,15 @@ sign in with a seeded user (`alice@entralocal.dev` / `bob@entralocal.dev`). > `VITE_AUTH_AUTHORITY`/`VITE_AUTH_KNOWN_AUTHORITIES` into the Portal dev > container so the browser always targets the correct per-worktree emulator. +### SCOPE_AUTH_CLIENT_ID, SCOPE_AUTH_AUTHORITY, SCOPE_AUTH_SCOPES + +**Scope:** Portal nginx container at runtime. These optional public OAuth +settings are written into `/config.js` at startup and override the matching +`VITE_AUTH_*` build values as one complete set. Provide the SPA client ID, +Entra authority URL, and comma-separated API scopes for an environment. An +explicitly empty value is treated as missing configuration, not as a request to +use the build fallback. + ### VITE_AUTH_CLIENT_ID **Default (dev):** `cccccccc-0000-0000-0000-000000000001` (entra-local seeded "Sample SPA" app) **Type:** GUID string @@ -1053,7 +1066,7 @@ not used by API verification and not served by an `/auth/config` endpoint. **Type:** string (GUID) Reserved client-configuration metadata; not used by API verification. The current -Portal uses build-time `VITE_AUTH_CLIENT_ID`, not this API setting or an +Portal uses runtime `SCOPE_AUTH_CLIENT_ID` or build-time `VITE_AUTH_CLIENT_ID`, not this API setting or an `/auth/config` endpoint. ### AUTH_SCOPES @@ -1062,7 +1075,8 @@ Portal uses build-time `VITE_AUTH_CLIENT_ID`, not this API setting or an Scopes intended for clients acquiring an API access token (e.g. `api:///access`). Not consumed by API verification or -served by a config endpoint; configure the current Portal through `VITE_AUTH_SCOPES`. +served by a config endpoint; configure the Portal through runtime +`SCOPE_AUTH_SCOPES` or build-time `VITE_AUTH_SCOPES`. ### AUTH_BOOTSTRAP_ADMINS **Default:** (empty) diff --git a/apps/portal/docker-entrypoint.sh b/apps/portal/docker-entrypoint.sh index 7ea04f3b3..ca5e9e387 100755 --- a/apps/portal/docker-entrypoint.sh +++ b/apps/portal/docker-entrypoint.sh @@ -8,7 +8,7 @@ set -eu DOCS_BASE_URL="${SCOPE_DOCS_BASE_URL:-https://urban-disco-1qzzq7z.pages.github.io}" -CONFIG_PATH="/usr/share/nginx/html/config.js" +CONFIG_PATH="${SCOPE_CONFIG_PATH:-/usr/share/nginx/html/config.js}" # Auth feature toggle for this environment (integration/production). Auth is ON # by default (secure by default); set SCOPE_AUTH_ENABLED=false on the overlay to @@ -19,12 +19,35 @@ case "$(printf '%s' "${SCOPE_AUTH_ENABLED:-true}" | tr '[:upper:]' '[:lower:]' | *) AUTH_ENABLED=true ;; esac -# Escape characters that are special inside a JS double-quoted string. -escaped_docs_base=$(printf '%s' "$DOCS_BASE_URL" | sed 's/\\/\\\\/g; s/"/\\"/g') +# Serialize UTF-8 strings as JS literals, including embedded quotes and newlines. +js_string() { + printf '"' + printf '%s' "$1" | od -An -v -tu1 | awk ' + { for (i = 1; i <= NF; i++) { + byte = $i + 0 + if (byte == 34 || byte == 92) printf "\\%c", byte + else if (byte < 32 || byte == 127) printf "\\x%02X", byte + else printf "%c", byte + } + }' + printf '"' +} + +DOCS_JS=$(js_string "$DOCS_BASE_URL") +AUTH_CONFIG="" +if [ "${SCOPE_AUTH_CLIENT_ID+x}" ]; then + AUTH_CONFIG="${AUTH_CONFIG}, authClientId: $(js_string "$SCOPE_AUTH_CLIENT_ID")" +fi +if [ "${SCOPE_AUTH_AUTHORITY+x}" ]; then + AUTH_CONFIG="${AUTH_CONFIG}, authAuthority: $(js_string "$SCOPE_AUTH_AUTHORITY")" +fi +if [ "${SCOPE_AUTH_SCOPES+x}" ]; then + AUTH_CONFIG="${AUTH_CONFIG}, authScopes: $(js_string "$SCOPE_AUTH_SCOPES")" +fi cat > "$CONFIG_PATH" <Scope application }, + parameters: { + docs: { description: { component: "Requires a configured OAuth client from runtime SCOPE_AUTH_* or build-time VITE_AUTH_* settings before sign-in." } }, + }, } satisfies Meta; export default meta; diff --git a/apps/portal/src/components/auth/RequireAuth.tsx b/apps/portal/src/components/auth/RequireAuth.tsx index 160413be2..0de1609a4 100644 --- a/apps/portal/src/components/auth/RequireAuth.tsx +++ b/apps/portal/src/components/auth/RequireAuth.tsx @@ -9,8 +9,8 @@ * "Log in" button; the interactive redirect only starts when the user clicks it. * * When the Portal auth config is missing (a production build without the - * `VITE_AUTH_*` env vars), we render a clear configuration error instead of - * bouncing into a broken redirect loop. + * runtime `SCOPE_AUTH_*` or build-time `VITE_AUTH_*` settings), we render a + * configuration error instead of bouncing into a broken redirect loop. */ import type { ReactNode } from "react"; import { Button } from "@/components/ui/button"; @@ -54,9 +54,9 @@ function NotConfigured() { Authentication not configured

- This build is missing its identity-provider configuration - (VITE_AUTH_*). Set the Entra ID values at build time and - redeploy. + The identity-provider configuration is incomplete. Set the public + Entra ID client, authority, and API scopes through SCOPE_AUTH_* + at container startup or VITE_AUTH_* at build time.

diff --git a/apps/portal/src/lib/auth/authConfig.test.ts b/apps/portal/src/lib/auth/authConfig.test.ts index 0145944d5..79ced393f 100644 --- a/apps/portal/src/lib/auth/authConfig.test.ts +++ b/apps/portal/src/lib/auth/authConfig.test.ts @@ -32,9 +32,9 @@ describe("resolveAuthEnabled", () => { vi.resetModules(); }); - async function config(overrides: Record = {}) { + async function config(overrides: Record = {}, runtimeConfig?: ScopeRuntimeConfig) { vi.resetModules(); - vi.stubGlobal("window", { location: { origin: "https://scope.example.com" } }); + vi.stubGlobal("window", { location: { origin: "https://scope.example.com" }, __SCOPE_CONFIG__: runtimeConfig }); vi.stubEnv("DEV", false); for (const [key, value] of Object.entries({ VITE_AUTH_CLIENT_ID: "portal-client", @@ -91,6 +91,44 @@ describe("resolveAuthEnabled", () => { expect((await config({ [key]: "" })).isConfigured).toBe(false); }, ); + + it("prefers runtime client settings and parses scopes like build settings", async () => { + expect(await config({}, { + authEnabled: true, + authClientId: "runtime-client", + authAuthority: "https://login.microsoftonline.com/runtime-tenant", + authScopes: "api://runtime/read, api://runtime/write", + })).toMatchObject({ + clientId: "runtime-client", + authority: "https://login.microsoftonline.com/runtime-tenant", + scopes: ["api://runtime/read", "api://runtime/write"], + isConfigured: true, + }); + }); + + it("keeps build settings when runtime values are absent", async () => { + expect(await config({}, { authEnabled: true })).toMatchObject({ + clientId: "portal-client", + authority: "https://login.microsoftonline.com/test-tenant", + scopes: ["api://api-client/access_as_user"], + isConfigured: true, + }); + }); + + it("treats explicit empty runtime settings as incomplete", async () => { + expect((await config({}, { authClientId: "", authScopes: "" })).isConfigured).toBe(false); + }); + + it("does not mix a partial runtime override with the baked-in client", async () => { + const resolved = await config({}, { authAuthority: "https://login.microsoftonline.com/other-tenant" }); + expect(resolved.clientId).toBe(""); + expect(resolved.scopes).toEqual([]); + expect(resolved.isConfigured).toBe(false); + }); + + it("keeps authentication disabled when runtime switch is off", async () => { + expect((await config({}, { authEnabled: false, authClientId: "runtime-client" })).enabled).toBe(false); + }); }); it("runtime authEnabled governs integration/production (built bundle)", () => { diff --git a/apps/portal/src/lib/auth/authConfig.ts b/apps/portal/src/lib/auth/authConfig.ts index 149d6e8c0..390bb28e7 100644 --- a/apps/portal/src/lib/auth/authConfig.ts +++ b/apps/portal/src/lib/auth/authConfig.ts @@ -5,10 +5,9 @@ * Portal authentication configuration (Microsoft Entra ID via MSAL). * * Per the auth spec ([docs/architecture/auth-rbac.md](../../../../../docs/architecture/auth-rbac.md) - * §8, subtask 10) the Portal's IdP config is **build-time** configuration, not - * fetched from the API. It is read from `VITE_AUTH_*` environment variables so - * retargeting the IdP (local `entra-local` emulator vs. production Entra ID) is - * a config change, mirroring the CLI. + * §8, subtask 10) the Portal's IdP config is public configuration, not + * fetched from the API. Container runtime settings override `VITE_AUTH_*` + * build settings, allowing the same image to use each environment's IdP. * * In `dev` builds the values default to the deterministic seed directory shipped * by [entra-local](https://github.com/cmaneu/entra-local) (tag `v0.0.3`) so @@ -18,7 +17,7 @@ * MSAL requires an https authority. Under Docker the compose stack injects * `VITE_AUTH_AUTHORITY` / `VITE_AUTH_KNOWN_AUTHORITIES` with the per-worktree * host port, overriding these defaults. Production builds **must** supply the - * `VITE_AUTH_*` values (baked into the bundle at build time); when they are + * client ID, authority, and API scopes at runtime or build time; when they are * missing the config is reported as not configured so the app can surface a * clear error instead of silently pointing at `localhost`. * @@ -64,7 +63,7 @@ export interface PortalAuthConfig { enabled: boolean; /** * `true` when a usable configuration was resolved. In production this requires - * the `VITE_AUTH_*` env vars to be present at build time. + * client ID, authority, and API scopes from runtime or build-time settings. */ isConfigured: boolean; } @@ -162,19 +161,22 @@ function resolveConfig(): PortalAuthConfig { const runtime = typeof window !== "undefined" ? window.__SCOPE_CONFIG__ : undefined; const enabled = resolveAuthEnabled(runtime, env, isDev); + const hasRuntimeAuth = runtime?.authClientId !== undefined || + runtime?.authAuthority !== undefined || runtime?.authScopes !== undefined; - const clientId = - (env.VITE_AUTH_CLIENT_ID as string | undefined) ?? - (isDev ? ENTRA_LOCAL_DEFAULTS.clientId : ""); - const authority = - (env.VITE_AUTH_AUTHORITY as string | undefined) ?? - (isDev ? ENTRA_LOCAL_DEFAULTS.authority : ""); + // A partial runtime override must not pair a different tenant with a baked-in client. + const clientId = hasRuntimeAuth ? (runtime?.authClientId ?? "") : + ((env.VITE_AUTH_CLIENT_ID as string | undefined) ?? + (isDev ? ENTRA_LOCAL_DEFAULTS.clientId : "")); + const authority = hasRuntimeAuth ? (runtime?.authAuthority ?? "") : + ((env.VITE_AUTH_AUTHORITY as string | undefined) ?? + (isDev ? ENTRA_LOCAL_DEFAULTS.authority : "")); const knownAuthorities = envList(env.VITE_AUTH_KNOWN_AUTHORITIES as string | undefined) ?? (isDev ? ENTRA_LOCAL_DEFAULTS.knownAuthorities : []); - const scopes = - envList(env.VITE_AUTH_SCOPES as string | undefined) ?? - (isDev ? ENTRA_LOCAL_DEFAULTS.scopes : []); + const scopes = hasRuntimeAuth ? (envList(runtime?.authScopes) ?? []) : + (envList(env.VITE_AUTH_SCOPES as string | undefined) ?? + (isDev ? ENTRA_LOCAL_DEFAULTS.scopes : [])); const protocolMode = (env.VITE_AUTH_PROTOCOL_MODE as ProtocolMode | undefined) || (isDev ? ENTRA_LOCAL_DEFAULTS.protocolMode : "AAD"); @@ -201,7 +203,7 @@ function resolveConfig(): PortalAuthConfig { protocolMode, cacheLocation, enabled, - isConfigured: Boolean(clientId && authority), + isConfigured: Boolean(clientId && authority && scopes.length), }; } diff --git a/apps/portal/src/lib/auth/runtimeConfig.test.ts b/apps/portal/src/lib/auth/runtimeConfig.test.ts new file mode 100644 index 000000000..355f966fa --- /dev/null +++ b/apps/portal/src/lib/auth/runtimeConfig.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +describe.skipIf(process.platform === "win32")("Portal runtime config entrypoint", () => { + it("serializes public auth settings and preserves escaped characters", () => { + const directory = mkdtempSync(join(tmpdir(), "scope-portal-config-")); + const configPath = join(directory, "config.js"); + try { + const result = spawnSync("sh", [resolve("apps/portal/docker-entrypoint.sh")], { + env: { + ...process.env, + SCOPE_CONFIG_PATH: configPath, + SCOPE_AUTH_ENABLED: "false", + SCOPE_DOCS_BASE_URL: "https://docs.example.com/\"quoted\"", + SCOPE_AUTH_CLIENT_ID: "client\"\\\nnext", + SCOPE_AUTH_AUTHORITY: "https://login.example.com/tenant", + SCOPE_AUTH_SCOPES: "api://sample/read, api://sample/write", + }, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + + const window = { __SCOPE_CONFIG__: {} as Record }; + runInNewContext(readFileSync(configPath, "utf8"), { window }); + expect(window.__SCOPE_CONFIG__).toEqual({ + docsBaseUrl: "https://docs.example.com/\"quoted\"", + authEnabled: false, + authClientId: "client\"\\\nnext", + authAuthority: "https://login.example.com/tenant", + authScopes: "api://sample/read, api://sample/write", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/portal/src/vite-env.d.ts b/apps/portal/src/vite-env.d.ts index 863c96ee3..a4d197012 100644 --- a/apps/portal/src/vite-env.d.ts +++ b/apps/portal/src/vite-env.d.ts @@ -34,6 +34,12 @@ interface ScopeRuntimeConfig { * instead. When present it takes precedence. Auth is ON by default. */ authEnabled?: boolean; + /** Public OAuth client ID supplied by SCOPE_AUTH_CLIENT_ID at container start. */ + authClientId?: string; + /** Entra authority supplied by SCOPE_AUTH_AUTHORITY at container start. */ + authAuthority?: string; + /** Comma-separated API scopes supplied by SCOPE_AUTH_SCOPES at container start. */ + authScopes?: string; } interface Window { diff --git a/docs/architecture/auth-rbac.md b/docs/architecture/auth-rbac.md index 12fb57c2c..db4bd6e2b 100644 --- a/docs/architecture/auth-rbac.md +++ b/docs/architecture/auth-rbac.md @@ -889,8 +889,11 @@ The remaining interactive CLI design is **deferred**: - MSAL (`@azure/msal-browser` + `@azure/msal-react`) uses **Auth Code + PKCE** redirect login. `MsalProvider` and `AuthProvider` are composed in - [main.tsx](../../apps/portal/src/main.tsx). IdP settings are build-time - `VITE_AUTH_*` configuration; there is no `/api/v1/auth/config` fetch. + [main.tsx](../../apps/portal/src/main.tsx). The container entrypoint writes + public `SCOPE_AUTH_CLIENT_ID`, `SCOPE_AUTH_AUTHORITY`, and `SCOPE_AUTH_SCOPES` + to `/config.js`; the complete runtime triplet overrides build-time `VITE_AUTH_*` + settings. A partial runtime triplet is unconfigured. There is no + `/api/v1/auth/config` fetch. - `initializeAuth()` distinguishes an account-bound completed callback from a cached-account reload. `AuthProvider` alone owns the Scope handshake via `api.enrollCurrentUser({ signal })` or `api.getCurrentUser({ signal })`: @@ -956,7 +959,7 @@ the existing External Secrets pipeline.** | Material | Secret? | Where it lives | Notes | |----------|---------|----------------|-------| | **IdP JWKS** (signing public keys) | No | **Fetched at runtime** from the IdP's `jwks_uri`, cached in-memory in the API with TTL + `kid` rotation | Public keys; never persisted to disk or DB. | -| **OIDC discovery / authority / clientId / scopes / audience** | No | Plain env / ConfigMap for the API; **hardcoded into the CLI and Portal builds** (no `/auth/config` endpoint) | Non-secret configuration. | +| **OIDC discovery / authority / clientId / scopes / audience** | No | Plain env / ConfigMap for the API; Portal runtime `/config.js` with build-time fallback; CLI build settings (no `/auth/config` endpoint) | Non-secret configuration. | | **`INTERNAL_API_KEY_`** (per-service key, service-to-service) | **Yes** | **Azure Key Vault** \u2192 synced to a K8s Secret by **External Secrets Operator** (same pattern as `mongo-secrets`/`redis-secrets`); each service gets its **own** key mounted as env | Constant-time compared; **no single global key**; rotate per-service in Key Vault. || **`INTERNAL_JWT_SECRET` / internal signing key** (mints on-behalf-of user tokens) | **Yes** | **Azure Key Vault** → External Secret. **Asymmetric (chosen)**: API holds the **private** key; downstream verifiers hold only the **public** key. HMAC secret is a single-deployment alternative. | Scope-owned, **independent of the IdP**; rotate via Key Vault. || **Entra API client secret** (only if the optional confidential-client/client-credentials path in \u00a76 is used) | **Yes** | **Azure Key Vault** \u2192 External Secret | The public CLI/Portal clients are **public** clients (PKCE / device-code) and have **no** secret. | | **CLI user tokens** (access/refresh) | **Yes** | User's machine via the Scope **`SecretStore`** abstraction (default backend **`cross-keychain`** → OS keychain, service `scope-cli`); `0600` file backend only where no keyring exists | MSAL token cache; never logged; silent refresh. **No `keytar`.** | | **Portal tokens** | **Yes** | Browser memory via MSAL (session/`localStorage` per MSAL cache config) | No tokens in app code or repo. | @@ -1092,24 +1095,26 @@ verified separately when those deferred credentials are introduced. 10. 🟡 **Portal auth** — Delivered: MSAL bearer transport plus one `AuthProvider` handshake, callback/reload distinction, query gating, account-bound deduplication/ cancellation, and API-authoritative Scope identity/role (§8). **Deferred**: - permission-aware nav/pages and self-scoped runs. IdP settings remain build-time - `VITE_AUTH_*`; no `/auth/config`, Scope token store, or dev role switcher. + permission-aware nav/pages and self-scoped runs. Portal IdP settings are + runtime `SCOPE_AUTH_*` with build-time `VITE_AUTH_*` fallback; no `/auth/config`, + Scope token store, or dev role switcher. > **MVP shipped (authentication only).** Delivered so far: MSAL sign-in > (auth-code + PKCE redirect), `MsalProvider` + `AuthProvider`, a `RequireAuth` > route guard, a header sign-in/sign-out `UserMenu`, and centralized token > acquisition + silent refresh + `401`→re-auth handled entirely inside the > `api-client` interceptor (`apps/portal/src/lib/api-client.ts`, via the - > `setApiTokenProvider`/`setReauthHandler` seams). IdP config is build-time - > (`VITE_AUTH_*`, see [ENV_VARIABLES.md](../../ENV_VARIABLES.md)) defaulting to - > the `entra-local` emulator for local dev. + > `setApiTokenProvider`/`setReauthHandler` seams). Public IdP config uses + > runtime `SCOPE_AUTH_*` with build-time `VITE_AUTH_*` fallback (see + > [ENV_VARIABLES.md](../../ENV_VARIABLES.md)); local dev defaults to `entra-local`. > Docker's `builder` stage accepts these public settings as build arguments: > Compose forwards them through `portal.build.args`, and CI forwards the > same-named GitHub Actions configuration variables. They are embedded by - > Vite, not read from the final nginx container's environment. The dev image - > continues to read them from the Vite process environment. Changing the - > IdP requires rebuilding the image; promoting one image preserves its IdP - > settings. Empty optional redirect settings retain the current Portal origin. + > Vite as fallbacks. The final nginx container reads `SCOPE_AUTH_CLIENT_ID`, + > `SCOPE_AUTH_AUTHORITY`, and `SCOPE_AUTH_SCOPES` at startup and writes them + > safely to `/config.js`; a promoted image can use each environment's IdP. + > The dev image reads `VITE_AUTH_*` from its Vite process environment. Empty + > optional redirect settings retain the current Portal origin. > > **Feature toggle (important).** Portal auth is **on by default (secure by > default)** but can be turned off per environment via **three independent