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 carry aud: <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"} 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>:
apikeyAuth — no X-API-Key → returns ErrNoCredential → chain continues.
bearerAuth (pkg/auth/inbound/bearer.go:44-46) — JWT is not in the static token list → returns ErrInvalidCredential.
- 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
-
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.
-
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...)
-
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/aud → Identity 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=false → ErrNoCredential.
- 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.
Summary
The inbound auth chain in
internal/server/server.gois constructed with only the API-key and static-bearer authenticators. The OIDC validator (auth.NewOIDC) is built later inbuildPortalbut is wired exclusively into the browser-portal session flow (NewBrowserAuth), never into the chain that protects/v1/*. As a result, every OIDC JWT presented asAuthorization: Bearer <jwt>is rejected with401 {"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 correctissandaudfor the configuredoidc.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>].<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/whoami401 {"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: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.pkg/auth/inbound/chain.go:20),ErrInvalidCredentialis no-fallthrough → 401.The OIDC validator is constructed at
server.go:193insidebuildPortaland stored onPortalDeps.BrowserAuth. It is never wrapped as aninbound.Authenticatoror added toapp.chain.Proposed fix
Introduce an
inbound.Authenticatoradapter that wrapsauth.OIDCAuthenticator. The adapter should:ErrNoCredentialwhen theAuthorizationheader is absent or notBearer.ErrNoCredential(notErrInvalidCredential) when the bearer value fails to parse as a JWT — so the chain can still fall through to the staticBearerAuthenticatorfor dev tokens.ErrInvalidCredentialonly on a structurally-valid JWT that fails signature/issuer/audience/expiry checks.Identityon success, populated from the verified claims.Update
server.go:buildPortal(or share the instance with the chain) so the chain can use it whether or not the portal is enabled.BearerAuthenticator: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
oidc + bearer + apikeyregistered, asserting:iss/aud→Identityfrom OIDC.ErrInvalidCredential(no fallthrough to static bearer).IdentityfromBearerAuthenticator(fallthrough from OIDC works because OIDC returnsErrNoCredentialon non-JWT).allow_anonymous=false→ErrNoCredential.curlround-trip on/v1/whoamireturns 200 with the JWT subject populatingIdentity.Notes
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.Authorization: Bearer <jwt>against/v1/*. No upstream change is needed once api-test accepts OIDC bearers on the API surface.