9 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 29e461e108 feat(auth): name the path that granted access, and stop blaming the caller
CI / Lint / Test / Vet (push) Successful in 5s
Closes #1. Closes gitea-mcp#58. Closes gitea-mcp#60.

Three small changes to one decision point, kept together because they are the
same surface and the same incident.

1. A JWT IS NEVER A STATIC-TOKEN MISMATCH (gitea-mcp#58)

The fall-through reason was chosen by which comparison ran last, not by what the
caller presented. So a JWT against a server with no JWT auth configured logged:

    reason=static_token_mismatch  token_type=jwt  status=401

The two fields contradict each other, and the sentence reads as "you presented
the wrong token" when the truth is "this server could not have accepted any
token of that kind". The misdirection reached the user — claude.ai surfaces
"Authorization with gitea failed. You can check your credentials and
permissions." — and a debugging session went into re-running the OAuth flow
while the credentials were valid throughout.

Now `jwt_auth_not_configured` for a JWT; `static_token_mismatch` stays, and is
asserted to stay, for an actual static-token mismatch. Fixing the misleading
case must not delete the accurate one.

Note this is now the ONLY way to reach that branch with a JWT: since v0.6.0 a
validator that merely could not reach its issuer is non-nil and answers
ErrUnavailable -> 503. A nil validator means the server was never given an
issuer.

2. SUCCESS IS LOGGED, WITH THE PATH THAT GRANTED IT (gitea-mcp#60)

The middleware logged only on rejection. After re-authing a connector there was
no way to tell whether a request used the static token or a JWT, only inference
from which failures were absent. A log that proves a request failed, and nothing
about how the successful ones succeeded, cannot answer the question anyone
actually has during an auth incident.

    INFO mcp auth accepted auth_path=jwt caller=alice token_fp=... remote=...
    INFO mcp auth accepted auth_path=static token_fp=... remote=...

Same disclosure rules as deny: a fingerprint, never the credential. The JWT
subject is included because distinguishing WHO is the point of that path;
`caller` is omitted rather than empty for the static path, which has no identity
to report.

3. JWKS REFRESH INTERVAL IS CONFIGURABLE (#1)

It was hardcoded at 1h, so an issuer rotating its signing key could leave every
consumer rejecting valid JWTs for up to an hour. WithRefreshInterval, defaulting
to 1h so nothing changes for existing callers.

Variadic, per #1's own preferred option — a new positional parameter would force
a coordinated bump across every consumer. Deliberately NOT read from the
environment inside the library: a shared lib calling os.Getenv is a hidden
dependency that surprises its consumers. Config ownership stays at the edge.

The effective value is logged when an issuer resolves, which is #1's second
acceptance criterion and the thing an operator needs during a key-rotation
incident. The test asserts the configured value reaches the registration site
via that line — a silently-ignored option being the failure mode worth pinning.
Whether jwx then honours it is jwx's to test.

One of my own assertions was wrong and the test caught it: tokenType() says
"opaque", not "static", for a non-JWT credential. Corrected to the existing
vocabulary rather than changing the vocabulary to match a new test.

Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:57:48 +02:00
mathiasandClaude Opus 5 3257364f23 fix(auth): a validator whose issuer is down at boot is built anyway
CI / Lint / Test / Vet (push) Successful in 5s
Closes #4. Closes gitea-mcp#57.

Filed 2026-07-17, filed again 2026-09-07, recurred 2026-09-14. Each occurrence
ended with a human running `kubectl rollout restart`; the last one ran for six
days and was found by chance while gathering context for unrelated work.

THE BUG

NewMultiJWTValidator resolved OIDC discovery eagerly and returned (nil, err) if
the issuer was not answering. BearerMiddleware reads a nil validator as "this
server does not do JWT auth", so it fell through to the static-token comparison
and rejected every Authentik JWT:

    WARN mcp auth rejected reason=static_token_mismatch token_type=jwt status=401

Two states collapsed into one nil:

    static-token-only by design      -> nil   correct
    JWT required, IdP down at boot   -> nil   wrong, and permanent

Permanent because discovery was attempted exactly once, at construction.

WHY THE FIX IS SMALL

Everything downstream was already right. Validate distinguishes ErrUnavailable
(issuer unreachable -> 503, client retries) from a definitive rejection (-> 401),
and BearerMiddleware already honours that — gitea-mcp#6 got it right. That path
was simply unreachable when the validator was never built.

So: build it anyway. jwks_uri now resolves lazily on first use and re-resolves on
failure, no sooner than WithRetryInterval apart (default 30s) so a down issuer
costs one discovery request per interval, not one per request. Until it resolves,
Validate returns ErrUnavailable and the existing machinery does the rest. The
constructor still warms discovery eagerly on a healthy start; it just no longer
fails on it.

Ready(ctx) reports whether every issuer resolved, and attempts resolution for any
that have not. That makes a readiness probe the retry loop — no background
goroutine, no context to leak, and the platform's own cadence drives recovery.
A nil validator is Ready: "JWT auth disabled" is a configuration, not a fault.

WHAT DID NOT CHANGE

No issuer configured still returns (nil, nil). That is the one case where nil is
not a lie, and collapsing it would turn every static-only server into a JWT
server. Pinned by TestNewMultiJWTValidator_NoIssuer_StillReturnsNilNil.

ONE EXISTING TEST CHANGED ITS MECHANISM, NOT ITS CLAIM

TestNewMultiJWTValidator_UsesPerIssuerHTTPClientForAuthedFetch asserted that the
constructor ERRORS when discovery 401s without the fetch credential. It cannot,
now, by design. Rewritten to assert the property that actually mattered, and
more directly than before: without the credential the validator is not Ready AND
a correctly-minted token from that very issuer is refused as ErrUnavailable. The
old test only checked that a constructor returned an error.

Written test-first: jwt_bootrace_test.go red (API absent), then green. Also red
in the behavioural sense — the constructor-errors assertion above had to be
rewritten precisely because the semantics genuinely changed.

Verified: golangci-lint 0 issues; go vet clean; go test -race -count=1 ./... ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:03:50 +02:00
mathias 6f3467fa47 ci: add CI — this repo verified nothing on push
CI / Lint / Test / Vet (push) Successful in 7s
Until now no .gitea/workflows step ran anything here: no build, no test, no
vet, no lint. A push could break the repo and nothing would say so. Found by
infra scripts/check-task-properties.py, a gate that asserts what CI RUNS
rather than what a repo contains (infra v1.6.0).

Runs lint, vet and test. setup-go carries cache: false, matching the rest of
the estate -- this runner keeps GOMODCACHE warm on its own volume, so
setup-go cache restore can only collide with it (infra#360).

Verified before adding, so switching verification on could not turn the repo
red: go build, go vet, go test -race and golangci-lint against the canonical
.golangci.yml all pass at 0 issues. Each command was run exactly as the
workflow spells it, not reasoned about.

Worth noting for this repo in particular: mcp-chassis is a shared library --
gitea-mcp imports it for auth -- so it was the dependency with the least
verification and the widest blast radius.
2026-09-05 16:49:39 +02:00
mathias 0a846becee chore(lint): adopt the canonical .golangci.yml from infra/ci-templates
Estate had 26 Go repos, 3 with a lint config, all three different; the other
23 ran golangci-lint v2 defaults, which enable NO formatter -- the hole that
let gofmt drift rot undetected until it was found by hand (dma-cli#12).

Canonical lives at infra ci-templates/golangci.yml and is distributed by the
infra#309 pattern: byte-identical copies, checksum drift gate in infra. Do
not edit this file here -- edit the canonical and run `task ci:sync`.

Verified locally before pushing, not pushed-and-hoped: golangci-lint v2.11.4
run against a Go 1.26.1 toolchain (matching CI) reports 0 issues in this
repo with this config.
2026-09-05 12:51:06 +02:00
mathiasandClaude Opus 4.8 4e16913248 feat(auth): per-issuer HTTPClient for authenticated JWKS fetch (infra ADR-0011)
The k3s OIDC discovery + JWKS endpoints require an AUTHENTICATED request
(anonymous -> 401; anonymous-auth is off on the cluster) and are served over the
cluster CA. A bare http.DefaultClient (correct for public Authentik) cannot
reach them, so multi-issuer alone (v0.4.0) could not actually validate k8s SA
tokens in-cluster.

Add IssuerConfig.HTTPClient: when set, it fetches THAT issuer's discovery + JWKS
(threaded into discoverJWKSURI and jwk.Cache.Register via jwk.WithHTTPClient).
The k8s-issuer consumer supplies a client that trusts the cluster CA
(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) and carries the server
pod's SA bearer. nil keeps http.DefaultClient (Authentik/public, unchanged).

Test: an auth-gated in-process issuer — unauthenticated fetch fails, a client
carrying the credential builds + validates a token. Empirically grounded in the
k3s 401 finding during the ADR-0011 in-cluster PoC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:25:04 +02:00
mathiasandClaude Opus 4.8 75eb8b831c feat(auth): multi-issuer JWT validation — accept k8s SA tokens (infra ADR-0011)
JWTValidator trusted a single issuer (iss matched exactly against DEX_ISSUER_URL),
which is why every in-cluster caller fell back to a static bearer. Add support
for a LIST of trusted issuers so one MCP server can accept Authentik JWTs
(interactive/web) AND k3s cluster ServiceAccount tokens (in-cluster, audience-
bound, kubelet-rotated) at once — the enabler for ADR-0011.

- Add IssuerConfig{IssuerURL, Audience} + NewMultiJWTValidator([]IssuerConfig).
- Refactor JWTValidator to hold []issuerEntry + 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 —
  existing consumers (gitea-mcp, ingestion) compile and behave identically.
- Add auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS +
  token minting) — the chassis previously had NO happy-path JWT test. Covers
  accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer
  audience enforcement, single-issuer backward compat.

Proven viable by the ADR-0011 live-cluster spike (k3s OIDC/JWKS validates a
projected SA token). Follow-up: wire gitea-mcp/brain-mcp/ingestion to also trust
the k3s issuer + mount an audience-scoped projected SA token on one pod.

Refs infra ADR-0011; supersedes the sidecar in infra#183. (Tracked as
hyperguild#77, which was misfiled — the chassis is this repo, not hyperguild.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:14:56 +02:00
mathiasandClaude Opus 4.8 d5aa5b992c feat(auth): audit-log rejections + 503 on Dex outage (gitea-mcp#9, #6)
BearerMiddleware now emits a structured slog line on every rejection with the
reason (no_token / static_token_mismatch / jwt_invalid / jwt_dex_unavailable),
client IP (X-Forwarded-For aware), presented token type (jwt/opaque), and a
truncated SHA-256 fingerprint — never the raw token, so no secret material
reaches stdout/log aggregation.

Validate now tags a JWKS/Dex fetch failure with the exported ErrUnavailable
sentinel (distinct from a present-but-invalid token). BearerMiddleware maps it
to HTTP 503 with `WWW-Authenticate: Bearer error="temporarily_unavailable"`, so
a transient Dex outage is distinguishable from a bad token instead of a silent
generic 401.

No signature change — logging goes through slog.Default(); existing consumers
are unaffected until they set a default logger. Behavior reaches a consumer only
when it bumps the chassis version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:32 +02:00
mathiasandClaude Opus 4.8 69d389d3f7 chore(module): rename gitea.d-ma.be → git.d-ma.be module path
The host rename broke `go mod download` for consumers: the server serves a
go-import meta tag of git.d-ma.be/... which no longer matches the old
gitea.d-ma.be/... import path (hyperguild #74). Rename the module path to
match. Single leaf package (auth/), no internal self-imports — go.mod +
README example only. Tagged v0.2.0; v0.1.0 (old path) left intact for
unmigrated consumers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:34:18 +02:00
mathiasandClaude Opus 4.7 67decddc8a feat: initial mcp-chassis with auth primitives
Shared Go library for Mathias-owned MCP servers, born from spike S3 of
the 2026-05 homelab architecture review (see
gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md
for the viability assessment and abort-criterion check).

Provides three primitives every MCP server today re-implements:

- auth.JWTValidator — Dex OIDC JWT validation. nil-safe (nil = "JWT
  disabled"), audience-optional. Lifted from the identical
  ~80-LOC implementations in gitea-mcp and hyperguild/ingestion.
- auth.BearerMiddleware — dual-mode static-Bearer-or-Dex-JWT gate.
  Static wins first to avoid emitting a WWW-Authenticate challenge
  that would flip claude.ai's MCP client into OAuth discovery for
  static-only deployments. The fall-through 401 emits the RFC 9728
  resource_metadata header only when explicitly configured.
- auth.ProtectedResourceHandler — RFC 9728
  /.well-known/oauth-protected-resource metadata document handler.

Test coverage exercises every branch (static OK, JWT-disabled,
empty bearer, wrong static, with-challenge vs without-challenge,
nil-validator-Validate). go test -race clean.

Deps: github.com/lestrrat-go/jwx/v2 (already a dep of every consumer)
and testify (test-only). No new transitive deps.

First migration target: gitea-mcp. If that port lands in one PR
(abort criterion from spec), brain-mcp (ingestion) follows. Otherwise
chassis reverts per the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:07:53 +02:00