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/auth.go b/cmd/docker-mcp/oauth/auth.go index 6d5cb6c99..52c3e6405 100644 --- a/cmd/docker-mcp/oauth/auth.go +++ b/cmd/docker-mcp/oauth/auth.go @@ -5,10 +5,15 @@ import ( "fmt" "time" + "github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret" "github.com/docker/mcp-gateway/pkg/desktop" + "github.com/docker/mcp-gateway/pkg/log" pkgoauth "github.com/docker/mcp-gateway/pkg/oauth" ) +// clientSecretSuffix is the naming convention for OAuth client secrets in the secrets store. +const clientSecretSuffix = ".client_secret" + func Authorize(ctx context.Context, app string, scopes string) error { // Check if running in CE mode if pkgoauth.IsCEMode() { @@ -23,6 +28,25 @@ func Authorize(ctx context.Context, app string, scopes string) error { func authorizeDesktopMode(ctx context.Context, app string, scopes string) error { client := desktop.NewAuthClient() + // For pre-registered OAuth clients, re-register the DCR client with the latest + // client_secret from the Secrets Engine. The user may have set the secret after + // the server was added to the profile. + if dcrClient, err := client.GetDCRClient(ctx, app); err == nil && dcrClient.ClientID != "" { + clientSecretKey := secret.GetDefaultSecretKey(app + clientSecretSuffix) + if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" { + req := desktop.RegisterDCRRequest{ + ProviderName: dcrClient.ProviderName, + ClientID: dcrClient.ClientID, + ClientSecret: string(env.Value), + AuthorizationEndpoint: dcrClient.AuthorizationEndpoint, + TokenEndpoint: dcrClient.TokenEndpoint, + } + if err := client.RegisterDCRClientPending(ctx, app, req); err != nil { + log.Logf("Warning: failed to update DCR client with client_secret: %v", err) + } + } + } + // Start OAuth flow - Docker Desktop handles DCR automatically if needed authResponse, err := client.PostOAuthApp(ctx, app, scopes, false) if err != nil { 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/cmd/docker-mcp/server/enable.go b/cmd/docker-mcp/server/enable.go index 08787c212..269e205c2 100644 --- a/cmd/docker-mcp/server/enable.go +++ b/cmd/docker-mcp/server/enable.go @@ -60,8 +60,8 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad Ref: "", } - // DCR flag enabled AND type="remote" AND oauth present - if mcpOAuthDcrEnabled && server.HasExplicitOAuthProviders() { + // DCR flag enabled AND (remote OAuth server OR pre-registered OAuth from catalog) + if mcpOAuthDcrEnabled && (server.HasExplicitOAuthProviders() || server.HasPreRegisteredOAuth()) { // In CE mode, skip lazy setup - DCR happens during oauth authorize if pkgoauth.IsCEMode() { fmt.Printf("OAuth server %s enabled. Run 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName) @@ -74,7 +74,7 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName) } } - } else if !mcpOAuthDcrEnabled && server.HasExplicitOAuthProviders() { + } else if !mcpOAuthDcrEnabled && (server.HasExplicitOAuthProviders() || server.HasPreRegisteredOAuth()) { // Provide guidance when DCR is needed but disabled fmt.Printf("Server %s requires OAuth authentication but DCR is disabled.\n", serverName) fmt.Printf(" To enable automatic OAuth setup, run: docker mcp feature enable mcp-oauth-dcr\n") 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/catalog/types.go b/pkg/catalog/types.go index 9ee7768c6..4477f24ad 100644 --- a/pkg/catalog/types.go +++ b/pkg/catalog/types.go @@ -70,6 +70,18 @@ func (s *Server) HasExplicitOAuthProviders() bool { return s.Type == "remote" && s.IsOAuthServer() } +// HasPreRegisteredOAuth returns true if this server has pre-registered OAuth +// client metadata embedded in the catalog, regardless of server type. +// This supports local container servers that need OAuth tokens (e.g., Google Workspace) +// where the admin pre-registers the OAuth client in the catalog. +func (s *Server) HasPreRegisteredOAuth() bool { + if s.OAuth == nil || len(s.OAuth.Providers) == 0 { + return false + } + p := s.OAuth.Providers[0] + return p.Registration != nil && p.Registration.ClientID != "" +} + type Secret struct { Name string `yaml:"name" json:"name"` Env string `yaml:"env" json:"env"` @@ -95,6 +107,34 @@ type OAuthProvider struct { Provider string `yaml:"provider" json:"provider"` Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` Env string `json:"env,omitempty" yaml:"env,omitempty"` + // ServerMetadata holds OAuth authorization server metadata following RFC 8414 field naming. + // Omit if the server supports /.well-known/oauth-authorization-server discovery. + // Required for local servers or providers that don't publish metadata endpoints. + ServerMetadata *OAuthServerMetadata `json:"server_metadata,omitempty" yaml:"server_metadata,omitempty"` + // Registration holds out-of-band client credentials from manual OAuth app registration. + // Used when the provider does not support Dynamic Client Registration (DCR). + Registration *OAuthRegistration `json:"registration,omitempty" yaml:"registration,omitempty"` +} + +// OAuthServerMetadata follows RFC 8414 (OAuth 2.0 Authorization Server Metadata) field naming. +// https://datatracker.ietf.org/doc/html/rfc8414 +type OAuthServerMetadata struct { + Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"` + AuthorizationEndpoint string `json:"authorization_endpoint" yaml:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint" yaml:"token_endpoint"` + ScopesSupported []string `json:"scopes_supported,omitempty" yaml:"scopes_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported,omitempty" yaml:"response_types_supported,omitempty"` + GrantTypesSupported []string `json:"grant_types_supported,omitempty" yaml:"grant_types_supported,omitempty"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty" yaml:"code_challenge_methods_supported,omitempty"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty" yaml:"token_endpoint_auth_methods_supported,omitempty"` +} + +// OAuthRegistration holds the client_id from an out-of-band OAuth app registration. +// The client_secret is NOT included here -- it is provided by the user via the +// Docker secrets store (e.g., docker mcp secret set {server}.client_secret). +// This ensures secrets are never distributed in catalogs. +type OAuthRegistration struct { + ClientID string `json:"client_id" yaml:"client_id"` } // POCI tools diff --git a/pkg/desktop/auth.go b/pkg/desktop/auth.go index 2cefb4b38..c5399da1f 100644 --- a/pkg/desktop/auth.go +++ b/pkg/desktop/auth.go @@ -86,13 +86,15 @@ func (c *Tools) PostOAuthApp(ctx context.Context, app, scopes string, disableAut // DCR (Dynamic Client Registration) Methods type RegisterDCRRequest struct { - ClientID string `json:"clientId"` - ProviderName string `json:"providerName"` - ClientName string `json:"clientName,omitempty"` - AuthorizationServer string `json:"authorizationServer,omitempty"` - AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` - TokenEndpoint string `json:"tokenEndpoint,omitempty"` - ResourceURL string `json:"resourceUrl,omitempty"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret,omitempty"` + ProviderName string `json:"providerName"` + ClientName string `json:"clientName,omitempty"` + AuthorizationServer string `json:"authorizationServer,omitempty"` + AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` + TokenEndpoint string `json:"tokenEndpoint,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` + Scopes []string `json:"scopes,omitempty"` } type DCRClient struct { diff --git a/pkg/gateway/clientpool.go b/pkg/gateway/clientpool.go index b9afe3268..4c6c345ab 100644 --- a/pkg/gateway/clientpool.go +++ b/pkg/gateway/clientpool.go @@ -328,6 +328,11 @@ func (cp *clientPool) argsAndEnv(serverConfig *catalog.ServerConfig, targetConfi // Secrets for _, s := range serverConfig.Spec.Secrets { + // Skip secrets without an env field - they are OAuth infrastructure + // (e.g., client_secret) and are not injected into containers. + if s.Env == "" { + continue + } args = append(args, "-e", s.Env) secretValue, ok := serverConfig.Secrets[s.Name] diff --git a/pkg/gateway/configuration.go b/pkg/gateway/configuration.go index dd60f3b7f..52154afdb 100644 --- a/pkg/gateway/configuration.go +++ b/pkg/gateway/configuration.go @@ -581,6 +581,20 @@ func (c *FileBasedConfiguration) readOnce(ctx context.Context) (Configuration, e } } + // CE mode: resolve OAuth tokens for container secret injection. + // In CE mode there is no se:// URI resolution, so tokens must be read from + // the credential helper and injected as raw env var values. + if ceSecrets := readCEModeOAuthSecrets(ctx, servers, serverNames); len(ceSecrets) > 0 { + if secrets == nil { + secrets = make(map[string]string) + } + for k, v := range ceSecrets { + if _, exists := secrets[k]; !exists { + secrets[k] = v + } + } + } + log.Log("- Configuration read in", time.Since(start)) return Configuration{ serverNames: serverNames, diff --git a/pkg/gateway/configuration_workingset.go b/pkg/gateway/configuration_workingset.go index b940d9efd..d5c23cca7 100644 --- a/pkg/gateway/configuration_workingset.go +++ b/pkg/gateway/configuration_workingset.go @@ -137,6 +137,17 @@ func (c *WorkingSetConfiguration) readOnce(ctx context.Context, dao db.DAO) (Con // } } + // CE mode: resolve OAuth tokens for container secret injection. + // In CE mode there is no se:// URI resolution, so tokens must be read from + // the credential helper and injected as raw env var values. + if ceSecrets := readCEModeOAuthSecrets(ctx, servers, serverNames); len(ceSecrets) > 0 { + for k, v := range ceSecrets { + if _, exists := secrets[k]; !exists { + secrets[k] = v + } + } + } + log.Log("- Configuration read in", time.Since(start)) return Configuration{ diff --git a/pkg/gateway/run.go b/pkg/gateway/run.go index a3903f9ee..2cd5491fa 100644 --- a/pkg/gateway/run.go +++ b/pkg/gateway/run.go @@ -362,7 +362,7 @@ func (g *Gateway) Run(ctx context.Context) error { continue } - if serverConfig.Spec.HasExplicitOAuthProviders() { + if serverConfig.Spec.HasExplicitOAuthProviders() || serverConfig.Spec.HasPreRegisteredOAuth() { g.startProvider(ctx, serverName) } else if serverConfig.IsRemote() { // Community servers: start provider if they have a stored OAuth token diff --git a/pkg/gateway/secrets_ce.go b/pkg/gateway/secrets_ce.go new file mode 100644 index 000000000..f788f7233 --- /dev/null +++ b/pkg/gateway/secrets_ce.go @@ -0,0 +1,50 @@ +package gateway + +import ( + "context" + + "github.com/docker/mcp-gateway/pkg/catalog" + "github.com/docker/mcp-gateway/pkg/log" + "github.com/docker/mcp-gateway/pkg/oauth" +) + +// readCEModeOAuthSecrets resolves OAuth tokens for container secret injection in CE mode. +// +// In Desktop mode, secrets are injected into containers via se:// URIs that Docker +// Desktop resolves at container start time. In CE mode, the standard Docker CLI has +// no se:// URI resolution. Instead, OAuth tokens stored in the system credential +// helper (e.g., macOS Keychain) by `docker mcp oauth authorize` must be read directly +// and injected as raw environment variable values. +// +// This function reads those tokens and maps them to the secret names expected by +// server definitions, so that clientpool.go can set them as container env vars. +func readCEModeOAuthSecrets(ctx context.Context, servers map[string]catalog.Server, serverNames []string) map[string]string { + secrets := make(map[string]string) + + if !oauth.IsCEMode() { + return secrets + } + + credHelper := oauth.NewOAuthCredentialHelper() + + for _, serverName := range serverNames { + server, ok := servers[serverName] + if !ok || server.OAuth == nil { + continue + } + + for _, provider := range server.OAuth.Providers { + token, err := credHelper.GetOAuthToken(ctx, provider.Provider) + if err != nil { + log.Logf(" - CE mode: no OAuth token for provider %s: %v", provider.Provider, err) + continue + } + if token != "" { + secrets[provider.Secret] = token + log.Logf(" - CE mode: resolved OAuth token for %s -> %s", provider.Provider, provider.Secret) + } + } + } + + return secrets +} diff --git a/pkg/mcp/remote.go b/pkg/mcp/remote.go index 2bf706187..0d786ef52 100644 --- a/pkg/mcp/remote.go +++ b/pkg/mcp/remote.go @@ -106,6 +106,15 @@ func (c *remoteMCPClient) Initialize(ctx context.Context, _ *mcp.InitializeParam } } + // 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) + } + } + var mcpTransport mcp.Transport var err error 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/dcr_registration.go b/pkg/oauth/dcr_registration.go index d7070cb8b..33c5694ca 100644 --- a/pkg/oauth/dcr_registration.go +++ b/pkg/oauth/dcr_registration.go @@ -6,11 +6,15 @@ import ( oauthhelpers "github.com/docker/mcp-gateway-oauth-helpers" + "github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret" "github.com/docker/mcp-gateway/pkg/catalog" "github.com/docker/mcp-gateway/pkg/desktop" "github.com/docker/mcp-gateway/pkg/log" ) +// clientSecretSuffix is the naming convention for OAuth client secrets in the secrets store. +const clientSecretSuffix = ".client_secret" + // dcrRegistrationClient is the subset of desktop.Tools used for DCR registration. // Extracted as an interface to enable testing. type dcrRegistrationClient interface { @@ -53,16 +57,36 @@ func RegisterProviderForLazySetup(ctx context.Context, serverName string) error return fmt.Errorf("server %s not found in catalog", serverName) } - // Verify this is a remote OAuth server (Type="remote" && OAuth providers exist) - if !server.HasExplicitOAuthProviders() { - return fmt.Errorf("server %s is not a remote OAuth server", serverName) + // Verify this server has OAuth providers (remote with explicit providers, or pre-registered) + if !server.HasExplicitOAuthProviders() && !server.HasPreRegisteredOAuth() { + return fmt.Errorf("server %s does not have OAuth providers configured", serverName) } - providerName := server.OAuth.Providers[0].Provider + provider := server.OAuth.Providers[0] - // Register with DD (pending DCR state) + // Build DCR request with pre-registered metadata if available. + // When registration + server_metadata are provided (from a catalog with + // embedded OAuth metadata), Pinata skips DCR discovery and uses them directly. dcrRequest := desktop.RegisterDCRRequest{ - ProviderName: providerName, + ProviderName: provider.Provider, + } + if provider.Registration != nil { + dcrRequest.ClientID = provider.Registration.ClientID + + // Look up client_secret from the Secrets Engine (user sets it via docker mcp secret set). + // The catalog only contains client_id; the secret is never distributed in catalogs. + clientSecretKey := secret.GetDefaultSecretKey(serverName + clientSecretSuffix) + if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" { + dcrRequest.ClientSecret = string(env.Value) + log.Logf("- Registering pre-configured OAuth client for %s with client_secret", serverName) + } else { + log.Logf("- Registering pre-configured OAuth client for %s without client_secret (not yet set)", serverName) + } + } + if provider.ServerMetadata != nil { + dcrRequest.AuthorizationEndpoint = provider.ServerMetadata.AuthorizationEndpoint + dcrRequest.TokenEndpoint = provider.ServerMetadata.TokenEndpoint + dcrRequest.Scopes = provider.ServerMetadata.ScopesSupported } return client.RegisterDCRClientPending(ctx, serverName, dcrRequest) @@ -103,7 +127,7 @@ func registerProviderForDynamicDiscovery(ctx context.Context, serverName, server // RegisterProviderWithSnapshot registers a DCR provider using OAuth metadata from the server snapshot // This avoids querying the catalog since the snapshot already contains all necessary OAuth information // Idempotent - safe to call multiple times for the same server -func RegisterProviderWithSnapshot(ctx context.Context, serverName, providerName string) error { +func RegisterProviderWithSnapshot(ctx context.Context, serverName string, provider catalog.OAuthProvider, scopes []string) error { client := desktop.NewAuthClient() // Idempotent check - already registered? @@ -113,8 +137,26 @@ func RegisterProviderWithSnapshot(ctx context.Context, serverName, providerName } // Register with Docker Desktop (pending DCR state) + // Include pre-registered client metadata if available from catalog dcrRequest := desktop.RegisterDCRRequest{ - ProviderName: providerName, + ProviderName: provider.Provider, + Scopes: scopes, + } + if provider.Registration != nil { + dcrRequest.ClientID = provider.Registration.ClientID + + // Look up client_secret from Secrets Engine (not distributed in catalogs) + clientSecretKey := secret.GetDefaultSecretKey(serverName + clientSecretSuffix) + if env, err := secret.GetSecret(ctx, clientSecretKey); err == nil && string(env.Value) != "" { + dcrRequest.ClientSecret = string(env.Value) + } + } + if provider.ServerMetadata != nil { + dcrRequest.AuthorizationEndpoint = provider.ServerMetadata.AuthorizationEndpoint + dcrRequest.TokenEndpoint = provider.ServerMetadata.TokenEndpoint + if len(dcrRequest.Scopes) == 0 { + dcrRequest.Scopes = provider.ServerMetadata.ScopesSupported + } } return client.RegisterDCRClientPending(ctx, serverName, dcrRequest) diff --git a/pkg/oauth/manager.go b/pkg/oauth/manager.go index e7f67c105..bd5ea50bf 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 { @@ -107,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 @@ -161,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) diff --git a/pkg/oauth/provider.go b/pkg/oauth/provider.go index 1256edf51..e544125c1 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, diff --git a/pkg/workingset/oauth.go b/pkg/workingset/oauth.go index d019e0e6b..e4c79c10c 100644 --- a/pkg/workingset/oauth.go +++ b/pkg/workingset/oauth.go @@ -24,11 +24,16 @@ func RegisterOAuthProvidersForServers(ctx context.Context, servers []Server) { if server.Snapshot == nil { continue } - if server.Snapshot.Server.HasExplicitOAuthProviders() { + if server.Snapshot.Server.HasExplicitOAuthProviders() || server.Snapshot.Server.HasPreRegisteredOAuth() { serverName := server.Snapshot.Server.Name - providerName := server.Snapshot.Server.OAuth.Providers[0].Provider + provider := server.Snapshot.Server.OAuth.Providers[0] - if err := oauth.RegisterProviderWithSnapshot(ctx, serverName, providerName); err != nil { + // Read scopes from OAuth.Scopes (legacy) or from server_metadata.scopes_supported (RFC 8414) + scopes := server.Snapshot.Server.OAuth.Scopes + if len(scopes) == 0 && provider.ServerMetadata != nil { + scopes = provider.ServerMetadata.ScopesSupported + } + if err := oauth.RegisterProviderWithSnapshot(ctx, serverName, provider, scopes); err != nil { log.Log(fmt.Sprintf("Warning: Failed to register OAuth provider for %s: %v", serverName, err)) } } else if server.Snapshot.Server.Type == "remote" && server.Snapshot.Server.Remote.URL != "" {