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
9 changes: 3 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,15 @@ RUN pnpm run build

FROM nginx:1.27-alpine

# `envsubst` is provided by gettext; the official nginx:alpine image already
# includes it. We list it here defensively in case the base image ever drops it.
RUN apk add --no-cache gettext

COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

# nginx config: security-headers snippet (included at server level AND inside
# any location block that declares its own add_header — nginx replaces rather
# than merges across levels) + default.conf template that the entrypoint
# renders at container start (envsubst on ${CSP_REMOTE}).
# than merges across levels) + default.conf that the entrypoint copies into
# conf.d at container start. No runtime templating anymore (the SPA is
# same-origin only), but the file keeps its .template name and location.
RUN mkdir -p /etc/nginx/snippets
COPY nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
services:
insight-front:
# Serves the static SPA only. Auth (/auth/*) and the API (/api/*) are
# fronted by the nginx `gateway` in front of this container, so no OIDC
# env is needed here anymore.
build: .
ports:
- "8080:80"
environment:
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
restart: unless-stopped
132 changes: 12 additions & 120 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,125 +1,17 @@
#!/bin/sh
set -e

# Escape a value for safe interpolation inside a JavaScript string literal
# wrapped in double quotes. Backslash MUST be first so the other escapes
# we add aren't themselves doubled. Newlines / CR aren't realistic in an
# OIDC issuer URL or client_id, but if anything weird ever creeps in via
# env, we reject the value at validation time below — never silently
# embed a multi-line value.
escape_js() {
printf '%s' "$1" | sed \
-e 's|\\|\\\\|g' \
-e 's|"|\\"|g'
}

# Reject values containing characters that don't belong in an OIDC issuer
# or client_id: whitespace, control chars, anything that breaks a JS
# string literal in nasty ways. If any var has those, fail loudly.
contains_unsafe_chars() {
printf '%s' "$1" | LC_ALL=C grep -q '[[:cntrl:][:space:]]'
}
if contains_unsafe_chars "${OIDC_ISSUER:-}" || contains_unsafe_chars "${OIDC_CLIENT_ID:-}"; then
echo "ERROR: OIDC_ISSUER or OIDC_CLIENT_ID contains whitespace or control characters; refusing to start." >&2
exit 1
fi

# DEV_USER_EMAIL is the docker-compose dev stack's escape hatch — it
# turns on impersonation in production builds when no OIDC is configured.
# It's mutually exclusive with real OIDC: shipping both means a
# misconfigured prod deploy where everyone gets silently impersonated as
# DEV_USER_EMAIL. Fail loud here rather than serving a broken-but-up app.
if [ -n "${DEV_USER_EMAIL:-}" ]; then
if contains_unsafe_chars "$DEV_USER_EMAIL"; then
echo "ERROR: DEV_USER_EMAIL contains whitespace or control characters; refusing to start." >&2
exit 1
fi
if [ -n "${OIDC_ISSUER:-}" ] || [ -n "${OIDC_CLIENT_ID:-}" ]; then
echo "ERROR: DEV_USER_EMAIL is set together with OIDC config — these are mutually exclusive." >&2
echo " Either remove DEV_USER_EMAIL (real OIDC flow), or clear OIDC_ISSUER/OIDC_CLIENT_ID (dev impersonation)." >&2
exit 1
fi
fi

# OIDC_SCOPES is space-separated, so internal whitespace is expected. Validate
# each token has no control chars (still a JS-string-injection concern).
if [ -n "${OIDC_SCOPES:-}" ]; then
for tok in $OIDC_SCOPES; do
if printf '%s' "$tok" | LC_ALL=C grep -q '[[:cntrl:]]'; then
echo "ERROR: OIDC_SCOPES contains control characters; refusing to start." >&2
exit 1
fi
done
fi

# Write runtime config to an external JS file. We can't inline the script
# in index.html — strict CSP (`script-src 'self'`) would reject it. Always
# write the file so index.html's <script src> tag never 404s. The file
# may contain __OIDC_CONFIG__ (real OIDC), __DEV_CONFIG__ (compose dev
# impersonation), or be empty (Vite dev fallback / undefined deploy).
OIDC_CONFIG_FILE=/usr/share/nginx/html/oidc-config.js
: > "$OIDC_CONFIG_FILE"
if [ -n "$OIDC_ISSUER" ] && [ -n "$OIDC_CLIENT_ID" ]; then
if [ -z "${OIDC_SCOPES:-}" ]; then
echo "ERROR: OIDC_SCOPES must be set when OIDC_ISSUER and OIDC_CLIENT_ID are set." >&2
exit 1
fi
issuer_js=$(escape_js "$OIDC_ISSUER")
client_id_js=$(escape_js "$OIDC_CLIENT_ID")
scopes_js=$(escape_js "$OIDC_SCOPES")
printf 'window.__OIDC_CONFIG__={issuer_url:"%s",client_id:"%s",scopes:"%s"};\n' \
"$issuer_js" "$client_id_js" "$scopes_js" >> "$OIDC_CONFIG_FILE"
echo "OIDC config written to $OIDC_CONFIG_FILE: issuer=$OIDC_ISSUER client_id=$OIDC_CLIENT_ID scopes=$OIDC_SCOPES"
elif [ -n "${DEV_USER_EMAIL:-}" ]; then
dev_email_js=$(escape_js "$DEV_USER_EMAIL")
printf 'window.__DEV_CONFIG__={devUserEmail:"%s"};\n' "$dev_email_js" >> "$OIDC_CONFIG_FILE"
# Don't log DEV_USER_EMAIL — it can contain real PII if a deployer points
# this at an actual mailbox. The "wrote DEV config" signal is what matters.
echo "DEV config written to $OIDC_CONFIG_FILE (auth bypassed via runtime dev impersonation)"
else
echo "No OIDC or DEV config set — $OIDC_CONFIG_FILE left empty (Vite-dev fallback only)"
fi

# Inject <script src="/oidc-config.js?v=<ts>"> into index.html.
# Query string is a cache-bust — some intermediates (CDN, browser SW) have
# been observed serving a stale /oidc-config.js after env changes. Strip any
# prior oidc-config tag first (matches with or without an existing ?v=…) so
# restarts replace the tag in-place rather than stacking duplicates.
CACHE_BUST=$(date +%s)
sed -i 's|<script src="/oidc-config\.js[^"]*"></script>||g' /usr/share/nginx/html/index.html
sed -i "s|</head>|<script src=\"/oidc-config.js?v=${CACHE_BUST}\"></script></head>|" \
/usr/share/nginx/html/index.html

# Render CSP with the actual OIDC issuer origin so connect-src / frame-src can
# be tight (specific host) instead of broad `https:`. Falls back to `https:` if
# OIDC_ISSUER is not set, keeping the build usable without runtime config.
if [ -n "$OIDC_ISSUER" ]; then
# Strip path/query/fragment to get just `scheme://host[:port]`. Validate
# that the input actually looks like a URL — if not, fall back to `https:`
# rather than splatting a malformed value into the CSP header. The
# character class [^/?#] stops at the first authority terminator, so an
# OIDC_ISSUER like https://issuer.example.com?foo=bar still yields a
# valid CSP source (https://issuer.example.com), not a malformed token.
if echo "$OIDC_ISSUER" | grep -qE '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]/?#]+'; then
# Extraction MUST exclude whitespace too (matches validation). Otherwise
# an OIDC_ISSUER like `https://issuer.example.com https:` would pass
# validation (first authority looks fine) but the sed greedy match would
# extract `https://issuer.example.com https:` — CSP directive values are
# space-separated, so that smuggles in `https:` as a third allowed CSP
# source, opening connect-src/frame-src to ALL https origins.
CSP_REMOTE=$(echo "$OIDC_ISSUER" | sed -E 's|^([A-Za-z][A-Za-z0-9+.-]*://[^[:space:]/?#]+).*|\1|')
else
echo "WARN: OIDC_ISSUER='$OIDC_ISSUER' is not a well-formed URL — using https: in CSP"
CSP_REMOTE="https:"
fi
else
CSP_REMOTE="https:"
fi
export CSP_REMOTE

# Substitute placeholders in nginx config template.
envsubst '${CSP_REMOTE}' < /etc/nginx/templates/default.conf.template \
> /etc/nginx/conf.d/default.conf
echo "CSP connect-src/frame-src remote: $CSP_REMOTE"
# The SPA is a pure static bundle served behind the nginx `gateway`, which
# fronts `/api/*`, `/auth/*`, and `/` (this container). Authentication is a
# server-side cookie/BFF flow: the browser hits `/auth/login` (gateway ->
# authenticator -> IdP), gets a `__Host-sid` session cookie, and the SPA calls
# `/api/*` and `/auth/me` same-origin with `credentials: 'include'`. There is
# no client-side OIDC and no runtime config injection anymore, so this
# entrypoint only renders the nginx config template and execs the CMD.

# Place the nginx config. It has no runtime placeholders anymore (the SPA is
# same-origin only, so the CSP needs no injected OIDC issuer origin), but the
# build ships it under /etc/nginx/templates, so copy it into conf.d here.
cp /etc/nginx/templates/default.conf.template /etc/nginx/conf.d/default.conf

exec "$@"
8 changes: 4 additions & 4 deletions nginx/default.conf.template
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
map $sent_http_content_type $csp_header {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ${CSP_REMOTE}; frame-src 'self' ${CSP_REMOTE}; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'";
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'";
}

server {
Expand All @@ -15,9 +15,9 @@ server {
# location block that declares its own add_header (nginx replaces rather
# than merges across levels). CSP: 'unsafe-inline' for style-src is
# required for React inline styles + recharts; tighten via nonce in a
# later iteration. connect-src/frame-src use the actual OIDC issuer origin
# (substituted by docker-entrypoint.sh) when available, falling back to
# `https:` if OIDC_ISSUER is not set at runtime.
# later iteration. The SPA is same-origin only (it calls /api/* and
# /auth/* through the gateway that fronts this container), so connect-src
# and frame-src stay at 'self' — there is no client-side OIDC / IdP origin.
include /etc/nginx/snippets/security-headers.conf;

# MSW service worker ships in dist/ from public/ but is inert without
Expand Down
9 changes: 4 additions & 5 deletions nginx/security-headers.conf
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
add_header Content-Security-Policy $csp_header always;
# SAMEORIGIN (not DENY) so the OIDC silent-renew hidden iframe can load
# the SPA's redirect_uri from the same origin. Third-party framing remains
# blocked; combined with frame-ancestors 'self' in the CSP, this keeps
# clickjacking protection while not breaking automaticSilentRenew in
# oidc-client-ts.
# SAMEORIGIN (not DENY): the SPA is same-origin only, so allowing same-origin
# framing is harmless and future-proofs any in-app iframe. Third-party framing
# stays blocked; combined with frame-ancestors 'self' in the CSP this keeps
# clickjacking protection intact.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Expand Down
3 changes: 2 additions & 1 deletion src/api/catalog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export function CatalogProvider({
}: {
children: React.ReactNode;
}): React.ReactElement {
const { tenantId } = useAuth();
const { session } = useAuth();
const tenantId = session?.tenants[0] ?? null;
const queryClient = useQueryClient();
const prevTenantRef = useRef<string | null>(tenantId);

Expand Down
102 changes: 33 additions & 69 deletions src/api/fetch-with-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { getDevBearerEmail } = vi.hoisted(() => ({
getDevBearerEmail: vi.fn<() => string | null>(),
}));
vi.mock("@/auth/use-viewer", () => ({ getDevBearerEmail }));

import { authStore } from "@/auth/auth-store";
import { OidcManager } from "@/auth/oidc-manager";
import { fetchWithAuth } from "./fetch-with-auth";

const fetchMock = () => globalThis.fetch as ReturnType<typeof vi.fn>;

function authHeaderOfLastCall(): string | null {
function initOfLastCall(): RequestInit {
const calls = fetchMock().mock.calls;
const init = calls[calls.length - 1]?.[1] as RequestInit;
return new Headers(init.headers).get("Authorization");
return (calls[calls.length - 1]?.[1] ?? {}) as RequestInit;
}

describe("fetchWithAuth", () => {
let assign: ReturnType<typeof vi.fn>;

beforeEach(() => {
authStore.reset();
authStore.setToken("tok");
getDevBearerEmail.mockReset();
getDevBearerEmail.mockReturnValue(null);
authStore.setAuthenticated({
personId: "p-1",
email: "bob.park@example.com",
tenants: ["t-1"],
roles: ["user"],
});
vi.stubGlobal("fetch", vi.fn());
// jsdom's `window.location.assign` is a non-configurable no-op; replace
// the whole location object so the full-page login bounce is observable.
assign = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, assign, pathname: "/dash", search: "?q=1" },
});
});

afterEach(() => {
Expand All @@ -32,73 +37,32 @@ describe("fetchWithAuth", () => {
authStore.reset();
});

it("sends credentials:'include' and no Authorization / X-Tenant-ID header", async () => {
fetchMock().mockResolvedValue(new Response(null, { status: 200 }));
await fetchWithAuth("/x", { headers: { Accept: "application/json" } });
const init = initOfLastCall();
expect(init.credentials).toBe("include");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBeNull();
expect(headers.get("X-Tenant-ID")).toBeNull();
});

it("returns non-401 responses unchanged without touching auth", async () => {
const ok = new Response(null, { status: 200 });
fetchMock().mockResolvedValue(ok);
const refresh = vi.spyOn(OidcManager, "refresh");
const res = await fetchWithAuth("/x");
expect(res).toBe(ok);
expect(refresh).not.toHaveBeenCalled();
});

it("retries with the refreshed token on a 401", async () => {
const r200 = new Response(null, { status: 200 });
fetchMock()
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(r200);
vi.spyOn(OidcManager, "refresh").mockResolvedValue("newtok");
const res = await fetchWithAuth("/x");
expect(res).toBe(r200);
expect(fetchMock()).toHaveBeenCalledTimes(2);
expect(authStore.getSnapshot().status).toBe("authenticated");
expect(assign).not.toHaveBeenCalled();
});

it("requires reauth and returns the 401 when refresh fails", async () => {
it("on a 401 marks the session unauthenticated and bounces into the login flow", async () => {
const r401 = new Response(null, { status: 401 });
fetchMock().mockResolvedValue(r401);
vi.spyOn(OidcManager, "refresh").mockResolvedValue(null);
const requireReauth = vi
.spyOn(OidcManager, "requireReauth")
.mockResolvedValue(undefined);
const res = await fetchWithAuth("/x");
expect(res).toBe(r401);
expect(requireReauth).toHaveBeenCalledWith("refresh_failed");
});

it("requires reauth when the retry is still 401", async () => {
fetchMock().mockResolvedValue(new Response(null, { status: 401 }));
vi.spyOn(OidcManager, "refresh").mockResolvedValue("newtok");
const requireReauth = vi
.spyOn(OidcManager, "requireReauth")
.mockResolvedValue(undefined);
const res = await fetchWithAuth("/x");
expect(res.status).toBe(401);
expect(requireReauth).toHaveBeenCalledWith("token_rejected");
});

it("mints a dev bearer only for the disabled + dev_bypass state", async () => {
authStore.reset();
authStore.setStatus("disabled", "dev_bypass");
getDevBearerEmail.mockReturnValue("dev@example.com");
fetchMock().mockResolvedValue(new Response(null, { status: 200 }));
await fetchWithAuth("/x");
expect(authHeaderOfLastCall()).toMatch(/^Bearer \S+\.\S+\./);
});

it("never mints an unsigned bearer while renewing under OIDC", async () => {
authStore.reset();
authStore.setStatus("renewing");
getDevBearerEmail.mockReturnValue("dev@example.com");
fetchMock().mockResolvedValue(new Response(null, { status: 200 }));
await fetchWithAuth("/x");
expect(authHeaderOfLastCall()).toBeNull();
});

it("fails closed on an unconfigured deploy (missing_oidc_config)", async () => {
authStore.reset();
authStore.setStatus("disabled", "missing_oidc_config");
getDevBearerEmail.mockReturnValue("dev@example.com");
fetchMock().mockResolvedValue(new Response(null, { status: 200 }));
await fetchWithAuth("/x");
expect(authHeaderOfLastCall()).toBeNull();
expect(authStore.getSnapshot().status).toBe("unauthenticated");
expect(assign).toHaveBeenCalledTimes(1);
expect(assign.mock.calls[0][0]).toMatch(/^\/auth\/login\?return_to=/);
});
});
Loading