Skip to content

Add follow-up CIMD E2E and unit tests#5130

Merged
amirejaz merged 16 commits into
mainfrom
cimd-followup-tests
May 1, 2026
Merged

Add follow-up CIMD E2E and unit tests#5130
amirejaz merged 16 commits into
mainfrom
cimd-followup-tests

Conversation

@amirejaz

Copy link
Copy Markdown
Contributor

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/)

  • Extended cimdMockAuthServer with a rejectCIMD option: on the first authorize request with a CIMD URL as client_id, the mock AS redirects with 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" — exercises the full retry path end-to-end: CIMD attempted → rejected → DCR fallback fires → server becomes reachable.

Unit test (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.

Dependency

Depends on #5085. Targeting that branch as base; will be rebased onto main once #5085 merges.

Generated with Claude Code

amirejaz and others added 10 commits April 28, 2026 16:08
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>
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Apr 29, 2026
@codecov

codecov Bot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.38%. Comparing base (ac8fe8e) to head (2d38188).
⚠️ Report is 3 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Brings in changes from main (v0.25.0 release cycle) and regenerates
the swagger documentation to match, fixing the Docs CI check.
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Apr 29, 2026
@amirejaz amirejaz marked this pull request as ready for review April 29, 2026 15:53
amirejaz and others added 2 commits April 30, 2026 02:41
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>
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Apr 30, 2026
Base automatically changed from cimd-thv-run-client-support to main April 30, 2026 13:40
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Apr 30, 2026

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • isCIMDClientID in the helper duplicates oauthproto.IsClientIDMetadataDocumentURL. The other test file in this package already imports oauthproto, so we could just call the production function directly. There's a TODO in cimd.go to 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 CachedClientSecretRef is 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.
  • TestResolveClientCredentials doesn't cover the secretProvider success or error paths (the warn-and-fall-through branch). A mockSecretProvider already 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.

@amirejaz amirejaz merged commit 9f8256f into main May 1, 2026
46 of 47 checks passed
@amirejaz amirejaz deleted the cimd-followup-tests branch May 1, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants