Implements the one hard dependency of infra ADR-0011 (service-to-service auth: native identity per trust domain).
Problem
mcp-chassis BearerMiddleware/JWTValidator matches issexactly against a single DEX_ISSUER_URL. That's why every in-cluster caller falls back to a long-lived static bearer. ADR-0011's whole plan (in-cluster pods authenticate with kubelet-rotated, audience-bound k8s ServiceAccount tokens) hinges on the chassis trusting more than one issuer.
Change
Make the validator accept a configurable list of trusted issuers, each with its own JWKS source and (optional) required audience:
Authentik (D3/D4, existing).
The k3s cluster OIDC issuerhttps://kubernetes.default.svc.cluster.local, JWKS from https://kubernetes.default.svc/openid/v1/jwks (in-cluster, cached) — for D1 SA tokens.
(later) an optional trusted Tailscale-proxy identity header for D2.
Additive and backward-compatible: keep accepting the current Authentik/static bearers through the migration.
Proven viable (live-cluster spike, 2026-07-06)
k3s exposes OIDC discovery + audience-scoped kubectl create token out of the box — no cluster-config change. A jwx/v2 verifier (the library the chassis already uses) validated a real SA token: accepts for its audience, rejects wrong-audience (replay block), rejects wrong-issuer. Verifier used:
For a trusted-issuer LIST: pick the entry whose issuer matches the token's iss (peek unverified), then jwt.Parse with that entry's JWKS + required audience. In-cluster, fetch+cache JWKS from kubernetes.default.svc.
Acceptance
JWTValidator accepts a []TrustedIssuer{ Issuer, JWKSURL, Audience } (config/env).
A SA token with iss=https://kubernetes.default.svc.cluster.local + matching audience validates; wrong-audience and wrong-issuer are rejected (unit tests).
Existing Authentik/static-bearer paths still work (no regression).
gitea-mcp + brain-mcp wired to trust the cluster issuer.
Then infra rolls SA-token projected volumes onto the in-cluster callers (ADR-0011 migration step 2+). Full rationale + spike detail: brain wiki/homelab/decisions/service-to-service-auth-native-identity-per-domain-2026-07-06; infra ADR-0011.
Implements the one hard dependency of infra **ADR-0011** (service-to-service auth: native identity per trust domain).
## Problem
mcp-chassis `BearerMiddleware`/`JWTValidator` matches `iss` **exactly** against a single `DEX_ISSUER_URL`. That's why every in-cluster caller falls back to a long-lived static bearer. ADR-0011's whole plan (in-cluster pods authenticate with kubelet-rotated, audience-bound k8s ServiceAccount tokens) hinges on the chassis trusting **more than one** issuer.
## Change
Make the validator accept a configurable **list** of trusted issuers, each with its own JWKS source and (optional) required audience:
- Authentik (D3/D4, existing).
- The **k3s cluster OIDC issuer** `https://kubernetes.default.svc.cluster.local`, JWKS from `https://kubernetes.default.svc/openid/v1/jwks` (in-cluster, cached) — for D1 SA tokens.
- (later) an optional trusted Tailscale-proxy identity header for D2.
Additive and backward-compatible: keep accepting the current Authentik/static bearers through the migration.
## Proven viable (live-cluster spike, 2026-07-06)
k3s exposes OIDC discovery + audience-scoped `kubectl create token` out of the box — **no cluster-config change**. A `jwx/v2` verifier (the library the chassis already uses) validated a real SA token: accepts for its audience, **rejects wrong-audience** (replay block), rejects wrong-issuer. Verifier used:
```go
func Verify(token []byte, jwks jwk.Set, issuer, audience string) error {
_, err := jwt.Parse(bytes.TrimSpace(token),
jwt.WithKeySet(jwks), jwt.WithValidate(true),
jwt.WithIssuer(issuer), jwt.WithAudience(audience))
return err
}
```
For a trusted-issuer LIST: pick the entry whose `issuer` matches the token's `iss` (peek unverified), then `jwt.Parse` with that entry's JWKS + required audience. In-cluster, fetch+cache JWKS from `kubernetes.default.svc`.
## Acceptance
- [ ] `JWTValidator` accepts a `[]TrustedIssuer{ Issuer, JWKSURL, Audience }` (config/env).
- [ ] A SA token with `iss=https://kubernetes.default.svc.cluster.local` + matching audience validates; wrong-audience and wrong-issuer are rejected (unit tests).
- [ ] Existing Authentik/static-bearer paths still work (no regression).
- [ ] gitea-mcp + brain-mcp wired to trust the cluster issuer.
Then infra rolls SA-token projected volumes onto the in-cluster callers (ADR-0011 migration step 2+). Full rationale + spike detail: brain `wiki/homelab/decisions/service-to-service-auth-native-identity-per-domain-2026-07-06`; infra ADR-0011.
Done — shipped in mcp-chassis v0.4.0 (75eb8b8). Recommend close.
Heads-up: this issue was misfiled — the chassis is its own repo git.d-ma.be/mathias/mcp-chassis (module .../auth), not hyperguild. The change landed there.
JWTValidator now holds a list of issuer entries + a shared jwk.Cache; Validate tries each trusted issuer, returns the subject on first success. All-issuers-JWKS-unreachable → ErrUnavailable (503); any definitive reject → 401.
NewJWTValidator (single issuer) and BearerMiddleware signatures UNCHANGED → gitea-mcp / ingestion compile and behave identically; opting into multi-issuer is a one-line constructor swap.
Added auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS + token minting) — the chassis had no happy-path JWT test before. New tests: accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer audience enforcement, single-issuer backward compat. Full suite green (incl. all prior tests), -race/vet/gofmt clean.
gitea-mcp + brain-mcp wired to trust the cluster issuer — remaining follow-up
Remaining (ADR-0011 migration step 2+, not this issue)
Bump the mcp-chassis dep in gitea-mcp + ingestion; swap their NewJWTValidator(...) → NewMultiJWTValidator(..., {IssuerURL: "https://kubernetes.default.svc.cluster.local", Audience: "<svc>"}); mount an audience-scoped projected SA token on one pod (issue-reconcile is the clean guinea pig) → prove D1 end-to-end through a live MCP server.
Note there's no CI in mcp-chassis (.gitea/workflows absent) — a shared auth library arguably wants a test gate; worth a separate issue.
Recommend closing (chassis change complete); track the consumer wiring under ADR-0011.
## Done — shipped in mcp-chassis `v0.4.0` (`75eb8b8`). Recommend close.
Heads-up: **this issue was misfiled** — the chassis is its own repo `git.d-ma.be/mathias/mcp-chassis` (module `.../auth`), not hyperguild. The change landed there.
### What shipped (TDD, backward-compatible)
- `IssuerConfig{IssuerURL, Audience}` + **`NewMultiJWTValidator([]IssuerConfig)`**.
- `JWTValidator` now holds a list of issuer entries + a shared `jwk.Cache`; `Validate` tries each trusted issuer, returns the subject on first success. All-issuers-JWKS-unreachable → `ErrUnavailable` (503); any definitive reject → 401.
- **`NewJWTValidator` (single issuer) and `BearerMiddleware` signatures UNCHANGED** → gitea-mcp / ingestion compile and behave identically; opting into multi-issuer is a one-line constructor swap.
- Added `auth/jwt_test.go`: an in-process OIDC issuer harness (discovery + JWKS + token minting) — **the chassis had no happy-path JWT test before**. New tests: accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer audience enforcement, single-issuer backward compat. Full suite green (incl. all prior tests), `-race`/`vet`/`gofmt` clean.
### Acceptance
- [x] validator accepts `[]TrustedIssuer{Issuer, Audience}` (`IssuerConfig`)
- [x] SA-token issuer + matching audience validates; wrong-audience + wrong-issuer rejected
- [x] existing Authentik/static-bearer paths unregressed
- [ ] gitea-mcp + brain-mcp wired to trust the cluster issuer — **remaining follow-up**
### Remaining (ADR-0011 migration step 2+, not this issue)
Bump the `mcp-chassis` dep in gitea-mcp + ingestion; swap their `NewJWTValidator(...)` → `NewMultiJWTValidator(..., {IssuerURL: "https://kubernetes.default.svc.cluster.local", Audience: "<svc>"})`; mount an audience-scoped projected SA token on one pod (issue-reconcile is the clean guinea pig) → prove D1 end-to-end through a live MCP server.
Note there's **no CI in mcp-chassis** (`.gitea/workflows` absent) — a shared auth library arguably wants a test gate; worth a separate issue.
**Recommend closing** (chassis change complete); track the consumer wiring under ADR-0011.
The remaining acceptance item ("gitea-mcp wired to trust the cluster issuer") is complete and verified end-to-end on the live server.
gitea-mcp (240c3ec): bumped chassis v0.3.0 → v0.5.0; switched NewJWTValidator → NewMultiJWTValidator (Authentik + k8s issuer); added cmd/gitea-mcp/k8soidc.go (cluster-CA + SA-bearer client for the authenticated JWKS fetch). Additive + fallback: if the in-cluster OIDC is unreachable at startup the k8s issuer is dropped so Authentik/static auth is never affected. Smoke-tested off-pod (graceful skip).
infra (v0.27.0): K8S_ISSUER_URL + K8S_MCP_AUDIENCE=gitea-mcp on the deployment. Rolled via Flux; the live pod loaded both issuers with no fallback (authed JWKS fetch works from the real pod).
Live proof: a real aud=gitea-mcp SA token → the live gitea-mcp via the public ingress returned HTTP 200 (full MCP initialize); no-token = 401; the claude-ai/static path stayed up throughout.
gitea-mcp wired to trust the cluster issuer
Remaining is per-caller migration (give an in-cluster caller a projected aud=gitea-mcp token and drop its static bearer — issue-reconcile is the clean first) + optionally wiring ingestion/brain-mcp the same way. Tracked under ADR-0011, not this issue. This one is fully done — close it.
## Consumer wiring DONE + proven live on gitea-mcp
The remaining acceptance item ("gitea-mcp wired to trust the cluster issuer") is complete and verified end-to-end on the **live** server.
- **gitea-mcp** (`240c3ec`): bumped chassis v0.3.0 → **v0.5.0**; switched `NewJWTValidator` → `NewMultiJWTValidator` (Authentik + k8s issuer); added `cmd/gitea-mcp/k8soidc.go` (cluster-CA + SA-bearer client for the authenticated JWKS fetch). Additive + fallback: if the in-cluster OIDC is unreachable at startup the k8s issuer is dropped so Authentik/static auth is never affected. Smoke-tested off-pod (graceful skip).
- **infra** (`v0.27.0`): `K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp` on the deployment. Rolled via Flux; the live pod loaded **both** issuers with **no fallback** (authed JWKS fetch works from the real pod).
- **Live proof:** a real `aud=gitea-mcp` SA token → the live gitea-mcp via the public ingress returned **HTTP 200** (full MCP `initialize`); no-token = 401; the claude-ai/static path stayed up throughout.
- [x] gitea-mcp wired to trust the cluster issuer
Remaining is per-caller migration (give an in-cluster caller a projected `aud=gitea-mcp` token and drop its static bearer — issue-reconcile is the clean first) + optionally wiring ingestion/brain-mcp the same way. Tracked under ADR-0011, not this issue. **This one is fully done — close it.**
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.
Implements the one hard dependency of infra ADR-0011 (service-to-service auth: native identity per trust domain).
Problem
mcp-chassis
BearerMiddleware/JWTValidatormatchesissexactly against a singleDEX_ISSUER_URL. That's why every in-cluster caller falls back to a long-lived static bearer. ADR-0011's whole plan (in-cluster pods authenticate with kubelet-rotated, audience-bound k8s ServiceAccount tokens) hinges on the chassis trusting more than one issuer.Change
Make the validator accept a configurable list of trusted issuers, each with its own JWKS source and (optional) required audience:
https://kubernetes.default.svc.cluster.local, JWKS fromhttps://kubernetes.default.svc/openid/v1/jwks(in-cluster, cached) — for D1 SA tokens.Additive and backward-compatible: keep accepting the current Authentik/static bearers through the migration.
Proven viable (live-cluster spike, 2026-07-06)
k3s exposes OIDC discovery + audience-scoped
kubectl create tokenout of the box — no cluster-config change. Ajwx/v2verifier (the library the chassis already uses) validated a real SA token: accepts for its audience, rejects wrong-audience (replay block), rejects wrong-issuer. Verifier used:For a trusted-issuer LIST: pick the entry whose
issuermatches the token'siss(peek unverified), thenjwt.Parsewith that entry's JWKS + required audience. In-cluster, fetch+cache JWKS fromkubernetes.default.svc.Acceptance
JWTValidatoraccepts a[]TrustedIssuer{ Issuer, JWKSURL, Audience }(config/env).iss=https://kubernetes.default.svc.cluster.local+ matching audience validates; wrong-audience and wrong-issuer are rejected (unit tests).Then infra rolls SA-token projected volumes onto the in-cluster callers (ADR-0011 migration step 2+). Full rationale + spike detail: brain
wiki/homelab/decisions/service-to-service-auth-native-identity-per-domain-2026-07-06; infra ADR-0011.Done — shipped in mcp-chassis
v0.4.0(75eb8b8). Recommend close.Heads-up: this issue was misfiled — the chassis is its own repo
git.d-ma.be/mathias/mcp-chassis(module.../auth), not hyperguild. The change landed there.What shipped (TDD, backward-compatible)
IssuerConfig{IssuerURL, Audience}+NewMultiJWTValidator([]IssuerConfig).JWTValidatornow holds a list of issuer entries + a sharedjwk.Cache;Validatetries each trusted issuer, returns the subject on first success. All-issuers-JWKS-unreachable →ErrUnavailable(503); any definitive reject → 401.NewJWTValidator(single issuer) andBearerMiddlewaresignatures UNCHANGED → gitea-mcp / ingestion compile and behave identically; opting into multi-issuer is a one-line constructor swap.auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS + token minting) — the chassis had no happy-path JWT test before. New tests: accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer audience enforcement, single-issuer backward compat. Full suite green (incl. all prior tests),-race/vet/gofmtclean.Acceptance
[]TrustedIssuer{Issuer, Audience}(IssuerConfig)Remaining (ADR-0011 migration step 2+, not this issue)
Bump the
mcp-chassisdep in gitea-mcp + ingestion; swap theirNewJWTValidator(...)→NewMultiJWTValidator(..., {IssuerURL: "https://kubernetes.default.svc.cluster.local", Audience: "<svc>"}); mount an audience-scoped projected SA token on one pod (issue-reconcile is the clean guinea pig) → prove D1 end-to-end through a live MCP server.Note there's no CI in mcp-chassis (
.gitea/workflowsabsent) — a shared auth library arguably wants a test gate; worth a separate issue.Recommend closing (chassis change complete); track the consumer wiring under ADR-0011.
Consumer wiring DONE + proven live on gitea-mcp
The remaining acceptance item ("gitea-mcp wired to trust the cluster issuer") is complete and verified end-to-end on the live server.
gitea-mcp (
240c3ec): bumped chassis v0.3.0 → v0.5.0; switchedNewJWTValidator→NewMultiJWTValidator(Authentik + k8s issuer); addedcmd/gitea-mcp/k8soidc.go(cluster-CA + SA-bearer client for the authenticated JWKS fetch). Additive + fallback: if the in-cluster OIDC is unreachable at startup the k8s issuer is dropped so Authentik/static auth is never affected. Smoke-tested off-pod (graceful skip).infra (
v0.27.0):K8S_ISSUER_URL+K8S_MCP_AUDIENCE=gitea-mcpon the deployment. Rolled via Flux; the live pod loaded both issuers with no fallback (authed JWKS fetch works from the real pod).Live proof: a real
aud=gitea-mcpSA token → the live gitea-mcp via the public ingress returned HTTP 200 (full MCPinitialize); no-token = 401; the claude-ai/static path stayed up throughout.gitea-mcp wired to trust the cluster issuer
Remaining is per-caller migration (give an in-cluster caller a projected
aud=gitea-mcptoken and drop its static bearer — issue-reconcile is the clean first) + optionally wiring ingestion/brain-mcp the same way. Tracked under ADR-0011, not this issue. This one is fully done — close it.