Skip to content

refactor(auth): use go-oidc IDTokenVerifier instead of hand-rolled JWKS+JWT - #36

Merged
AYDEV-FR merged 1 commit into
mainfrom
refactor/jwt-go-oidc
Jun 4, 2026
Merged

refactor(auth): use go-oidc IDTokenVerifier instead of hand-rolled JWKS+JWT#36
AYDEV-FR merged 1 commit into
mainfrom
refactor/jwt-go-oidc

Conversation

@AYDEV-FR

@AYDEV-FR AYDEV-FR commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Replaces the hand-rolled JWT validator with the canonical github.com/coreos/go-oidc stack. Supersedes PR #35 — the bug it patched (kid-miss not triggering refresh) is fixed implicitly because RemoteKeySet does it natively.

What was hand-rolled and is gone

  • JWKS fetcher with a 15-minute TTL cache.
  • kid lookup that didn't refresh on miss → the production symptom of >2 days of broken logins (PR fix(auth): refetch JWKS on unknown kid (rate-limited) #35).
  • jwkToRSAPublicKey via big.Int because the chain hardcoded token.Method.(*jwt.SigningMethodRSA) — any non-RSA IdP would have been rejected.
  • iss / aud / exp checks where aud was assumed to be a string (multi-valued audiences crashed on .(string)).
  • No nbf, no nonce, no at_hash.

What replaces it

keySet  := oidc.NewRemoteKeySet(ctx, jwksURL)
verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{ClientID: audience})
// …
idToken, err := verifier.Verify(ctx, tokenString)
  • RemoteKeySet handles the cache, refresh-on-unknown-kid (the PR fix(auth): refetch JWKS on unknown kid (rate-limited) #35 bug), key rotation transparently.
  • IDTokenVerifier handles signature + iss + aud (string or array) + exp + nbf, with whatever algorithm the JWKS advertises (not just RS256).
  • Split-horizon DNS still works: NewRemoteKeySet takes the JWKS URL directly, decoupled from the expected issuer the tokens carry.

Public surface preserved

Symbol Status
NewJWTValidator(jwksURL, issuer, audience, usernameClaim) same signature
(*JWTValidator).Validate(token) → (username, claims, error) same shape
(*JWTValidator).ValidateToken(token) → (username, error) kept
SanitizeUsername(string) unchanged

No caller in cmd/api, internal/handlers, internal/auth/middleware.go or internal/controller/naming.go needs to change.

Stats

Before After Δ
jwt.go lines 269 85 -180 (-67%)
Net repo change +51 / -202
Direct deps added coreos/go-oidc/v3 v3.18.0
Direct deps dropped golang-jwt/jwt/v5

Build green, existing admin_test.go still passes (it doesn't go through JWT validation).

…KS+JWT

The previous internal/auth/jwt.go reimplemented what github.com/coreos/go-oidc
already provides for free, and not particularly well:

- hand-rolled JWKS fetcher with a 15-minute TTL and a kid lookup that
  stayed silently stuck across many cycles when the IdP rotated (the
  production symptom that prompted PR #35 — login broken for >2 days)
- hand-rolled jwkToRSAPublicKey via big.Int because the chain assumed
  RSA only (`token.Method.(*jwt.SigningMethodRSA)` hardcoded)
- hand-rolled iss / aud / exp checks where aud was assumed to be a
  string (multi-valued audiences would crash on .(string))
- no nonce, no nbf, no at_hash

Replace with:

    keySet  := oidc.NewRemoteKeySet(ctx, jwksURL)
    verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{ClientID: audience})
    // …
    idToken, err := verifier.Verify(ctx, tokenString)

RemoteKeySet handles the cache, including refresh-on-unknown-kid out of
the box (the very bug PR #35 patched manually). IDTokenVerifier handles
signature, iss, aud (string or array), exp, nbf — and accepts any algo
advertised in the JWKS, not just RS256.

Public surface preserved:
- NewJWTValidator(jwksURL, issuer, audience, usernameClaim) — same args
- (v *JWTValidator) Validate(token) (username, claims, error) — same shape
- ValidateToken(token) (username, error) — kept for callers
- SanitizeUsername(string) — unchanged

Stats:
- jwt.go: 269 -> 89 lines (-180, -67%)
- golang-jwt/jwt/v5 dependency dropped
- coreos/go-oidc/v3 added
- net: ~30 lines of refactor logic vs ~270 lines of reimplemented standard
@AYDEV-FR
AYDEV-FR merged commit 7601186 into main Jun 4, 2026
6 of 7 checks passed
@AYDEV-FR
AYDEV-FR deleted the refactor/jwt-go-oidc branch June 4, 2026 18:45
AYDEV-FR added a commit that referenced this pull request Jun 4, 2026
Phase B of the auth-code-rationalisation pair. Continues the work of
PR #36 (jwt.go -> go-oidc IDTokenVerifier) on the discovery/code-exchange
side, replacing 386 lines of hand-rolled HTTP+url.Values plumbing with
the canonical golang.org/x/oauth2 and github.com/coreos/go-oidc APIs.

What's gone:
- OIDCDiscovery struct + discoverOIDC HTTP fetch of the well-known doc.
  oidc.NewProvider does this and validates the document for us.
- discoverOIDCWithRetry's hand-rolled retry. We keep the same retry
  policy (5 attempts, exponential backoff capped at 4 s) but wrap
  oidc.NewProvider instead of the raw HTTP call.
- Dual-discovery dance for split-horizon issuers (fetch public, fall
  back to URL substitution). The new code uses
  oidc.InsecureIssuerURLContext to tell go-oidc which issuer to expect
  in tokens, and just substitutes the public base on the
  AuthorizationEndpoint we got from discovery. Single code path.
- OIDCTokenResponse struct + exchangeCode's manual POST form-encode.
  oauth2.Config.Exchange does it, and oauth2.Token.Extra("id_token")
  pulls the OIDC-specific extra.
- Login's hand-built query-string. oauth2.Config.AuthCodeURL(state)
  returns the full URL.

Public surface preserved:
- NewOIDCHandler(*config.Config) (*OIDCHandler, error) — same signature,
  same retry budget at boot, same "fatal if discovery never recovers"
  semantics (cmd/api/main.go logs Fatal then exits, k8s restarts).
- (*OIDCHandler).Login / Callback / Logout fiber handlers — same shape,
  same returnUrl-via-hash-fragment redirect, same open-redirect guard
  on the returnUrl.

State management unchanged (in-memory map + 5-min cleanup goroutine).
Signed-cookie state would survive pod restarts but is a separate
behavioural change; out of scope here.

Stats:
- oidc.go: 386 -> 252 lines (-134, -35%)
- Together with PR #36: 655 -> 337 (-318, -48% of pre-refactor auth code)
- golang.org/x/oauth2 promoted from indirect to direct (was already in
  go.sum thanks to other transitive deps).
AYDEV-FR added a commit that referenced this pull request Jun 4, 2026
Phase B of the auth-code-rationalisation pair. Continues the work of
PR #36 (jwt.go -> go-oidc IDTokenVerifier) on the discovery/code-exchange
side, replacing 386 lines of hand-rolled HTTP+url.Values plumbing with
the canonical golang.org/x/oauth2 and github.com/coreos/go-oidc APIs.

What's gone:
- OIDCDiscovery struct + discoverOIDC HTTP fetch of the well-known doc.
  oidc.NewProvider does this and validates the document for us.
- discoverOIDCWithRetry's hand-rolled retry. We keep the same retry
  policy (5 attempts, exponential backoff capped at 4 s) but wrap
  oidc.NewProvider instead of the raw HTTP call.
- Dual-discovery dance for split-horizon issuers (fetch public, fall
  back to URL substitution). The new code uses
  oidc.InsecureIssuerURLContext to tell go-oidc which issuer to expect
  in tokens, and just substitutes the public base on the
  AuthorizationEndpoint we got from discovery. Single code path.
- OIDCTokenResponse struct + exchangeCode's manual POST form-encode.
  oauth2.Config.Exchange does it, and oauth2.Token.Extra("id_token")
  pulls the OIDC-specific extra.
- Login's hand-built query-string. oauth2.Config.AuthCodeURL(state)
  returns the full URL.

Public surface preserved:
- NewOIDCHandler(*config.Config) (*OIDCHandler, error) — same signature,
  same retry budget at boot, same "fatal if discovery never recovers"
  semantics (cmd/api/main.go logs Fatal then exits, k8s restarts).
- (*OIDCHandler).Login / Callback / Logout fiber handlers — same shape,
  same returnUrl-via-hash-fragment redirect, same open-redirect guard
  on the returnUrl.

State management unchanged (in-memory map + 5-min cleanup goroutine).
Signed-cookie state would survive pod restarts but is a separate
behavioural change; out of scope here.

Stats:
- oidc.go: 386 -> 252 lines (-134, -35%)
- Together with PR #36: 655 -> 337 (-318, -48% of pre-refactor auth code)
- golang.org/x/oauth2 promoted from indirect to direct (was already in
  go.sum thanks to other transitive deps).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant