Skip to content

Commit 4e4df18

Browse files
authored
OAuth DCR with Docker CE (#208)
1 parent d5847e4 commit 4e4df18

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+5565
-77
lines changed

cmd/docker-mcp/oauth/auth.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,24 @@ package oauth
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
"github.com/docker/mcp-gateway/pkg/desktop"
9+
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
810
)
911

1012
func Authorize(ctx context.Context, app string, scopes string) error {
13+
// Check if running in CE mode
14+
if pkgoauth.IsCEMode() {
15+
return authorizeCEMode(ctx, app, scopes)
16+
}
17+
18+
// Desktop mode - existing implementation
19+
return authorizeDesktopMode(ctx, app, scopes)
20+
}
21+
22+
// authorizeDesktopMode handles OAuth via Docker Desktop (existing behavior)
23+
func authorizeDesktopMode(ctx context.Context, app string, scopes string) error {
1124
client := desktop.NewAuthClient()
1225

1326
// Start OAuth flow - Docker Desktop handles DCR automatically if needed
@@ -24,3 +37,81 @@ func Authorize(ctx context.Context, app string, scopes string) error {
2437
fmt.Printf("Opening your browser for authentication. If it doesn't open automatically, please visit: %s\n", authResponse.BrowserURL)
2538
return nil
2639
}
40+
41+
// authorizeCEMode handles OAuth in standalone CE mode
42+
func authorizeCEMode(ctx context.Context, serverName string, scopes string) error {
43+
fmt.Printf("Starting OAuth authorization for %s...\n", serverName)
44+
45+
// Create OAuth manager with read-write credential helper
46+
credHelper := pkgoauth.NewReadWriteCredentialHelper()
47+
manager := pkgoauth.NewManager(credHelper)
48+
49+
// Step 1: Ensure DCR client is registered
50+
fmt.Printf("Checking DCR registration...\n")
51+
if err := manager.EnsureDCRClient(ctx, serverName, scopes); err != nil {
52+
return fmt.Errorf("DCR registration failed: %w", err)
53+
}
54+
55+
// Step 2: Create callback server
56+
callbackServer, err := pkgoauth.NewCallbackServer()
57+
if err != nil {
58+
return fmt.Errorf("failed to create callback server: %w", err)
59+
}
60+
61+
// Start callback server in background
62+
go func() {
63+
if err := callbackServer.Start(); err != nil {
64+
fmt.Printf("Callback server error: %v\n", err)
65+
}
66+
}()
67+
defer func() {
68+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
69+
defer cancel()
70+
if err := callbackServer.Shutdown(shutdownCtx); err != nil {
71+
fmt.Printf("Warning: failed to shutdown callback server: %v\n", err)
72+
}
73+
}()
74+
75+
// Step 3: Build authorization URL with callback URL in state
76+
fmt.Printf("Generating authorization URL...\n")
77+
78+
scopesList := []string{}
79+
if scopes != "" {
80+
scopesList = []string{scopes}
81+
}
82+
83+
// Pass callback URL - will be embedded in state for mcp-oauth proxy routing
84+
callbackURL := callbackServer.URL()
85+
authURL, baseState, _, err := manager.BuildAuthorizationURL(ctx, serverName, scopesList, callbackURL)
86+
if err != nil {
87+
return fmt.Errorf("failed to generate authorization URL: %w", err)
88+
}
89+
90+
// Store base state for later validation
91+
_ = baseState // We'll validate using the state from callback
92+
93+
// Step 4: Display authorization URL
94+
fmt.Printf("Please visit this URL to authorize:\n\n %s\n\n", authURL)
95+
96+
// Step 5: Wait for callback
97+
fmt.Printf("Waiting for authorization callback on http://localhost:%d/callback...\n", callbackServer.Port())
98+
99+
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
100+
defer cancel()
101+
102+
code, callbackState, err := callbackServer.Wait(timeoutCtx)
103+
if err != nil {
104+
return fmt.Errorf("failed to receive callback: %w", err)
105+
}
106+
107+
// Step 6: Exchange code for token
108+
fmt.Printf("Exchanging authorization code for access token...\n")
109+
if err := manager.ExchangeCode(ctx, code, callbackState); err != nil {
110+
return fmt.Errorf("token exchange failed: %w", err)
111+
}
112+
113+
fmt.Printf("Authorization successful! Token stored securely.\n")
114+
fmt.Printf("You can now use: docker mcp server start %s\n", serverName)
115+
116+
return nil
117+
}

cmd/docker-mcp/oauth/revoke.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,23 @@ import (
66

77
"github.com/docker/mcp-gateway/pkg/catalog"
88
"github.com/docker/mcp-gateway/pkg/desktop"
9+
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
910
)
1011

1112
func Revoke(ctx context.Context, app string) error {
13+
fmt.Printf("Revoking OAuth access for %s...\n", app)
14+
15+
// Check if CE mode
16+
if pkgoauth.IsCEMode() {
17+
return revokeCEMode(ctx, app)
18+
}
19+
20+
// Desktop mode - existing implementation
21+
return revokeDesktopMode(ctx, app)
22+
}
23+
24+
// revokeDesktopMode handles revoke via Docker Desktop (existing behavior)
25+
func revokeDesktopMode(ctx context.Context, app string) error {
1226
client := desktop.NewAuthClient()
1327

1428
// Get catalog to check if this is a remote OAuth server
@@ -20,8 +34,6 @@ func Revoke(ctx context.Context, app string) error {
2034
server, found := catalogData.Servers[app]
2135
isRemoteOAuth := found && server.IsRemoteOAuthServer()
2236

23-
fmt.Printf("Revoking OAuth access for %s...\n", app)
24-
2537
// Revoke tokens
2638
if err := client.DeleteOAuthApp(ctx, app); err != nil {
2739
return fmt.Errorf("failed to revoke OAuth access: %w", err)
@@ -34,5 +46,27 @@ func Revoke(ctx context.Context, app string) error {
3446
}
3547
}
3648

49+
fmt.Printf("OAuth access revoked for %s\n", app)
50+
return nil
51+
}
52+
53+
// revokeCEMode handles revoke in standalone CE mode
54+
// Matches Desktop behavior: deletes both token and DCR client
55+
func revokeCEMode(ctx context.Context, app string) error {
56+
credHelper := pkgoauth.NewReadWriteCredentialHelper()
57+
manager := pkgoauth.NewManager(credHelper)
58+
59+
// Delete OAuth token
60+
if err := manager.RevokeToken(ctx, app); err != nil {
61+
// Token might not exist, continue to DCR deletion
62+
fmt.Printf("Note: %v\n", err)
63+
}
64+
65+
// Delete DCR client (matches Desktop behavior)
66+
if err := manager.DeleteDCRClient(app); err != nil {
67+
return fmt.Errorf("failed to delete DCR client: %w", err)
68+
}
69+
70+
fmt.Printf("OAuth access revoked for %s\n", app)
3771
return nil
3872
}

cmd/docker-mcp/server/enable.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/docker/mcp-gateway/pkg/catalog"
1313
"github.com/docker/mcp-gateway/pkg/config"
1414
"github.com/docker/mcp-gateway/pkg/docker"
15-
"github.com/docker/mcp-gateway/pkg/oauth"
15+
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
1616
)
1717

1818
func Disable(ctx context.Context, docker docker.Client, dockerCli command.Cli, serverNames []string, mcpOAuthDcrEnabled bool) error {
@@ -60,13 +60,19 @@ func update(ctx context.Context, docker docker.Client, dockerCli command.Cli, ad
6060
Ref: "",
6161
}
6262

63-
// Three-condition check: DCR flag enabled AND type="remote" AND oauth present
63+
// DCR flag enabled AND type="remote" AND oauth present
6464
if mcpOAuthDcrEnabled && server.IsRemoteOAuthServer() {
65-
if err := oauth.RegisterProviderForLazySetup(ctx, serverName); err != nil {
66-
fmt.Printf("Warning: Failed to register OAuth provider for %s: %v\n", serverName, err)
67-
fmt.Printf(" You can run 'docker mcp oauth authorize %s' later to set up authentication.\n", serverName)
65+
// In CE mode, skip lazy setup - DCR happens during oauth authorize
66+
if pkgoauth.IsCEMode() {
67+
fmt.Printf("OAuth server %s enabled. Run 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
6868
} else {
69-
fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
69+
// Desktop mode - register provider for lazy setup
70+
if err := pkgoauth.RegisterProviderForLazySetup(ctx, serverName); err != nil {
71+
fmt.Printf("Warning: Failed to register OAuth provider for %s: %v\n", serverName, err)
72+
fmt.Printf(" You can run 'docker mcp oauth authorize %s' later to set up authentication.\n", serverName)
73+
} else {
74+
fmt.Printf("OAuth provider configured for %s - use 'docker mcp oauth authorize %s' to authenticate\n", serverName, serverName)
75+
}
7076
}
7177
} else if !mcpOAuthDcrEnabled && server.IsRemoteOAuthServer() {
7278
// Provide guidance when DCR is needed but disabled

docs/oauth-ce-mode.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# OAuth DCR with Docker CE
2+
3+
Complete guide for using MCP Gateway OAuth with Docker Engine (CE Mode).
4+
5+
## Prerequisites
6+
7+
### 1. Credential Helper Installed
8+
9+
MCP Gateway requires [Docker credential helper](https://github.com/docker/docker-credential-helpers) configured to securely store OAuth tokens.
10+
11+
12+
Download from releases:
13+
https://github.com/docker/docker-credential-helpers/releases
14+
15+
### 2. Configure Docker to Use Credential Helper
16+
17+
Edit or create `~/.docker/config.json`:
18+
19+
**macOS:**
20+
```json
21+
{
22+
"credsStore": "osxkeychain"
23+
}
24+
```
25+
26+
**Linux (Desktop):**
27+
```json
28+
{
29+
"credsStore": "secretservice"
30+
}
31+
```
32+
33+
**Linux (Headless):**
34+
```json
35+
{
36+
"credsStore": "pass"
37+
}
38+
```
39+
40+
**Windows:**
41+
```json
42+
{
43+
"credsStore": "wincred"
44+
}
45+
```
46+
47+
**Verify configuration:**
48+
```bash
49+
# Helper should be in your PATH
50+
which docker-credential-osxkeychain # macOS
51+
which docker-credential-secretservice # Linux Desktop
52+
which docker-credential-pass # Linux Headless
53+
where docker-credential-wincred.exe # Windows
54+
```
55+
56+
For detailed installation instructions, see:
57+
https://github.com/docker/docker-credential-helpers
58+
59+
## Configuration
60+
61+
### Enable CE Mode
62+
63+
Set the environment variable to enable standalone OAuth:
64+
65+
```bash
66+
export DOCKER_MCP_USE_CE=true
67+
```
68+
### Optional: Customize OAuth Callback Port
69+
70+
By default, MCP Gateway listens on `localhost:5000` for OAuth callbacks.
71+
72+
If port 5000 is already in use, set a custom port:
73+
74+
```bash
75+
export MCP_GATEWAY_OAUTH_PORT=5001
76+
```
77+
78+
Valid range: 1024-65535
79+
80+
**Error handling:**
81+
- If port is in use, you'll see a clear error message with solutions
82+
- Invalid port values (< 1024 or > 65535) fall back to default with warning
83+
84+
## Usage
85+
86+
### Step-by-Step: Authorizing an OAuth Server
87+
88+
This example uses Notion, but works with any OAuth-enabled MCP server.
89+
90+
#### Step 1: Enable the Server
91+
92+
```bash
93+
docker mcp server enable notion-remote
94+
```
95+
96+
#### Step 2: Authorize
97+
98+
```bash
99+
docker mcp oauth authorize notion-remote
100+
```

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/docker/cli-docs-tool v0.10.0
1414
github.com/docker/docker v28.3.3+incompatible
1515
github.com/docker/docker-credential-helpers v0.9.3
16+
github.com/docker/mcp-gateway-oauth-helpers v0.0.3
1617
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
1718
github.com/fsnotify/fsnotify v1.9.0
1819
github.com/go-playground/validator/v10 v10.28.0
@@ -37,6 +38,7 @@ require (
3738
go.opentelemetry.io/otel/sdk v1.38.0
3839
go.opentelemetry.io/otel/sdk/metric v1.38.0
3940
go.opentelemetry.io/otel/trace v1.38.0
41+
golang.org/x/oauth2 v0.32.0
4042
golang.org/x/sync v0.17.0
4143
gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473
4244
gopkg.in/yaml.v3 v3.0.1
@@ -117,7 +119,7 @@ require (
117119
github.com/gogo/protobuf v1.3.2 // indirect
118120
github.com/golang/snappy v1.0.0 // indirect
119121
github.com/google/certificate-transparency-go v1.3.2 // indirect
120-
github.com/google/uuid v1.6.0 // indirect
122+
github.com/google/uuid v1.6.0
121123
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
122124
github.com/in-toto/attestation v1.1.2 // indirect
123125
github.com/in-toto/in-toto-golang v0.9.0 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHz
244244
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
245245
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
246246
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
247+
github.com/docker/mcp-gateway-oauth-helpers v0.0.3 h1:TgXk/DFaXFYpDhduN3l7LCjdjo6doTHXoEg2P43NZT0=
248+
github.com/docker/mcp-gateway-oauth-helpers v0.0.3/go.mod h1:IK0jk+ZNYW/yy5yotveLFcU9Jgk3pMpNasX4X1hheZM=
247249
github.com/docker/mcp-community-registry v0.0.0-20251024214917-21000e320421 h1:18Ut0n3NwDXDyCc4vHE03Wcl3LhzOltstayBsg8QqtM=
248250
github.com/docker/mcp-community-registry v0.0.0-20251024214917-21000e320421/go.mod h1:TDsRGHqTXW9BykauHTOGUL5xQ74PH7zB9iK5HatW1Bg=
249251
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
@@ -838,8 +840,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
838840
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
839841
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
840842
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
841-
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
842-
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
843+
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
844+
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
843845
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
844846
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
845847
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

pkg/gateway/run.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,16 @@ func (g *Gateway) Run(ctx context.Context) error {
275275

276276
if g.McpOAuthDcrEnabled && !inContainer {
277277
// Start OAuth notification monitor to receive OAuth related events from Docker Desktop
278-
log.Log("- Starting OAuth notification monitor")
279-
monitor := oauth.NewNotificationMonitor()
280-
monitor.OnOAuthEvent = func(event oauth.Event) {
281-
// Route event to specific provider
282-
g.routeEventToProvider(event)
278+
// Skip in CE mode (no Desktop to connect to)
279+
if !oauth.IsCEMode() {
280+
log.Log("- Starting OAuth notification monitor")
281+
monitor := oauth.NewNotificationMonitor()
282+
monitor.OnOAuthEvent = func(event oauth.Event) {
283+
// Route event to specific provider
284+
g.routeEventToProvider(event)
285+
}
286+
monitor.Start(ctx)
283287
}
284-
monitor.Start(ctx)
285288

286289
// Start OAuth provider for each OAuth server
287290
// Each provider runs in its own goroutine with dynamic timing based on token expiry

0 commit comments

Comments
 (0)