Skip to content

inbound auth chain rejects OIDC JWTs: OIDCAuthenticator is built but never added to the chain #10

@cjimti

Description

@cjimti

Summary

The inbound auth chain in internal/server/server.go is constructed with only the API-key and static-bearer authenticators. The OIDC validator (auth.NewOIDC) is built later in buildPortal but is wired exclusively into the browser-portal session flow (NewBrowserAuth), never into the chain that protects /v1/*. As a result, every OIDC JWT presented as Authorization: Bearer <jwt> is rejected with 401 {"error":"invalid credential"} regardless of issuer, audience, signature, or expiry — there is no code path that validates JWTs against the configured issuer's JWKS for API requests.

This breaks any client that authenticates via OIDC with oidc.enabled: true, auth.allow_anonymous: false, even when the JWT carries the correct iss and aud for the configured oidc.issuer / oidc.audience.

Reproduction

Config: oidc.enabled: true, oidc.issuer: <real-issuer>, oidc.audience: <expected-aud>, auth.allow_anonymous: false, bearer.tokens: [<some-static-dev-token>].

  1. Obtain an access token from <real-issuer> for a client whose tokens carry aud: <expected-aud> (verified via JWT decode + a configured audience mapper if using Keycloak).
  2. curl -H "Authorization: Bearer <jwt>" https://<host>/v1/whoami
  3. Response: 401 {"error":"invalid credential"} with WWW-Authenticate: Bearer realm="api-test", error="invalid_token".

Static-bearer auth via the configured dev token still works. API-key auth still works. Only the OIDC JWT path is broken — and it is the only path the config advertises as the production auth mode.

Root cause

internal/server/server.go:101-102:

bearerAuth := inbound.NewBearer(cfg.Bearer.Tokens)
app.chain = inbound.NewChain(cfg.Auth.AllowAnonymous, apikeyAuth, bearerAuth)

Chain has two authenticators: API-key + static Bearer. For an incoming Authorization: Bearer <jwt>:

  1. apikeyAuth — no X-API-Key → returns ErrNoCredential → chain continues.
  2. bearerAuth (pkg/auth/inbound/bearer.go:44-46) — JWT is not in the static token list → returns ErrInvalidCredential.
  3. Per the chain's documented semantics (pkg/auth/inbound/chain.go:20), ErrInvalidCredential is no-fallthrough → 401.

The OIDC validator is constructed at server.go:193 inside buildPortal and stored on PortalDeps.BrowserAuth. It is never wrapped as an inbound.Authenticator or added to app.chain.

Proposed fix

  1. Introduce an inbound.Authenticator adapter that wraps auth.OIDCAuthenticator. The adapter should:

    • Return ErrNoCredential when the Authorization header is absent or not Bearer.
    • Return ErrNoCredential (not ErrInvalidCredential) when the bearer value fails to parse as a JWT — so the chain can still fall through to the static BearerAuthenticator for dev tokens.
    • Return ErrInvalidCredential only on a structurally-valid JWT that fails signature/issuer/audience/expiry checks.
    • Return Identity on success, populated from the verified claims.
  2. Update server.go:

    • Lift the OIDC validator construction out of buildPortal (or share the instance with the chain) so the chain can use it whether or not the portal is enabled.
    • Add the OIDC adapter to the chain before the static BearerAuthenticator:
      auths := []inbound.Authenticator{apikeyAuth}
      if cfg.OIDC.Enabled {
          auths = append(auths, oidcBearerAuth)
      }
      auths = append(auths, bearerAuth)
      app.chain = inbound.NewChain(cfg.Auth.AllowAnonymous, auths...)
  3. Consider softening BearerAuthenticator.Authenticate to return ErrNoCredential instead of ErrInvalidCredential on a non-matching bearer. With OIDC in the chain, every JWT first looks like an unknown static bearer; the strict ErrInvalidCredential semantics block the OIDC-first ordering unless the OIDC adapter consumes the credential first. The current "no fallthrough on invalid" rule is reasonable but only when the chain isn't expected to contain multiple bearer-style authenticators.

Tests to add

  • Table-driven test on the chain with oidc + bearer + apikey registered, asserting:
    • Valid JWT with correct iss/audIdentity from OIDC.
    • JWT signed by a foreign issuer → ErrInvalidCredential (no fallthrough to static bearer).
    • Static dev token → Identity from BearerAuthenticator (fallthrough from OIDC works because OIDC returns ErrNoCredential on non-JWT).
    • No credential, allow_anonymous=falseErrNoCredential.
  • Integration test that boots the full server with a stub OIDC discovery + JWKS and asserts a real curl round-trip on /v1/whoami returns 200 with the JWT subject populating Identity.

Notes

  • The Keycloak side of the deployment that surfaced this was correct: the relevant client had an oidc-audience-mapper writing the expected audience into access.token.claim, and the JWT decoded to the expected iss / aud / exp. The 401 is structural in api-test, not a token-claims mismatch.
  • The platform calling api-test sends a clean RFC-6750 Authorization: Bearer <jwt> against /v1/*. No upstream change is needed once api-test accepts OIDC bearers on the API surface.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions