auth: JWKS cache refresh interval is hardcoded at 1h #1

Closed
opened 2026-06-02 14:12:33 +00:00 by mathias · 2 comments
Owner

Migrated here from gitea-mcp#8 — the cited code moved to this module in gitea-mcp@658f4ba. Fixing it here fixes it for every consumer (gitea-mcp, brain-mcp/ingestion, future template-go-agent spawns).

Problem

NewJWTValidator hardcodes the JWKS min-refresh interval at 1 hour:

// auth/jwt.go — NewJWTValidator
cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour))

If Dex rotates signing keys, every consumer rejects valid JWTs issued under the new key for up to 60 minutes.

Proposed change

  • Make the interval configurable; default to 1h to preserve current behaviour.
  • Log the effective value at validator init.

API-shape decision (needs a call)

NewJWTValidator(ctx, issuerURL, audience) is consumed by gitea-mcp/cmd/gitea-mcp/main.go (and others). Three options, in rough order of preference:

  1. Variadic option — NewJWTValidator(ctx, issuer, aud, opts ...Option) with WithRefreshInterval(d). Non-breaking; existing callers compile unchanged. Cleanest for a shared lib.
  2. New positional param — breaking; forces a coordinated bump in every consumer's go.mod + call site.
  3. Read JWKS_REFRESH_INTERVAL env inside the library — avoid; a shared lib reaching into the process env is a hidden dependency and surprises consumers.

Lean option 1. Consumer then surfaces its own env→duration parse and passes the option, keeping config ownership at the edge.

Acceptance

  • Refresh interval configurable, default 1h
  • Effective value logged at init
  • No breaking change to existing NewJWTValidator callers (or, if breaking, a v0.2.0 tag + consumer-bump checklist)
  • Test covers a non-default interval reaching jwk.WithMinRefreshInterval

Ref: gitea-mcp#8

Migrated here from `gitea-mcp#8` — the cited code moved to this module in `gitea-mcp@658f4ba`. Fixing it here fixes it for every consumer (gitea-mcp, brain-mcp/ingestion, future template-go-agent spawns). ## Problem `NewJWTValidator` hardcodes the JWKS min-refresh interval at 1 hour: ```go // auth/jwt.go — NewJWTValidator cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)) ``` If Dex rotates signing keys, every consumer rejects valid JWTs issued under the new key for up to 60 minutes. ## Proposed change - Make the interval configurable; default to 1h to preserve current behaviour. - Log the effective value at validator init. ## API-shape decision (needs a call) `NewJWTValidator(ctx, issuerURL, audience)` is consumed by `gitea-mcp/cmd/gitea-mcp/main.go` (and others). Three options, in rough order of preference: 1. **Variadic option** — `NewJWTValidator(ctx, issuer, aud, opts ...Option)` with `WithRefreshInterval(d)`. Non-breaking; existing callers compile unchanged. Cleanest for a shared lib. 2. New positional param — breaking; forces a coordinated bump in every consumer's `go.mod` + call site. 3. Read `JWKS_REFRESH_INTERVAL` env inside the library — avoid; a shared lib reaching into the process env is a hidden dependency and surprises consumers. Lean **option 1**. Consumer then surfaces its own env→duration parse and passes the option, keeping config ownership at the edge. ## Acceptance - [ ] Refresh interval configurable, default 1h - [ ] Effective value logged at init - [ ] No breaking change to existing `NewJWTValidator` callers (or, if breaking, a v0.2.0 tag + consumer-bump checklist) - [ ] Test covers a non-default interval reaching `jwk.WithMinRefreshInterval` Ref: gitea-mcp#8
mathias added the risk/mediumsize/stype/feat labels 2026-08-27 05:25:44 +00:00
Author
Owner

Same constructor, and the API-shape decision here is the same one

This issue and #4 both change NewJWTValidator / NewMultiJWTValidator in auth/jwt.go, and both are about the validator's lifecycle rather than its verification logic:

  • #1 (this): the JWKS refresh interval is hardcoded at 1h, so a key rotation blackholes valid tokens for up to 60 minutes.
  • #4: discovery is resolved eagerly at construction, so an IdP that is briefly unavailable at boot leaves the caller holding nil — which the middleware reads as "no JWT auth configured", permanently.

They are the same defect class at two points on the timeline: this one is refresh-after-success, that one is resolve-before-first-success. Both stem from NewJWTValidator treating discovery as a one-shot that must succeed now.

Why they should be done together

The "API-shape decision (needs a call)" flagged here is resolved by #4's proposal, not separately. If discovery becomes lazy with backoff — constructor always succeeds, Validate returns ErrUnavailable until resolution lands — then the refresh interval stops being a constructor argument and becomes part of the same resolution policy. Fixing #1 alone by threading one more parameter through the constructor makes #4's change harder, because the constructor is the thing #4 wants to stop being able to fail.

Suggested order: #4 first (lazy/retrying resolution), then #1 falls out as configuring the same policy object rather than a new parameter.

Field evidence for the pair

Measured 2026-09-07: gitea-mcp took a 502 on OIDC discovery at boot (koala rebooted, Authentik not yet serving), started anyway with a nil validator, and rejected every OAuth JWT for ~16 hours with static_token_mismatch. /healthz reported {"ok":true, "jwt":{"status":"degraded"}} throughout. A rollout restart fixed it instantly.

Note the shared blast radius called out here — "Fixing it here fixes it for every consumer (gitea-mcp, brain-mcp/ingestion, future template-go-agent spawns)" — cuts the other way too: on that reboot brain-mcp won the race and gitea-mcp lost, and nothing in the design made that outcome deterministic.

Cross-referenced from #4.


Created via git-mcp on behalf of @mathias

## Same constructor, and the API-shape decision here is the same one This issue and #4 both change `NewJWTValidator` / `NewMultiJWTValidator` in `auth/jwt.go`, and both are about the validator's **lifecycle** rather than its verification logic: - **#1 (this):** the JWKS refresh interval is hardcoded at 1h, so a key rotation blackholes valid tokens for up to 60 minutes. - **#4:** discovery is resolved *eagerly at construction*, so an IdP that is briefly unavailable at boot leaves the caller holding `nil` — which the middleware reads as "no JWT auth configured", permanently. They are the same defect class at two points on the timeline: **this one is refresh-after-success, that one is resolve-before-first-success.** Both stem from `NewJWTValidator` treating discovery as a one-shot that must succeed now. ## Why they should be done together The "API-shape decision (needs a call)" flagged here is resolved by #4's proposal, not separately. If discovery becomes lazy with backoff — constructor always succeeds, `Validate` returns `ErrUnavailable` until resolution lands — then the refresh interval stops being a constructor argument and becomes part of the same resolution policy. Fixing #1 alone by threading one more parameter through the constructor makes #4's change harder, because the constructor is the thing #4 wants to stop being able to fail. Suggested order: **#4 first** (lazy/retrying resolution), then #1 falls out as configuring the same policy object rather than a new parameter. ## Field evidence for the pair Measured 2026-09-07: gitea-mcp took a `502` on OIDC discovery at boot (koala rebooted, Authentik not yet serving), started anyway with a `nil` validator, and rejected every OAuth JWT for ~16 hours with `static_token_mismatch`. `/healthz` reported `{"ok":true, "jwt":{"status":"degraded"}}` throughout. A `rollout restart` fixed it instantly. Note the shared blast radius called out here — *"Fixing it here fixes it for every consumer (gitea-mcp, brain-mcp/ingestion, future template-go-agent spawns)"* — cuts the other way too: on that reboot brain-mcp won the race and gitea-mcp lost, and nothing in the design made that outcome deterministic. Cross-referenced from #4. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Done in v0.7.0 — WithRefreshInterval(d), defaulting to 1h.

Option 1, as recommended here

Variadic option — NewJWTValidator(ctx, issuer, aud, opts ...Option) with WithRefreshInterval(d). Non-breaking; existing callers compile unchanged. Cleanest for a shared lib.

That, and option 3 stayed rejected for the reason given here:

Read JWKS_REFRESH_INTERVAL env inside the library — avoid; a shared lib reaching into the process env is a hidden dependency and surprises consumers.

Config ownership stays at the edge. gitea-mcp parses its own env and passes the option, or takes the default.

Acceptance

  • Refresh interval configurable, default 1h
  • Effective value logged at init — INFO jwt issuer resolved issuer=... jwks_uri=... jwks_refresh_interval=1h0m0s
  • No breaking change — the Option variadic was added by mcp-chassis#4 in the same release; every existing call site compiles untouched
  • Test covers a non-default interval reaching the registration site

One honest limit on that last box

The interval reaching jwk.WithMinRefreshInterval is not observable from outside jwx, and testing whether jwx then honours it is jwx's job, not this library's. The test asserts the configured value reaches the registration site and is stated in the log — table-driven over default and override.

That is a weaker claim than "jwx refreshes every 5 minutes", and it is the claim this package can actually make. It does pin the failure mode worth pinning: a silently-ignored option, which is what you get if someone later adds a code path that registers the cache without threading opts through.

Closing.


Created via git-mcp on behalf of @mathias

Done in **v0.7.0** — `WithRefreshInterval(d)`, defaulting to 1h. ### Option 1, as recommended here > **Variadic option** — `NewJWTValidator(ctx, issuer, aud, opts ...Option)` with `WithRefreshInterval(d)`. Non-breaking; existing callers compile unchanged. Cleanest for a shared lib. That, and option 3 stayed rejected for the reason given here: > Read `JWKS_REFRESH_INTERVAL` env inside the library — avoid; a shared lib reaching into the process env is a hidden dependency and surprises consumers. Config ownership stays at the edge. `gitea-mcp` parses its own env and passes the option, or takes the default. ### Acceptance - [x] Refresh interval configurable, default 1h - [x] Effective value logged at init — `INFO jwt issuer resolved issuer=... jwks_uri=... jwks_refresh_interval=1h0m0s` - [x] No breaking change — the `Option` variadic was added by mcp-chassis#4 in the same release; every existing call site compiles untouched - [x] Test covers a non-default interval reaching the registration site ### One honest limit on that last box The interval reaching `jwk.WithMinRefreshInterval` is not observable from outside `jwx`, and testing whether `jwx` then honours it is `jwx`'s job, not this library's. The test asserts the configured value reaches the **registration site** and is stated in the log — table-driven over default and override. That is a weaker claim than "jwx refreshes every 5 minutes", and it is the claim this package can actually make. It does pin the failure mode worth pinning: **a silently-ignored option**, which is what you get if someone later adds a code path that registers the cache without threading `opts` through. Closing. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.