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
36 changes: 25 additions & 11 deletions ENV_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,24 +447,24 @@ 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`,
`VITE_AUTH_AUTHORITY`, `VITE_AUTH_KNOWN_AUTHORITIES`, `VITE_AUTH_SCOPES`,
`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.

Expand All @@ -486,9 +486,13 @@ access tokens as Portal build arguments.
--build-arg VITE_AUTH_SCOPES="api://<api-client-id>/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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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://<AUTH_API_CLIENT_ID>/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)
Expand Down
31 changes: 27 additions & 4 deletions apps/portal/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" <<EOF
window.__SCOPE_CONFIG__ = Object.assign(
{ docsBaseUrl: "${escaped_docs_base}", authEnabled: ${AUTH_ENABLED} },
{ docsBaseUrl: ${DOCS_JS}, authEnabled: ${AUTH_ENABLED}${AUTH_CONFIG} },
window.__SCOPE_CONFIG__
);
EOF
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/public/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
//
// In production this file is regenerated at container start by
// docker-entrypoint.sh from environment variables (SCOPE_DOCS_BASE_URL,
// SCOPE_AUTH_ENABLED), so the docs base URL and the auth feature toggle can
// change per environment without rebuilding the image.
// SCOPE_AUTH_ENABLED, SCOPE_AUTH_CLIENT_ID, SCOPE_AUTH_AUTHORITY,
// SCOPE_AUTH_SCOPES), so public auth settings can change per environment.
//
// In development (Vite) this static file is served as-is and provides the
// default values. Note: `authEnabled` is intentionally omitted here so local
Expand Down
3 changes: 3 additions & 0 deletions apps/portal/src/components/auth/RequireAuth.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const meta = {
component: RequireAuth,
tags: ["ai-generated"],
args: { children: <div>Scope application</div> },
parameters: {
docs: { description: { component: "Requires a configured OAuth client from runtime SCOPE_AUTH_* or build-time VITE_AUTH_* settings before sign-in." } },
},
} satisfies Meta<typeof RequireAuth>;

export default meta;
Expand Down
10 changes: 5 additions & 5 deletions apps/portal/src/components/auth/RequireAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,9 +54,9 @@ function NotConfigured() {
Authentication not configured
</h1>
<p className="mt-2 text-sm text-muted-foreground">
This build is missing its identity-provider configuration
(<code>VITE_AUTH_*</code>). 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 <code>SCOPE_AUTH_*</code>
at container startup or <code>VITE_AUTH_*</code> at build time.
</p>
</div>
</div>
Expand Down
42 changes: 40 additions & 2 deletions apps/portal/src/lib/auth/authConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ describe("resolveAuthEnabled", () => {
vi.resetModules();
});

async function config(overrides: Record<string, string> = {}) {
async function config(overrides: Record<string, string> = {}, 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",
Expand Down Expand Up @@ -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)", () => {
Expand Down
34 changes: 18 additions & 16 deletions apps/portal/src/lib/auth/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
Expand All @@ -201,7 +203,7 @@ function resolveConfig(): PortalAuthConfig {
protocolMode,
cacheLocation,
enabled,
isConfigured: Boolean(clientId && authority),
isConfigured: Boolean(clientId && authority && scopes.length),
};
}

Expand Down
43 changes: 43 additions & 0 deletions apps/portal/src/lib/auth/runtimeConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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 });
}
});
});
6 changes: 6 additions & 0 deletions apps/portal/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading