返回 DeepSeek-Reasonix
oauth.go
根目录 / internal / plugin / oauth.go
1 package plugin
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "net"
14 "net/http"
15 "net/url"
16 "os"
17 "slices"
18 "strings"
19 "time"
20
21 "reasonix/internal/mcpdiag"
22 "reasonix/internal/secrets"
23 )
24
25 const (
26 mcpOAuthStateFile = "oauth.json"
27 mcpOAuthGenerationFile = "oauth.generation"
28 maxOAuthBody = 1 << 20
29 )
30
31 // Protocol sources: MCP Authorization (2025-11-25), RFC 9728 protected
32 // resources, RFC 8414 server metadata, RFC 7591 DCR, and RFC 7636 PKCE.
33
34 // mcpOAuthState is Reasonix-owned authorization state for one MCP server. It
35 // lives under Spec.StateDir, never in a project or another client's keychain.
36 // Versioned JSON gives future readers an explicit migration boundary.
37 type mcpOAuthState struct {
38 Version int `json:"version"`
39 Resource string `json:"resource"`
40 Issuer string `json:"issuer"`
41 AuthorizationEndpoint string `json:"authorization_endpoint"`
42 TokenEndpoint string `json:"token_endpoint"`
43 RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
44 ClientID string `json:"client_id"`
45 ClientSecret string `json:"client_secret,omitempty"`
46 TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
47 Scope string `json:"scope,omitempty"`
48 AccessToken string `json:"access_token,omitempty"`
49 RefreshToken string `json:"refresh_token,omitempty"`
50 TokenType string `json:"token_type,omitempty"`
51 Expiry time.Time `json:"expiry,omitempty"`
52 }
53
54 type protectedResourceMetadata struct {
55 Resource string `json:"resource"`
56 AuthorizationServers []string `json:"authorization_servers"`
57 ScopesSupported []string `json:"scopes_supported"`
58 }
59
60 type authorizationServerMetadata struct {
61 Issuer string `json:"issuer"`
62 AuthorizationEndpoint string `json:"authorization_endpoint"`
63 TokenEndpoint string `json:"token_endpoint"`
64 RegistrationEndpoint string `json:"registration_endpoint"`
65 CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
66 TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
67 }
68
69 type dynamicClientRegistration struct {
70 ClientID string `json:"client_id"`
71 ClientSecret string `json:"client_secret"`
72 TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
73 }
74
75 type oauthTokenResponse struct {
76 AccessToken string `json:"access_token"`
77 RefreshToken string `json:"refresh_token"`
78 TokenType string `json:"token_type"`
79 ExpiresIn int64 `json:"expires_in"`
80 Scope string `json:"scope"`
81 Error string `json:"error"`
82 Description string `json:"error_description"`
83 }
84
85 type mcpOAuthClient struct {
86 stateDir string
87 state mcpOAuthState
88 client *http.Client
89 runtime mcpOAuthSDKRuntime
90 }
91
92 // AuthorizeHTTPMCP performs the user-initiated OAuth authorization-code flow
93 // for an HTTP MCP server. It implements protected-resource discovery, OAuth
94 // server metadata discovery, dynamic client registration, PKCE S256, a
95 // loopback callback, resource indicators, and private token persistence.
96 func AuthorizeHTTPMCP(ctx context.Context, spec Spec, openURL func(string) error) error {
97 if err := validateHTTPMCPAuthorization(spec, openURL); err != nil {
98 return err
99 }
100 if err := os.MkdirAll(spec.StateDir, 0o700); err != nil {
101 return fmt.Errorf("MCP OAuth: prepare private state directory: %w", err)
102 }
103 generation, err := captureMCPOAuthGeneration(ctx, spec.StateDir)
104 if err != nil {
105 return err
106 }
107 endpoint, err := parseSecureOAuthURL(spec.URL, true)
108 if err != nil {
109 return fmt.Errorf("MCP OAuth endpoint: %w", err)
110 }
111 client := newOAuthHTTPClient(spec.OAuthHTTPClient)
112 resourceMeta, challengedScope, err := discoverProtectedResource(ctx, client, endpoint)
113 if err != nil {
114 return err
115 }
116 resource, issuer, err := oauthResourceAndIssuer(resourceMeta, endpoint)
117 if err != nil {
118 return err
119 }
120 authMeta, err := discoverAuthorizationServer(ctx, client, issuer)
121 if err != nil {
122 return err
123 }
124 if strings.TrimSpace(authMeta.AuthorizationEndpoint) == "" || strings.TrimSpace(authMeta.TokenEndpoint) == "" {
125 return fmt.Errorf("MCP OAuth: authorization server metadata is missing authorization_endpoint or token_endpoint")
126 }
127 if len(authMeta.CodeChallengeMethodsSupported) > 0 && !slices.Contains(authMeta.CodeChallengeMethodsSupported, "S256") {
128 return fmt.Errorf("MCP OAuth: authorization server does not support PKCE S256")
129 }
130
131 listener, err := net.Listen("tcp", "127.0.0.1:0")
132 if err != nil {
133 return fmt.Errorf("MCP OAuth callback: %w", err)
134 }
135 defer listener.Close()
136 redirectURI := "http://" + listener.Addr().String() + "/oauth/callback"
137
138 registration, err := registerOAuthClient(ctx, client, authMeta, redirectURI)
139 if err != nil {
140 return err
141 }
142 verifier, err := randomBase64URL(64)
143 if err != nil {
144 return fmt.Errorf("MCP OAuth PKCE: %w", err)
145 }
146 requestState, err := randomBase64URL(32)
147 if err != nil {
148 return fmt.Errorf("MCP OAuth state: %w", err)
149 }
150 scope := strings.TrimSpace(challengedScope)
151 if scope == "" {
152 scope = strings.Join(resourceMeta.ScopesSupported, " ")
153 }
154
155 callbackResult := make(chan oauthCallbackResult, 1)
156 callbackServer := &http.Server{ReadHeaderTimeout: 5 * time.Second}
157 callbackServer.Handler = oauthCallbackHandler(requestState, callbackResult)
158 serveDone := make(chan error, 1)
159 go func() {
160 err := callbackServer.Serve(listener)
161 if errors.Is(err, http.ErrServerClosed) {
162 err = nil
163 }
164 serveDone <- err
165 }()
166 defer callbackServer.Close()
167
168 authorizationURL, err := buildAuthorizationURL(authMeta.AuthorizationEndpoint, registration.ClientID, redirectURI, requestState, verifier, resource, scope)
169 if err != nil {
170 return err
171 }
172 if err := openURL(authorizationURL); err != nil {
173 return fmt.Errorf("open MCP authorization page: %w", err)
174 }
175
176 var callback oauthCallbackResult
177 select {
178 case <-ctx.Done():
179 return fmt.Errorf("MCP OAuth callback: %w", ctx.Err())
180 case err := <-serveDone:
181 if err != nil {
182 return fmt.Errorf("MCP OAuth callback server: %w", err)
183 }
184 return fmt.Errorf("MCP OAuth callback server stopped before authorization completed")
185 case callback = <-callbackResult:
186 }
187 if callback.Err != nil {
188 return callback.Err
189 }
190
191 state := mcpOAuthState{
192 Version: 1,
193 Resource: resource,
194 Issuer: authMeta.Issuer,
195 AuthorizationEndpoint: authMeta.AuthorizationEndpoint,
196 TokenEndpoint: authMeta.TokenEndpoint,
197 RegistrationEndpoint: authMeta.RegistrationEndpoint,
198 ClientID: registration.ClientID,
199 ClientSecret: registration.ClientSecret,
200 TokenEndpointAuthMethod: registration.TokenEndpointAuthMethod,
201 Scope: scope,
202 }
203 token, err := exchangeAuthorizationCode(ctx, client, state, callback.Code, verifier, redirectURI)
204 if err != nil {
205 return err
206 }
207 applyTokenResponse(&state, token, time.Now())
208 return saveMCPOAuthStateIfGenerationUnchanged(ctx, spec.StateDir, generation, state)
209 }
210
211 func validateHTTPMCPAuthorization(spec Spec, openURL func(string) error) error {
212 if openURL == nil {
213 return fmt.Errorf("MCP OAuth: browser opener is required")
214 }
215 if !isHTTPMCPTransport(spec.Type) {
216 return fmt.Errorf("MCP OAuth is only available for HTTP transports")
217 }
218 if mcpdiag.HasAuthConfig(spec.Headers, spec.Env, spec.URL) {
219 return fmt.Errorf("MCP OAuth is unavailable while explicit authentication is configured")
220 }
221 if strings.TrimSpace(spec.StateDir) == "" {
222 return fmt.Errorf("MCP OAuth: private state directory is unavailable")
223 }
224 return nil
225 }
226
227 // ClearHTTPMCPOAuth removes Reasonix-owned OAuth client and token state for one
228 // MCP server. It does not alter static headers or another application's data.
229 func ClearHTTPMCPOAuth(spec Spec) (bool, error) {
230 return reconcileHTTPMCPOAuth(spec, "")
231 }
232
233 // ReconcileHTTPMCPOAuthAfterRemoval removes Reasonix-owned OAuth state after an
234 // MCP declaration is removed, unless the remaining effective HTTP declaration
235 // uses the same resource. Callers pass an empty remainingResource when no
236 // eligible fallback remains.
237 func ReconcileHTTPMCPOAuthAfterRemoval(spec Spec, remainingResource string) (bool, error) {
238 return reconcileHTTPMCPOAuth(spec, strings.TrimSpace(remainingResource))
239 }
240
241 func reconcileHTTPMCPOAuth(spec Spec, remainingResource string) (bool, error) {
242 path := mcpOAuthStatePath(spec.StateDir)
243 if path == "" {
244 return false, nil
245 }
246 if err := os.MkdirAll(spec.StateDir, 0o700); err != nil {
247 return false, fmt.Errorf("clear MCP OAuth state: prepare private state directory: %w", err)
248 }
249 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
250 defer cancel()
251 release, err := acquireMCPOAuthStateLock(ctx, spec.StateDir)
252 if err != nil {
253 return false, fmt.Errorf("clear MCP OAuth state: %w", err)
254 }
255 defer release()
256 if remainingResource != "" {
257 state, loadErr := loadMCPOAuthState(spec.StateDir)
258 if loadErr == nil && sameCanonicalResource(state.Resource, remainingResource) {
259 return false, nil
260 }
261 }
262 if err := bumpMCPOAuthGeneration(spec.StateDir); err != nil {
263 return false, fmt.Errorf("clear MCP OAuth state: %w", err)
264 }
265 if err := os.Remove(path); err != nil {
266 if errors.Is(err, os.ErrNotExist) {
267 return false, nil
268 }
269 return false, fmt.Errorf("clear MCP OAuth state: %w", err)
270 }
271 return true, nil
272 }
273
274 func newMCPOAuthClient(stateDir string, httpClient *http.Client) (*mcpOAuthClient, error) {
275 state, err := loadMCPOAuthState(stateDir)
276 if err != nil {
277 return nil, err
278 }
279 if strings.TrimSpace(state.AccessToken) == "" && strings.TrimSpace(state.RefreshToken) == "" {
280 return nil, nil
281 }
282 return &mcpOAuthClient{stateDir: stateDir, state: state, client: newOAuthHTTPClient(httpClient)}, nil
283 }
284
285 func discoverProtectedResource(ctx context.Context, client *http.Client, endpoint *url.URL) (protectedResourceMetadata, string, error) {
286 var metadataURL string
287 var scope string
288 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
289 if err != nil {
290 return protectedResourceMetadata{}, "", err
291 }
292 req.Header.Set("Content-Type", "application/json")
293 req.Header.Set("Accept", "application/json, text/event-stream")
294 resp, err := client.Do(req)
295 if err == nil {
296 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
297 _ = resp.Body.Close()
298 if resp.StatusCode == http.StatusUnauthorized {
299 for _, challenge := range resp.Header.Values("WWW-Authenticate") {
300 if foundURL, foundScope, ok := parseBearerChallenge(challenge); ok {
301 metadataURL, scope = foundURL, foundScope
302 break
303 }
304 }
305 }
306 }
307 if metadataURL != "" {
308 u, parseErr := parseSecureOAuthURL(metadataURL, true)
309 if parseErr != nil {
310 return protectedResourceMetadata{}, "", fmt.Errorf("MCP OAuth resource metadata URL: %w", parseErr)
311 }
312 if !sameHTTPOrigin(endpoint, u) {
313 return protectedResourceMetadata{}, "", fmt.Errorf("MCP OAuth resource metadata URL changed origin")
314 }
315 var metadata protectedResourceMetadata
316 if err := getOAuthJSON(ctx, client, u.String(), &metadata); err != nil {
317 return protectedResourceMetadata{}, "", fmt.Errorf("MCP OAuth protected resource metadata: %w", err)
318 }
319 return metadata, scope, nil
320 }
321
322 var lastErr error
323 for _, candidate := range protectedResourceMetadataURLs(endpoint) {
324 var metadata protectedResourceMetadata
325 if err := getOAuthJSON(ctx, client, candidate, &metadata); err == nil {
326 return metadata, scope, nil
327 } else {
328 lastErr = err
329 }
330 }
331 if lastErr == nil && err != nil {
332 lastErr = err
333 }
334 return protectedResourceMetadata{}, "", fmt.Errorf("MCP OAuth protected resource discovery failed: %w", lastErr)
335 }
336
337 func discoverAuthorizationServer(ctx context.Context, client *http.Client, issuer *url.URL) (authorizationServerMetadata, error) {
338 var lastErr error
339 for _, candidate := range authorizationServerMetadataURLs(issuer) {
340 var metadata authorizationServerMetadata
341 if err := getOAuthJSON(ctx, client, candidate, &metadata); err != nil {
342 lastErr = err
343 continue
344 }
345 if strings.TrimRight(metadata.Issuer, "/") != strings.TrimRight(issuer.String(), "/") {
346 lastErr = fmt.Errorf("issuer mismatch: metadata=%q requested=%q", metadata.Issuer, issuer.String())
347 continue
348 }
349 if _, err := parseSecureOAuthURL(metadata.AuthorizationEndpoint, true); err != nil {
350 return authorizationServerMetadata{}, fmt.Errorf("MCP OAuth authorization endpoint: %w", err)
351 }
352 if _, err := parseSecureOAuthURL(metadata.TokenEndpoint, true); err != nil {
353 return authorizationServerMetadata{}, fmt.Errorf("MCP OAuth token endpoint: %w", err)
354 }
355 return metadata, nil
356 }
357 return authorizationServerMetadata{}, fmt.Errorf("MCP OAuth authorization server discovery failed: %w", lastErr)
358 }
359
360 func registerOAuthClient(ctx context.Context, client *http.Client, metadata authorizationServerMetadata, redirectURI string) (dynamicClientRegistration, error) {
361 if strings.TrimSpace(metadata.RegistrationEndpoint) == "" {
362 return dynamicClientRegistration{}, fmt.Errorf("MCP OAuth: authorization server does not advertise dynamic client registration")
363 }
364 if _, err := parseSecureOAuthURL(metadata.RegistrationEndpoint, true); err != nil {
365 return dynamicClientRegistration{}, fmt.Errorf("MCP OAuth registration endpoint: %w", err)
366 }
367 method := chooseTokenEndpointAuthMethod(metadata.TokenEndpointAuthMethodsSupported)
368 body, err := json.Marshal(map[string]any{
369 "client_name": "Reasonix",
370 "redirect_uris": []string{redirectURI},
371 "grant_types": []string{"authorization_code", "refresh_token"},
372 "response_types": []string{"code"},
373 "token_endpoint_auth_method": method,
374 })
375 if err != nil {
376 return dynamicClientRegistration{}, err
377 }
378 req, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.RegistrationEndpoint, bytes.NewReader(body))
379 if err != nil {
380 return dynamicClientRegistration{}, err
381 }
382 req.Header.Set("Content-Type", "application/json")
383 req.Header.Set("Accept", "application/json")
384 resp, err := client.Do(req)
385 if err != nil {
386 return dynamicClientRegistration{}, fmt.Errorf("MCP OAuth client registration: %w", err)
387 }
388 defer resp.Body.Close()
389 if resp.StatusCode/100 != 2 {
390 return dynamicClientRegistration{}, oauthHTTPError("MCP OAuth client registration", resp)
391 }
392 var registration dynamicClientRegistration
393 if err := decodeLimitedJSON(resp.Body, &registration); err != nil {
394 return dynamicClientRegistration{}, fmt.Errorf("MCP OAuth client registration: %w", err)
395 }
396 if strings.TrimSpace(registration.ClientID) == "" {
397 return dynamicClientRegistration{}, fmt.Errorf("MCP OAuth client registration returned no client_id")
398 }
399 if registration.TokenEndpointAuthMethod == "" {
400 registration.TokenEndpointAuthMethod = method
401 }
402 return registration, nil
403 }
404
405 func exchangeAuthorizationCode(ctx context.Context, client *http.Client, state mcpOAuthState, code, verifier, redirectURI string) (oauthTokenResponse, error) {
406 form := url.Values{
407 "grant_type": {"authorization_code"},
408 "code": {code},
409 "redirect_uri": {redirectURI},
410 "code_verifier": {verifier},
411 "client_id": {state.ClientID},
412 "resource": {state.Resource},
413 }
414 return requestOAuthToken(ctx, client, state, form)
415 }
416
417 func requestOAuthToken(ctx context.Context, client *http.Client, state mcpOAuthState, form url.Values) (oauthTokenResponse, error) {
418 req, err := http.NewRequestWithContext(ctx, http.MethodPost, state.TokenEndpoint, strings.NewReader(form.Encode()))
419 if err != nil {
420 return oauthTokenResponse{}, err
421 }
422 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
423 req.Header.Set("Accept", "application/json")
424 switch state.TokenEndpointAuthMethod {
425 case "client_secret_post":
426 form.Set("client_secret", state.ClientSecret)
427 req.Body = io.NopCloser(strings.NewReader(form.Encode()))
428 req.ContentLength = int64(len(form.Encode()))
429 case "none":
430 default:
431 if state.ClientSecret != "" {
432 // RFC 6749 section 2.3.1 applies application/x-www-form-urlencoded
433 // encoding to both credentials before constructing HTTP Basic auth.
434 req.SetBasicAuth(url.QueryEscape(state.ClientID), url.QueryEscape(state.ClientSecret))
435 }
436 }
437 resp, err := client.Do(req)
438 if err != nil {
439 return oauthTokenResponse{}, err
440 }
441 defer resp.Body.Close()
442 if resp.StatusCode/100 != 2 {
443 return oauthTokenResponse{}, oauthHTTPError("MCP OAuth token request", resp)
444 }
445 var token oauthTokenResponse
446 if err := decodeLimitedJSON(resp.Body, &token); err != nil {
447 return oauthTokenResponse{}, err
448 }
449 if token.Error != "" {
450 return oauthTokenResponse{}, fmt.Errorf("%s: %s", secrets.RedactCredentials(token.Error), secrets.RedactCredentials(token.Description))
451 }
452 if strings.TrimSpace(token.AccessToken) == "" {
453 return oauthTokenResponse{}, fmt.Errorf("token response has no access_token")
454 }
455 return token, nil
456 }
457
458 func applyTokenResponse(state *mcpOAuthState, token oauthTokenResponse, now time.Time) {
459 state.AccessToken = token.AccessToken
460 if token.RefreshToken != "" {
461 state.RefreshToken = token.RefreshToken
462 }
463 state.TokenType = token.TokenType
464 if state.TokenType == "" {
465 state.TokenType = "Bearer"
466 }
467 if token.Scope != "" {
468 state.Scope = token.Scope
469 }
470 if token.ExpiresIn > 0 {
471 state.Expiry = now.Add(time.Duration(token.ExpiresIn) * time.Second)
472 } else {
473 state.Expiry = time.Time{}
474 }
475 }
476
477 type oauthCallbackResult struct {
478 Code string
479 Err error
480 }
481
482 func oauthCallbackHandler(expectedState string, result chan<- oauthCallbackResult) http.Handler {
483 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
484 if r.URL.Path != "/oauth/callback" {
485 http.NotFound(w, r)
486 return
487 }
488 query := r.URL.Query()
489 var callback oauthCallbackResult
490 switch {
491 case query.Get("state") != expectedState:
492 callback.Err = fmt.Errorf("MCP OAuth callback state did not match")
493 case query.Get("error") != "":
494 callback.Err = fmt.Errorf("MCP OAuth authorization failed: %s: %s", secrets.RedactCredentials(query.Get("error")), secrets.RedactCredentials(query.Get("error_description")))
495 case strings.TrimSpace(query.Get("code")) == "":
496 callback.Err = fmt.Errorf("MCP OAuth callback did not include an authorization code")
497 default:
498 callback.Code = query.Get("code")
499 }
500 if callback.Err != nil {
501 http.Error(w, "Reasonix could not complete MCP authorization. You can close this window.", http.StatusBadRequest)
502 } else {
503 w.Header().Set("Content-Type", "text/html; charset=utf-8")
504 _, _ = io.WriteString(w, "<!doctype html><title>Reasonix MCP authorized</title><p>Authorization completed. You can close this window and return to Reasonix.</p>")
505 }
506 select {
507 case result <- callback:
508 default:
509 }
510 })
511 }
512
513 func buildAuthorizationURL(endpoint, clientID, redirectURI, state, verifier, resource, scope string) (string, error) {
514 u, err := parseSecureOAuthURL(endpoint, true)
515 if err != nil {
516 return "", fmt.Errorf("MCP OAuth authorization endpoint: %w", err)
517 }
518 q := u.Query()
519 q.Set("response_type", "code")
520 q.Set("client_id", clientID)
521 q.Set("redirect_uri", redirectURI)
522 q.Set("state", state)
523 q.Set("code_challenge", pkceChallenge(verifier))
524 q.Set("code_challenge_method", "S256")
525 q.Set("resource", resource)
526 if scope != "" {
527 q.Set("scope", scope)
528 }
529 u.RawQuery = q.Encode()
530 return u.String(), nil
531 }
532
533 func pkceChallenge(verifier string) string {
534 sum := sha256.Sum256([]byte(verifier))
535 return base64.RawURLEncoding.EncodeToString(sum[:])
536 }
537
538 func randomBase64URL(n int) (string, error) {
539 b := make([]byte, n)
540 if _, err := rand.Read(b); err != nil {
541 return "", err
542 }
543 return base64.RawURLEncoding.EncodeToString(b), nil
544 }
545
546 func chooseTokenEndpointAuthMethod(supported []string) string {
547 for _, preferred := range []string{"client_secret_basic", "client_secret_post", "none"} {
548 if len(supported) == 0 || slices.Contains(supported, preferred) {
549 return preferred
550 }
551 }
552 return "none"
553 }
554
555 func protectedResourceMetadataURLs(endpoint *url.URL) []string {
556 root := *endpoint
557 root.RawQuery, root.Fragment = "", ""
558 path := strings.TrimPrefix(root.EscapedPath(), "/")
559 root.Path, root.RawPath = "/.well-known/oauth-protected-resource", ""
560 urls := []string{}
561 if path != "" {
562 withPath := root
563 withPath.Path += "/" + path
564 urls = append(urls, withPath.String())
565 }
566 return append(urls, root.String())
567 }
568
569 func authorizationServerMetadataURLs(issuer *url.URL) []string {
570 base := *issuer
571 base.RawQuery, base.Fragment = "", ""
572 issuerPath := strings.Trim(strings.TrimSpace(base.Path), "/")
573 base.Path, base.RawPath = "", ""
574 if issuerPath == "" {
575 oauth := base
576 oauth.Path = "/.well-known/oauth-authorization-server"
577 oidc := base
578 oidc.Path = "/.well-known/openid-configuration"
579 return []string{oauth.String(), oidc.String()}
580 }
581 oauth := base
582 oauth.Path = "/.well-known/oauth-authorization-server/" + issuerPath
583 oidcInserted := base
584 oidcInserted.Path = "/.well-known/openid-configuration/" + issuerPath
585 oidcAppended := *issuer
586 oidcAppended.Path = strings.TrimRight(oidcAppended.Path, "/") + "/.well-known/openid-configuration"
587 return []string{oauth.String(), oidcInserted.String(), oidcAppended.String()}
588 }
589
590 func parseBearerChallenge(header string) (metadataURL, scope string, ok bool) {
591 lower := strings.ToLower(header)
592 for offset := 0; offset < len(header); {
593 idx := strings.Index(lower[offset:], "bearer")
594 if idx < 0 {
595 return "", "", false
596 }
597 idx += offset
598 beforeOK := idx == 0 || header[idx-1] == ',' || header[idx-1] == ' ' || header[idx-1] == '\t'
599 after := idx + len("bearer")
600 afterOK := after == len(header) || header[after] == ' ' || header[after] == '\t'
601 if beforeOK && afterOK {
602 params := parseAuthParams(header[after:])
603 return params["resource_metadata"], params["scope"], true
604 }
605 offset = after
606 }
607 return "", "", false
608 }
609
610 func parseAuthParams(raw string) map[string]string {
611 params := map[string]string{}
612 for i := 0; i < len(raw); {
613 i = skipAuthSeparators(raw, i)
614 start, end := i, scanAuthToken(raw, i)
615 if start == end {
616 break
617 }
618 key := strings.ToLower(raw[start:end])
619 i = skipAuthWhitespace(raw, end)
620 if i >= len(raw) || raw[i] != '=' {
621 break
622 }
623 value, next := parseAuthParamValue(raw, skipAuthWhitespace(raw, i+1))
624 params[key], i = value, next
625 }
626 return params
627 }
628
629 func getOAuthJSON(ctx context.Context, client *http.Client, rawURL string, out any) error {
630 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
631 if err != nil {
632 return err
633 }
634 req.Header.Set("Accept", "application/json")
635 resp, err := client.Do(req)
636 if err != nil {
637 return err
638 }
639 defer resp.Body.Close()
640 if resp.StatusCode/100 != 2 {
641 return oauthHTTPError("GET "+rawURL, resp)
642 }
643 return decodeLimitedJSON(resp.Body, out)
644 }
645
646 func decodeLimitedJSON(r io.Reader, out any) error {
647 decoder := json.NewDecoder(io.LimitReader(r, maxOAuthBody+1))
648 if err := decoder.Decode(out); err != nil {
649 return err
650 }
651 return nil
652 }
653
654 func oauthHTTPError(action string, resp *http.Response) error {
655 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
656 return fmt.Errorf("%s: HTTP %d: %s", action, resp.StatusCode, secrets.RedactCredentials(strings.TrimSpace(string(body))))
657 }
658
659 func newOAuthHTTPClient(base *http.Client) *http.Client {
660 client := &http.Client{Timeout: 30 * time.Second}
661 if base != nil {
662 *client = *base
663 if client.Timeout == 0 {
664 client.Timeout = 30 * time.Second
665 }
666 }
667 previousRedirect := client.CheckRedirect
668 client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
669 if len(via) > 0 && !sameHTTPOrigin(via[0].URL, req.URL) {
670 return http.ErrUseLastResponse
671 }
672 if previousRedirect != nil {
673 return previousRedirect(req, via)
674 }
675 return nil
676 }
677 return client
678 }
679
680 func parseSecureOAuthURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
681 u, err := url.Parse(strings.TrimSpace(raw))
682 if err != nil || u == nil || u.Host == "" || u.User != nil || u.Fragment != "" {
683 return nil, fmt.Errorf("invalid URL")
684 }
685 if strings.EqualFold(u.Scheme, "https") {
686 return u, nil
687 }
688 host := strings.ToLower(u.Hostname())
689 if allowLoopbackHTTP && strings.EqualFold(u.Scheme, "http") && (host == "localhost" || net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()) {
690 return u, nil
691 }
692 return nil, fmt.Errorf("URL must use HTTPS")
693 }
694
695 func sameCanonicalResource(a, b string) bool {
696 ua, errA := url.Parse(strings.TrimSpace(a))
697 ub, errB := url.Parse(strings.TrimSpace(b))
698 if errA != nil || errB != nil || ua == nil || ub == nil {
699 return false
700 }
701 // URL userinfo is explicit credential material, never an OAuth resource
702 // identity. Do not let a credentialed URL match a credential-free state.
703 if ua.User != nil || ub.User != nil {
704 return false
705 }
706 ua.Fragment, ub.Fragment = "", ""
707 return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host) && ua.EscapedPath() == ub.EscapedPath() && ua.RawQuery == ub.RawQuery
708 }
709
710 func isHTTPMCPTransport(transport string) bool {
711 switch strings.ToLower(strings.TrimSpace(transport)) {
712 case "http", "streamable-http", "streamable_http":
713 return true
714 default:
715 return false
716 }
717 }
718
718 lines GO