Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 172 additions & 4 deletions cmd/docker-mcp/oauth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,51 @@ package oauth
import (
"context"
"fmt"
"net/url"
"time"

"golang.org/x/oauth2"

"github.com/docker/mcp-gateway/pkg/catalog"
"github.com/docker/mcp-gateway/pkg/desktop"
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
"github.com/docker/mcp-gateway/pkg/oauth/dcr"
)

// Authorize performs OAuth authorization for a server, routing to the
// appropriate flow based on the per-server mode (Desktop, CE, or Community).
func Authorize(ctx context.Context, app string, scopes string) error {
// Check if running in CE mode
if pkgoauth.IsCEMode() {
isCommunity, err := lookupIsCommunity(ctx, app)
if err != nil {
// Server not in catalog -- fall back to legacy global routing
// so existing servers without catalog entries still work.
if pkgoauth.IsCEMode() {
return authorizeCEMode(ctx, app, scopes)
}
return authorizeDesktopMode(ctx, app, scopes)
}

Comment on lines +20 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ, on the isCommunity check -> is this true for all servers that are not in the Docker catalog?

IE: how would this behave for a custom catalog built with servers from the DD catalog?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lookupIsCommunity loads all catalogs (Docker + configured/custom) via catalog.GetWithOptions(ctx, true, nil), then checks server.IsCommunity() which looks for the "community" tag in Metadata.Tags. That tag is only set by catalog_next/create.go when importing from the community registry.

So servers from the DD catalog in a custom catalog would not have the "community" tag -- they route to ModeDesktop (unchanged behavior).

The error path (server not in any catalog) falls back to the legacy global IsCEMode() check for backward compat with servers configured outside of catalogs.

switch pkgoauth.DetermineMode(ctx, isCommunity) {
case pkgoauth.ModeCE:
return authorizeCEMode(ctx, app, scopes)
case pkgoauth.ModeCommunity:
return authorizeCommunityMode(ctx, app, scopes)
default: // ModeDesktop
return authorizeDesktopMode(ctx, app, scopes)
}
}

// Desktop mode - existing implementation
return authorizeDesktopMode(ctx, app, scopes)
// lookupIsCommunity checks the catalog to determine if a server is a community server.
func lookupIsCommunity(ctx context.Context, serverName string) (bool, error) {
cat, err := catalog.GetWithOptions(ctx, true, nil)
if err != nil {
return false, err
}
server, found := cat.Servers[serverName]
if !found {
return false, fmt.Errorf("server %s not found in catalog", serverName)
}
return server.IsCommunity(), nil
}

// authorizeDesktopMode handles OAuth via Docker Desktop (existing behavior)
Expand Down Expand Up @@ -115,3 +146,140 @@ func authorizeCEMode(ctx context.Context, serverName string, scopes string) erro

return nil
}

// authorizeCommunityMode handles OAuth for community servers in Desktop mode.
// Uses the Gateway OAuth flow (localhost callback, PKCE) with docker pass storage.
Copy link
Copy Markdown
Contributor

@austin5456 austin5456 Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had understood that CE mode ( and therefore the community servers ) would use docker-credential-helpers for storage, are we migrating it to pass as well?
I think pass might require DD

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No -- CE mode is unchanged and still uses docker-credential-helpers via authorizeCEMode / NewReadWriteCredentialHelper().

authorizeCommunityMode (docker pass) is only reached when DetermineMode returns ModeCommunity, which requires: Desktop mode + community server + McpGatewayOAuth flag ON. CE mode always returns ModeCE from DetermineMode and routes to authorizeCEMode.

func authorizeCommunityMode(ctx context.Context, serverName string, scopes string) error {
fmt.Printf("Starting OAuth authorization for %s (community)...\n", serverName)

// Validate docker pass is available (required for community mode)
if err := desktop.CheckHasDockerPass(ctx); err != nil {
return fmt.Errorf("docker pass required for community server OAuth: %w", err)
}

// Step 1: Ensure DCR client is registered in docker pass
fmt.Printf("Checking DCR registration...\n")
dcrClient, err := pkgoauth.GetDCRClientFromDockerPass(ctx, serverName)
if err != nil || dcrClient.ClientID == "" {
// No DCR client in docker pass -- perform discovery and registration
dcrClient, err = dcr.DiscoverAndRegister(ctx, serverName, scopes, pkgoauth.DefaultRedirectURI)
if err != nil {
return fmt.Errorf("DCR registration failed: %w", err)
}
if err := pkgoauth.SaveDCRClientToDockerPass(ctx, serverName, dcrClient); err != nil {
return fmt.Errorf("failed to save DCR client: %w", err)
}
}

// Step 2: Create callback server
callbackServer, err := pkgoauth.NewCallbackServer()
if err != nil {
return fmt.Errorf("failed to create callback server: %w", err)
}

// Start callback server in background
go func() {
if err := callbackServer.Start(); err != nil {
fmt.Printf("Callback server error: %v\n", err)
}
}()
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := callbackServer.Shutdown(shutdownCtx); err != nil {
fmt.Printf("Warning: failed to shutdown callback server: %v\n", err)
}
}()

// Step 3: Build authorization URL with PKCE
fmt.Printf("Generating authorization URL...\n")

provider := pkgoauth.NewDCRProvider(dcrClient, pkgoauth.DefaultRedirectURI)
verifier := provider.GeneratePKCE()

stateManager := pkgoauth.NewStateManager()
baseState := stateManager.Generate(serverName, verifier)

// Encode callback port in state for mcp-oauth proxy routing
callbackURL := callbackServer.URL()
parsedCallback, err := url.Parse(callbackURL)
if err != nil {
return fmt.Errorf("invalid callback URL: %w", err)
}
port := parsedCallback.Port()
if port == "" {
return fmt.Errorf("callback URL missing port")
}
state := fmt.Sprintf("mcp-gateway:%s:%s", port, baseState)

config := provider.Config()

scopesList := []string{}
if scopes != "" {
scopesList = []string{scopes}
}
if len(scopesList) > 0 {
config.Scopes = scopesList
}

opts := []oauth2.AuthCodeOption{
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier),
}
if provider.ResourceURL() != "" {
opts = append(opts, oauth2.SetAuthURLParam("resource", provider.ResourceURL()))
}

authURL := config.AuthCodeURL(state, opts...)

// Step 4: Display authorization URL
fmt.Printf("Please visit this URL to authorize:\n\n %s\n\n", authURL)

// Step 5: Wait for callback
fmt.Printf("Waiting for authorization callback on http://localhost:%d/callback...\n", callbackServer.Port())

timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

code, callbackState, err := callbackServer.Wait(timeoutCtx)
if err != nil {
return fmt.Errorf("failed to receive callback: %w", err)
}

// Validate the returned state to prevent CSRF attacks.
// The mcp-oauth proxy strips the "mcp-gateway:PORT:" prefix and passes
// the bare UUID to our localhost callback, so callbackState is the UUID
// that stateManager.Generate() returned.
validatedServer, validatedVerifier, err := stateManager.Validate(callbackState)
if err != nil {
return fmt.Errorf("OAuth state validation failed: %w", err)
}
if validatedServer != serverName {
return fmt.Errorf("OAuth state mismatch: expected server %q, got %q", serverName, validatedServer)
}

// Step 6: Exchange code for token
fmt.Printf("Exchanging authorization code for access token...\n")

exchangeOpts := []oauth2.AuthCodeOption{
oauth2.VerifierOption(validatedVerifier),
}
if provider.ResourceURL() != "" {
exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("resource", provider.ResourceURL()))
}

token, err := config.Exchange(ctx, code, exchangeOpts...)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}

// Step 7: Store token in docker pass
if err := pkgoauth.SaveTokenToDockerPass(ctx, serverName, token); err != nil {
return fmt.Errorf("failed to store token: %w", err)
}

fmt.Printf("Authorization successful! Token stored securely.\n")
fmt.Printf("You can now use: docker mcp server start %s\n", serverName)

return nil
}
42 changes: 37 additions & 5 deletions cmd/docker-mcp/oauth/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,35 @@ import (
"context"
"fmt"

"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret"
"github.com/docker/mcp-gateway/pkg/db"
"github.com/docker/mcp-gateway/pkg/desktop"
pkgoauth "github.com/docker/mcp-gateway/pkg/oauth"
"github.com/docker/mcp-gateway/pkg/workingset"
)

// Revoke revokes OAuth access for a server, routing to the appropriate flow
// based on the per-server mode (Desktop, CE, or Community).
func Revoke(ctx context.Context, app string) error {
fmt.Printf("Revoking OAuth access for %s...\n", app)

// Check if CE mode
if pkgoauth.IsCEMode() {
return revokeCEMode(ctx, app)
isCommunity, err := lookupIsCommunity(ctx, app)
if err != nil {
// Server not in catalog -- fall back to legacy global routing.
if pkgoauth.IsCEMode() {
return revokeCEMode(ctx, app)
}
return revokeDesktopMode(ctx, app)
}

// Desktop mode - existing implementation
return revokeDesktopMode(ctx, app)
switch pkgoauth.DetermineMode(ctx, isCommunity) {
case pkgoauth.ModeCE:
return revokeCEMode(ctx, app)
case pkgoauth.ModeCommunity:
return revokeCommunityMode(ctx, app)
default: // ModeDesktop
return revokeDesktopMode(ctx, app)
}
}

// revokeDesktopMode handles revoke via Docker Desktop (existing behavior)
Expand Down Expand Up @@ -64,3 +77,22 @@ func revokeCEMode(ctx context.Context, app string) error {
fmt.Printf("OAuth access revoked for %s\n", app)
return nil
}

// revokeCommunityMode handles revoke for community servers in Desktop mode.
// Deletes the OAuth token and DCR client from docker pass.
func revokeCommunityMode(ctx context.Context, app string) error {
// Delete OAuth token from docker pass
if err := secret.DeleteOAuthToken(ctx, app); err != nil {
// Token might not exist, continue to DCR deletion
fmt.Printf("Note: %v\n", err)
}

// Delete DCR client from docker pass (soft failure -- entry may not exist
// if authorize was never completed or was already revoked)
if err := secret.DeleteDCRClient(ctx, app); err != nil {
fmt.Printf("Note: %v\n", err)
}

fmt.Printf("OAuth access revoked for %s\n", app)
return nil
}
Loading
Loading