From 7c6fc1d7ab3e3ae89f695c5c5997edbf0dc6bffd Mon Sep 17 00:00:00 2001 From: Jim Clark Date: Tue, 16 Dec 2025 14:07:44 -0800 Subject: [PATCH 1/3] Add manual OAuth registration and comprehensive CE mode documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for manually registering OAuth client credentials for MCP servers that don't support Dynamic Client Registration (DCR), along with comprehensive documentation of OAuth flows in Docker CE mode. Changes: - Add 'docker mcp oauth register' command for manual client registration - Supports both confidential and public OAuth clients - Stores credentials securely in Docker credential helpers - Includes validation for URLs and required fields - Add detailed OAuth CE mode documentation (572 lines) - Documents DCR flow, authorization, token storage, and refresh - Includes architecture diagrams, CLI examples, and troubleshooting - Provides file references with line numbers for code navigation - Covers security features (PKCE, token binding, credential helpers) - Fix linting issues in OAuth command handlers - Add explicit error handling for MarkFlagRequired calls - Rename unused context parameter to underscore - Apply gofmt formatting to imports The manual registration feature enables OAuth integration with providers that don't support RFC 7591 DCR, expanding compatibility with a wider range of OAuth providers. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/docker-mcp/commands/oauth.go | 46 ++ cmd/docker-mcp/oauth/register.go | 132 ++++ .../manual-oauth-registration.md | 572 ++++++++++++++++++ pkg/oauth/dcr/credentials.go | 1 + pkg/oauth/provider.go | 2 +- 5 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 cmd/docker-mcp/oauth/register.go create mode 100644 docs/feature-specs/manual-oauth-registration.md diff --git a/cmd/docker-mcp/commands/oauth.go b/cmd/docker-mcp/commands/oauth.go index f3bf74ba1..527e1c692 100644 --- a/cmd/docker-mcp/commands/oauth.go +++ b/cmd/docker-mcp/commands/oauth.go @@ -14,6 +14,7 @@ func oauthCommand() *cobra.Command { cmd.AddCommand(lsOauthCommand()) cmd.AddCommand(authorizeOauthCommand()) cmd.AddCommand(revokeOauthCommand()) + cmd.AddCommand(registerOauthCommand()) return cmd } @@ -61,3 +62,48 @@ func revokeOauthCommand() *cobra.Command { }, } } + +func registerOauthCommand() *cobra.Command { + var opts oauth.RegisterOptions + cmd := &cobra.Command{ + Use: "register ", + Short: "Manually register OAuth client credentials for a server.", + Long: `Manually register OAuth client credentials for servers that don't support Dynamic Client Registration (DCR). + +This command allows you to configure pre-registered OAuth client credentials from your OAuth provider. +After registration, you can authorize with: docker mcp oauth authorize + +Examples: + # Register with client ID and secret (confidential client) + docker mcp oauth register my-server \ + --client-id "abc123" \ + --client-secret "secret456" \ + --auth-endpoint "https://provider.com/oauth/authorize" \ + --token-endpoint "https://provider.com/oauth/token" \ + --scopes "read,write" + + # Register public client (no secret) + docker mcp oauth register my-server \ + --client-id "public-client-id" \ + --auth-endpoint "https://provider.com/oauth/authorize" \ + --token-endpoint "https://provider.com/oauth/token"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return oauth.Register(cmd.Context(), args[0], opts) + }, + } + flags := cmd.Flags() + flags.StringVar(&opts.ClientID, "client-id", "", "OAuth client ID (required)") + flags.StringVar(&opts.ClientSecret, "client-secret", "", "OAuth client secret (optional, for confidential clients)") + flags.StringVar(&opts.AuthorizationEndpoint, "auth-endpoint", "", "Authorization endpoint URL (required)") + flags.StringVar(&opts.TokenEndpoint, "token-endpoint", "", "Token endpoint URL (required)") + flags.StringVar(&opts.Scopes, "scopes", "", "Comma-separated list of OAuth scopes") + flags.StringVar(&opts.Provider, "provider", "", "Provider name (defaults to server name)") + flags.StringVar(&opts.ResourceURL, "resource-url", "", "Resource URL for the OAuth provider (defaults to auth endpoint base)") + + _ = cmd.MarkFlagRequired("client-id") + _ = cmd.MarkFlagRequired("auth-endpoint") + _ = cmd.MarkFlagRequired("token-endpoint") + + return cmd +} diff --git a/cmd/docker-mcp/oauth/register.go b/cmd/docker-mcp/oauth/register.go new file mode 100644 index 000000000..e3fa07c38 --- /dev/null +++ b/cmd/docker-mcp/oauth/register.go @@ -0,0 +1,132 @@ +package oauth + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + pkgoauth "github.com/docker/mcp-gateway/pkg/oauth" + "github.com/docker/mcp-gateway/pkg/oauth/dcr" +) + +// RegisterOptions contains configuration for manually registering OAuth credentials +type RegisterOptions struct { + ClientID string + ClientSecret string + AuthorizationEndpoint string + TokenEndpoint string + Scopes string + Provider string + ResourceURL string +} + +// Register manually registers OAuth client credentials for a server +// This is used when the OAuth provider does not support Dynamic Client Registration (DCR) +func Register(_ context.Context, serverName string, opts RegisterOptions) error { + // Validate required fields + if err := validateRegisterOptions(serverName, opts); err != nil { + return err + } + + // Parse scopes + var scopesList []string + if opts.Scopes != "" { + scopesList = strings.Split(opts.Scopes, ",") + // Trim whitespace from each scope + for i, scope := range scopesList { + scopesList[i] = strings.TrimSpace(scope) + } + } + + // Use server name as provider if not specified + provider := opts.Provider + if provider == "" { + provider = serverName + } + + // Use authorization endpoint as resource URL if not specified + resourceURL := opts.ResourceURL + if resourceURL == "" { + // Try to extract base URL from authorization endpoint + if u, err := url.Parse(opts.AuthorizationEndpoint); err == nil { + resourceURL = fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + + // Create DCR client struct + client := dcr.Client{ + ServerName: serverName, + ClientID: opts.ClientID, + ClientSecret: opts.ClientSecret, + ProviderName: provider, + AuthorizationEndpoint: opts.AuthorizationEndpoint, + TokenEndpoint: opts.TokenEndpoint, + RequiredScopes: scopesList, + ResourceURL: resourceURL, + RegisteredAt: time.Now(), + ClientName: fmt.Sprintf("MCP Gateway - %s (manual)", serverName), + } + + // Store in credential helper + credHelper := pkgoauth.NewReadWriteCredentialHelper() + credentials := dcr.NewCredentials(credHelper) + + if err := credentials.SaveClient(serverName, client); err != nil { + return fmt.Errorf("failed to store OAuth credentials: %w", err) + } + + fmt.Printf("Successfully registered OAuth client for server: %s\n", serverName) + fmt.Printf(" Provider: %s\n", provider) + fmt.Printf(" Client ID: %s\n", opts.ClientID) + if opts.ClientSecret != "" { + fmt.Printf(" Client Secret: [configured]\n") + } else { + fmt.Printf(" Client Secret: [none - public client]\n") + } + fmt.Printf(" Authorization Endpoint: %s\n", opts.AuthorizationEndpoint) + fmt.Printf(" Token Endpoint: %s\n", opts.TokenEndpoint) + if len(scopesList) > 0 { + fmt.Printf(" Scopes: %s\n", strings.Join(scopesList, ", ")) + } + fmt.Printf("\nYou can now authorize with: docker mcp oauth authorize %s\n", serverName) + + return nil +} + +// validateRegisterOptions validates the registration options +func validateRegisterOptions(serverName string, opts RegisterOptions) error { + if serverName == "" { + return fmt.Errorf("server name is required") + } + + if opts.ClientID == "" { + return fmt.Errorf("client-id is required") + } + + if opts.AuthorizationEndpoint == "" { + return fmt.Errorf("auth-endpoint is required") + } + + if opts.TokenEndpoint == "" { + return fmt.Errorf("token-endpoint is required") + } + + // Validate URLs + if _, err := url.Parse(opts.AuthorizationEndpoint); err != nil { + return fmt.Errorf("invalid auth-endpoint URL: %w", err) + } + + if _, err := url.Parse(opts.TokenEndpoint); err != nil { + return fmt.Errorf("invalid token-endpoint URL: %w", err) + } + + if opts.ResourceURL != "" { + if _, err := url.Parse(opts.ResourceURL); err != nil { + return fmt.Errorf("invalid resource-url: %w", err) + } + } + + return nil +} diff --git a/docs/feature-specs/manual-oauth-registration.md b/docs/feature-specs/manual-oauth-registration.md new file mode 100644 index 000000000..037eede29 --- /dev/null +++ b/docs/feature-specs/manual-oauth-registration.md @@ -0,0 +1,572 @@ +# OAuth Providers in Docker CE Mode + +**OAuth Flow Implementation for Standalone Environments** + +## Overview + +The MCP Gateway supports OAuth authentication for remote MCP servers in both Docker Desktop and Docker CE (standalone) modes. In Docker CE mode, the gateway handles all OAuth flows independently, including Dynamic Client Registration (DCR), token management, and automatic refresh. + +This document explains the complete OAuth provider architecture when running in Docker CE mode. + +## Mode Detection + +Docker CE mode is detected via `pkg/oauth/mode.go:19`. The system uses CE mode when: +- Running inside a container +- On Linux when Docker Desktop is not detected +- Environment variable `DOCKER_MCP_USE_CE=true` is set + +CE mode is essentially the inverse of Docker Desktop mode - when Docker Desktop isn't available, the MCP Gateway handles OAuth flows standalone. + +## Key Differences from Docker Desktop Mode + +| Aspect | Docker Desktop Mode | Docker CE Mode | +|--------|---------------------|----------------| +| OAuth Registration | Desktop app manages DCR | Gateway performs DCR | +| Auth UI | Unified Desktop UI | CLI + browser flow | +| Token Storage | Desktop backend | Docker credential helpers | +| Token Refresh | Desktop API | Gateway refresh loop | +| Callback Handling | Desktop proxy | Local callback server + mcp-oauth proxy | + +## OAuth Flow Architecture (CE Mode) + +### 1. Dynamic Client Registration (DCR) + +When you run `docker mcp oauth authorize `, the system first ensures a DCR client exists via `pkg/oauth/dcr/manager.go:43`: + +``` +Manager.PerformDiscoveryAndRegistration(): + ↓ +1. OAuth Discovery (RFC 9728, RFC 8414) + - Fetches /.well-known/oauth-authorization-server metadata from server + - Discovers authorization endpoints, token endpoints, supported scopes + ↓ +2. Dynamic Client Registration (RFC 7591) + - Registers a new OAuth client with the provider + - Uses redirect URI: https://mcp.docker.com/oauth/callback + - Receives client_id (and optionally client_secret for confidential clients) + ↓ +3. Store DCR Client + - Saves to Docker credential helper with key: https://{serverName}.mcp-dcr + - Stores: clientID, endpoints, scopes, provider name +``` + +**Key Files:** +- `pkg/oauth/dcr/manager.go:43` - DCR orchestration +- `pkg/oauth/dcr/credentials.go:57` - Credential storage +- Uses `github.com/docker/mcp-gateway-oauth-helpers` for RFC compliance + +### 2. Authorization Flow + +After DCR, the authorization flow begins in `cmd/docker-mcp/oauth/auth.go:42`: + +``` +authorizeCEMode(): + ↓ +1. Start Local Callback Server (pkg/oauth/callback_server.go) + - Binds to localhost:5000 (or MCP_GATEWAY_OAUTH_PORT) + - Provides endpoint: http://localhost:5000/callback + ↓ +2. Build Authorization URL (pkg/oauth/manager.go:60) + - Generates PKCE verifier (RFC 7636) for security + - Creates state parameter: "mcp-gateway:5000:UUID" + - Adds resource parameter (RFC 8707) for token audience binding + - Constructs URL: {auth_endpoint}?client_id=...&redirect_uri=...&state=...&code_challenge=... + ↓ +3. User Opens Browser + - User authenticates with OAuth provider + - Provider redirects to: https://mcp.docker.com/oauth/callback?code=...&state=... + - mcp-oauth proxy (Docker infrastructure) routes to localhost:5000 based on state + ↓ +4. Callback Received (pkg/oauth/callback_server.go:111) + - Local server receives authorization code and state + - Displays success page to user + ↓ +5. Token Exchange (pkg/oauth/manager.go:126) + - Validates state and retrieves PKCE verifier + - Exchanges authorization code for access token + refresh token + - Uses PKCE verifier for security + ↓ +6. Token Storage (pkg/oauth/token_store.go) + - Stores tokens in Docker credential helper + - Key format: {auth_endpoint}/{provider_name} + - Value: base64-encoded JSON with access_token, refresh_token, expiry +``` + +**Key Components:** +- `cmd/docker-mcp/oauth/auth.go:42` - Authorization orchestration +- `pkg/oauth/callback_server.go` - Local HTTP server for OAuth callbacks +- `pkg/oauth/manager.go` - OAuth flow management +- `pkg/oauth/token_store.go` - Token persistence + +### 3. Credential Storage + +All credentials use Docker credential helpers (`pkg/oauth/credhelper.go:201`): + +#### DCR Clients + +Stored in credential helper with: +- **Key:** `https://{serverName}.mcp-dcr` +- **Username:** `dcr_client` +- **Secret:** Base64-encoded JSON containing: + ```json + { + "serverName": "my-server", + "providerName": "my-server", + "clientId": "...", + "clientSecret": "...", + "authorizationEndpoint": "https://...", + "tokenEndpoint": "https://...", + "resourceUrl": "https://...", + "scopesSupported": ["read", "write"], + "requiredScopes": ["read"], + "registeredAt": "2025-12-16T12:00:00Z" + } + ``` + +#### OAuth Tokens + +Stored in credential helper with: +- **Key:** `{authorizationEndpoint}/{providerName}` +- **Username:** `oauth_token` +- **Secret:** Base64-encoded JSON containing: + ```json + { + "access_token": "eyJ...", + "token_type": "Bearer", + "refresh_token": "...", + "expiry": "2025-12-16T12:00:00Z" + } + ``` + +#### Credential Helper Types + +The system uses different credential helper instances: +- **Read-only** (`NewOAuthCredentialHelper` at line 31): For reading tokens during gateway operations +- **Read-write** (`NewReadWriteCredentialHelper` at line 217): For storing DCR clients and tokens + +### 4. Automatic Token Refresh + +The gateway runs a background refresh loop for each OAuth-enabled server in `pkg/oauth/provider.go:93`: + +``` +Provider.Run(): + ↓ +Loop: + 1. Check token status (GetTokenStatus at credhelper.go:110) + - Parses token expiry from credential helper + - Token needs refresh if expiry <= 10 seconds + ↓ + 2. If needs refresh (provider.go:145): + CE Mode: + - Call refreshTokenCE() (line 208) + - Retrieves DCR client and current token + - Uses oauth2.Config.TokenSource() for automatic refresh + - Saves refreshed token back to credential helper + + Desktop Mode: + - Calls Desktop API to trigger refresh + ↓ + 3. Wait with exponential backoff + - First attempt: 30s + - Subsequent attempts: 1min, 2min, 4min, 8min... + - Max 7 retry attempts (maxRefreshRetries at line 78) + ↓ + 4. Listen for events + - SSE events from mcp-oauth proxy + - EventLoginSuccess / EventTokenRefresh reset retry count +``` + +**Refresh Logic:** +- Uses `golang.org/x/oauth2` library's built-in refresh mechanism +- Automatically handles refresh token exchange +- Updates token expiry after successful refresh +- Exponential backoff prevents hammering provider APIs + +**Key Files:** +- `pkg/oauth/provider.go:93` - Background refresh loop +- `pkg/oauth/provider.go:208` - CE mode token refresh +- `pkg/oauth/credhelper.go:110` - Token status check + +### 5. Token Retrieval During Runtime + +When MCP servers need tokens (`pkg/oauth/credhelper.go:49`): + +``` +CredentialHelper.GetOAuthToken(): + ↓ +1. Determine credential key + CE Mode: Read DCR client from credential helper + Desktop Mode: Call Desktop API for DCR client + ↓ +2. Construct key: {authEndpoint}/{providerName} + ↓ +3. Retrieve from credential helper + ↓ +4. Decode base64 JSON + ↓ +5. Extract access_token field + ↓ +6. Return to MCP server as environment variable or header +``` + +## Manual Registration + +For servers that don't support DCR, you can manually register pre-configured OAuth clients: + +```bash +# Register with client ID and secret (confidential client) +docker mcp oauth register my-server \ + --client-id "abc123" \ + --client-secret "secret456" \ + --auth-endpoint "https://provider.com/oauth/authorize" \ + --token-endpoint "https://provider.com/oauth/token" \ + --scopes "read,write" + +# Register public client (no secret) +docker mcp oauth register my-server \ + --client-id "public-client-id" \ + --auth-endpoint "https://provider.com/oauth/authorize" \ + --token-endpoint "https://provider.com/oauth/token" +``` + +This command stores a pre-configured DCR client, skipping the discovery/registration steps. After registration, authorize normally: + +```bash +docker mcp oauth authorize my-server +``` + +**Implementation:** +- `cmd/docker-mcp/commands/oauth.go:66` - Command definition +- `cmd/docker-mcp/oauth/register.go` - Registration handler + +## Security Features + +1. **PKCE** (Proof Key for Code Exchange, RFC 7636) + - All flows use S256 challenge method + - Generated in `pkg/oauth/provider.go:59-63` + - Protects against authorization code interception + +2. **State Parameter** + - Prevents CSRF attacks + - Format: `mcp-gateway:{port}:{uuid}` + - Includes routing info for mcp-oauth proxy + - Managed by `pkg/oauth/state.go` + +3. **Credential Helpers** + - Tokens stored in OS-native secure storage + - macOS: Keychain + - Linux: Secret Service / pass + - Windows: Credential Manager + - Uses `github.com/docker/docker-credential-helpers` + +4. **Token Audience Binding** (RFC 8707) + - Resource parameter ties tokens to specific servers + - Prevents token reuse across services + - Set in `pkg/oauth/manager.go:115-117` + +5. **Container Isolation** + - MCP servers run in containers + - Can't directly access credential storage + - Gateway injects tokens at runtime + +## Configuration + +### Environment Variables + +- `MCP_GATEWAY_OAUTH_PORT`: Custom OAuth callback port (default: 5000) + - Used when default port is unavailable + - Must be in range 1024-65535 + - Configured in `pkg/oauth/callback_server.go:36` + +- `DOCKER_MCP_USE_CE`: Force CE mode even on Docker Desktop + - Useful for testing CE flows on Desktop + - Set to `true` to enable + +### Credential Helper Configuration + +The gateway automatically detects credential helpers using: +1. Docker config file (`~/.docker/config.json`) +2. Platform defaults (osxkeychain, secretservice, wincred) + +## CLI Commands + +### List OAuth Apps + +```bash +docker mcp oauth ls [--json] +``` + +Lists all registered OAuth applications and their authorization status. + +### Authorize an App + +```bash +docker mcp oauth authorize [--scopes "scope1 scope2"] +``` + +Initiates OAuth flow for an MCP server. In CE mode: +1. Performs DCR if needed +2. Starts local callback server +3. Opens browser for authentication +4. Waits for callback and exchanges code for token + +### Revoke Authorization + +```bash +docker mcp oauth revoke +``` + +Removes stored tokens for an app. Does not revoke with provider. + +### Manual Registration + +```bash +docker mcp oauth register \ + --client-id \ + --client-secret \ + --auth-endpoint \ + --token-endpoint \ + --scopes +``` + +Registers pre-configured OAuth credentials for servers without DCR support. + +**Command Implementation:** +- `cmd/docker-mcp/commands/oauth.go` - Command definitions +- `cmd/docker-mcp/oauth/` - Command handlers + +## File Reference + +### Core OAuth Components + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `pkg/oauth/mode.go` | Mode detection | `IsCEMode()` - Determines if running in CE mode | +| `pkg/oauth/provider.go` | Provider lifecycle | `Run()` - Background refresh loop
`refreshTokenCE()` - CE mode token refresh | +| `pkg/oauth/manager.go` | OAuth orchestration | `BuildAuthorizationURL()` - Generate auth URLs
`ExchangeCode()` - Token exchange | +| `pkg/oauth/credhelper.go` | Credential access | `GetOAuthToken()` - Retrieve tokens
`GetTokenStatus()` - Check token validity | + +### DCR Components + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `pkg/oauth/dcr/manager.go` | DCR orchestration | `PerformDiscoveryAndRegistration()` - DCR flow | +| `pkg/oauth/dcr/credentials.go` | DCR storage | `SaveClient()` - Store DCR client
`RetrieveClient()` - Load DCR client | + +### Command Handlers + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `cmd/docker-mcp/oauth/auth.go` | Authorization | `authorizeCEMode()` - CE mode auth flow | +| `cmd/docker-mcp/oauth/ls.go` | List apps | `Ls()` - List OAuth apps | +| `cmd/docker-mcp/oauth/revoke.go` | Revocation | `Revoke()` - Remove tokens | +| `cmd/docker-mcp/oauth/register.go` | Manual registration | `Register()` - Manual client registration | + +### Supporting Components + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `pkg/oauth/callback_server.go` | HTTP callback server | `Start()` - Run callback server
`Wait()` - Wait for callback | +| `pkg/oauth/token_store.go` | Token persistence | `Save()` - Store tokens
`Retrieve()` - Load tokens | +| `pkg/oauth/state.go` | State management | `Generate()` - Create state
`Validate()` - Verify state | + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Gateway β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ OAuth Manager │◄────►│ DCR Manager β”‚ β”‚ +β”‚ β”‚ (manager.go) β”‚ β”‚ (dcr/manager.go)β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Token Store β”‚ β”‚ DCR Credentials β”‚ β”‚ +β”‚ β”‚(token_store.go)β”‚ β”‚(dcr/creds.go) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Credential Helperβ”‚ β”‚ +β”‚ β”‚ (credhelper.go) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Docker Credential β”‚ + β”‚ Helper β”‚ + β”‚ (osxkeychain/ β”‚ + β”‚ secretservice/ β”‚ + β”‚ wincred) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ OS Secure Storage β”‚ + β”‚ (Keychain/SecretSvc/ β”‚ + β”‚ CredManager) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +External Components: + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ OAuth Provider │◄───────►│ mcp-oauth Proxy β”‚ +β”‚ (e.g., Notion) β”‚ β”‚ (Docker infra) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Callback Server β”‚ + β”‚ (localhost:5000) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Example Flow + +### Full Authorization Flow + +```bash +# 1. User initiates authorization +$ docker mcp oauth authorize notion-remote + +# Gateway performs: +Starting OAuth authorization for notion-remote... + +# 2. DCR (if needed) +Checking DCR registration... +- No DCR client found for notion-remote, performing registration... +- Starting OAuth discovery for: notion-remote at: https://notion.example.com +- Discovery successful for: notion-remote +- Registration successful for: notion-remote, clientID: abc123 +- Stored DCR client for notion-remote + +# 3. Start callback server +OAuth callback server bound to localhost:5000 + +# 4. Generate authorization URL +Generating authorization URL... +- Generated authorization URL for notion-remote with PKCE +- State format for proxy: mcp-gateway:5000:UUID + +# 5. User authenticates +Please visit this URL to authorize: + + https://provider.com/oauth/authorize?client_id=abc123&redirect_uri=... + +Waiting for authorization callback on http://localhost:5000/callback... + +# 6. Provider redirects β†’ mcp-oauth proxy β†’ localhost:5000 +- Received OAuth callback with code and state + +# 7. Token exchange +Exchanging authorization code for access token... +- Exchanging authorization code for notion-remote +- Token exchanged for notion-remote (access: true, refresh: true) + +# 8. Success +Authorization successful! Token stored securely. +You can now use: docker mcp server start notion-remote +``` + +### Token Refresh Loop + +```bash +# Gateway starts provider loop for each OAuth server +$ docker mcp gateway run + +# Background loop output: +- Started OAuth provider loop for notion-remote +- Token valid for notion-remote, next check in 3590s +# ... time passes ... +- Triggering token refresh for notion-remote, attempt 1/7, waiting 30s +- Successfully refreshed token for notion-remote +- Token valid for notion-remote, next check in 3590s +``` + +## Troubleshooting + +### Port Already in Use + +If port 5000 is already in use: + +```bash +# Option 1: Use custom port +export MCP_GATEWAY_OAUTH_PORT=5001 +docker mcp oauth authorize + +# Option 2: Find what's using the port +lsof -i :5000 + +# Option 3: Kill the conflicting process +kill $(lsof -t -i :5000) +``` + +### No Credential Helper Found + +If credential helper is missing: + +```bash +# Install credential helper +# macOS: +brew install docker-credential-helper + +# Linux: +apt-get install pass # or install gnome-keyring + +# Configure Docker to use it +cat > ~/.docker/config.json < +docker mcp oauth authorize +``` + +### CE Mode Not Detected + +Force CE mode explicitly: + +```bash +export DOCKER_MCP_USE_CE=true +docker mcp oauth authorize +``` + +## Standards Compliance + +The implementation follows these RFCs: + +- **RFC 6749**: OAuth 2.0 Authorization Framework +- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol +- **RFC 7636**: Proof Key for Code Exchange (PKCE) +- **RFC 8414**: OAuth 2.0 Authorization Server Metadata +- **RFC 8707**: Resource Indicators for OAuth 2.0 +- **RFC 9728**: OAuth 2.0 Device Authorization Grant (discovery) + +External library used: `github.com/docker/mcp-gateway-oauth-helpers` + +## Future Enhancements + +Potential improvements for CE mode OAuth: + +1. **Token Revocation**: Call provider revocation endpoint on `oauth revoke` +2. **Multi-tenant Support**: Support multiple OAuth tenants per server +3. **Refresh Token Rotation**: Implement refresh token rotation for enhanced security +4. **Browser Detection**: Auto-open browser on authorization +5. **OAuth Cache**: Cache discovery metadata to reduce provider calls +6. **Credential Migration**: Migrate credentials between helpers diff --git a/pkg/oauth/dcr/credentials.go b/pkg/oauth/dcr/credentials.go index d3768119c..cbfcffa2e 100644 --- a/pkg/oauth/dcr/credentials.go +++ b/pkg/oauth/dcr/credentials.go @@ -24,6 +24,7 @@ type Client struct { AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` AuthorizationServer string `json:"authorizationServer,omitempty"` ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` // For confidential clients ClientName string `json:"clientName,omitempty"` ProviderName string `json:"providerName"` RegisteredAt time.Time `json:"registeredAt"` diff --git a/pkg/oauth/provider.go b/pkg/oauth/provider.go index 9980ffe5e..893ea7084 100644 --- a/pkg/oauth/provider.go +++ b/pkg/oauth/provider.go @@ -25,7 +25,7 @@ type DCRProvider struct { func NewDCRProvider(dcrClient dcr.Client, redirectURL string) *DCRProvider { config := &oauth2.Config{ ClientID: dcrClient.ClientID, - ClientSecret: "", // Public client - no secret + ClientSecret: dcrClient.ClientSecret, // Empty for public clients, populated for confidential clients RedirectURL: redirectURL, Endpoint: oauth2.Endpoint{ AuthURL: dcrClient.AuthorizationEndpoint, From 32f489236e122cb5d70f8615fab815b1c423072b Mon Sep 17 00:00:00 2001 From: Saurabh Davala Date: Wed, 24 Dec 2025 08:17:19 -0800 Subject: [PATCH 2/3] Add env var to customize redirect URL + logging for debugging --- pkg/mcp/remote.go | 29 +++++++++++++++++++++++++++-- pkg/oauth/manager.go | 11 ++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/remote.go b/pkg/mcp/remote.go index c9cd9687d..69e401b86 100644 --- a/pkg/mcp/remote.go +++ b/pkg/mcp/remote.go @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/docker/mcp-gateway/pkg/catalog" + "github.com/docker/mcp-gateway/pkg/log" "github.com/docker/mcp-gateway/pkg/oauth" ) @@ -60,10 +61,30 @@ func (c *remoteMCPClient) Initialize(ctx context.Context, _ *mcp.InitializeParam } // Add OAuth token if remote server has OAuth configuration - if c.config.Spec.OAuth != nil && len(c.config.Spec.OAuth.Providers) > 0 { + oauthConfigPresent := c.config.Spec.OAuth != nil + providerCount := 0 + if oauthConfigPresent { + providerCount = len(c.config.Spec.OAuth.Providers) + } + log.Logf("- Remote client for %s: OAuth config present=%v, providers=%d", c.config.Name, oauthConfigPresent, providerCount) + + if oauthConfigPresent && providerCount > 0 { token := c.getOAuthToken(ctx) + log.Logf("- Remote client for %s: getOAuthToken returned token=%v (len=%d)", c.config.Name, token != "", len(token)) if token != "" { headers["Authorization"] = "Bearer " + token + log.Logf("- Remote client for %s: Added Authorization header", c.config.Name) + } else { + log.Logf("! Remote client for %s: No token, Authorization header NOT added", c.config.Name) + } + } + + // Log final headers (mask sensitive values) + for k, v := range headers { + if k == "Authorization" { + log.Logf("- Header: %s = Bearer ***", k) + } else { + log.Logf("- Header: %s = %s", k, v) } } @@ -148,7 +169,10 @@ func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error } func (c *remoteMCPClient) getOAuthToken(ctx context.Context) string { + log.Logf("- getOAuthToken called for %s", c.config.Name) + if c.config.Spec.OAuth == nil || len(c.config.Spec.OAuth.Providers) == 0 { + log.Logf("- getOAuthToken: No OAuth config for %s", c.config.Name) return "" } @@ -157,9 +181,10 @@ func (c *remoteMCPClient) getOAuthToken(ctx context.Context) string { credHelper := oauth.NewOAuthCredentialHelper() token, err := credHelper.GetOAuthToken(ctx, c.config.Name) if err != nil { - // Token might not exist if user hasn't authorized yet + log.Logf("! getOAuthToken: Error getting token for %s: %v", c.config.Name, err) return "" } + log.Logf("- getOAuthToken: Successfully retrieved token for %s (len=%d)", c.config.Name, len(token)) return token } diff --git a/pkg/oauth/manager.go b/pkg/oauth/manager.go index e7f67c105..f7f7b1cc8 100644 --- a/pkg/oauth/manager.go +++ b/pkg/oauth/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "os" "github.com/docker/docker-credential-helpers/credentials" "golang.org/x/oauth2" @@ -13,7 +14,15 @@ import ( ) // DefaultRedirectURI is the OAuth callback endpoint -const DefaultRedirectURI = "https://mcp.docker.com/oauth/callback" +// Can be overridden with MCP_OAUTH_REDIRECT_URI environment variable +var DefaultRedirectURI = getDefaultRedirectURI() + +func getDefaultRedirectURI() string { + if uri := os.Getenv("MCP_OAUTH_REDIRECT_URI"); uri != "" { + return uri + } + return "https://mcp.docker.com/oauth/callback" +} // Manager orchestrates OAuth flows for DCR-based providers type Manager struct { From d91ac03260828f9f6726b190e5a88ebcee6d7873 Mon Sep 17 00:00:00 2001 From: Saurabh Davala Date: Fri, 26 Dec 2025 08:01:39 -0800 Subject: [PATCH 3/3] Request refresh token on subsequent auth, add debug logging --- pkg/oauth/manager.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/oauth/manager.go b/pkg/oauth/manager.go index f7f7b1cc8..bd5ea50bf 100644 --- a/pkg/oauth/manager.go +++ b/pkg/oauth/manager.go @@ -116,8 +116,9 @@ func (m *Manager) BuildAuthorizationURL(_ context.Context, serverName string, sc } opts := []oauth2.AuthCodeOption{ - oauth2.AccessTypeOffline, // Request refresh token - oauth2.S256ChallengeOption(verifier), // PKCE challenge + oauth2.AccessTypeOffline, // Request refresh token + oauth2.SetAuthURLParam("prompt", "consent"), // Force consent to get refresh token (Google requires this) + oauth2.S256ChallengeOption(verifier), // PKCE challenge } // Add resource parameter for RFC 8707 token audience binding @@ -170,6 +171,10 @@ func (m *Manager) ExchangeCode(ctx context.Context, code string, state string) e log.Logf("- Token exchanged for %s (access: %v, refresh: %v)", serverName, token.AccessToken != "", token.RefreshToken != "") + if token.RefreshToken == "" { + log.Logf("! WARNING: No refresh token received for %s - token refresh will fail when access token expires", serverName) + } + // Store token if err := m.tokenStore.Save(dcrClient, token); err != nil { return fmt.Errorf("failed to store token for %s: %w", serverName, err)