Conversation
# Conflicts: # dev/Makefile
|
📄 Docs preview for |
dcmcand
left a comment
There was a problem hiding this comment.
Review scope
This pass covers the Go (key-manager), Helm chart, docs, and CI changes. I skipped the React/TS app this round.
Overall this is careful, well-structured work. The docs were updated alongside the code, the token validator has good security instincts (RSA-only signing method, fail-closed middleware), and the removal of the embedded internal/ui package is clean with no dangling references. Everything builds and passes locally: go build ./..., go vet ./..., key-manager go test ./..., helm lint, and helm template are all green.
I'm requesting changes for three defects plus one design question that I think need a decision before this is exposed publicly. Specifics are in the inline comments; summary below.
Blockers
- JWKS unknown-
kidrefetch is a DoS amplification vector (jwt_validator.go, inline). An unauthenticated caller can force an outbound Keycloak call per request with a forged token. - Data race on
lastFetch(jwt_validator.go:206vs:308, inline).go test -racewill flag it. - NebariApp can target a Service that isn't rendered (
key-manager-nebariapp.yaml:16, inline).frontend.enabled: falseis a documented config and breaks the gateway target.
Question (needs a maintainer decision)
aud/azpare intentionally unvalidated (jwt_validator.go, inline). Fine if the realm is single-purpose; a real gap if it hosts other OIDC clients. That call depends on the realm's client topology.
Non-blocking cleanups
docs/src/content/docs/ui-development.md:99-102tells readers torollout restart deployment/llm-frontend, but the dev Makefile builds no frontend image and creates no such deployment. Drop the paragraph or add a real dev target.- Both nginx configs (the baked
frontend/nginx.default.confand the Helm-renderedfrontend-configmap.yaml) lackCache-Control: no-storeon/config.jsonand lackX-Frame-Options/frame-ancestorsplusX-Content-Type-Options. The framing header matters here because the UI exposes one-click "revoke key." Fix both, since they are hand-duplicated. frontend/public/config.jsonis committed and not gitignored, contradictingfrontend-rewrite-plan.md:155. Harmless while the ConfigMap overrides it, a footgun if the raw image runs without the mount.provider: keycloakis hardcoded in the NebariApp whilearchitecture.md:371advertiseskeycloakorgeneric-oidc. Consistent with the Keycloak-only limitation in #61; worth a one-line comment or a values passthrough.AuthConfig.Validatoris a concrete*JWTValidatorrather than an interface, which is whymiddleware_test.gobuilds it from unexported fields. A smallTokenValidatorinterface would let tests use a fake.- No
jwt_validator_test.go: the retry/backoff state machine,fetchPublicKeyserror handling, key parsing, and the unknown-kidpath have no direct coverage, even though the knobs were made overridable specifically for testing. - The retry/poll knobs are package-level mutable vars and would race under
t.Parallel(); instance fields with constructor defaults would be safer. frontend-rewrite-plan.md(234 lines) is a scratch planning doc at the repo root that already contradicts the shipped design. Consider dropping it now thatarchitecture.mdcovers the as-built design.
Nice work
- Explicit
*jwt.SigningMethodRSAassertion blocksalg:noneand HMAC-confusion attacks. TestAuthMiddlewareMisconfiguredverifies the middleware fails closed rather than admitting requests when misconfigured.- The
ErrNotReadyto 503 +Retry-Afterdistinction from a bad-token 401 is a thoughtful operational touch. go mod tidyis a zero-diff, so the removed indirect deps are genuinely unused.- CI actions are SHA-pinned and the new frontend/key-manager build contexts are correct.
- jwt_validator: rate-limit + de-duplicate (singleflight) unknown-kid JWKS refreshes so forged tokens can't drive a fetch storm on Keycloak; promote golang.org/x/sync to a direct dep. - jwt_validator: read lastFetch under keysMu (fixes data race flagged by go test -race). - jwt_validator/main.go/chart: opt-in azp pin (LLM_KEYCLOAK_SPA_CLIENT_ID / keyManager.keycloak.pinClientAudience) to reject tokens minted for other clients in the shared nebari realm; unset preserves iss-only. - key-manager-nebariapp: fail guard when nebariApp.enabled && !frontend.enabled, so the gateway can't target an unrendered -frontend Service. - Add jwt_validator_test covering the refresh rate-limit and azp pin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dcmcand
left a comment
There was a problem hiding this comment.
Re-review: Go, backend, and infra
Scope matches the last round: key-manager Go, Helm chart, docs, CI, and dev tooling. The React app is still out of scope for me.
I verified each claimed fix from 23d541b by reading the code and running the commands, not by trusting the thread. Results:
| Prior finding | Status |
|---|---|
JWKS unknown-kid DoS amplification |
Fixed for the stated attack. The cooldown gate introduces a new availability bug, see the inline blocker. |
Data race on lastFetch |
Fixed. Read at jwt_validator.go:242-244 now snapshots under keysMu.RLock(). go test -race -count=1 ./... is clean on my machine. |
| NebariApp targeting an unrendered Service | Fixed. I ran helm template on all three enable combinations: the bad combo fails with a clear message, the happy path renders, disabled renders clean. The required guards on hostname and frontend.keycloak.url are a nice touch. |
aud/azp unvalidated |
Fixed. The pin is ordered after iss, a missing or non-string azp fails closed, and TestAzpValidation covers all six set/unset/mismatch cases. azp is the right claim for a public PKCE client. |
Kudos
- The azp fix is exactly what I hoped for: correct claim choice, fail-closed on absent claims, and a table test that covers the matrix.
TestUnknownKIDRefreshIsRateLimitedproving one fetch for a 50-goroutine burst is a good test. It has one structural blind spot, covered inline.- The chart derivation for the pinned client ID reuses
frontend.keycloak.clientIdwith the same default convention the ConfigMap uses, so the pin and the SPA always agree out of the box. - Handler authorization held up under a fresh look:
deleteKeyverifies ownership before revoking,createKeychecks model access,getKeysis scoped to the caller.
Blockers
1 new, inline on jwt_validator.go. Short version: the cooldown that fixes the DoS makes concurrent requests with a legitimately rotated key fail with 401.
Questions
Three, inline: whether the hourly JWKS refresh belongs on the request path, the pinClientAudience + frontend.enabled=false combination, and two config values that must be overridden in pairs.
Non-blocking, carried over from last round
23d541b addressed the Go items and the Helm blocker but none of the cleanups, so re-listing the ones I still care about:
- Both nginx configs (
frontend/nginx.default.confandfrontend-configmap.yaml) still lackX-Frame-Options/frame-ancestors,X-Content-Type-Options, andCache-Control: no-storeon/config.json. There is no dedicatedlocation = /config.jsonblock at all. The framing headers matter because the UI has one-click revoke. frontend/public/config.jsonis still tracked, andfrontend-rewrite-plan.md:155still claims it is gitignored. The Dockerfile bakes the localhost placeholder into the image.docs/src/content/docs/ui-development.md:100-101still tells readers to runmake build-images(builds no frontend image) and restartdeployment/llm-frontend(does not exist).frontend-rewrite-plan.mdis still at the repo root and now contradicts both the shipped design and itself.
Non-blocking, new this round
- No workload in the chart has liveness or readiness probes, and the frontend Deployment has no ConfigMap checksum annotation, so a config-only
helm upgradenever rolls the pod (which is why the docs reach for a manualrollout restart). Note the key-manager is now API-only with no health endpoint, so it needs a/healthzbefore it can get probes. frontend.image.pullPolicy: Alwaysagainst a pinned tag;IfNotPresentis the usual choice.provider: keycloakin the NebariApp is fine given the documented Keycloak-only limitation, but a comment pointing at #61 would stop the next reviewer from flagging it.- Go minors:
ValidateTokentakes nocontext.Contextso a cancelled request can't cancel the JWKS fetch it may trigger;fetchPublicKeysdecodes the response body with no size cap;main.gonever callsvalidator.Stop()in shutdown;SetExpectedClientIDlacks the empty-string guardSetIssuerURLhas;AuthConfig.Validatoris still the concrete type; the retry/poll knobs are still package-level vars. - 401 bodies echo
err.Error(), which includes the expected issuer and azp values. Everything in there is already public viaconfig.json, so this is a nit. test-auth.sh:115has an em dash in a FAIL message.
Everything builds and passes here: go build, go vet, go test -race, helm lint, helm template.
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>
Round-2 review addressed —
|
Follow-up to the round-2 blocker fixes, taking on the non-blocking suggestions: - Derive the provisioned SPA client ID from frontend.keycloak.clientId so the operator's client, the client the SPA presents, and the key-manager azp pin can no longer be overridden apart; spaClientId is now an explicit override on top of that. Verified across all combinations with helm template. - Depend on a TokenValidator interface instead of the concrete *JWTValidator in AuthConfig, so the middleware can be tested with a fake (added TestAuthMiddlewareWithFakeValidator). - Thread context.Context through ValidateToken. The request context cancels the caller's *wait* on a JWKS fetch (via singleflight DoChan) while the fetch itself runs on a validator-lifetime context, so one cancelled request can't abort a fetch other in-flight requests are sharing. Stop() cancels that lifetime context. - Cap the JWKS fetch context and wire it to the validator lifetime; move the retry/poll/refresh knobs from package-level mutable vars to instance fields with constructor defaults (safe under t.Parallel()), and add direct coverage of the retry state machine (TestInitialFetchRetriesThenSucceeds, TestInitialFetchFallsBackToSlowPoll). - Add an unauthenticated GET /healthz to the (now API-only) key-manager, add liveness/readiness probes to both the key-manager and frontend Deployments, and add a checksum/config annotation to the frontend pod template so a config-only helm upgrade rolls the pod. Verified the checksum changes when config.json content changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up suggestions addressed —
|
dcmcand
left a comment
There was a problem hiding this comment.
Re-review (round 3)
Reviewed at HEAD 2c63aac4 in an isolated worktree. This pass verified the round-2 fixes across the Go key-manager, Helm chart, and docs, and it is the first review of the React/TS frontend (the prior two rounds skipped it).
Round-2 fixes: all resolved
| Prior finding | Status |
|---|---|
| Blocker: cooldown gate 401s legit tokens during key rotation | Fixed. The cooldown check moved inside the singleflight callback and keyFunc re-checks the cache regardless of refresh outcome. TestRotatedKIDResolvesForConcurrentBurst starts with only the old key cached, signs with an uncached rotated kid, fires 50 concurrent validations, and asserts all 50 validate with exactly one fetch. That test fails under the old code, so it is a real regression guard. go test -race is clean. |
| Q: hourly JWKS refresh on the request path | Fixed. Moved to a background refreshLoop ticker; ValidateToken does no synchronous refresh; fail-open on stale keys; dead lastFetch field removed. |
Q: pinClientAudience + frontend.enabled=false locks out callers |
Documented in values.yaml (see Q5 below on guarding it). |
| Q: groups-claim name could drift | Fixed. The mapper claim.name is templated from .Values.auth.oidc.groupsClaim; helm template --set auth.oidc.groupsClaim=custom_groups renders both the mapper and LLM_OIDC_GROUPS_CLAIM as custom_groups. |
| Q: SPA client ID in two values needing joint override | Fixed. spaClientId defaults to frontend.keycloak.clientId; helm template --set frontend.keycloak.clientId=my-spa renders config.json, the NebariApp spaClient.clientId, and the azp env identically, and the both-empty path falls back to the operator convention consistently. |
| New non-blockers: context / size-cap / Stop / interface / knobs | All addressed. ValidateToken takes context.Context; fetchPublicKeys caps the body at 1 MiB; main.go calls validator.Stop() on shutdown; AuthConfig.Validator is now the TokenValidator interface; the retry/poll knobs are instance fields. |
| Carried-over cleanups | All addressed. Security headers plus a location = /config.json no-store block are in both nginx configs; ui-development.md is corrected; frontend-rewrite-plan.md is deleted; the test-auth.sh em dash is gone. |
Verification run: go build, go vet, go test -race -count=1 (all green), go mod tidy zero-diff, helm lint clean, helm template across all enable/override combinations, and confirmed the removed embedded UI leaves no dangling references.
Questions (need a maintainer answer)
KeyCreatedDialog.tsx:42offers a "Download .txt" of the plaintext key, which can sync to cloud backups. This is common for API-key UIs (AWS, Stripe), so likely intentional, but worth a conscious call.keycloak.ts:69setscheckLoginIframe:false, so the SPA will not detect a server-side single-logout until the next token refresh. The documented compensating control is a short realm access-token lifespan. Is the realm tuned for that? (That setting lives outside this diff.)
Suggestions (non-blocking, worth addressing)
- The OIDC issuer is expressed twice with nothing cross-validating them. The operator/model endpoint reads full-form
auth.oidc.issuerURL(.../realms/nebari); the SPA and key-manager read split-formfrontend.keycloak.url+frontend.keycloak.realmand reconstructissas<url>/realms/<realm>. After round 2 single-sourced the groups claim and the client ID, this is the last split source: a typo or an http/https drift silently makes the two auth paths trust different issuers. Deriving one from the other in_helpers.tpl, or afail/NOTES assertion that they describe the same realm, would close it. api.ts:32-36: the 401-retry does not force a refresh. It callsgetToken()again, which callsupdateToken(30), a no-op when the token still has more than 30s left, so it re-sends the identical bearer and reproduces the same 401. The comment says "force a refresh." EitherupdateToken(-1)before the retry, or escalate a second consecutive 401 tologin(). The error does surface to the UI, so this is not a hidden failure.main.tsx:16: the bootstrapawait initKeycloak()has no try/catch, so a malformed/config.json(a realistic operator misconfig) yields a blank white page. Render a fallback message instead.- The nginx
server{}block is duplicated byte-for-byte betweennginx.default.confandfrontend-configmap.yaml. They are in sync today, but they are two sources of truth to keep in lockstep; consider single-sourcing or a parity test. frontend.titleis dead plumbing: wired through values, the ConfigMap, and the TS type, but nothing reads it or setsdocument.title. Wire it up inmain.tsxor remove it.- The
pinClientAudiencefootgun is documented but not guarded. A{{- fail }}onpinClientAudience && not frontend.enabledwould mirror the round-1 NebariApp guard. Fine as-is since it is off by default. useUser()usesuseMemo(..., []), a one-time snapshot rather than reactive state, so name/email can go stale after a token refresh. Low impact.- Dead leftovers from the embedded-Go UI: the
vite.config.ts/logoutproxy and the exported-but-unusedgetAppConfig().
Nits (cosmetic)
architecture.md:371still annotatesprovider: keycloak # or generic-oidcwhile everything shipped is Keycloak-only (generic-oidc is #61). Drop the annotation.config.json's placeholderclientId: "nebari-frontend-spa"is a fourth distinct client-ID string that matches none of the three runtime sources. Align it or use an obvious sentinel.ui-development.mdsays Node 20+ but the Dockerfile and.node-versionpin 22.keycloak.yaml:12comment says logintestuser/testuserbut the realm definesdev/password.- The dev Keycloak user
devis in/llm-userswhile dev models are scoped to groupllm, so the real-auth path shows an empty model list. Align the realm group or the model scope. imagePullPolicy: Alwayson a pinned tag (preferIfNotPresent).- A few em dashes crept back into comments and markdown after round 2 cleaned them out (
values.yaml:134,184,key-manager-nebariapp.yaml:63,key-manager-deployment.yaml:82,frontend-configmap.yaml:69,80,README.md:291).
Note (accepted limitation, no change requested)
- Go: the concurrent-burst rotation case is fully fixed. One narrow case remains: a real rotation landing within 30s after a prior unknown-kid fetch can 401 the first requests until the cooldown clears (self-healing, at most 30s). An attacker could keep the cooldown warm to extend that first-request window to ~30s after each rotation. Bounded and low severity; flagging for awareness, and a one-line doc note would be enough if you want to record it.
Test coverage gaps (frontend)
auth/keycloak.tsandauth/user.tshave no direct tests, and they are the most security-relevant files.getToken()'s refresh-failure,SessionExpiredError, and missing-token branches are never exercised; existing tests always go through thefakeSession()shim.KeyCreatedDialog(secret display, copy, download) andRevokeKeyDialog(the one-click destructive action the clickjacking headers exist to protect) have no test files.api.ts's two-consecutive-401 path is untested, as areTopbarsign-out and theconfig.tserror path.
What is well done
- Go: the rotation fix uses the right claim, fails closed, and ships a genuine concurrent regression test; the refresh is off the request path; and
/healthzdeliberately does not couple readiness to Keycloak, so a slow IdP will not CrashLoop the pod. - Frontend: tokens are never persisted (in-memory only, confirmed against the Keycloak adapter docs;
localStorageholds only the theme string), PKCE S256, bearer auth with no CSRF surface, the minted secret lives only in an in-memory atom, and there is no XSS surface (dangerouslySetInnerHTML,innerHTML, andevalare all absent). The E2E auth-bypass branches are gated behindimport.meta.env.MODE !== "production", so they are dead-code-eliminated from the shipped bundle. - Helm: guards hold across every render combination, the pods are well hardened (non-root UID 101,
readOnlyRootFilesystem,drop: [ALL], seccompRuntimeDefault), and CI now builds, lints, and tests the frontend image with every third-party action pinned to a SHA.
No blockers. All round-2 blockers and questions are resolved, and the first frontend review surfaced only questions, cleanups, and test-coverage gaps.
|
In reference to the 2 questions... Re: KeyCreatedDialog.tsx:42 — "Download .txt" of the plaintext keyIntentional, and a conscious call. This mirrors the display-once secret pattern used by AWS (access key .csv), Stripe, GitHub PATs, etc.: the key is shown exactly once and cannot be retrieved later (KeyCreatedDialog.tsx:87,90-93), so we give the user a reliable way to capture it. On the cloud-backup concern specifically: the exposure is inherent to any "capture this secret now" flow rather than unique to the download button. The Copy path has the same property — clipboards sync too (macOS Universal Clipboard, cloud clipboard managers), and the download lands in whatever the user's browser download dir is, which they control. We're not writing to a predictable synced location ourselves. We've kept the guardrails that matter: destructive-styled warning that the key won't be shown again, the display-once server contract, and no persistence of the plaintext on our side. Net: we accept the tradeoff in favor of not stranding users who can't paste into a manager immediately. If we later want to be stricter we could drop the download and lean on Copy only, but that's a UX regression I don't think is worth it here. Re: keycloak.ts:69 — checkLoginIframe:false and single-logout detectionGood catch, and the compensating control is exactly the access-token lifespan. Worth spelling out the effective detection window: getToken() runs on every API request (lib/api.ts:18) and calls updateToken(30), whloak whenever the access token has <30s of validity left. If the SSO session wasterminated server-side, that refresh fails and we hit login() + SessionExpiredError (keycloak.ts:105-108). So for an active user, single-logout propagates within roughly one access-token lifespan of their next request — the iframe would only shorten the window for an i Action item, but out of scope for this PR: confirm/document the Nebari realm's accnot silently depending on the default. If ops has bumped it well above 5 min, we'dwant to reconsider re-enabling checkLoginIframe for idle-tab logout detection. |
Reference Issues or PRs
Fixes #92
What does this implement/fix?
Put a
xin the boxes that applyTesting
Documentation
Access-centered content checklist
Text styling
H1or#in markdown).Non-text content
Any other comments?
Screenshots (new)