| 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, ®istration); 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 |