Skip to content

feat: Token Validation Hardening #355

Description

@quotentiroler

Context

Raised by @hamedmishian (TU Dresden, Institut für Medizinische Informatik und Biometrie) who is deploying Proxy Smart alongside a separate Next.js clinical application (ophthalmic demo app), both sharing one Keycloak instance behind nginx on separate subdomains.

Related projects:

Problem

In the current single-realm architecture, Keycloak's SSO causes session leakage across co-deployed applications. When a doctor logs into the clinical app, the shared SSO cookie automatically authenticates them in the Proxy Smart admin UI — no credentials prompt, no way to block it at the frontend level.

Hamed's workaround: split Keycloak into two realms (admin realm for Proxy Smart, clinical realm for SMART apps), with a service account bridge for cross-realm admin operations.

Security Gaps Found

  1. No issuer verificationjwt.verify() had no issuer constraint. Any token signed by the configured realm's keys was accepted.
  2. Stale JWKS client — JWKS client was created once at module load; reconfiguring the realm via /admin/keycloak-config left it pointing at the old realm's keys.
  3. Missing SMART aud validation — SMART App Launch 2.2.0 requires the resource server to validate the aud parameter from the authorization request matches its own FHIR endpoint. This was not implemented, nor was aud even in the authorization query schema.

Industry Best Practice: Single-Realm Multi-Tenancy

Research of the Keycloak ecosystem shows the community strongly favors single-realm models:

  • Keycloak 26.x Organizations (GA) — native multi-tenancy within a single realm: per-org IdPs, per-org auth flows, org-scoped tokens
  • p2-inc/keycloak-orgs (526★) — explicitly tried and rejected "one realm per tenant" due to scale/frailty trade-offs
  • anarsultanov/keycloak-multi-tenancy (171★) — tenants as entities within a single realm

Realm-per-tenant becomes fragile as apps grow (3rd app = 3rd realm or more complex routing), and cross-realm admin requires service account bridges that add operational burden.

Changes

Phase 1: Token Validation Hardening ✅

  • Add expectedIssuer getter to config.keycloak (match against configured realm's issuer URL)
  • Add issuer verification to jwt.verify() in auth.ts
  • Lazy JWKS client that re-creates when realm/URL changes at runtime (fixes stale client after /admin/keycloak-config reconfiguration)
  • Fix global logger mock leak in pdf-extract-opendataloader.test.ts (was causing 100 test failures via mock.module poisoning)

Phase 2: SMART aud Parameter Validation ✅

  • Add aud, resource (RFC 8707 synonym), and launch params to AuthorizationQuery schema
  • Validate aud at /authorize endpoint against configured FHIR server endpoints
  • Return 400 invalid_request if aud doesn't match any known FHIR endpoint
  • Prevents token leakage to counterfeit resource servers (SMART App Launch 2.2.0 spec requirement)

Note: OIDC aud claim validation (the JWT aud field) was evaluated and intentionally skipped — Keycloak sets aud to "account" by default, not a meaningful value. Custom audiences require configuring a Keycloak protocol mapper per client. The SMART aud validation above addresses the actual spec requirement.

Phase 3: Documentation

  • Document Keycloak Organizations-based multi-tenant setup as recommended approach
  • Document SSO session isolation options (separate browser profiles, prompt=login, etc.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions