Skip to content

Auth0 WIP #2: fail-fast config gate, JWKS rotation/outage handling, version-controlled Post-Login Action - #98

Merged
NovrusShehaj merged 14 commits into
mainfrom
chore/Auth0-Improvement
Sep 14, 2026
Merged

NovrusShehaj merged 14 commits into
mainfrom
chore/Auth0-Improvement

Conversation

@jcschaff

Copy link
Copy Markdown
Contributor

Summary

Auth0 WIP #2 — hardens the authentication path landed in #96 so that an
identity-provider hiccup, a rotated signing key, or a half-configured cluster
each produce a correct and legible outcome instead of a silent one.

Stacked on #97 (chore/keycloak-tests) — this PR's base is that branch, so
the diff here is the four commits on top of it. Merge #97 first; GitHub will
retarget this to main automatically.

What changed

1. Startup gate instead of a startup warning (e799d9a)

_warn_if_auth0_misconfigured() logged a warning and let the pod start, so a
cluster with a missing AUTH0_DOMAIN/AUTH0_AUDIENCE reported healthy and then
failed every authenticated request. The warning also misdescribed the failure
(it promised a 401, which was never what happened).

  • Auth0Settings.configuration_errors() (config.py) — pure, side-effect-free
    enumeration of every reason the settings could not verify a token. Accepts
    both valid shapes: a bare AUTH0_DOMAIN, or explicit AUTH0_ISSUER and
    AUTH0_JWKS_URI overrides (how a non-Auth0 OIDC provider, e.g. the Keycloak
    test realm, is configured). Half of the override pair is reported as an error.
  • _validate_auth0_configuration() (api/main.py) raises out of lifespan, so
    uvicorn exits non-zero and Kubernetes shows CrashLoopBackOff with the reason
    in kubectl logs.
  • New AUTH_REQUIRED setting (default true) is the escape hatch: set it false
    to run a deployment deliberately without an identity provider — the API then
    starts, logs what is missing, and every authenticated endpoint returns 503.
  • kustomize/config/biosim-{local,rke}/api.env get AUTH0_DOMAIN +
    AUTH0_AUDIENCE (biosim-gke already had them from Auth0-Integration #96), so those overlays do
    not start crash-looping the moment the gate lands.

2. JWKS handling: rotation, outages, and malformed key sets (e3db45b, de31e3e)

_get_jwks() previously refetched on every miss, raised on any failure, and
indexed k["kty"]/["kid"]/["use"]/["n"]/["e"] directly — an entry missing the
RFC 7517-optional use field raised KeyError → HTTP 500.

  • Unknown kid forces one refresh (cooldown-guarded, 60s) before rejecting
    the token. Auth0 rotates signing keys without notice; this turns a rotation
    from an hour-long outage into one slow request. The cooldown is load-bearing:
    without it a flood of bogus kids is an amplification vector against the IdP.
  • Stale-while-revalidate: a cached document past its 1h TTL is still served
    for up to 24h while refreshes fail, then refused. Well inside Auth0's rotation
    overlap, so a key cached in that window is still a key the tenant published.
  • Negative cache (10s) after a failed fetch: one outbound request per window
    per process, not one per inbound request.
  • Single-flight refresh via asyncio.Lock with the double-checked pattern
    already used in auth0_management.py. The lock is held across the fetch only,
    never across jwt.decode, so validation stays parallel.
  • _select_rsa_key() guards every field access and defaults a missing use to
    "sig".

3. Error responses and diagnostics (95c3383)

  • When no usable key set exists, the response is 503 + Retry-After
    ("Authentication temporarily unavailable"), not a 401 — the caller's token was
    never the problem. Detail text names no URL, no exception, no token material.
  • get_optional_user no longer swallows the 503. Downgrading an
    authenticated caller to anonymous during an Auth0 outage silently changes the
    authorization outcome (ownership checks, role gates). 401s stay swallowed —
    a bad token on an optional-auth endpoint is still just "not authenticated".
  • The rejected-kid log line deliberately does not echo the kid; it comes
    from an unverified, attacker-controlled header.
  • _warn_roles_claim_absent() — rate-limited (5 min) runtime assertion that the
    Post-Login Action is live. Without it, an absent Action means every
    require_roles endpoint 403s and no admin exists, presenting as a permissions
    bug with no signal anywhere.

4. The Auth0 Action is now version-controlled (e799d9a)

auth0/actions/post-login.js + auth0/README.md. #96 depended on a Post-Login
Action that existed only as dashboard state; this is the reviewed source of
truth the dashboard is expected to match. The README documents the required
Roles, the M2M application and its exact scopes (read:roles,
create:role_members — kept separate from the update:users/delete:users
application /api/v1/me will need), the Action secrets, the auth0 dependency,
the flow binding, and a post-deploy smoke check. Nothing in auth0/ is
deployed by CI or kubectl
— applying it is a dashboard action.

Backend CLAUDE.md and .env.example gain matching Authentication sections.

Tests

All new, all against real tokens or real HTTP behavior — no mocked JWT
verification:

File Covers
tests/api/test_startup_auth_config.py the startup gate, both AUTH_REQUIRED modes, each malformed-config shape
tests/common/test_auth0_jwks.py TTL, stale-while-revalidate bound, negative cache, single-flight, malformed key sets
tests/common/test_auth0_reliability.py rotation recovery, forced-refresh cooldown, outage behavior
tests/common/test_auth0_roles_claim.py roles-claim absent / empty / wrong-type
tests/api/test_auth_error_responses.py 401 vs 503 status/headers/detail, get_optional_user propagation
tests/fixtures/jwks_fixtures.py locally generated RSA key sets for the JWKS tests

Review notes

Two things worth a look before merging:

  • .mcp.json is committed at the repo root and points at a machine-local
    PyCharm MCP endpoint (http://127.0.0.1:64462/stream). That port is specific
    to one developer's IDE session — it probably belongs in .gitignore (as
    .vscode/.cursor are in this same commit) rather than in the repo.
  • _warn_if_auth0_misconfigured() is commented out rather than deleted
    wrapped in a """ block in api/main.py, with the call site left as a
    comment. It is fully replaced by _validate_auth0_configuration(); worth
    deleting outright.

NovrusShehaj and others added 6 commits August 25, 2026 11:11
Commits the previously-uncommitted P1 tail and the net-new P2 auth work,
which were interleaved in the same files and could not be cleanly split.

P2 items landed and verified (127 auth tests pass; ruff + mypy clean):
- #21 signed token with no usable `sub` -> 401 (guarded, never 500)
- #17 60s clock-skew leeway on jwt.decode (single commented constant)
- #16 OIDC discovery (discovery.py) with explicit timeout + single-flight;
  best-effort lifespan warm-up, convention fallback, never fails startup
- #22b python-jose floor bumped >=3.5.0 (lock re-resolved; specifier-only)
- #22c update_auth0_user explicit signature; no **fields splat to Auth0
- #24 DI/testability seam: get_auth0_settings + get_jwks_cache + JwksCache;
  tests no longer mutate the settings singleton or a module-global cache
  (order-independent, both directions green)
- #19a structured JSON logging + per-outcome events; sub SHA-256 hashed and
  truncated; parametrised leak guard proves no token/email/raw-sub in output
- #19b optional-auth invalid-token visible at INFO, distinct from no-token
- #19c /ready reports JWKS-cache health as a non-gating info.auth field
  (no outbound Auth0 call; ok stays MongoDB + Temporal)
- #20 rbac_demo router gated behind ENABLE_RBAC_DEMO (default false; 404 +
  absent from OpenAPI in prod; test env enables it for the Keycloak suite)
- #18 negative-path coverage traced; missing-sub + leeway gaps closed
- Decisions register (backend/docs/auth0-p2-decisions.md): D-5/D-6/D-9/D-10
  ratified + implemented; D-11 partial; D-1/D-2/D-3/D-4/D-7/D-8 left OPEN

Deferred / not in this commit:
- #23 retry/backoff (blocked on D-8), #23 config execution (D-1/D-7)
- #22 account-deletion data lifecycle (blocked on D-2/D-3, data-protection)
- #19 counters + histogram (blocked on D-4, no metrics backend)
- #26 backend/docs/authentication.md (write last, after the above settle)

Config: non-secret Auth0 + rate-limit values added to per-cluster ConfigMaps
(no credential values). .junie added to .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4aSVUpjs9QXRtPzqSr4aQ
The three Management API resource calls previously did one attempt and
collapsed every failure to 502, so a transient 429/5xx failed the request
and a rate-limit was indistinguishable from an outage.

_send_with_retry now wraps get/update/delete_auth0_user with bounded
exponential backoff:
- 3 attempts (1 + 2 retries), base 0.5s x2 full-jitter, 15s total deadline
- retries 429, 5xx, and httpx transport errors only; any other 4xx is
  returned unretried so raise_for_status() still surfaces it
- honours a 429 Retry-After verbatim, clamped to 30s and the deadline
- exhausted 429 -> Auth0ManagementRateLimited -> HTTP 503 + Retry-After
  (matches the cold-cache JWKS "try again shortly" shape); exhausted
  5xx/transport -> Auth0ManagementUnavailable -> HTTP 502
- WARN logs name only op/status/attempt/exception-type; never the bearer
  token, the client secret, or a response body (leak-guard test asserts it)

The token cache/lock is left byte-for-byte unchanged (EH-11).

D-8 was OPEN (team, no source steer); ratified as an engineering decision
and recorded in backend/docs/auth0-p2-decisions.md. CLAUDE.md failure-mode
table updated with the 503/502 rows.

Tests: tests/common/test_auth0_management_retry.py (8 cases via
httpx.MockTransport, no network, asyncio.sleep stubbed) + existing
tests/users/test_router.py mapping tests. ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4aSVUpjs9QXRtPzqSr4aQ
… for verification endpoints and adding rate limiting for compatibility checks. Updated documentation to reflect changes in endpoint access and requirements. Introduced owner-sub tracking for workflows and refined error handling for archive URL validation to prevent SSRF vulnerabilities.
…tributes. Update database service methods to support owner-specific queries and improve logging for workflow initiation. Refactor file upload logic to ensure proper visibility assignment based on user authentication status. Update relevant models and API endpoints to accommodate these changes, ensuring backward compatibility with legacy records.
Base automatically changed from chore/keycloak-tests to main September 11, 2026 22:02
NovrusShehaj and others added 4 commits September 14, 2026 11:03
Resolve conflicts against current main (which now includes PR #97):
- .gitignore: union of editor/local ignores and main's .postman rules
- backend/CLAUDE.md: keep Auth0 outage, rendered-config and auth smoke
  guidance inside main's Flux two-PR release/deploy procedure
- compatibility/router.py: keep the rate-limit Depends and SSRF checks;
  keep main's Optional import, still used by archive_url
- simulations/database.py: _coerce_date(value: object) -> datetime, which
  raises InvalidDateFilterError on bad input
- tests/api/test_main.py: keep the BiosimulatorVersion import (still used)
- tests/simulations/test_router.py: keep ownership/visibility tests and
  add main's logs 404 test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Stop tracking the machine-local root .mcp.json (PyCharm loopback MCP
  endpoint) and ignore it with a root-only /.mcp.json rule.
- Delete the discarded triple-quoted _warn_if_auth0_misconfigured
  implementation and its commented-out lifespan call. The active
  _validate_auth0_configuration startup gate is unchanged.
- smoke: start the backend with AUTH_REQUIRED=false. The job runs without
  an IdP, so the Auth0 startup gate refused to boot and every later step
  failed. Production kustomize config is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main's new tests assumed the verify endpoints were anonymous and did not
know about the demo:read permission endpoint, so they failed once merged
with this branch's auth contract:
- test_main: authenticate test_get_output_not_found and
  test_verify_omex_unknown_simulator; their 404/400 assertions are kept.
- test_openapi_endpoints: classify verify-omex, get-verify-output and
  verify-runs as auth-required and add demo-private-permission; run the
  verify-omex 422 validation probe as an authenticated caller; keep the
  verify-runs PENDING-workflow assertions as an authenticated test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NovrusShehaj

Copy link
Copy Markdown
Collaborator

Thanks Jim, both review notes are addressed.

  • Removed the tracked root .mcp.json and added a root-scoped /.mcp.json rule to .gitignore. The file is no longer tracked, while unrelated MCP configuration remains unaffected.
  • Deleted the obsolete commented _warn_if_auth0_misconfigured() implementation and its commented lifespan call. The active _validate_auth0_configuration() startup gate is unchanged.

I also resolved the conflicts after PR #97 was merged and the PR was retargeted to main. Validation is complete: the full backend suite passes with 801 tests passing, the Keycloak integration tests pass, and backend/frontend CI, smoke tests, GitGuardian, and Snyk are all green.

@NovrusShehaj
NovrusShehaj merged commit 23f406b into main Sep 14, 2026
5 checks passed
@NovrusShehaj
NovrusShehaj deleted the chore/Auth0-Improvement branch September 14, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants