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:
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:
Variadic option — NewJWTValidator(ctx, issuer, aud, opts ...Option) with WithRefreshInterval(d). Non-breaking; existing callers compile unchanged. Cleanest for a shared lib.
New positional param — breaking; forces a coordinated bump in every consumer's go.mod + call site.
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
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.
## 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_
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.
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_
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Migrated here from
gitea-mcp#8— the cited code moved to this module ingitea-mcp@658f4ba. Fixing it here fixes it for every consumer (gitea-mcp, brain-mcp/ingestion, future template-go-agent spawns).Problem
NewJWTValidatorhardcodes the JWKS min-refresh interval at 1 hour:If Dex rotates signing keys, every consumer rejects valid JWTs issued under the new key for up to 60 minutes.
Proposed change
API-shape decision (needs a call)
NewJWTValidator(ctx, issuerURL, audience)is consumed bygitea-mcp/cmd/gitea-mcp/main.go(and others). Three options, in rough order of preference:NewJWTValidator(ctx, issuer, aud, opts ...Option)withWithRefreshInterval(d). Non-breaking; existing callers compile unchanged. Cleanest for a shared lib.go.mod+ call site.JWKS_REFRESH_INTERVALenv 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
NewJWTValidatorcallers (or, if breaking, a v0.2.0 tag + consumer-bump checklist)jwk.WithMinRefreshIntervalRef: gitea-mcp#8
Same constructor, and the API-shape decision here is the same one
This issue and #4 both change
NewJWTValidator/NewMultiJWTValidatorinauth/jwt.go, and both are about the validator's lifecycle rather than its verification logic: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
NewJWTValidatortreating 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,
ValidatereturnsErrUnavailableuntil 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
502on OIDC discovery at boot (koala rebooted, Authentik not yet serving), started anyway with anilvalidator, and rejected every OAuth JWT for ~16 hours withstatic_token_mismatch./healthzreported{"ok":true, "jwt":{"status":"degraded"}}throughout. Arollout restartfixed 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
Done in v0.7.0 —
WithRefreshInterval(d), defaulting to 1h.Option 1, as recommended here
That, and option 3 stayed rejected for the reason given here:
Config ownership stays at the edge.
gitea-mcpparses its own env and passes the option, or takes the default.Acceptance
INFO jwt issuer resolved issuer=... jwks_uri=... jwks_refresh_interval=1h0m0sOptionvariadic was added by mcp-chassis#4 in the same release; every existing call site compiles untouchedOne honest limit on that last box
The interval reaching
jwk.WithMinRefreshIntervalis not observable from outsidejwx, and testing whetherjwxthen honours it isjwx'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
optsthrough.Closing.
Created via git-mcp on behalf of @mathias