Add follow-up CIMD E2E and unit tests#5130
Conversation
When a remote authorization server advertises client_id_metadata_document_supported in its discovery document, thv run now presents https://toolhive.dev/oauth/client-metadata.json as its client_id instead of performing a DCR round-trip. Falls back to DCR gracefully if the AS rejects the CIMD client_id. The CIMD check runs inside PerformOAuthFlow before the DCR gate so it works regardless of which issuer discovery path was taken (configured issuer, realm-derived, or resource metadata). Includes hack/mock-cimd-server for local E2E testing. Closes #4826 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Move cimd.go and cimd_test.go to pkg/oauthproto, update package declaration - Update imports from pkg/oauth to pkg/oauthproto in handler.go and handler_test.go - Fix CodeQL SSRF alert in mock-cimd-server: validate redirect_uri is localhost before making outbound request; use io.Discard to drain response body - Fix revive lint: unused parameter, redefined builtin min - Fix errcheck lint: handle resp.Body.Close error Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Remove hack/mock-cimd-server: was added for a manual test session but has no E2E test coverage and does not belong in the final PR - Remove toolhive-client-metadata.json: the authoritative copy is in the infra repo (stacklok/infra#4549) where it gets deployed to https://toolhive.dev/oauth/client-metadata.json via CloudFront - Add client_id_metadata_document_supported: true to test/e2e/oidc_mock.go discovery document so the existing E2E mock server is CIMD-capable for future integration tests Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- cimd.go: collapse duplicate godoc; expand IsClientIDMetadataDocumentURL comment with rationale for broad HTTPS check and Phase 2 TODO - config.go: clear CachedCIMDClientID in ClearCachedClientCredentials; note in doc comment that the restart-skip reader is deferred (#2728) - discovery.go: extract applyDiscoveryPreCheck helper to stay under cyclomatic complexity limit; populate discovered endpoints on config to prevent double-fetch on the DCR path; add SkipCIMD to OAuthFlowConfig; emit WARN when AS discovery fetch fails instead of silently falling back - handler.go: fix retry-loop bug — set SkipCIMD=true on retry so the pre-check does not re-apply the CIMD URL; narrow retry trigger to invalid_client / unauthorized_client only via isCIMDRejectionError - handler_test.go: add TestIsCIMDRejectionError covering all error cases the RFC specifies (invalid_client/unauthorized_client retry; invalid_request/other errors propagate) PKCE is already enforced unconditionally in createOAuthConfig (lines 659/675 of discovery.go) for all flows including CIMD — no change needed. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Move explicit priority chain (P1/P2/P3 with comments) to performOAuthFlow; remove duplicate CIMD block from buildOAuthFlowConfig so it is a pure config builder with no registration logic - Fix Priority 1 and Priority 2 in discoverIssuerAndScopes: fetch AS discovery document even when issuer is pre-configured or realm-derived, so ClientIDMetadataDocumentSupported is populated and CIMD detection works on those paths - Remove TestBuildOAuthFlowConfig_CIMD: CIMD behaviour is now tested via TestIsCIMDRejectionError and the priority chain in performOAuthFlow Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- discovery.go: restore precise port-resolution comment explaining why order matters (DCR allows fallback, pre-registered/CIMD clients require exact port); consolidate CIMD note into the else-branch comment - handler.go: remove incorrect RFC 6749 §5.2 reference from isCIMDRejectionError — §5.2 covers token endpoint errors but CIMD rejection happens at the authorization endpoint; use plain RFC 6749 reference without a specific section Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Two test cases under Label("remote", "auth", "cimd"):
1. AS advertises CIMD support: verifies thv run presents
ToolHiveClientMetadataDocumentURL as client_id, includes PKCE
code_challenge, and never calls /oauth/register (DCR skipped).
2. AS does not advertise CIMD: verifies thv run falls back to DCR
and does not use the CIMD metadata URL as client_id.
Uses a minimal httptest-based mock AS (cimdMockAuthServer) rather
than OIDCMockServer because Fosite's GetClient rejects HTTPS client
IDs that are not pre-registered — CIMD requires accepting any valid
HTTPS URL as client_id.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ror check CachedCIMDClientID: - Re-introduce the field with a clear doc comment explaining why it is kept separate from CachedClientID (independent lifecycle — DCR credential rotation must not clear the stable CIMD URL) - Guard clientCredentialsPersister so CIMD URLs are NOT stored in CachedClientID; they go to CachedCIMDClientID instead - Implement the reader in resolveClientCredentials: CIMD URL is returned as client_id for token refresh on restart, checked before DCR credentials; this was the deferred reader Jakub flagged - ClearCachedClientCredentials does not clear CachedCIMDClientID (documented in its comment) shouldUseCIMD: - Extract the four-condition CIMD predicate into a named helper, mirroring shouldDynamicallyRegisterClient (jhrozek non-blocking) isCIMDRejectionError: - Replace strings.Contains with errors.As(*oauth2.RetrieveError) to avoid substring collisions and format coupling (jhrozek non-blocking) - Update TestIsCIMDRejectionError to use real *oauth2.RetrieveError values matching what golang.org/x/oauth2 actually returns Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
CIMD rejection surfaces from two distinct stages: - Authorization endpoint: AS redirects callback with error=invalid_client; flow.go formats this as "OAuth error: <code> - <description>" (plain error, not *oauth2.RetrieveError). - Token endpoint: *oauth2.RetrieveError with ErrorCode set. The previous errors.As-only check missed auth-endpoint rejections entirely, meaning the DCR fallback never triggered when the AS rejected the CIMD URL at the authorize step. Fix by checking errors.As first (precise, for token endpoint), then strings.HasPrefix for the auth-endpoint format. Update TestIsCIMDRejectionError to cover both error sources. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
E2E (test/e2e/): - Extend cimdMockAuthServer with rejectCIMD option: first authorize request with a CIMD client_id returns error=invalid_client; subsequent requests with a DCR-issued client_id succeed normally - New test: "falls back to DCR when AS rejects the CIMD client_id" — verifies the full retry path end-to-end: CIMD attempted, rejected, DCR fallback fires, server becomes reachable Unit (pkg/auth/remote/handler_test.go): - TestResolveClientCredentials: 5 table-driven cases covering CachedCIMDClientID precedence over DCR and static credentials, empty-secret guarantee for CIMD, and DCR/static fallback when CachedCIMDClientID is empty Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5130 +/- ##
=======================================
Coverage 67.37% 67.38%
=======================================
Files 599 599
Lines 60610 60610
=======================================
+ Hits 40839 40844 +5
+ Misses 16677 16670 -7
- Partials 3094 3096 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Brings in changes from main (v0.25.0 release cycle) and regenerates the swagger documentation to match, fixing the Docs CI check.
Some authorization servers (e.g. Granola) advertise client_id_metadata_document_supported only in their RFC 8414 /.well-known/oauth-authorization-server document, not in the OIDC /.well-known/openid-configuration document. When discoverOIDCEndpointsWithClientAndValidation falls back to the OAuth AS metadata to fetch a missing registration_endpoint, it now also merges ClientIDMetadataDocumentSupported so CIMD detection works correctly for servers that split their metadata across the two well-known endpoints. Tested against Granola (https://mcp-auth.granola.ai): CIMD URL now appears as client_id in the authorize request instead of a DCR-issued opaque identifier. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
LGTM. Tests-only follow-up to the CIMD work — useful additions:
- TestResolveClientCredentials pins down the CIMD > DCR > static priority chain, which is the kind of precedence logic that's easy to break in a refactor.
- The "AS rejects CIMD client_id" e2e scenario is the right next test after #5085 — it exercises the runtime fallback that the discovery-based test doesn't reach.
A few non-blocking nits if you fancy a follow-up:
isCIMDClientIDin the helper duplicatesoauthproto.IsClientIDMetadataDocumentURL. The other test file in this package already importsoauthproto, so we could just call the production function directly. There's a TODO incimd.goto tighten that check per the IETF draft, and if that lands the mock will silently accept URLs production rejects.- The "CachedClientID used when CachedCIMDClientID is empty" case asserts that a DCR client_id pairs with a static client_secret when no
CachedClientSecretRefis stored. Looking at handler.go:236-264 that's what the code does today, but it feels off — a public DCR client and a configured secret being combined. Worth a follow-up issue to either fix it or comment why it's intentional, but not for this PR. TestResolveClientCredentialsdoesn't cover the secretProvider success or error paths (the warn-and-fall-through branch). AmockSecretProvideralready lives in this package — easy to add.
Separately: the *bytes.Buffer shared between exec's stream-copy goroutine and the test's Eventually polling is a latent data race that pre-dates this PR (came in with #5085). The new test adds more polling against it, which makes flakes a bit more likely. Worth wrapping in a tiny mutex-guarded snapshot helper at some point — go test -race should fault on it.
Summary
Follow-up to #5085 — adds the two test cases Jakub flagged as out-of-scope for the main PR.
Changes
E2E tests (
test/e2e/)cimdMockAuthServerwith arejectCIMDoption: on the first authorize request with a CIMD URL asclient_id, the mock AS redirects witherror=invalid_client. Subsequent requests (with a DCR-issuedclient_id) succeed normally.Unit test (
pkg/auth/remote/handler_test.go)TestResolveClientCredentials: 5 table-driven cases coveringCachedCIMDClientIDprecedence over DCR and static credentials, empty-secret guarantee for CIMD, and DCR/static fallback whenCachedCIMDClientIDis empty.Dependency
Depends on #5085. Targeting that branch as base; will be rebased onto main once #5085 merges.
Generated with Claude Code