Skip to content

Delegate tokenexchange HTTP plumbing to pkg/oauthproto#5226

Open
jhrozek wants to merge 1 commit intomainfrom
oauth-refactor-prep-4
Open

Delegate tokenexchange HTTP plumbing to pkg/oauthproto#5226
jhrozek wants to merge 1 commit intomainfrom
oauth-refactor-prep-4

Conversation

@jhrozek
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek commented May 8, 2026

Summary

  • Why: pkg/auth/tokenexchange carried its own copies of token-endpoint request construction, body draining, size capping, and RFC 6749 response parsing. Earlier commits in this series moved those into pkg/oauthproto for the upcoming JWT-bearer grant. Keeping the local duplicates around meant the two paths would drift, so this PR completes the migration and fulfils the TODO: drop when executeTokenExchangeRequest is replaced by oauthproto.DoTokenRequest planted in Use shared pkg/oauthproto helpers in tokenexchange #5212.
  • What:
    • exchangeToken now collapses to buildFormData + oauthproto.NewFormRequest + oauthproto.DoTokenRequest. The 5 local helpers (createTokenExchangeRequest, executeTokenExchangeRequest, parseTokenExchangeResponse, validateResponseStatus, the local response struct, and the maxResponseBodySize constant) are deleted, along with the now-unused imports encoding/json, io, log/slog, strconv, time.
    • RFC 8693 §2.2.1 enforcement (non-empty token_type and issued_token_type) is preserved at the call site — oauthproto.ParseTokenResponse is intentionally permissive on token_type to match x/oauth2/Google, so the exchange grant tightens it back. The redundant access_token empty check is gone; oauthproto.ParseTokenResponse emits "oauth: token response missing access_token (RFC 6749 Section 5.1)" centrally.
    • The historical SubjectTokenType short-form normalization in Validate() is preserved and pinned by TestExchangeConfig_Validate_NormalizesSubjectTokenType.
    • Test fixtures migrate from the in-file responseBuilder to the shared oauthtest.ResponseBuilder from pkg/oauthproto/oauthtest. Three literal response{...} constructions in middleware_test.go get the same treatment.
    • RetrieveError.Body scrubbing is preserved as an explicit opt-in: pkg/oauthproto.parseRetrieveError keeps Body populated for general-purpose callers, but tokenexchange.exchangeToken does an errors.As + Body = nil step right after DoTokenRequest. TestExchangeToken_HTTPErrorResponses re-asserts Body == nil so the property cannot drift again.

Type of change

  • Refactoring (no behavior change)

Test plan

  • Unit tests (task test) — pkg/auth/tokenexchange/... and pkg/vmcp/auth/strategies/... pass with -race.
  • Linting (task lint-fix) — 0 issues.
  • E2E tests (task test-e2e via PR CI on kindest/node v1.33/v1.34/v1.35).

API Compatibility

  • This PR does not break the v1beta1 API.

Does this introduce a user-facing change?

Yes — operators or alerts keyed on the old tokenexchange error strings need to pick up the new substrings:

  • "token exchange request failed""oauth: token request failed"
  • "failed to parse token exchange response""oauth: cannot parse token response"
  • "failed to create token exchange request""tokenexchange: build request"
  • "empty access_token" (vmcp strategy path) → "missing access_token"

Error types are unchanged: *oauth2.RetrieveError still surfaces on non-2xx responses (and now on 2xx responses that carry an RFC 6749 §5.2 error field), and errors.As chains still work.

Special notes for reviewers

  • Third PR in the pkg/oauthproto consolidation series (after Use shared pkg/oauthproto helpers in tokenexchange #5212 and the CIMD refactor). The shared NewFormRequest / DoTokenRequest / ParseTokenResponse helpers landed in earlier commits; this PR is purely the consumer migration.
  • The body-clearing on RetrieveError (commit body section "RetrieveError.Body preservation") is an explicit opt-in to keep the pre-refactor security property — pkg/oauthproto deliberately preserves Body so general-purpose callers can surface server replies verbatim, but the tokenexchange errors flow through paths that may log err.Error(), so the package opts back into the stricter behavior.
  • Diff is dominated by test churn (most of exchange_test.go is migrated to oauthtest.ResponseBuilder); the production-code delta in exchange.go is the substantive change.
  • An initial draft of this PR also tightened ExchangeConfig.Validate() on TokenURL to reject plain-HTTP non-localhost endpoints. That hardening was reverted (force-pushed) after the e2e suite — which spins up cluster-local plain-HTTP mock auth servers — surfaced it as a behavior change. A refactor PR shouldn't carry it; the tightening will be proposed separately with appropriate plumbing for in-cluster test setups.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label May 8, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 8, 2026

Codecov Report

❌ Patch coverage is 84.61538% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.83%. Comparing base (8d9eb28) to head (2e9af0c).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/auth/tokenexchange/exchange.go 84.61% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5226      +/-   ##
==========================================
- Coverage   67.87%   67.83%   -0.04%     
==========================================
  Files         610      610              
  Lines       62383    62340      -43     
==========================================
- Hits        42340    42287      -53     
- Misses      16871    16874       +3     
- Partials     3172     3179       +7     

☔ 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.

Now that pkg/oauthproto owns request construction, body draining, size
limiting, and RFC 6749 token-response parsing, tokenexchange no longer
needs its own copies. Route exchangeToken through
oauthproto.NewFormRequest + oauthproto.DoTokenRequest and drop the
in-package helpers plus the now-unused imports (encoding/json, io,
log/slog, strconv, time).

exchange.go changes:

- Delete createTokenExchangeRequest, executeTokenExchangeRequest,
  parseTokenExchangeResponse, validateResponseStatus, the local
  response struct, and the local maxResponseBodySize constant.
  exchangeToken collapses to three calls: buildFormData,
  oauthproto.NewFormRequest, oauthproto.DoTokenRequest.

- Preserve RFC 8693 Section 2.2.1 at the call site.
  oauthproto.ParseTokenResponse is deliberately permissive about
  token_type (x/oauth2 library parity, Google historically omits it),
  so tokenSource.Token() still enforces both token_type and
  issued_token_type for RFC 8693 compliance. The redundant
  AccessToken-empty check is gone; oauthproto.ParseTokenResponse emits
  "oauth: token response missing access_token (RFC 6749 Section 5.1)"
  centrally.

- Rename buildTokenExchangeFormData to buildFormData now that the
  package context makes the TokenExchange prefix redundant.

- The SubjectTokenType mutation (c.SubjectTokenType = normalized) is
  preserved — pkg/vmcp and pkg/runner read the field post-Validate —
  and pinned by a new TestExchangeConfig_Validate_NormalizesSubjectTokenType
  regression test covering short forms, already-normalized URNs, and
  empty.

Test changes:

- Replace the in-file testResponse struct and responseBuilder helper
  with oauthtest.ResponseBuilder from pkg/oauthproto/oauthtest. The
  shared builder was introduced in the first commit of this series
  specifically so tokenexchange and the upcoming jwtbearer grant stop
  drifting; keeping a local duplicate defeated the point. Same
  treatment for three literal testResponse{...} constructions in
  middleware_test.go.

- Update error-substring assertions to match the new emitters in
  pkg/oauthproto: "token exchange request failed" -> "oauth: token
  request failed", "failed to parse token exchange response" ->
  "oauth: cannot parse token response", "failed to create token
  exchange request" -> "tokenexchange: build request". In
  pkg/vmcp/auth/strategies/tokenexchange_test.go, "empty access_token"
  -> "missing access_token" because oauthproto.ParseTokenResponse now
  owns that §5.1 check.

RetrieveError.Body preservation:

- pkg/oauthproto.parseRetrieveError leaves the raw upstream body on
  *oauth2.RetrieveError so general-purpose callers can surface server
  replies verbatim. tokenexchange historically scrubbed this field to
  prevent raw IdP content from leaking into error strings (matching the
  pattern used by Ory Hydra). exchangeToken now opts back into the
  stricter behavior with an errors.As + Body = nil step right after
  DoTokenRequest. The TestExchangeToken_HTTPErrorResponses table
  re-asserts Body == nil so the property cannot drift again.

User-facing: operators or alerts keyed on the old tokenexchange
error strings will need to pick up the new substrings. Error types
are unchanged (*oauth2.RetrieveError still surfaces on non-2xx and
2xx-with-error bodies; errors.As chains still work)
@jhrozek jhrozek force-pushed the oauth-refactor-prep-4 branch from 96acd35 to 2e9af0c Compare May 8, 2026 12:22
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/L Large PR: 600-999 lines changed size/M Medium PR: 300-599 lines changed labels May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant