Skip to content

feat(plugins): Add keycloak_url + keycloak_realm to jwt-validation for internal JWKS derivation#383

Merged
pdettori merged 1 commit into
rossoctl:mainfrom
huang195:feat/jwt-validation-keycloak-url
May 8, 2026
Merged

feat(plugins): Add keycloak_url + keycloak_realm to jwt-validation for internal JWKS derivation#383
pdettori merged 1 commit into
rossoctl:mainfrom
huang195:feat/jwt-validation-keycloak-url

Conversation

@huang195

@huang195 huang195 commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Per-plugin cutover (PR #378) silently dropped the cross-plugin derivation that let inbound.jwks_url be derived from outbound.keycloak_url + keycloak_realm. Without that, jwt-validation falls back to deriving jwks_url from issuer — which in split-horizon deployments is the public URL (required to match the iss claim bit-for-bit) and typically isn't reachable from inside the sidecar pod. Result: every inbound JWT validation hits connection refused fetching the JWKS → 401.

Observed failure in rossoctl/rossoctl#1507 CI, weather-tool-advanced envoy-proxy stderr:

msg="JWT validation failed" error="fetching JWKS:
  Get \"http://keycloak.localtest.me:8080/realms/kagenti/protocol/openid-connect/certs\":
  dial tcp [::1]:8080: connect: connection refused"

This restores feature parity with pre-#378 semantics by giving jwt-validation its own keycloak_url + keycloak_realm fields — symmetric with token-exchange, which already has them.

Changes

  • authlib/plugins/jwtvalidation.go: two new config fields, updated applyDefaults() derivation priority:
    1. Explicit jwks_url wins
    2. keycloak_url + keycloak_realm → internal URL
    3. issuer → fallback (single-horizon; existing behavior)
  • authlib/plugins/plugins_test.go: tightened existing DefaultsJWKSFromIssuer to assert exact URL; added DerivesJWKSFromInternalKeycloakURL, ExplicitJWKSURLWins, PartialKeycloakConfigFallsThroughToIssuer.
  • authproxy/authbridge-combined.yaml (image default): pass KEYCLOAK_URL / KEYCLOAK_REALM into inbound jwt-validation as well as outbound. Block comment explains split-horizon expectation.

Pipeline config change

Before (chart/operator had to hand-roll the full URL):

pipeline:
  inbound:
    plugins:
    - name: jwt-validation
      config:
        issuer: "http://keycloak.public:8080/realms/kagenti"
        jwks_url: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/certs"

After:

pipeline:
  inbound:
    plugins:
    - name: jwt-validation
      config:
        issuer: "http://keycloak.public:8080/realms/kagenti"
        keycloak_url: "http://keycloak-service.keycloak.svc:8080"
        keycloak_realm: kagenti

Both plugins now take the same shape for "where is Keycloak internally."

Backward compatibility

Purely additive:

  • Configs with only issuer still work (single-horizon).
  • Configs with explicit jwks_url still win (custom JWKS proxies).
  • Plugin configs remain isolated — the new fields decode independently from token-exchange's copies.

Follow-ups

  • kagenti-operator: update synthesizePipeline to pass keycloak_url + keycloak_realm through to jwt-validation (already in NamespaceConfig). New alpha tag.
  • kagenti chart (kagenti#1507): drop the temporary explicit jwks_url workaround once both new pins are available.

Test plan

  • go test ./... in authlib/ — all green, including 3 new tests
  • go test ./... in cmd/authbridge/ — all green
  • kagenti-operator PR ingests new extensions tag → synthesizePipeline emits new fields
  • kagenti chart PR #1507 re-run with new extensions + operator pins → advanced E2E passes

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

## Problem

Pre-PR-rossoctl#378 the binary ran `config.Resolve()` → `deriveJWKSURL()` to
derive `inbound.jwks_url` from `outbound.token_url` (which itself came
from `keycloak_url` + `keycloak_realm` — the INTERNAL service URL).
After the per-plugin cutover, each plugin decodes its config in
isolation, so jwt-validation no longer sees token-exchange's fields.
Its only fallback is to derive jwks_url from its own `issuer` — but
`issuer` is the PUBLIC hostname that appears in token `iss` claims,
which in split-horizon deployments (Kagenti's typical Kind and
OpenShift setups) isn't reachable from inside the sidecar pod.

Observed failure (CI run kagenti/kagenti#25525390807, weather-tool-
advanced envoy-proxy stderr):

    msg="JWT validation failed" error="fetching JWKS:
      Get \"http://keycloak.localtest.me:8080/realms/kagenti
      /protocol/openid-connect/certs\":
      dial tcp [::1]:8080: connect: connection refused"

Every inbound request → 401. Authbridge Weather (advanced) E2E broke.

## Fix

Add `keycloak_url` + `keycloak_realm` fields to jwt-validation's
config, symmetric with token-exchange's fields of the same name.
`applyDefaults()` now derives `jwks_url` with this priority:

  1. Explicit jwks_url wins
  2. keycloak_url + keycloak_realm (the internal URL)
  3. Issuer (single-horizon fallback — existing behavior)

Chart/operator/backend callers pass `keycloak_url` + `keycloak_realm`
that they already know; the plugin figures out the JWKS path. No one
has to hand-roll the full URL anymore.

## Scope

- `jwtvalidation.go` — two new fields, updated applyDefaults comment
- `plugins_test.go` — 4 new tests + tightened existing JWKS test to
  assert the exact derived URL (so a future refactor of the priority
  chain can't silently fall through to a 404 path). Partial-keycloak
  fall-through is covered in both directions (url-without-realm and
  realm-without-url) to catch a future AND-check refactor.
- `authbridge-combined.yaml` — pass KEYCLOAK_URL/KEYCLOAK_REALM into
  inbound jwt-validation as well as outbound token-exchange. Header
  comment explains where the env vars land from (envFrom on the
  Deployment, injected by operator/chart).

## Backward compatibility

Purely additive:
- Existing configs with only `issuer` still parse and work
  (single-horizon deployments unaffected).
- Existing configs with explicit `jwks_url` still win
  (custom JWKS proxies unaffected).
- New fields share namespace with token-exchange but decode
  independently — plugin configs remain isolated.

## Follow-ups

Once this lands and a new extensions tag ships:

- kagenti-operator: update `synthesizePipeline` to pass
  keycloak_url + keycloak_realm through to jwt-validation (it already
  has them in NamespaceConfig). Release a new operator alpha tag.
- kagenti chart: drop the temporary explicit `jwks_url` added in
  kagenti#1507 once both new pins are available.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 force-pushed the feat/jwt-validation-keycloak-url branch from e30bd6c to 76147af Compare May 7, 2026 23:29

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, focused PR that restores split-horizon JWKS derivation lost during the per-plugin cutover in PR #378.

The three-tier priority logic in applyDefaults() is correct and well-tested (4 tests covering all paths including partial-config fallthrough). YAML template aligns with the Go struct fields.

Areas reviewed: Go, Tests, YAML, Security
Commits: 1 commit, signed-off ✓
CI: all passing (17/17 checks green)

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants