Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions authbridge/authlib/plugins/jwtvalidation.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,37 @@ import (
// validate pattern.
type jwtValidationConfig struct {
// Issuer is the JWT `iss` claim expected on inbound tokens.
// In split-horizon deployments this is the PUBLIC Keycloak URL
// (whatever Keycloak stamps into the `iss` claim) — it only needs
// to match bit-for-bit, not be reachable from inside the pod.
Issuer string `json:"issuer"`

// JWKSURL points at the JWKS endpoint used to verify signatures.
// When empty, derived from Issuer using Keycloak's convention
// (/protocol/openid-connect/certs).
// The sidecar actually GETs this URL from inside the cluster, so
// in split-horizon deployments it must be the INTERNAL Keycloak
// URL — not the public hostname from Issuer, which typically
// won't resolve from inside the mesh.
//
// When empty, the URL is derived with this priority:
// 1. KeycloakURL + KeycloakRealm (Keycloak convention; the
// internal URL, when the operator supplies it)
// 2. Issuer (fallback for single-horizon deployments where the
// issuer hostname is reachable from inside the cluster)
JWKSURL string `json:"jwks_url"`

// KeycloakURL and KeycloakRealm are a convenience for deriving
// JWKSURL from the internal Keycloak service URL, symmetric with
// token-exchange's fields of the same name. Prefer supplying these
// over JWKSURL when you also want the usual split-horizon behavior
// (public Issuer + internal JWKS fetch).
//
// Pre-PR-#378 the binary derived jwks_url from outbound.token_url
// via a cross-plugin pass; per-plugin configs don't share state,
// so each plugin now carries its own copy of the "where is
// Keycloak internally" hint.
KeycloakURL string `json:"keycloak_url"`
KeycloakRealm string `json:"keycloak_realm"`

// Audience is the literal audience value expected on inbound
// tokens. One of {Audience, AudienceFile, AudienceMode:"per-host"}
// is required.
Expand Down Expand Up @@ -57,8 +81,19 @@ type jwtValidationConfig struct {
}

func (c *jwtValidationConfig) applyDefaults() {
if c.JWKSURL == "" && c.Issuer != "" {
c.JWKSURL = strings.TrimRight(c.Issuer, "/") + "/protocol/openid-connect/certs"
// JWKSURL derivation priority:
// 1. Explicit JWKSURL wins.
// 2. KeycloakURL + KeycloakRealm → internal Keycloak URL (the
// reachable host for JWKS fetching in split-horizon setups).
// 3. Issuer → same host as the token's iss claim (fine for
// single-horizon deployments; breaks split-horizon).
if c.JWKSURL == "" {
if c.KeycloakURL != "" && c.KeycloakRealm != "" {
base := strings.TrimRight(c.KeycloakURL, "/") + "/realms/" + c.KeycloakRealm
c.JWKSURL = base + "/protocol/openid-connect/certs"
} else if c.Issuer != "" {
c.JWKSURL = strings.TrimRight(c.Issuer, "/") + "/protocol/openid-connect/certs"
}
}
if c.AudienceMode == "" {
c.AudienceMode = "static"
Expand Down
93 changes: 90 additions & 3 deletions authbridge/authlib/plugins/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,101 @@ func TestJWTValidation_Configure_DefaultsJWKSFromIssuer(t *testing.T) {
if err != nil {
t.Fatalf("Configure: %v", err)
}
// The derived JWKS URL is applied during Configure — we can't
// inspect it directly because it's buried inside the verifier, but
// if the inner auth handler is nil we know Configure bailed.
// Issuer-derived fallback; verify the exact URL so a future refactor
// of the priority chain can't silently fall through to a 404 path.
if got, want := p.cfg.JWKSURL, "http://keycloak/realms/kagenti/protocol/openid-connect/certs"; got != want {
t.Errorf("JWKSURL = %q, want %q", got, want)
}
if p.inner == nil {
t.Fatal("Configure produced no inner auth handler")
}
}

// Split-horizon case: operator supplies a public `issuer` (for iss-claim
// matching) and internal `keycloak_url` + `keycloak_realm` (for reachable
// JWKS fetching). The derivation prefers the internal URL — deriving from
// issuer here would send the request into the public hostname which, in
// Kagenti's Kind/OpenShift setups, resolves to 127.0.0.1 and fails
// "connection refused" (see authbridge CLAUDE.md gotcha #2).
func TestJWTValidation_Configure_DerivesJWKSFromInternalKeycloakURL(t *testing.T) {
p := NewJWTValidation()
raw := []byte(`{
"issuer": "http://keycloak.localtest.me:8080/realms/kagenti",
"keycloak_url": "http://keycloak-service.keycloak.svc:8080",
"keycloak_realm": "kagenti",
"audience": "a"
}`)
if err := p.Configure(raw); err != nil {
t.Fatalf("Configure: %v", err)
}
want := "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/certs"
if got := p.cfg.JWKSURL; got != want {
t.Errorf("JWKSURL = %q, want %q (internal URL from keycloak_url+realm, not issuer)", got, want)
}
}

// Explicit jwks_url beats every derivation source. Operators who know
// exactly which endpoint to hit (e.g. a custom JWKS proxy) must not have
// it silently overwritten by the keycloak_url/realm derivation.
func TestJWTValidation_Configure_ExplicitJWKSURLWins(t *testing.T) {
p := NewJWTValidation()
raw := []byte(`{
"issuer": "http://keycloak.public:8080/realms/kagenti",
"jwks_url": "http://custom-jwks-proxy.example/keys",
"keycloak_url": "http://keycloak-internal:8080",
"keycloak_realm": "kagenti",
"audience": "a"
}`)
if err := p.Configure(raw); err != nil {
t.Fatalf("Configure: %v", err)
}
if got, want := p.cfg.JWKSURL, "http://custom-jwks-proxy.example/keys"; got != want {
t.Errorf("JWKSURL = %q, want %q (explicit jwks_url must win over derivations)", got, want)
}
}

// Only one of keycloak_url/keycloak_realm is supplied — derivation can't
// complete (Keycloak path needs both). Fall through to issuer-derivation
// rather than crashing or leaving JWKSURL empty.
//
// Covered both directions to catch a future refactor that ANDs the check
// differently (e.g. a short-circuit that treats empty realm as default
// would silently build a bogus URL).
func TestJWTValidation_Configure_PartialKeycloakConfigFallsThroughToIssuer(t *testing.T) {
cases := []struct {
name, raw string
}{
{
name: "keycloak_url without realm",
raw: `{
"issuer": "http://keycloak/realms/kagenti",
"keycloak_url": "http://internal:8080",
"audience": "a"
}`,
},
{
name: "keycloak_realm without url",
raw: `{
"issuer": "http://keycloak/realms/kagenti",
"keycloak_realm": "kagenti",
"audience": "a"
}`,
},
}
want := "http://keycloak/realms/kagenti/protocol/openid-connect/certs"
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := NewJWTValidation()
if err := p.Configure([]byte(tc.raw)); err != nil {
t.Fatalf("Configure: %v", err)
}
if got := p.cfg.JWKSURL; got != want {
t.Errorf("JWKSURL = %q, want %q (partial keycloak_* must not short-circuit issuer-derivation)", got, want)
}
})
}
}

func TestJWTValidation_Configure_AudienceFromFile(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "aud")
Expand Down
17 changes: 16 additions & 1 deletion authbridge/authproxy/authbridge-combined.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
# Default config for the combined AuthBridge sidecar image.
# Env vars are expanded at startup by the unified binary's config loader.
# These env vars match the operator's ConfigMap contract (authbridge-config).
# At runtime they land on the sidecar via envFrom on the Deployment —
# injected by the kagenti-operator admission webhook (or by the chart
# template for pre-declared namespaces). Keys are listed in
# authbridge/CLAUDE.md "Required ConfigMaps for Webhook Injection".
#
# Plugin settings live under pipeline.*.plugins[].config; see
# authbridge/authlib/plugins/CONVENTIONS.md for the per-plugin schema.
# Fields omitted below fall back to plugin defaults — notably:
# jwt-validation: audience_file=/shared/client-id.txt, bypass_paths
# = .well-known/* + health/ready/live probes,
# jwks_url derived from issuer
# jwks_url derived from keycloak_url + keycloak_realm
# (internal URL; see split-horizon note below).
# token-exchange: identity file paths under /shared/, jwt_svid_path
# =/opt/jwt_svid.token, routes.file
# =/etc/authproxy/routes.yaml
#
# Split-horizon DNS note: `issuer` is the PUBLIC Keycloak URL (matches
# the `iss` claim Keycloak stamps on tokens); `keycloak_url` is the
# INTERNAL service URL that the sidecar actually reaches. The
# jwt-validation plugin uses the latter to build its JWKS fetch URL,
# which is why KEYCLOAK_URL has to be passed into inbound as well as
# outbound — even though pre-PR-#378 the binary derived jwks_url from
# outbound.token_url via a cross-plugin pass.
mode: envoy-sidecar

pipeline:
Expand All @@ -19,6 +32,8 @@ pipeline:
- name: jwt-validation
config:
issuer: "${ISSUER}"
keycloak_url: "${KEYCLOAK_URL}"
keycloak_realm: "${KEYCLOAK_REALM}"
outbound:
plugins:
- name: token-exchange
Expand Down
Loading