Skip to content

Instantly share code, notes, and snippets.

@bartoszmajsak
Last active May 6, 2026 14:43
Show Gist options
  • Select an option

  • Save bartoszmajsak/215e00861943d1450e329fee65e91275 to your computer and use it in GitHub Desktop.

Select an option

Save bartoszmajsak/215e00861943d1450e329fee65e91275 to your computer and use it in GitHub Desktop.
Multi-Cluster Auth Design Analysis: odh-model-controller wristband token exchange, weaknesses, SPIFFE/SPIRE comparison, and proposed Authorino enhancements

Authorino Scaling Analysis: Wristband Token Path

Performance analysis of Authorino's wristband token issuance and JWKS serving paths, with concrete fixes and estimated improvement factors.

Based on code analysis of the Authorino codebase in the context of the multi-cluster auth design for odh-model-controller.

The Hot Path

For a wristband token request (GET /api/v1/auth/token), the auth pipeline executes:

  1. Identity: kubernetesTokenReview (K8s API call) + condition checks on wristband-user and any external JWT evaluators
  2. Authorization: skipped for token path (conditions exclude it), but conditions still evaluated
  3. Response: 5 response headers each with when conditions + wristband Call() with custom claims

Each condition check and each custom claim resolution calls GetAuthorizationJSON(), which triggers a full serialize/deserialize cycle.

Issue 1: GetAuthorizationJSON() Called ~11 Times Per Request

Files: pkg/service/auth_pipeline.go:592-669, pkg/expressions/cel/expressions.go:39-88

What happens on each call

GetAuthorizationJSON()
  ├─ RLock 5 pipeline maps (Identity, Metadata, Authorization, Response, Callbacks)
  ├─ Copy all map entries
  ├─ json.Marshal entire pipeline state → string        [alloc + serialize]
  └─ return string

Then the caller (CEL predicate or expression) does:
AuthJsonToCel(json)
  ├─ protojson.Unmarshal(json) → structpb.Struct        [alloc + deserialize]
  └─ Extract 5 top-level fields

Then for expressions (not predicates):
ValueToJSON(result)
  └─ protojson.Marshal(result) → string                 [alloc + serialize]

That's 2-3 serialization cycles per call. With ~11 calls per wristband token request:

Call site Count Trigger
AuthConfig-level conditions 1 pipeline.Evaluate() entry
wristband-user identity condition 1 request.path != '/api/v1/auth/token'
inference-access authz condition 1 path exclusion check
inference-access-delegate authz condition 1 path exclusion + header check
x-gateway-inference-fairness-id response condition 1 (no condition, but wired)
x-gateway-inference-objective response condition 1 (no condition)
x-maas-user response condition 1 path check
x-maas-groups response condition 1 path check
x-wristband-token response condition 1 request.path == '/api/v1/auth/token'
Wristband cache key resolution 1 response.go:85
Wristband custom claims 1 wristband.go:120 (single call, resolves all claims)

~11 calls x 2-3 serialization cycles = ~25-33 serialize/deserialize operations per request.

Fix: Cache authorization JSON per phase

type AuthPipeline struct {
    // ...existing fields...
    authJSONCache     string
    authJSONCacheDirty bool
}

func (pipeline *AuthPipeline) GetAuthorizationJSON() string {
    pipeline.mu.RLock()
    if !pipeline.authJSONCacheDirty && pipeline.authJSONCache != "" {
        defer pipeline.mu.RUnlock()
        return pipeline.authJSONCache
    }
    pipeline.mu.RUnlock()

    pipeline.mu.Lock()
    defer pipeline.mu.Unlock()
    // double-check after acquiring write lock
    if !pipeline.authJSONCacheDirty && pipeline.authJSONCache != "" {
        return pipeline.authJSONCache
    }
    pipeline.authJSONCache = pipeline.buildAuthorizationJSON()
    pipeline.authJSONCacheDirty = false
    return pipeline.authJSONCache
}

// Invalidate cache when phase results change
func (pipeline *AuthPipeline) setIdentityObj(conf *evaluators.IdentityConfig, obj interface{}) {
    pipeline.mu.Lock()
    defer pipeline.mu.Unlock()
    pipeline.Identity[conf] = obj
    pipeline.authJSONCacheDirty = true
}
// ...same for setMetadataObj, setAuthorizationObj, setResponseObj, setCallbackObj

Within a single phase (e.g., all response evaluators), the pipeline state doesn't change - all response evaluators run against the same snapshot. The cache is only invalidated when a new phase writes results.

Estimated improvement: 11 calls → 3-4 actual serializations (one per phase transition). ~3x reduction in JSON serialization per request. At 1000 req/s, this eliminates ~7000-8000 unnecessary serialize/deserialize cycles per second.

Alternative fix: Pass CEL inputs directly, bypass JSON round-trip

The deeper issue is that GetAuthorizationJSON() serializes Go structs to JSON, then AuthJsonToCel() deserializes JSON back to protobuf structs, just to pass data to CEL. This is a Go map → JSON string → protobuf struct round-trip on every evaluation.

A more invasive but higher-impact fix: build the CEL input (map[string]interface{}) directly from the pipeline maps without the JSON intermediate representation.

func (pipeline *AuthPipeline) GetCELInput() (map[string]interface{}, error) {
    // Build protobuf structs directly from pipeline maps
    // Skip json.Marshal + protojson.Unmarshal entirely
}

This would require changing the Matches(json string) and ResolveFor(json string) signatures to accept structured input, which is a larger refactor but eliminates the serialization entirely.

Estimated improvement: 5-10x for CEL-heavy AuthConfigs. The JSON round-trip dominates the cost of simple predicate evaluations like request.path == '/api/v1/auth/token'.


Issue 2: JWKS Recomputed on Every HTTP Request

Files: pkg/evaluators/response/wristband.go:170-186, pkg/service/oidc.go:54-65

What happens

GET /.well-known/openid-connect/certs
  └─ OidcService.ServeHTTP()
       └─ wristband.JWKS()
            ├─ for each signingKey:
            │    └─ signingKey.Public()     [crypto: extract public key from private]
            ├─ allocate []jose.JSONWebKey
            └─ json.Marshal(jwks)           [serialize]

No caching. No ETag. No Cache-Control headers. Every request recomputes from scratch.

With N peer clusters polling every 30s, that's N requests/30s, each doing crypto + JSON serialization for an output that only changes on AuthConfig reconciliation.

Fix: Memoize JWKS output

type Wristband struct {
    Issuer        string
    CustomClaims  []json.JSONProperty
    TokenDuration int64
    SigningKeys   []jose.JSONWebKey

    // Cached outputs - computed once, invalidated on key change
    jwksCache      string
    jwksCacheOnce  sync.Once
    oidcConfigCache string
}

func (w *Wristband) JWKS() (string, error) {
    var err error
    w.jwksCacheOnce.Do(func() {
        publicKeys := make([]jose.JSONWebKey, 0, len(w.SigningKeys))
        for _, signingKey := range w.SigningKeys {
            publicKeys = append(publicKeys, signingKey.Public())
        }
        jwks := jose.JSONWebKeySet{Keys: publicKeys}
        var encoded []byte
        encoded, err = gojson.Marshal(jwks)
        if err == nil {
            w.jwksCache = string(encoded)
        }
    })
    return w.jwksCache, err
}

Since Wristband instances are created during AuthConfig reconciliation and replaced atomically (new reconcile = new instance), sync.Once is safe - the cached value is valid for the entire lifetime of the instance.

Add HTTP cache headers in OidcService.ServeHTTP():

case "/.well-known/openid-connect/certs":
    responseBody, err = wristband.JWKS()
    if err == nil {
        // JWKS only changes on AuthConfig reconciliation
        writer.Header().Set("Cache-Control", "public, max-age=300")
        writer.Header().Set("ETag", wristband.JWKSETag())
    }

Estimated improvement: For JWKS serving, O(1) instead of O(keys) per request. At 10 clusters polling every 30s with 5 signing keys each, this eliminates 50 crypto operations and 10 JSON serializations per 30-second window. The Cache-Control header lets clients skip requests entirely.


Issue 3: SHA-256 Identity Hash Redundant with JSON Serialization

File: pkg/evaluators/response/wristband.go:102-105

What happens

idStr, _ := gojson.Marshal(resolvedIdentityObject)   // serialize identity to JSON
hash := sha256.New()
hash.Write(idStr)                                      // hash the JSON
sub := fmt.Sprintf("%x", hash.Sum(nil))                // hex-encode

The identity object is JSON-serialized here, then GetAuthorizationJSON() serializes the entire pipeline (including the identity) again for custom claim resolution at line 120. Two marshals of the same identity object within 18 lines.

Fix: Derive sub from already-serialized data

Since GetAuthorizationJSON() will be called anyway for custom claims, extract the identity portion from it instead of marshaling separately:

func (w *Wristband) Call(pipeline auth.AuthPipeline, ctx context.Context) (interface{}, error) {
    resolvedIdentity, _ := pipeline.GetResolvedIdentity()
    // ...issuer check...

    authJSON := pipeline.GetAuthorizationJSON()  // will be cached per Issue 1 fix

    // Derive sub from the identity portion of authJSON
    identityJSON := gjson.Get(authJSON, "auth.identity").Raw
    hash := sha256.Sum256([]byte(identityJSON))
    sub := fmt.Sprintf("%x", hash[:])

    // ...rest of claims using authJSON...
}

This eliminates one json.Marshal call per wristband issuance.

Estimated improvement: ~10-15% reduction in wristband issuance latency by eliminating one redundant JSON serialization of the identity object.


Issue 4: No Backpressure on K8s API Calls

Files: pkg/service/auth.go:238-309, main.go:72

What happens

The gRPC server allows MaxConcurrentStreams: 10000. Each wristband token request triggers a kubernetesTokenReview (HTTP call to K8s API server). With no admission control, 10,000 concurrent token requests means 10,000 concurrent TokenReview API calls.

The K8s API server has its own rate limiting, but Authorino doesn't respect it - failed TokenReviews bubble up as auth failures rather than backpressure signals.

Fix: Add a semaphore for K8s API calls

// In main.go or a shared config
var k8sAPISemaphore = make(chan struct{}, 100) // limit concurrent K8s API calls

// In identity/kubernetes.go, wrap the TokenReview call
func (k *KubernetesAuth) Call(pipeline auth.AuthPipeline, ctx context.Context) (interface{}, error) {
    select {
    case k8sAPISemaphore <- struct{}{}:
        defer func() { <-k8sAPISemaphore }()
    case <-ctx.Done():
        return nil, ctx.Err()
    }
    // ...existing TokenReview logic...
}

Same pattern for SubjectAccessReview in authorization evaluators.

Estimated improvement: Prevents K8s API server saturation under load spikes. No throughput improvement under normal load, but prevents cascading failures at >100 concurrent requests (configurable). The difference between "auth latency increases under load" and "K8s API server becomes unresponsive for the entire cluster."


Issue 5: Index Global RWMutex Under Write Contention

File: pkg/index/index.go:50-66

What happens

Every auth request does index.Get(host) (read lock). Every OIDC request does index.FindKeys(realm) + index.Get(host) (two read locks). AuthConfig reconciliation does index.Set() or index.Delete() (write lock).

During reconciliation (write lock held), all concurrent auth and OIDC requests block. With 28 InferenceServices triggering AuthConfig reconciliation in rapid succession (as seen in RHOAISUP-25), that's sustained write lock contention.

Fix: Copy-on-write index

type authConfigTree struct {
    current atomic.Pointer[treeSnapshot]  // lock-free reads
    mu      sync.Mutex                     // writes only
}

type treeSnapshot struct {
    root *treeNode
    keys map[string][]string
}

func (c *authConfigTree) Get(key string) *evaluators.AuthConfig {
    snapshot := c.current.Load()  // atomic, no lock
    if entry := snapshot.root.get(revertKey(key)); entry != nil {
        return &entry.AuthConfig
    }
    return nil
}

func (c *authConfigTree) Set(id, key string, config evaluators.AuthConfig, override bool) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    // clone current snapshot, modify clone, atomic swap
    newSnapshot := c.current.Load().clone()
    // ...modify newSnapshot...
    c.current.Store(newSnapshot)
    return nil
}

Reads become lock-free. Writes clone-and-swap, so concurrent reads see a consistent snapshot without blocking.

Estimated improvement: Eliminates read-side contention entirely. Under sustained reconciliation (e.g., 28 AuthConfig updates), auth request latency no longer spikes. P99 latency improvement of 10-100x during reconciliation storms, no change during steady state.


Issue 6: Token Signing CPU at Scale

File: pkg/evaluators/response/wristband.go:138

What happens

ES256 (ECDSA P-256) signing takes ~0.1ms per operation. At 10,000 tokens/second, that's 1 full CPU core just for signing. This is inherent to the crypto and can't be optimized away, but it can be parallelized.

Fix: Not needed at typical scales

ES256 signing is already fast. At realistic scales (100-1000 tokens/second), signing consumes 1-10% of a CPU core. The serialization overhead (Issues 1-3) dominates.

If signing becomes a bottleneck at extreme scales, consider:

  • Horizontal scaling (multiple Authorino replicas behind the same Gateway)
  • Pre-signing token batches during idle periods (not practical for per-user tokens)

No fix needed unless token issuance exceeds ~5000/second sustained.


Summary: Impact by Fix

# Fix Effort Improvement Metric
1a Cache GetAuthorizationJSON() per phase Small ~3x fewer serializations CPU, alloc/s
1b Pass CEL inputs directly (no JSON round-trip) Large 5-10x for CEL evaluations CPU, latency
2 Memoize JWKS + HTTP cache headers Small O(1) JWKS serving CPU, network
3 Reuse serialized identity for sub hash Small ~10-15% per issuance CPU, alloc
4 K8s API call semaphore Small Prevents cascading failure Availability
5 Copy-on-write index Medium 10-100x P99 during reconciliation Tail latency

Quick wins (small effort, high impact)

Fix 1a + Fix 2 + Fix 3 together would reduce per-request CPU cost by roughly 2-3x for the wristband token path. These are localized changes with no API surface impact.

High-impact refactor

Fix 1b (eliminate JSON round-trip for CEL) would be the single largest performance improvement but requires changing internal interfaces. This benefits all AuthConfig evaluations, not just wristbands.

Resilience

Fix 4 + Fix 5 don't improve steady-state throughput but prevent the system from falling over under load spikes and reconciliation storms - exactly the scenario seen in RHOAISUP-25.

Request Cost Breakdown (Current vs. Fixed)

Current: wristband token request

Operation Count Cost each Total
json.Marshal (pipeline state) ~11 ~50us ~550us
protojson.Unmarshal (CEL input) ~11 ~30us ~330us
json.Marshal (identity for sub) 1 ~10us ~10us
K8s TokenReview API call 1 ~5ms ~5ms
ES256 signing 1 ~100us ~100us
Map copies + RLocks ~11 ~1us ~11us
Total (excl. network) ~6ms
Serialization overhead ~890us (~15%)

After Fix 1a + 2 + 3:

Operation Count Cost each Total
json.Marshal (pipeline state) ~3 ~50us ~150us
protojson.Unmarshal (CEL input) ~3 ~30us ~90us
K8s TokenReview API call 1 ~5ms ~5ms
ES256 signing 1 ~100us ~100us
Cache lookups ~8 ~0.1us ~1us
Total (excl. network) ~5.3ms
Serialization overhead ~240us (~4.5%)

The K8s API call dominates regardless. The serialization fixes matter most when the K8s API is fast (cached TokenReview) or when running at high concurrency where CPU becomes the bottleneck before network does.

Multi-Cluster Auth Design Analysis: odh-model-controller

Analysis of the multi-cluster JWT token exchange design and Vault integration test plan in odh-model-controller.

TL;DR

The design builds a cross-cluster identity federation system on top of Kuadrant/Authorino's wristband feature. It works, and the author has clearly done real-world validation. But it hits fundamental limitations in Authorino that force security compromises - most critically, distributing private signing keys to every peer cluster because Authorino's signingKeyRefs can't accept public-key-only Secrets. This means compromising any one cluster exposes every cluster's signing keys.

Rather than working around these limitations at the controller level, the right move is to enhance Authorino itself. Seven concrete enhancements are proposed below.


How It Works (Not Reinventing the Wheel)

The design is not implementing a custom token exchange service from scratch. It's a thin orchestration layer over Authorino's native capabilities:

  1. Authorino does the heavy lifting - wristband issuance is configured as an AuthPolicy response rule. Authorino authenticates via kubernetesTokenReview, extracts user.username and user.groups from standard K8s identity metadata, signs a JWT, and injects it as a response header.

  2. The "endpoint" is a passthrough - the model-serving-api server has a ~10-line Go handler that reads the X-Wristband-Token header Authorino already set and wraps it in an OAuth 2.0 JSON response body.

  3. The controller wires the plumbing - auto-generates ES256 signing keys, creates HTTPRoutes for /api/v1/auth/token, computes AuthConfig hashes for the issuer URL, manages annotation-driven peer key lists.

The custom code is about plumbing, not token issuance logic.


Design Weaknesses

Critical: Private Key Distribution

The document itself flags this honestly:

Authorino's signingKeyRefs requires private keys - it rejects public-key-only Secrets. This means every peer cluster holds every other peer's private signing key. Consequence: compromising any peer cluster exposes all peers' private keys.

In a proper federation, you only share public verification keys. Here, every cluster can impersonate every other cluster. The security model collapses to "trust the weakest cluster."

Root cause: NewSigningKey() in Authorino's wristband.go only calls ParseECPrivateKeyFromPEM() / ParseRSAPrivateKeyFromPEM(). There's no code path for public keys.

Critical: No Authorization on Token Minting

The /api/v1/auth/token path explicitly skips authorization. Any authenticated user on the cluster gets a wristband - even service accounts with zero RBAC for any models. The wristband is useless without RBAC on the target, but it leaks identity metadata.

High: N-squared Key Distribution

For N clusters: N signing keys distributed to N clusters = N*(N-1) copy operations. At 5 clusters that's 20. At 10 it's 90. Every key rotation multiplies this. The mechanism ("Via GitOps, RH ACM, or manual oc apply") is left undefined.

High: Identity Consistency Assumed, Not Enforced

The same SA name/namespace must exist across clusters for RBAC to work. If Cluster A has ns:sa-name and Cluster B has a different ns:sa-name, the wristband from Cluster A authenticates as that workload on Cluster B. No mechanism detects or prevents this collision.

High: Fragile Issuer URL

The wristband issuer URL depends on a SHA-256 hash of the full Gateway API topology path. If policy-machinery changes from index-based (rule-1) to name-based rule naming, the hash changes and all in-flight tokens break.

High: No Audience Scoping

Wristbands have no aud claim. A token obtained for one model works for any model on any peer cluster where the user has RBAC. Leaked token blast radius = everything the user can access, everywhere.

Medium: 24-Hour Default Token Duration

tokenDuration: 86400 combined with no audience scoping and no proof-of-possession means a leaked bearer token is usable for up to 24 hours across all clusters. Compare to SPIRE's 1-hour default.

Medium: Path-Based Security Controls

Token escalation prevention relies on exact path matching (request.path != '/api/v1/auth/token'). Path-based security is brittle - URL encoding, trailing slashes, and proxy normalization differences can bypass exact string matches.

Medium: No Automatic Key Rotation

The controller "never rotates automatically." Rotation is a manual multi-step process. In practice, keys will rarely be rotated.

Medium: Cross-Cluster JWKS Delivery Undefined

The Authorino OIDC server is a ClusterIP service - not accessible from other clusters. How peers actually get each other's JWKS is listed as an open question.

Medium: CEL Expression Complexity

Authorization and identity normalization logic is embedded as CEL in YAML. Some expressions are substantial and hard to test. The Vault test plan already found null vs [] group handling issues - this class of bug will recur with each new provider.

Low: x-maas-user Header Trust

The authorization rule trusts x-maas-user if present in request headers. If an attacker can inject this header via a misconfigured proxy, they can impersonate any user for authorization.


Token Revocation Gap

The revocation section describes four scenarios, but there's a gap worth highlighting.

Authorino's wristband sub claim is an auto-generated hash unique per token issuance - not a user identifier. This means:

Revocation scope Mechanism What you need Effect
One specific token Deny rule on sub hash The exact token or audit logs That JWT returns 401; user gets a new one
All user access Remove RoleBindings The username All tokens return 403; user blocked everywhere
All tokens from a key Rotate signing Secret Which key was compromised All tokens from that key return 401

The gap: there's no way to say "revoke all wristbands for user jane" without either hunting down every sub hash or removing her RoleBindings (which also blocks her legitimate K8s token access). A username-based deny rule would fill this gap but isn't in the design.


SPIFFE/SPIRE: Does It Fit?

SPIFFE/SPIRE is the most commonly suggested alternative. It solves the infrastructure-level problems well but has a fundamental scope mismatch.

What SPIRE solves

  • Automatic key distribution via federation bundle endpoints (no manual Secret copying)
  • Automatic key rotation (configurable CA/SVID TTL, rotates at 50% lifetime)
  • Workload attestation (kernel-level inspection of pod namespace, SA, container image SHA)
  • Smaller blast radius (separate CAs per trust domain, private keys never leave)

What SPIRE doesn't solve

SPIFFE identifies workloads, not humans. A JWT-SVID's sub is a SPIFFE ID like spiffe://cluster-a.example.org/ns/ml-platform/sa/inference-gateway - there's no username, no groups. The wristband system's core job (carrying human identity across clusters for per-user RBAC) is out of SPIRE's scope entirely.

The pragmatic hybrid

  1. SPIRE for transport trust - automatic key distribution, rotation, workload attestation, cross-cluster federation
  2. Human identity rides on top - the gateway authenticates the user, forwards the request over a SPIRE-authenticated channel, target cluster trusts user identity headers because the calling workload's SPIFFE ID is verified ("trusted frontend" pattern)

Cost reality

Deploying SPIRE properly means 4-5 new components per cluster (Server, Agent DaemonSet, CSI Driver, Controller Manager, OIDC Discovery Provider). Red Hat ships the Zero Trust Workload Identity Manager operator (GA'd January 2026) that manages SPIRE lifecycle on OpenShift.

For 2 clusters, SPIRE is probably overkill. For 5+ clusters, the manual approach breaks down and SPIRE's automated federation becomes the obvious choice.


Proposed Authorino Enhancements

Based on analysis of the Authorino codebase (v1beta3 API, pkg/evaluators/response/wristband.go, controllers/auth_config_controller.go), here are seven enhancements that would address the design weaknesses at the right layer.

1. Public-Key-Only Verification Keys (Critical)

Problem: signingKeyRefs requires private keys because NewSigningKey() only parses private key PEM formats. Every peer holds every other peer's private key.

Fix: Add verificationKeyRefs alongside signingKeyRefs:

type WristbandAuthResponseSpec struct {
    // ... existing fields ...
    // Public keys from peer clusters, published in JWKS but never used for signing
    VerificationKeyRefs []*WristbandVerificationKeyRef `json:"verificationKeyRefs,omitempty"`
}

Add NewVerificationKey() that parses PUBLIC KEY PEM blocks via x509.ParsePKIXPublicKey(). The JWKS() method publishes both signing keys (via .Public()) and verification keys (already public). Call() still only uses SigningKeys[0] for signing.

Impact - why this matters for compromise containment:

Today, every cluster holds every cluster's private key. If Cluster A is compromised, the attacker gets Cluster B's and C's private keys from the Secrets in kuadrant-system. They can forge tokens that look like they came from B or C. All three clusters are now compromised.

With public-key-only verification, Cluster A only holds its own private key plus B's and C's public keys. If Cluster A is compromised, the attacker gets Cluster A's private key only. They can forge tokens from A (which they already control), but they cannot forge tokens that appear to come from B or C.

Scenario Today With verification keys
Cluster A compromised Attacker forges tokens as A, B, or C Attacker forges tokens as A only
Revocation response Rotate keys on ALL clusters Revoke A's key from B and C; B and C are unaffected
Blast radius Total federation compromise Single cluster compromise

The attacker still has A's signing key, so they can mint arbitrary wristbands signed by A with any username/groups claims. Those forged tokens will authenticate on B and C (because they trust A's public key). The mitigation is the same as today for a single-cluster compromise: remove A's verification key from B and C, making all A-signed tokens fail with 401. The difference is containment - you surgically remove trust in one compromised cluster instead of a fire drill rotating keys everywhere.

2. Automatic Key Rotation (High)

Problem: No automatic rotation. Manual multi-step process across N clusters.

Fix: Add keyRotation spec:

type KeyRotationSpec struct {
    Period      metav1.Duration `json:"period"`
    OverlapKeys int             `json:"overlapKeys,omitempty"` // default: 1
}

On reconcile, check signing key age. If expired, generate new key, add as first in signingKeyRefs, keep old key(s) for verification overlap, delete oldest after N rotations.

3. Remote JWKS Fetching for Wristband Verification (High)

Problem: Cross-cluster JWKS delivery is undefined. OIDC server is ClusterIP-only.

Fix: Add remoteJwksUrls to wristband spec:

type RemoteJwksRef struct {
    Url string `json:"url"`
    TTL int    `json:"ttl,omitempty"`
}

Authorino already has oidc.NewRemoteKeySet() in jwt.go for remote JWKS fetching with caching. Reuse it. Fetched keys merge into the local wristband's JWKS endpoint.

Composes with #1: remote cluster publishes public keys, local cluster fetches automatically. Zero manual distribution.

4. Audience (aud) Claim Support (Medium)

Problem: No audience scoping. Leaked token works everywhere the user has RBAC.

Fix: Add audience to wristband spec (supports CEL expressions for dynamic resolution). On verification side, add audiences field to JwtAuthenticationSpec - the go-oidc verifier already supports audience validation, it's just not exposed.

5. Secret Watch for Wristband Signing Keys (Medium)

Problem: SecretReconciler only watches API key / mTLS secrets. Wristband signing key updates don't trigger reconciliation.

Fix: Extend getAuthConfigsWithK8sSecretBasedIdentity() to also scan ResponseConfigs for wristband SigningKeyRefs. Small change - the pattern already exists.

6. Token Revocation via Deny List (Low-Medium)

Problem: Gap between "deny one token by opaque hash" and "nuke all access via RoleBindings."

Fix: Add denyList to JWT auth spec with claim-based matching (by username, sub, iss, or CEL expression). Makes incident response more ergonomic than hand-writing patternMatching authorization rules.

7. Proper jti Claim (Low)

Problem: sub is a hash of the identity JSON, serving double duty as subject and token identifier. Non-standard.

Fix: Use the actual username as sub, generate a UUID jti per issuance. Makes deny rules work on user identity directly, and jti is available for audit correlation.

Priority Order

# Enhancement Effort Impact
1 Public-key-only verification Medium Eliminates cascading compromise risk
3 Remote JWKS fetching Medium Eliminates manual key distribution
5 Secret watch for wristband keys Small Fixes surprising gap
4 Audience support Medium Principle of least privilege
2 Auto key rotation Medium-Large Reduces operational toil
7 jti claim Small Improves revocation ergonomics
6 Deny list Medium Nice-to-have, workaround exists via CEL

Items 1 and 3 together would transform the security posture: peers only share public keys, distribution is automatic, and the blast radius of a compromised cluster drops from "everything" to "just that cluster."

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