Skip to content

Commit be84cf9

Browse files
jbouderclaude
andcommitted
Address round-2 PR review: rotation 401 blocker + cleanups
Blocker: the unknown-kid cooldown gate 401'd legitimate tokens during a real Keycloak key rotation. The SPA fires /api/me, /api/models and /api/keys concurrently on page load, so on the first load after a rotation one request won the CAS and fetched while the others got errKIDRefreshCooldown and were rejected. Move the cooldown check inside the singleflight callback so concurrent callers block on the one in-flight fetch and share its result, and re-check the cache in keyFunc regardless of the refresh outcome. The DoS bound (one outbound fetch per 30s window) is preserved. Add TestRotatedKIDResolvesForConcurrentBurst. Take the steady-state JWKS refresh off the request path: a synchronous hourly refresh stalled every concurrent request for up to the fetch timeout when Keycloak was unreachable as the cache went stale. Move it to a background ticker (refreshLoop) and drop the now-dead lastFetch. Other review items: - Cap the JWKS response body at 1 MiB (io.LimitReader). - Call validator.Stop() on shutdown. - Add security headers (X-Frame-Options, CSP frame-ancestors, X-Content-Type-Options) and a no-store /config.json block to both nginx configs; the UI exposes one-click revoke. - Template the NebariApp groups mapper claim.name from auth.oidc.groupsClaim so it can't drift from what the key-manager reads. - Comment provider: keycloak pointing at #61. - Note pinClientAudience is only meaningful with frontend.enabled=true. - Fix the stale ui-development.md shipping instructions. - Delete the stale frontend-rewrite-plan.md. - Drop an em dash in test-auth.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23d541b commit be84cf9

11 files changed

Lines changed: 204 additions & 290 deletions

File tree

charts/nebari-llm-serving/templates/frontend-configmap.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ data:
6262
root /usr/share/nginx/html;
6363
index index.html;
6464
65+
# Security headers. add_header does NOT inherit into a location that
66+
# sets its own add_header, so these cover the SPA document (location
67+
# /); the config.json block below repeats the ones that matter for it.
68+
# The framing headers matter because the UI exposes one-click "revoke
69+
# key" — deny embedding to block clickjacking.
70+
add_header X-Frame-Options "DENY" always;
71+
add_header Content-Security-Policy "frame-ancestors 'none'" always;
72+
add_header X-Content-Type-Options "nosniff" always;
73+
6574
gzip on;
6675
gzip_types text/plain text/css application/json application/javascript
6776
text/xml application/xml application/xml+rss text/javascript
@@ -91,6 +100,14 @@ data:
91100
add_header Content-Type text/plain;
92101
}
93102
103+
# Runtime config: never cache, so Keycloak settings changes take
104+
# effect on the next page load instead of being pinned by a stale copy.
105+
location = /config.json {
106+
add_header Cache-Control "no-store" always;
107+
add_header X-Content-Type-Options "nosniff" always;
108+
try_files $uri =404;
109+
}
110+
94111
# SPA fallback.
95112
location / {
96113
try_files $uri $uri/ /index.html;

charts/nebari-llm-serving/templates/key-manager-nebariapp.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,23 @@ spec:
3333
# provisions the public SPA client but emits no gateway SecurityPolicy.
3434
auth:
3535
enabled: true
36+
# Hardcoded to keycloak: the key-manager only validates Keycloak-minted
37+
# tokens today. generic-oidc support is tracked in nebari-dev/#61.
3638
provider: keycloak
3739
enforceAtGateway: false
3840
scopes:
3941
{{- toYaml .Values.keyManager.nebariApp.auth.scopes | nindent 6 }}
4042
# The groups mapper ensures access tokens carry group claims so the
41-
# key-manager can filter models by group membership.
43+
# key-manager can filter models by group membership. claim.name is derived
44+
# from the same value the key-manager reads (auth.oidc.groupsClaim, wired to
45+
# LLM_OIDC_GROUPS_CLAIM) so overriding the claim name can't drift the mapper
46+
# and the reader out of sync.
4247
keycloakConfig:
4348
protocolMappers:
4449
- name: group-membership
4550
protocolMapper: oidc-group-membership-mapper
4651
config:
47-
claim.name: groups
52+
claim.name: {{ .Values.auth.oidc.groupsClaim | quote }}
4853
full.path: "false"
4954
id.token.claim: "true"
5055
access.token.claim: "true"

charts/nebari-llm-serving/values.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ keyManager:
100100
# realm (issuer-only validation). The client ID is derived from
101101
# frontend.keycloak.clientId (or the operator convention), so it always
102102
# matches the token the SPA actually presents.
103+
#
104+
# Only meaningful with frontend.enabled=true: the pinned client is the SPA
105+
# client the NebariApp provisions, so enabling this on an API-only install
106+
# (frontend.enabled=false) pins an `azp` that no token in the realm carries
107+
# and every request 401s.
103108
pinClientAudience: false
104109
# nebariApp - the NebariApp CR that exposes the key-manager UI via the
105110
# nebari-operator (HTTPRoute, TLS cert, Keycloak client, landing-page tile).

dev/test-auth.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ check() { # name expected actual
112112
if [[ "$2" == "$3" ]]; then
113113
echo " PASS: $1 (HTTP $3)"
114114
else
115-
echo " FAIL: $1 expected $2, got $3"
115+
echo " FAIL: $1 - expected $2, got $3"
116116
FAILED=1
117117
fi
118118
}

docs/src/content/docs/ui-development.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,14 @@ npm run check # biome lint + format
9292
The UI ships as its own image
9393
(`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`, nginx serving the built
9494
bundle) - it is no longer embedded in the Go key-manager binary. Committing your
95-
edits to `frontend/` is all that is needed for them to ship in the next image
96-
build. To see your changes in the actual in-cluster pod (rather than the dev
97-
server), rebuild and reload:
98-
99-
```bash
100-
make build-images && make load-images
101-
kubectl -n llm-operator-system rollout restart deployment/llm-frontend
102-
```
95+
edits to `frontend/` is all that is needed for them to ship: CI builds and
96+
publishes the frontend image on merge, and a chart upgrade rolls it out.
97+
98+
During local development you iterate against the Vite dev server (`make ui` in
99+
`dev/`), which hot-reloads on save and proxies `/api` to the port-forwarded
100+
key-manager - there is no in-cluster frontend pod in the dev stack to rebuild or
101+
restart. (`make build-images` builds only the operator, key-manager, and
102+
mock-vllm images.)
103103

104104
## API reference (what the UI calls)
105105

frontend-rewrite-plan.md

Lines changed: 0 additions & 234 deletions
This file was deleted.

frontend/nginx.default.conf

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ http {
3636
root /usr/share/nginx/html;
3737
index index.html;
3838

39+
# Security headers. add_header does NOT inherit into a location that sets
40+
# its own add_header, so these cover the SPA document (location /); the
41+
# config.json block below repeats the ones that matter for it. The framing
42+
# headers matter because the UI exposes one-click "revoke key" — deny
43+
# embedding to block clickjacking.
44+
add_header X-Frame-Options "DENY" always;
45+
add_header Content-Security-Policy "frame-ancestors 'none'" always;
46+
add_header X-Content-Type-Options "nosniff" always;
47+
3948
gzip on;
4049
gzip_types text/plain text/css application/json application/javascript
4150
text/xml application/xml application/xml+rss text/javascript
@@ -55,6 +64,14 @@ http {
5564
add_header Content-Type text/plain;
5665
}
5766

67+
# Runtime config: never cache, so Keycloak settings changes take effect on
68+
# the next page load instead of being pinned by a stale cached copy.
69+
location = /config.json {
70+
add_header Cache-Control "no-store" always;
71+
add_header X-Content-Type-Options "nosniff" always;
72+
try_files $uri =404;
73+
}
74+
5875
# SPA fallback.
5976
location / {
6077
try_files $uri $uri/ /index.html;

0 commit comments

Comments
 (0)