Repository navigation
inbound auth chain rejects OIDC JWTs: OIDCAuthenticator is built but never added to the chain #10
Description
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>].
- Obtain an access token from
<real-issuer>for a client whose tokens carryaud: <expected-aud>(verified via JWT decode + a configured audience mapper if using Keycloak). curl -H "Authorization: Bearer <jwt>" https://<host>/v1/whoami- Response:
401 {"error":"invalid credential"}withWWW-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>:
apikeyAuth— noX-API-Key→ returnsErrNoCredential→ chain continues.bearerAuth(pkg/auth/inbound/bearer.go:44-46) — JWT is not in the static token list → returnsErrInvalidCredential.- Per the chain's documented semantics (
pkg/auth/inbound/chain.go:20),ErrInvalidCredentialis 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
-
Introduce an
inbound.Authenticatoradapter that wrapsauth.OIDCAuthenticator. The adapter should:- Return
ErrNoCredentialwhen theAuthorizationheader is absent or notBearer. - Return
ErrNoCredential(notErrInvalidCredential) when the bearer value fails to parse as a JWT — so the chain can still fall through to the staticBearerAuthenticatorfor dev tokens. - Return
ErrInvalidCredentialonly on a structurally-valid JWT that fails signature/issuer/audience/expiry checks. - Return
Identityon success, populated from the verified claims.
- Return
-
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...)
- Lift the OIDC validator construction out of
-
Consider softening
BearerAuthenticator.Authenticateto returnErrNoCredentialinstead ofErrInvalidCredentialon a non-matching bearer. With OIDC in the chain, every JWT first looks like an unknown static bearer; the strictErrInvalidCredentialsemantics 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 + apikeyregistered, asserting:- Valid JWT with correct
iss/aud→Identityfrom OIDC. - JWT signed by a foreign issuer →
ErrInvalidCredential(no fallthrough to static bearer). - Static dev token →
IdentityfromBearerAuthenticator(fallthrough from OIDC works because OIDC returnsErrNoCredentialon non-JWT). - No credential,
allow_anonymous=false→ErrNoCredential.
- Valid JWT with correct
- Integration test that boots the full server with a stub OIDC discovery + JWKS and asserts a real
curlround-trip on/v1/whoamireturns 200 with the JWT subject populatingIdentity.
Notes
- The Keycloak side of the deployment that surfaced this was correct: the relevant client had an
oidc-audience-mapperwriting the expected audience intoaccess.token.claim, and the JWT decoded to the expectediss/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.
Activity
Merge pull request #11 from plexara/fix-oidc-inbound-chain