diff --git a/cmd/mecated/command.go b/cmd/mecated/command.go index 2524f77cd8..f672a17046 100644 --- a/cmd/mecated/command.go +++ b/cmd/mecated/command.go @@ -269,7 +269,7 @@ func resolveMCPSubcommand(args []string) commandResolution { } func writeMCPHelp(out io.Writer) { - _, _ = fmt.Fprintln(out, "Usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]\n\nAuthorize one operator-configured OAuth MCP server. --permission-config selects trusted operator settings only; it never supplies OAuth values.") + _, _ = fmt.Fprintln(out, "Usage: "+strings.TrimPrefix(mcpLoginUsage, "usage: ")+"\n\nAuthorize one operator-configured OAuth MCP server. --permission-config selects trusted operator settings only; it never supplies OAuth values. DCR recovery modifiers are mutually exclusive and perform an explicit registration operation before login.") } func mcpUsageError(argv []string) error { @@ -278,9 +278,9 @@ func mcpUsageError(argv []string) error { sub = argv[2] } if sub == "" { - return errors.New("mcp: missing subcommand\navailable subcommands:\n mcp login SERVER [--no-browser] [--permission-config PATH ...] authorize a configured OAuth server") + return errors.New("mcp: missing subcommand\navailable subcommands:\n " + strings.TrimPrefix(mcpLoginUsage, "usage: mecated ") + " authorize a configured OAuth server") } - return fmt.Errorf("mcp: unknown subcommand %q\navailable subcommands:\n mcp login SERVER [--no-browser] [--permission-config PATH ...] authorize a configured OAuth server", sub) + return fmt.Errorf("mcp: unknown subcommand %q\navailable subcommands:\n %s authorize a configured OAuth server", sub, strings.TrimPrefix(mcpLoginUsage, "usage: mecated ")) } // configUsageError builds the error message for a bare/unknown `config` invocation. diff --git a/cmd/mecated/mcplogin.go b/cmd/mecated/mcplogin.go index 352c694c1d..5eea98d8a0 100644 --- a/cmd/mecated/mcplogin.go +++ b/cmd/mecated/mcplogin.go @@ -19,12 +19,15 @@ import ( "github.com/stacklok/mecatl/mcp/oauthlogin" ) -var errMCPLoginUsage = errors.New("usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]") +const mcpLoginUsage = "usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] [--reset-dcr-registration | --retry-dcr-registration]" + +var errMCPLoginUsage = errors.New(mcpLoginUsage) type mcpLoginArgs struct { server string noBrowser bool permissionConfigs []string + dcrAction mcp.OAuthDCRLoginAction } func parseMCPLoginArgs(args []string, out io.Writer) (mcpLoginArgs, error) { @@ -33,19 +36,33 @@ func parseMCPLoginArgs(args []string, out io.Writer) (mcpLoginArgs, error) { arg := args[i] switch arg { case "-h", "--help": - _, _ = fmt.Fprintln(out, "Usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]\n\nAuthorize one operator-configured OAuth MCP server. --no-browser prints the terminal authorization URL. --permission-config selects trusted operator settings and is repeatable.") + _, _ = fmt.Fprintln(out, "Usage: "+strings.TrimPrefix(mcpLoginUsage, "usage: ")+"\n\nAuthorize one operator-configured OAuth MCP server. --no-browser prints the terminal authorization URL. --permission-config selects trusted operator settings and is repeatable. DCR registration recovery requires exactly one explicit --reset-dcr-registration or --retry-dcr-registration operation.") return mcpLoginArgs{}, flag.ErrHelp case "--no-browser": if parsed.noBrowser { return mcpLoginArgs{}, errMCPLoginUsage } parsed.noBrowser = true + case "--reset-dcr-registration": + if parsed.dcrAction != mcp.OAuthDCRLoginReuse { + return mcpLoginArgs{}, errMCPLoginUsage + } + parsed.dcrAction = mcp.OAuthDCRLoginResetRegistration + case "--retry-dcr-registration": + if parsed.dcrAction != mcp.OAuthDCRLoginReuse { + return mcpLoginArgs{}, errMCPLoginUsage + } + parsed.dcrAction = mcp.OAuthDCRLoginRetryRegistration case "--permission-config": - i++ - if i >= len(args) || args[i] == "" || strings.HasPrefix(args[i], "-") { + if i+1 >= len(args) { return mcpLoginArgs{}, errMCPLoginUsage } - parsed.permissionConfigs = append(parsed.permissionConfigs, args[i]) + path := args[i+1] // #nosec G602 -- the immediately preceding bound check proves i+1 is valid. + if path == "" || strings.HasPrefix(path, "-") { + return mcpLoginArgs{}, errMCPLoginUsage + } + i++ + parsed.permissionConfigs = append(parsed.permissionConfigs, path) default: if path, ok := strings.CutPrefix(arg, "--permission-config="); ok { if path == "" { @@ -91,6 +108,23 @@ func mcpLoginRemedy(err error) error { var rejected *oauthlogin.CallbackRejectedError var bind *oauthlogin.CallbackBindError switch { + case errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired): + switch mcp.OAuthDCRRecoveryCategoryOf(err) { + case mcp.OAuthDCRRecoveryPending: + return errors.New("MCP OAuth DCR previous registration attempt did not complete and its exact safe failure stage was not recorded; use --retry-dcr-registration only if creating a duplicate or orphan client is acceptable") + case mcp.OAuthDCRRecoveryRegistrationOutcomeUnknown: + return errors.New("MCP OAuth DCR registration request outcome is unknown; use --retry-dcr-registration only if a possible orphan client is acceptable") + case mcp.OAuthDCRRecoveryResponseInvalid: + return errors.New("the OAuth provider returned a registration response that Mecatl could not safely use for DCR; use --retry-dcr-registration only if a possible orphan client is acceptable") + case mcp.OAuthDCRRecoveryReadyPersistence: + return errors.New("MCP OAuth DCR registration response was accepted but the ready record was not persisted; use --retry-dcr-registration only if a possible orphan client is acceptable") + case mcp.OAuthDCRRecoveryResetRequired: + return errors.New("MCP OAuth DCR valid ready registration identity differs from the current profile, principal, canonical resource, or exact issuer; use --reset-dcr-registration") + case mcp.OAuthDCRRecoveryPendingIdentityMismatch: + return errors.New("MCP OAuth DCR pending registration identity does not match the current configuration; restore the matching profile, principal, canonical resource, and exact issuer, then use --retry-dcr-registration") + default: + return errors.New("MCP OAuth DCR registration state is corrupt or unreadable; do not retry or reset registration. Preserve the records and configuration without editing, deleting, or renaming them. Contact the deployment operator or support team with only the server name and redacted command error; never send credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. Reset/retry do not repair corrupt state or revoke an upstream client") + } case errors.As(err, &provider) && errors.Is(err, app.ErrMCPLoginAuthorization): return fmt.Errorf("MCP OAuth authorization server rejected login (%s); review the requested scopes and provider policy", provider.Sanitized()) case errors.As(err, &rejected) && errors.Is(err, app.ErrMCPLoginAuthorization): @@ -115,12 +149,12 @@ func mcpLoginRemedy(err error) error { } } -var executeMCPLogin = func(ctx context.Context, server mcp.ServerConfig, opts oauthlogin.Options) error { - runtime, err := oauthlogin.New(opts) +var executeMCPLogin = func(ctx context.Context, server mcp.ServerConfig, runtimeOpts oauthlogin.Options, loginOpts app.MCPLoginOptions) error { + runtime, err := oauthlogin.New(runtimeOpts) if err != nil { return errors.New("MCP OAuth login runtime unavailable") } - return app.LoginMCP(ctx, server, runtime) + return app.LoginMCPWithOptions(ctx, server, runtime, loginOpts) } func loadMCPLoginProfiles(explicit []string) (*cliconfig.MCPProfiles, error) { @@ -155,7 +189,7 @@ func runMCPLogin(args []string, stdout io.Writer) error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := executeMCPLogin(ctx, server, opts); err != nil { + if err := executeMCPLogin(ctx, server, opts, app.MCPLoginOptions{DCRAction: parsed.dcrAction}); err != nil { return mcpLoginRemedy(err) } _, err = fmt.Fprintf(stdout, "MCP OAuth login succeeded for %s\n", server.Name) diff --git a/cmd/mecated/mcplogin_test.go b/cmd/mecated/mcplogin_test.go index 851e4058b1..945c787a6f 100644 --- a/cmd/mecated/mcplogin_test.go +++ b/cmd/mecated/mcplogin_test.go @@ -2,16 +2,26 @@ package main import ( "context" + "crypto/sha256" "encoding/base64" + "encoding/binary" + "encoding/json" "errors" "flag" "fmt" "io" + "math" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "github.com/stacklok/mecatl/internal/adapter/credentialstore" "github.com/stacklok/mecatl/internal/adapter/mcp" "github.com/stacklok/mecatl/internal/app" @@ -32,7 +42,8 @@ func TestMCPLoginHelpAndUsageAreSideEffectFree(t *testing.T) { if err := res.run(strings.NewReader(""), &out, io.Discard); !errors.Is(err, flag.ErrHelp) { t.Fatalf("help error = %v", err) } - if !strings.Contains(out.String(), "mecated mcp login SERVER [--no-browser]") { + if !strings.Contains(out.String(), "mecated mcp login SERVER [--no-browser]") || + !strings.Contains(out.String(), "--reset-dcr-registration | --retry-dcr-registration") { t.Fatalf("help = %q", out.String()) } @@ -63,6 +74,65 @@ func TestMCPLoginHelpAndUsageAreSideEffectFree(t *testing.T) { } } +func TestParseMCPLoginDCRResetAndRetryFlags(t *testing.T) { + for _, tc := range []struct { + flag string + want mcp.OAuthDCRLoginAction + }{ + {"--reset-dcr-registration", mcp.OAuthDCRLoginResetRegistration}, + {"--retry-dcr-registration", mcp.OAuthDCRLoginRetryRegistration}, + } { + parsed, err := parseMCPLoginArgs([]string{"gateway", tc.flag}, io.Discard) + if err != nil { + t.Fatalf("parse %s: %v", tc.flag, err) + } + if parsed.dcrAction != tc.want { + t.Fatalf("parse %s action = %v, want %v", tc.flag, parsed.dcrAction, tc.want) + } + } + for _, args := range [][]string{ + {"gateway", "--reset-dcr-registration", "--retry-dcr-registration"}, + {"gateway", "--retry-dcr-registration", "--reset-dcr-registration"}, + {"gateway", "--reset-dcr-registration", "--reset-dcr-registration"}, + {"gateway", "--retry-dcr-registration", "--retry-dcr-registration"}, + } { + if _, err := parseMCPLoginArgs(args, io.Discard); !errors.Is(err, errMCPLoginUsage) { + t.Errorf("parseMCPLoginArgs(%q) error = %v", args, err) + } + } + + const secret = "registration-client-id-secret-canary" + for _, tc := range []struct { + name string + kind mcp.OAuthDCRRecoveryCategory + want []string + avoid []string + }{ + {name: "legacy pending", kind: mcp.OAuthDCRRecoveryPending, want: []string{"previous registration attempt did not complete", "safe failure stage was not recorded", "--retry-dcr-registration", "duplicate or orphan client"}, avoid: []string{"--reset-dcr-registration", secret}}, + {name: "corrupt", kind: mcp.OAuthDCRRecoveryCorrupt, want: []string{"corrupt or unreadable", "Preserve the records and configuration", "without editing, deleting, or renaming", "deployment operator or support team", "only the server name and redacted command error", "never send credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response", "do not repair corrupt state or revoke an upstream client"}, avoid: []string{"--retry-dcr-registration", "--reset-dcr-registration", secret}}, + {name: "unknown outcome", kind: mcp.OAuthDCRRecoveryRegistrationOutcomeUnknown, want: []string{"request outcome is unknown", "--retry-dcr-registration", "orphan client"}, avoid: []string{secret}}, + {name: "invalid response", kind: mcp.OAuthDCRRecoveryResponseInvalid, want: []string{"provider returned a registration response", "could not safely use", "--retry-dcr-registration", "orphan client"}, avoid: []string{"contract validation", secret}}, + {name: "persistence", kind: mcp.OAuthDCRRecoveryReadyPersistence, want: []string{"ready record was not persisted", "--retry-dcr-registration", "orphan client"}, avoid: []string{secret}}, + {name: "ready reset", kind: mcp.OAuthDCRRecoveryResetRequired, want: []string{"valid ready registration", "current profile, principal, canonical resource, or exact issuer", "--reset-dcr-registration"}, avoid: []string{"restore the matching configuration", "--retry-dcr-registration", secret}}, + {name: "pending identity mismatch", kind: mcp.OAuthDCRRecoveryPendingIdentityMismatch, want: []string{"pending registration identity", "restore the matching profile, principal, canonical resource, and exact issuer", "--retry-dcr-registration"}, avoid: []string{"--reset-dcr-registration", secret}}, + } { + t.Run(tc.name, func(t *testing.T) { + recovery := errors.Join(app.ErrMCPLoginAuthorization, mcp.NewOAuthDCRRecoveryError(tc.kind), errors.New(secret)) + got := mcpLoginRemedy(recovery).Error() + for _, want := range tc.want { + if !strings.Contains(got, want) { + t.Fatalf("recovery remedy = %q, want %q", got, want) + } + } + for _, forbidden := range tc.avoid { + if strings.Contains(got, forbidden) { + t.Fatalf("recovery remedy leaked or suggested forbidden %q: %q", forbidden, got) + } + } + }) + } +} + func TestMCPGroupHelpActionHasNoRuntimeSideEffects(t *testing.T) { xdg := t.TempDir() t.Setenv("XDG_CONFIG_HOME", xdg) @@ -78,7 +148,7 @@ func TestMCPGroupHelpActionHasNoRuntimeSideEffects(t *testing.T) { original := executeMCPLogin t.Cleanup(func() { executeMCPLogin = original }) calls := 0 - executeMCPLogin = func(context.Context, mcp.ServerConfig, oauthlogin.Options) error { + executeMCPLogin = func(context.Context, mcp.ServerConfig, oauthlogin.Options, app.MCPLoginOptions) error { calls++ return errors.New("help must not execute login") } @@ -202,7 +272,7 @@ func TestRunMCPLoginExecutionPathUsesRandomCallback(t *testing.T) { original := executeMCPLogin t.Cleanup(func() { executeMCPLogin = original }) calls := 0 - executeMCPLogin = func(_ context.Context, server mcp.ServerConfig, opts oauthlogin.Options) error { + executeMCPLogin = func(_ context.Context, server mcp.ServerConfig, opts oauthlogin.Options, loginOpts app.MCPLoginOptions) error { calls++ if server.Name != "GitHub" || server.OAuth == nil || server.OAuth.CredentialStore == nil || server.OAuth.CredentialReader != nil { t.Fatalf("selected server = %#v", server) @@ -210,10 +280,13 @@ func TestRunMCPLoginExecutionPathUsesRandomCallback(t *testing.T) { if !opts.NoBrowser || opts.URLWriter == nil || opts.RedirectURL != "" { t.Fatalf("runtime options = %#v; no-browser or random-path default was not forwarded", opts) } + if loginOpts.DCRAction != mcp.OAuthDCRLoginRetryRegistration { + t.Fatalf("login options = %#v", loginOpts) + } return nil } var out strings.Builder - if err := runMCPLogin([]string{"github", "--permission-config", local, "--no-browser"}, &out); err != nil { + if err := runMCPLogin([]string{"github", "--permission-config", local, "--no-browser", "--retry-dcr-registration"}, &out); err != nil { t.Fatal(err) } if calls != 1 || !strings.Contains(out.String(), "succeeded for GitHub") { @@ -287,3 +360,374 @@ func TestMCPLoginArgsAcceptServerInteractionAndConfigSelectionOnly(t *testing.T) } } } + +func TestADR_0325_DCRResetAndRetryCLI(t *testing.T) { + fixture := newMCPLoginDCRFixture(t) + root := filepath.Join(t.TempDir(), "credentials") + settings := filepath.Join(t.TempDir(), "settings.yaml") + t.Setenv("MECATL_LOGIN_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err := os.WriteFile(settings, []byte(mcpLoginDCRYAML(fixture, root)), 0o600); err != nil { + t.Fatal(err) + } + original := executeMCPLogin + t.Cleanup(func() { executeMCPLogin = original }) + + seed := func(t *testing.T, ready, grant bool) { + t.Helper() + profiles, err := loadMCPLoginProfiles([]string{settings}) + if err != nil { + t.Fatalf("load seed profile: %v", err) + } + defer profiles.Close() + server, ok := profiles.OAuthServer("connector") + if !ok { + t.Fatal("connector seed profile missing") + } + mcp.AllowOAuthLoopbackForTest(t, server.OAuth) + mcp.TrustOAuthCertificateForTest(t, server.OAuth, fixture.server.Certificate()) + prepared, callbackPath, err := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, mcp.OAuthDCRLoginReuse) + if err != nil { + t.Fatalf("prepare seed: %v", err) + } + if !ready { + return + } + prepared.RedirectURL = "http://127.0.0.1:49152" + callbackPath + prepared.Presenter = mcp.OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, parseErr := url.Parse(raw) + if parseErr != nil { + return nil, parseErr + } + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + controller, err := mcp.NewOAuthController(context.Background(), fixture.resource(), prepared) + if err != nil { + t.Fatalf("register seed: %v", err) + } + defer controller.Close() + if grant { + req, reqErr := http.NewRequest(http.MethodGet, fixture.resource(), nil) + if reqErr != nil { + t.Fatal(reqErr) + } + resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"WWW-Authenticate": {`Bearer scope="openid"`}}, Body: io.NopCloser(strings.NewReader(""))} + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + } + } + registrationKey := dcrLoginRecordKey("mecatl/mcp/oauth-dcr-lifecycle-key/v1", "connector") + getRecord := func(t *testing.T, key []byte) credentialstore.Record { + t.Helper() + profiles, err := loadMCPLoginProfiles([]string{settings}) + if err != nil { + t.Fatal(err) + } + defer profiles.Close() + server, _ := profiles.OAuthServer("connector") + record, err := server.OAuth.CredentialStore.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + return record + } + assertUnchanged := func(t *testing.T, key []byte, before credentialstore.Record) { + t.Helper() + after := getRecord(t, key) + if string(after.Value) != string(before.Value) || !after.Version.Equal(before.Version) { + t.Fatal("rejected CLI action mutated the durable record") + } + } + runAction := func(t *testing.T, actionFlag string, operation func(mcp.ServerConfig, app.MCPLoginOptions) error) (int, error) { + t.Helper() + calls := 0 + executeMCPLogin = func(_ context.Context, server mcp.ServerConfig, _ oauthlogin.Options, options app.MCPLoginOptions) error { + calls++ + mcp.AllowOAuthLoopbackForTest(t, server.OAuth) + mcp.TrustOAuthCertificateForTest(t, server.OAuth, fixture.server.Certificate()) + return operation(server, options) + } + args := []string{"connector", "--permission-config", settings} + if actionFlag != "" { + args = append(args, actionFlag) + } + return calls, runMCPLogin(args, io.Discard) + } + + t.Run("missing registration rejects reset without bootstrap", func(t *testing.T) { + calls, err := runAction(t, "--reset-dcr-registration", func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if err == nil || calls != 1 { + t.Fatalf("missing reset calls=%d error=%v", calls, err) + } + profiles, loadErr := loadMCPLoginProfiles([]string{settings}) + if loadErr != nil { + t.Fatal(loadErr) + } + server, _ := profiles.OAuthServer("connector") + _, getErr := server.OAuth.CredentialStore.Get(context.Background(), registrationKey) + _ = profiles.Close() + if !errors.Is(getErr, credentialstore.ErrNotFound) { + t.Fatalf("rejected reset created registration state: %v", getErr) + } + }) + + t.Run("ready reset and pending retry reach login seam", func(t *testing.T) { + seed(t, true, true) + ready := getRecord(t, registrationKey) + var readyEnvelope struct { + Generation string `json:"generation"` + Registration struct { + ClientID string `json:"client_id"` + } `json:"registration"` + } + if err := json.Unmarshal(ready.Value, &readyEnvelope); err != nil { + t.Fatal(err) + } + grantKey := dcrLoginRecordKey("mecatl/mcp/oauth-dcr-credential-key/v1", "work", "operator", fixture.resource(), fixture.server.URL, "dcr", readyEnvelope.Registration.ClientID, readyEnvelope.Generation) + grant := getRecord(t, grantKey) + profiles, err := loadMCPLoginProfiles([]string{settings}) + if err != nil { + t.Fatal(err) + } + server, _ := profiles.OAuthServer("connector") + corruptGrant, err := server.OAuth.CredentialStore.Put(context.Background(), grantKey, []byte(`{"schema":"corrupt"}`), &grant.Version) + _ = profiles.Close() + if err != nil { + t.Fatal(err) + } + calls, err := runAction(t, "--reset-dcr-registration", func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if err == nil || calls != 1 { + t.Fatalf("corrupt grant reset calls=%d error=%v", calls, err) + } + assertUnchanged(t, registrationKey, ready) + assertUnchanged(t, grantKey, corruptGrant) + profiles, err = loadMCPLoginProfiles([]string{settings}) + if err != nil { + t.Fatal(err) + } + server, _ = profiles.OAuthServer("connector") + if _, err := server.OAuth.CredentialStore.Put(context.Background(), grantKey, grant.Value, &corruptGrant.Version); err != nil { + t.Fatal(err) + } + _ = profiles.Close() + + for name, operation := range map[string]func(mcp.ServerConfig, app.MCPLoginOptions) error{ + "retry ready": func(server mcp.ServerConfig, _ app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, mcp.OAuthDCRLoginRetryRegistration) + return prepErr + }, + "backend failure": func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + unavailable := *server.OAuth + unavailable.CredentialStore = unavailableMCPLoginStore{Store: server.OAuth.CredentialStore} + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, unavailable, options.DCRAction) + return prepErr + }, + } { + t.Run(name, func(t *testing.T) { + before := getRecord(t, registrationKey) + flag := "--reset-dcr-registration" + if name == "retry ready" { + flag = "--retry-dcr-registration" + } + calls, actionErr := runAction(t, flag, operation) + if actionErr == nil || calls != 1 { + t.Fatalf("calls=%d error=%v", calls, actionErr) + } + assertUnchanged(t, registrationKey, before) + }) + } + + beforeConflict := getRecord(t, registrationKey) + for name, args := range map[string][]string{ + "conflicting flags": {"connector", "--permission-config", settings, "--reset-dcr-registration", "--retry-dcr-registration"}, + "grant reset flag": {"connector", "--permission-config", settings, "--reset-dcr-grant"}, + } { + t.Run(name, func(t *testing.T) { + seamCalls := 0 + executeMCPLogin = func(context.Context, mcp.ServerConfig, oauthlogin.Options, app.MCPLoginOptions) error { + seamCalls++ + return nil + } + usageErr := runMCPLogin(args, io.Discard) + if !errors.Is(usageErr, errMCPLoginUsage) || seamCalls != 0 { + t.Fatalf("error=%v seam calls=%d", usageErr, seamCalls) + } + assertUnchanged(t, registrationKey, beforeConflict) + }) + } + + calls, err = runAction(t, "--reset-dcr-registration", func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if err != nil || calls != 1 { + t.Fatalf("ready reset calls=%d error=%v", calls, err) + } + pending := getRecord(t, registrationKey) + if pending.Version.Equal(ready.Version) || string(pending.Value) == string(ready.Value) { + t.Fatal("ready reset did not replace the registration attempt") + } + calls, err = runAction(t, "--retry-dcr-registration", func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if err != nil || calls != 1 { + t.Fatalf("pending retry calls=%d error=%v", calls, err) + } + retried := getRecord(t, registrationKey) + if retried.Version.Equal(pending.Version) || string(retried.Value) == string(pending.Value) { + t.Fatal("pending retry did not replace the registration attempt") + } + }) + + t.Run("invalid action state and plain pending preserve record", func(t *testing.T) { + for _, flag := range []string{"", "--reset-dcr-registration"} { + before := getRecord(t, registrationKey) + calls, err := runAction(t, flag, func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if err == nil || calls != 1 { + t.Fatalf("flag %q calls=%d error=%v", flag, calls, err) + } + assertUnchanged(t, registrationKey, before) + } + }) + + t.Run("corrupt registration cannot be reset or retried", func(t *testing.T) { + profiles, err := loadMCPLoginProfiles([]string{settings}) + if err != nil { + t.Fatal(err) + } + server, _ := profiles.OAuthServer("connector") + before := getRecord(t, registrationKey) + corrupt, err := server.OAuth.CredentialStore.Put(context.Background(), registrationKey, []byte(`{"schema":"corrupt"}`), &before.Version) + _ = profiles.Close() + if err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--reset-dcr-registration", "--retry-dcr-registration"} { + calls, runErr := runAction(t, flag, func(server mcp.ServerConfig, options app.MCPLoginOptions) error { + _, _, prepErr := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, options.DCRAction) + return prepErr + }) + if runErr == nil || calls != 1 { + t.Fatalf("flag %q calls=%d error=%v", flag, calls, runErr) + } + assertUnchanged(t, registrationKey, corrupt) + } + }) + + t.Run("non-DCR modifier reaches login seam and fails", func(t *testing.T) { + legacy := filepath.Join(t.TempDir(), "legacy.yaml") + if err := os.WriteFile(legacy, []byte(mcpLoginOAuthYAML("legacy", "local", filepath.Join(t.TempDir(), "legacy-credentials"))), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + executeMCPLogin = func(ctx context.Context, server mcp.ServerConfig, runtimeOptions oauthlogin.Options, options app.MCPLoginOptions) error { + calls++ + runtime, err := oauthlogin.New(runtimeOptions) + if err != nil { + return err + } + return app.LoginMCPWithOptions(ctx, server, runtime, options) + } + err := runMCPLogin([]string{"legacy", "--permission-config", legacy, "--reset-dcr-registration"}, io.Discard) + if err == nil || calls != 1 { + t.Fatalf("non-DCR modifier calls=%d error=%v", calls, err) + } + }) +} + +type unavailableMCPLoginStore struct { + credentialstore.Store +} + +func (unavailableMCPLoginStore) Get(context.Context, []byte) (credentialstore.Record, error) { + return credentialstore.Record{}, credentialstore.ErrUnavailable +} + +type mcpLoginDCRFixture struct { + server *httptest.Server + registerCount int + tokenCount int +} + +func newMCPLoginDCRFixture(t *testing.T) *mcpLoginDCRFixture { + t.Helper() + fixture := &mcpLoginDCRFixture{} + fixture.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuer := fixture.server.URL + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "oauth-protected-resource"): + _ = json.NewEncoder(w).Encode(map[string]any{"resource": fixture.resource(), "authorization_servers": []string{issuer}, "scopes_supported": []string{"openid"}}) + case strings.Contains(r.URL.Path, ".well-known/oauth-authorization-server"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, "authorization_endpoint": issuer + "/oauth/authorize", "token_endpoint": issuer + "/oauth/token", "registration_endpoint": issuer + "/oauth/register", + "scopes_supported": []string{"openid"}, "response_types_supported": []string{"code"}, "grant_types_supported": []string{"authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"none"}, "code_challenge_methods_supported": []string{"S256"}, + }) + case r.URL.Path == "/oauth/register": + fixture.registerCount++ + var request oauthex.ClientRegistrationMetadata + _ = json.NewDecoder(r.Body).Decode(&request) + _ = json.NewEncoder(w).Encode(map[string]any{ + "client_id": "public-client", "token_endpoint_auth_method": "none", "redirect_uris": request.RedirectURIs, + "grant_types": request.GrantTypes, "response_types": request.ResponseTypes, "scope": request.Scope, + }) + case r.URL.Path == "/oauth/token": + fixture.tokenCount++ + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access-token", "token_type": "Bearer", "expires_in": 3600, "scope": "openid"}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(fixture.server.Close) + return fixture +} + +func (f *mcpLoginDCRFixture) resource() string { return f.server.URL + "/gw/mcp" } + +func mcpLoginDCRYAML(fixture *mcpLoginDCRFixture, root string) string { + return fmt.Sprintf(`mcp: + mode: global + servers: + - name: connector + url: %q + auth: + mode: oauth + oauth: + profile: work + principal: operator + issuer: %q + client: {mode: dcr, dcr: {}} + scopes: [openid] + request_refresh_token: false + credentials: + mode: local + local: {root: %q, key_env: MECATL_LOGIN_KEY} + network: {additional_origins: [], private_origins: [%q], max_redirects: 0} +`, fixture.resource(), fixture.server.URL, root, fixture.server.URL) +} + +func dcrLoginRecordKey(domain string, fields ...string) []byte { + framed := []byte(domain) + var size [4]byte + for _, field := range fields { + if len(field) > math.MaxUint32 { + panic("test fixture field too large") + } + binary.BigEndian.PutUint32(size[:], uint32(len(field))) // #nosec G115 -- test field is bounded above. + framed = append(framed, size[:]...) + framed = append(framed, field...) + } + digest := sha256.Sum256(framed) + return digest[:] +} diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index ce4c94b705..55f3fcf3cb 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -153,7 +153,7 @@ PR after verification. There is no cleanup or status-only PR. an explicit human waiver of a separate amendment PR/merge for inclusion in the implementation PR. - [Idle-session MCP broker workspace refresh](idle-session-broker-workspace-refresh.md) — allows an owned idle broker session to explicitly refresh its complete protected-tool bundle after earlier turns and to recover after broker-process loss without resetting the conversation; retains ToolHive custody and broker-mode single-replica operation. Status: proposed. - [MCP broker DCR client](mcp-broker-dcr-client.md) — a third `mcp.servers[].auth.oauth.client.mode: dcr`, exposing ToolHive's existing RFC 7591 Dynamic Client Registration upstream-client support for protected MCP servers with no preregistered client or hosted CIMD document. Status: draft. -- [Direct MCP Dynamic Client Registration](direct-mcp-dcr.md) — proposed contract for durable public-client DCR in a local direct MCP profile, reusing the existing authorization-code login while keeping broker and remote mecatui OIDC authority separate. Status: proposed. +- [Direct MCP Dynamic Client Registration](direct-mcp-dcr.md) — core implementation exists for durable public-client DCR in a local direct MCP profile, with no-refresh access grants and explicit re-login after expiry; remaining acceptance proofs, review, and human qualification are tracked in the plan. Status: in-progress. - [Live authenticated MCP metadata replaces static stand-ins](authenticated-mcp-metadata-replaces-static-standins.md) — replace protected-tool placeholders with safely admitted authenticated metadata after either lazy authorization or complete pre-prompt enrollment, without revealing undeclared tools on the lazy path. Status: in-progress. - [Local mecak8s Kind fixture](mecak8s-kind-fixture.md) — cleanly separates an diff --git a/docs/acceptance/direct-mcp-dcr.md b/docs/acceptance/direct-mcp-dcr.md index 8d918cf57b..e0f45a9f51 100644 --- a/docs/acceptance/direct-mcp-dcr.md +++ b/docs/acceptance/direct-mcp-dcr.md @@ -1,13 +1,15 @@ # Direct MCP Dynamic Client Registration — acceptance plan -**Contract:** human-reviewed/v1 +**Contract:** human-reviewed/v2 **Phase:** local direct-MCP OAuth durability -**Status:** proposed, 2026-09-09. All human decisions required for implementation are resolved and recorded; the proposed contract awaits human plan/interface review, not approval or landing. Gateway discovery, the standalone public-client DCR/PKCE port probe, and an operator-driven mecatui spike against the deployed gateway passed; refresh and the full failure/recovery contract remain unverified. +**Work classification:** Bounded — this amendment changes the accepted direct-DCR lifecycle contract and its offline acceptance evidence without adding a public API or configuration key. +**Decision record:** None — the human-approved lifecycle-record decision is an in-place amendment to this bounded acceptance contract; it does not create a new architectural decision. +**Status:** in-progress. Candidate implementation, the seven named acceptance proofs, documentation/site builds, and the cloud-native resource re-audit are complete on the implementation branch. Aggregate gates, repeat panel review, implementation PR and merge, and the separate human-run local-mecatui qualification remain; this candidate is not yet landed. Plan / Interface PR #1331 merged at `4ef7af6a74225e04a45e1e9dda7c3888b25cad3e`. On 2026-09-10 the human explicitly waived a separate amendment PR and amended direct DCR v1 in place to disable refresh; complete resource-bound public-client refresh is deferred to issue #1355. On 2026-09-14 the human further amended this contract in place to use the private server-scoped lifecycle record described below. **Delivery:** Split. This changes an operator-facing OAuth client union, durable credential identity, registration lifecycle, and the local interactive authorization boundary. **Expected tasks:** deferred to orchestration **Issue:** none — scope supplied for acceptance-contract review. **Plan PR:** https://github.com/stacklok/mecatl/pull/1331 -**Approved baseline:** +**Approved baseline:** `4ef7af6a74225e04a45e1e9dda7c3888b25cad3e` Enable a local mecatui direct (non-broker) MCP profile to use RFC 7591 Dynamic Client Registration (DCR), then the existing authorization-code/PKCE login path, against `https://connector-gateway.example.com/gw/mcp`. The durable registration and resulting grant must survive a local restart and permit a harmless discovered read tool. This does not change remote mecatui OIDC, the MCP broker, or `CallMcpWithQuery`. @@ -18,20 +20,22 @@ With explicit user authorization, a standalone local Python probe registered one ## Human decisions - [x] Direct versus broker client shape — Decision: retain `client.mode: dcr` with separate authority-selected direct/broker validation, preserving broker compatibility. Direct requires exact `issuer` and forbids `upstream`; broker retains explicit OAuth2 `upstream` and `discovery_url`. Proposed direct spelling below is `dcr: {}` with no extra knobs. -- [x] Registration versus grant lifetime — Decision: durable registration is separate from the grant; grant reset and failed refresh preserve registration. Replacing registration requires explicit reset. Registration identity is bound to profile, principal, canonical resource, and exact issuer; no client identity is inferred from a token. +- [x] Registration versus grant lifetime — Decision: durable registration is separate from the access grant; grant reset and access-token expiry preserve registration. Replacing registration requires explicit reset. Registration identity is bound to profile, principal, canonical resource, and exact issuer; no client identity is inferred from a token. - [x] Concurrent first login guarantee — Decision: use the existing credential-store CAS winner-adoption model and accept possible duplicate upstream clients. Exactly-once upstream registration is not promised or required. -- [x] Consent owner — Decision: reuse `mecated mcp login SERVER [--no-browser]` and `internal/app.LoginMCP`; local mecatui consumes saved credentials. No startup, reconnect, model tool, or background refresh may launch a browser or register a client. +- [x] Consent owner — Decision: reuse `mecated mcp login SERVER [--no-browser]` and `internal/app.LoginMCP`; local mecatui consumes saved credentials. No startup, reconnect, model tool, or background work may launch a browser, register a client, or refresh a DCR credential. - [x] Redirect reuse — Decision: stable registration-bound random callback path with an ephemeral IPv4 loopback port, fresh state and PKCE per authorization, and exact callback path/Host/state validation. The live mecatui spike establishes runtime port variation and registration reuse; offline rejection tests remain required. -- [x] Scope and refresh defaults — Decision: DCR defaults to durable operation: when omitted, `request_refresh_token` is true and scopes resolve to `[openid, offline_access]`. Explicit `request_refresh_token: false` defaults omitted scopes to `[openid]`. Explicit scopes must equal the set selected by the refresh setting; never add `profile` or `email`. These defaults apply only to direct DCR, not preregistered, CIMD, or broker profiles. +- [x] Scope and refresh policy — Decision: direct DCR v1 is no-refresh. Omitted `request_refresh_token` resolves to false; explicit false is accepted and explicit true is rejected. Omitted scopes resolve to `[openid]`; explicit scopes must equal exactly `{openid}`. Direct DCR requests only `openid` and never requests `offline_access`, the refresh-token grant, or a refresh token. An authorization server may expand the registration response to additional scopes it advertised; Mecatl does not persist or request those additions during authorization, and still rejects any issued refresh token. Broker, preregistered, and CIMD behavior is unchanged. Complete resource-bound public-client refresh is deferred to [issue #1355](https://github.com/stacklok/mecatl/issues/1355). - [x] Unknown registration outcome — Decision: no automatic registration POST retry after an unknown outcome; retain durable evidence, report recovery-required, and require explicit operator retry acknowledging possible orphan upstream clients. - [x] Reset/retry public behavior — Decision: the two mutually exclusive DCR-only login modifiers are `--reset-dcr-registration` (valid registration and its grant) and `--retry-dcr-registration` (valid unresolved attempt only). Each proceeds to login after its conditional local operation; neither deletes or revokes an upstream client. Grant-only reset stays on the existing internal `ResetCredential` seam, with no new CLI modifier. -- [x] Pending-record coordination and recovery — Decision: one identity-keyed record transitions pending → ready by CAS, with no lease, clock-based takeover, lock service, attempt index, or exactly-once claim. A competing caller adopts a ready winner; while pending it stops recovery-required rather than joining or polling. Explicit retry replaces pending with a fresh generation and fences the old process, which may still create an orphan upstream client but cannot publish it. A valid ready winner takes precedence over a late uncertain loser. Only current and immediately previous attempt metadata are retained, not an unbounded audit history. -- [x] Registration binding and corruption recovery — Decision: fingerprint intentional issuer/resource, scopes, authentication method, grants/response types, and redirect policy/path bindings, not current issuer endpoints or cosmetic client name. Revalidate current endpoints under the existing exact issuer-origin and token-credential endpoint checks; endpoint rotation or cosmetic name drift alone never requires replacement registration. Binding changes require explicit registration reset. Corrupt/unsupported registration payloads remain recovery-required and cannot be reset by these commands; generic backend corruption is never a cache miss or a bypass. No corruption-specific schema variant or force-reset path is added. +- [x] Pending-record coordination and recovery — Decision: one server-scoped record transitions pending → ready by CAS, with no lease, clock-based takeover, lock service, attempt index, or exactly-once claim. A competing caller adopts a ready winner; while pending it stops recovery-required rather than joining or polling. Explicit retry replaces an identity-matching pending record with a fresh generation and fences the old process, which may still create an orphan upstream client but cannot publish it. A mismatched pending record returns the distinct pending-identity-mismatch recovery category and cannot reset or retry; the operator must restore the matching profile, principal, canonical resource, and exact issuer before retrying. A valid ready winner takes precedence over a late uncertain loser. Only current and immediately previous attempt metadata are retained, not an unbounded audit history. +- [x] Registration binding and corruption recovery — Decision: one server-scoped DCR lifecycle record and its grant remain bound to profile, principal, canonical resource, and exact issuer; fingerprint intentional issuer/resource, scopes, authentication method, grants/response types, and redirect policy/path bindings, not current issuer endpoints or cosmetic client name. Revalidate current endpoints under the existing exact issuer-origin and token-credential endpoint checks; endpoint rotation or cosmetic name drift alone never requires replacement registration. A configured binding change requires explicit registration reset. Corrupt/unsupported lifecycle record, grant, or backend state remains recovery-required and cannot be reset by these commands; generic backend corruption is never a cache miss or a bypass. No corruption-specific schema variant or force-reset path is added. +- [x] Lifecycle record and identity mismatch — Decision: one private encrypted lifecycle/control record is scoped to the selected configured MCP server name within the credential-store domain. It contains the current identity-bound registration and is CAS-updated; it is not a cache-miss fallback, public configuration, event, or user-facing identifier. Ordinary login validates its stored profile/principal/canonical-resource/exact-issuer binding against the current configuration with no registration POST, browser, token, grant, or protected-call side effect. Ready identity drift returns reset-required and explicit reset may replace that valid ready lifecycle with a new pending identity-bound generation before continuing the standard registration/authorization/grant flow. Pending identity drift returns pending-identity-mismatch; matching configuration must be restored before retry. A server-name rename or credential-store root/key change creates a distinct lifecycle. Missing, corrupt, unreadable, internally inconsistent, or backend-failed lifecycle/grant state is recovery-required; retry remains valid-pending-only. +- [x] Human amendment and ADR relationship — Decision: this human-reviewed/v2 plan supersedes only ADR 0325's detailed identity-keyed lifecycle proposal to the extent it omitted the private server-scoped lifecycle record and ordinary identity-mismatch/reset behavior. ADR 0325 remains accepted and unedited for direct-DCR authority, no-refresh, security, and public-client decisions. The lifecycle record is carried only through the internal MCP adapter configuration path; it is not a user-facing configuration key or an engine API. ## Interface contract - **gRPC / protobuf:** None — direct MCP profile loading and local authorization stay host-local; no mecatui remote-server OIDC, session, or MCP broker wire contract changes. -- **Exported Go APIs / interfaces:** Proposed additions and unchanged signatures are specified below. Reuse the existing `OAuthOptions.CredentialStore`, official SDK handler, host `LoginMCP`, and `oauthlogin.Runtime`; there is no new store port, engine API, or broker interface. Internal registration resolution must preserve kind `dcr` rather than disguise a public client as the existing confidential `Preregistered` variant. +- **Exported Go APIs / interfaces:** No engine, gRPC, broker, or external-consumer interface changes. The root-internal MCP adapter adds the resolved configured server name to `OAuthDCRConfig` so `internal/cliconfig` can bind the private lifecycle record; it has no YAML tag or user-facing configuration key. Reuse the existing `OAuthOptions.CredentialStore`, official SDK handler, host `LoginMCP`, and `oauthlogin.Runtime`. Internal registration resolution must preserve kind `dcr` rather than disguise a public client as the existing confidential `Preregistered` variant. - **Tool schemas:** No schema change: discovered MCP tools retain their existing namespaced wrapper schema. The acceptance demonstration calls one harmless tool advertised read-only by the gateway; DCR itself is never model-callable. - **CLI / config:** Direct/global DCR is exactly: @@ -48,8 +52,8 @@ With explicit user authorization, a standalone local Python probe registered one principal: local-user issuer: https://connector-gateway.example.com client: {mode: dcr, dcr: {}} - scopes: [openid, offline_access] - request_refresh_token: true + scopes: [openid] + request_refresh_token: false credentials: mode: local local: @@ -58,9 +62,9 @@ With explicit user authorization, a standalone local Python probe registered one network: {additional_origins: [], private_origins: [], max_redirects: 0} ``` - This is a proposed direct/global profile, not a shipped configuration. The key environment reference uses the existing canonical padded-base64 32-byte encryption key, shared by login and local mecatui; it is not a new keyring dependency. Direct DCR defaults omitted `request_refresh_token` to true and omitted `scopes` to `[openid, offline_access]`; operators may spell those defaults explicitly as above. Explicit `request_refresh_token: false` defaults omitted scopes to `[openid]`. When scopes are explicit, compare sets after sorting/deduplication and require exactly the set selected by the effective refresh setting. This tri-state requires preserving field presence during strict profile decoding before resolving the existing runtime boolean; it does not widen `OAuthOptions`. Broker, preregistered, and CIMD defaults and scope rules do not change. Direct DCR rejects other scopes, inconsistent scope/refresh selection, `upstream`, nonempty `dcr.discovery_url`, static credential headers, read-only/environment credentials, non-HTTPS issuer/registration endpoints, and mixed client forms. Strict parsing preserves field presence so broker cannot accept `{}` or direct accept a supplied empty broker field. `permconfig.MCPDCRClientProfile` retains `DiscoveryURL string` with YAML tag `discovery_url`; no new DCR configuration keys are needed. Authority validation belongs in both `ResolveMCPAuthority` and the direct-only `LoadMCPProfiles` entry used by login, which must reject broker authority rather than bypass it. The approved CLI behavior is specified below; its proposed flags are not yet implemented. -- **Events / persistence:** Use the existing encrypted `mecatl-mcp-oauth` namespace and `credentialstore.Store` (`Get`, create-only/version-matched `Put`, version-matched `Delete`). Production direct DCR requires persistent cross-process CAS capabilities, already supplied by the local encrypted-file backend; test memory stores exercise the same semantics. Registration control and grant remain separate records. Exact proposed JSON schemas, hash domains, tombstones, and CAS rules below are review proposals. No registration data enters events, diagnostics, model-visible content, or unencrypted metadata. -- **Security / authority:** DCR creates an upstream-minted public client identity, so it is allowed only for trusted operator direct-MCP profiles and only through explicit host-authorized local browser consent. Preserve the OAuth controller's DNS-pinned, no-proxy, exact-issuer credential-egress, redirect, TLS, and error-redaction rules. Registration discovery/POST is subject to the same hardened client and exact issuer origin. The opaque authorization URL may reach only the explicit host browser launcher or, in explicit no-browser mode, its host-owned writer; this narrow presentation exception never permits logs, model content, or persisted output to contain the URL. Codes/state, tokens, client secrets, registration access material, and raw registration responses stay out of all diagnostic/model output. Public `none` SDK code exchange and refresh require real wire qualification: no Basic header, `client_secret`, or client assertion; S256 and resource binding remain mandatory. This adds a constrained public path only; preregistered confidential clients retain ADR 0219's Basic-only and form-secret-rejection rule. The direct authority never inherits broker DCR's `upstream` semantics. + This is the shipped direct/global profile shape. The generated configuration reference must describe this existing direct-DCR shape and its no-refresh defaults accurately; this is a documentation correction only and adds no user-facing configuration key. The key environment reference uses the existing canonical padded-base64 32-byte encryption key, shared by login and local mecatui; it is not a new keyring dependency. Direct DCR defaults omitted `request_refresh_token` to false and omitted `scopes` to `[openid]`; operators may spell those defaults explicitly as above. Explicit `request_refresh_token: true`, `offline_access`, and every scope set other than exactly `{openid}` are rejected. Broker, preregistered, and CIMD defaults and scope rules do not change. Direct DCR also rejects `upstream`, nonempty `dcr.discovery_url`, static credential headers, read-only/environment credentials, non-HTTPS issuer/registration endpoints, and mixed client forms. Strict parsing preserves field presence so broker cannot accept `{}` or direct accept a supplied empty broker field. `permconfig.MCPDCRClientProfile` retains `DiscoveryURL string` with YAML tag `discovery_url`; no new DCR configuration keys are needed. Authority validation belongs in both `ResolveMCPAuthority` and the direct-only `LoadMCPProfiles` entry used by login, which rejects broker authority rather than bypassing it. The approved CLI behavior is specified below. +- **Events / persistence:** Use the existing encrypted `mecatl-mcp-oauth` namespace and `credentialstore.Store` (`Get`, create-only/version-matched `Put`, version-matched `Delete`). Production direct DCR requires persistent cross-process CAS capabilities, already supplied by the local encrypted-file backend; test memory stores exercise the same semantics. One private lifecycle/control record contains the registration and one generation-bound grant record contains the access state; neither is a token cache or event. The lifecycle record is keyed by the selected configured server name within its credential-store domain, carries the current identity-bound registration, is CAS-read/updated, and never permits cache-miss fallback. Exact proposed JSON schemas, hash domains, tombstones, and CAS rules below are review proposals. No registration data enters events, diagnostics, model-visible content, or unencrypted metadata. +- **Security / authority:** DCR creates an upstream-minted public client identity, so it is allowed only for trusted operator direct-MCP profiles and only through explicit host-authorized local browser consent. Preserve the OAuth controller's DNS-pinned, no-proxy, exact-issuer credential-egress, redirect, TLS, and error-redaction rules. Registration discovery/POST is subject to the same hardened client and exact issuer origin. The opaque authorization URL may reach only the explicit host browser launcher or, in explicit no-browser mode, its host-owned writer; this narrow presentation exception never permits logs, model content, or persisted output to contain the URL. Codes/state, access tokens, unsolicited refresh tokens, client secrets, registration access material, and raw registration responses stay out of all diagnostic/model output. Public `none` SDK code exchange requires real wire qualification: S256 and exactly one canonical resource remain mandatory, and no Basic header, `client_secret`, or client assertion may reach upstream. The pinned SDK maps a restored public client to `AuthStyleAutoDetect`; the accepted narrow DCR-only mediation rejects its Basic probe locally before dialing, never strips and forwards it, then admits only the SDK's parameter fallback carrying the exact expected public `client_id` and no secret/assertion. This is not a generic local OAuth stack. Preregistered confidential clients retain ADR 0219's Basic-only and form-secret-rejection rule. The direct authority never inherits broker DCR's `upstream` semantics. - **Compatibility / migration:** Additive under this proposed contract. Existing preregistered and CIMD direct profiles remain byte-for-byte compatible; existing broker DCR profiles retain their broker-only validation and runtime behavior. Legacy credential records remain readable, no DCR record is synthesized from a token record, and an orphan token without a ready matching registration fails closed. ## Proposed Go surface and ordering @@ -69,7 +73,11 @@ These are reviewable signatures, not implemented APIs. New helper names are engi ```go // internal/adapter/mcp: additive third arm; other fields unchanged. -type OAuthDCRConfig struct{} +type OAuthDCRConfig struct { + // ServerName is injected only by the resolved direct-MCP profile loader. + // It identifies the private encrypted DCR lifecycle record; it has no YAML form. + ServerName string +} // OAuthClientConfig gains: DCR *OAuthDCRConfig type OAuthDCRLoginAction uint8 @@ -92,7 +100,7 @@ func (r *Runtime) AuthorizeWithCallbackPath(ctx context.Context, expectedIssuer callbackPath string, authorize AuthorizeFunc) error ``` -`PrepareOAuthDCRLogin` is host-consent-only. It validates/discovers metadata using the existing hardened client and SDK `oauthex` helpers, loads or conditionally prepares the registration record, and returns a copied `OAuthOptions` carrying an **unexported** DCR preparation ticket plus the selected callback path. It does not launch a browser or POST registration. The ticket binds identity, pending record version/generation, metadata, and path; callers cannot configure a public resolved client ID or arbitrary generation through YAML. A saved ready registration can be prepared without mutation. +`PrepareOAuthDCRLogin` is host-consent-only. It validates/discovers metadata and loads or conditionally prepares the server-scoped lifecycle record using the existing hardened client and SDK `oauthex` helpers, then returns a copied `OAuthOptions` carrying an **unexported** DCR preparation ticket plus the selected callback path. It does not launch a browser or POST registration. The ticket binds the lifecycle record's pending version/generation, identity, metadata, and path; callers cannot configure a public resolved client ID or arbitrary generation through YAML. A saved ready registration can be prepared without mutation only when the one lifecycle record and all four identity fields match. `LoginMCPWithOptions` invokes preparation before binding the callback and uses `AuthorizeWithCallbackPath`. Inside its existing `AuthorizeFunc`, it sets the actual bound `RedirectURL` and `Presenter` and calls `mcp.Connect` as today. `NewOAuthController(ctx context.Context, resource string, opts OAuthOptions) (*OAuthController, error)` keeps its signature: only a matching prepared pending ticket permits one registration POST, using `oauthex.RegisterClient(ctx, endpoint, metadata, hardenedClient)`. Success must publish ready by CAS before `newOAuthPersistenceCore`. Ordinary controller construction can restore ready state but cannot prepare/POST; missing means `ErrOAuthLoginRequired`, pending/corrupt/mismatch means the new safe sentinel `ErrOAuthDCRRecoveryRequired = errors.New("OAuth DCR recovery required")`. Composition must preserve that category through `loginDiagnostic` and CLI remedies, never the raw underlying response or URL. A prepared ticket is consumed once; no handler/reconnect re-entry can send it twice. Existing single-flight authorization remains in place. @@ -106,7 +114,7 @@ The private `oauthRegistration` carries kind `dcr`, client ID and `sdk: &oauthex Both payloads use bounded UTF-8 strict JSON (unknown fields, duplicate keys, trailing values, invalid tags/variants, identity mismatch, or size above `credentialstore.MaxValueBytes` fail closed), encrypted by the existing store envelope. Never persist the raw registration response, client secrets, registration management URI/access token, callback state, code, or verifier. Unknown optional upstream response extensions can be ignored only after validating the selected public metadata; reject a returned confidential authentication method or client secret. This is a selected public-client protocol, not arbitrary RFC 7591 lifecycle support. -### Identity-keyed registration control record, version 1 +### Server-scoped DCR lifecycle control record, version 1 Exact fields and types (all required except those identified as optional): @@ -120,35 +128,38 @@ Exact fields and types (all required except those identified as optional): | `metadata` | exact registration-binding metadata object defined below; excludes current endpoints and cosmetic name | | `metadata_fingerprint` | lowercase hex SHA-256 of canonical registration-binding metadata | | `previous_attempt` | optional object: `generation` string, `attempt_started_at` string, `reason` enum `explicit_retry` or `explicit_reset`; at most one predecessor | +| `failure_category` | optional, pending-only enum `registration_outcome_unknown`, `registration_response_invalid`, or `ready_persistence_failed`; unknown values are invalid, omission means unclassified, and ready records forbid it | | `registration` | present only for ready: object with required nonempty string `client_id` and string `registered_redirect_uri`, optional nonnegative integer `client_id_issued_at` (Unix seconds) | -The opaque registration key is `SHA256(domain || frame(profile) || frame(principal) || frame(resource) || frame(issuer))`, domain literal `mecatl/mcp/oauth-dcr-registration-key/v1`. `frame` is uint32 big-endian byte length followed by UTF-8 bytes, matching `oauthCredentialKey` (the domain itself is not length-prefixed). It uses the existing `mecatl-mcp-oauth` namespace; physical filename/encryption framing stays entirely owned by `credentialstore.NewEncryptedFile`. No attempt index or store listing is needed. +`failure_category` is diagnostic evidence only. It never authorizes retry, bypasses the four-field identity check, or relaxes CAS. Recording it is a best-effort version-matched write against the exact pending version and generation; failure leaves the pending record unchanged. A fresh retry or reset clears it. -Registration-binding `metadata` has exactly these fields in this canonical serialization order: `issuer`, `resource`, `redirect_policy`, `redirect_path`, `token_endpoint_auth_method`, `grant_types`, `response_types`, `scopes`. Strings are JSON-encoded with Go `encoding/json` default escaping, no whitespace/trailing newline; arrays are deduplicated and byte-lexicographically sorted. Values: `redirect_policy:"ipv4-loopback-variable-port/v1"`, `token_endpoint_auth_method:"none"`, `response_types:["code"]`, `grant_types:["authorization_code"]` plus `"refresh_token"` iff requested, and the exact scope set below. The ephemeral port, issuance timestamp, cosmetic client name, and registration/authorization/token endpoints are excluded. The registration request maps these bindings to SDK metadata with the one current `redirect_uris` URI, space-joined `scope`, and current cosmetic `client_name:"mecatl"`; `issuer`/`resource`/fingerprint/policy are local binding fields, not invented registration parameters. There is no registration lifetime/TTL knob; do not invent expiry from access-token expiry. +The opaque lifecycle key is `SHA256(domain || frame(server_name))`, domain literal `mecatl/mcp/oauth-dcr-lifecycle-key/v1`. `server_name` is the selected configured MCP server name; the encrypted credential store supplies the separate custody-domain boundary. `frame` is uint32 big-endian byte length followed by UTF-8 bytes, matching `oauthCredentialKey` (the domain itself is not length-prefixed). It uses the existing `mecatl-mcp-oauth` namespace; physical filename/encryption framing stays entirely owned by `credentialstore.NewEncryptedFile`. The record carries the four-field binding identity, so profile/principal/resource/issuer drift is detected without changing the lifecycle key. No attempt index or store listing is needed. -Current issuer endpoints are freshly discovered and validated separately from durable registration binding; they are not registration identity or fingerprint fields. Changes solely to registration/authorization/token endpoints within the permitted issuer origin, or to the cosmetic name, do not reset generation, overwrite the registration, or trigger another POST. A valid stored binding that differs from the requested scope/auth/grant/response/redirect binding requires explicit registration reset; an identity mismatch or internally inconsistent fingerprint is recovery-required, not permission to adopt another identity. Endpoint validation still applies on every use: discovery must satisfy the exact issuer-origin policy, and stored grant `refresh.token_url` must pass the existing token credential URL/origin checks and hardened credential-egress rules independently. Never blindly rewrite a stored refresh endpoint from discovery or let a matching registration fingerprint bypass token-envelope validation. If endpoint rotation makes an existing grant unusable, fail/reauthorize at the grant layer while preserving registration; registration replacement is not the remedy. +Registration-binding `metadata` has exactly these fields in this canonical serialization order: `issuer`, `resource`, `redirect_policy`, `redirect_path`, `token_endpoint_auth_method`, `grant_types`, `response_types`, `scopes`. Strings are JSON-encoded with Go `encoding/json` default escaping, no whitespace/trailing newline; arrays are deduplicated and byte-lexicographically sorted. Values: `redirect_policy:"ipv4-loopback-variable-port/v1"`, `token_endpoint_auth_method:"none"`, `response_types:["code"]`, `grant_types:["authorization_code"]`, and `scopes:["openid"]`. The ephemeral port, issuance timestamp, cosmetic client name, and registration/authorization/token endpoints are excluded. The registration request maps these bindings to SDK metadata with the one current `redirect_uris` URI, space-joined `scope`, and current cosmetic `client_name:"mecatl"`; `issuer`/`resource`/fingerprint/policy are local binding fields, not invented registration parameters. It never requests `refresh_token` or `offline_access`. There is no registration lifetime/TTL knob; do not invent expiry from access-token expiry. -The response must contain a nonempty bounded client ID and explicitly select `token_endpoint_auth_method:"none"`; returned redirect URIs must include exactly the submitted URI, and returned grant/response types and scope, when supplied, must equal the request after canonicalization. Omitted grant/response/scope fields retain the requested effective metadata, not broader defaults. A secret or unsupported method is rejected, never stored or projected. Store only the ready projection after validation. Normal restart uses the saved registered URI as inert handler configuration when no interactive listener exists; it does not bind that old port or invent a callback. Reauthorization uses the new live URI. Reuse verifies the stored registration-binding fingerprint with the persisted path and separately revalidates current issuer endpoints before constructing the handler. +Current issuer endpoints are freshly discovered and validated separately from durable registration binding; they are not registration identity or fingerprint fields. Changes solely to registration/authorization/token endpoints within the permitted issuer origin, or to the cosmetic name, do not reset generation, overwrite the registration, or trigger another POST. A valid stored binding that differs from the fixed scope/auth/grant/response/redirect binding requires explicit registration reset; a ready identity mismatch is reset-required, a pending identity mismatch is pending-identity-mismatch, and an internally inconsistent fingerprint is recovery-required—not permission to adopt another identity. Endpoint validation still applies on every explicit authorization: discovery must satisfy the exact issuer-origin policy, and the persisted grant's `authorization.token_url` must pass token credential URL/origin checks independently. A matching registration fingerprint never bypasses grant-envelope validation or authorizes network refresh. If endpoint rotation makes a grant unusable, explicit reauthorization retains the registration; registration replacement is not the remedy. + +The response must contain a nonempty bounded client ID and explicitly select `token_endpoint_auth_method:"none"`; returned redirect URIs must include exactly the submitted URI, and returned grant/response types, when supplied, must equal the request after canonicalization. A returned scope set must contain every requested scope and may contain only additional scopes advertised by the freshly validated authorization-server metadata. Those server-added registration scopes are neither persisted as local authority nor added to the authorization request; omitted scope retains the requested effective metadata. A secret or unsupported method is rejected, never stored or projected. Store only the ready projection after validation. Normal restart uses the saved registered URI as inert handler configuration when no interactive listener exists; it does not bind that old port or invent a callback. Reauthorization uses the new live URI. Reuse verifies the stored registration-binding fingerprint with the persisted path and separately revalidates current issuer endpoints before constructing the handler. ### Generation-bound DCR grant record, version 2 -Keep existing preregistered/CIMD `mecatl.mcp.oauth-credential` version 1 and its key byte-for-byte. DCR uses the same schema string with `version:2`, required `identity` (existing six string fields, including `client_kind:"dcr"` and resolved `client_id`), required `registration_generation` string, and required `state` (`active` or `reset`). Active requires existing `token` and `refresh` objects with their current fields; reset forbids both. Existing `token` fields are `access_token`, `token_type`, optional `refresh_token` and RFC3339Nano `expiry`; `refresh` is `token_url`, integer `auth_style`, `redirect_url`, sorted unique `scopes`. DCR requires `auth_style:1` (`oauth2.AuthStyleInParams`) with an empty client secret, not AutoDetect/Basic. Real SDK qualification must prove this and refresh resource handling; if unsupported, delivery blocks pending an official SDK-compatible solution, not local OAuth replacement. +Keep existing preregistered/CIMD `mecatl.mcp.oauth-credential` version 1 and its key byte-for-byte. DCR uses the same schema string with `version:2`, required `identity` (existing six string fields, including `client_kind:"dcr"` and resolved `client_id`), required `registration_generation` string, and required `state` (`active` or `reset`). Active requires `token` and `authorization`; reset forbids both. `token` contains required `access_token` and `token_type`, optional RFC3339Nano `expiry`, and **forbids** `refresh_token`. `authorization` contains exactly `token_url`, integer `auth_style`, `redirect_url`, and sorted unique `scopes`; for DCR these validate as the exact issuer-origin token endpoint, `auth_style:1` (`oauth2.AuthStyleInParams`), the registration-bound loopback redirect, and `["openid"]`. The authorization object records the validated code-exchange configuration; it is not permission to refresh. Runtime restoration must use a DCR-specific non-refresh token source and must never construct `oauth2.Config.TokenSource` for this record. An unsolicited refresh token makes login fail before persistence. The DCR token key uses domain `mecatl/mcp/oauth-dcr-credential-key/v1` and length-framed profile, principal, canonical resource, exact issuer, literal `dcr`, resolved client ID, and registration generation, in that order. Both identity and generation are validated on restore. No old DCR format exists to migrate; never synthesize registration from a version-1 token or scan orphan keys. -Before authorization, a missing current-generation grant key is create-only initialized to `reset`; the flow captures that record's version **before** the SDK exchange, not at persistence time. Grant reset and `invalid_grant` write a reset tombstone with the observed version, even if already reset (to fence concurrent authorization); never delete the key. New-token and refresh writes use their captured version. Conflict adopts only a valid active current-generation winner; reset/corruption/mismatch means login/recovery-required, never rebase-and-retry the stale token. Generic network refresh failures preserve registration and existing grant evidence, as today; they do not destroy a potentially usable grant or claim refresh succeeded. +Before authorization, a missing current-generation grant key is create-only initialized to `reset`; the flow captures that record's version **before** the SDK exchange, not at persistence time. Grant reset writes a reset tombstone with the observed version, even if already reset, to fence concurrent authorization; never delete the key. A new access-token write uses its captured version. Conflict adopts only a valid active current-generation winner; reset/corruption/mismatch means login/recovery-required, never rebase-and-retry the stale token. An expired token returns the existing safe login-required category without token-endpoint traffic or browser launch. Only explicit `mecated mcp login` may reauthorize, reusing the registration/path with a fresh port/state/PKCE. -Changing registration generation makes all prior-generation grant records unreachable from the current registration. Before using a cached token, reload its current grant record so another process's reset tombstone is honored; generation equality alone does not detect a grant-only reset. Stale writers may finish a write to an old generation's key, but it is never restored/adopted as current: check the registration generation before and after saving and before returning a token to new work. There is no cross-key transaction, and a request already handed to the network before reset may complete; this is local credential invalidation, not upstream revocation. Current-generation grant tombstones close the create-only ABA hole. Old-generation encrypted grants may remain as unreachable bounded-per-generation evidence; garbage collection and secure upstream deletion are not promised. Do not claim a separate generation read plus token `Put` is atomic. +Changing registration generation makes all prior-generation grant records unreachable from the current registration. Before using a cached access token, reload its current grant record so another process's reset tombstone is honored; generation equality alone does not detect a grant-only reset. Stale authorization writers may finish a write to an old generation's key, but it is never restored/adopted as current: check the registration generation before and after saving and before returning a token to new work. There is no cross-key transaction, and a request already handed to the network before reset may complete; this is local credential invalidation, not upstream revocation. Current-generation grant tombstones close the create-only ABA hole. Old-generation encrypted grants may remain as unreachable bounded-per-generation evidence; garbage collection and secure upstream deletion are not promised. Do not claim a separate generation read plus token `Put` is atomic. ## Proposed pending-state CAS and explicit recovery -1. After successful metadata validation, explicit login reads the identity key. On genuine not-found, it create-only writes pending with random generation/path and timestamp **before** registration POST. Persistence error, including an uncertain save, means no POST. Restart after any confirmed pending write is recovery-required, even if the process crashed before sending; that conservative false-positive is preferable to an automatic duplicate POST. -2. A successful pending write gives only that invocation its private one-POST ticket. There is no persisted lease/owner and no timeout takeover. On create conflict, read the winner once: ready and matching → adopt its client/path; pending → recovery-required with no POST. A fresh ordinary login never treats pending as permission to send. This incidentally suppresses competing POSTs in the normal pending race; it does not guarantee upstream exactly-once. -3. Bind callback, submit one `oauthex.RegisterClient` POST with bounded no-redirect/no-retry transport and no initial access credential, validate the response, then CAS the exact pending version to ready. Transport timeout/cancel, malformed response, or any uncertain POST/save leaves pending evidence. A successful response with a failed durable write never proceeds to browser authorization. If a save error might have committed, re-read: a fully validated matching ready record is usable; still pending/unavailable means recovery-required, with no second POST. -4. On ready-publication conflict, never retry `Put` against a newly read version. Adopt only a matching ready winner in the **same generation**; a new generation or pending winner means this flow is stale and must stop. Do not authorize using a losing client or an old callback path. A late error likewise cannot overwrite a ready winner with pending/unknown. A ready winner is authoritative locally even though another upstream orphan may exist. -5. Explicit `--retry-dcr-registration` reads pending and CAS-replaces it with a new pending generation/path, retaining one predecessor summary; only that invocation may POST. Reject retry against ready or missing state. Explicit registration reset similarly changes a ready record to fresh pending before any new POST, invalidating old generation grants. CAS conflict in either explicit operation stops with a safe conflict category; it does not silently reset a winner. No timestamp, PID, or age can authorize retry. +1. After successful metadata validation, explicit login reads the server-scoped lifecycle record. Its genuine absence is the only bootstrap case: create the selected identity's pending lifecycle record with random generation/path and timestamp **before** registration POST. Persistence error, including an uncertain save, means no POST. A valid **ready** lifecycle record whose four-field identity differs from the current profile/principal/canonical-resource/exact-issuer identity returns reset-required with no registration, browser, grant, token, or protected-call side effect. A valid **pending** lifecycle mismatch returns pending-identity-mismatch with the same no-side-effect guarantee; matching configuration must be restored before retry. A corrupt, unreadable, internally inconsistent, or backend-failed lifecycle/grant read is recovery-required, never a cache miss. +2. A successful pending write gives only that invocation its private one-POST ticket. There is no persisted lease/owner and no timeout takeover. On create conflict, read the winner once: ready and identity-matching → adopt its client/path; pending → recovery-required with no POST. A fresh ordinary login never treats pending as permission to send. This incidentally suppresses competing POSTs in the normal pending race; it does not guarantee upstream exactly-once. +3. Bind callback, submit one `oauthex.RegisterClient` POST with bounded no-redirect/no-retry transport and no initial access credential, validate the response, then CAS the exact pending lifecycle-record version to ready. A successful response with a failed durable write never proceeds to browser authorization. If a save error might have committed, re-read: only a fully validated matching ready lifecycle record is usable; still pending/unavailable means recovery-required, with no second POST. +4. On ready-publication conflict, never retry `Put` against a newly read version. Adopt only a matching ready winner in the **same generation and identity**; a new generation, pending state, or stored inconsistency means this flow is stale and must stop. Do not authorize using a losing client or an old callback path. A late error likewise cannot overwrite a ready winner with pending/unknown. A ready winner is authoritative locally even though another upstream orphan may exist. +5. Explicit `--retry-dcr-registration` reads a structurally valid identity-matching pending lifecycle record and CAS-replaces it with a new pending generation/path; only that invocation may POST. A pending identity mismatch returns pending-identity-mismatch: retry and reset perform no write or network effect, and the operator must restore the matching profile, principal, canonical resource, and exact issuer before retrying. It is valid-pending-only and acknowledges unknown upstream outcome. Explicit registration reset reads a structurally valid previous ready lifecycle record, then CAS-replaces it with a fresh pending lifecycle record under the current identity before continuing the standard registration/authorization/grant flow. Reset against missing, corrupt, unreadable, backend-failed, internally inconsistent, or non-ready state stops recovery-required without mutation. CAS conflict in either explicit operation stops with a safe conflict category; it does not silently reset a winner. No timestamp, PID, or age can authorize retry. 6. Retry may race the old process between its final version check and upstream POST: both requests can create upstream clients. The old ticket cannot publish into the new generation, even if it completes first. This is the accepted possible-orphan residual, not an exactly-once lock. Bounded predecessor evidence records the local recovery action, not an inventory of upstream clients. -7. Corrupt, unsupported, identity-mismatched, or internally inconsistent registration payloads are recovery-required, even when the authenticated store supplies a readable version. Neither reset nor retry interprets or replaces such payloads. Preserve them for separate operator repair; there is no automated corrupt-payload reset, digest variant, or force flag. Generic backend corruption, wrong key, or unavailability likewise fails closed and can never become not-found/bootstrap or bypass validation merely because a CAS version is available. `previous_attempt.reason` remains exactly `explicit_retry` or `explicit_reset`, with the three fields specified in the schema and no alternate form. +7. Configured identity drift is distinct from stored corruption: a valid ready lifecycle whose binding differs from current configuration is reset-required; a valid pending lifecycle mismatch is pending-identity-mismatch and requires restoring matching configuration before retry; a corrupt, unsupported, internally inconsistent, or grant-mismatched record is recovery-required. Neither reset nor retry interprets or replaces corrupt state. Preserve its records and configuration without editing, deleting, or renaming them; contact the deployment operator or support team with only the server name and redacted command error, never credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. There is no automated corrupt-payload reset, digest variant, or force flag. Generic backend corruption, wrong key, or unavailability likewise fails closed and can never become not-found/bootstrap or bypass validation merely because a CAS version is available. `previous_attempt.reason` remains exactly `explicit_retry` or `explicit_reset`, with the three fields specified in the schema and no alternate form. ### Proposed CLI surface @@ -159,28 +170,28 @@ mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] Without a modifier, existing login behavior is preserved. For DCR: registration reset starts a new generation/client/path and subsequent grant acquisition; retry is valid-pending-only and acknowledges unknown upstream outcome. These are the only new CLI modifiers; grant-only reset remains internal and is exercised through `ResetCredential` tests. Modifiers are DCR-only for this delivery, reject repeats/combinations and non-DCR/broker/read-only profiles before mutations or browser launch. Existing `--permission-config` selection and host-only `--no-browser` URL writer remain. Plain login against valid pending state prints a safe recovery-required remedy naming the retry command and possible orphan-client consequence; corrupt state instead requires separate operator repair, never a retry/reset suggestion that bypasses validation. Output does not print client IDs, paths, tokens, endpoints from responses, or attempt payloads. All failed/incomplete operations return nonzero, and success retains the existing login success line only after authenticated initialize/list plus durable active grant. No reset-only command, automatic revocation, remote keyring action, or new mecatui command is introduced. -For a non-DCR profile, `LoginMCPWithOptions` delegates unchanged behavior only for the zero action; other actions and unknown enum values fail configuration validation. For DCR, reset-registration requires a structurally valid ready registration whose identity matches the selected key (its binding may differ from the newly requested binding); retry requires a structurally valid identity-matching pending record; a missing registration admits only ordinary bootstrap. Actions do not silently turn into one another. Corrupt registration/backend state is never admitted by either action. Existing internal grant-only reset semantics and CAS tests remain separate from these CLI actions; no new corrupt-payload interpretation or repair API is introduced. +For a non-DCR profile, `LoginMCPWithOptions` delegates unchanged behavior only for the zero action; other actions and unknown enum values fail configuration validation. For DCR, reset-registration requires a structurally valid ready lifecycle record and may replace it even when its stored binding differs from the current binding; retry requires a structurally valid identity-matching pending lifecycle record; pending identity mismatch returns pending-identity-mismatch until matching configuration is restored; only a missing lifecycle record admits ordinary bootstrap. Ordinary login against ready configured identity drift returns reset-required without registration, browser, token, grant, or protected-call side effects. Corrupt, internally inconsistent, or backend-failed lifecycle/grant state is recovery-required and is never admitted by either action. Existing internal grant-only reset semantics and CAS tests remain separate from these CLI actions; no new corrupt-payload interpretation, repair API, or configuration key is introduced. ## Exact selected SDK metadata, scopes, and resource constraints -Reuse the official SDK's discovery and `oauthex.RegisterClient` helpers, not its per-flow DCR hook. Require RFC 9728 metadata (no SDK resource-origin fallback) whose resource equals the configured canonical MCP URL and whose `authorization_servers` is exactly the configured issuer. Require discovered AS `issuer` byte-equality, including trailing slash, rather than the SDK's looser issuer tolerance. Require explicit advertised `code`, `authorization_code`, S256, and token authentication `none`; if refresh requested, require `refresh_token` and AS-advertised `offline_access`. Registration, authorization, and token endpoints are HTTPS on the exact issuer origin; token/registration URLs have no userinfo, query, or fragment. `AdditionalOrigins` never expands DCR credential egress. Redirected registration POSTs are forbidden regardless of the generic redirect bound. Metadata fetches retain existing bounded/DNS-pinned/no-proxy policy. +Reuse the official SDK's discovery and `oauthex.RegisterClient` helpers, not its per-flow DCR hook. Require RFC 9728 metadata (no SDK resource-origin fallback) whose resource equals the configured canonical MCP URL and whose `authorization_servers` is exactly the configured issuer. Require discovered AS `issuer` byte-equality, including trailing slash, and explicit advertised `code`, `authorization_code`, S256, and token authentication `none`; the AS scope advertisement bounds any server-added scopes in the registration response. The authorization challenge at callback time must independently identify the exact configured issuer; a missing, differently encoded, or different challenge issuer fails before code exchange and grant persistence. Registration, authorization, and token endpoints are HTTPS on the exact issuer origin; token/registration URLs have no userinfo, query, or fragment. `AdditionalOrigins` never expands DCR credential egress. Redirected registration POSTs are forbidden regardless of the generic redirect bound. Metadata fetches retain existing bounded/DNS-pinned/no-proxy policy. -Requested scopes are exactly the effective set: `{openid}` without refresh and `{openid, offline_access}` with refresh. For direct DCR only, omitted `request_refresh_token` resolves to true; omitted scopes derive from that effective setting. An explicit false derives `{openid}` when scopes are omitted, and explicit scopes must equal the corresponding set. Require PRM and AS to advertise `openid`; `offline_access` need only be AS-advertised (the gateway PRM advertises only `openid`). Pass the admitted scopes through the existing `ScopeFilter`/`RequestRefreshToken` seams; the SDK's automatic offline-access addition must neither bypass configuration nor duplicate/widen the final set. Check the final host-presented authorization query as well: SDK step-up union happens after `ScopeFilter`, so an unexpected challenge/prior scope cannot sneak in `profile`, `email`, or another scope. Missing refresh issuance fails the selected refresh-required login while preserving registration; no unrequested downgrade to non-refresh success. +Requested scopes are exactly `{openid}`. Omitted `request_refresh_token` resolves false; explicit false is accepted and explicit true is rejected. Omitted scopes resolve `[openid]`; explicit scopes must equal that set. Require PRM and AS to advertise `openid`; reject authorization requests containing `offline_access` or every broader or step-up scope. A registration response may contain additional scopes only when the AS metadata advertised each one and the response still contains `openid`; these additions never flow into the authorization request or durable grant. Pass only the admitted `openid` scope through the existing `ScopeFilter` seam and check the final host-presented authorization query because the SDK unions challenge scopes afterwards. An authorization response containing a refresh token is rejected rather than stored. -Authorization and code exchange each carry exactly one `resource` equal to the canonical RFC 9728 URI, not `/mcp`, issuer origin, or a challenge-supplied replacement. Refresh must carry the same bound resource; the existing persisted `oauth2.Config.TokenSource` path is not proof of that behavior. Fixture assertions must inspect actual refresh form parameters. If the current SDK/oauth2 seam cannot preserve that binding without replacing protocol logic, report implementation blockage and seek an official supported seam. DCR has no token exchange, extra audiences, scope step-up beyond the admitted set, or broker/query-agent delegation. Existing confidential `NewHardenedOAuthTokenClient` and preregistered Basic-only paths are not relaxed. +Authorization and code exchange each carry exactly one `resource` equal to the canonical RFC 9728 URI, not `/mcp`, issuer origin, or a challenge-supplied replacement. The pinned SDK maps the restored public client to `AuthStyleAutoDetect`. For direct DCR only, the hardened transport rejects its Basic probe locally before dialing, then allows only the SDK fallback containing the exact expected `client_id` in form parameters and no Basic header, `client_secret`, assertion, or other client-authentication field. Never strip the Basic header and send that malformed first request. A real-wire fixture must prove zero Basic requests reached the upstream fixture and exactly one valid parameter-form exchange succeeded. DCR has no refresh, token exchange, extra audiences, scope step-up, or broker/query-agent delegation. Existing confidential `NewHardenedOAuthTokenClient` and preregistered Basic-only paths are not relaxed. Complete resource-bound public-client refresh is deferred to [issue #1355](https://github.com/stacklok/mecatl/issues/1355). ## Named offline regression fixtures -The implementation extends the existing `oauthFixture` in `internal/adapter/mcp/oauth_sdk_fixture_test.go` and `loginFixture` in `internal/app/mcplogin_test.go`; it does not export a new fixture framework. These are planned test names, not claims that DCR tests already exist: +The implementation extends the existing `oauthFixture` in `internal/adapter/mcp/oauth_sdk_fixture_test.go` and `loginFixture` in `internal/app/mcplogin_test.go`; it does not export a new fixture framework. The implemented proof inventory is: -- `internal/cliconfig/mcp_authority_test.go`: `TestDirectMCPDCR_Scenario1_AuthoritySeparatedProfile` covers selected direct/broker authority and direct-only login loading. Extend `internal/adapter/permconfig/mcp_test.go` to preserve strict syntax and the existing broker DCR regression, moving authority-dependent expectations out of syntax-only assertions. +- `internal/cliconfig/mcp_authority_test.go`: `TestDirectMCPDCR_Scenario1_AuthoritySeparatedProfile` covers selected direct/broker authority and direct-only login loading. `internal/adapter/permconfig/mcp_test.go` preserves strict syntax and the existing broker DCR regression. - `internal/cliconfig/mcpprofile_test.go`: `TestADR_0325_DirectDCRProfileScopeAndStorePolicy` covers explicit scope sets, existing key reference, mutable-store-only admission, and no eager POST. -- `internal/adapter/mcp/oauth_sdk_qualification_test.go`: `TestADR_0325_PublicNoneWireQualification` covers real SDK code/refresh forms, resource, exact scope set, nil secret/Basic/assertion, alongside unchanged confidential Basic rejection tests. `TestADR_0325_DirectDCRMetadataAndEgressPolicy` tests exact issuer/resource, no fallback, method/grants/S256, unknown response fields, prohibited credentials, DNS/redirect rejection, and registration-binding fingerprint drift. The same proof rotates issuer-local registration/authorization/token endpoints and changes cosmetic name without changing registration ID/generation or issuing another POST; malformed/off-origin endpoints and invalid stored grant token URLs still fail closed. -- `internal/app/mcplogin_test.go`: `TestDirectMCPDCR_Scenario2_RegistersAuthorizesAndLists`, `TestDirectMCPDCR_Scenario2_RestartRestoresRegistrationGrantAndReadTool`, `TestDirectMCPDCR_Scenario2_ReauthorizationRedirectAndScopeBinding`, and `TestDirectMCPDCR_Scenario2_HostOnlyAuthorizationPresentation` use the real profile loader, encrypted store, `LoginMCP`/`LoginMCPWithOptions`, callback runtime, controller, initialize/list/read and second process-equivalent load. No test reaches the deployed gateway. +- `internal/adapter/mcp/oauth_dcr_test.go`: `TestADR_0325_PublicNoneWireQualification` covers real SDK code exchange, canonical resource, exact `[openid]` scope, local rejection of the SDK Basic probe, zero Basic requests reaching upstream, and absence of client secret/assertion, alongside unchanged confidential Basic rejection tests. `TestADR_0325_DirectDCRMetadataAndEgressPolicy` tests exact issuer/resource, no fallback, method/grants/S256, unknown response fields, prohibited credentials, DNS/redirect rejection, and registration-binding fingerprint drift. The same proof rotates issuer-local registration/authorization/token endpoints and changes cosmetic name without changing registration ID/generation or issuing another POST; malformed/off-origin endpoints fail closed. +- `internal/app/mcp_dcr_acceptance_proofs_test.go`: `TestDirectMCPDCR_Scenario2_RegistersAuthorizesAndLists`, `TestDirectMCPDCR_Scenario2_RestartRestoresRegistrationGrantAndReadTool`, and `TestDirectMCPDCR_Scenario2_ReauthorizationRedirectAndScopeBinding` use the real profile loader, encrypted store, `LoginMCP`/`LoginMCPWithOptions`, callback runtime, controller, initialize/list/read and second process-equivalent load. No test reaches the deployed gateway. - `mcp/oauthlogin/runtime_test.go`: `TestADR_0325_RegistrationBoundCallbackPath` covers two ephemeral ports/same path, fresh state, wrong path/Host/state rejection, conflicting fixed redirect, canceled listener cleanup, and unchanged legacy callback modes. -- `internal/adapter/mcp/oauth_credential_test.go` and `internal/adapter/mcp/oauth_tokensource_test.go`: `TestADR_0325_DirectDCRRegistrationPrecedesTokenIdentity`, `TestADR_0325_DirectDCRRegistrationCASWinnerAdoption`, `TestADR_0325_DirectDCRGrantResetFencesStaleWriters`, and `TestADR_0325_DirectDCRStaleRegistrationLifecycle` cover strict schemas/hash framing, generation isolation, active-versus-reset conflicts, corrupt inner/outer records, and registration preservation on failures. -- `internal/adapter/mcp/oauth_controller_test.go`: `TestADR_0325_DirectDCRUnknownAttemptRecovery` uses deterministic barriers/fault-injected CAS to cover crash before POST, POST timeout, unknown save committed/not committed, pending first-login contention, retry while old POST is in flight, stale ready publication, ready winner versus late error, reset/reauthorization races, and absence of automatic duplicate POST. The proof asserts the resolved pending-contender and bounded evidence-retention policy. -- `cmd/mecated/mcplogin_test.go`: `TestADR_0325_DCRResetAndRetryCLI` covers the two mutually exclusive registration modifiers, rejection of any grant-reset CLI modifier, invalid combinations/modes, nonzero failure, safe remedies, corrupt-state rejection without writes, and zero modifiers doing no implicit reset/retry. `TestInvariant_direct_mcp_dcr_secret_redaction` and `TestDirectMCPDCR_Scenario3_RestartIdentityMismatchFailsClosed` belong to the composition fixture and seed canaries throughout the registration/grant lifecycle. +- `internal/adapter/mcp/oauth_dcr_test.go` and `internal/adapter/mcp/oauth_dcr_acceptance_proofs_test.go`: `TestADR_0325_DirectDCRRegistrationPrecedesTokenIdentity`, `TestADR_0325_DirectDCRRegistrationCASWinnerAdoption`, `TestADR_0325_DirectDCRGrantResetFencesStaleWriters`, and `TestADR_0325_DirectDCRStaleRegistrationLifecycle` cover strict schemas/hash framing, generation isolation, active-versus-reset conflicts, corrupt inner/outer records, and registration preservation on failures. `TestADR_0325_DirectDCRLifecycleBindingAndCAS` covers server-name/root-key domain separation, all four identity bindings, and single-record safe CAS publication. +- `internal/adapter/mcp/oauth_dcr_acceptance_proofs_test.go` and `internal/adapter/mcp/oauth_dcr_test.go`: `TestADR_0325_DirectDCRUnknownAttemptRecovery` uses deterministic barriers/fault-injected CAS to cover crash before POST, POST timeout, unknown save committed/not committed, pending first-login contention, retry while old POST is in flight, stale ready publication, ready winner versus late error, reset/reauthorization races, and absence of automatic duplicate POST. `TestADR_0325_DirectDCRIdentityMismatchHasNoSideEffects`, `TestADR_0325_DirectDCRResetRotatesLifecycleAndContinues`, and `TestADR_0325_DirectDCRCorruptLifecycleOrGrantFailsClosed` cover configured drift, end-to-end reset/retry continuation, and lifecycle/grant corruption or backend failure. +- `cmd/mecated/mcplogin_test.go`: `TestADR_0325_DCRResetAndRetryCLI` covers the two mutually exclusive registration modifiers, rejection of any grant-reset CLI modifier, invalid combinations/modes, nonzero failure, safe remedies, corrupt-state rejection without writes, and zero modifiers doing no implicit reset/retry. `internal/app/mcp_dcr_acceptance_proofs_test.go` carries `TestInvariant_direct_mcp_dcr_secret_redaction` and `TestDirectMCPDCR_Scenario3_RestartIdentityMismatchFailsClosed`, seeding canaries throughout the registration/grant lifecycle and the encrypted profile-loader/app/catalog/tool restart path. ## Proposed partial-state matrix @@ -188,16 +199,17 @@ The matrix records the resolved recovery policy in this proposed contract. Absen | Observed durable state | Explicit login behavior | Ordinary startup/reconnect behavior | |---|---|---| -| No registration, no grant, no unresolved registration attempt | Bootstrap: record the selected durable attempt evidence, register, persist registration before authorization, then persist grant | Login-required; no registration POST or browser | -| Valid matching registration, no grant (including denied/cancelled login) | Reuse client; authorize only with the stable registered callback path and an ephemeral IPv4 loopback port; never register again | Login-required | -| Valid matching registration and valid grant | Reuse both; no new registration or consent | Restore and invoke | -| Valid registration, expired grant | Refresh only when refresh was requested and issued; persist rotation via existing CAS; otherwise explicit authorization | Refresh if allowed, else login-required; no hidden consent | -| Valid registration, `invalid_grant` or token reset | Preserve registration; CAS the grant to reset tombstone, then explicit authorization may follow | Login-required; stale refresh/authorization cannot replace the tombstone | -| Valid registration, transient refresh network failure | Preserve registration and grant evidence; return a redacted failure | No hidden consent or re-registration; retain existing bounded refresh behavior | -| Missing registration with a known orphaned token record | Recovery-required; never infer client ID or adopt orphaned grant | Fail closed; orphan record is unusable | -| Corrupt/unsupported/identity-mismatched registration | Recovery-required; preserve evidence for separate operator repair; reset/retry cannot bypass validation even with a readable version | Fail closed without bootstrap | -| Valid registration with changed intentional binding | Explicit registration reset required; endpoint/name changes alone are not binding changes | Recovery-required for binding mismatch; preserve registration | -| Valid registration, corrupt/mismatched grant | Recovery-required; preserve evidence and registration for separate operator repair; no new CLI repair path | Fail closed; do not replace client merely because the grant is corrupt | +| No lifecycle record, no grant, no unresolved registration attempt | Bootstrap: create the selected identity's pending lifecycle record, register, persist it ready before authorization, then persist grant | Login-required; no registration POST or browser | +| Valid lifecycle record whose binding differs from current profile, principal, canonical resource, or exact issuer | Reset-required; ordinary login and retry perform no registration, grant/token, browser, or protected-call side effect; explicit reset may replace only a valid ready record under the current binding. A mismatched pending record requires restoring the matching configuration before retry | Reset-required; no hidden recovery | +| Valid lifecycle record pending | Recovery-required; only explicit `--retry-dcr-registration` may replace it and continue | Recovery-required; no retry or browser | +| Valid matching lifecycle record, no grant (including denied/cancelled login) | Reuse client; authorize only with the stable registered callback path and an ephemeral IPv4 loopback port; never register again | Login-required | +| Valid matching lifecycle record and valid grant | Reuse both; no new registration or consent | Restore and invoke | +| Valid lifecycle record, expired grant | Reuse registration/path and perform explicit authorization with a fresh port/state/PKCE; never refresh or re-register | Login-required; no token-endpoint traffic, browser, or registration POST | +| Valid lifecycle record and token reset | Preserve registration; CAS the grant to a reset tombstone, then explicit authorization may follow | Login-required; stale authorization cannot replace the tombstone | +| Valid lifecycle record and unsolicited refresh token | Reject login before grant persistence; preserve registration | Login-required; no refresh attempt | +| Missing lifecycle record with a known orphaned token record | Recovery-required; never infer client ID or adopt orphaned grant | Fail closed; orphan record is unusable | +| Corrupt/unsupported/internally inconsistent lifecycle record | Recovery-required; preserve evidence for separate operator repair; reset/retry cannot bypass validation even with a readable version | Fail closed without bootstrap | +| Valid lifecycle record, corrupt/mismatched grant | Recovery-required; preserve evidence and registration for separate operator repair; no new CLI repair path | Fail closed; do not replace client merely because the grant is corrupt | | Registration POST outcome unknown, or response received but durable save uncertain | Recovery-required; no automatic repeat POST. Only `--retry-dcr-registration` may make a new fenced attempt | Recovery-required; no retry or browser | Registration-first ordering deliberately permits registration-only state. The registration and token records are not an atomic transaction. CAS winner adoption protects a stored winner, not upstream exactly-once registration. The resolved protocol makes recovery and stale-write fencing concrete without changing that honesty claim. @@ -209,50 +221,56 @@ Registration-first ordering deliberately permits registration-only state. The re The direct profile path in `internal/cliconfig/mcpprofile.go` currently selects only preregistered or CIMD credentials, while the shared `dcr` schema is explicitly broker-shaped: it requires an OAuth2 `upstream`, rejects `issuer`, and has no direct resolver. The implementation must make direct DCR an explicit third direct registration form without allowing the broker configuration to leak into direct authority. It must resolve a stored valid registration or create/persist one before the existing `auth.AuthorizationCodeHandler` is built; it must not use the SDK's per-authorization DCR hook that [ADR 0219](../adr/0219-mcp-oauth-sdk-profile.md) excluded from durable use. The direct flow follows the hardened controller and credential-store boundaries in [architecture](../architecture.md#internal-credential-store). **Acceptance:** -- AC1.1: A valid direct profile for the canonical gateway resource selects DCR only in direct authority, requires the exact configured issuer, defaults omitted refresh/scopes to durable `[openid, offline_access]`, supports explicit no-refresh `[openid]`, and rejects inconsistent explicit selections, `upstream`, broker-only discovery payloads, mixed client forms, static credential headers, and read-only/environment credential sources before registration or browser work. +- AC1.1: A valid direct profile for the canonical gateway resource selects DCR only in direct authority, requires the exact configured issuer, resolves omitted refresh to false and omitted scopes to `[openid]`, accepts explicit false, and rejects explicit true, any scope set other than `{openid}`, `upstream`, broker-only discovery payloads, mixed client forms, static credential headers, and read-only/environment credential sources before registration or browser work. - verify: `TestDirectMCPDCR_Scenario1_AuthoritySeparatedProfile` -- AC1.2: The registration resolver validates exact resource/issuer binding and uses only an advertised HTTPS registration endpoint on the exact issuer origin; metadata without `S256`, authorization-code, or public-client support fails closed before browser launch, token exchange, or credential write. Refresh requirements follow the approved `request_refresh_token`/scope policy rather than being a generic DCR prerequisite; reuse verifies the registration-binding fingerprint separately from revalidation of current issuer endpoints. Endpoint rotation or cosmetic name drift alone does not trigger registration replacement; existing token-credential endpoint validation remains mandatory. +- AC1.2: The registration resolver validates exact resource/issuer binding and uses only an advertised HTTPS registration endpoint on the exact issuer origin; metadata without `S256`, authorization-code, or public-client support fails closed before browser launch, token exchange, or credential write. Direct DCR does not require AS refresh-token or `offline_access` support; reuse verifies the fixed no-refresh registration-binding fingerprint separately from revalidation of current issuer endpoints. Endpoint rotation or cosmetic name drift alone does not trigger registration replacement; grant endpoint validation remains mandatory. - verify: `TestADR_0325_DirectDCRMetadataAndEgressPolicy` - AC1.3: A restored valid registration resolves the public client before `newOAuthPersistenceCore` derives the token identity, so the token key contains the resolved client ID rather than an unknown/empty placeholder; preregistered and CIMD key derivation do not change. - verify: `TestADR_0325_DirectDCRRegistrationPrecedesTokenIdentity` -- AC1.4: The implementation uses the existing `credentialstore.Store` conditional-write semantics for registration and token records. A CAS conflict validates and adopts the winner; it does not overwrite it or claim that competing first registrations caused exactly one upstream `/register` request. - - verify: `TestADR_0325_DirectDCRRegistrationCASWinnerAdoption` +- AC1.5: One private lifecycle/control record is keyed by configured server name inside the encrypted credential-store domain and carries the current registration bound to profile, principal, canonical resource, and exact issuer; server-name rename and credential-store root/key changes select distinct lifecycles and add no configuration key. + - verify: `TestADR_0325_DirectDCRLifecycleBindingAndCAS` +- AC1.6: Challenge-time issuer validation requires the exact configured issuer before code exchange or grant persistence; missing, differently encoded, or different issuer challenges fail closed. + - verify: `TestADR_0325_DirectDCRChallengeIssuerBinding` ### Scenario 2 — Offline durable registration, authorization, and direct tool use -A hermetic loopback fixture models RFC 9728 protected-resource metadata, authorization-server metadata, a public DCR endpoint, PKCE S256 authorization-code exchange, refresh rotation, and one harmless read-only MCP tool. It drives the real direct profile loader, encrypted credential store, registration resolver, existing `LoginMCP` seam, direct `mcp.Connect`, and a second process-equivalent load. It proves the new durable registration layer and existing grant layer compose; it does not use the real gateway or a live browser. The existing local login seam is `internal/app/mcplogin.go` (`LoginMCP`), and the durable-direct-DCR boundary is recorded by [ADR 0325](../adr/0325-direct-mcp-dcr.md). +A hermetic loopback fixture models RFC 9728 protected-resource metadata, authorization-server metadata, a public DCR endpoint, PKCE S256 authorization-code exchange, expiry without refresh, and one harmless read-only MCP tool. It drives the real direct profile loader, encrypted credential store, registration resolver, existing `LoginMCP` seam, direct `mcp.Connect`, and a second process-equivalent load before expiry. It proves the new durable registration and generation-bound access-grant layers compose; it does not use the real gateway or a live browser. The existing local login seam is `internal/app/mcplogin.go` (`LoginMCP`), and the durable-direct-DCR boundary is recorded by [ADR 0325](../adr/0325-direct-mcp-dcr.md). **Acceptance:** -- AC2.1: The first explicit local login registers a public client once in the uncontended fixture, requests authorization-code plus refresh-token authorization with PKCE S256, completes host-owned callback authorization, persists registration and grant records, and initializes/lists the direct MCP tools. +- AC2.1: The first explicit local login registers a public client once in the uncontended fixture, requests only authorization-code access with `[openid]` and PKCE S256, completes host-owned callback authorization, rejects any unsolicited refresh token, persists registration and generation-bound access-grant records, and initializes/lists the direct MCP tools. - verify: `TestDirectMCPDCR_Scenario2_RegistersAuthorizesAndLists` -- AC2.2: A fresh loader/controller over the same encrypted store restores the registration and grant without browser launch or another registration POST, refreshes a short-lived token through the existing conditional token-rotation path, and calls the fixture's harmless discovered read tool. +- AC2.2: A fresh loader/controller over the same encrypted store restores a still-valid registration and access grant without browser launch, token-endpoint refresh, or another registration POST, and calls the fixture's harmless discovered read tool; after expiry it returns login-required without network refresh or browser launch. - verify: `TestDirectMCPDCR_Scenario2_RestartRestoresRegistrationGrantAndReadTool` -- AC2.3: Clean bootstrap, registration-only, grant-only, corrupt/mismatched records, token reset, refresh failure, and uncertain registration outcomes follow the partial-state matrix. Stale authorization/refresh results cannot resurrect reset grants; ambiguous registration never triggers an automatic repeat POST or fallback to SDK per-flow DCR. +- AC2.3: Clean bootstrap, registration-only, grant-only, corrupt/mismatched records, token reset, expiry, unsolicited refresh-token issuance, and uncertain registration outcomes follow the partial-state matrix. A stale authorization result cannot resurrect a reset grant; ambiguous registration never triggers an automatic repeat POST or fallback to SDK per-flow DCR. - verify: `TestADR_0325_DirectDCRStaleRegistrationLifecycle` -- AC2.4: Registration responses, client IDs, tokens, refresh tokens, authorization URLs/codes/state, and registration access material are absent from diagnostics, errors, model-visible tool content, and persisted non-credential metadata. +- AC2.4: Registration responses, client IDs, access or unsolicited refresh tokens, authorization URLs/codes/state, and registration access material are absent from diagnostics, errors, model-visible tool content, and persisted non-credential metadata. - verify: `TestInvariant_direct_mcp_dcr_secret_redaction` -- AC2.5: Real SDK wire tests requalify public `none` code exchange and refresh: public requests carry no HTTP Basic authorization, `client_secret`, or client assertion; PKCE S256 and resource binding hold. Confidential preregistered tests retain Basic-only and form-secret rejection. An unsupported SDK path blocks delivery instead of prompting a local OAuth-stack replacement. +- AC2.5: Real SDK wire tests qualify public `none` code exchange: the hardened transport rejects the SDK's Basic probe locally before dialing, the fallback carries the exact public `client_id` in parameters, zero Basic requests reach upstream, no `client_secret` or client assertion appears, and PKCE S256 plus exactly one canonical resource hold. Confidential preregistered tests retain Basic-only and form-secret rejection. - verify: `TestADR_0325_PublicNoneWireQualification` -- AC2.6: A second explicit authorization after a grant reset reuses the registered client/path with a new ephemeral port. Exact-path/Host/state checks remain enforced; a server rejecting port variation fails without silent re-registration (at authorization or exchange, wherever the rejection is observable). Requested scopes and fingerprint drift follow the selected policies, including explicit `offline_access` and missing refresh issuance. +- AC2.6: A second explicit authorization after grant reset or expiry reuses the registered client/path with a new ephemeral port and fresh state/PKCE, without refresh or re-registration. Exact-path/Host/state checks remain enforced; a server rejecting port variation fails without silent re-registration. Requested scopes and fingerprint remain fixed to the no-refresh policy. - verify: `TestDirectMCPDCR_Scenario2_ReauthorizationRedirectAndScopeBinding` - AC2.7: Only an explicit browser-launcher or no-browser host-writer invocation receives the opaque authorization URL; logs, model content, and persisted output never do. Ordinary mecatui startup consumes saved credentials without presenting a URL. - - verify: `TestDirectMCPDCR_Scenario2_HostOnlyAuthorizationPresentation` + - verify: `TestDirectMCPDCR_Scenario2_RestartRestoresRegistrationGrantAndReadTool` - AC2.8: Unknown-attempt recovery and its stale-writer/ready-winner races follow the resolved bounded pending CAS protocol; no clock-based or automatic retry is introduced. - verify: `TestADR_0325_DirectDCRUnknownAttemptRecovery` -- AC2.9: The two approved registration CLI modifiers reject conflicting flags and corrupt registration/backend state without bypass or mutation. Internal `ResetCredential` remains grant-only for DCR and cannot revive an old authorization or refresh result; it adds no CLI modifier. +- AC2.9: The two approved registration CLI modifiers reject conflicting flags and corrupt registration/backend state without bypass or mutation. Internal `ResetCredential` remains grant-only for DCR and cannot revive an old authorization result; it adds no CLI modifier. - verify: `TestADR_0325_DCRResetAndRetryCLI`, `TestADR_0325_DirectDCRGrantResetFencesStaleWriters` -- AC2.10: Registration-bound callback path admission and cancellation preserve the existing runtime's Host/state checks, connection bounds, serialization and cleanup. - - verify: `TestADR_0325_RegistrationBoundCallbackPath` +- AC2.11: Ordinary login detects configured identity drift in a valid lifecycle record across any of profile, principal, canonical resource, or exact issuer without registration, browser, token, grant, or protected-call side effects: ready returns reset-required, while pending returns pending-identity-mismatch and requires restoring matching configuration before retry; corrupt, unreadable, internally inconsistent, or backend-failed lifecycle/grant state remains recovery-required with no cache-miss fallback. + - verify: `TestADR_0325_DirectDCRIdentityMismatchHasNoSideEffects`, `TestADR_0325_DirectDCRCorruptLifecycleOrGrantFailsClosed` +- AC2.12: Explicit reset validates the previous ready lifecycle record, CAS-rotates it to a fresh pending identity-bound generation, and continues the normal registration, authorization, and grant flow; stale CAS writers cannot publish or authorize. Retry remains identity-matching-valid-pending-only and continues the same flow after replacing pending evidence; a mismatched pending record requires restoring the matching configuration before retry. + - verify: `TestADR_0325_DirectDCRResetRotatesLifecycleAndContinues`, `TestADR_0325_DirectDCRUnknownAttemptRecovery` +- AC2.13: The generated configuration reference accurately documents the existing direct-DCR fields and no-refresh defaults without inventing a lifecycle record or other user-facing key. + - verify: `TestADR_0325_DirectDCRConfigurationReference` ### Scenario 3 — Manual local mecatui qualification against the connector gateway -After offline tests pass and a human approves the exact profile and registration lifecycle, a user performs a separate manual qualification using the approved shipped `mecated mcp login SERVER` command, then local mecatui with its saved credentials, against canonical `/gw/mcp`: explicit consent opens the browser, the gateway authorizes the registered public client, a harmless discovered read tool succeeds in local mecatui, then a local restart restores registration/grant and repeats the call without re-registration or consent. This is a manual acceptance step, not CI and not permission to register, authorize, or mutate the live gateway during implementation. [ADR 0325](../adr/0325-direct-mcp-dcr.md) does not approve a new mecatui consent action. +After offline tests pass, a user performs a separate manual qualification using the approved shipped `mecated mcp login SERVER` command, then local mecatui with its saved credentials, against canonical `/gw/mcp`: explicit consent opens the browser, the gateway authorizes the registered public client, a harmless discovered read tool succeeds in local mecatui, and a restart before access-token expiry restores registration/grant and repeats the call without re-registration or consent. The qualification also confirms that expiry reports login-required without refresh or hidden browser launch, then an explicit login reuses the same registration/path with fresh port/state/PKCE. This is a manual acceptance step, not CI and not permission to register, authorize, or mutate the live gateway during implementation. [ADR 0325](../adr/0325-direct-mcp-dcr.md) does not approve a new mecatui consent action. **Acceptance:** -- AC3.1: The qualification run records only safe evidence: canonical resource, issuer origin, whether explicit consent occurred, discovered harmless tool name, success/failure category, and whether restart reused persisted registration/grant; it excludes all OAuth and registration secrets. +- AC3.1: The qualification run records only safe evidence: canonical resource, issuer origin, whether explicit consent occurred, discovered harmless tool name, success/failure category, restart reuse before expiry, expiry returning login-required without refresh, and explicit re-login reusing the registration; it excludes all OAuth and registration secrets. - verify: demonstration — human-run local mecatui checklist after offline gates; live registration/login/tool calls are intentionally not automated -- AC3.2: Restart retains the registration and grant only when both encrypted records validate against the same profile/principal/resource/issuer identity; otherwise local mecatui reports a redacted login-required/reset-required category and does not issue a hidden registration or browser request. - - verify: `TestDirectMCPDCR_Scenario3_RestartIdentityMismatchFailsClosed` +- AC3.2: Restart retains the registration and grant only when the encrypted server-scoped lifecycle record and grant validate against the same profile/principal/resource/issuer identity and current ready generation; otherwise local mecatui reports a redacted login-required, ready reset-required, or pending-identity-mismatch category and does not issue a hidden registration, browser, token, or protected request. + - verify: `TestDirectMCPDCRLifecycleRecoveryAcceptance` ## Out of scope @@ -261,8 +279,9 @@ After offline tests pass and a human approves the exact profile and registration | Remote mecatui server OIDC/keyring | Existing remote-OIDC work | Explicitly excluded; this plan concerns local direct MCP only | | MCP broker DCR | [MCP broker DCR client plan](mcp-broker-dcr-client.md) | Broker retains its ToolHive-owned DCR lifecycle | | `CallMcpWithQuery` | Separate MCP query work | No change to its transport or authorization path | -| Live registration, login, refresh, or tool calls during automated tests | Manual qualification after approval | CI remains hermetic and offline | -| Exactly-once registration, leases, attempt index, unbounded audit archive | Follow-on only for a demonstrated requirement | The resolved identity-keyed pending evidence uses existing CAS with explicit retry, not a distributed lock/lease or upstream exactly-once guarantee | +| Live registration, login, or tool calls during automated tests | Manual qualification after approval | CI remains hermetic and offline | +| Resource-bound public-client refresh | [Issue #1355](https://github.com/stacklok/mecatl/issues/1355) | Direct DCR v1 never requests, stores, or performs refresh; expiry requires explicit re-login | +| Exactly-once registration, leases, attempt index, unbounded audit archive | Follow-on only for a demonstrated requirement | The resolved server-scoped pending lifecycle record uses existing CAS with explicit retry, not a distributed lock/lease or upstream exactly-once guarantee | | Registration access-token management, client update/delete, and automatic re-registration | Follow-on after a concrete gateway need | No initial access credential or unbounded lifecycle is introduced | | Per-session/client/inline-agent OAuth | Existing MCP OAuth boundary | OAuth remains named global static-profile only | @@ -279,9 +298,8 @@ After offline tests pass and a human approves the exact profile and registration ## Deferred decisions and known risks -- All human decisions required for implementation are resolved; this plan is `proposed` pending human plan/interface review, not approved or landed. Go names are not separate human-policy decisions. Registration-binding versus endpoint revalidation and fail-closed corrupt-state handling follow the review corrections above. -- The merged approved baseline must accept ADR 0325 and make its public-`none` and durable-DCR supersession of ADRs 0219/0220 explicit before orchestration; the proposed documents intentionally do not authorize implementation yet. -- Existing DCR schema/types are broker-only despite sharing the `dcr` label. The proposed direct empty payload and authority-selected validation must preserve broker compatibility. -- The official SDK supports public client credentials and `oauthex.RegisterClient`, but per-flow DCR has no durable registration hook ([ADR 0219](../adr/0219-mcp-oauth-sdk-profile.md)). API availability is not wire qualification; persistent refresh resource/auth-style behavior is an explicit implementation gate. +- All human decisions required for implementation are resolved. The human explicitly waived a separate amendment PR on 2026-09-10 and amended this in-progress contract in place to disable refresh; Go names are not separate human-policy decisions. +- Existing DCR schema/types are broker-only despite sharing the `dcr` label. The direct empty payload and authority-selected validation must preserve broker compatibility. +- The official SDK supports public client credentials and `oauthex.RegisterClient`, but maps a restored public client to `AuthStyleAutoDetect`. The accepted DCR-only hardened-transport mediation must reject the Basic probe before dialing and admit only the exact parameter fallback; the real-wire fixture is the anti-drift proof. Persistent resource-bound refresh is deferred to [issue #1355](https://github.com/stacklok/mecatl/issues/1355). - CAS prevents destructive overwrites, not upstream orphan creation after an uncertain POST or explicit retry racing an old request. Reset is local invalidation, not remote revocation, and no cross-record atomicity is claimed. - Implementation must inventory the registration control record, generation-bound grants/tombstones, preparation ticket and existing-runtime/client lifetimes in the living resource/fidelity documentation. Do not amend frozen accepted ADRs in this contract-only task. \ No newline at end of file diff --git a/docs/adr/0027-cloud-native.md b/docs/adr/0027-cloud-native.md index c78e463959..4fb7f4dfc9 100644 --- a/docs/adr/0027-cloud-native.md +++ b/docs/adr/0027-cloud-native.md @@ -851,7 +851,7 @@ durable artifact survives and is reloaded), or **lost** (gone, possibly leaking) | 48 | Durable agent-owned skill manifest, immutable version files, bounded receipt index, and stable flock (issue #510, ADR 0111) | `app.Build` constructs one lazy `skillstore.Store` beside the user-model store and shares it with reflection, server lifecycle APIs, and every catalog assembly | process handle over principal/project-partitioned durable data | the stable `skills.lock` flock is acquired and released per operation; files are opened no-follow and regular-file status is checked from the opened descriptor. `manifest.json` (including the bounded, stable-order receipt index) and each content-addressed `versions//SKILL.md` use temp-file fsync, rename, and directory fsync. Empty startup is lazy. A crash before manifest commit can leave only an unreferenced immutable version, which reopen safely ignores | **persisted**: bounded lifecycle metadata, provenance, validation disposition, evaluations, active selection, immutable bodies, and historical-version receipt pages reload together. Expired receipt cursors fail closed. Exact convergence preserves the stricter similarity disposition. Cross-store proposal linkage is create-then-CAS-link and reconciles by ProposalID/SkillID without a duplicate. See List 2 row 31 | `internal/adapter/skillstore/store.go` (`Store`, `locked`, `save`, `ensureVersion`, `ListSkillReceipts`); `internal/adapter/skillstore/materialize.go` (`MaterializeProposal`) | | 49 | Partitioned live learned-skill generations and publication gate (issue #510, ADR 0111) | `app.Build` constructs one `skillfs.AtomicCatalog` plus one `learnedSkillPublication`; caller-bound per-session catalogs and the server lister select immutable views from them | process (one mutex plus bounded immutable path-free generations keyed by principal/project; no goroutine, watcher, path, asset materialization, or workspace root) | one gate serializes collision check, durable transition, authoritative reread, generation swap, and partition-local failure quarantine. Refresh retains unrelated partitions and immutable external entries. Each tool request uses one caller-bound view for Spec/inventory/Execute. Caller-scoped list/run hydration reconciles global and admitted project partitions; uncertain reads clear that partition. Build teardown needs no explicit close | **derived** from List 2 row 31's active durable versions plus the build-time immutable external skill snapshot. Views/generations reset on restart and lazily reconstruct for the authenticated caller; a crash after durable activation but before publication heals without another mutation. No separate persisted catalog state | `engine/adapter/skillfs/atomic.go` (`AtomicCatalog`, `RefreshPartitions`, `View`, `LiveTool`); `internal/app/learned_skills.go` (`learnedSkillPublication`, `learnedSkillPublisher`); `internal/app/catalog.go` (`registerSkillFamily`) | | 50 | Local encrypted/plaintext credential records, stable flock sentinels, store-owned encryption key, and clientauth backend pin (issue #519, ADR 0218; clientauth selection ADR 0318) | canonical MCP profiles in `internal/cliconfig` (`MCPProfiles`), transferred to `app.Build`, with explicit login borrowing the Store; remote mecatui login/connect/reauth/logout own their selected clientauth Store handles | namespace/store handle for the in-memory key and rooted namespace; durable records and clientauth `clientauth-credential-backend.json` under the canonical owner-controlled root | operations open/close flock/data/temp files per call; stable per-record `.lock` sentinels and the root selection lock persist. Encrypted records and `clientauth-plaintext/` records remain on disk. Store `Close` closes its rooted handle; encrypted Close additionally clears its owned key. Selection releases its root lock before OAuth; cancellation and logout retain the backend pin, while logout conditionally deletes the target credential. Crashes may leave owner-only temporary files, deliberately not swept | **persisted / explicitly reattached**: MCP reopens its explicit root/namespace with the same externally acquired 32-byte key. Clientauth login pins one backend before OAuth; subsequent commands reopen that exact pinned backend, never detect/fall back/migrate. Plaintext needs no key, retains local CAS/lock protections, and is not encrypted at rest. Rotated credentials survive process restart. No List 2 row: credentials and backend selection are adapter-owned durable source of truth, not session/run state | `internal/adapter/credentialstore/encrypted_file.go` (`EncryptedFileStore`, `NewEncryptedFile`, `Close`, `PlainFileStore`, `NewPlainFile`, `OpenExistingPlainFile`); `internal/adapter/clientauth/credential_backend.go` (`ResolveCredentialStore`, `OpenExistingCredentialStore`); `internal/adapter/credentialstore/envelope.go`; `internal/cliconfig/mcpprofile.go` (`LoadMCPProfiles`) | -| 51 | Optional per-server MCP OAuth controller: one official handler, token source/CAS version, authorization flight, and dedicated hardened HTTP client's idle pool; plus its credential record (issue #521/#542, ADR 0220/0221) | `internal/adapter/mcp.Server` | connected server / one credential identity | `Server.Close` closes the MCP session first, then idempotently closes the controller's HTTP idle pool; the injected mutable Store or read-only Reader handle is borrowed and remains injector-owned. No background refresh, listener, browser, or timer goroutine | **persisted / explicitly reattached when mutable; source-defined when read-only**: a new controller derives the same opaque key from injected identity/config, reloads the injected persistence source, and reattaches through the official handler's initial-token-source hook. Opt-in in-memory refresh deliberately resets on restart and the old source record is re-read. In-memory flight/idle state resets. No List 2 row: credentials are adapter-owned state, not session/run state | `internal/adapter/mcp/oauth.go` (`OAuthController`, `NewOAuthController`, `Close`); `internal/adapter/mcp/oauth_tokensource.go`; `internal/adapter/mcp/mcp.go` (`Close`) | +| 51 | Optional per-server MCP OAuth controller: one official handler, token source/CAS version, authorization flight, dedicated hardened HTTP client idle pool, and direct-DCR pending/ready registration plus generation-bound no-refresh grant records (issue #521/#542, ADR 0220/0221/0325) | `internal/adapter/mcp.Server` | connected server / one credential identity | `Server.Close` closes the MCP session first, then idempotently closes the controller's HTTP idle pool; the injected mutable Store or read-only Reader handle is borrowed and remains injector-owned. No background refresh, listener, browser, or timer goroutine | **persisted / explicitly reattached when mutable; source-defined when read-only**: a new controller derives the same opaque key from injected identity/config, reloads the injected persistence source, and reattaches through the official handler's initial-token-source hook. DCR reload validates its identity, ready registration generation, and matching grant before token use; pending/corrupt/mismatched records fail closed, while in-memory flight/idle state resets. Opt-in in-memory refresh deliberately resets on restart. No List 2 row: credentials are adapter-owned state, not session/run state | `internal/adapter/mcp/oauth.go` (`OAuthController`, `NewOAuthController`, `Close`); `internal/adapter/mcp/oauth_dcr.go` (`PrepareOAuthDCRLogin`, `resolvePreparedDCR`); `internal/adapter/mcp/oauth_dcr_grant.go`; `internal/adapter/mcp/oauth_tokensource.go`; `internal/adapter/mcp/mcp.go` (`Close`) | | 52 | Opt-in MCP OAuth loopback interaction: random IPv4 listener, bounded callback HTTP server, runtime serialization gate, and optional browser child (issue #522, ADR 0112) | `mcp/oauthlogin.Runtime`; the one-shot caller owns the `Authorize` operation | one authorization operation; the gate lives with the explicitly constructed runtime | every return path cancels presentation, publishes a terminal callback outcome, performs detached bounded `http.Server.Shutdown`, closes the listener, joins `Serve`, and only then releases the runtime gate. The fixed-argv browser command inherits the operation context and is killed on cancellation; no refresh goroutine exists | **per-operation / reset-by-design**: listener, server, callback state, and browser process must not survive. The resulting credential is row 51's durable adapter record. No List 2 row because no session/run state is held | `mcp/oauthlogin/runtime.go` (`Runtime`, `Authorize`, `stopServer`); `mcp/oauthlogin/callback.go`; `mcp/oauthlogin/browser.go`; invoked by `internal/app/mcplogin.go` (`LoginMCP`) | | 53 | Explicit environment credential Reader handle (issue #542, ADR 0221) | canonical MCP environment profiles loaded by `internal/cliconfig.MCPProfiles` and transferred to `app.Build`; used by mecak8s/mecatequi/mecated serving without a presenter | namespace/key/environment-name/lookup tuple | no goroutine, fd, cache, mutation, or global lookup; `EnvironmentReader.Close` idempotently seals the borrowed lookup handle. Each `Get` snapshots its immutable lookup configuration under the lifecycle read lock, invokes the host callback without that lock, then rechecks closure before returning | **explicitly reattached / source-defined**: restart reconstructs the Reader with the same tuple and receives the process/pod's current environment snapshot. In-memory refresh is not written back. Kubernetes Secret env rotation requires an external controller and pod restart, or a future Secret `resourceVersion` CAS writer. No List 2 row: this is adapter credential state, not session/run state | `internal/adapter/credentialstore/environment.go` (`EnvironmentReader`, `NewEnvironment`, `Close`); `internal/cliconfig/mcpprofile.go` (`LoadMCPProfiles`) | | 54 | Canonical MCP profile loader's shared encrypted Stores and per-profile environment Readers (issue #523, ADR 0113) | `internal/cliconfig` (`MCPProfiles`), transferred to `app.Build` through `Config.MCPProfileLifecycle` | process / one loaded operator profile set; local Stores are shared by `(root, key-env reference)` within the load | partial-load failure closes every source already opened; successful Build closes the global MCP manager/controllers first, then the profile lifecycle, and `MCPProfiles.Close` closes every distinct Store/Reader exactly once. The default/no-profile path opens nothing and owns nothing | **persisted / explicitly reattached for local; source-defined for environment**: restart reloads operator metadata and references, derives the identical opaque record key, and reopens row 50/53. Process-local read-only refresh resets by design. No List 2 row because credentials remain adapter state, not session/run state | `internal/cliconfig/mcpprofile.go` (`LoadMCPProfiles`, `MCPProfiles`, `Close`); `internal/app/build.go` (`Config.MCPProfileLifecycle`, `Build`) | diff --git a/docs/adr/0325-direct-mcp-dcr.md b/docs/adr/0325-direct-mcp-dcr.md index a13b0f7522..d1174eb0d6 100644 --- a/docs/adr/0325-direct-mcp-dcr.md +++ b/docs/adr/0325-direct-mcp-dcr.md @@ -1,9 +1,10 @@ # ADR 0325 — Durable Dynamic Client Registration for direct MCP profiles -- Status: Proposed +- Status: Accepted - Date: 2026-09-09 -- Scope: local direct streaming-HTTP MCP authorization-code client identity, registration, and credential lifecycle -- Supersedes: Proposed narrowing of ADR 0219's Basic-only qualification and ADR 0220's direct-DCR exclusion only; neither is superseded until approval +- Amended: 2026-09-10 — direct DCR v1 is no-refresh; complete public-client refresh is deferred to issue #1355 +- Scope: local direct streaming-HTTP MCP authorization-code client identity, registration, and no-refresh access-grant lifecycle +- Supersedes: ADR 0219's Basic-only qualification and ADR 0220's direct-DCR exclusion only for the constrained public no-refresh profile defined here - Superseded by: None ## Context @@ -23,11 +24,16 @@ port. It requested only `openid` and `authorization_code`, used S256 and no secr discarded tokens. A subsequent operator-driven Go spike used `mecated mcp login` and local mecatui against the deployed gateway, discovered the real connector tool catalog, invoked the harmless Excalidraw `read_me` tool, restarted mecatui without another registration, and invoked -it again using the restored encrypted credential. This establishes the selected +it again using the restored encrypted access credential. This establishes the selected SDK/DCR/PKCE/login/persistence/MCP invocation happy path and deployed loopback-port variation, -but not refresh, wrong-path rejection, concurrency, or uncertain-outcome recovery. No -credential/authorization URL is recorded here. Further live qualification remains human-run, -not CI or implied authorization for an implementing agent to contact the gateway. +but not wrong-path rejection, concurrency, uncertain-outcome recovery, or refresh. + +Implementation qualification found two dependency gaps. The pinned SDK delegates persistent +refresh to `oauth2.Config.TokenSource`, which cannot attach the required RFC 8707 `resource`. +It also maps a restored public client to `AuthStyleAutoDetect`, whose first token request uses +Basic with an empty secret before retrying with `client_id` in form parameters. The human +explicitly selected a no-refresh first delivery and deferred complete resource-bound +public-client refresh to [issue #1355](https://github.com/stacklok/mecatl/issues/1355). Broker DCR is a separate ToolHive-owned authority with explicit OAuth2 `upstream` and `discovery_url`. Neither that lifecycle, `CallMcpWithQuery`, nor remote mecatui OIDC/keyring @@ -35,138 +41,109 @@ work belongs to direct DCR. ## Decision -This ADR remains proposed and its [acceptance plan](../acceptance/direct-mcp-dcr.md) remains -**proposed**. Every human decision required for implementation is resolved and recorded; neither -document is approved or landed. The following observable policies have explicit user approval: - 1. Retain `client.mode: dcr`, validating direct and broker shapes separately after authority - selection while preserving broker compatibility. Direct requires exact `issuer` and no - `upstream`; the concrete direct payload proposed in the plan is `dcr: {}`. -2. Persist registration separately from the grant. Grant reset and failed refresh preserve - registration; replacing registration requires explicit reset. + selection while preserving broker compatibility. Direct requires exact `issuer`, no + `upstream`, and `dcr: {}`. +2. Persist registration separately from the access grant. Grant reset and access-token expiry + preserve registration; replacing registration requires explicit reset. 3. Use existing credential-store CAS winner adoption, accepting possible duplicate upstream clients. Do not promise or build upstream exactly-once registration. 4. Reuse `mecated mcp login SERVER [--no-browser]` and `internal/app.LoginMCP`, followed by local mecatui consuming saved credentials. No new mecatui command or model-visible login - tool; startup/reconnect/background refresh never register or launch consent. + tool; startup/reconnect/background work never registers, refreshes, or launches consent. 5. Bind a stable random callback path to the registration and a fresh ephemeral IPv4 loopback port to each authorization. State and PKCE stay fresh; exact path/Host/state validation and bounded listener lifetime remain mandatory. -6. Default direct DCR to durable authorization: omitted `request_refresh_token` resolves to true and omitted scopes to `[openid, offline_access]`. Explicit false derives `[openid]` when scopes are omitted; explicit scopes must match the effective setting. Never add implicit `profile`/`email`. These defaults do not change preregistered, CIMD, or broker profiles, and refresh issuance is required only for an effective refresh-enabled profile. +6. Direct DCR v1 is no-refresh. Omitted `request_refresh_token` resolves false; explicit false + is accepted and explicit true is rejected. Omitted scopes resolve `[openid]`; explicit + scopes must equal exactly `{openid}`. Never request or accept `offline_access`, the + refresh-token grant, or an issued refresh token. Broker, preregistered, and CIMD behavior + remains unchanged. 7. An unknown registration POST outcome is recovery-required, with durable evidence and no automatic retry. A new POST requires explicit operator retry acknowledging possible orphans. 8. `--reset-dcr-registration` applies only to a valid registration and its grant, while `--retry-dcr-registration` applies only to a valid unresolved attempt. Each conditional - local action proceeds to login and neither deletes or revokes an upstream client; grant-only + local action proceeds to login and neither deletes nor revokes an upstream client; grant-only reset remains the internal `ResetCredential` seam with no new CLI modifier. 9. A pending record is written before registration POST and transitions to ready by CAS. A - competing login adopts a ready winner or stops recovery-required while pending, without - joining or polling. Explicit retry replaces pending with a fresh generation and fences an - old publication, though an already-sent upstream request may orphan a client. A valid ready - winner prevails over a late uncertain loser; retain only current and immediately previous - attempt metadata, with no lease, clock takeover, lock service, attempt index, or exactly-once - claim. + competing login adopts a ready winner or stops recovery-required while pending. Explicit + retry replaces pending with a fresh generation and fences an old publication. Retain only + current and immediately previous attempt metadata; add no lease, clock takeover, lock + service, attempt index, or exactly-once claim. -The exact proposed APIs, config, persistence formats/hash domains, CLI grammar, state matrix, -and named offline proofs are in the acceptance plan. This avoids independent, drifting copies -of the contract. Registration-binding versus current-endpoint validation and fail-closed -corrupt-state handling are resolved contract choices; arbitrary API names are not human-policy -questions. +The exact APIs, config, persistence formats/hash domains, CLI grammar, state matrix, and named +offline proofs live in the [acceptance plan](../acceptance/direct-mcp-dcr.md). -### Existing seams, selected public SDK constraints +### Official SDK boundary and public token exchange Add an empty direct DCR arm to `OAuthClientConfig`; do not reinterpret its confidential `Preregistered` arm. A host-only preparation helper obtains the durable callback path and -private one-POST ticket before the existing runtime binds its listener. Inside the login -callback, controller registration resolution uses the official `oauthex.RegisterClient` -with the actual bound URI, persists ready state, and supplies a resolved public -`oauthex.ClientCredentials` to the existing SDK handler's `PreregisteredClient` seam. +private one-POST ticket before the runtime binds its listener. Controller registration uses +the official `oauthex.RegisterClient`, persists ready state, and supplies the resolved public +client through the SDK handler's `PreregisteredClient` seam. The SDK's per-flow `DynamicClientRegistrationConfig` stays nil. Ordinary construction may restore ready state but has no ticket to POST. No new store interface or second OAuth stack is introduced. -The callback extension is per-call, not shared mutable runtime configuration. Existing -random-path and fixed-redirect callers retain their behavior. A gateway that rejects port -variation fails where the server exposes the rejection (authorization or exchange); the -client never silently re-registers or claims the rejection was necessarily detected earlier. - -Public credentials must have nil `ClientSecretAuth`, no Basic header, no `client_secret`, -and no client assertion on real code/refresh wire requests. Persisted public auth style must -be explicit, not auto-detected. Existing confidential preregistered Basic-only/form-secret -rejection and the broker's hardened Basic token client remain unchanged. SDK API availability -is not qualification: an unsupported public exchange or resource-bound persistent refresh -blocks implementation until an official supported seam is available, not a local replacement -OAuth implementation. - -Require exact canonical RFC 9728 resource and sole configured issuer, exact AS issuer, -S256, code, authorization-code and `none`, plus refresh grant/AS `offline_access` support -when requested. No resource-origin discovery fallback or SDK trailing-slash issuer tolerance -may weaken those bindings. HTTPS registration/authorization/token endpoints stay on the exact +Public credentials have nil `ClientSecretAuth`. Initial authorization uses S256 and exactly +one canonical RFC 8707 `resource`. The pinned SDK maps a restored public client to +`AuthStyleAutoDetect`; for direct DCR only, the hardened transport rejects the SDK's Basic +probe locally before dialing. It does not strip and forward the malformed request. The SDK +then performs its parameter fallback, which is admitted only with the exact expected public +`client_id` and no Basic header, `client_secret`, client assertion, or other authentication +field. A real-wire fixture must prove zero Basic requests reached upstream and one valid +parameter-form exchange succeeded. This bounded transport mediation is accepted reuse of the +official SDK flow, not a generic local OAuth implementation. Existing confidential +preregistered Basic-only/form-secret rejection remains unchanged. + +Require exact canonical RFC 9728 resource and sole configured issuer, exact AS issuer, S256, +code, authorization-code and `none`. AS support for `refresh_token` or `offline_access` is not +required. No resource-origin discovery fallback or SDK trailing-slash issuer tolerance may +weaken those bindings. HTTPS registration/authorization/token endpoints stay on the exact issuer origin, with existing DNS pinning, no proxy, TLS, bounds, and redacted errors. Registration POST has no redirect or automatic retry and no initial access credential. -The effective direct-DCR scope allowlist is `[openid, offline_access]` by default: omitted `request_refresh_token` resolves to true and omitted scopes derive from that setting. Explicit false derives `[openid]` when scopes are omitted; explicit scopes must match the effective setting. Profile decoding therefore preserves refresh-field presence before resolving the existing runtime boolean. These defaults are DCR-only. PRM and AS must advertise `openid`; only AS advertisement is required for `offline_access`. ScopeFilter alone is not a final security gate: the SDK adds offline access and unions step-up scopes afterwards. The final authorization request must still match the admitted set, and authorization, code exchange, and refresh must bind exactly the canonical resource. Missing requested refresh issuance never reports durable-refresh success. - -### Proposed bounded CAS lifecycle, not an exactly-once lock - -One deterministic identity-keyed registration control record, in the existing encrypted -`mecatl-mcp-oauth` namespace, is either pending or ready. A create-only pending write with -random generation/path precedes any POST. The successful writer gets a one-use in-process -ticket; a competing login adopts a valid ready winner or stops recovery-required if pending. -There is no lease, PID, time-based takeover, polling service, append log, or attempt index. -This proposed lifecycle suppresses ordinary competing pending POSTs but does not establish -exactly-once. Its pending-contender behavior is a resolved contract choice. - -POST/save uncertainty leaves durable pending evidence. Explicit retry CAS-replaces pending -with a fresh generation, retaining one predecessor summary; explicit registration reset does -the same from ready. Old processes cannot publish against the replacement generation, though -an already-issued upstream POST may create an orphan. A late error cannot overwrite a ready -winner, and no conflict rebases an old result onto a new version. A validated ready result -in the same generation can be adopted after an uncertain save; otherwise stop, never POST -again automatically. A crash before POST conservatively leaves recovery-required state. - -Registration and grant are separate CAS records, not a transaction. The proposed DCR grant -key includes resolved client ID **and registration generation**. Grant-only reset writes a -versioned reset tombstone, including when a grant has not yet been issued; authorization -captures that version before exchange. Conflicts adopt only a valid active current-generation -winner, never a reset tombstone. Registration reset makes old-generation grants unreachable; -a late old-key write is inert, not resurrected current state. Token consumers recheck durable -grant/reset state and registration generation. An already-dispatched request may complete; -local reset does not revoke credentials upstream. - -Transient refresh network failure preserves registration and grant evidence; `invalid_grant` -or internal grant reset tombstones the grant without replacing the client. No grant-only -CLI modifier is added. Corrupt/unsupported/identity-mismatched registration payloads remain -recovery-required even if the store supplies an authenticated version. Neither registration -reset nor retry may replace them; generic backend corruption, wrong keys and unavailability -also fail closed, never as a cache miss or a validation bypass. Separate operator repair is -required. The predecessor schema stays exactly generation, attempt timestamp, and reason -`explicit_retry` or `explicit_reset`: there is no corrupt-reset/digest variant or force flag. - -The registration-binding fingerprint covers issuer/resource, scopes, authentication method, -grants/response types and redirect policy/path. Cosmetic client name and current registration, -authorization and token endpoints are excluded. Intentional binding changes require explicit -registration reset; same-issuer endpoint rotation or cosmetic name drift alone does not replace -the client or change registration generation. Current endpoints are separately rediscovered and -revalidated under the existing exact issuer-origin policy. Stored grant `refresh.token_url` -still passes existing token credential URL/origin checks and hardened egress validation; -registration fingerprint equality never overrides those checks or blindly rewrites the stored -endpoint. If rotation makes a grant unusable, fail/reauthorize at the grant layer while retaining -registration. Corrupt grants remain recovery-required without a new CLI repair path. +### Bounded CAS lifecycle + +One deterministic identity-keyed registration control record in the encrypted +`mecatl-mcp-oauth` namespace is pending or ready. A create-only pending write with random +generation/path precedes any POST. The successful writer gets a one-use in-process ticket; a +competing login adopts a valid ready winner or stops recovery-required if pending. POST/save +uncertainty leaves durable pending evidence. Explicit retry or registration reset CAS-replaces +the old record with a fresh generation, retaining one predecessor summary. Old processes +cannot publish against the replacement generation, though an already-issued request may +create an upstream orphan. + +Registration and grant are separate CAS records, not a transaction. The DCR grant key includes +resolved client ID and registration generation. Grant-only reset writes a versioned reset +tombstone, including before the first grant; authorization captures that version before code +exchange. Conflicts adopt only a valid active current-generation winner, never a reset +tombstone. Registration reset makes old-generation grants unreachable. Token consumers +recheck durable grant/reset state and registration generation before returning a token. + +A DCR version-2 active grant contains only an access token and strictly named authorization +metadata needed to validate the non-refresh credential. It forbids `refresh_token`. Runtime +restoration uses a DCR-specific non-refresh token source and never constructs +`oauth2.Config.TokenSource`. Expiry returns the existing safe login-required category without +network refresh or browser launch. Explicit login reuses the registration/path with a fresh +port/state/PKCE. An unsolicited refresh token makes login fail before persistence. + +Corrupt, unsupported, or identity-mismatched registration/grant payloads remain +recovery-required. Neither registration reset nor retry may reinterpret or replace corrupt +state. Generic backend corruption, wrong keys, and unavailability fail closed. Local reset is +not upstream revocation, and no cross-record atomicity is claimed. ## Consequences -Direct DCR can eventually support this gateway without borrowing broker authority or weakening -confidential-client behavior. It adds encrypted registration control, generation-bound grants, -and reset tombstones to the existing local store dependency. Preregistered/CIMD version-1 -credentials and their hash keys remain unchanged. There is no legacy direct DCR migration or -client-ID inference from grants. +Direct DCR supports explicit public-client registration, authorization, restart-before-expiry, +and reauthorization without borrowing broker authority or weakening confidential-client +behavior. It adds encrypted registration control, generation-bound access grants, and reset +tombstones. Preregistered/CIMD version-1 credentials and hash keys remain unchanged. There is +no legacy direct-DCR migration or client-ID inference from grants. -The plan records resolved observable policies alongside proposed interfaces and implementation -proofs. It is proposed and awaits human plan/interface review, not approval or implementation. -Its offline fixtures must exercise the real profile loader, SDK, controller, callback runtime, -encrypted store and second-load MCP call; the standalone live probe is not a substitute. Nothing -here changes the remote OIDC keyring, MCP broker/query agent, public protobuf API, or runtime in -this contract-only task. +An expired direct-DCR access token requires explicit `mecated mcp login`; ordinary mecatui +startup reports login-required and performs no refresh or hidden consent. Complete +resource-bound public-client refresh, including rotation and restart qualification, is deferred +to [issue #1355](https://github.com/stacklok/mecatl/issues/1355). ## See also @@ -174,4 +151,5 @@ this contract-only task. - [ADR 0220 — Adapter-local MCP OAuth controller](./0220-mcp-oauth-controller.md) - [ADR 0314 — Dynamic Client Registration for MCP broker upstreams](./0314-mcp-broker-dcr-client.md) - [Direct MCP Dynamic Client Registration acceptance plan](../acceptance/direct-mcp-dcr.md) +- [Issue #1355 — resource-bound public-client OAuth refresh](https://github.com/stacklok/mecatl/issues/1355) - [Architecture — internal credential store](../architecture.md#internal-credential-store) diff --git a/docs/adr/README.md b/docs/adr/README.md index d1e8f03503..7d97bd4fd3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -206,7 +206,7 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0311 — Per-upstream MCP broker OAuth grants](./0311-per-upstream-mcp-broker-oauth-grants.md) *(static-tool admission superseded by 0310)* - [0312 — Confidential ToolHive broker client credentials](./0312-confidential-toolhive-broker-client.md) - [0314 — Dynamic Client Registration for MCP broker upstreams](./0314-mcp-broker-dcr-client.md) -- [0325 — Durable Dynamic Client Registration for direct MCP profiles](./0325-direct-mcp-dcr.md) *(proposed)* +- [0325 — Durable Dynamic Client Registration for direct MCP profiles](./0325-direct-mcp-dcr.md) - [0326 — Lazy ToolHive grants refresh declared metadata](./0326-lazy-toolhive-metadata-refresh.md) ### Performance & diagnostics diff --git a/docs/architecture.md b/docs/architecture.md index 30fbe4f416..a51cc13e6a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,13 +112,18 @@ unadvertised server may omit `iss`, while any supplied value must still match th issuer. The strict operator-tier `mcp.servers` schema and the single `internal/cliconfig` loader feed all three headless roots. Normal serve, ACP, mecatequi, and mecak8s install no presenter; only -`mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]` authorizes a +`mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] +[--reset-dcr-registration | --retry-dcr-registration]` authorizes a mutable local profile, selecting trusted operator settings through the same resolver and precedence as serve; the option never carries OAuth values. A hermetic cross-boundary gate -proves login-process exit, a first warm serving process, lazy refresh with durable -refresh-token rotation, a second warm process, and transparent MCP-session reconnect through -the model-visible global catalog. No reauthorization occurs across either restart or -reconnect. The global manager/controllers close before loader-owned Stores and Readers. ACP +proves preregistered login-process exit, warm serving, lazy refresh with durable refresh-token +rotation, and transparent MCP-session reconnect through the model-visible global catalog. +Direct DCR profiles instead persist a separate public-client registration and a +generation-bound no-refresh access grant. A valid registration is reused across explicit +logins; expiry returns login-required without refresh or browser launch, while the two +DCR-only login modifiers explicitly retry an identity-matching pending attempt or replace a ready registration. Ready identity drift is reset-required. Pending identity drift is the distinct pending-identity-mismatch category and cannot retry or reset; restore the matching profile, principal, canonical resource, and exact issuer first. +No reauthorization occurs across restart or reconnect while the selected credential remains +valid. The global manager/controllers close before loader-owned Stores and Readers. ACP cannot provide OAuth profiles or install/drive authorization, but after operator authorization ACP sessions may invoke the shared global OAuth-backed tools under ordinary permissions. OAuth is not available for per-session MCP, inline agent definitions, or diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 2a63d8c85a..6ef096d923 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -8389,10 +8389,32 @@ Basic and form `client_secret` is rejected before dialing. The MCP resource clie separate exact-resource marker for its audience-bound bearer, remains no-proxy/DNS-pinned, and rejects cleartext except for an exact private-origin opt-in; an allowlist entry alone never grants credential egress. Static `Authorization` and OAuth are mutually exclusive. -This controller supports preregistered confidential and CIMD clients only; DCR -remains blocked here on ADR 0219's official-SDK hooks. The separately configured -ToolHive MCP broker supports its own durable DCR resolver under ADR 0314. The -root module pins `github.com/modelcontextprotocol/go-sdk` at +Direct/global DCR clients use one private server-scoped durable lifecycle record in the same +credential-store namespace; it carries and validates profile, principal, canonical resource, +and exact issuer. `internal/adapter/mcp/oauth_dcr.go` (`PrepareOAuthDCRLogin`) discovers and validates +protected-resource and authorization-server metadata through the hardened client, then +creates or reuses a CAS-protected pending/ready registration. Preparation never registers +or launches a browser; a one-use private ticket permits the subsequent login controller to +POST exactly one public-client registration after the callback listener has supplied its +actual variable-port URI. Reset replaces only a valid ready registration; retry replaces +only a valid pending attempt. Corrupt, mismatched, or uncertain state fails with the +redacted recovery-required category instead of being deleted or bypassed; reset/retry do not +repair corrupt selected state. + +The DCR grant is a distinct generation-bound v2 envelope implemented by +`internal/adapter/mcp/oauth_dcr_grant.go`. It stores only the access token required for the +active generation; direct DCR rejects refresh tokens, `offline_access`, scopes other than +exactly `openid`, and refresh-token grants. Expiry, grant reset, a registration-generation +change, or an orphan grant returns login-required without refresh or browser side effects. +Grant reset writes a generation-bound reset tombstone so a stale authorization writer cannot +revive the old result. Explicit re-login reuses a valid registration. For this public-client +path only, `internal/adapter/mcp/oauth_http.go` rejects the official SDK's +`AuthStyleAutoDetect` Basic probe before dial and admits only its exact parameter retry with +the expected client ID and no secret/assertion; preregistered confidential clients retain +the Basic-only policy. + +The separately configured ToolHive MCP broker retains its own durable DCR resolver under +ADR 0314. The root module pins `github.com/modelcontextprotocol/go-sdk` at `v1.7.1-0.20260825151509-2732839dbadd`; the controller enables the SDK's `AcceptUnadvertisedIss` compatibility path, leaving authorization-server discovery and metadata-conditioned RFC 9207 validation in the SDK. A missing callback `iss` is accepted @@ -8436,12 +8458,16 @@ and controller close on every path while the injected store stays caller-owned. project to context/runtime categories or fixed `ErrMCPLoginConfig`/`ErrMCPLoginFailed` without endpoint or credential-bearing causes. -The shipped `mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]` -command is the sole runtime constructor. The repeatable permission-config option selects trusted -operator settings only, never OAuth values. It uses the canonical operator profile loader, requires a mutable local Store, -and emits an authorization URL to stdout only in explicit no-browser mode. Normal serving, -ACP, mecatequi, and mecak8s keep the presenter nil. ADR 0219's metadata-profile blockers -remain open. +The shipped `mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] +[--reset-dcr-registration | --retry-dcr-registration]` command is the sole runtime +constructor. The repeatable permission-config option selects trusted operator settings only, +never OAuth values. The mutually exclusive DCR-only modifiers perform the explicit +ready-registration reset or pending-attempt retry before continuing into login; they reject +other client kinds and invalid/corrupt state without mutation. Grant-only reset remains an +internal controller operation and has no CLI flag. The command uses the canonical operator +profile loader, requires a mutable local Store, and emits an authorization URL to stdout only +in explicit no-browser mode. Normal serving, ACP, mecatequi, and mecak8s keep the presenter +nil. Complete public-client refresh remains deferred to issue #1355. ## Operator MCP profiles (ADR 0113) diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index d2423cb4c0..681d8d3eb7 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -16,6 +16,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do |---|---|---|---| | Multi-provider / multi-model | ✅ P0+P1+live listing & metadata · ✅ same-provider history carryover (issue #20) · ✅ experimental manual `openai-codex` subscription token (ADR 0215) · ✅ ToolHive OpenAI Responses and native Anthropic protocol providers over one gateway identity (ADR 0334) · ✅ internal opaque credential-store substrate consumed by MCP profiles with local encrypted-file Store and explicit read-only environment Reader (issues #519/#542) · ⛔ disk cache (P2) · ⛔ key acquisition/per-client routing/remote stores or Kubernetes Secret `resourceVersion` CAS backend (P3) | [MULTI-PROVIDER.md](../adr/0016-multi-provider.md) · [0215](../adr/0215-openai-subscription-manual-token.md) · [0218](../adr/0218-credential-store.md) · [0221](../adr/0221-read-only-credential-source.md) · [0334](../adr/0334-toolhive-protocol-specific-providers.md) | [providers](../architecture/providers.md) · [credential store](../architecture.md#internal-credential-store) | | Remote mecatui OIDC login, refresh, recovery, and logout | ✅ fixed-callback PKCE enrollment, actual-record-only root-scoped keyring migration, one refresh/enroll/logout target transaction with ambiguous-commit compensation, target-bound encrypted credentials, token-demand-gated proactive refresh, exact structured-rejection cleanup, ownership-checked same-target recovery, CAS-safe local-first logout, one-budget best-effort RFC 7009 revocation, and target-aware TLS defaults with saved-auth verified-TLS enforcement shipped; ✅ offline rotation/rejection/crash-residual/logout/TLS-policy coverage · ⚠️ no cross-store journal: a crash may require login or leave an unenumerable credential-only orphan · ⚠️ legacy zero-padded-port credential identities require one login · ⛔ live Kind qualification remains environment-dependent | [0277](../adr/0277-remote-mecatui-oidc.md) · [0287](../adr/0287-target-aware-mecatui-tls.md) · [0218](../adr/0218-credential-store.md) | [remote login](../tui.md#oidc-connected-server) | +| Direct MCP OAuth dynamic client registration | 🔨 Core trusted-profile validation, durable CAS registration, generation-bound no-refresh grant, explicit reset/retry recovery, restart reuse, expiry-to-login-required, protocol/security fixtures, seven named acceptance proofs, and cloud-native resource re-audit complete · ⛔ repeat panel review, implementation PR/merge, and live local-mecatui qualification remain · ⛔ complete resource-bound public-client refresh ([issue #1355](https://github.com/stacklok/mecatl/issues/1355)) | [0325](../adr/0325-direct-mcp-dcr.md) | [credential store](../architecture.md#internal-credential-store) | | Provider-side conversation prompt caching | ✅ shipped: anthropic 4-slot breakpoint budget + uniform TTL, openai/openrouter dialect-gated `prompt_cache_key`/`prompt_cache_retention`/`cache_control`, openaichat dormant-but-tested · ⛔ operator-supplied cache key · ⛔ `settings.yaml` TTL key · ⛔ Anthropic 1h TTL via OpenRouter | [0100](../adr/0100-provider-prompt-caching.md) | [providers](../architecture/providers.md) | | OpenAI Responses adapter | ✅ shipped (research brief frozen) | [OPENAI-RESPONSES-API.md](../adr/0017-openai-responses-api.md) | [providers](../architecture/providers.md) | | Agent definitions (Tier-1 specialists) | ✅ shipped · ⛔ per-agent memory write path · ⛔ `local` tier | [AGENT-DEFINITIONS.md](../adr/0013-agent-definitions.md) | [subagents & teams](../architecture/subagents-and-teams.md) | diff --git a/go.mod b/go.mod index 782b14a40f..8de351b819 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/adrg/xdg v0.5.3 github.com/alicebob/miniredis/v2 v2.39.0 - github.com/anthropics/anthropic-sdk-go v1.71.0 + github.com/anthropics/anthropic-sdk-go v1.72.0 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/cedar-policy/cedar-go v1.8.0 github.com/charmbracelet/colorprofile v0.4.3 @@ -28,7 +28,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.7.1-0.20260825151509-2732839dbadd github.com/onsi/ginkgo/v2 v2.30.0 github.com/onsi/gomega v1.41.0 - github.com/openai/openai-go/v3 v3.56.0 + github.com/openai/openai-go/v3 v3.61.0 github.com/ory/fosite v0.49.0 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 @@ -321,7 +321,7 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.49.1 // indirect - mvdan.cc/sh/v3 v3.12.0 // indirect + mvdan.cc/sh/v3 v3.14.1 // indirect oras.land/oras-go/v2 v2.6.2 // indirect sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index f7122c591c..22a4b4f028 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,8 @@ github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/anthropics/anthropic-sdk-go v1.71.0 h1:DK9xG3s5t+xUIOp+R+1LIRjkU/wG57YyQO3630+gYOE= -github.com/anthropics/anthropic-sdk-go v1.71.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY= +github.com/anthropics/anthropic-sdk-go v1.72.0 h1:T0qQWWfygiL24lqEQaffRXCvEeno3yiv/mFzUdY1E88= +github.com/anthropics/anthropic-sdk-go v1.72.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -293,8 +293,8 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8 github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -575,8 +575,8 @@ github.com/onsi/ginkgo/v2 v2.30.0 h1:zxM/9XneXFIy64j6/wAmBIX4zRC7Hu6U8XFNZvDnCQc github.com/onsi/ginkgo/v2 v2.30.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/openai/openai-go/v3 v3.56.0 h1:Xb+gsS7Gsb5dx+Uat9BHTWhifAOvQjU+pQNWT/HXeWQ= -github.com/openai/openai-go/v3 v3.56.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= +github.com/openai/openai-go/v3 v3.61.0 h1:nMLuGFdKBF0sB3qFVNwE8kpBpenVN1ucYRoKcx7E1u0= +github.com/openai/openai-go/v3 v3.61.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -634,8 +634,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -1074,8 +1074,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= -mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= +mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/internal/adapter/mcp/oauth.go b/internal/adapter/mcp/oauth.go index 8a5d6bb2e8..584a2861e9 100644 --- a/internal/adapter/mcp/oauth.go +++ b/internal/adapter/mcp/oauth.go @@ -3,6 +3,7 @@ package mcp import ( "context" "crypto/sha256" + "crypto/x509" "errors" "net" "net/http" @@ -27,10 +28,30 @@ type OAuthSubject struct { Principal string } +// OAuthDCRConfig selects durable Dynamic Client Registration for a direct MCP profile. +type OAuthDCRConfig struct { + // ServerName identifies the private lifecycle record. It is injected by the + // resolved profile loader and deliberately has no configuration serialization. + ServerName string +} + +// OAuthDCRLoginAction selects the explicit registration operation performed by login. +type OAuthDCRLoginAction uint8 + +const ( + // OAuthDCRLoginReuse reuses a ready registration or creates the initial registration. + OAuthDCRLoginReuse OAuthDCRLoginAction = iota + // OAuthDCRLoginResetRegistration explicitly replaces a ready registration. + OAuthDCRLoginResetRegistration + // OAuthDCRLoginRetryRegistration explicitly retries a pending registration attempt. + OAuthDCRLoginRetryRegistration +) + // OAuthClientConfig selects one durable client-registration profile. type OAuthClientConfig struct { Preregistered *oauthex.ClientCredentials ClientIDMetadataDocumentURL string + DCR *OAuthDCRConfig } // OAuthNetworkPolicy declares endpoint origins. DNS and transport enforcement is @@ -76,6 +97,9 @@ type OAuthOptions struct { AllowedScopes []string Timeout time.Duration allowLoopbackForTest bool + testRootCAs *x509.CertPool + dcr *oauthDCRResolved + dcrTicket *oauthDCRTicket } // AllowOAuthLoopbackForTest enables loopback only for in-process test servers. @@ -88,12 +112,26 @@ func AllowOAuthLoopbackForTest(t interface{ Helper() }, opts *OAuthOptions) { } } +// TrustOAuthCertificateForTest trusts one test server certificate for OAuth TLS. +func TrustOAuthCertificateForTest(t interface{ Helper() }, opts *OAuthOptions, cert *x509.Certificate) { + t.Helper() + if opts == nil || cert == nil { + return + } + roots := x509.NewCertPool() + roots.AddCert(cert) + opts.testRootCAs = roots +} + type oauthRegistration struct { - kind string - clientID string - clientSecret string - sdk *oauthex.ClientCredentials - cimd string + kind string + clientID string + clientSecret string + generation string + redirectPath string + dcrServerName string + sdk *oauthex.ClientCredentials + cimd string } func oauthPersistence(opts OAuthOptions) (credentialstore.Reader, credentialstore.ConditionalWriter, error) { @@ -142,7 +180,7 @@ func validateOAuthOptions(opts OAuthOptions) (oauthRegistration, map[string]stru return oauthRegistration{}, nil, errors.New("OAuth max redirects must be between zero and five") } - registration, err := validateOAuthRegistration(opts.Client, opts.Issuer) + registration, err := resolvedOAuthRegistration(opts) if err != nil { return oauthRegistration{}, nil, err } @@ -156,9 +194,13 @@ func validateOAuthOptions(opts OAuthOptions) (oauthRegistration, map[string]stru func validateOAuthRegistration(clientOpts OAuthClientConfig, issuer string) (oauthRegistration, error) { preregistered := clientOpts.Preregistered != nil cimd := clientOpts.ClientIDMetadataDocumentURL != "" - if preregistered == cimd { + dcr := clientOpts.DCR != nil + if boolCount(preregistered, cimd, dcr) != 1 { return oauthRegistration{}, errors.New("OAuth client must configure exactly one registration form") } + if dcr { + return oauthRegistration{}, errors.New("OAuth DCR client registration is unresolved") + } if preregistered { client := clientOpts.Preregistered if err := client.Validate(); err != nil { @@ -183,6 +225,33 @@ func validateOAuthRegistration(clientOpts OAuthClientConfig, issuer string) (oau return oauthRegistration{kind: "cimd", clientID: clientOpts.ClientIDMetadataDocumentURL, cimd: clientOpts.ClientIDMetadataDocumentURL}, nil } +func boolCount(values ...bool) int { + count := 0 + for _, value := range values { + if value { + count++ + } + } + return count +} + +func resolvedOAuthRegistration(opts OAuthOptions) (oauthRegistration, error) { + if opts.Client.DCR == nil { + return validateOAuthRegistration(opts.Client, opts.Issuer) + } + if boolCount(opts.Client.Preregistered != nil, opts.Client.ClientIDMetadataDocumentURL != "", true) != 1 || opts.dcr == nil { + return oauthRegistration{}, errors.New("OAuth DCR client registration is unresolved") + } + if opts.dcr.issuer != opts.Issuer || opts.dcr.clientID == "" || !validDCRRandom(opts.dcr.generation) || !validDCRServerName(opts.dcr.serverName) { + return oauthRegistration{}, errors.New("OAuth DCR client registration is invalid") + } + client := &oauthex.ClientCredentials{ClientID: opts.dcr.clientID, Issuer: opts.Issuer} + if err := client.Validate(); err != nil { + return oauthRegistration{}, errors.New("OAuth DCR client registration is invalid") + } + return oauthRegistration{kind: oauthDCRClientKind, clientID: opts.dcr.clientID, generation: opts.dcr.generation, redirectPath: opts.dcr.path, dcrServerName: opts.dcr.serverName, sdk: client}, nil +} + func validateOAuthOrigins(issuer *url.URL, network OAuthNetworkPolicy) (map[string]struct{}, error) { origins := map[string]struct{}{urlOrigin(issuer): {}} for _, raw := range network.AdditionalOrigins { @@ -230,7 +299,7 @@ func validateHTTPURL(field, raw string, httpsOnly bool) (*url.URL, error) { if err != nil || !u.IsAbs() || u.Host == "" || u.User != nil || u.Fragment != "" { return nil, errors.New(field + " is invalid") } - if (httpsOnly && !strings.EqualFold(u.Scheme, "https")) || (!httpsOnly && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) { + if (httpsOnly && !strings.EqualFold(u.Scheme, "https")) || (!httpsOnly && !strings.EqualFold(u.Scheme, oauthHTTPURLScheme) && !strings.EqualFold(u.Scheme, "https")) { return nil, errors.New(field + " is invalid") } if u.Hostname() == "" || !validPort(u) { @@ -257,7 +326,7 @@ func urlOrigin(u *url.URL) string { hostname = ip.String() } port := u.Port() - if port == "" || scheme == "https" && port == "443" || scheme == "http" && port == "80" { + if port == "" || scheme == "https" && port == "443" || scheme == oauthHTTPURLScheme && port == "80" { port = "" } if strings.Contains(hostname, ":") { @@ -378,8 +447,8 @@ func NewOAuthController(ctx context.Context, resource string, opts OAuthOptions) if err := ctx.Err(); err != nil { return nil, err } - if _, _, err := validateOAuthOptions(opts); err != nil { - return nil, err + if opts.Client.DCR != nil && !validDCRRequestedScopes(opts) { + return nil, errors.New("OAuth DCR configuration is invalid") } if _, err := canonicalOAuthResource(resource); err != nil { return nil, err @@ -388,6 +457,19 @@ func NewOAuthController(ctx context.Context, resource string, opts OAuthOptions) if err != nil { return nil, err } + opts, err = resolvePreparedDCR(ctx, resource, opts, client) + if err != nil { + transport.base.CloseIdleConnections() + return nil, projectOAuthError(err) + } + if opts.dcr != nil { + transport.dcrPublicClientID = opts.dcr.clientID + transport.dcrIssuer = opts.Issuer + } + if _, _, err := validateOAuthOptions(opts); err != nil { + transport.base.CloseIdleConnections() + return nil, err + } lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) controller := &OAuthController{ presenter: opts.Presenter, @@ -431,6 +513,11 @@ func (c *OAuthController) presentAuthorization(ctx context.Context, args *auth.A if err != nil || urlOrigin(authorizationURL) != c.transport.issuerOrigin { return nil, projectOAuthError(ErrOAuthUnavailable) } + if c.state.registration.kind == oauthDCRClientKind { + if err := validateDCRAuthorizationURL(args.URL, c.state.identity.Resource); err != nil { + return nil, projectOAuthError(err) + } + } result, err := c.presenter.PresentAuthorization(ctx, args.URL) return result, projectOAuthError(err) } @@ -488,7 +575,7 @@ func (c *OAuthController) authorizationAllowed(ctx context.Context) error { // this credential identity while leaving cancellation bounded by each caller's // context. A completed outcome remains attached to its challenge key until a // different credential/challenge arrives or ResetCredential invalidates it. -func (c *OAuthController) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { +func (c *OAuthController) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { //nolint:gocyclo // single-flight state transitions stay explicit. if err := c.authorizationAllowed(ctx); err != nil { closeOAuthResponse(resp) return err @@ -558,16 +645,25 @@ func (c *OAuthController) Authorize(ctx context.Context, req *http.Request, resp c.flight = flight c.flightMu.Unlock() + if err := c.state.beginAuthorization(ctx); err != nil { + closeOAuthResponse(resp) + c.completeAuthorizationFlight(flight, err) + return err + } err := projectOAuthError(c.authorize(ctx, req, resp)) - c.flightMu.Lock() - flight.err = err - flight.completed = true - close(flight.done) - c.flightMu.Unlock() + c.completeAuthorizationFlight(flight, err) return err } } +func (c *OAuthController) completeAuthorizationFlight(flight *authorizationFlight, err error) { + c.flightMu.Lock() + flight.err = err + flight.completed = true + close(flight.done) + c.flightMu.Unlock() +} + // ResetCredential conditionally deletes the current record and clears the live // token source. A concurrent CAS winner is preserved and adopted. func (c *OAuthController) ResetCredential(ctx context.Context) error { diff --git a/internal/adapter/mcp/oauth_credential.go b/internal/adapter/mcp/oauth_credential.go index fabaa1bfc8..e010b2bfb9 100644 --- a/internal/adapter/mcp/oauth_credential.go +++ b/internal/adapter/mcp/oauth_credential.go @@ -68,7 +68,7 @@ func canonicalOAuthResource(raw string) (string, error) { hostname = ip.String() } port := u.Port() - if port == "" || scheme == "https" && port == "443" || scheme == "http" && port == "80" { + if port == "" || scheme == "https" && port == "443" || scheme == oauthHTTPURLScheme && port == "80" { port = "" } if strings.Contains(hostname, ":") { @@ -149,7 +149,7 @@ func validateOAuthIdentity(identity oauthCredentialIdentity) error { return err } } - if identity.ClientKind != "preregistered" && identity.ClientKind != "cimd" { + if identity.ClientKind != "preregistered" && identity.ClientKind != "cimd" && identity.ClientKind != oauthDCRClientKind { return errors.New("OAuth client kind is unsupported") } return nil @@ -169,15 +169,19 @@ func OAuthCredentialRecordKey(resource string, opts OAuthOptions) ([]byte, error if err != nil { return nil, err } - registration, err := validateOAuthRegistration(opts.Client, opts.Issuer) + registration, err := resolvedOAuthRegistration(opts) if err != nil { return nil, err } - return oauthCredentialKey(oauthCredentialIdentity{ + identity := oauthCredentialIdentity{ Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: canonical, Issuer: opts.Issuer, ClientKind: registration.kind, ClientID: registration.clientID, - }) + } + if registration.kind == oauthDCRClientKind { + return oauthDCRCredentialKey(identity, registration.generation) + } + return oauthCredentialKey(identity) } func oauthCredentialKey(identity oauthCredentialIdentity) ([]byte, error) { diff --git a/internal/adapter/mcp/oauth_dcr.go b/internal/adapter/mcp/oauth_dcr.go new file mode 100644 index 0000000000..d012d6573b --- /dev/null +++ b/internal/adapter/mcp/oauth_dcr.go @@ -0,0 +1,958 @@ +package mcp + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" + + "github.com/stacklok/mecatl/internal/adapter/credentialstore" +) + +const ( + oauthDCRRegistrationSchema = "mecatl.mcp.oauth-dcr-registration" + oauthDCRRegistrationVersion = 1 + oauthDCRRegistrationKeyDomain = "mecatl/mcp/oauth-dcr-lifecycle-key/v1" + oauthDCRRedirectPolicy = "ipv4-loopback-variable-port/v1" + oauthDCRCallbackPrefix = "/oauth/callback/" + oauthDCRClientKind = "dcr" + oauthDCRScope = "openid" + oauthDCRStatePending = "pending" + oauthDCRStateReady = "ready" + oauthDCRFailureOutcomeUnknown = "registration_outcome_unknown" + oauthDCRFailureResponseInvalid = "registration_response_invalid" + oauthDCRFailureReadyPersistence = "ready_persistence_failed" + oauthDCRFailureRecordTimeout = 2 * time.Second + oauthHTTPURLScheme = "http" +) + +// ErrOAuthDCRRecoveryRequired reports durable DCR state that requires an explicit operator recovery action. +var ErrOAuthDCRRecoveryRequired = errors.New("OAuth DCR recovery required") + +// OAuthDCRRecoveryCategory distinguishes safe DCR recovery outcomes without +// retaining registration response or credential details. +type OAuthDCRRecoveryCategory uint8 + +const ( + // OAuthDCRRecoveryUnspecified reports no classified recovery stage. + OAuthDCRRecoveryUnspecified OAuthDCRRecoveryCategory = iota + // OAuthDCRRecoveryPending reports a legacy pending record without stage evidence. + OAuthDCRRecoveryPending + // OAuthDCRRecoveryCorrupt reports inconsistent or undecodable durable state. + OAuthDCRRecoveryCorrupt + // OAuthDCRRecoveryRegistrationOutcomeUnknown reports an uncertain registration POST outcome. + OAuthDCRRecoveryRegistrationOutcomeUnknown + // OAuthDCRRecoveryResponseInvalid reports an unusable registration response. + OAuthDCRRecoveryResponseInvalid + // OAuthDCRRecoveryReadyPersistence reports failure to persist an accepted registration. + OAuthDCRRecoveryReadyPersistence + // OAuthDCRRecoveryResetRequired reports a valid ready lifecycle record that requires explicit reset. + OAuthDCRRecoveryResetRequired + // OAuthDCRRecoveryPendingIdentityMismatch reports a valid pending lifecycle whose configured identity must be restored before retry. + OAuthDCRRecoveryPendingIdentityMismatch +) + +// OAuthDCRRecoveryError preserves the recovery sentinel while carrying a +// closed, safe category for the CLI remedy. +type OAuthDCRRecoveryError struct { + category OAuthDCRRecoveryCategory +} + +func (*OAuthDCRRecoveryError) Error() string { return ErrOAuthDCRRecoveryRequired.Error() } + +// Is preserves errors.Is compatibility with ErrOAuthDCRRecoveryRequired. +func (*OAuthDCRRecoveryError) Is(target error) bool { + return target == ErrOAuthDCRRecoveryRequired +} + +// NewOAuthDCRRecoveryError constructs a recovery error for a closed category. +func NewOAuthDCRRecoveryError(category OAuthDCRRecoveryCategory) error { + return &OAuthDCRRecoveryError{category: category} +} + +// OAuthDCRRecoveryCategoryOf returns a safe category, or unspecified when the +// error is not a classified DCR recovery error. +func OAuthDCRRecoveryCategoryOf(err error) OAuthDCRRecoveryCategory { + var recovery *OAuthDCRRecoveryError + if errors.As(err, &recovery) { + return recovery.category + } + return OAuthDCRRecoveryUnspecified +} + +func dcrRecovery(category OAuthDCRRecoveryCategory) error { + return NewOAuthDCRRecoveryError(category) +} + +func recoveryCategoryForDCRIdentityMismatch(state string) OAuthDCRRecoveryCategory { + switch state { + case oauthDCRStateReady: + return OAuthDCRRecoveryResetRequired + case oauthDCRStatePending: + return OAuthDCRRecoveryPendingIdentityMismatch + default: + return OAuthDCRRecoveryCorrupt + } +} + +func persistedDCRFailureCategory(category OAuthDCRRecoveryCategory) string { + switch category { + case OAuthDCRRecoveryRegistrationOutcomeUnknown: + return oauthDCRFailureOutcomeUnknown + case OAuthDCRRecoveryResponseInvalid: + return oauthDCRFailureResponseInvalid + case OAuthDCRRecoveryReadyPersistence: + return oauthDCRFailureReadyPersistence + default: + return "" + } +} + +func recoveryCategoryForPersistedDCRFailure(category string) OAuthDCRRecoveryCategory { + switch category { + case oauthDCRFailureOutcomeUnknown: + return OAuthDCRRecoveryRegistrationOutcomeUnknown + case oauthDCRFailureResponseInvalid: + return OAuthDCRRecoveryResponseInvalid + case oauthDCRFailureReadyPersistence: + return OAuthDCRRecoveryReadyPersistence + default: + return OAuthDCRRecoveryPending + } +} + +func validPersistedDCRFailureCategory(category string) bool { + return category == "" || category == oauthDCRFailureOutcomeUnknown || category == oauthDCRFailureResponseInvalid || category == oauthDCRFailureReadyPersistence +} + +// validateDCRAuthorizationURL checks the final SDK authorization request before +// the host presents it. The SDK may union challenge scopes after ScopeFilter runs. +func validateDCRAuthorizationURL(authorizationURL, resource string) error { + canonical, err := canonicalOAuthResource(resource) + if err != nil { + return errors.New("OAuth DCR authorization resource is invalid") + } + u, err := url.Parse(authorizationURL) + if err != nil { + return errors.New("OAuth DCR authorization URL is invalid") + } + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return errors.New("OAuth DCR authorization URL is invalid") + } + if scopes := query["scope"]; len(scopes) != 1 || scopes[0] != oauthDCRScope { + return errors.New("OAuth DCR authorization scope is invalid") + } + if resources := query["resource"]; len(resources) != 1 || resources[0] != canonical { + return errors.New("OAuth DCR authorization resource is invalid") + } + return nil +} + +type oauthDCRIdentity struct { + Profile string `json:"profile"` + Principal string `json:"principal"` + Resource string `json:"resource"` + Issuer string `json:"issuer"` +} + +type oauthDCRMetadata struct { + Issuer string `json:"issuer"` + Resource string `json:"resource"` + RedirectPolicy string `json:"redirect_policy"` + RedirectPath string `json:"redirect_path"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + Scopes []string `json:"scopes"` + supportedScopes []string +} + +type oauthDCRRegistration struct { + ClientID string `json:"client_id"` + RegisteredRedirectURI string `json:"registered_redirect_uri"` + ClientIDIssuedAt *int64 `json:"client_id_issued_at,omitempty"` +} + +type oauthDCRPreviousAttempt struct { + Generation string `json:"generation"` + AttemptStartedAt string `json:"attempt_started_at"` + Reason string `json:"reason"` +} + +type oauthDCRRecord struct { + Schema string `json:"schema"` + Version int `json:"version"` + Identity oauthDCRIdentity `json:"identity"` + Generation string `json:"generation"` + State string `json:"state"` + AttemptStartedAt string `json:"attempt_started_at"` + Metadata oauthDCRMetadata `json:"metadata"` + MetadataFingerprint string `json:"metadata_fingerprint"` + PreviousAttempt *oauthDCRPreviousAttempt `json:"previous_attempt,omitempty"` + FailureCategory string `json:"failure_category,omitempty"` + Registration *oauthDCRRegistration `json:"registration,omitempty"` +} + +type oauthDCRResolved struct { + issuer string + clientID string + generation string + path string + serverName string +} + +type oauthDCRTicket struct { + mu sync.Mutex + used bool + key []byte + version credentialstore.Version + record oauthDCRRecord + registrationEndpoint string +} + +func (t *oauthDCRTicket) consume() bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.used { + return false + } + t.used = true + return true +} + +// PrepareOAuthDCRLogin validates discovery and prepares one durable registration +// attempt. It never launches a browser or sends the registration request. +func PrepareOAuthDCRLogin(ctx context.Context, resource string, opts OAuthOptions, action OAuthDCRLoginAction) (OAuthOptions, string, error) { //nolint:gocyclo // explicit CAS states and recovery actions stay visible. + if ctx == nil { + return OAuthOptions{}, "", errors.New("OAuth DCR preparation requires a context") + } + if err := ctx.Err(); err != nil { + return OAuthOptions{}, "", err + } + if action > OAuthDCRLoginRetryRegistration || opts.Client.DCR == nil || opts.Client.Preregistered != nil || opts.Client.ClientIDMetadataDocumentURL != "" || !validDCRRequestedScopes(opts) || !validDCRServerName(opts.Client.DCR.ServerName) { + return OAuthOptions{}, "", errors.New("OAuth DCR login configuration is invalid") + } + if opts.CredentialStore == nil || opts.CredentialReader != nil { + return OAuthOptions{}, "", errors.New("OAuth DCR requires a mutable credential store") + } + caps := opts.CredentialStore.Capabilities() + if !opts.allowLoopbackForTest && (!caps.Persistent || !caps.CrossProcessCAS) { + return OAuthOptions{}, "", errors.New("OAuth DCR requires persistent cross-process credential CAS") + } + canonical, err := canonicalOAuthResource(resource) + if err != nil { + return OAuthOptions{}, "", err + } + identity := oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: canonical, Issuer: opts.Issuer} + if err := validateDCRIdentity(identity); err != nil { + return OAuthOptions{}, "", err + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + return OAuthOptions{}, "", err + } + record, getErr := opts.CredentialStore.Get(ctx, key) + firstReadMissing := errors.Is(getErr, credentialstore.ErrNotFound) + if getErr != nil && !firstReadMissing { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if getErr == nil { + stored, decodeErr := decodeOAuthDCRRecordRaw(record.Value) + if decodeErr != nil { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if stored.Identity != identity { + if stored.State == oauthDCRStatePending || action != OAuthDCRLoginResetRegistration { + return OAuthOptions{}, "", dcrRecovery(recoveryCategoryForDCRIdentityMismatch(stored.State)) + } + } + } + client, transport, err := newOAuthHTTPClient(canonical, opts) + if err != nil { + return OAuthOptions{}, "", err + } + defer transport.base.CloseIdleConnections() + meta, endpoint, err := discoverDCRMetadata(ctx, canonical, opts, client) + if err != nil { + return OAuthOptions{}, "", err + } + record, getErr = opts.CredentialStore.Get(ctx, key) + if getErr == nil { + stored, decodeErr := decodeOAuthDCRRecordRaw(record.Value) + if decodeErr != nil { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if stored.Identity != identity { + if stored.State == oauthDCRStatePending || action != OAuthDCRLoginResetRegistration { + return OAuthOptions{}, "", dcrRecovery(recoveryCategoryForDCRIdentityMismatch(stored.State)) + } + } + } + if getErr == nil { + stored, decodeErr := decodeOAuthDCRRecordRaw(record.Value) + if decodeErr != nil { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if action == OAuthDCRLoginReuse { + meta.RedirectPath = stored.Metadata.RedirectPath + if stored.State == oauthDCRStatePending { + return OAuthOptions{}, "", dcrRecovery(recoveryCategoryForPersistedDCRFailure(stored.FailureCategory)) + } + if stored.State != oauthDCRStateReady || stored.MetadataFingerprint != fingerprintDCRMetadata(meta) || !equalDCRMetadata(stored.Metadata, meta) { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if err := prepareDCRGrantForExplicitLogin(ctx, opts.CredentialStore, stored, transport.issuerOrigin); err != nil { + return OAuthOptions{}, "", err + } + return withResolvedDCR(opts, stored), stored.Metadata.RedirectPath, nil + } + if action == OAuthDCRLoginResetRegistration && stored.State == oauthDCRStatePending { + return OAuthOptions{}, "", dcrRecovery(recoveryCategoryForPersistedDCRFailure(stored.FailureCategory)) + } + if action == OAuthDCRLoginResetRegistration && stored.State != oauthDCRStateReady || action == OAuthDCRLoginRetryRegistration && stored.State != oauthDCRStatePending { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryResetRequired) + } + if action == OAuthDCRLoginResetRegistration { + grantIdentity := oauthCredentialIdentity{ + Profile: stored.Identity.Profile, Principal: stored.Identity.Principal, Resource: stored.Identity.Resource, + Issuer: stored.Identity.Issuer, ClientKind: oauthDCRClientKind, ClientID: stored.Registration.ClientID, + } + grantKey, keyErr := oauthDCRCredentialKey(grantIdentity, stored.Generation) + if keyErr != nil { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + grantRecord, grantErr := opts.CredentialStore.Get(ctx, grantKey) + if grantErr == nil { + storedIssuer, issuerErr := validateHTTPURL("stored OAuth DCR issuer", stored.Identity.Issuer, !opts.allowLoopbackForTest) + if issuerErr != nil { + return OAuthOptions{}, "", dcrRecovery(OAuthDCRRecoveryCorrupt) + } + issuerOrigins := map[string]struct{}{urlOrigin(storedIssuer): {}} + if _, decodeErr := decodeOAuthDCRGrant(grantRecord.Value, grantIdentity, stored.Generation, issuerOrigins); decodeErr != nil { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + } else if !errors.Is(grantErr, credentialstore.ErrNotFound) { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + } + reason := "explicit_reset" + if action == OAuthDCRLoginRetryRegistration { + reason = "explicit_retry" + } + pending, pendingErr := newOAuthDCRPending(identity, meta, stored, reason) + if pendingErr != nil { + return OAuthOptions{}, "", pendingErr + } + value, encodeErr := encodeOAuthDCRRecord(pending, identity) + if encodeErr != nil { + return OAuthOptions{}, "", encodeErr + } + updated, putErr := opts.CredentialStore.Put(ctx, key, value, &record.Version) + if putErr != nil { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + opts.dcrTicket = &oauthDCRTicket{key: append([]byte(nil), key...), version: updated.Version, record: pending, registrationEndpoint: endpoint} + return opts, pending.Metadata.RedirectPath, nil + } + if !firstReadMissing || !errors.Is(getErr, credentialstore.ErrNotFound) || action != OAuthDCRLoginReuse { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + pending, err := newOAuthDCRPending(identity, meta, oauthDCRRecord{}, "") + if err != nil { + return OAuthOptions{}, "", err + } + value, err := encodeOAuthDCRRecord(pending, identity) + if err != nil { + return OAuthOptions{}, "", err + } + created, err := opts.CredentialStore.Put(ctx, key, value, nil) + if errors.Is(err, credentialstore.ErrConflict) { + winner, winnerErr := opts.CredentialStore.Get(ctx, key) + if winnerErr != nil { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + stored, decodeErr := decodeOAuthDCRRecord(winner.Value, identity) + if decodeErr == nil { + meta.RedirectPath = stored.Metadata.RedirectPath + } + if decodeErr == nil && stored.State == oauthDCRStateReady && stored.MetadataFingerprint == fingerprintDCRMetadata(meta) && equalDCRMetadata(stored.Metadata, meta) { + return withResolvedDCR(opts, stored), stored.Metadata.RedirectPath, nil + } + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + if err != nil { + return OAuthOptions{}, "", ErrOAuthDCRRecoveryRequired + } + opts.dcrTicket = &oauthDCRTicket{key: append([]byte(nil), key...), version: created.Version, record: pending, registrationEndpoint: endpoint} + return opts, pending.Metadata.RedirectPath, nil +} + +func prepareDCRGrantForExplicitLogin(ctx context.Context, store credentialstore.Store, registration oauthDCRRecord, issuerOrigin string) error { + identity := oauthCredentialIdentity{ + Profile: registration.Identity.Profile, Principal: registration.Identity.Principal, + Resource: registration.Identity.Resource, Issuer: registration.Identity.Issuer, + ClientKind: oauthDCRClientKind, ClientID: registration.Registration.ClientID, + } + key, err := oauthDCRCredentialKey(identity, registration.Generation) + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + record, err := store.Get(ctx, key) + if errors.Is(err, credentialstore.ErrNotFound) { + return nil + } + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + grant, err := decodeOAuthDCRGrant(record.Value, identity, registration.Generation, map[string]struct{}{issuerOrigin: {}}) + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + if grant.State == "reset" { + return nil + } + token, err := oauthDCRGrantToken(grant) + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + if token.Valid() { + return nil + } + reset := newOAuthDCRResetGrant(identity, registration.Generation) + value, err := encodeOAuthDCRGrant(reset, identity, registration.Generation, map[string]struct{}{issuerOrigin: {}}) + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + if _, err := store.Put(ctx, key, value, &record.Version); err != nil { + return ErrOAuthDCRRecoveryRequired + } + return nil +} + +func newOAuthDCRPending(identity oauthDCRIdentity, meta oauthDCRMetadata, previous oauthDCRRecord, reason string) (oauthDCRRecord, error) { + generation, err := randomDCRValue() + if err != nil { + return oauthDCRRecord{}, errors.New("generate OAuth DCR registration identity: failed") + } + pathValue, err := randomDCRValue() + if err != nil { + return oauthDCRRecord{}, errors.New("generate OAuth DCR callback path: failed") + } + meta.RedirectPath = oauthDCRCallbackPrefix + pathValue + pending := oauthDCRRecord{ + Schema: oauthDCRRegistrationSchema, Version: oauthDCRRegistrationVersion, Identity: identity, + Generation: generation, State: oauthDCRStatePending, AttemptStartedAt: time.Now().UTC().Format(time.RFC3339Nano), Metadata: meta, + } + if reason != "" { + pending.PreviousAttempt = &oauthDCRPreviousAttempt{Generation: previous.Generation, AttemptStartedAt: previous.AttemptStartedAt, Reason: reason} + } + pending.MetadataFingerprint = fingerprintDCRMetadata(meta) + return pending, nil +} + +func discoverDCRMetadata(ctx context.Context, resource string, opts OAuthOptions, client *http.Client) (oauthDCRMetadata, string, error) { //nolint:gocyclo // fail-closed metadata matrix is intentionally explicit. + resourceURL, _ := url.Parse(resource) + prmURL := urlOrigin(resourceURL) + "/.well-known/oauth-protected-resource" + resourceURL.EscapedPath() + prm, err := oauthex.GetProtectedResourceMetadata(ctx, prmURL, resource, client) + if err != nil { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR protected-resource metadata request failed") + } + if prm == nil || prm.Resource != resource || len(prm.AuthorizationServers) != 1 || prm.AuthorizationServers[0] != opts.Issuer || !slices.Contains(prm.ScopesSupported, oauthDCRScope) { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR protected-resource metadata is invalid") + } + issuerURL, err := validateHTTPURL("OAuth DCR issuer", opts.Issuer, !opts.allowLoopbackForTest) + if err != nil { + return oauthDCRMetadata{}, "", err + } + metadataURL := urlOrigin(issuerURL) + "/.well-known/oauth-authorization-server" + if issuerURL.EscapedPath() != "" && issuerURL.EscapedPath() != "/" { + metadataURL += issuerURL.EscapedPath() + } + as, err := oauthex.GetAuthServerMeta(ctx, metadataURL, opts.Issuer, client) + if err != nil { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR authorization-server metadata request failed") + } + if as == nil || as.Issuer != opts.Issuer { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR authorization-server metadata is invalid") + } + for field, raw := range map[string]string{"registration": as.RegistrationEndpoint, "authorization": as.AuthorizationEndpoint, "token": as.TokenEndpoint} { + u, endpointErr := validateHTTPURL("OAuth DCR "+field+" endpoint", raw, !opts.allowLoopbackForTest) + if endpointErr != nil || u.RawQuery != "" || u.Fragment != "" || urlOrigin(u) != urlOrigin(issuerURL) { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR endpoint is invalid") + } + } + if !slices.Contains(as.CodeChallengeMethodsSupported, "S256") || !slices.Contains(as.ResponseTypesSupported, "code") || !slices.Contains(as.GrantTypesSupported, "authorization_code") || !slices.Contains(as.TokenEndpointAuthMethodsSupported, "none") || !slices.Contains(as.ScopesSupported, oauthDCRScope) { + return oauthDCRMetadata{}, "", errors.New("OAuth DCR public authorization-code metadata is unsupported") + } + return oauthDCRMetadata{ + Issuer: opts.Issuer, Resource: resource, RedirectPolicy: oauthDCRRedirectPolicy, + TokenEndpointAuthMethod: "none", GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, Scopes: []string{oauthDCRScope}, + supportedScopes: slices.Clone(as.ScopesSupported), + }, as.RegistrationEndpoint, nil +} + +type dcrRegistrationResponseCapture struct { + base http.RoundTripper + issuedAt *int64 + status int + invalid bool + readFailure bool +} + +func (c *dcrRegistrationResponseCapture) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := c.base.RoundTrip(req) + if err != nil || resp == nil || resp.Body == nil { + return resp, err + } + c.status = resp.StatusCode + body, readErr := io.ReadAll(io.LimitReader(resp.Body, credentialstore.MaxValueBytes+1)) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + if readErr != nil { + c.readFailure = true + return resp, nil + } + if len(body) > credentialstore.MaxValueBytes { + c.invalid = true + return resp, nil + } + var fields map[string]json.RawMessage + if json.Unmarshal(body, &fields) != nil { + c.invalid = true + return resp, nil + } + raw, present := fields["client_id_issued_at"] + if !present { + return resp, nil + } + var issuedAt int64 + if json.Unmarshal(raw, &issuedAt) != nil { + c.invalid = true + return resp, nil + } + c.issuedAt = &issuedAt + return resp, nil +} + +func resolvePreparedDCR(ctx context.Context, resource string, opts OAuthOptions, client *http.Client) (OAuthOptions, error) { //nolint:gocyclo // registration publication keeps each failure state explicit. + if opts.Client.DCR == nil || opts.dcr != nil { + return opts, nil + } + ticket := opts.dcrTicket + if ticket == nil { + if opts.CredentialStore == nil { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + canonical, err := canonicalOAuthResource(resource) + if err != nil { + return OAuthOptions{}, err + } + identity := oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: canonical, Issuer: opts.Issuer} + meta, _, err := discoverDCRMetadata(ctx, canonical, opts, client) + if err != nil { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + return OAuthOptions{}, err + } + record, err := opts.CredentialStore.Get(ctx, key) + if errors.Is(err, credentialstore.ErrNotFound) { + return OAuthOptions{}, ErrOAuthLoginRequired + } + if err != nil { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + stored, err := decodeOAuthDCRRecordRaw(record.Value) + if err != nil { + return OAuthOptions{}, dcrRecovery(OAuthDCRRecoveryCorrupt) + } + if stored.Identity != identity { + return OAuthOptions{}, dcrRecovery(recoveryCategoryForDCRIdentityMismatch(stored.State)) + } + if stored.State != oauthDCRStateReady { + return OAuthOptions{}, dcrRecovery(OAuthDCRRecoveryCorrupt) + } + meta.RedirectPath = stored.Metadata.RedirectPath + if stored.MetadataFingerprint != fingerprintDCRMetadata(meta) || !equalDCRMetadata(stored.Metadata, meta) { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + opts = withResolvedDCR(opts, stored) + opts.RedirectURL = stored.Registration.RegisteredRedirectURI + return opts, nil + } + if !ticket.consume() || opts.CredentialStore == nil { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + redirect, err := url.Parse(opts.RedirectURL) + if err != nil || redirect.Scheme != oauthHTTPURLScheme || redirect.User != nil || redirect.Hostname() != "127.0.0.1" || !validDCRRedirectPort(redirect.Port()) || redirect.RawQuery != "" || redirect.Fragment != "" || redirect.Path != ticket.record.Metadata.RedirectPath { + return OAuthOptions{}, ErrOAuthDCRRecoveryRequired + } + request := &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{opts.RedirectURL}, TokenEndpointAuthMethod: "none", GrantTypes: append([]string(nil), ticket.record.Metadata.GrantTypes...), + ResponseTypes: []string{"code"}, ClientName: "mecatl", Scope: strings.Join(ticket.record.Metadata.Scopes, " "), + } + registerClient := *client + registerClient.CheckRedirect = func(*http.Request, []*http.Request) error { return ErrOAuthDCRRecoveryRequired } + transport := registerClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + capture := &dcrRegistrationResponseCapture{base: transport} + registerClient.Transport = capture + response, err := oauthex.RegisterClient(ctx, ticket.registrationEndpoint, request, ®isterClient) + if err != nil { + if winner, ok := adoptDCRReady(ctx, opts.CredentialStore, ticket); ok { + return withResolvedDCR(opts, winner), nil + } + if capture.invalid || (!capture.readFailure && capture.status >= http.StatusOK && capture.status < http.StatusMultipleChoices) { + return failPreparedDCR(ctx, opts, ticket, OAuthDCRRecoveryResponseInvalid) + } + return failPreparedDCR(ctx, opts, ticket, OAuthDCRRecoveryRegistrationOutcomeUnknown) + } + if capture.invalid || capture.issuedAt != nil && *capture.issuedAt < 0 || !validDCRRegistrationResponse(response, request, ticket.record.Metadata.supportedScopes) { + if winner, ok := adoptDCRReady(ctx, opts.CredentialStore, ticket); ok { + return withResolvedDCR(opts, winner), nil + } + return failPreparedDCR(ctx, opts, ticket, OAuthDCRRecoveryResponseInvalid) + } + ready := ticket.record + ready.State = oauthDCRStateReady + ready.Registration = &oauthDCRRegistration{ClientID: response.ClientID, RegisteredRedirectURI: opts.RedirectURL} + if capture.issuedAt != nil { + issued := *capture.issuedAt + ready.Registration.ClientIDIssuedAt = &issued + } + value, encodeErr := encodeOAuthDCRRecord(ready, ticket.record.Identity) + if encodeErr != nil { + return failPreparedDCR(ctx, opts, ticket, OAuthDCRRecoveryReadyPersistence) + } + if _, err = opts.CredentialStore.Put(ctx, ticket.key, value, &ticket.version); err != nil { + winner, ok := adoptDCRReady(ctx, opts.CredentialStore, ticket) + if !ok { + return failPreparedDCR(ctx, opts, ticket, OAuthDCRRecoveryReadyPersistence) + } + ready = winner + } + return withResolvedDCR(opts, ready), nil +} + +func failPreparedDCR(ctx context.Context, opts OAuthOptions, ticket *oauthDCRTicket, category OAuthDCRRecoveryCategory) (OAuthOptions, error) { + persisted := persistedDCRFailureCategory(category) + if persisted != "" { + pending := ticket.record + pending.FailureCategory = persisted + if value, err := encodeOAuthDCRRecord(pending, pending.Identity); err == nil { + recordCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), oauthDCRFailureRecordTimeout) + _, _ = opts.CredentialStore.Put(recordCtx, ticket.key, value, &ticket.version) + cancel() + } + } + return OAuthOptions{}, dcrRecovery(category) +} + +func adoptDCRReady(ctx context.Context, store credentialstore.Store, ticket *oauthDCRTicket) (oauthDCRRecord, bool) { + winner, err := store.Get(ctx, ticket.key) + if err != nil { + return oauthDCRRecord{}, false + } + stored, err := decodeOAuthDCRRecord(winner.Value, ticket.record.Identity) + if err != nil || stored.State != oauthDCRStateReady || stored.Generation != ticket.record.Generation || stored.MetadataFingerprint != ticket.record.MetadataFingerprint || !equalDCRMetadata(stored.Metadata, ticket.record.Metadata) { + return oauthDCRRecord{}, false + } + return stored, true +} + +func validDCRRegistrationResponse(response *oauthex.ClientRegistrationResponse, request *oauthex.ClientRegistrationMetadata, supportedScopes []string) bool { + if response == nil || validateSafeValue("OAuth DCR client ID", response.ClientID) != nil || response.ClientSecret != "" || response.TokenEndpointAuthMethod != "none" { + return false + } + if !response.ClientIDIssuedAt.IsZero() && response.ClientIDIssuedAt.Unix() < 0 { + return false + } + if !slices.Equal(response.RedirectURIs, request.RedirectURIs) { + return false + } + if len(response.GrantTypes) != 0 && !sameStrings(response.GrantTypes, request.GrantTypes) || len(response.ResponseTypes) != 0 && !sameStrings(response.ResponseTypes, request.ResponseTypes) { + return false + } + if response.Scope != "" && !validDCRResponseScopes(strings.Fields(response.Scope), strings.Fields(request.Scope), supportedScopes) { + return false + } + return true +} + +func validDCRResponseScopes(returned, requested, supported []string) bool { + for _, scope := range requested { + if !slices.Contains(returned, scope) { + return false + } + } + for _, scope := range returned { + if !slices.Contains(supported, scope) { + return false + } + } + return true +} + +func sameStrings(a, b []string) bool { + a = append([]string(nil), a...) + b = append([]string(nil), b...) + sort.Strings(a) + sort.Strings(b) + return slices.Equal(compactStrings(a), compactStrings(b)) +} + +func withResolvedDCR(opts OAuthOptions, record oauthDCRRecord) OAuthOptions { + opts.dcrTicket = nil + opts.dcr = &oauthDCRResolved{issuer: record.Identity.Issuer, clientID: record.Registration.ClientID, generation: record.Generation, path: record.Metadata.RedirectPath, serverName: opts.Client.DCR.ServerName} + return opts +} + +func validDCRServerName(name string) bool { + return validateSafeValue("OAuth DCR server name", name) == nil +} + +func validateDCRIdentity(identity oauthDCRIdentity) error { + for _, field := range []struct{ name, value string }{{"OAuth profile", identity.Profile}, {"OAuth principal", identity.Principal}} { + if err := validateSafeValue(field.name, field.value); err != nil { + return err + } + } + canonicalResource, err := canonicalOAuthResource(identity.Resource) + if err != nil || canonicalResource != identity.Resource { + return errors.New("OAuth DCR resource is not canonical") + } + canonicalIssuer, err := canonicalOAuthResource(identity.Issuer) + issuer, issuerErr := validateHTTPURL("OAuth DCR issuer", identity.Issuer, false) + if err != nil || issuerErr != nil || issuer.RawQuery != "" || issuer.ForceQuery || (canonicalIssuer != identity.Issuer && (issuer.Path != "" || canonicalIssuer != identity.Issuer+"/")) { + return errors.New("OAuth DCR issuer is not canonical") + } + return nil +} + +func oauthDCRLifecycleKey(serverName string) ([]byte, error) { + if !validDCRServerName(serverName) { + return nil, errors.New("OAuth DCR server name is invalid") + } + fields := []string{serverName} + framed := []byte(oauthDCRRegistrationKeyDomain) + var size [4]byte + for _, field := range fields { + if len(field) > math.MaxUint32 { + return nil, errors.New("OAuth DCR identity field is too large") + } + binary.BigEndian.PutUint32(size[:], uint32(len(field))) // #nosec G115 -- checked above. + framed = append(framed, size[:]...) + framed = append(framed, field...) + } + digest := sha256.Sum256(framed) + return digest[:], nil +} + +func encodeOAuthDCRRecord(record oauthDCRRecord, expected oauthDCRIdentity) ([]byte, error) { + if err := validateOAuthDCRRecord(record, expected); err != nil { + return nil, err + } + value, err := json.Marshal(record) + if err != nil || len(value) > credentialstore.MaxValueBytes { + return nil, errors.New("OAuth DCR registration record is invalid") + } + return value, nil +} + +func decodeOAuthDCRRecordRaw(value []byte) (oauthDCRRecord, error) { + if len(value) == 0 || len(value) > credentialstore.MaxValueBytes || !uniqueDCRJSONKeys(value) { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record is invalid") + } + decoder := json.NewDecoder(io.LimitReader(bytes.NewReader(value), credentialstore.MaxValueBytes+1)) + decoder.DisallowUnknownFields() + var record oauthDCRRecord + if err := decoder.Decode(&record); err != nil { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record is invalid") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record has trailing data") + } + if err := validateOAuthDCRRecord(record, record.Identity); err != nil { + return oauthDCRRecord{}, err + } + return record, nil +} + +func decodeOAuthDCRRecord(value []byte, expected oauthDCRIdentity) (oauthDCRRecord, error) { + if len(value) == 0 || len(value) > credentialstore.MaxValueBytes || !uniqueDCRJSONKeys(value) { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record is invalid") + } + decoder := json.NewDecoder(io.LimitReader(bytes.NewReader(value), credentialstore.MaxValueBytes+1)) + decoder.DisallowUnknownFields() + var record oauthDCRRecord + if err := decoder.Decode(&record); err != nil { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record is invalid") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return oauthDCRRecord{}, errors.New("OAuth DCR registration record is invalid") + } + if err := validateOAuthDCRRecord(record, expected); err != nil { + return oauthDCRRecord{}, err + } + return record, nil +} + +func uniqueDCRJSONKeys(value []byte) bool { + decoder := json.NewDecoder(bytes.NewReader(value)) + token, err := decoder.Token() + if err != nil || !uniqueDCRJSONValue(decoder, token) { + return false + } + _, err = decoder.Token() + return errors.Is(err, io.EOF) +} + +func uniqueDCRJSONValue(decoder *json.Decoder, token json.Token) bool { + delim, composite := token.(json.Delim) + if !composite { + return true + } + switch delim { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + key, ok := keyToken.(string) + if err != nil || !ok { + return false + } + if _, duplicate := seen[key]; duplicate { + return false + } + seen[key] = struct{}{} + valueToken, err := decoder.Token() + if err != nil || !uniqueDCRJSONValue(decoder, valueToken) { + return false + } + } + closeToken, err := decoder.Token() + return err == nil && closeToken == json.Delim('}') + case '[': + for decoder.More() { + valueToken, err := decoder.Token() + if err != nil || !uniqueDCRJSONValue(decoder, valueToken) { + return false + } + } + closeToken, err := decoder.Token() + return err == nil && closeToken == json.Delim(']') + default: + return false + } +} + +func validateOAuthDCRRecord(record oauthDCRRecord, expected oauthDCRIdentity) error { //nolint:gocyclo // strict persisted-state validation is intentionally linear. + if record.Schema != oauthDCRRegistrationSchema || record.Version != oauthDCRRegistrationVersion || record.Identity != expected || validateDCRIdentity(record.Identity) != nil { + return errors.New("OAuth DCR registration record identity is invalid") + } + if !validDCRRandom(record.Generation) || record.AttemptStartedAt == "" { + return errors.New("OAuth DCR registration attempt is invalid") + } + parsedAttempt, err := time.Parse(time.RFC3339Nano, record.AttemptStartedAt) + if err != nil || !strings.HasSuffix(record.AttemptStartedAt, "Z") || parsedAttempt.Format(time.RFC3339Nano) != record.AttemptStartedAt { + return errors.New("OAuth DCR registration attempt is invalid") + } + if record.MetadataFingerprint != fingerprintDCRMetadata(record.Metadata) || !validDCRMetadata(record.Metadata, expected) { + return errors.New("OAuth DCR registration binding is invalid") + } + if record.PreviousAttempt != nil { + previous := record.PreviousAttempt + if !validDCRRandom(previous.Generation) || previous.AttemptStartedAt == "" || previous.Reason != "explicit_retry" && previous.Reason != "explicit_reset" { + return errors.New("OAuth DCR previous registration attempt is invalid") + } + parsedPrevious, err := time.Parse(time.RFC3339Nano, previous.AttemptStartedAt) + if err != nil || !strings.HasSuffix(previous.AttemptStartedAt, "Z") || parsedPrevious.Format(time.RFC3339Nano) != previous.AttemptStartedAt { + return errors.New("OAuth DCR previous registration attempt is invalid") + } + } + switch record.State { + case oauthDCRStatePending: + if record.Registration != nil || !validPersistedDCRFailureCategory(record.FailureCategory) { + return errors.New("OAuth DCR pending record is invalid") + } + case oauthDCRStateReady: + if record.FailureCategory != "" { + return errors.New("OAuth DCR ready record contains failure category") + } + if record.Registration == nil || validateSafeValue("OAuth DCR client ID", record.Registration.ClientID) != nil || record.Registration.RegisteredRedirectURI == "" { + return errors.New("OAuth DCR ready record is invalid") + } + u, err := url.Parse(record.Registration.RegisteredRedirectURI) + if err != nil || u.Scheme != oauthHTTPURLScheme || u.User != nil || u.Hostname() != "127.0.0.1" || !validDCRRedirectPort(u.Port()) || u.Path != record.Metadata.RedirectPath || u.RawQuery != "" || u.Fragment != "" { + return errors.New("OAuth DCR registered redirect is invalid") + } + if record.Registration.ClientIDIssuedAt != nil && *record.Registration.ClientIDIssuedAt < 0 { + return errors.New("OAuth DCR issuance time is invalid") + } + default: + return errors.New("OAuth DCR registration state is invalid") + } + return nil +} + +func validDCRMetadata(meta oauthDCRMetadata, identity oauthDCRIdentity) bool { + return meta.Issuer == identity.Issuer && meta.Resource == identity.Resource && meta.RedirectPolicy == oauthDCRRedirectPolicy && validDCRCallbackPath(meta.RedirectPath) && meta.TokenEndpointAuthMethod == "none" && slices.Equal(meta.GrantTypes, []string{"authorization_code"}) && slices.Equal(meta.ResponseTypes, []string{"code"}) && slices.Equal(meta.Scopes, []string{oauthDCRScope}) +} + +func validDCRRequestedScopes(opts OAuthOptions) bool { + return !opts.RequestRefreshToken && len(opts.AllowedScopes) == 1 && opts.AllowedScopes[0] == oauthDCRScope +} + +func validDCRRedirectPort(port string) bool { + value, err := strconv.ParseUint(port, 10, 16) + return err == nil && value != 0 +} + +func equalDCRMetadata(a, b oauthDCRMetadata) bool { + return a.Issuer == b.Issuer && a.Resource == b.Resource && a.RedirectPolicy == b.RedirectPolicy && a.RedirectPath == b.RedirectPath && a.TokenEndpointAuthMethod == b.TokenEndpointAuthMethod && slices.Equal(a.GrantTypes, b.GrantTypes) && slices.Equal(a.ResponseTypes, b.ResponseTypes) && slices.Equal(a.Scopes, b.Scopes) +} + +func fingerprintDCRMetadata(meta oauthDCRMetadata) string { + value, _ := json.Marshal(meta) + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func randomDCRValue() (string, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw[:]), nil +} + +func validDCRRandom(value string) bool { + raw, err := base64.RawURLEncoding.Strict().DecodeString(value) + return err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == value +} + +func validDCRCallbackPath(value string) bool { + return strings.HasPrefix(value, oauthDCRCallbackPrefix) && validDCRRandom(strings.TrimPrefix(value, oauthDCRCallbackPrefix)) +} diff --git a/internal/adapter/mcp/oauth_dcr_acceptance_proofs_test.go b/internal/adapter/mcp/oauth_dcr_acceptance_proofs_test.go new file mode 100644 index 0000000000..4756e085f7 --- /dev/null +++ b/internal/adapter/mcp/oauth_dcr_acceptance_proofs_test.go @@ -0,0 +1,735 @@ +package mcp + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + "golang.org/x/oauth2" + + "github.com/stacklok/mecatl/internal/adapter/credentialstore" +) + +func dcrChallenge(t *testing.T, resource string) (*http.Request, *http.Response) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, resource, nil) + if err != nil { + t.Fatal(err) + } + resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"WWW-Authenticate": {`Bearer scope="openid"`}}, Body: io.NopCloser(strings.NewReader(""))} + return req, resp +} + +func TestADR_0325_DirectDCRStaleRegistrationLifecycle(t *testing.T) { + t.Run("clean and registration only", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + if _, err := NewOAuthController(context.Background(), resource, opts); !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("clean bootstrap = %v", err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + if _, err := NewOAuthController(context.Background(), resource, opts); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("registration-only restore = %v", err) + } + if fixture.registerCount != 0 || fixture.tokenCount != 0 { + t.Fatalf("registration-only state used network: %d/%d", fixture.registerCount, fixture.tokenCount) + } + }) + + t.Run("stale registration generation is fenced", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + old, oldPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + newer, newerPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration) + if err != nil { + t.Fatal(err) + } + old.RedirectURL, newer.RedirectURL = "http://127.0.0.1:49152"+oldPath, "http://127.0.0.1:49153"+newerPath + if _, err := NewOAuthController(context.Background(), resource, old); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("stale publication = %v", err) + } + winner, err := NewOAuthController(context.Background(), resource, newer) + if err != nil { + t.Fatal(err) + } + defer winner.Close() + reused, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if path != newerPath || reused.dcr == nil || reused.dcr.generation != newer.dcrTicket.record.Generation { + t.Fatalf("ready winner not retained: %q %#v", path, reused.dcr) + } + }) + + t.Run("grant only and corrupt registration fail closed", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + controller := authorizeDCRForProof(t, fixture, resource, opts) + _ = controller.Close() + key, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + record, _ := store.Get(context.Background(), key) + if err := store.Delete(context.Background(), key, record.Version); err != nil { + t.Fatal(err) + } + if _, err := NewOAuthController(context.Background(), resource, opts); !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("grant-only restore = %v", err) + } + if _, err := store.Put(context.Background(), key, []byte(`{"schema":"corrupt"}`), nil); err != nil { + t.Fatal(err) + } + if _, err := NewOAuthController(context.Background(), resource, opts); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("corrupt registration = %v", err) + } + }) + + t.Run("reset tombstone fences in-flight authorization", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + opts.Presenter = proofPresenter(fixture) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + if err := controller.state.beginAuthorization(context.Background()); err != nil { + t.Fatal(err) + } + if err := controller.state.reset(context.Background()); err != nil { + t.Fatal(err) + } + cfg := &oauth2.Config{ClientID: controller.state.registration.clientID, Endpoint: oauth2.Endpoint{TokenURL: fixture.server.URL + "/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect}, RedirectURL: prepared.RedirectURL, Scopes: []string{"openid"}} + if _, err := controller.state.newTokenSource(context.Background(), cfg, &oauth2.Token{AccessToken: "stale-canary", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)}); !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("stale authorization = %v", err) + } + if controller.state.dcrGrant.State != "reset" { + t.Fatalf("stale authorization resurrected %q", controller.state.dcrGrant.State) + } + }) + + t.Run("unsolicited refresh and expiry", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.unsolicitedRefresh = true + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err == nil { + t.Fatal("unsolicited refresh token was accepted") + } + if source, _ := controller.TokenSource(context.Background()); source != nil { + t.Fatal("unsolicited refresh token was persisted") + } + _ = controller.Close() + + fixture = newDCRMetadataFixture(t) + resource, store = fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts = fixture.options(t, store) + controller = authorizeDCRForProof(t, fixture, resource, opts) + identity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: fixture.clientID} + key, _ := oauthDCRCredentialKey(identity, controller.state.registration.generation) + record, _ := store.Get(context.Background(), key) + regKey, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + regStored, _ := store.Get(context.Background(), regKey) + regRecord, _ := decodeOAuthDCRRecord(regStored.Value, oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer}) + cfg := &oauth2.Config{ClientID: fixture.clientID, Endpoint: oauth2.Endpoint{TokenURL: fixture.server.URL + "/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect}, RedirectURL: regRecord.Registration.RegisteredRedirectURI, Scopes: []string{"openid"}} + grant := newOAuthDCRActiveGrant(identity, controller.state.registration.generation, cfg, &oauth2.Token{AccessToken: "expired", TokenType: "Bearer", Expiry: time.Now().Add(-time.Hour)}) + value, _ := encodeOAuthDCRGrant(grant, identity, controller.state.registration.generation, controller.state.origins) + if _, err := store.Put(context.Background(), key, value, &record.Version); err != nil { + t.Fatal(err) + } + _ = controller.Close() + restored, err := NewOAuthController(context.Background(), resource, opts) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + source, _ := restored.TokenSource(context.Background()) + if _, err := source.Token(); !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("expired token = %v", err) + } + if fixture.tokenCount != 1 { + t.Fatalf("expiry triggered hidden exchange/refresh: %d", fixture.tokenCount) + } + }) + + t.Run("mismatched grant is not adopted", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + controller := authorizeDCRForProof(t, fixture, resource, opts) + generation, clientID := controller.state.registration.generation, controller.state.registration.clientID + _ = controller.Close() + identity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: clientID} + key, _ := oauthDCRCredentialKey(identity, generation) + record, _ := store.Get(context.Background(), key) + mismatch := strings.Replace(string(record.Value), `"principal":"local-user"`, `"principal":"other-user"`, 1) + if _, err := store.Put(context.Background(), key, []byte(mismatch), &record.Version); err != nil { + t.Fatal(err) + } + if _, err := NewOAuthController(context.Background(), resource, opts); err == nil { + t.Fatal("cross-identity grant was adopted") + } + if fixture.registerCount != 1 || fixture.tokenCount != 1 { + t.Fatalf("mismatch triggered network: %d/%d", fixture.registerCount, fixture.tokenCount) + } + }) +} + +func proofPresenter(fixture *dcrMetadataFixture) OAuthPresenter { + return OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) +} + +func authorizeDCRForProof(t *testing.T, fixture *dcrMetadataFixture, resource string, opts OAuthOptions) *OAuthController { + t.Helper() + opts.Presenter = proofPresenter(fixture) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + return controller +} + +func TestInvariant_direct_mcp_dcr_secret_redaction(t *testing.T) { + const ( + clientID = "client-id-redaction-canary" + accessToken = "access-token-redaction-canary" + code = "authorization-code-redaction-canary" + registrationAccess = "registration-access-redaction-canary" + unsolicitedRefresh = "refresh-token-redaction-canary" + ) + fixture := newDCRMetadataFixture(t) + fixture.clientID, fixture.accessToken, fixture.registrationAccessToken = clientID, accessToken, registrationAccess + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + var presented string + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + presented = raw + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: code, State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + registrationKey, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + registration, _ := store.Get(context.Background(), registrationKey) + grantIdentity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: clientID} + grantKey, _ := oauthDCRCredentialKey(grantIdentity, controller.state.registration.generation) + grant, _ := store.Get(context.Background(), grantKey) + registrationJSON, grantJSON := string(registration.Value), string(grant.Value) + if !strings.Contains(registrationJSON, clientID) || strings.Contains(registrationJSON, accessToken) { + t.Fatal("registration projection did not contain only its intentional client identity") + } + if !strings.Contains(grantJSON, clientID) || !strings.Contains(grantJSON, accessToken) { + t.Fatal("encrypted credential projection omitted its intentional client/access credential") + } + u, _ := url.Parse(presented) + state := u.Query().Get("state") + fixture.mu.Lock() + verifier := fixture.tokenForms[0].Get("code_verifier") + fixture.mu.Unlock() + if verifier == "" { + t.Fatal("fixture did not observe the PKCE verifier") + } + for _, forbidden := range []string{code, state, presented, verifier, "code_verifier", registrationAccess, unsolicitedRefresh} { + if forbidden != "" && (strings.Contains(registrationJSON, forbidden) || strings.Contains(grantJSON, forbidden)) { + t.Fatalf("credential projection leaked transient %q", forbidden) + } + } + + bad := newDCRMetadataFixture(t) + bad.clientID, bad.unsolicitedRefresh, bad.refreshToken, bad.registrationAccessToken = clientID, true, unsolicitedRefresh, registrationAccess + badResource, badStore := bad.server.URL+"/gw/mcp", newDCRMemoryStore(t) + badOpts := bad.options(t, badStore) + badOpts.Presenter = proofPresenter(bad) + badPrepared, badPath, err := PrepareOAuthDCRLogin(context.Background(), badResource, badOpts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + badPrepared.RedirectURL = "http://127.0.0.1:49153" + badPath + badController, err := NewOAuthController(context.Background(), badResource, badPrepared) + if err != nil { + t.Fatal(err) + } + defer badController.Close() + req, resp = dcrChallenge(t, badResource) + authErr := badController.Authorize(context.Background(), req, resp) + if authErr == nil { + t.Fatal("unsolicited refresh token was accepted") + } + for _, secret := range []string{clientID, unsolicitedRefresh, registrationAccess, badResource, "fixture-code"} { + if strings.Contains(authErr.Error(), secret) { + t.Fatalf("returned error leaked %q: %v", secret, authErr) + } + } +} + +func TestDirectMCPDCR_Scenario2_ReauthorizationRedirectAndScopeBinding(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + var redirects, states, challenges []string + presenter := OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + q := u.Query() + redirects, states, challenges = append(redirects, q.Get("redirect_uri")), append(states, q.Get("state")), append(challenges, q.Get("code_challenge")) + if q.Get("scope") != "openid" || len(q["resource"]) != 1 || q.Get("resource") != resource { + t.Fatalf("scope/resource drift: %v", q) + } + return &auth.AuthorizationResult{Code: "fixture-code", State: q.Get("state"), Iss: fixture.server.URL}, nil + }) + opts := fixture.options(t, store) + opts.Presenter = presenter + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + if err := controller.ResetCredential(context.Background()); err != nil { + t.Fatal(err) + } + reused, reusedPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if reusedPath != path { + t.Fatalf("registration path changed: %q != %q", reusedPath, path) + } + reused.Presenter, reused.RedirectURL = presenter, "http://127.0.0.1:49153"+reusedPath + second, err := NewOAuthController(context.Background(), resource, reused) + if err != nil { + t.Fatal(err) + } + defer second.Close() + req, resp = dcrChallenge(t, resource) + if err := second.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + if len(redirects) != 2 || redirects[0] == redirects[1] || states[0] == states[1] || challenges[0] == challenges[1] { + t.Fatalf("reauthorization did not bind fresh port/state/PKCE: redirects=%v states=%v challenges=%v", redirects, states, challenges) + } + fixture.mu.Lock() + registrations := fixture.registerCount + fixture.mu.Unlock() + if registrations != 1 { + t.Fatalf("reauthorization registered %d clients", registrations) + } + + if err := second.ResetCredential(context.Background()); err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + fixture.rejectPortVariation = true + fixture.mu.Unlock() + rejected, rejectedPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + rejected.Presenter, rejected.RedirectURL = presenter, "http://127.0.0.1:49154"+rejectedPath + third, err := NewOAuthController(context.Background(), resource, rejected) + if err != nil { + t.Fatal(err) + } + defer third.Close() + req, resp = dcrChallenge(t, resource) + if err := third.Authorize(context.Background(), req, resp); err == nil { + t.Fatal("server rejection of redirect-port variation was hidden") + } + fixture.mu.Lock() + registrations = fixture.registerCount + fixture.mu.Unlock() + if registrations != 1 { + t.Fatalf("port rejection silently re-registered: %d", registrations) + } + key, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + record, _ := store.Get(context.Background(), key) + drifted := strings.Replace(string(record.Value), `"scopes":["openid"]`, `"scopes":["other"]`, 1) + if _, err := store.Put(context.Background(), key, []byte(drifted), &record.Version); err != nil { + t.Fatal(err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("scope/fingerprint drift = %v", err) + } +} + +type scriptedDCRStore struct { + credentialstore.Store + get func(context.Context, []byte) (credentialstore.Record, error) + put func(context.Context, []byte, []byte, *credentialstore.Version) (credentialstore.Record, error) +} + +func (s *scriptedDCRStore) Get(ctx context.Context, key []byte) (credentialstore.Record, error) { + if s.get != nil { + return s.get(ctx, key) + } + return s.Store.Get(ctx, key) +} + +func (s *scriptedDCRStore) Put(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + if s.put != nil { + return s.put(ctx, key, value, expected) + } + return s.Store.Put(ctx, key, value, expected) +} + +func TestADR_0325_DirectDCRUnknownAttemptRecovery(t *testing.T) { + t.Run("pending contender stops while winner still owns preparation", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, base := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + committed, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + store := &scriptedDCRStore{Store: base} + store.put = func(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + record, err := base.Put(ctx, key, value, expected) + if err == nil && expected == nil { + once.Do(func() { + close(committed) + <-release + }) + } + return record, err + } + opts := fixture.options(t, store) + type result struct { + opts OAuthOptions + path string + err error + } + winner := make(chan result, 1) + go func() { + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + winner <- result{opts: prepared, path: path, err: err} + }() + <-committed + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("pending contender = %v, want recovery-required", err) + } + close(release) + if got := <-winner; got.err != nil || got.opts.dcrTicket == nil || got.path == "" { + t.Fatalf("pending winner = %#v", got) + } + if fixture.registerCount != 0 { + t.Fatalf("contention issued %d registration POSTs", fixture.registerCount) + } + }) + + t.Run("unknown POST outcome is not repeated automatically", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + started, release := make(chan struct{}), make(chan struct{}) + fixture.registrationStarted, fixture.registrationRelease = started, release + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := NewOAuthController(ctx, resource, prepared) + result <- err + }() + <-started + cancel() + close(release) + if err := <-result; !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("unknown POST outcome = %v, want recovery-required", err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("ordinary retry after unknown POST = %v", err) + } + if fixture.registerCount != 1 { + t.Fatalf("unknown POST was repeated %d times", fixture.registerCount) + } + }) + + t.Run("explicit retry fences an old publication and retains one predecessor", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, base := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + store := &scriptedDCRStore{Store: base} + opts := fixture.options(t, store) + old, oldPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + oldGeneration := old.dcrTicket.record.Generation + atPublication, releasePublication := make(chan struct{}), make(chan struct{}) + var once sync.Once + store.put = func(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + record, decodeErr := decodeOAuthDCRRecord(value, old.dcrTicket.record.Identity) + if decodeErr == nil && record.State == oauthDCRStateReady && record.Generation == oldGeneration { + once.Do(func() { + close(atPublication) + <-releasePublication + }) + } + return base.Put(ctx, key, value, expected) + } + old.RedirectURL = "http://127.0.0.1:49152" + oldPath + oldResult := make(chan error, 1) + go func() { + _, err := NewOAuthController(context.Background(), resource, old) + oldResult <- err + }() + <-atPublication + retry, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration) + if err != nil { + t.Fatal(err) + } + close(releasePublication) + if err := <-oldResult; !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("stale publication = %v, want recovery-required", err) + } + latest, latestPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration) + if err != nil { + t.Fatal(err) + } + latest.RedirectURL = "http://127.0.0.1:49153" + latestPath + winner, err := NewOAuthController(context.Background(), resource, latest) + if err != nil { + t.Fatal(err) + } + _ = winner.Close() + + identity := latest.dcrTicket.record.Identity + key, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + stored, err := base.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + ready, err := decodeOAuthDCRRecord(stored.Value, identity) + if err != nil { + t.Fatal(err) + } + if ready.Generation != latest.dcrTicket.record.Generation || ready.PreviousAttempt == nil || ready.PreviousAttempt.Generation != retry.dcrTicket.record.Generation || ready.PreviousAttempt.Reason != "explicit_retry" { + t.Fatalf("retry winner/evidence = %#v", ready) + } + if ready.PreviousAttempt.Generation == oldGeneration || strings.Count(string(stored.Value), `"previous_attempt"`) != 1 { + t.Fatalf("previous-attempt evidence is not bounded: %s", stored.Value) + } + }) + + t.Run("ready winner is adopted over a late registration error", func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + started, release := make(chan struct{}), make(chan struct{}) + fixture.registrationStarted, fixture.registrationRelease, fixture.registrationStatus = started, release, http.StatusGatewayTimeout + result := make(chan struct { + controller *OAuthController + err error + }, 1) + go func() { + controller, err := NewOAuthController(context.Background(), resource, prepared) + result <- struct { + controller *OAuthController + err error + }{controller: controller, err: err} + }() + <-started + identity := prepared.dcrTicket.record.Identity + key, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + pending, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + ready := prepared.dcrTicket.record + ready.State = oauthDCRStateReady + ready.Registration = &oauthDCRRegistration{ClientID: fixture.clientID, RegisteredRedirectURI: prepared.RedirectURL} + value, err := encodeOAuthDCRRecord(ready, identity) + if err != nil { + t.Fatal(err) + } + if _, err := store.Put(context.Background(), key, value, &pending.Version); err != nil { + t.Fatal(err) + } + close(release) + got := <-result + if got.err != nil || got.controller == nil { + t.Fatalf("late error did not adopt ready winner: controller=%v err=%v", got.controller, got.err) + } + _ = got.controller.Close() + if fixture.registerCount != 1 { + t.Fatalf("late error caused %d registration POSTs", fixture.registerCount) + } + }) + + for _, tc := range []struct { + name string + commitPut bool + wantOK bool + }{ + {name: "reported save failure with committed ready winner", commitPut: true, wantOK: true}, + {name: "uncommitted save ambiguity remains recovery required", commitPut: false, wantOK: false}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, base := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + store := &scriptedDCRStore{Store: base} + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + store.put = func(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + record, decodeErr := decodeOAuthDCRRecord(value, prepared.dcrTicket.record.Identity) + if decodeErr == nil && record.State == oauthDCRStateReady { + if tc.commitPut { + if _, err := base.Put(ctx, key, value, expected); err != nil { + return credentialstore.Record{}, err + } + } + return credentialstore.Record{}, credentialstore.ErrUnavailable + } + return base.Put(ctx, key, value, expected) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if tc.wantOK { + if err != nil || controller == nil { + t.Fatalf("committed winner was not adopted: controller=%v err=%v", controller, err) + } + _ = controller.Close() + return + } + if !errors.Is(err, ErrOAuthDCRRecoveryRequired) || controller != nil { + t.Fatalf("uncommitted ambiguity = controller=%v err=%v", controller, err) + } + identity := prepared.dcrTicket.record.Identity + key, _ := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + persisted, getErr := base.Get(context.Background(), key) + if getErr != nil { + t.Fatal(getErr) + } + pending, decodeErr := decodeOAuthDCRRecord(persisted.Value, identity) + if decodeErr != nil || pending.State != oauthDCRStatePending { + t.Fatalf("uncommitted ambiguity lost pending evidence: %#v %v", pending, decodeErr) + } + }) + } +} + +func TestDirectMCPDCR_Scenario3_RestartIdentityMismatchFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(string) string + }{ + {name: "registration identity", mutate: func(value string) string { + return strings.Replace(value, `"principal":"local-user"`, `"principal":"other-user"`, 1) + }}, + {name: "grant cross identity", mutate: func(value string) string { + return strings.Replace(value, `"principal":"local-user"`, `"principal":"other-user"`, 1) + }}, + {name: "grant generation", mutate: func(value string) string { + replacement, _ := randomDCRValue() + start := strings.Index(value, `"registration_generation":"`) + if start < 0 { + return value + } + start += len(`"registration_generation":"`) + end := start + strings.Index(value[start:], `"`) + return value[:start] + replacement + value[end:] + }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + controller := authorizeDCRForProof(t, fixture, resource, opts) + generation, clientID := controller.state.registration.generation, controller.state.registration.clientID + _ = controller.Close() + var key []byte + if tc.name == "registration identity" { + key, _ = oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + } else { + grantIdentity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: clientID} + key, _ = oauthDCRCredentialKey(grantIdentity, generation) + } + record, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + mutated := tc.mutate(string(record.Value)) + if mutated == string(record.Value) { + t.Fatal("fixture did not mutate persisted identity") + } + if _, err := store.Put(context.Background(), key, []byte(mutated), &record.Version); err != nil { + t.Fatal(err) + } + if _, err := NewOAuthController(context.Background(), resource, opts); err == nil { + t.Fatal("mismatched persisted identity was restored") + } + fixture.mu.Lock() + registrations, tokens := fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if registrations != 1 || tokens != 1 { + t.Fatalf("mismatched restart used registration/token endpoint: %d/%d", registrations, tokens) + } + }) + } +} diff --git a/internal/adapter/mcp/oauth_dcr_grant.go b/internal/adapter/mcp/oauth_dcr_grant.go new file mode 100644 index 0000000000..94698e1897 --- /dev/null +++ b/internal/adapter/mcp/oauth_dcr_grant.go @@ -0,0 +1,169 @@ +package mcp + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "io" + "math" + "net/url" + "sort" + "time" + + "golang.org/x/oauth2" + + "github.com/stacklok/mecatl/internal/adapter/credentialstore" +) + +const ( + oauthDCRGrantVersion = 2 + oauthDCRGrantKeyDomain = "mecatl/mcp/oauth-dcr-credential-key/v1" +) + +type oauthDCRTokenEnvelope struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Expiry string `json:"expiry,omitempty"` +} + +type oauthDCRAuthorizationEnvelope struct { + TokenURL string `json:"token_url"` + AuthStyle int `json:"auth_style"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes"` +} + +type oauthDCRGrantEnvelope struct { + Schema string `json:"schema"` + Version int `json:"version"` + Identity oauthCredentialIdentity `json:"identity"` + RegistrationGeneration string `json:"registration_generation"` + State string `json:"state"` + Token *oauthDCRTokenEnvelope `json:"token,omitempty"` + Authorization *oauthDCRAuthorizationEnvelope `json:"authorization,omitempty"` +} + +func oauthDCRCredentialKey(identity oauthCredentialIdentity, generation string) ([]byte, error) { + if err := validateOAuthIdentity(identity); err != nil || identity.ClientKind != oauthDCRClientKind || !validDCRRandom(generation) { + return nil, errors.New("OAuth DCR credential identity is invalid") + } + fields := []string{identity.Profile, identity.Principal, identity.Resource, identity.Issuer, oauthDCRClientKind, identity.ClientID, generation} + framed := []byte(oauthDCRGrantKeyDomain) + var size [4]byte + for _, field := range fields { + if len(field) > math.MaxUint32 { + return nil, errors.New("OAuth DCR credential identity field is too large") + } + binary.BigEndian.PutUint32(size[:], uint32(len(field))) // #nosec G115 -- checked above. + framed = append(framed, size[:]...) + framed = append(framed, field...) + } + digest := sha256.Sum256(framed) + return digest[:], nil +} + +func newOAuthDCRResetGrant(identity oauthCredentialIdentity, generation string) oauthDCRGrantEnvelope { + return oauthDCRGrantEnvelope{Schema: oauthCredentialSchema, Version: oauthDCRGrantVersion, Identity: identity, RegistrationGeneration: generation, State: "reset"} +} + +func newOAuthDCRActiveGrant(identity oauthCredentialIdentity, generation string, cfg *oauth2.Config, token *oauth2.Token) oauthDCRGrantEnvelope { + expiry := "" + if !token.Expiry.IsZero() { + expiry = token.Expiry.UTC().Format(time.RFC3339Nano) + } + return oauthDCRGrantEnvelope{ + Schema: oauthCredentialSchema, Version: oauthDCRGrantVersion, Identity: identity, RegistrationGeneration: generation, State: "active", + Token: &oauthDCRTokenEnvelope{AccessToken: token.AccessToken, TokenType: token.TokenType, Expiry: expiry}, + Authorization: &oauthDCRAuthorizationEnvelope{TokenURL: cfg.Endpoint.TokenURL, AuthStyle: int(oauth2.AuthStyleInParams), RedirectURL: cfg.RedirectURL, Scopes: []string{oauthDCRScope}}, + } +} + +func encodeOAuthDCRGrant(grant oauthDCRGrantEnvelope, expected oauthCredentialIdentity, generation string, origins map[string]struct{}) ([]byte, error) { + if err := validateOAuthDCRGrant(grant, expected, generation, origins); err != nil { + return nil, err + } + value, err := json.Marshal(grant) + if err != nil || len(value) > credentialstore.MaxValueBytes { + return nil, errors.New("OAuth DCR grant is invalid") + } + return value, nil +} + +func decodeOAuthDCRGrant(value []byte, expected oauthCredentialIdentity, generation string, origins map[string]struct{}) (oauthDCRGrantEnvelope, error) { + if len(value) == 0 || len(value) > credentialstore.MaxValueBytes || !uniqueDCRJSONKeys(value) { + return oauthDCRGrantEnvelope{}, errors.New("OAuth DCR grant is invalid") + } + decoder := json.NewDecoder(io.LimitReader(bytes.NewReader(value), credentialstore.MaxValueBytes+1)) + decoder.DisallowUnknownFields() + var grant oauthDCRGrantEnvelope + if err := decoder.Decode(&grant); err != nil { + return oauthDCRGrantEnvelope{}, errors.New("OAuth DCR grant is invalid") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return oauthDCRGrantEnvelope{}, errors.New("OAuth DCR grant has trailing data") + } + if err := validateOAuthDCRGrant(grant, expected, generation, origins); err != nil { + return oauthDCRGrantEnvelope{}, err + } + return grant, nil +} + +func validateOAuthDCRGrant(grant oauthDCRGrantEnvelope, expected oauthCredentialIdentity, generation string, origins map[string]struct{}) error { //nolint:gocyclo // strict persisted-state validation is intentionally linear. + if grant.Schema != oauthCredentialSchema || grant.Version != oauthDCRGrantVersion || grant.Identity != expected || grant.RegistrationGeneration != generation || !validDCRRandom(generation) { + return errors.New("OAuth DCR grant identity is invalid") + } + switch grant.State { + case "reset": + if grant.Token != nil || grant.Authorization != nil { + return errors.New("OAuth DCR reset grant contains credential data") + } + return nil + case "active": + if grant.Token == nil || grant.Authorization == nil { + return errors.New("OAuth DCR active grant is incomplete") + } + default: + return errors.New("OAuth DCR grant state is invalid") + } + if validateSafeValue("OAuth DCR access token", grant.Token.AccessToken) != nil || validateSafeValue("OAuth DCR token type", grant.Token.TokenType) != nil { + return errors.New("OAuth DCR token is invalid") + } + if grant.Token.Expiry != "" { + if _, err := time.Parse(time.RFC3339Nano, grant.Token.Expiry); err != nil { + return errors.New("OAuth DCR token expiry is invalid") + } + } + auth := grant.Authorization + tokenURL, err := validateHTTPURL("OAuth DCR token URL", auth.TokenURL, false) + if err != nil || tokenURL.RawQuery != "" || tokenURL.Fragment != "" { + return errors.New("OAuth DCR token authorization is invalid") + } + if _, ok := origins[urlOrigin(tokenURL)]; !ok || oauth2.AuthStyle(auth.AuthStyle) != oauth2.AuthStyleInParams { + return errors.New("OAuth DCR token authorization is invalid") + } + redirect, err := url.Parse(auth.RedirectURL) + if err != nil || redirect.Scheme != oauthHTTPURLScheme || redirect.User != nil || redirect.Hostname() != "127.0.0.1" || !validDCRRedirectPort(redirect.Port()) || !validDCRCallbackPath(redirect.Path) || redirect.RawQuery != "" || redirect.Fragment != "" { + return errors.New("OAuth DCR redirect is invalid") + } + if !sort.StringsAreSorted(auth.Scopes) || len(auth.Scopes) != 1 || auth.Scopes[0] != oauthDCRScope { + return errors.New("OAuth DCR scopes are invalid") + } + return nil +} + +func oauthDCRGrantToken(grant oauthDCRGrantEnvelope) (*oauth2.Token, error) { + if grant.State != "active" || grant.Token == nil { + return nil, ErrOAuthLoginRequired + } + var expiry time.Time + var err error + if grant.Token.Expiry != "" { + expiry, err = time.Parse(time.RFC3339Nano, grant.Token.Expiry) + if err != nil { + return nil, errors.New("OAuth DCR token expiry is invalid") + } + } + return &oauth2.Token{AccessToken: grant.Token.AccessToken, TokenType: grant.Token.TokenType, Expiry: expiry}, nil +} diff --git a/internal/adapter/mcp/oauth_dcr_test.go b/internal/adapter/mcp/oauth_dcr_test.go new file mode 100644 index 0000000000..b1a71284c3 --- /dev/null +++ b/internal/adapter/mcp/oauth_dcr_test.go @@ -0,0 +1,1712 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" + + "github.com/stacklok/mecatl/internal/adapter/credentialstore" +) + +type dcrMetadataFixture struct { + mu sync.Mutex + server *httptest.Server + registration string + issuerOverride string + resourceValue string + authServers []string + codeMethods []string + grantTypes []string + authMethods []string + responseTypes []string + scopes []string + registerCount int + metadataCount int + tokenCount int + tokenForms []url.Values + registrationScope string + clientID string + clientIDIssuedAt *int64 + cosmeticSuffix string + unsolicitedRefresh bool + accessToken string + refreshToken string + registrationAccessToken string + rejectPortVariation bool + registeredRedirect string + registrationStarted chan struct{} + registrationRelease <-chan struct{} + metadataStarted chan struct{} + metadataRelease <-chan struct{} + registrationStatus int + registrationScopes []string +} + +func newDCRMetadataFixture(t *testing.T) *dcrMetadataFixture { + t.Helper() + f := &dcrMetadataFixture{ + codeMethods: []string{"S256"}, grantTypes: []string{"authorization_code", "refresh_token"}, + authMethods: []string{"none"}, responseTypes: []string{"code"}, scopes: []string{"openid", "offline_access"}, clientID: "public-client", + } + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + issuer := f.server.URL + issuerValue := issuer + if f.issuerOverride != "" { + issuerValue = f.issuerOverride + } + switch { + case strings.Contains(r.URL.Path, "oauth-protected-resource"): + f.metadataCount++ + if f.metadataStarted != nil { + close(f.metadataStarted) + f.metadataStarted = nil + } + if f.metadataRelease != nil { + <-f.metadataRelease + } + resourceValue := issuer + "/gw/mcp" + if f.resourceValue != "" { + resourceValue = f.resourceValue + } + authServers := f.authServers + if authServers == nil { + authServers = []string{issuer} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(oauthex.ProtectedResourceMetadata{Resource: resourceValue, AuthorizationServers: authServers, ScopesSupported: []string{"openid"}}) + case strings.Contains(r.URL.Path, ".well-known/oauth-authorization-server") || strings.Contains(r.URL.Path, ".well-known/openid-configuration"): + f.metadataCount++ + w.Header().Set("Content-Type", "application/json") + registration := f.registration + if registration == "" { + registration = issuer + "/oauth/register" + } + _ = json.NewEncoder(w).Encode(oauthex.AuthServerMeta{ + Issuer: issuerValue, AuthorizationEndpoint: issuer + "/oauth/authorize" + f.cosmeticSuffix, + TokenEndpoint: issuer + "/oauth/token", RegistrationEndpoint: registration, + ScopesSupported: f.scopes, ResponseTypesSupported: f.responseTypes, + GrantTypesSupported: f.grantTypes, TokenEndpointAuthMethodsSupported: f.authMethods, + CodeChallengeMethodsSupported: f.codeMethods, + }) + case r.URL.Path == "/oauth/register" || r.URL.Path == "/oauth/register-v2": + w.Header().Set("Content-Type", "application/json") + f.registerCount++ + if f.registrationStarted != nil { + close(f.registrationStarted) + f.registrationStarted = nil + } + if f.registrationRelease != nil { + <-f.registrationRelease + } + if f.registrationStatus != 0 { + http.Error(w, `{"error":"registration_outcome_unknown"}`, f.registrationStatus) + return + } + var request oauthex.ClientRegistrationMetadata + _ = json.NewDecoder(r.Body).Decode(&request) + f.registrationScope = request.Scope + if len(request.RedirectURIs) == 1 { + f.registeredRedirect = request.RedirectURIs[0] + } + responseScope := request.Scope + if f.registrationScopes != nil { + responseScope = strings.Join(f.registrationScopes, " ") + } + response := map[string]any{ + "client_id": f.clientID, "token_endpoint_auth_method": "none", "redirect_uris": request.RedirectURIs, + "grant_types": request.GrantTypes, "response_types": request.ResponseTypes, "scope": responseScope, + } + if f.clientIDIssuedAt != nil { + response["client_id_issued_at"] = *f.clientIDIssuedAt + } + if f.registrationAccessToken != "" { + response["registration_access_token"] = f.registrationAccessToken + } + _ = json.NewEncoder(w).Encode(response) + case r.URL.Path == "/oauth/token": + f.tokenCount++ + _ = r.ParseForm() + f.tokenForms = append(f.tokenForms, r.PostForm) + if f.rejectPortVariation && r.PostForm.Get("redirect_uri") != f.registeredRedirect { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + access := f.accessToken + if access == "" { + access = "dcr-access" + } + response := map[string]any{"access_token": access, "token_type": "Bearer", "expires_in": 3600, "scope": "openid"} + if f.unsolicitedRefresh { + refresh := f.refreshToken + if refresh == "" { + refresh = "unsolicited-refresh" + } + response["refresh_token"] = refresh + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(f.server.Close) + return f +} + +func (f *dcrMetadataFixture) options(t *testing.T, store credentialstore.Store) OAuthOptions { + t.Helper() + opts := OAuthOptions{ + Subject: OAuthSubject{Profile: "connector", Principal: "local-user"}, Issuer: f.server.URL, + Client: OAuthClientConfig{DCR: &OAuthDCRConfig{ServerName: "connector"}}, CredentialStore: store, + AllowedScopes: []string{"openid"}, + } + AllowOAuthLoopbackForTest(t, &opts) + return opts +} + +func TestDCRRegistrationResponseScopesMayExpandWithinAdvertisedSet(t *testing.T) { + request := &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"http://127.0.0.1:49152/oauth/callback/test"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + Scope: "openid", + } + response := &oauthex.ClientRegistrationResponse{ + ClientRegistrationMetadata: oauthex.ClientRegistrationMetadata{ + RedirectURIs: request.RedirectURIs, + TokenEndpointAuthMethod: "none", + GrantTypes: request.GrantTypes, + ResponseTypes: request.ResponseTypes, + Scope: "openid offline_access", + }, + ClientID: "public-client", + } + + if !validDCRRegistrationResponse(response, request, []string{"openid", "offline_access"}) { + t.Fatal("advertised server-added scope was rejected") + } + if validDCRRegistrationResponse(response, request, []string{"openid"}) { + t.Fatal("unadvertised server-added scope was accepted") + } + response.Scope = "offline_access" + if validDCRRegistrationResponse(response, request, []string{"openid", "offline_access"}) { + t.Fatal("response missing the requested scope was accepted") + } +} + +func TestOAuthDCRAcceptsAdvertisedServerAddedRegistrationScope(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.registrationScopes = []string{"openid", "offline_access"} + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + var presented string + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + presented = raw + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatalf("NewOAuthController() rejected advertised scope expansion: %v", err) + } + defer controller.Close() + + fixture.mu.Lock() + registrationScope := fixture.registrationScope + fixture.mu.Unlock() + if registrationScope != "openid" { + t.Fatalf("registration requested scope %q, want openid", registrationScope) + } + identity := oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer} + registrationKey, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + storedRegistration, err := store.Get(context.Background(), registrationKey) + if err != nil { + t.Fatal(err) + } + registration, err := decodeOAuthDCRRecord(storedRegistration.Value, identity) + if err != nil { + t.Fatal(err) + } + if len(registration.Metadata.Scopes) != 1 || registration.Metadata.Scopes[0] != "openid" { + t.Fatalf("durable registration scopes = %v, want [openid]", registration.Metadata.Scopes) + } + + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + u, err := url.Parse(presented) + if err != nil { + t.Fatal(err) + } + query := u.Query() + if query.Get("scope") != "openid" || len(query["scope"]) != 1 || query.Get("resource") != resource || len(query["resource"]) != 1 { + t.Fatalf("final authorization request = %v", query) + } + grantIdentity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: fixture.clientID} + grantKey, err := oauthDCRCredentialKey(grantIdentity, registration.Generation) + if err != nil { + t.Fatal(err) + } + storedGrant, err := store.Get(context.Background(), grantKey) + if err != nil { + t.Fatal(err) + } + grant, err := decodeOAuthDCRGrant(storedGrant.Value, grantIdentity, registration.Generation, controller.state.origins) + if err != nil { + t.Fatal(err) + } + if grant.Authorization == nil || len(grant.Authorization.Scopes) != 1 || grant.Authorization.Scopes[0] != "openid" { + t.Fatalf("durable DCR grant scopes = %#v, want [openid]", grant.Authorization) + } +} + +func TestOAuthDCRMinimalPublicAuthorizationCodeMetadataSucceedsAndReusesReadyRecord(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.grantTypes = []string{"authorization_code"} + fixture.authMethods = []string{"none"} + fixture.responseTypes = []string{"code"} + fixture.codeMethods = []string{"S256"} + fixture.scopes = []string{"openid"} + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + if err := controller.Close(); err != nil { + t.Fatal(err) + } + reused, reusedPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if reusedPath != path || reused.dcr == nil { + t.Fatalf("ready record was not reused: path=%q dcr=%#v", reusedPath, reused.dcr) + } + fixture.mu.Lock() + registrations := fixture.registerCount + fixture.mu.Unlock() + if registrations != 1 { + t.Fatalf("registrations = %d, want 1", registrations) + } +} + +func TestValidateDCRAuthorizationURL(t *testing.T) { + const resource = "https://connector.example/gw/mcp" + valid := "https://issuer.example/authorize?scope=openid&resource=https%3A%2F%2Fconnector.example%2Fgw%2Fmcp" + for _, tc := range []struct { + name string + raw string + ok bool + }{ + {name: "exact", raw: valid, ok: true}, + {name: "missing scope", raw: strings.Replace(valid, "scope=openid&", "", 1)}, + {name: "challenge scope union", raw: strings.Replace(valid, "scope=openid", "scope=openid+admin", 1)}, + {name: "duplicate scope", raw: valid + "&scope=openid"}, + {name: "missing resource", raw: strings.Split(valid, "&resource=")[0]}, + {name: "wrong resource", raw: strings.Replace(valid, url.QueryEscape(resource), url.QueryEscape("https://connector.example/mcp"), 1)}, + {name: "duplicate resource", raw: valid + "&resource=" + url.QueryEscape(resource)}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateDCRAuthorizationURL(tc.raw, resource) + if (err == nil) != tc.ok { + t.Fatalf("validateDCRAuthorizationURL() error = %v, want success %t", err, tc.ok) + } + }) + } +} + +func TestOAuthControllerValidatesDCRAuthorizationBeforeCallingPresenter(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + presented := 0 + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(context.Context, string) (*auth.AuthorizationResult, error) { + presented++ + return &auth.AuthorizationResult{}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + + invalid := fixture.server.URL + "/oauth/authorize?scope=openid+admin&resource=" + url.QueryEscape(resource) + if _, err := controller.presentAuthorization(context.Background(), &auth.AuthorizationArgs{URL: invalid}); err == nil { + t.Fatal("scope-union authorization URL was accepted") + } + if presented != 0 { + t.Fatalf("invalid DCR URL reached presenter %d times", presented) + } + valid := fixture.server.URL + "/oauth/authorize?scope=openid&resource=" + url.QueryEscape(resource) + if _, err := controller.presentAuthorization(context.Background(), &auth.AuthorizationArgs{URL: valid}); err != nil { + t.Fatalf("valid DCR authorization URL: %v", err) + } + if presented != 1 { + t.Fatalf("valid DCR URL reached presenter %d times", presented) + } + + nonDCRCalls := 0 + nonDCR := newControllerForTest(t, OAuthPresenterFunc(func(context.Context, string) (*auth.AuthorizationResult, error) { + nonDCRCalls++ + return &auth.AuthorizationResult{}, nil + })) + if _, err := nonDCR.presentAuthorization(context.Background(), &auth.AuthorizationArgs{URL: "https://issuer.example/authorize?scope=openid+admin&resource=" + url.QueryEscape(resource)}); err != nil { + t.Fatalf("non-DCR presenter was incorrectly constrained: %v", err) + } + if nonDCRCalls != 1 { + t.Fatalf("non-DCR presenter calls = %d, want 1", nonDCRCalls) + } +} + +func TestNewOAuthControllerRejectsInvalidDCRLocalAuthorityBeforeDiscovery(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*OAuthOptions) + }{ + {name: "refresh token", mutate: func(opts *OAuthOptions) { opts.RequestRefreshToken = true }}, + {name: "broader scopes", mutate: func(opts *OAuthOptions) { opts.AllowedScopes = []string{"openid", "offline_access"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + opts := fixture.options(t, newDCRMemoryStore(t)) + tc.mutate(&opts) + if _, err := NewOAuthController(context.Background(), fixture.server.URL+"/gw/mcp", opts); err == nil { + t.Fatal("invalid DCR local authority was accepted") + } + fixture.mu.Lock() + metadata, registrations := fixture.metadataCount, fixture.registerCount + fixture.mu.Unlock() + if metadata != 0 || registrations != 0 { + t.Fatalf("invalid DCR configuration used metadata/registration network: %d/%d", metadata, registrations) + } + }) + } +} + +func TestADR_0325_DCRPersistedFormatsRejectMalformedRecords(t *testing.T) { + generation := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + path := oauthDCRCallbackPrefix + base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) + identity := oauthDCRIdentity{Profile: "connector", Principal: "local-user", Resource: "https://connector.example/gw/mcp", Issuer: "https://issuer.example"} + metadata := oauthDCRMetadata{ + Issuer: identity.Issuer, Resource: identity.Resource, RedirectPolicy: oauthDCRRedirectPolicy, + RedirectPath: path, TokenEndpointAuthMethod: "none", GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, Scopes: []string{"openid"}, + } + registration := oauthDCRRecord{ + Schema: oauthDCRRegistrationSchema, Version: oauthDCRRegistrationVersion, Identity: identity, + Generation: generation, State: oauthDCRStateReady, AttemptStartedAt: "2026-09-10T10:00:00Z", + Metadata: metadata, MetadataFingerprint: fingerprintDCRMetadata(metadata), + Registration: &oauthDCRRegistration{ClientID: "public-client", RegisteredRedirectURI: "http://127.0.0.1:49152" + path}, + } + validRegistration, err := json.Marshal(registration) + if err != nil { + t.Fatal(err) + } + registrationJSON := func(mutate func(*oauthDCRRecord)) []byte { + candidate := registration + candidate.Metadata = registration.Metadata + candidate.Registration = &oauthDCRRegistration{ClientID: registration.Registration.ClientID, RegisteredRedirectURI: registration.Registration.RegisteredRedirectURI} + mutate(&candidate) + value, marshalErr := json.Marshal(candidate) + if marshalErr != nil { + t.Fatal(marshalErr) + } + return value + } + registrationCases := map[string][]byte{ + "duplicate key": append([]byte(`{"version":1,`), validRegistration[1:]...), + "unknown key": append(validRegistration[:len(validRegistration)-1], []byte(`,"unknown":true}`)...), + "trailing JSON": append(append([]byte(nil), validRegistration...), []byte(` {}`)...), + "oversized value": bytes.Repeat([]byte{'x'}, credentialstore.MaxValueBytes+1), + "malformed generation": registrationJSON(func(record *oauthDCRRecord) { record.Generation = "not-base64url" }), + "malformed callback path": registrationJSON(func(record *oauthDCRRecord) { + record.Metadata.RedirectPath = "/oauth/callback/not-base64url" + record.MetadataFingerprint = fingerprintDCRMetadata(record.Metadata) + record.Registration.RegisteredRedirectURI = "http://127.0.0.1:49152" + record.Metadata.RedirectPath + }), + "non-UTC timestamp": registrationJSON(func(record *oauthDCRRecord) { record.AttemptStartedAt = "2026-09-10T12:00:00+02:00" }), + "unsupported state": registrationJSON(func(record *oauthDCRRecord) { record.State = "retired" }), + "unsupported version": registrationJSON(func(record *oauthDCRRecord) { record.Version++ }), + "metadata fingerprint mismatch": registrationJSON(func(record *oauthDCRRecord) { record.MetadataFingerprint = strings.Repeat("0", 64) }), + "metadata identity mismatch": registrationJSON(func(record *oauthDCRRecord) { + record.Metadata.Issuer = "https://other.example" + record.MetadataFingerprint = fingerprintDCRMetadata(record.Metadata) + }), + "record identity mismatch": registrationJSON(func(record *oauthDCRRecord) { record.Identity.Principal = "other-user" }), + "unknown failure category": registrationJSON(func(record *oauthDCRRecord) { + record.State = oauthDCRStatePending + record.Registration = nil + record.FailureCategory = "provider said secret detail" + }), + "ready failure category": registrationJSON(func(record *oauthDCRRecord) { + record.FailureCategory = oauthDCRFailureOutcomeUnknown + }), + } + for name, value := range registrationCases { + t.Run("registration/"+name, func(t *testing.T) { + if _, decodeErr := decodeOAuthDCRRecord(value, identity); decodeErr == nil { + t.Fatal("invalid registration record was accepted") + } + }) + } + + grantIdentity := oauthCredentialIdentity{ + Profile: identity.Profile, Principal: identity.Principal, Resource: identity.Resource, + Issuer: identity.Issuer, ClientKind: oauthDCRClientKind, ClientID: "public-client", + } + config := &oauth2.Config{Endpoint: oauth2.Endpoint{TokenURL: identity.Issuer + "/oauth/token"}, RedirectURL: registration.Registration.RegisteredRedirectURI} + grant := newOAuthDCRActiveGrant(grantIdentity, generation, config, &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Date(2026, 9, 10, 11, 0, 0, 0, time.UTC)}) + validGrant, err := json.Marshal(grant) + if err != nil { + t.Fatal(err) + } + grantJSON := func(mutate func(*oauthDCRGrantEnvelope)) []byte { + candidate := grant + token, authorization := *grant.Token, *grant.Authorization + candidate.Token, candidate.Authorization = &token, &authorization + mutate(&candidate) + value, marshalErr := json.Marshal(candidate) + if marshalErr != nil { + t.Fatal(marshalErr) + } + return value + } + origins := map[string]struct{}{identity.Issuer: {}} + grantCases := map[string][]byte{ + "duplicate key": append([]byte(`{"version":2,`), validGrant[1:]...), + "unknown key": append(validGrant[:len(validGrant)-1], []byte(`,"unknown":true}`)...), + "trailing JSON": append(append([]byte(nil), validGrant...), []byte(` []`)...), + "oversized value": bytes.Repeat([]byte{'x'}, credentialstore.MaxValueBytes+1), + "malformed generation": grantJSON(func(record *oauthDCRGrantEnvelope) { record.RegistrationGeneration = "not-base64url" }), + "malformed timestamp": grantJSON(func(record *oauthDCRGrantEnvelope) { record.Token.Expiry = "tomorrow" }), + "unsupported state": grantJSON(func(record *oauthDCRGrantEnvelope) { record.State = "expired" }), + "unsupported version": grantJSON(func(record *oauthDCRGrantEnvelope) { record.Version++ }), + "identity mismatch": grantJSON(func(record *oauthDCRGrantEnvelope) { record.Identity.ClientID = "other-client" }), + "generation mismatch": grantJSON(func(record *oauthDCRGrantEnvelope) { + record.RegistrationGeneration = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) + }), + "off-origin token endpoint": grantJSON(func(record *oauthDCRGrantEnvelope) { + record.Authorization.TokenURL = "https://evil.example/oauth/token" + }), + "invalid token endpoint": grantJSON(func(record *oauthDCRGrantEnvelope) { + record.Authorization.TokenURL = "https://issuer.example/oauth/token?secret=value" + }), + } + for name, value := range grantCases { + t.Run("grant/"+name, func(t *testing.T) { + if _, decodeErr := decodeOAuthDCRGrant(value, grantIdentity, generation, origins); decodeErr == nil { + t.Fatal("invalid grant record was accepted") + } + }) + } +} + +func newDCRMemoryStore(t *testing.T) credentialstore.Store { + t.Helper() + store, err := credentialstore.NewMemoryBackend().Open("mecatl-mcp-oauth") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func TestOAuthDCRRecoveryCategoriesAreSafeAndImmediate(t *testing.T) { + if OAuthDCRRecoveryResetRequired != 6 || OAuthDCRRecoveryPendingIdentityMismatch != 7 { + t.Fatalf("DCR recovery category values changed: reset=%d pending identity mismatch=%d", OAuthDCRRecoveryResetRequired, OAuthDCRRecoveryPendingIdentityMismatch) + } + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryPending { + t.Fatalf("plain pending recovery = %v, category %v", err, OAuthDCRRecoveryCategoryOf(err)) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginResetRegistration); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryPending { + t.Fatalf("reset plain pending recovery = %v, category %v", err, OAuthDCRRecoveryCategoryOf(err)) + } + + fixture.registrationStatus = http.StatusServiceUnavailable + prepared.RedirectURL = "http://127.0.0.1:49152" + path + if controller, registerErr := NewOAuthController(context.Background(), resource, prepared); controller != nil || + !errors.Is(registerErr, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(registerErr) != OAuthDCRRecoveryRegistrationOutcomeUnknown { + t.Fatalf("registration transport recovery = controller %v, error %v, category %v", controller, registerErr, OAuthDCRRecoveryCategoryOf(registerErr)) + } + if _, _, retryErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); OAuthDCRRecoveryCategoryOf(retryErr) != OAuthDCRRecoveryRegistrationOutcomeUnknown { + t.Fatalf("persisted transport recovery category = %v, want outcome unknown", OAuthDCRRecoveryCategoryOf(retryErr)) + } + if _, _, resetErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginResetRegistration); OAuthDCRRecoveryCategoryOf(resetErr) != OAuthDCRRecoveryRegistrationOutcomeUnknown { + t.Fatalf("reset persisted transport recovery category = %v, want outcome unknown", OAuthDCRRecoveryCategoryOf(resetErr)) + } + + fixture = newDCRMetadataFixture(t) + resource = fixture.server.URL + "/gw/mcp" + opts = fixture.options(t, newDCRMemoryStore(t)) + prepared, path, err = PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + fixture.clientID = "" + prepared.RedirectURL = "http://127.0.0.1:49152" + path + if controller, registerErr := NewOAuthController(context.Background(), resource, prepared); controller != nil || + !errors.Is(registerErr, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(registerErr) != OAuthDCRRecoveryResponseInvalid { + t.Fatalf("registration response recovery = controller %v, error %v, category %v", controller, registerErr, OAuthDCRRecoveryCategoryOf(registerErr)) + } + if _, _, retryErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); OAuthDCRRecoveryCategoryOf(retryErr) != OAuthDCRRecoveryResponseInvalid { + t.Fatalf("persisted response recovery category = %v, want response invalid", OAuthDCRRecoveryCategoryOf(retryErr)) + } + + fixture = newDCRMetadataFixture(t) + resource = fixture.server.URL + "/gw/mcp" + store = &failBeforePutStore{Store: newDCRMemoryStore(t), failOnPut: 2} + opts = fixture.options(t, store) + prepared, path, err = PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + if controller, registerErr := NewOAuthController(context.Background(), resource, prepared); controller != nil || + !errors.Is(registerErr, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(registerErr) != OAuthDCRRecoveryReadyPersistence { + t.Fatalf("registration persistence recovery = controller %v, error %v, category %v", controller, registerErr, OAuthDCRRecoveryCategoryOf(registerErr)) + } + if _, _, retryErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); OAuthDCRRecoveryCategoryOf(retryErr) != OAuthDCRRecoveryReadyPersistence { + t.Fatalf("persisted ready-record recovery category = %v, want persistence failure", OAuthDCRRecoveryCategoryOf(retryErr)) + } + + fixture = newDCRMetadataFixture(t) + resource = fixture.server.URL + "/gw/mcp" + store = newDCRMemoryStore(t) + opts = fixture.options(t, store) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + record, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if _, err := store.Put(context.Background(), key, []byte(`{}`), &record.Version); err != nil { + t.Fatal(err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryCorrupt { + t.Fatalf("corrupt recovery = %v, category %v", err, OAuthDCRRecoveryCategoryOf(err)) + } +} + +func TestOAuthDCRFailureCategoryCASDoesNotOverwriteNewGeneration(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.registrationStatus = http.StatusServiceUnavailable + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + old, oldPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration); err != nil { + t.Fatal(err) + } + old.RedirectURL = "http://127.0.0.1:49152" + oldPath + if controller, err := NewOAuthController(context.Background(), resource, old); controller != nil || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryRegistrationOutcomeUnknown { + t.Fatalf("stale registration result = controller %v, error %v", controller, err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryPending { + t.Fatalf("stale failure overwrote newer pending generation: category %v", OAuthDCRRecoveryCategoryOf(err)) + } +} + +type conflictAfterCommitStore struct { + credentialstore.Store + mu sync.Mutex + conflictOnPut int + puts int +} + +type failBeforePutStore struct { + credentialstore.Store + mu sync.Mutex + failOnPut int + puts int +} + +func (s *failBeforePutStore) Put(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.puts++ + if s.puts == s.failOnPut { + return credentialstore.Record{}, credentialstore.ErrConflict + } + return s.Store.Put(ctx, key, value, expected) +} + +func (s *conflictAfterCommitStore) Put(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.puts++ + record, err := s.Store.Put(ctx, key, value, expected) + if err == nil && s.puts == s.conflictOnPut { + return credentialstore.Record{}, credentialstore.ErrConflict + } + return record, err +} + +func TestADR_0325_DirectDCRMetadataAndEgressPolicy(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatalf("prepare valid metadata: %v", err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatalf("register public client: %v", err) + } + _ = controller.Close() + if fixture.registerCount != 1 { + t.Fatalf("registration POST count = %d, want 1", fixture.registerCount) + } + + fixture.registration = fixture.server.URL + "/oauth/register-v2" + fixture.cosmeticSuffix = "-v2" + reused, reusedPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatalf("reuse after endpoint/name rotation: %v", err) + } + if reused.Client.DCR == nil || reusedPath != path || fixture.registerCount != 1 { + t.Fatalf("reuse = %#v path %q registrations %d", reused.Client, reusedPath, fixture.registerCount) + } + + for _, tc := range []struct { + name string + mutate func(*dcrMetadataFixture) + }{ + {name: "missing S256", mutate: func(f *dcrMetadataFixture) { f.codeMethods = []string{"plain"} }}, + {name: "missing authorization code", mutate: func(f *dcrMetadataFixture) { f.grantTypes = []string{"refresh_token"} }}, + {name: "missing public auth", mutate: func(f *dcrMetadataFixture) { f.authMethods = []string{"client_secret_basic"} }}, + {name: "protected resource mismatch", mutate: func(f *dcrMetadataFixture) { f.resourceValue = f.server.URL + "/other" }}, + {name: "multiple authorization servers", mutate: func(f *dcrMetadataFixture) { f.authServers = []string{f.server.URL, "https://other.example"} }}, + {name: "exact issuer mismatch", mutate: func(f *dcrMetadataFixture) { f.issuerOverride = f.server.URL + "/" }}, + {name: "queried authorization endpoint", mutate: func(f *dcrMetadataFixture) { f.cosmeticSuffix = "?audience=other" }}, + {name: "off-origin registration", mutate: func(f *dcrMetadataFixture) { f.registration = "https://evil.example/register" }}, + } { + t.Run(tc.name, func(t *testing.T) { + bad := newDCRMetadataFixture(t) + tc.mutate(bad) + _, _, prepErr := PrepareOAuthDCRLogin(context.Background(), bad.server.URL+"/gw/mcp", bad.options(t, newDCRMemoryStore(t)), OAuthDCRLoginReuse) + if prepErr == nil { + t.Fatal("invalid DCR metadata was admitted") + } + if bad.registerCount != 0 { + t.Fatalf("invalid metadata caused %d registration POSTs", bad.registerCount) + } + }) + } +} + +func TestADR_0325_DirectDCRRegistrationIssuedAtIsNonnegativeAndPresencePreserved(t *testing.T) { + for _, tc := range []struct { + name string + issuedAt int64 + wantOK bool + }{ + {name: "negative rejected", issuedAt: -1}, + {name: "Unix epoch preserved", issuedAt: 0, wantOK: true}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.clientIDIssuedAt = &tc.issuedAt + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if (err == nil) != tc.wantOK { + t.Fatalf("registration error = %v, want success %t", err, tc.wantOK) + } + if controller != nil { + _ = controller.Close() + } + identity := oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer} + key, keyErr := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if keyErr != nil { + t.Fatal(keyErr) + } + record, getErr := store.Get(context.Background(), key) + if getErr != nil { + t.Fatal(getErr) + } + stored, decodeErr := decodeOAuthDCRRecord(record.Value, identity) + if tc.wantOK { + if decodeErr != nil || stored.Registration == nil || stored.Registration.ClientIDIssuedAt == nil || *stored.Registration.ClientIDIssuedAt != 0 { + t.Fatalf("stored epoch registration = %#v, error %v", stored.Registration, decodeErr) + } + } else if decodeErr == nil && stored.State == oauthDCRStateReady { + t.Fatalf("negative issuance time was persisted ready: %#v", stored.Registration) + } + }) + } +} + +func TestADR_0325_DirectDCRRegistrationPrecedesTokenIdentity(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + opts := fixture.options(t, newDCRMemoryStore(t)) + if _, err := OAuthCredentialRecordKey(resource, opts); err == nil { + t.Fatal("unresolved DCR produced a token key") + } + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + _ = controller.Close() + restoredController, err := NewOAuthController(context.Background(), resource, opts) + if err != nil { + t.Fatalf("ordinary controller did not restore ready registration: %v", err) + } + _ = restoredController.Close() + if fixture.registerCount != 1 { + t.Fatalf("ordinary restore registered again: %d POSTs", fixture.registerCount) + } + resolved, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + key, err := OAuthCredentialRecordKey(resource, resolved) + if err != nil || len(key) != 32 { + t.Fatalf("resolved DCR token key = %x, error %v", key, err) + } + if resolved.Client.DCR == nil || resolved.Client.Preregistered != nil { + t.Fatalf("resolved public client kind was disguised: %#v", resolved.Client) + } +} + +func TestADR_0325_DirectDCRResetRotatesLifecycleAndContinues(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + _ = controller.Close() + + _, resetPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginResetRegistration) + if err != nil { + t.Fatalf("reset ready registration: %v", err) + } + if resetPath == path { + t.Fatal("registration reset reused callback path") + } + identity := oauthDCRIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer} + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + record, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + resetPending, err := decodeOAuthDCRRecord(record.Value, identity) + if err != nil { + t.Fatal(err) + } + if resetPending.State != "pending" || resetPending.PreviousAttempt == nil || resetPending.PreviousAttempt.Reason != "explicit_reset" { + t.Fatalf("reset predecessor = %#v", resetPending.PreviousAttempt) + } + + retry, retryPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration) + if err != nil { + t.Fatalf("retry pending registration: %v", err) + } + if retryPath == resetPath { + t.Fatal("registration retry reused callback path") + } + record, err = store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + retryPending, err := decodeOAuthDCRRecord(record.Value, identity) + if err != nil { + t.Fatal(err) + } + if retryPending.PreviousAttempt == nil || retryPending.PreviousAttempt.Reason != "explicit_retry" || retryPending.PreviousAttempt.Generation != resetPending.Generation { + t.Fatalf("retry predecessor = %#v, reset generation %q", retryPending.PreviousAttempt, resetPending.Generation) + } + retry.RedirectURL = "http://127.0.0.1:49153" + retryPath + controller, err = NewOAuthController(context.Background(), resource, retry) + if err != nil { + t.Fatal(err) + } + _ = controller.Close() + if fixture.registerCount != 2 { + t.Fatalf("registration POST count = %d, want 2", fixture.registerCount) + } +} + +func TestADR_0325_DirectDCRCorruptLifecycleOrGrantFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + mutate func([]byte) []byte + fail bool + }{ + {name: "corrupt", mutate: func([]byte) []byte { return []byte(`{"schema":"corrupt"}`) }}, + {name: "unsupported", mutate: func(value []byte) []byte { + return []byte(strings.Replace(string(value), `"version":2`, `"version":3`, 1)) + }}, + {name: "identity mismatch", mutate: func(value []byte) []byte { + return []byte(strings.Replace(string(value), `"principal":"local-user"`, `"principal":"other-user"`, 1)) + }}, + {name: "backend failure", fail: true}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + controller := authorizeDCRForProof(t, fixture, resource, opts) + generation, clientID := controller.state.registration.generation, controller.state.registration.clientID + _ = controller.Close() + + grantIdentity := oauthCredentialIdentity{ + Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, + Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: clientID, + } + grantKey, err := oauthDCRCredentialKey(grantIdentity, generation) + if err != nil { + t.Fatal(err) + } + grant, err := store.Get(context.Background(), grantKey) + if err != nil { + t.Fatal(err) + } + var resetStore credentialstore.Store + resetStore = store + if tc.fail { + wrapped := &scriptedDCRStore{Store: store} + wrapped.get = func(ctx context.Context, key []byte) (credentialstore.Record, error) { + if string(key) == string(grantKey) { + return credentialstore.Record{}, credentialstore.ErrUnavailable + } + return store.Get(ctx, key) + } + resetStore = wrapped + } else { + mutated := tc.mutate(grant.Value) + if string(mutated) == string(grant.Value) { + t.Fatal("fixture did not corrupt the grant") + } + if _, err := store.Put(context.Background(), grantKey, mutated, &grant.Version); err != nil { + t.Fatal(err) + } + } + registrationKey, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + before, err := store.Get(context.Background(), registrationKey) + if err != nil { + t.Fatal(err) + } + resetOpts := fixture.options(t, resetStore) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, resetOpts, OAuthDCRLoginResetRegistration); !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("reset with invalid current grant = %v, want recovery-required", err) + } + after, err := store.Get(context.Background(), registrationKey) + if err != nil { + t.Fatal(err) + } + if string(after.Value) != string(before.Value) || !after.Version.Equal(before.Version) { + t.Fatal("reset mutated the registration after grant validation failed") + } + }) + } +} + +func TestOAuthDCRRegistrationResetAllowsValidOrMissingGrant(t *testing.T) { + for _, state := range []string{"active", "reset", "missing"} { + t.Run(state, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + var controller *OAuthController + if state == "active" { + controller = authorizeDCRForProof(t, fixture, resource, opts) + } else { + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err = NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + } + generation, clientID := controller.state.registration.generation, controller.state.registration.clientID + _ = controller.Close() + if state == "missing" { + identity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: oauthDCRClientKind, ClientID: clientID} + key, _ := oauthDCRCredentialKey(identity, generation) + record, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if err := store.Delete(context.Background(), key, record.Version); err != nil { + t.Fatal(err) + } + } + if _, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginResetRegistration); err != nil || path == "" { + t.Fatalf("reset with %s grant = path %q, error %v", state, path, err) + } + }) + } +} + +func TestADR_0325_DirectDCRRegistrationCASWinnerAdoption(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := &conflictAfterCommitStore{Store: newDCRMemoryStore(t), conflictOnPut: 2} + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + _ = controller.Close() + + winner, winnerPath, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + if winnerPath != path || winner.Client.DCR == nil { + t.Fatalf("ready winner was not adopted: path=%q client=%#v", winnerPath, winner.Client) + } + if fixture.registerCount != 1 { + t.Fatalf("winner adoption registered again: %d POSTs", fixture.registerCount) + } + + _, _, err = PrepareOAuthDCRLogin(context.Background(), resource, fixture.options(t, store), OAuthDCRLoginRetryRegistration) + if !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("retry against ready = %v, want recovery-required", err) + } +} + +func TestADR_0325_PublicNoneWireQualification(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + q := u.Query() + if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || len(q["resource"]) != 1 || q.Get("resource") != resource || q.Get("scope") != "openid" { + t.Fatalf("authorization query = %v", q) + } + return &auth.AuthorizationResult{Code: "fixture-code", State: q.Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + req, _ := http.NewRequest(http.MethodGet, resource, nil) + resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))} + resp.Header.Set("WWW-Authenticate", `Bearer scope="openid"`) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("authorize public DCR: %v", err) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + if fixture.tokenCount != 1 || len(fixture.tokenForms) != 1 { + t.Fatalf("upstream token requests = %d, want one parameter fallback", fixture.tokenCount) + } + form := fixture.tokenForms[0] + if form.Get("client_id") != fixture.clientID || len(form["resource"]) != 1 || form.Get("resource") != resource || form.Get("client_secret") != "" || form.Get("client_assertion") != "" || form.Get("code_verifier") == "" { + t.Fatalf("public token form = %v", form) + } +} + +func TestOAuthDCRRestoresPersistedGrant(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + req, _ := http.NewRequest(http.MethodGet, resource, nil) + resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))} + resp.Header.Set("WWW-Authenticate", `Bearer scope="openid"`) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + _ = controller.Close() + + restored, err := NewOAuthController(context.Background(), resource, fixture.options(t, store)) + if err != nil { + t.Fatalf("restore controller: %v", err) + } + defer restored.Close() + source, err := restored.TokenSource(context.Background()) + if err != nil || source == nil { + t.Fatalf("restored token source = %v, %v", source, err) + } + token, err := source.Token() + if err != nil || token.AccessToken != "dcr-access" || token.RefreshToken != "" { + t.Fatalf("restored token = %#v, %v", token, err) + } + fixture.mu.Lock() + registrations, exchanges := fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if registrations != 1 || exchanges != 1 { + t.Fatalf("restart network counts register=%d token=%d", registrations, exchanges) + } +} + +func TestADR_0325_DirectDCRRejectsUnsolicitedRefreshToken(t *testing.T) { + fixture := newDCRMetadataFixture(t) + fixture.unsolicitedRefresh = true + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + req, _ := http.NewRequest(http.MethodGet, resource, nil) + resp := &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))} + resp.Header.Set("WWW-Authenticate", `Bearer scope="openid"`) + if err := controller.Authorize(context.Background(), req, resp); err == nil { + t.Fatal("unsolicited refresh token was accepted") + } + source, err := controller.TokenSource(context.Background()) + if err != nil || source != nil { + t.Fatalf("unsolicited refresh persisted source = %v, %v", source, err) + } +} + +func TestADR_0325_DirectDCRExpiryRequiresLoginWithoutRefresh(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + generation := controller.state.registration.generation + clientID := controller.state.registration.clientID + identity := oauthCredentialIdentity{Profile: opts.Subject.Profile, Principal: opts.Subject.Principal, Resource: resource, Issuer: opts.Issuer, ClientKind: "dcr", ClientID: clientID} + key, _ := oauthDCRCredentialKey(identity, generation) + record, _ := store.Get(context.Background(), key) + cfg := &oauth2.Config{ClientID: clientID, Endpoint: oauth2.Endpoint{TokenURL: fixture.server.URL + "/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect}, RedirectURL: prepared.RedirectURL, Scopes: []string{"openid"}} + expired := &oauth2.Token{AccessToken: "expired", TokenType: "Bearer", Expiry: time.Now().Add(-time.Hour)} + grant := newOAuthDCRActiveGrant(identity, generation, cfg, expired) + value, _ := encodeOAuthDCRGrant(grant, identity, generation, controller.state.origins) + _, err = store.Put(context.Background(), key, value, &record.Version) + if err != nil { + t.Fatal(err) + } + _ = controller.Close() + restored, err := NewOAuthController(context.Background(), resource, fixture.options(t, store)) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + source, _ := restored.TokenSource(context.Background()) + if source == nil { + t.Fatal("expired durable grant was not restored for login-required classification") + } + if _, err := source.Token(); !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("expired DCR token error = %v", err) + } + fixture.mu.Lock() + exchanges := fixture.tokenCount + fixture.mu.Unlock() + if exchanges != 0 { + t.Fatalf("expiry caused %d token requests", exchanges) + } +} + +func TestADR_0325_DirectDCRGrantResetFencesStaleWriters(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource := fixture.server.URL + "/gw/mcp" + store := newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + if err := controller.state.beginAuthorization(context.Background()); err != nil { + t.Fatal(err) + } + if err := controller.state.reset(context.Background()); err != nil { + t.Fatal(err) + } + cfg := &oauth2.Config{ClientID: controller.state.registration.clientID, Endpoint: oauth2.Endpoint{TokenURL: fixture.server.URL + "/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect}, RedirectURL: prepared.RedirectURL, Scopes: []string{"openid"}} + _, err = controller.state.newTokenSource(context.Background(), cfg, &oauth2.Token{AccessToken: "stale", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)}) + if !errors.Is(err, ErrOAuthLoginRequired) { + t.Fatalf("stale authorization write error = %v, want login required", err) + } + controller.state.mu.Lock() + state := controller.state.dcrGrant.State + controller.state.mu.Unlock() + if state != "reset" { + t.Fatalf("stale writer resurrected grant state %q", state) + } +} + +func TestADR_0325_DirectDCRIdentityMismatchHasNoSideEffects(t *testing.T) { + for _, tc := range []struct { + name string + change func(*dcrMetadataFixture, *OAuthOptions, *string) + }{ + {name: "profile", change: func(_ *dcrMetadataFixture, opts *OAuthOptions, _ *string) { opts.Subject.Profile = "other-profile" }}, + {name: "principal", change: func(_ *dcrMetadataFixture, opts *OAuthOptions, _ *string) { opts.Subject.Principal = "other-user" }}, + {name: "canonical resource", change: func(f *dcrMetadataFixture, _ *OAuthOptions, resource *string) { + *resource = f.server.URL + "/other/mcp" + f.resourceValue = *resource + }}, + {name: "exact issuer", change: func(f *dcrMetadataFixture, opts *OAuthOptions, _ *string) { + opts.Issuer = f.server.URL + "/issuer" + f.authServers = []string{opts.Issuer} + f.issuerOverride = opts.Issuer + }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + if err := controller.Close(); err != nil { + t.Fatal(err) + } + + changedResource, changed := resource, opts + tc.change(fixture, &changed, &changedResource) + fixture.mu.Lock() + beforeMetadata, beforeRegistrations, beforeTokens := fixture.metadataCount, fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if _, _, err := PrepareOAuthDCRLogin(context.Background(), changedResource, changed, OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryResetRequired { + t.Fatalf("reuse with changed binding = %v, want reset-required recovery", err) + } + fixture.mu.Lock() + metadata, registrations, tokens := fixture.metadataCount, fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if metadata != beforeMetadata || registrations != beforeRegistrations || tokens != beforeTokens { + t.Fatalf("ordinary mismatch had metadata/registration/token side effects: %d/%d/%d, want %d/%d/%d", metadata, registrations, tokens, beforeMetadata, beforeRegistrations, beforeTokens) + } + + changed.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: changed.Issuer}, nil + }) + reset, resetPath, err := PrepareOAuthDCRLogin(context.Background(), changedResource, changed, OAuthDCRLoginResetRegistration) + if err != nil { + t.Fatalf("explicit reset = %v", err) + } + reset.RedirectURL = "http://127.0.0.1:49153" + resetPath + resetController, err := NewOAuthController(context.Background(), changedResource, reset) + if err != nil { + t.Fatalf("continue reset registration = %v", err) + } + defer resetController.Close() + req, resp := dcrChallenge(t, changedResource) + if err := resetController.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("continue reset authorization = %v", err) + } + fixture.mu.Lock() + registrations, tokens = fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if registrations != beforeRegistrations+1 || tokens != beforeTokens+1 { + t.Fatalf("reset continuation register/token = %d/%d, want %d/%d", registrations, tokens, beforeRegistrations+1, beforeTokens+1) + } + }) + } +} + +func TestPrepareOAuthDCRLoginClassifiesFreshPendingIdentityMismatchAfterDiscovery(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(_ context.Context, raw string) (*auth.AuthorizationResult, error) { + u, _ := url.Parse(raw) + return &auth.AuthorizationResult{Code: "fixture-code", State: u.Query().Get("state"), Iss: fixture.server.URL}, nil + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err != nil { + t.Fatal(err) + } + if err := controller.Close(); err != nil { + t.Fatal(err) + } + + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + ready, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + stored, err := decodeOAuthDCRRecordRaw(ready.Value) + if err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + started, release := make(chan struct{}), make(chan struct{}) + fixture.metadataStarted, fixture.metadataRelease = started, release + fixture.mu.Unlock() + result := make(chan error, 1) + go func() { + _, _, prepareErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + result <- prepareErr + }() + <-started + defer func() { + if release != nil { + close(release) + } + }() + changedIdentity := stored.Identity + changedIdentity.Profile = "concurrent-profile" + pending, err := newOAuthDCRPending(changedIdentity, stored.Metadata, oauthDCRRecord{}, "") + if err != nil { + t.Fatal(err) + } + value, err := encodeOAuthDCRRecord(pending, changedIdentity) + if err != nil { + t.Fatal(err) + } + if _, err := store.Put(context.Background(), key, value, &ready.Version); err != nil { + t.Fatalf("concurrent lifecycle reset: %v", err) + } + close(release) + release = nil + if err := <-result; !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryPendingIdentityMismatch { + t.Fatalf("reuse after concurrent pending identity change = %v, want pending-identity-mismatch recovery", err) + } + current, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if current.Version == ready.Version || !bytes.Equal(current.Value, value) { + t.Fatal("reuse adopted or changed the lifecycle record observed before discovery") + } +} + +func TestPrepareOAuthDCRLoginFirstLifecycleReadFailureStopsBeforeDiscovery(t *testing.T) { + fixture := newDCRMetadataFixture(t) + base := newDCRMemoryStore(t) + getCalls, putCalls := 0, 0 + store := &scriptedDCRStore{Store: base} + store.get = func(context.Context, []byte) (credentialstore.Record, error) { + getCalls++ + return credentialstore.Record{}, credentialstore.ErrUnavailable + } + store.put = func(context.Context, []byte, []byte, *credentialstore.Version) (credentialstore.Record, error) { + putCalls++ + return credentialstore.Record{}, errors.New("unexpected lifecycle write") + } + + resource := fixture.server.URL + "/gw/mcp" + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, fixture.options(t, store), OAuthDCRLoginReuse); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryCorrupt { + t.Fatalf("first lifecycle read failure = %v, want unreadable recovery", err) + } + fixture.mu.Lock() + metadata, registrations := fixture.metadataCount, fixture.registerCount + fixture.mu.Unlock() + if getCalls != 1 || putCalls != 0 || metadata != 0 || registrations != 0 { + t.Fatalf("side effects after first read failure: gets=%d puts=%d metadata=%d registrations=%d", getCalls, putCalls, metadata, registrations) + } +} + +func TestPrepareOAuthDCRLoginDoesNotBootstrapLifecycleDeletedDuringDiscovery(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, base := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, base) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + record, err := base.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + beforeMetadata, beforeRegistrations := fixture.metadataCount, fixture.registerCount + started, release := make(chan struct{}), make(chan struct{}) + fixture.metadataStarted, fixture.metadataRelease = started, release + fixture.mu.Unlock() + + getCalls, putCalls := 0, 0 + store := &scriptedDCRStore{Store: base} + store.get = func(ctx context.Context, key []byte) (credentialstore.Record, error) { + getCalls++ + return base.Get(ctx, key) + } + store.put = func(ctx context.Context, key, value []byte, expected *credentialstore.Version) (credentialstore.Record, error) { + putCalls++ + return base.Put(ctx, key, value, expected) + } + opts.CredentialStore = store + result := make(chan error, 1) + go func() { + _, _, prepareErr := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + result <- prepareErr + }() + <-started + if err := base.Delete(context.Background(), key, record.Version); err != nil { + close(release) + t.Fatal(err) + } + close(release) + if err := <-result; !errors.Is(err, ErrOAuthDCRRecoveryRequired) { + t.Fatalf("deleted lifecycle preparation = %v, want recovery-required", err) + } + fixture.mu.Lock() + metadata, registrations := fixture.metadataCount, fixture.registerCount + fixture.mu.Unlock() + if getCalls != 2 || putCalls != 0 { + t.Fatalf("lifecycle store calls after concurrent delete: gets=%d puts=%d, want 2/0", getCalls, putCalls) + } + if metadata <= beforeMetadata || registrations != beforeRegistrations { + t.Fatalf("network calls after concurrent delete: metadata=%d (before %d), registrations=%d (before %d)", metadata, beforeMetadata, registrations, beforeRegistrations) + } + if _, err := base.Get(context.Background(), key); !errors.Is(err, credentialstore.ErrNotFound) { + t.Fatalf("deleted lifecycle was recreated: %v", err) + } +} + +func TestADR_0325_DirectDCRChallengeIssuerBinding(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + presented := 0 + opts := fixture.options(t, store) + opts.Presenter = OAuthPresenterFunc(func(context.Context, string) (*auth.AuthorizationResult, error) { + presented++ + return nil, errors.New("must not present") + }) + prepared, path, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + prepared.RedirectURL = "http://127.0.0.1:49152" + path + controller, err := NewOAuthController(context.Background(), resource, prepared) + if err != nil { + t.Fatal(err) + } + defer controller.Close() + fixture.mu.Lock() + fixture.issuerOverride = fixture.server.URL + "/" + fixture.mu.Unlock() + req, resp := dcrChallenge(t, resource) + if err := controller.Authorize(context.Background(), req, resp); err == nil { + t.Fatal("runtime issuer mismatch was accepted") + } + if presented != 0 { + t.Fatalf("presenter calls = %d, want 0", presented) + } +} + +func TestOAuthDCRPendingBindingMismatchRequiresMatchingConfiguration(t *testing.T) { + for _, tc := range []struct { + name string + change func(*OAuthOptions, *string) + }{ + {name: "profile", change: func(opts *OAuthOptions, _ *string) { opts.Subject.Profile = "other-profile" }}, + {name: "principal", change: func(opts *OAuthOptions, _ *string) { opts.Subject.Principal = "other-user" }}, + {name: "canonical resource", change: func(_ *OAuthOptions, resource *string) { *resource += "/other" }}, + {name: "exact issuer", change: func(opts *OAuthOptions, _ *string) { opts.Issuer += "/other" }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + before, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + beforeMetadata := fixture.metadataCount + fixture.mu.Unlock() + + changedResource, changed := resource, opts + tc.change(&changed, &changedResource) + for _, action := range []OAuthDCRLoginAction{OAuthDCRLoginReuse, OAuthDCRLoginResetRegistration, OAuthDCRLoginRetryRegistration} { + if _, _, err := PrepareOAuthDCRLogin(context.Background(), changedResource, changed, action); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryPendingIdentityMismatch { + t.Fatalf("action %d with pending binding drift = %v, want pending-identity-mismatch recovery", action, err) + } + after, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if after.Version != before.Version || !bytes.Equal(after.Value, before.Value) { + t.Fatalf("action %d changed pending lifecycle record", action) + } + } + fixture.mu.Lock() + metadata, registrations, tokens := fixture.metadataCount, fixture.registerCount, fixture.tokenCount + fixture.mu.Unlock() + if metadata != beforeMetadata || registrations != 0 || tokens != 0 { + t.Fatalf("pending mismatch side effects metadata/register/token = %d/%d/%d, want %d/0/0", metadata, registrations, tokens, beforeMetadata) + } + }) + } +} + +func TestOAuthDCRResetValidatesActiveGrantAgainstStoredIssuerAfterIssuerDrift(t *testing.T) { + oldFixture := newDCRMetadataFixture(t) + newFixture := newDCRMetadataFixture(t) + resource, store := oldFixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + oldOpts := oldFixture.options(t, store) + controller := authorizeDCRForProof(t, oldFixture, resource, oldOpts) + if err := controller.Close(); err != nil { + t.Fatal(err) + } + + oldFixture.mu.Lock() + oldFixture.authServers = []string{newFixture.server.URL} + oldFixture.mu.Unlock() + changed := newFixture.options(t, store) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, changed, OAuthDCRLoginResetRegistration); err != nil { + t.Fatalf("reset after issuer drift rejected valid old grant: %v", err) + } +} + +func TestOAuthDCRMalformedStoredIdentityIsCorruptWithoutMutation(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*oauthDCRRecord) + }{ + {name: "noncanonical resource", mutate: func(record *oauthDCRRecord) { + record.Identity.Resource += "/../mcp" + record.Metadata.Resource = record.Identity.Resource + record.MetadataFingerprint = fingerprintDCRMetadata(record.Metadata) + }}, + {name: "issuer query", mutate: func(record *oauthDCRRecord) { + record.Identity.Issuer += "?drift=1" + record.Metadata.Issuer = record.Identity.Issuer + record.MetadataFingerprint = fingerprintDCRMetadata(record.Metadata) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + before, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + var record oauthDCRRecord + if err := json.Unmarshal(before.Value, &record); err != nil { + t.Fatal(err) + } + tc.mutate(&record) + value, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if _, err := store.Put(context.Background(), key, value, &before.Version); err != nil { + t.Fatal(err) + } + corrupt, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginResetRegistration); !errors.Is(err, ErrOAuthDCRRecoveryRequired) || OAuthDCRRecoveryCategoryOf(err) != OAuthDCRRecoveryCorrupt { + t.Fatalf("reset malformed lifecycle = %v, want corrupt recovery", err) + } + after, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if string(after.Value) != string(corrupt.Value) || !after.Version.Equal(corrupt.Version) { + t.Fatal("reset mutated malformed lifecycle state") + } + }) + } +} + +func TestADR_0325_DirectDCRLifecycleBindingAndCAS(t *testing.T) { + fixture := newDCRMetadataFixture(t) + resource, store := fixture.server.URL+"/gw/mcp", newDCRMemoryStore(t) + opts := fixture.options(t, store) + prepared, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + key, err := oauthDCRLifecycleKey(opts.Client.DCR.ServerName) + if err != nil { + t.Fatal(err) + } + otherKey, err := oauthDCRLifecycleKey("other-connector") + if err != nil { + t.Fatal(err) + } + if string(key) == string(otherKey) { + t.Fatal("server names share a lifecycle key") + } + first, err := store.Get(context.Background(), key) + if err != nil { + t.Fatal(err) + } + identity := prepared.dcrTicket.record.Identity + for _, changed := range []oauthDCRIdentity{ + {Profile: "other-profile", Principal: identity.Principal, Resource: identity.Resource, Issuer: identity.Issuer}, + {Profile: identity.Profile, Principal: "other-principal", Resource: identity.Resource, Issuer: identity.Issuer}, + {Profile: identity.Profile, Principal: identity.Principal, Resource: fixture.server.URL + "/other", Issuer: identity.Issuer}, + {Profile: identity.Profile, Principal: identity.Principal, Resource: identity.Resource, Issuer: fixture.server.URL + "/other-issuer"}, + } { + if _, err := decodeOAuthDCRRecord(first.Value, changed); err == nil { + t.Fatalf("lifecycle record accepted changed identity %#v", changed) + } + } + if _, _, err := PrepareOAuthDCRLogin(context.Background(), resource, opts, OAuthDCRLoginRetryRegistration); err != nil { + t.Fatal(err) + } + if _, err := store.Put(context.Background(), key, first.Value, &first.Version); !errors.Is(err, credentialstore.ErrConflict) { + t.Fatalf("stale lifecycle CAS write = %v, want conflict", err) + } +} diff --git a/internal/adapter/mcp/oauth_errors.go b/internal/adapter/mcp/oauth_errors.go index de2abc567e..297bf7f3da 100644 --- a/internal/adapter/mcp/oauth_errors.go +++ b/internal/adapter/mcp/oauth_errors.go @@ -28,6 +28,9 @@ func (e *OAuthError) Error() string { if errors.Is(e.kind, ErrOAuthLoginRequired) { return "mcp OAuth login required" } + if errors.Is(e.kind, ErrOAuthDCRRecoveryRequired) { + return ErrOAuthDCRRecoveryRequired.Error() + } return "mcp OAuth unavailable" } @@ -56,6 +59,9 @@ func projectOAuthError(err error) error { if errors.As(err, &rejected) { return &OAuthError{kind: ErrOAuthUnavailable, diagnostic: rejected.Sanitized()} } + if errors.Is(err, ErrOAuthDCRRecoveryRequired) { + return &OAuthError{kind: ErrOAuthDCRRecoveryRequired, diagnostic: dcrRecovery(OAuthDCRRecoveryCategoryOf(err))} + } if errors.Is(err, ErrOAuthLoginRequired) { return &OAuthError{kind: ErrOAuthLoginRequired} } diff --git a/internal/adapter/mcp/oauth_http.go b/internal/adapter/mcp/oauth_http.go index 817fef1984..7b13736314 100644 --- a/internal/adapter/mcp/oauth_http.go +++ b/internal/adapter/mcp/oauth_http.go @@ -1,9 +1,11 @@ package mcp import ( + "bytes" "context" "crypto/tls" "crypto/x509" + "encoding/json" "errors" "io" "mime" @@ -98,6 +100,9 @@ type oauthHTTPTransport struct { issuerOrigin string resourceOrigin string requireClientBasic bool + dcrPublicClientID string + dcrResource string + dcrIssuer string lookup oauthLookupFunc dial oauthDialFunc allowLoopback bool @@ -134,13 +139,15 @@ func newOAuthHTTPClient(resource string, opts OAuthOptions) (*http.Client, *oaut issuerOrigin: urlOrigin(issuer), resourceOrigin: urlOrigin(resourceURL), requireClientBasic: opts.Client.Preregistered != nil, + dcrResource: canonical, lookup: resolver.LookupNetIP, dial: dialer.DialContext, allowLoopback: opts.allowLoopbackForTest, } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: opts.testRootCAs} transport.base = &http.Transport{ Proxy: nil, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSClientConfig: tlsConfig, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxResponseHeaderBytes: 64 << 10, @@ -242,6 +249,22 @@ func (t *oauthHTTPTransport) validateEgress(req *http.Request, origin string) er if t.requireClientBasic && !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") { return errors.New("OAuth confidential client must use client_secret_basic") } + if err := t.validateDCRPublicExchange(req, form); err != nil { + return err + } + } + return nil +} + +func (t *oauthHTTPTransport) validateDCRPublicExchange(req *http.Request, form url.Values) error { + if t.dcrPublicClientID == "" || form.Get("grant_type") != "authorization_code" { + return nil + } + if strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") { + return errors.New("OAuth public client Basic probe rejected") + } + if req.Header.Get("Authorization") != "" || len(form["client_id"]) != 1 || form.Get("client_id") != t.dcrPublicClientID || len(form["resource"]) != 1 || form.Get("resource") != t.dcrResource || form.Get("client_secret") != "" || form.Get("client_assertion") != "" || form.Get("client_assertion_type") != "" { + return errors.New("OAuth public client token request is invalid") } return nil } @@ -272,9 +295,33 @@ func (t *oauthHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error } return nil, projectOAuthError(err) } + if t.dcrIssuer != "" && (strings.Contains(req.URL.Path, "/.well-known/oauth-authorization-server") || strings.Contains(req.URL.Path, "/.well-known/openid-configuration")) { + if err := validateDCRRuntimeMetadataIssuer(resp, t.dcrIssuer); err != nil { + return nil, projectOAuthError(err) + } + } return resp, nil } +func validateDCRRuntimeMetadataIssuer(resp *http.Response, expected string) error { + if resp == nil || resp.Body == nil { + return ErrOAuthDCRRecoveryRequired + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxOAuthRequestBody+1)) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil || len(body) > maxOAuthRequestBody { + return ErrOAuthDCRRecoveryRequired + } + var metadata struct { + Issuer string `json:"issuer"` + } + if err := json.Unmarshal(body, &metadata); err != nil || metadata.Issuer != expected { + return ErrOAuthDCRRecoveryRequired + } + return nil +} + func (t *oauthHTTPTransport) dialContext(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { diff --git a/internal/adapter/mcp/oauth_tokensource.go b/internal/adapter/mcp/oauth_tokensource.go index 346ba5b4a8..29e319c85c 100644 --- a/internal/adapter/mcp/oauth_tokensource.go +++ b/internal/adapter/mcp/oauth_tokensource.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "net/url" "slices" "sync" @@ -25,10 +26,12 @@ type oauthCredentialState struct { allowInMemory bool lifetime context.Context - record *credentialstore.Record - envelope oauthCredentialEnvelope - config *oauth2.Config - token *oauth2.Token + record *credentialstore.Record + envelope oauthCredentialEnvelope + dcrGrant oauthDCRGrantEnvelope + authorizationVersion *credentialstore.Version + config *oauth2.Config + token *oauth2.Token } type persistentTokenSource struct { @@ -59,13 +62,30 @@ func (s *oauthCredentialState) operationContext(ctx context.Context) (context.Co func restoreOAuthCredential(ctx context.Context, reader credentialstore.Reader, writer credentialstore.ConditionalWriter, identity oauthCredentialIdentity, registration oauthRegistration, origins map[string]struct{}, client *http.Client, requestRefresh, allowInMemory bool) (*oauthCredentialState, error) { key, err := oauthCredentialKey(identity) + if registration.kind == oauthDCRClientKind { + key, err = oauthDCRCredentialKey(identity, registration.generation) + } if err != nil { return nil, err } state := &oauthCredentialState{reader: reader, writer: writer, key: key, identity: identity, registration: registration, origins: origins, client: client, requestRefresh: requestRefresh, allowInMemory: allowInMemory} record, err := reader.Get(ctx, key) if errors.Is(err, credentialstore.ErrNotFound) { - return state, nil + if registration.kind != oauthDCRClientKind { + return state, nil + } + if writer == nil { + return nil, projectOAuthError(ErrOAuthUnavailable) + } + reset := newOAuthDCRResetGrant(identity, registration.generation) + value, encodeErr := encodeOAuthDCRGrant(reset, identity, registration.generation, origins) + if encodeErr != nil { + return nil, projectOAuthError(encodeErr) + } + record, err = writer.Put(ctx, key, value, nil) + if errors.Is(err, credentialstore.ErrConflict) { + record, err = reader.Get(ctx, key) + } } if err != nil { return nil, projectOAuthError(err) @@ -89,7 +109,7 @@ func (s *oauthCredentialState) initialTokenSource() oauth2.TokenSource { func (s *oauthCredentialState) tokenSource(ctx context.Context) oauth2.TokenSource { s.mu.Lock() defer s.mu.Unlock() - if s.record == nil { + if s.record == nil || s.config == nil || s.token == nil { return nil } return &persistentTokenSource{state: s, ctx: ctx} @@ -112,6 +132,9 @@ func (s *oauthCredentialState) newTokenSource(ctx context.Context, cfg *oauth2.C if err := s.validateConfig(cfg); err != nil { return nil, projectOAuthError(err) } + if s.registration.kind == oauthDCRClientKind { + return s.newDCRTokenSource(ctx, cfg, token) + } envelope := newOAuthCredentialEnvelope(s.identity, cfg, token) value, err := encodeOAuthCredential(envelope, s.identity, s.requestRefresh, s.origins) if err != nil { @@ -149,6 +172,14 @@ func (s *oauthCredentialState) validateConfig(cfg *oauth2.Config) error { if s.registration.kind == "preregistered" && cfg.Endpoint.AuthStyle != oauth2.AuthStyleInHeader { return errors.New("OAuth token configuration authentication style is invalid") } + if s.registration.kind == oauthDCRClientKind { + tokenURL, tokenErr := validateHTTPURL("OAuth DCR token URL", cfg.Endpoint.TokenURL, false) + issuerURL, issuerErr := url.Parse(s.identity.Issuer) + redirect, redirectErr := url.Parse(cfg.RedirectURL) + if cfg.ClientSecret != "" || cfg.Endpoint.AuthStyle != oauth2.AuthStyleAutoDetect || len(cfg.Scopes) != 1 || cfg.Scopes[0] != oauthDCRScope || tokenErr != nil || issuerErr != nil || urlOrigin(tokenURL) != urlOrigin(issuerURL) || redirectErr != nil || redirect.Path != s.registration.redirectPath { + return errors.New("OAuth DCR token configuration is invalid") + } + } return nil } @@ -179,6 +210,18 @@ func (s *oauthCredentialState) tokenLocked(ctx context.Context, allowConflict bo if s.record == nil || s.config == nil { return nil, projectOAuthError(ErrOAuthLoginRequired) } + if s.registration.kind == oauthDCRClientKind { + if err := s.reloadLocked(ctx); err != nil { + return nil, projectOAuthError(err) + } + if err := s.validateDCRRegistrationLocked(ctx); err != nil { + return nil, projectOAuthError(err) + } + if s.token == nil || !s.token.Valid() { + return nil, projectOAuthError(ErrOAuthLoginRequired) + } + return cloneOAuthToken(s.token), nil + } if s.token.RefreshToken == "" && !s.token.Valid() { return nil, projectOAuthError(ErrOAuthLoginRequired) } @@ -307,6 +350,35 @@ func (s *oauthCredentialState) reloadLocked(ctx context.Context) error { } func (s *oauthCredentialState) installRecordLocked(record credentialstore.Record) error { + if s.registration.kind == oauthDCRClientKind { + grant, err := decodeOAuthDCRGrant(record.Value, s.identity, s.registration.generation, s.origins) + if err != nil { + return err + } + record.Value = slices.Clone(record.Value) + s.record = &record + s.dcrGrant = grant + s.envelope = oauthCredentialEnvelope{} + s.config = nil + s.token = nil + if grant.State == "reset" { + return nil + } + tokenURL, tokenErr := url.Parse(grant.Authorization.TokenURL) + issuerURL, issuerErr := url.Parse(s.identity.Issuer) + redirect, redirectErr := url.Parse(grant.Authorization.RedirectURL) + if tokenErr != nil || issuerErr != nil || redirectErr != nil || urlOrigin(tokenURL) != urlOrigin(issuerURL) || redirect.Path != s.registration.redirectPath { + return errors.New("OAuth DCR grant binding is invalid") + } + token, err := oauthDCRGrantToken(grant) + if err != nil { + return err + } + auth := grant.Authorization + s.config = &oauth2.Config{ClientID: s.registration.clientID, Endpoint: oauth2.Endpoint{TokenURL: auth.TokenURL, AuthStyle: oauth2.AuthStyleInParams}, RedirectURL: auth.RedirectURL, Scopes: append([]string(nil), auth.Scopes...)} + s.token = token + return nil + } envelope, err := decodeOAuthCredential(record.Value, s.identity, s.requestRefresh, s.origins) if err != nil { return err @@ -333,6 +405,8 @@ func (s *oauthCredentialState) installLocked(record credentialstore.Record, enve func (s *oauthCredentialState) clearLocked() { s.record = nil s.envelope = oauthCredentialEnvelope{} + s.dcrGrant = oauthDCRGrantEnvelope{} + s.authorizationVersion = nil s.config = nil s.token = nil } @@ -348,6 +422,9 @@ func (s *oauthCredentialState) reset(ctx context.Context) error { if s.writer == nil { return projectOAuthError(ErrOAuthUnavailable) } + if s.registration.kind == oauthDCRClientKind { + return s.resetDCRLocked(ctx) + } if s.record == nil { s.clearLocked() return nil @@ -370,6 +447,110 @@ func (s *oauthCredentialState) reset(ctx context.Context) error { return nil } +func (s *oauthCredentialState) beginAuthorization(ctx context.Context) error { + if s.registration.kind != oauthDCRClientKind { + return nil + } + ctx, cancel := s.operationContext(ctx) + defer cancel() + s.mu.Lock() + defer s.mu.Unlock() + if err := s.reloadLocked(ctx); err != nil { + return projectOAuthError(err) + } + if err := s.validateDCRRegistrationLocked(ctx); err != nil { + return projectOAuthError(err) + } + if err := s.resetDCRLocked(ctx); err != nil { + return err + } + version := s.record.Version + s.authorizationVersion = &version + return nil +} + +func (s *oauthCredentialState) newDCRTokenSource(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + if token.RefreshToken != "" { + return nil, projectOAuthError(ErrOAuthUnavailable) + } + grant := newOAuthDCRActiveGrant(s.identity, s.registration.generation, cfg, token) + value, err := encodeOAuthDCRGrant(grant, s.identity, s.registration.generation, s.origins) + if err != nil { + return nil, projectOAuthError(err) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.authorizationVersion == nil { + return nil, projectOAuthError(ErrOAuthDCRRecoveryRequired) + } + expected := *s.authorizationVersion + s.authorizationVersion = nil + if err := s.validateDCRRegistrationLocked(ctx); err != nil { + return nil, projectOAuthError(err) + } + record, err := s.writer.Put(ctx, s.key, value, &expected) + if err == nil { + if err := s.installRecordLocked(record); err != nil { + return nil, projectOAuthError(err) + } + if err := s.validateDCRRegistrationLocked(ctx); err != nil { + return nil, projectOAuthError(err) + } + return &persistentTokenSource{state: s}, nil + } + if !errors.Is(err, credentialstore.ErrConflict) { + return nil, projectOAuthError(err) + } + if err := s.reloadLocked(ctx); err != nil { + return nil, projectOAuthError(err) + } + if s.dcrGrant.State != "active" { + return nil, projectOAuthError(ErrOAuthLoginRequired) + } + return &persistentTokenSource{state: s}, nil +} + +func (s *oauthCredentialState) resetDCRLocked(ctx context.Context) error { + reset := newOAuthDCRResetGrant(s.identity, s.registration.generation) + value, err := encodeOAuthDCRGrant(reset, s.identity, s.registration.generation, s.origins) + if err != nil { + return projectOAuthError(err) + } + var expected *credentialstore.Version + if s.record != nil { + version := s.record.Version + expected = &version + } + record, err := s.writer.Put(ctx, s.key, value, expected) + if err == nil { + return projectOAuthError(s.installRecordLocked(record)) + } + if !errors.Is(err, credentialstore.ErrConflict) { + return projectOAuthError(err) + } + if err := s.reloadLocked(ctx); err != nil { + return projectOAuthError(err) + } + return projectOAuthError(ErrOAuthDCRRecoveryRequired) +} + +func (s *oauthCredentialState) validateDCRRegistrationLocked(ctx context.Context) error { + identity := oauthDCRIdentity{Profile: s.identity.Profile, Principal: s.identity.Principal, Resource: s.identity.Resource, Issuer: s.identity.Issuer} + key, err := oauthDCRLifecycleKey(s.registration.dcrServerName) + if err != nil { + return err + } + record, err := s.reader.Get(ctx, key) + if err != nil { + return ErrOAuthDCRRecoveryRequired + } + stored, err := decodeOAuthDCRRecord(record.Value, identity) + if err != nil || stored.State != oauthDCRStateReady || stored.Generation != s.registration.generation || stored.Registration.ClientID != s.registration.clientID { + return ErrOAuthDCRRecoveryRequired + } + return nil +} + func cloneOAuthConfig(cfg *oauth2.Config) *oauth2.Config { clone := *cfg clone.Scopes = append([]string(nil), cfg.Scopes...) diff --git a/internal/adapter/permconfig/schema.go b/internal/adapter/permconfig/schema.go index 9305bec401..da6f27806b 100644 --- a/internal/adapter/permconfig/schema.go +++ b/internal/adapter/permconfig/schema.go @@ -438,9 +438,9 @@ type MCPOAuthProfile struct { Upstream *MCPOAuthUpstreamProfile `yaml:"upstream"` // Client selects exactly one preregistered, CIMD, or DCR client declaration. Client MCPOAuthClientProfile `yaml:"client"` - // Scopes is the non-empty allowlist of OAuth scopes the client may request. + // Scopes is the non-empty OAuth scope allowlist, except direct/global DCR may omit it and uses exactly openid. Scopes []string `yaml:"scopes"` - // RequestRefreshToken asks the authorization server for refresh capability. + // RequestRefreshToken asks the authorization server for refresh capability; direct/global DCR defaults false and rejects true. RequestRefreshToken bool `yaml:"request_refresh_token"` // Credentials selects one global-mode local or environment credential source and is forbidden in broker mode. Credentials MCPOAuthCredentialProfile `yaml:"credentials"` @@ -511,7 +511,7 @@ type MCPOAuthClientProfile struct { Preregistered *MCPPreregisteredClientProfile `yaml:"preregistered"` // CIMD declares an HTTPS client-id metadata document URL. CIMD *MCPCIMDClientProfile `yaml:"cimd"` - // DCR declares an RFC 8414 metadata URL for RFC 7591 registration. + // DCR selects dynamic registration: direct/global profiles require an empty payload, discover from issuer, and use omitted scopes as openid; broker profiles require discovery_url and nonempty scopes. Ready direct-DCR identity drift is reset-required and uses --reset-dcr-registration; pending identity drift is pending-identity-mismatch and cannot reset or retry until the matching profile, principal, canonical resource, and exact issuer are restored. Corrupt direct-DCR state is not resettable: preserve its records and configuration without editing, deleting, or renaming them, then contact the deployment operator or support team with only the server name and redacted command error—never credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. DCR *MCPDCRClientProfile `yaml:"dcr"` } @@ -529,9 +529,9 @@ type MCPCIMDClientProfile struct { DocumentURL string `yaml:"document_url"` } -// MCPDCRClientProfile contains the HTTPS RFC 8414 discovery document URL. +// MCPDCRClientProfile carries broker-only RFC 8414 discovery metadata; direct/global DCR requires an empty payload. type MCPDCRClientProfile struct { - // DiscoveryURL is the required HTTPS authorization-server metadata URL. + // DiscoveryURL is required for broker DCR and forbidden for direct/global DCR, which discovers from issuer instead. DiscoveryURL string `yaml:"discovery_url"` } @@ -647,7 +647,7 @@ func (s *MCPServerProfile) UnmarshalYAML(node ast.Node) error { return errors.New("mcp.servers[].auth.oauth.client.cimd.document_url origin must match the issuer or resource origin, or appear in mcp.servers[].auth.oauth.network.additional_origins") } } - if client := s.Auth.OAuth.Client.DCR; client != nil { + if client := s.Auth.OAuth.Client.DCR; client != nil && client.DiscoveryURL != "" { discovery, _ := url.Parse(client.DiscoveryURL) if _, ok := allowed[mcpURLOrigin(discovery)]; !ok { return errors.New("mcp.servers[].auth.oauth.client.dcr.discovery_url origin must match the issuer or resource origin, or appear in mcp.servers[].auth.oauth.network.additional_origins") @@ -718,8 +718,8 @@ func (o *MCPOAuthProfile) UnmarshalYAML(node ast.Node) error { if o.Upstream != nil && o.Upstream.Mode == mcpOAuth2Mode && mappingHasKey(node, "issuer") { return errors.New("mcp.servers[].auth.oauth.issuer is forbidden for oauth2 upstream") } - if err := o.validateDCRUpstream(node); err != nil { - return err + if len(o.Scopes) == 0 && o.Client.Mode != "dcr" { + return errors.New("mcp.servers[].auth.oauth.scopes is required") } if o.Profile != "" { if err := validateMCPSafeValue("mcp.servers[].auth.oauth.profile", o.Profile); err != nil { @@ -736,9 +736,6 @@ func (o *MCPOAuthProfile) UnmarshalYAML(node ast.Node) error { return err } } - if len(o.Scopes) == 0 { - return errors.New("mcp.servers[].auth.oauth.scopes is required") - } for _, scope := range o.Scopes { if err := validateMCPSafeValue("mcp.servers[].auth.oauth.scopes[]", scope); err != nil { return err @@ -750,19 +747,6 @@ func (o *MCPOAuthProfile) UnmarshalYAML(node ast.Node) error { return nil } -func (o *MCPOAuthProfile) validateDCRUpstream(node ast.Node) error { - if o.Client.Mode != "dcr" { - return nil - } - if o.Upstream == nil || o.Upstream.Mode != mcpOAuth2Mode || o.Upstream.OAuth2 == nil { - return errors.New("mcp.servers[].auth.oauth.client.dcr requires an explicit oauth2 upstream") - } - if mappingHasKey(node, "issuer") { - return errors.New("mcp.servers[].auth.oauth.issuer is forbidden for dcr client") - } - return nil -} - func (o *MCPOAuthProfile) upstreamOrigins() []string { if o.Upstream != nil && o.Upstream.Mode == mcpOAuth2Mode && o.Upstream.OAuth2 != nil { authorize, _ := url.Parse(o.Upstream.OAuth2.AuthorizationEndpoint) @@ -889,11 +873,15 @@ func (c *MCPDCRClientProfile) strictFields() map[string]any { return map[string]any{"discovery_url": &c.DiscoveryURL} } -// UnmarshalYAML strictly decodes an HTTPS RFC 8414 discovery document URL. +// UnmarshalYAML strictly decodes the shared DCR payload. Direct authority uses +// an empty mapping; broker authority requires and validates discovery_url later. func (c *MCPDCRClientProfile) UnmarshalYAML(node ast.Node) error { if err := decodeStrictMapping(node, "mcp.servers[].auth.oauth.client.dcr", c.strictFields()); err != nil { return err } + if !mappingHasKey(node, "discovery_url") { + return nil + } u, err := validateMCPHTTPURL("mcp.servers[].auth.oauth.client.dcr.discovery_url", c.DiscoveryURL, true) if err != nil { return err diff --git a/internal/app/build.go b/internal/app/build.go index 889f375a3a..b82249ee49 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -5726,7 +5726,15 @@ func connectMCP(ctx context.Context, cfg Config) (*mcp.Manager, mcp.Provider, [] } onError := func(sc mcp.ServerConfig, err error) { - if errors.Is(err, mcp.ErrOAuthLoginRequired) { + switch mcp.OAuthDCRRecoveryCategoryOf(err) { + case mcp.OAuthDCRRecoveryResetRequired: + cfg.diag().Log(ctx, port.LevelWarn, "MCP OAuth DCR valid ready registration identity differs from current profile, principal, canonical resource, or exact issuer", "name", sc.Name, "remedy", "run "+mcpLoginRemedy(sc)+" --reset-dcr-registration") + return + case mcp.OAuthDCRRecoveryPendingIdentityMismatch: + cfg.diag().Log(ctx, port.LevelWarn, "MCP OAuth DCR pending registration identity mismatch", "name", sc.Name, "remedy", "restore the matching OAuth profile, principal, canonical resource, and exact issuer configuration, then run "+mcpLoginRemedy(sc)+" --retry-dcr-registration") + return + } + if errors.Is(err, mcp.ErrOAuthLoginRequired) || errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired) { cfg.diag().Log(ctx, port.LevelWarn, "MCP OAuth login required", "name", sc.Name, "remedy", mcpLoginRemedy(sc)) return } diff --git a/internal/app/mcp_dcr_acceptance_proofs_test.go b/internal/app/mcp_dcr_acceptance_proofs_test.go new file mode 100644 index 0000000000..4699f10850 --- /dev/null +++ b/internal/app/mcp_dcr_acceptance_proofs_test.go @@ -0,0 +1,444 @@ +package app_test + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/mockllm" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/internal/adapter/mcp" + "github.com/stacklok/mecatl/internal/adapter/permconfig" + "github.com/stacklok/mecatl/internal/app" + "github.com/stacklok/mecatl/mcp/oauthlogin" +) + +func writeDCRSettings(t *testing.T, fixture *loginFixture, root string) (string, func(string) (string, bool)) { + t.Helper() + key := base64.StdEncoding.EncodeToString([]byte("dcr-acceptance-encryption-key-32")) + settings := filepath.Join(t.TempDir(), "settings.yaml") + body := fmt.Sprintf(`mcp: + mode: global + servers: + - name: protected + url: %q + auth: + mode: oauth + oauth: + profile: connector + principal: local-user + issuer: %q + client: {mode: dcr, dcr: {}} + scopes: [openid] + request_refresh_token: false + credentials: + mode: local + local: {root: %q, key_env: MECATL_DCR_KEY} + network: + additional_origins: [] + private_origins: [%q] + max_redirects: 0 +`, fixture.resource(), fixture.issuer(), root, fixture.origin()) + if err := os.WriteFile(settings, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return settings, func(name string) (string, bool) { + if name == "MECATL_DCR_KEY" { + return key, true + } + return "", false + } +} + +func loginDCRProfile(t *testing.T, fixture *loginFixture, settings string, lookup func(string) (string, bool), runtime *oauthlogin.Runtime) { + t.Helper() + resolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{settings}}) + profiles, err := loadAcceptanceMCPProfiles(t, resolver.OperatorMCP(), lookup) + if err != nil { + t.Fatal(err) + } + defer profiles.Close() + cfg, ok := profiles.OAuthServer("protected") + if !ok { + t.Fatal("DCR profile was not resolved") + } + mcp.TrustOAuthCertificateForTest(t, cfg.OAuth, fixture.server.Certificate()) + if err := app.LoginMCP(context.Background(), cfg, runtime); err != nil { + t.Fatalf("DCR login: %v", err) + } +} + +func TestDirectMCPDCR_RegistrationFailureCategorySurvivesLoginWrapping(t *testing.T) { + fixture := newDCRLoginFixture(t) + fixture.badDCRResponse = true + fixture.dcrRegAccess = "registration-secret-must-not-surface" + settings, lookup := writeDCRSettings(t, fixture, filepath.Join(t.TempDir(), "credentials")) + resolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{settings}}) + profiles, err := loadAcceptanceMCPProfiles(t, resolver.OperatorMCP(), lookup) + if err != nil { + t.Fatal(err) + } + defer profiles.Close() + cfg, ok := profiles.OAuthServer("protected") + if !ok { + t.Fatal("DCR profile was not resolved") + } + mcp.TrustOAuthCertificateForTest(t, cfg.OAuth, fixture.server.Certificate()) + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: &loginBrowser{client: fixture.server.Client()}}) + if err != nil { + t.Fatal(err) + } + + loginErr := app.LoginMCPWithOptions(context.Background(), cfg, runtime, app.MCPLoginOptions{}) + if !errors.Is(loginErr, mcp.ErrOAuthDCRRecoveryRequired) || + mcp.OAuthDCRRecoveryCategoryOf(loginErr) != mcp.OAuthDCRRecoveryResponseInvalid { + t.Fatalf("login error lost DCR recovery category: %v, category %v", loginErr, mcp.OAuthDCRRecoveryCategoryOf(loginErr)) + } + if !errors.Is(loginErr, app.ErrMCPLoginAuthorization) || errors.Is(loginErr, app.ErrMCPLoginConnect) { + t.Fatalf("DCR registration failure category = %v, want authorization and not connect", loginErr) + } + if strings.Contains(loginErr.Error(), fixture.dcrRegAccess) { + t.Fatalf("DCR registration failure leaked response content: %q", loginErr) + } +} + +func TestDirectMCPDCR_Scenario2_RegistersAuthorizesAndLists(t *testing.T) { + fixture := newDCRLoginFixture(t) + settings, lookup := writeDCRSettings(t, fixture, filepath.Join(t.TempDir(), "credentials")) + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + loginDCRProfile(t, fixture, settings, lookup, runtime) + fixture.mu.Lock() + register, token, basic, forms, authenticated := fixture.register, fixture.token, fixture.basicRequests, append([]url.Values(nil), fixture.tokenForms...), fixture.authorized + fixture.mu.Unlock() + if register != 1 || token != 1 || basic != 0 || browser.calls.Load() != 1 || authenticated == 0 { + t.Fatalf("login register=%d token=%d Basic=%d browser=%d authenticated=%d", register, token, basic, browser.calls.Load(), authenticated) + } + if len(forms) != 1 || forms[0].Get("client_id") != loginClientID || forms[0].Get("client_secret") != "" || forms[0].Get("client_assertion") != "" || forms[0].Get("code_verifier") == "" { + t.Fatalf("public token form = %v", forms) + } + + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, settings, lookup, diag, mockllm.New(mockllm.ToolCallTurn(session.NewToolCall("read", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("complete"))) + if err != nil { + t.Fatal(err) + } + defer built.Close() + surfaces, err := runAcceptanceTool(built, "use the listed read tool") + if err != nil { + t.Fatalf("run: %v diagnostics=%v surfaces=%v", err, diagnosticSurfaces(diag), surfaces) + } + if !strings.Contains(strings.Join(surfaces, "\n"), "fixture-ready") { + t.Fatalf("registered/authorized tool was not listed and callable: %v", surfaces) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + if fixture.register != 1 || fixture.token != 1 || fixture.basicRequests != 0 || fixture.toolCalls != 1 { + t.Fatalf("serving path register=%d token=%d Basic=%d tools=%d", fixture.register, fixture.token, fixture.basicRequests, fixture.toolCalls) + } +} + +func TestDirectMCPDCR_Scenario2_ReauthorizationRedirectAndScopeBinding(t *testing.T) { + fixture := newDCRLoginFixture(t) + root := filepath.Join(t.TempDir(), "credentials") + settings, lookup := writeDCRSettings(t, fixture, root) + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + loginDCRProfile(t, fixture, settings, lookup, runtime) + + resolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{settings}}) + profiles, err := loadAcceptanceMCPProfiles(t, resolver.OperatorMCP(), lookup) + if err != nil { + t.Fatal(err) + } + cfg, ok := profiles.OAuthServer("protected") + if !ok { + t.Fatal("DCR profile was not resolved") + } + mcp.TrustOAuthCertificateForTest(t, cfg.OAuth, fixture.server.Certificate()) + resolved, callbackPath, err := mcp.PrepareOAuthDCRLogin(context.Background(), cfg.URL, *cfg.OAuth, mcp.OAuthDCRLoginReuse) + if err != nil { + t.Fatal(err) + } + cfg.OAuth = &resolved + if err := profiles.Close(); err != nil { + t.Fatal(err) + } + expireAcceptanceCredential(t, root, base64.StdEncoding.EncodeToString([]byte("dcr-acceptance-encryption-key-32")), cfg) + + fixture.mu.Lock() + fixture.dcrAccessToken = "replacement-dcr-access" + beforeRegister, beforeToken, beforeRefresh := fixture.register, fixture.token, fixture.refresh + firstRegisteredURI := fixture.registeredURI + fixture.mu.Unlock() + loginDCRProfile(t, fixture, settings, lookup, runtime) + + browser.mu.Lock() + presented := append([]string(nil), browser.urls...) + browser.mu.Unlock() + if len(presented) != 2 { + t.Fatalf("explicit authorization presentations = %d, want 2", len(presented)) + } + first, err := url.Parse(presented[0]) + if err != nil { + t.Fatal(err) + } + second, err := url.Parse(presented[1]) + if err != nil { + t.Fatal(err) + } + firstRedirect, err := url.Parse(first.Query().Get("redirect_uri")) + if err != nil { + t.Fatal(err) + } + secondRedirect, err := url.Parse(second.Query().Get("redirect_uri")) + if err != nil { + t.Fatal(err) + } + registeredRedirect, err := url.Parse(firstRegisteredURI) + if err != nil { + t.Fatal(err) + } + if registeredRedirect.Path != callbackPath || firstRedirect.Path != callbackPath || secondRedirect.Path != callbackPath || firstRedirect.Port() == secondRedirect.Port() { + t.Fatalf("registration callback binding changed: registered=%q first=%q second=%q", firstRegisteredURI, firstRedirect, secondRedirect) + } + if first.Query().Get("state") == second.Query().Get("state") || first.Query().Get("code_challenge") == second.Query().Get("code_challenge") { + t.Fatal("explicit re-login reused state or PKCE challenge") + } + for _, authorization := range []*url.URL{first, second} { + if authorization.Query().Get("scope") != "openid" || authorization.Query().Get("resource") != fixture.resource() { + t.Fatalf("authorization scope/resource drift: %v", authorization.Query()) + } + } + fixture.mu.Lock() + registers, tokens, refreshes := fixture.register, fixture.token, fixture.refresh + fixture.mu.Unlock() + if registers != beforeRegister || tokens != beforeToken+1 || refreshes != beforeRefresh { + t.Fatalf("explicit re-login register=%d token=%d refresh=%d; before=%d/%d/%d", registers, tokens, refreshes, beforeRegister, beforeToken, beforeRefresh) + } + + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, settings, lookup, diag, mockllm.New(mockllm.ToolCallTurn(session.NewToolCall("reauthorized", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("complete"))) + if err != nil { + t.Fatal(err) + } + defer built.Close() + surfaces, err := runAcceptanceTool(built, "use the harmless read tool after explicit re-login") + if err != nil { + t.Fatalf("run: %v diagnostics=%v", err, diagnosticSurfaces(diag)) + } + if !strings.Contains(strings.Join(surfaces, "\n"), "fixture-ready") { + t.Fatalf("replacement grant did not authorize harmless tool use: %v", surfaces) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + if fixture.register != registers || fixture.token != tokens || fixture.refresh != refreshes || fixture.toolCalls == 0 { + t.Fatalf("post-login use register=%d token=%d refresh=%d tools=%d", fixture.register, fixture.token, fixture.refresh, fixture.toolCalls) + } +} + +func TestDirectMCPDCR_Scenario2_RestartRestoresRegistrationGrantAndReadTool(t *testing.T) { + fixture := newDCRLoginFixture(t) + root := filepath.Join(t.TempDir(), "credentials") + settings, lookup := writeDCRSettings(t, fixture, root) + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + loginDCRProfile(t, fixture, settings, lookup, runtime) + if browser.calls.Load() != 1 { + t.Fatalf("explicit presenter calls=%d", browser.calls.Load()) + } + + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, settings, lookup, diag, mockllm.New(mockllm.ToolCallTurn(session.NewToolCall("restart", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("complete"))) + if err != nil { + t.Fatal(err) + } + defer built.Close() + surfaces, err := runAcceptanceTool(built, "ordinary restart") + if err != nil { + t.Fatalf("run: %v diagnostics=%v surfaces=%v", err, diagnosticSurfaces(diag), surfaces) + } + projections := append(surfaces, diagnosticSurfaces(diag)...) + settingsBytes, readErr := os.ReadFile(settings) + if readErr != nil { + t.Fatal(readErr) + } + projections = append(projections, string(settingsBytes)) + joined := strings.Join(projections, "\n") + browser.mu.Lock() + presented := append([]string(nil), browser.urls...) + browser.mu.Unlock() + fixture.mu.Lock() + register, token, tools := fixture.register, fixture.token, fixture.toolCalls + fixture.mu.Unlock() + if browser.calls.Load() != 1 || register != 1 || token != 1 || tools != 1 { + t.Fatalf("restart presenter=%d register=%d token=%d tools=%d", browser.calls.Load(), register, token, tools) + } + if len(presented) != 1 { + t.Fatalf("explicit presenter URLs=%d", len(presented)) + } + u, _ := url.Parse(presented[0]) + if strings.Contains(joined, presented[0]) || strings.Contains(joined, u.Query().Get("state")) || strings.Contains(joined, "login-code-canary") { + t.Fatalf("authorization material escaped host presenter: %s", joined) + } +} + +func TestInvariant_direct_mcp_dcr_secret_redaction(t *testing.T) { + const ( + clientID = "app-client-id-redaction-canary" + accessToken = "app-access-token-redaction-canary" + registrationAccess = "app-registration-access-redaction-canary" + unsolicitedRefresh = "app-unsolicited-refresh-redaction-canary" + ) + fixture := newDCRLoginFixture(t) + fixture.dcrClientID, fixture.dcrAccessToken, fixture.dcrRegAccess = clientID, accessToken, registrationAccess + root := filepath.Join(t.TempDir(), "credentials") + settings, lookup := writeDCRSettings(t, fixture, root) + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + loginDCRProfile(t, fixture, settings, lookup, runtime) + browser.mu.Lock() + presented := append([]string(nil), browser.urls...) + browser.mu.Unlock() + if len(presented) != 1 { + t.Fatalf("presented URLs=%d", len(presented)) + } + presentedURL, err := url.Parse(presented[0]) + if err != nil { + t.Fatal(err) + } + transient := []string{presented[0], "login-code-canary", presentedURL.Query().Get("state"), registrationAccess, unsolicitedRefresh} + + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, settings, lookup, diag, mockllm.New(mockllm.ToolCallTurn(session.NewToolCall("redaction", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("complete"))) + if err != nil { + t.Fatal(err) + } + defer built.Close() + model, err := runAcceptanceTool(built, "redaction projection") + if err != nil { + t.Fatal(err) + } + projections := append(model, diagnosticSurfaces(diag)...) + + bad := newDCRLoginFixture(t) + bad.dcrClientID, bad.dcrAccessToken, bad.dcrRegAccess, bad.dcrRefreshToken = clientID, accessToken, registrationAccess, unsolicitedRefresh + badSettings, badLookup := writeDCRSettings(t, bad, filepath.Join(t.TempDir(), "bad-credentials")) + resolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{badSettings}}) + profiles, loadErr := loadAcceptanceMCPProfiles(t, resolver.OperatorMCP(), badLookup) + if loadErr != nil { + t.Fatal(loadErr) + } + defer profiles.Close() + badCfg, ok := profiles.OAuthServer("protected") + if !ok { + t.Fatal("bad DCR profile missing") + } + badBrowser := &loginBrowser{client: bad.server.Client()} + badRuntime, runtimeErr := oauthlogin.New(oauthlogin.Options{Launcher: badBrowser}) + if runtimeErr != nil { + t.Fatal(runtimeErr) + } + loginErr := app.LoginMCP(context.Background(), badCfg, badRuntime) + if loginErr == nil { + t.Fatal("unsolicited refresh token was accepted by app login") + } + projections = append(projections, loginErr.Error()) + + settingsBytes, err := os.ReadFile(settings) + if err != nil { + t.Fatal(err) + } + projections = append(projections, string(settingsBytes)) + for _, projection := range projections { + for _, secret := range append([]string{clientID, accessToken}, transient...) { + if secret != "" && strings.Contains(projection, secret) { + t.Fatalf("non-credential projection leaked %q", secret) + } + } + } + if err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil || info.IsDir() { + return walkErr + } + value, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, secret := range append([]string{clientID, accessToken}, transient...) { + if secret != "" && strings.Contains(string(value), secret) { + t.Fatalf("encrypted credential artifact %s exposed %q", path, secret) + } + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestDirectMCPDCR_Scenario3_RestartIdentityMismatchFailsClosed(t *testing.T) { + fixture := newDCRLoginFixture(t) + fixture.dcrClientID = "restart-client-canary" + fixture.dcrAccessToken = "restart-access-canary" + root := filepath.Join(t.TempDir(), "credentials") + settings, lookup := writeDCRSettings(t, fixture, root) + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + loginDCRProfile(t, fixture, settings, lookup, runtime) + body, err := os.ReadFile(settings) + if err != nil { + t.Fatal(err) + } + mismatchSettings := filepath.Join(t.TempDir(), "settings.yaml") + if err := os.WriteFile(mismatchSettings, []byte(strings.Replace(string(body), "principal: local-user", "principal: other-user", 1)), 0o600); err != nil { + t.Fatal(err) + } + + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, mismatchSettings, lookup, diag, mockllm.New(mockllm.ToolCallTurn(session.NewToolCall("mismatch", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("complete"))) + if err != nil { + t.Fatal(err) + } + defer built.Close() + model, runErr := runAcceptanceTool(built, "restart with mismatched identity") + projections := append(model, diagnosticSurfaces(diag)...) + if runErr != nil { + projections = append(projections, runErr.Error()) + } + fixture.mu.Lock() + registrations, tokens, tools := fixture.register, fixture.token, fixture.toolCalls + fixture.mu.Unlock() + if browser.calls.Load() != 1 || registrations != 1 || tokens != 1 || tools != 0 { + t.Fatalf("mismatch performed hidden work: browser=%d register=%d token=%d tools=%d", browser.calls.Load(), registrations, tokens, tools) + } + joined := strings.Join(projections, "\n") + if !strings.Contains(joined, "login required") && !strings.Contains(joined, "unknown tool") { + t.Fatalf("host output omitted safe failure category: %s", joined) + } + for _, secret := range []string{"restart-client-canary", "restart-access-canary", "login-code-canary"} { + if strings.Contains(joined, secret) { + t.Fatalf("host mismatch output leaked %q", secret) + } + } +} diff --git a/internal/app/mcp_oauth_acceptance_test.go b/internal/app/mcp_oauth_acceptance_test.go index c8938b4822..c1bb433b91 100644 --- a/internal/app/mcp_oauth_acceptance_test.go +++ b/internal/app/mcp_oauth_acceptance_test.go @@ -2,8 +2,10 @@ package app_test import ( "context" + "crypto/x509" "encoding/base64" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -360,6 +362,185 @@ func TestMCPOAuthHermeticAcceptance(t *testing.T) { } } +func TestDirectMCPDCRLifecycleRecoveryAcceptance(t *testing.T) { + fixture := newDCRLoginFixture(t) + credentialRoot := filepath.Join(t.TempDir(), "credentials") + settings, lookup, _ := writeDCRAcceptanceOAuthSettings(t, fixture, credentialRoot, "stable") + + load := func(settings string) (*cliconfig.MCPProfiles, mcp.ServerConfig) { + resolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{settings}}) + profiles, err := loadAcceptanceMCPProfiles(t, resolver.OperatorMCP(), lookup) + if err != nil { + t.Fatal(err) + } + server, ok := profiles.OAuthServer("protected") + if !ok { + profiles.Close() + t.Fatal("DCR server was not resolved") + } + mcp.TrustOAuthCertificateForTest(t, server.OAuth, fixture.server.Certificate()) + return profiles, server + } + login := func(server mcp.ServerConfig, action mcp.OAuthDCRLoginAction) { + browser := &loginBrowser{client: fixture.server.Client()} + runtime, err := oauthlogin.New(oauthlogin.Options{Launcher: browser}) + if err != nil { + t.Fatal(err) + } + if err := app.LoginMCPWithOptions(context.Background(), server, runtime, app.MCPLoginOptions{DCRAction: action}); err != nil { + t.Fatal(err) + } + if browser.calls.Load() != 1 { + t.Fatalf("browser calls = %d, want 1", browser.calls.Load()) + } + } + + profiles, server := load(settings) + login(server, mcp.OAuthDCRLoginReuse) + if err := profiles.Close(); err != nil { + t.Fatal(err) + } + seeded := fixture.snapshot() + if seeded.registered != 1 || seeded.authorize != 1 || seeded.token != 1 || seeded.toolCalls != 0 { + t.Fatalf("initial DCR login did not complete registration/authorization: %+v", seeded) + } + + drifted, _, _ := writeDCRAcceptanceOAuthSettings(t, fixture, credentialRoot, "drifted") + profiles, server = load(drifted) + driftBrowser := &loginBrowser{client: fixture.server.Client()} + driftRuntime, err := oauthlogin.New(oauthlogin.Options{Launcher: driftBrowser}) + if err != nil { + t.Fatal(err) + } + if err := app.LoginMCP(context.Background(), server, driftRuntime); !errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired) || mcp.OAuthDCRRecoveryCategoryOf(err) != mcp.OAuthDCRRecoveryResetRequired { + t.Fatalf("identity drift login error = %v, want reset-required recovery", err) + } + if err := profiles.Close(); err != nil { + t.Fatal(err) + } + diag := &acceptanceDiag{} + built, err := buildDCRAcceptanceMCP(t, fixture, drifted, lookup, diag, mockllm.New(mockllm.TextTurn("unreached"))) + if err != nil { + t.Fatal(err) + } + built.Close() + afterDrift := fixture.snapshot() + if driftBrowser.calls.Load() != 0 || afterDrift.registered != seeded.registered || afterDrift.authorize != seeded.authorize || afterDrift.token != seeded.token || afterDrift.toolCalls != seeded.toolCalls || afterDrift.authenticated != seeded.authenticated { + t.Fatalf("identity drift had OAuth or protected-tool side effects: before=%+v after=%+v", seeded, afterDrift) + } + if got := strings.Join(diagnosticSurfaces(diag), "\n"); !strings.Contains(got, "MCP OAuth DCR valid ready registration identity differs from current profile, principal, canonical resource, or exact issuer") || !strings.Contains(got, "--reset-dcr-registration") { + t.Fatalf("identity drift omitted reset-required diagnostic: %q", diagnosticSurfaces(diag)) + } + + profiles, server = load(drifted) + login(server, mcp.OAuthDCRLoginResetRegistration) + if err := profiles.Close(); err != nil { + t.Fatal(err) + } + afterReset := fixture.snapshot() + if afterReset.registered != seeded.registered+1 || afterReset.authorize != seeded.authorize+1 || afterReset.token != seeded.token+1 { + t.Fatalf("reset did not continue through registration and authorization: before=%+v after=%+v", seeded, afterReset) + } + verifyDCRAcceptanceRead(t, fixture, drifted, lookup) + + retrySettings, _, _ := writeDCRAcceptanceOAuthSettings(t, fixture, filepath.Join(t.TempDir(), "retry-credentials"), "retry") + profiles, server = load(retrySettings) + if _, _, err := mcp.PrepareOAuthDCRLogin(context.Background(), server.URL, *server.OAuth, mcp.OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + beforeRetry := fixture.snapshot() + login(server, mcp.OAuthDCRLoginRetryRegistration) + if err := profiles.Close(); err != nil { + t.Fatal(err) + } + afterRetry := fixture.snapshot() + if afterRetry.registered != beforeRetry.registered+1 || afterRetry.authorize != beforeRetry.authorize+1 || afterRetry.token != beforeRetry.token+1 { + t.Fatalf("retry did not continue through registration and authorization: before=%+v after=%+v", beforeRetry, afterRetry) + } + verifyDCRAcceptanceRead(t, fixture, retrySettings, lookup) + + pendingRoot := filepath.Join(t.TempDir(), "pending-credentials") + pendingSettings, pendingLookup, _ := writeDCRAcceptanceOAuthSettings(t, fixture, pendingRoot, "pending") + pendingResolver := permconfig.New(permconfig.Options{ExplicitFiles: []string{pendingSettings}}) + pendingProfiles, err := loadAcceptanceMCPProfiles(t, pendingResolver.OperatorMCP(), pendingLookup) + if err != nil { + t.Fatal(err) + } + pendingServer, ok := pendingProfiles.OAuthServer("protected") + if !ok { + t.Fatal("pending DCR server was not resolved") + } + mcp.TrustOAuthCertificateForTest(t, pendingServer.OAuth, fixture.server.Certificate()) + if _, _, err := mcp.PrepareOAuthDCRLogin(context.Background(), pendingServer.URL, *pendingServer.OAuth, mcp.OAuthDCRLoginReuse); err != nil { + t.Fatal(err) + } + if err := pendingProfiles.Close(); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(pendingSettings) + if err != nil { + t.Fatal(err) + } + body = []byte(strings.Replace(string(body), `profile: "pending"`, `profile: "other-profile"`, 1)) + if err := os.WriteFile(pendingSettings, body, 0o600); err != nil { + t.Fatal(err) + } + pendingDiag := &acceptanceDiag{} + pendingBuilt, err := buildDCRAcceptanceMCP(t, fixture, pendingSettings, pendingLookup, pendingDiag, mockllm.New(mockllm.TextTurn("unreached"))) + if err != nil { + t.Fatal(err) + } + pendingBuilt.Close() + got := strings.Join(diagnosticSurfaces(pendingDiag), "\n") + for _, want := range []string{"MCP OAuth DCR pending registration identity mismatch", "restore the matching OAuth profile, principal, canonical resource, and exact issuer configuration", "--retry-dcr-registration"} { + if !strings.Contains(got, want) { + t.Fatalf("pending identity mismatch diagnostic = %q, want %q", got, want) + } + } + if strings.Contains(got, "--reset-dcr-registration") { + t.Fatalf("pending identity mismatch diagnostic suggested reset: %q", got) + } +} + +func writeDCRAcceptanceOAuthSettings(t *testing.T, fixture *loginFixture, root, profile string) (string, func(string) (string, bool), string) { + t.Helper() + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + settings := filepath.Join(t.TempDir(), "settings.yaml") + body := fmt.Sprintf(`mcp: + servers: + - name: protected + url: %q + auth: + mode: oauth + oauth: + profile: %q + principal: operator + issuer: %q + client: {mode: dcr, dcr: {}} + credentials: {mode: local, local: {root: %q, key_env: MECATL_DCR_ACCEPTANCE_KEY}} + network: {additional_origins: [], private_origins: [%q], max_redirects: 0} +`, fixture.resource(), profile, fixture.issuer(), root, fixture.origin()) + if err := os.WriteFile(settings, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return settings, func(name string) (string, bool) { return key, name == "MECATL_DCR_ACCEPTANCE_KEY" }, key +} + +func verifyDCRAcceptanceRead(t *testing.T, fixture *loginFixture, settings string, lookup func(string) (string, bool)) { + t.Helper() + built, err := buildDCRAcceptanceMCP(t, fixture, settings, lookup, &acceptanceDiag{}, mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("read", "mcp__protected__ready", []byte(`{}`))), mockllm.TextTurn("read complete"), + )) + if err != nil { + t.Fatal(err) + } + defer built.Close() + surfaces, err := runAcceptanceTool(built, "DCR read") + if err != nil || !strings.Contains(strings.Join(surfaces, "\n"), "fixture-ready") { + t.Fatalf("authenticated DCR MCP read = %q, %v", surfaces, err) + } +} + func loadAcceptanceMCPProfiles(t *testing.T, operator *permconfig.MCPSection, lookup func(string) (string, bool)) (*cliconfig.MCPProfiles, error) { t.Helper() profiles, err := cliconfig.LoadMCPProfiles(cliconfig.MCPProfileLoadOptions{Operator: operator, LookupEnv: lookup}) @@ -377,6 +558,7 @@ func loadAcceptanceMCPProfiles(t *testing.T, operator *permconfig.MCPSection, lo type acceptanceLoopbackProfileResolver struct { t *testing.T loader *cliconfig.MCPProfileResolver + cert *x509.Certificate } func (r *acceptanceLoopbackProfileResolver) Load(operator *permconfig.MCPSection) ([]mcp.ServerConfig, interface{ Close() error }, error) { @@ -387,6 +569,7 @@ func (r *acceptanceLoopbackProfileResolver) Load(operator *permconfig.MCPSection for i := range servers { if servers[i].OAuth != nil { mcp.AllowOAuthLoopbackForTest(r.t, servers[i].OAuth) + mcp.TrustOAuthCertificateForTest(r.t, servers[i].OAuth, r.cert) } } return servers, lifecycle, nil @@ -431,16 +614,26 @@ func writeAcceptanceOAuthSettings(t *testing.T, fixture *loginFixture, root stri func buildAcceptanceMCP(t *testing.T, settings string, lookup func(string) (string, bool), diag *acceptanceDiag, llm *mockllm.Provider) (*app.Built, error) { t.Helper() - return buildAcceptanceMCPConfig(t, settings, lookup, diag, llm, false) + return buildAcceptanceMCPConfigWithCert(t, settings, lookup, diag, llm, false, nil) +} + +func buildDCRAcceptanceMCP(t *testing.T, fixture *loginFixture, settings string, lookup func(string) (string, bool), diag *acceptanceDiag, llm *mockllm.Provider) (*app.Built, error) { + t.Helper() + return buildAcceptanceMCPConfigWithCert(t, settings, lookup, diag, llm, false, fixture.server.Certificate()) } func buildAcceptanceMCPConfig(t *testing.T, settings string, lookup func(string) (string, bool), diag *acceptanceDiag, llm *mockllm.Provider, headless bool) (*app.Built, error) { + t.Helper() + return buildAcceptanceMCPConfigWithCert(t, settings, lookup, diag, llm, headless, nil) +} + +func buildAcceptanceMCPConfigWithCert(t *testing.T, settings string, lookup func(string) (string, bool), diag *acceptanceDiag, llm *mockllm.Provider, headless bool, cert *x509.Certificate) (*app.Built, error) { t.Helper() return app.Build(context.Background(), app.Config{ Workspace: filepath.Dir(settings), Model: "mock", MockProvider: llm, Headless: headless, Diagnostics: diag, PermissionConfigs: []string{settings}, MCPProfileLoader: &acceptanceLoopbackProfileResolver{ - t: t, loader: cliconfig.NewMCPProfileResolver(nil, lookup), + t: t, loader: cliconfig.NewMCPProfileResolver(nil, lookup), cert: cert, }, }) } @@ -642,3 +835,25 @@ func assertNoAcceptanceSecrets(t *testing.T, surfaces []string, canaries map[str } } } + +func TestADR_0325_DirectDCRConfigurationReference(t *testing.T) { + reference, err := os.ReadFile(filepath.Join("..", "..", "user-docs", "reference", "configuration.md")) + if err != nil { + t.Fatal(err) + } + section := string(reference) + for _, want := range []string{ + "direct/global profiles require an empty payload, discover from issuer", + "broker profiles require discovery_url and nonempty scopes", + "DiscoveryURL is required for broker DCR and forbidden for direct/global DCR", + "direct/global DCR may omit it and uses exactly openid", + "direct/global DCR defaults false and rejects true", + "Ready direct-DCR identity drift is reset-required and uses --reset-dcr-registration", + "pending identity drift is pending-identity-mismatch and cannot reset or retry until the matching profile, principal, canonical resource, and exact issuer are restored", + "Corrupt direct-DCR state is not resettable", + } { + if !strings.Contains(section, want) { + t.Fatalf("configuration reference omitted %q", want) + } + } +} diff --git a/internal/app/mcplogin.go b/internal/app/mcplogin.go index b7f9114acf..1a8b9e164d 100644 --- a/internal/app/mcplogin.go +++ b/internal/app/mcplogin.go @@ -53,24 +53,58 @@ func loginDiagnostic(category, diagnostic error) error { diagnostic = rejected.Sanitized() case errors.As(diagnostic, &bind): diagnostic = &oauthlogin.CallbackBindError{Reason: bind.Reason} + case errors.Is(diagnostic, mcp.ErrOAuthDCRRecoveryRequired): + diagnostic = mcp.NewOAuthDCRRecoveryError(mcp.OAuthDCRRecoveryCategoryOf(diagnostic)) default: return category } return &mcpLoginDiagnostic{category: category, diagnostic: diagnostic} } +// MCPLoginOptions selects an explicit DCR registration recovery action. +type MCPLoginOptions struct { + DCRAction mcp.OAuthDCRLoginAction +} + // LoginMCP runs one host-authorized OAuth login against an already-resolved MCP // server configuration. The runtime and credential store are borrowed. A nil // error means the authenticated MCP initialize and initial tool listing // completed, a usable credential was durably stored or restored, and the // temporary server/controller were closed. func LoginMCP(ctx context.Context, cfg mcp.ServerConfig, runtime *oauthlogin.Runtime) error { + return LoginMCPWithOptions(ctx, cfg, runtime, MCPLoginOptions{}) +} + +// LoginMCPWithOptions runs LoginMCP with an explicit DCR registration action. +func LoginMCPWithOptions(ctx context.Context, cfg mcp.ServerConfig, runtime *oauthlogin.Runtime, opts MCPLoginOptions) error { if err := validateMCPLoginConfig(cfg, runtime); err != nil { return err } + if opts.DCRAction > mcp.OAuthDCRLoginRetryRegistration { + return ErrMCPLoginConfig + } + if cfg.OAuth.Client.DCR == nil { + if opts.DCRAction != mcp.OAuthDCRLoginReuse { + return ErrMCPLoginConfig + } + return loginMCPAuthorize(cfg, func(authorize oauthlogin.AuthorizeFunc) error { + return runtime.Authorize(ctx, cfg.OAuth.Issuer, authorize) + }) + } + prepared, callbackPath, err := mcp.PrepareOAuthDCRLogin(ctx, cfg.URL, *cfg.OAuth, opts.DCRAction) + if err != nil { + return loginDiagnostic(ErrMCPLoginAuthorization, err) + } + cfg.OAuth = &prepared + return loginMCPAuthorize(cfg, func(authorize oauthlogin.AuthorizeFunc) error { + return runtime.AuthorizeWithCallbackPath(ctx, cfg.OAuth.Issuer, callbackPath, authorize) + }) +} +func loginMCPAuthorize(cfg mcp.ServerConfig, run func(oauthlogin.AuthorizeFunc) error) error { var operationCategory error - err := runtime.Authorize(ctx, cfg.OAuth.Issuer, func(ctx context.Context, redirectURL string, present func(context.Context, string) (oauthlogin.Result, error)) error { + var operationDiagnostic error + err := run(func(ctx context.Context, redirectURL string, present func(context.Context, string) (oauthlogin.Result, error)) error { loginCfg := cfg oauth := *cfg.OAuth oauth.RedirectURL = redirectURL @@ -79,7 +113,8 @@ func LoginMCP(ctx context.Context, cfg mcp.ServerConfig, runtime *oauthlogin.Run server, err := mcp.Connect(ctx, loginCfg, nil) if err != nil { - if errors.Is(err, mcp.ErrOAuthLoginRequired) || errors.Is(err, mcp.ErrOAuthUnavailable) { + operationDiagnostic = err + if errors.Is(err, mcp.ErrOAuthLoginRequired) || errors.Is(err, mcp.ErrOAuthUnavailable) || errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired) { operationCategory = ErrMCPLoginAuthorization } else { operationCategory = ErrMCPLoginConnect @@ -105,6 +140,9 @@ func LoginMCP(ctx context.Context, cfg mcp.ServerConfig, runtime *oauthlogin.Run return err } if operationCategory != nil { + if operationDiagnostic != nil { + err = operationDiagnostic + } return loginDiagnostic(operationCategory, err) } if errors.Is(err, oauthlogin.ErrAuthorizationFailed) { diff --git a/internal/app/mcplogin_diagnostic_test.go b/internal/app/mcplogin_diagnostic_test.go index 3876cda56b..c342f18b9f 100644 --- a/internal/app/mcplogin_diagnostic_test.go +++ b/internal/app/mcplogin_diagnostic_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/stacklok/mecatl/internal/adapter/mcp" "github.com/stacklok/mecatl/mcp/oauthlogin" ) @@ -59,4 +60,12 @@ func TestMCPLoginDiagnosticPreservesCategoryAndSafeDetail(t *testing.T) { if unknown != ErrMCPLoginAuthorization || strings.Contains(unknown.Error(), unknownCanary) { t.Fatalf("unknown diagnostic was not collapsed: %v", unknown) } + + t.Run("DCR recovery category remains safe and available to the CLI", func(t *testing.T) { + err := loginDiagnostic(ErrMCPLoginAuthorization, mcp.NewOAuthDCRRecoveryError(mcp.OAuthDCRRecoveryResponseInvalid)) + if !errors.Is(err, ErrMCPLoginAuthorization) || !errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired) || + mcp.OAuthDCRRecoveryCategoryOf(err) != mcp.OAuthDCRRecoveryResponseInvalid { + t.Fatalf("DCR recovery category was not preserved: %v, category %v", err, mcp.OAuthDCRRecoveryCategoryOf(err)) + } + }) } diff --git a/internal/app/mcplogin_test.go b/internal/app/mcplogin_test.go index 278b778659..936a752408 100644 --- a/internal/app/mcplogin_test.go +++ b/internal/app/mcplogin_test.go @@ -48,10 +48,15 @@ type loginBrowser struct { max atomic.Int32 delay time.Duration block bool + mu sync.Mutex + urls []string } func (b *loginBrowser) Open(ctx context.Context, authorizationURL string) error { b.calls.Add(1) + b.mu.Lock() + b.urls = append(b.urls, authorizationURL) + b.mu.Unlock() active := b.active.Add(1) defer b.active.Add(-1) for current := b.max.Load(); active > current && !b.max.CompareAndSwap(current, active); current = b.max.Load() { @@ -83,29 +88,39 @@ func (b *loginBrowser) Open(ctx context.Context, authorizationURL string) error } type loginFixture struct { - server *httptest.Server - mcpServer *mcpsdk.Server - mcpHandler http.Handler - mu sync.Mutex - codes map[string]loginCode - authorize int - token int - refresh int - refreshTokens map[string]int - metadata int - authorized int - toolCalls int - unexpectedAuth int - sessionsOpened int - sessionsClosed int - failToken bool - keepRejecting bool - publicResource bool - acceptedBearer string - initialExpiry int - failClose bool - mcpBlockState *loginMCPBlockState - redirectURL string + server *httptest.Server + mcpServer *mcpsdk.Server + mcpHandler http.Handler + mu sync.Mutex + codes map[string]loginCode + authorize int + token int + refresh int + refreshTokens map[string]int + metadata int + authorized int + toolCalls int + unexpectedAuth int + sessionsOpened int + sessionsClosed int + failToken bool + keepRejecting bool + publicResource bool + acceptedBearer string + initialExpiry int + failClose bool + mcpBlockState *loginMCPBlockState + redirectURL string + dcr bool + badDCRResponse bool + register int + basicRequests int + tokenForms []url.Values + registeredURI string + dcrClientID string + dcrAccessToken string + dcrRegAccess string + dcrRefreshToken string } type loginMCPBlockState struct { @@ -124,6 +139,16 @@ type loginCode struct { } func newLoginFixture(t *testing.T) *loginFixture { + return newLoginFixtureWithTLS(t, false) +} + +func newDCRLoginFixture(t *testing.T) *loginFixture { + f := newLoginFixtureWithTLS(t, true) + f.dcr = true + return f +} + +func newLoginFixtureWithTLS(t *testing.T, useTLS bool) *loginFixture { t.Helper() f := &loginFixture{codes: make(map[string]loginCode), refreshTokens: make(map[string]int), acceptedBearer: loginAccessToken, initialExpiry: 3600} f.mcpServer = mcpsdk.NewServer( @@ -142,9 +167,14 @@ func newLoginFixture(t *testing.T) *loginFixture { return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fixture-ready"}}}, nil, nil }) f.mcpHandler = f.newMCPHandler() - f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.serveHTTP(w, r) - })) + }) + if useTLS { + f.server = httptest.NewTLSServer(handler) + } else { + f.server = httptest.NewServer(handler) + } t.Cleanup(f.server.Close) return f } @@ -158,7 +188,7 @@ func (f *loginFixture) newMCPHandler() http.Handler { } type loginFixtureCounts struct { - authorize, token, refresh, metadata, authenticated, toolCalls, unexpectedAuth, opened, closed int + authorize, token, refresh, metadata, authenticated, toolCalls, unexpectedAuth, registered, opened, closed int } func (f *loginFixture) snapshot() loginFixtureCounts { @@ -166,7 +196,7 @@ func (f *loginFixture) snapshot() loginFixtureCounts { defer f.mu.Unlock() return loginFixtureCounts{ authorize: f.authorize, token: f.token, refresh: f.refresh, metadata: f.metadata, - authenticated: f.authorized, toolCalls: f.toolCalls, unexpectedAuth: f.unexpectedAuth, + authenticated: f.authorized, toolCalls: f.toolCalls, unexpectedAuth: f.unexpectedAuth, registered: f.register, opened: f.sessionsOpened, closed: f.sessionsClosed, } } @@ -239,7 +269,11 @@ func (f *loginFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { f.unexpectedAuth++ f.mu.Unlock() } - w.Header().Set("WWW-Authenticate", `Bearer scope="read"`) + scope := "read" + if f.dcr { + scope = "openid" + } + w.Header().Set("WWW-Authenticate", `Bearer scope="`+scope+`"`) w.WriteHeader(http.StatusUnauthorized) return } @@ -279,21 +313,34 @@ func (f *loginFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { f.metadata++ f.mu.Unlock() w.Header().Set("Content-Type", "application/json") + scopes := []string{"read"} + if f.dcr { + scopes = []string{"openid"} + } _ = json.NewEncoder(w).Encode(oauthex.ProtectedResourceMetadata{ - Resource: f.resource(), AuthorizationServers: []string{f.issuer()}, ScopesSupported: []string{"read"}, + Resource: f.resource(), AuthorizationServers: []string{f.issuer()}, ScopesSupported: scopes, }) case strings.Contains(r.URL.Path, ".well-known/oauth-authorization-server"): f.mu.Lock() f.metadata++ f.mu.Unlock() w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(oauthex.AuthServerMeta{ + meta := oauthex.AuthServerMeta{ Issuer: f.issuer(), AuthorizationEndpoint: f.origin() + "/as/authorize", TokenEndpoint: f.origin() + "/as/token", ScopesSupported: []string{"read"}, ResponseTypesSupported: []string{"code"}, GrantTypesSupported: []string{"authorization_code", "refresh_token"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, CodeChallengeMethodsSupported: []string{"S256"}, AuthorizationResponseIssParameterSupported: true, - }) + } + if f.dcr { + meta.RegistrationEndpoint = f.origin() + "/as/register" + meta.ScopesSupported = []string{"openid"} + meta.GrantTypesSupported = []string{"authorization_code"} + meta.TokenEndpointAuthMethodsSupported = []string{"none"} + } + _ = json.NewEncoder(w).Encode(meta) case strings.Contains(r.URL.Path, ".well-known/openid-configuration"): http.NotFound(w, r) + case r.URL.Path == "/as/register": + f.serveRegister(w, r) case r.URL.Path == "/as/authorize": f.serveAuthorize(w, r) case r.URL.Path == "/as/token": @@ -303,9 +350,44 @@ func (f *loginFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { } } +func (f *loginFixture) serveRegister(w http.ResponseWriter, r *http.Request) { + var request oauthex.ClientRegistrationMetadata + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid registration", http.StatusBadRequest) + return + } + f.mu.Lock() + f.register++ + if len(request.RedirectURIs) == 1 { + f.registeredURI = request.RedirectURIs[0] + } + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + clientID := f.dcrClientID + if clientID == "" { + clientID = loginClientID + } + response := map[string]any{"client_id": clientID, "token_endpoint_auth_method": "none", "redirect_uris": request.RedirectURIs, "grant_types": request.GrantTypes, "response_types": request.ResponseTypes, "scope": request.Scope} + if f.badDCRResponse { + response["token_endpoint_auth_method"] = "client_secret_basic" + response["client_secret"] = f.dcrRegAccess + } + if f.dcrRegAccess != "" { + response["registration_access_token"] = f.dcrRegAccess + } + _ = json.NewEncoder(w).Encode(response) +} + func (f *loginFixture) serveAuthorize(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - if q.Get("client_id") != loginClientID || q.Get("response_type") != "code" || q.Get("code_challenge_method") != "S256" || q.Get("resource") != f.resource() { + wantScope, wantClientID := "read", loginClientID + if f.dcr { + wantScope = "openid" + if f.dcrClientID != "" { + wantClientID = f.dcrClientID + } + } + if q.Get("client_id") != wantClientID || q.Get("response_type") != "code" || q.Get("code_challenge_method") != "S256" || q.Get("resource") != f.resource() || q.Get("scope") != wantScope { http.Error(w, "invalid authorization request", http.StatusBadRequest) return } @@ -342,6 +424,36 @@ func (f *loginFixture) serveToken(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid form", http.StatusBadRequest) return } + if f.dcr { + _, _, basic := r.BasicAuth() + f.mu.Lock() + if basic { + f.basicRequests++ + } + f.tokenForms = append(f.tokenForms, r.PostForm) + f.mu.Unlock() + wantClientID := f.dcrClientID + if wantClientID == "" { + wantClientID = loginClientID + } + if basic || r.Form.Get("client_id") != wantClientID || r.Form.Get("client_secret") != "" || r.Form.Get("client_assertion") != "" || r.Form.Get("grant_type") != "authorization_code" { + http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) + return + } + access := f.dcrAccessToken + if access == "" { + access = loginAccessToken + } + f.mu.Lock() + f.acceptedBearer = access + f.mu.Unlock() + response := map[string]any{"access_token": access, "token_type": "Bearer", "expires_in": initialExpiry, "scope": "openid"} + if f.dcrRefreshToken != "" { + response["refresh_token"] = f.dcrRefreshToken + } + f.finishCodeExchange(w, r, response) + return + } clientID, secret, ok := r.BasicAuth() clientID, _ = url.QueryUnescape(clientID) secret, _ = url.QueryUnescape(secret) @@ -370,6 +482,10 @@ func (f *loginFixture) serveToken(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"access_token": accessToken, "token_type": "Bearer", "refresh_token": successorRefreshToken, "expires_in": 3600, "scope": "read"}) return } + f.finishCodeExchange(w, r, map[string]any{"access_token": loginAccessToken, "token_type": "Bearer", "refresh_token": loginRefreshToken, "expires_in": initialExpiry, "scope": "read"}) +} + +func (f *loginFixture) finishCodeExchange(w http.ResponseWriter, r *http.Request, response map[string]any) { f.mu.Lock() record, found := f.codes[r.Form.Get("code")] if found && !record.used { @@ -385,7 +501,7 @@ func (f *loginFixture) serveToken(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"access_token": loginAccessToken, "token_type": "Bearer", "refresh_token": loginRefreshToken, "expires_in": initialExpiry, "scope": "read"}) + _ = json.NewEncoder(w).Encode(response) } func loginConfig(t *testing.T, fixture *loginFixture, store credentialstore.Store) mcp.ServerConfig { @@ -672,6 +788,9 @@ func TestLoginMCPRejectsInvalidShapesBeforeRuntime(t *testing.T) { } }) } + if err := app.LoginMCPWithOptions(context.Background(), base, runtime, app.MCPLoginOptions{DCRAction: mcp.OAuthDCRLoginRetryRegistration}); !errors.Is(err, app.ErrMCPLoginConfig) { + t.Fatalf("non-DCR recovery action error = %v", err) + } if browser.calls.Load() != 0 { t.Fatalf("invalid config launched browser %d times", browser.calls.Load()) } diff --git a/internal/cliconfig/mcp_authority.go b/internal/cliconfig/mcp_authority.go index 50930b48f8..54ae811b20 100644 --- a/internal/cliconfig/mcp_authority.go +++ b/internal/cliconfig/mcp_authority.go @@ -69,12 +69,53 @@ func resolveGlobalAuthority(opts MCPAuthorityOptions) (*mcpauthority.Result, err func validateGlobalOAuth(route permconfig.MCPServerProfile) error { oauth := route.Auth.OAuth - if oauth.Profile == "" || oauth.Principal == "" || oauth.Issuer == "" || len(oauth.Scopes) == 0 || oauth.Network == nil || oauth.Credentials.Mode == "" { - return fmt.Errorf("%w: MCP server %q: global OAuth requires profile, principal, issuer, scopes, credentials, and network", ErrMCPProfileInvalid, route.Name) + if oauth.Profile == "" || oauth.Principal == "" || oauth.Issuer == "" || oauth.Network == nil || oauth.Credentials.Mode == "" { + return fmt.Errorf("%w: MCP server %q: global OAuth requires profile, principal, issuer, credentials, and network", ErrMCPProfileInvalid, route.Name) + } + if oauth.Upstream != nil { + return fmt.Errorf("%w: MCP server %q: oauth upstream selection is broker-only", ErrMCPProfileInvalid, route.Name) + } + if oauth.Client.Mode != "dcr" { + if len(oauth.Scopes) == 0 { + return fmt.Errorf("%w: MCP server %q: global OAuth requires scopes", ErrMCPProfileInvalid, route.Name) + } + return nil + } + if oauth.Client.DCR == nil || oauth.Client.DCR.DiscoveryURL != "" { + return fmt.Errorf("%w: MCP server %q: direct DCR requires an empty dcr payload", ErrMCPProfileInvalid, route.Name) + } + issuer, err := url.Parse(oauth.Issuer) + if err != nil || issuer.Scheme != "https" { + return fmt.Errorf("%w: MCP server %q: direct DCR requires an HTTPS issuer", ErrMCPProfileInvalid, route.Name) + } + if oauth.Credentials.Mode != "local" || oauth.Credentials.Local == nil || oauth.Credentials.Environment != nil { + return fmt.Errorf("%w: MCP server %q: direct DCR requires mutable local credentials", ErrMCPProfileInvalid, route.Name) + } + if oauth.RequestRefreshToken { + return fmt.Errorf("%w: MCP server %q: direct DCR does not support refresh tokens", ErrMCPProfileInvalid, route.Name) + } + if len(oauth.Scopes) != 0 && !sameStringSet(oauth.Scopes, []string{"openid"}) { + return fmt.Errorf("%w: MCP server %q: direct DCR scopes must contain only openid", ErrMCPProfileInvalid, route.Name) } return nil } +func sameStringSet(got, want []string) bool { + set := make(map[string]struct{}, len(got)) + for _, value := range got { + set[value] = struct{}{} + } + if len(set) != len(want) { + return false + } + for _, value := range want { + if _, ok := set[value]; !ok { + return false + } + } + return true +} + func resolveBrokerAuthority(section *permconfig.MCPSection) (*mcpauthority.Result, error) { if section == nil { return mcpauthority.NewBroker(mcpauthority.BrokerConfig{}), nil @@ -125,6 +166,12 @@ func validateBrokerOAuth(route permconfig.MCPServerProfile) error { } func validateBrokerOAuthUpstream(routeName string, oauth *permconfig.MCPOAuthProfile) error { + if oauth.Client.Mode == "dcr" { + if oauth.Client.DCR == nil || oauth.Client.DCR.DiscoveryURL == "" || oauth.Upstream == nil || oauth.Upstream.Mode != "oauth2" || oauth.Upstream.OAuth2 == nil || oauth.Issuer != "" { + return fmt.Errorf("%w: MCP server %q: broker DCR requires OAuth2 upstream, discovery_url, and no issuer", ErrMCPProfileInvalid, routeName) + } + return nil + } if oauth.Upstream == nil || oauth.Upstream.Mode == "oidc" { if oauth.Issuer == "" { return fmt.Errorf("%w: MCP server %q: broker OIDC requires issuer", ErrMCPProfileInvalid, routeName) diff --git a/internal/cliconfig/mcp_authority_test.go b/internal/cliconfig/mcp_authority_test.go index ef7c4516a3..6abc20bc6a 100644 --- a/internal/cliconfig/mcp_authority_test.go +++ b/internal/cliconfig/mcp_authority_test.go @@ -3,12 +3,122 @@ package cliconfig import ( "encoding/base64" "errors" + "fmt" + "path/filepath" + "strings" "testing" + "github.com/goccy/go-yaml" + + "github.com/stacklok/mecatl/internal/adapter/mcp" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" "github.com/stacklok/mecatl/internal/adapter/permconfig" ) +func TestDirectMCPDCR_Scenario1_AuthoritySeparatedProfile(t *testing.T) { + const template = `mcp: + mode: %s + servers: + - name: connector + url: https://connector-gateway.stacklok.dev/gw/mcp + auth: + mode: oauth + oauth: + profile: connector + principal: local-user + issuer: https://connector-gateway.stacklok.dev + client: {mode: dcr, dcr: {}} + %s + credentials: + mode: local + local: {root: %q, key_env: MECATL_MCP_CREDENTIAL_KEY} + network: {additional_origins: [], private_origins: [], max_redirects: 0} +` + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + lookup := func(name string) (string, bool) { return key, name == "MECATL_MCP_CREDENTIAL_KEY" } + parse := func(t *testing.T, mode, refresh string) *permconfig.MCPSection { + t.Helper() + var cfg permconfig.Config + if err := yaml.Unmarshal([]byte(fmt.Sprintf(template, mode, refresh, filepath.Join(t.TempDir(), "credentials"))), &cfg); err != nil { + t.Fatalf("parse direct DCR profile: %v", err) + } + return cfg.MCP + } + + for _, tc := range []struct { + name, refresh string + }{ + {name: "no-refresh defaults"}, + {name: "explicit no refresh", refresh: "request_refresh_token: false"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveMCPAuthority(MCPAuthorityOptions{Operator: parse(t, "global", tc.refresh), DefaultMode: mcpauthority.Global, LookupEnv: lookup}) + if err != nil { + t.Fatal(err) + } + servers, lifecycle, ok := got.Global() + if !ok || len(servers) != 1 || servers[0].OAuth == nil { + t.Fatalf("global DCR result = %#v, selected %t", servers, ok) + } + defer lifecycle.Close() + oauth := servers[0].OAuth + if oauth.Client.DCR == nil || oauth.Client.Preregistered != nil || oauth.Client.ClientIDMetadataDocumentURL != "" { + t.Fatalf("resolved client = %#v, want DCR only", oauth.Client) + } + if oauth.RequestRefreshToken || strings.Join(oauth.AllowedScopes, ",") != "openid" { + t.Fatalf("refresh/scopes = %t/%v, want false/[openid]", oauth.RequestRefreshToken, oauth.AllowedScopes) + } + }) + } + + for _, tc := range []struct{ name, mode, replacement string }{ + {name: "broker authority", mode: "broker"}, + {name: "upstream", mode: "global", replacement: "issuer: https://connector-gateway.stacklok.dev\n upstream: {mode: oidc}"}, + {name: "broker discovery payload", mode: "global", replacement: "client: {mode: dcr, dcr: {discovery_url: https://connector-gateway.stacklok.dev/.well-known/oauth-authorization-server}}"}, + {name: "mixed client forms", mode: "global", replacement: "client: {mode: dcr, dcr: {}, cimd: {document_url: https://client.example/metadata.json}}"}, + {name: "static credential payload", mode: "global", replacement: "static_bearer: {token_env: MECATL_TOKEN}"}, + {name: "environment credentials", mode: "global", replacement: "credentials: {mode: environment, environment: {credential_env: MECATL_CREDENTIAL}}"}, + {name: "refresh requested", mode: "global", replacement: "request_refresh_token: true"}, + {name: "unsupported scopes", mode: "global", replacement: "scopes: [openid, offline_access]"}, + } { + t.Run("reject "+tc.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "credentials") + body := fmt.Sprintf(template, tc.mode, "", root) + if tc.replacement != "" { + switch tc.name { + case "upstream": + body = strings.Replace(body, "issuer: https://connector-gateway.stacklok.dev", tc.replacement, 1) + case "broker discovery payload": + body = strings.Replace(body, "client: {mode: dcr, dcr: {}}", tc.replacement, 1) + case "mixed client forms": + body = strings.Replace(body, "client: {mode: dcr, dcr: {}}", tc.replacement, 1) + case "static credential payload": + body = strings.Replace(body, " oauth:\n", " "+tc.replacement+"\n oauth:\n", 1) + case "environment credentials": + body = strings.Replace(body, "credentials:\n mode: local\n local: {root: "+fmt.Sprintf("%q", root)+", key_env: MECATL_MCP_CREDENTIAL_KEY}", tc.replacement, 1) + case "refresh requested", "unsupported scopes": + body = strings.Replace(body, " \n", " "+tc.replacement+"\n", 1) + } + } + var cfg permconfig.Config + parseErr := yaml.Unmarshal([]byte(body), &cfg) + if parseErr == nil { + _, parseErr = ResolveMCPAuthority(MCPAuthorityOptions{Operator: cfg.MCP, DefaultMode: mcpauthority.Global, BrokerSupported: true, LookupEnv: lookup}) + } + if parseErr == nil { + t.Fatal("invalid authority-separated DCR profile was admitted") + } + }) + } + + brokerProfiles := parse(t, "broker", "") + if _, err := LoadMCPProfiles(MCPProfileLoadOptions{Operator: brokerProfiles, LookupEnv: lookup}); !errors.Is(err, ErrMCPProfileInvalid) { + t.Fatalf("direct loader accepted broker authority: %v", err) + } + + _ = mcp.OAuthDCRConfig{} +} + func TestMCPAuthorityRootDefaultsAndExplicitSelection(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/cliconfig/mcpprofile.go b/internal/cliconfig/mcpprofile.go index 9282162e09..416a273cbb 100644 --- a/internal/cliconfig/mcpprofile.go +++ b/internal/cliconfig/mcpprofile.go @@ -136,6 +136,9 @@ func (p *MCPProfiles) OAuthServer(name string) (mcp.ServerConfig, bool) { // retain their order; a same-name legacy CLI entry replaces the whole settings // entry in place, and a distinct legacy entry appends. func LoadMCPProfiles(opts MCPProfileLoadOptions) (*MCPProfiles, error) { + if opts.Operator != nil && opts.Operator.Mode == "broker" { + return nil, fmt.Errorf("%w: broker MCP authority cannot be loaded as direct profiles", ErrMCPProfileInvalid) + } if err := validateLegacyRelaxations(opts.Legacy); err != nil { return nil, err } @@ -291,15 +294,27 @@ func loadMCPProfile(input profileInput, lookup func(string) (string, bool), owne } } +func resolvedOAuthScopePolicy(decl *permconfig.MCPOAuthProfile) (bool, []string) { + scopes := append([]string(nil), decl.Scopes...) + if decl.Client.Mode == "dcr" && len(scopes) == 0 { + scopes = []string{"openid"} + } + return decl.RequestRefreshToken, scopes +} + func loadOAuthProfile(profile permconfig.MCPServerProfile, lookup func(string) (string, bool), owner *MCPProfiles, stores map[string]credentialstore.Store) (*mcp.OAuthOptions, error) { decl := profile.Auth.OAuth if decl == nil || decl.Network == nil { return nil, &MCPProfileError{Server: profile.Name, Field: "oauth.network", Kind: ErrMCPProfileInvalid} } + if err := validateGlobalOAuth(profile); err != nil { + return nil, err + } + requestRefresh, allowedScopes := resolvedOAuthScopePolicy(decl) opts := &mcp.OAuthOptions{ Subject: mcp.OAuthSubject{Profile: decl.Profile, Principal: decl.Principal}, Issuer: decl.Issuer, - AllowedScopes: append([]string(nil), decl.Scopes...), RequestRefreshToken: decl.RequestRefreshToken, + AllowedScopes: allowedScopes, RequestRefreshToken: requestRefresh, Network: mcp.OAuthNetworkPolicy{AdditionalOrigins: append([]string(nil), decl.Network.AdditionalOrigins...), PrivateOrigins: append([]string(nil), decl.Network.PrivateOrigins...), MaxRedirects: decl.Network.MaxRedirects}, } if err := loadOAuthClient(profile, decl, lookup, opts); err != nil { @@ -374,6 +389,10 @@ func loadOAuthClient(profile permconfig.MCPServerProfile, decl *permconfig.MCPOA opts.Client.Preregistered = &oauthex.ClientCredentials{ClientID: client.ID, ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: secret}, Issuer: decl.Issuer} return nil } + if decl.Client.DCR != nil { + opts.Client.DCR = &mcp.OAuthDCRConfig{ServerName: profile.Name} + return nil + } if decl.Client.CIMD == nil { return &MCPProfileError{Server: profile.Name, Field: "oauth.client", Kind: ErrMCPProfileInvalid} } diff --git a/internal/cliconfig/mcpprofile_test.go b/internal/cliconfig/mcpprofile_test.go index c65682fd60..dec35ef6d4 100644 --- a/internal/cliconfig/mcpprofile_test.go +++ b/internal/cliconfig/mcpprofile_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "os" "path/filepath" "slices" "strings" @@ -260,6 +261,52 @@ func TestLoadMCPProfileSecretErrorsAreActionableAndRedacted(t *testing.T) { } } +func TestADR_0325_DirectDCRProfileScopeAndStorePolicy(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + newProfile := func(root string) permconfig.MCPServerProfile { + return permconfig.MCPServerProfile{ + Name: "connector", URL: "https://connector.example/gw/mcp", + Auth: permconfig.MCPAuthProfile{Mode: "oauth", OAuth: &permconfig.MCPOAuthProfile{ + Profile: "connector", Principal: "local-user", Issuer: "http://issuer.example", + Client: permconfig.MCPOAuthClientProfile{Mode: "dcr", DCR: &permconfig.MCPDCRClientProfile{}}, + Credentials: permconfig.MCPOAuthCredentialProfile{Mode: "local", Local: &permconfig.MCPLocalCredentialProfile{Root: root, KeyEnv: "MECATL_KEY"}}, + Network: &permconfig.MCPOAuthNetworkProfile{}, + }}, + } + } + + root := filepath.Join(t.TempDir(), "must-not-exist") + lookups := 0 + profile := newProfile(root) + _, err := LoadMCPProfiles(MCPProfileLoadOptions{Operator: &permconfig.MCPSection{Servers: []permconfig.MCPServerProfile{profile}}, LookupEnv: func(string) (string, bool) { + lookups++ + return key, true + }}) + if !errors.Is(err, ErrMCPProfileInvalid) { + t.Fatalf("non-HTTPS direct DCR issuer error = %v, want invalid profile", err) + } + if lookups != 0 { + t.Fatalf("credential lookup occurred before issuer rejection: %d", lookups) + } + if _, statErr := os.Stat(root); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("credential store path was touched before issuer rejection: %v", statErr) + } + + preregistered := newProfile(filepath.Join(t.TempDir(), "preregistered")) + preregistered.Auth.OAuth.Client = permconfig.MCPOAuthClientProfile{Mode: "preregistered", Preregistered: &permconfig.MCPPreregisteredClientProfile{ID: "client", SecretEnv: "MECATL_CLIENT"}} + preregistered.Auth.OAuth.Scopes = []string{"read"} + profiles, err := LoadMCPProfiles(MCPProfileLoadOptions{Operator: &permconfig.MCPSection{Servers: []permconfig.MCPServerProfile{preregistered}}, LookupEnv: func(name string) (string, bool) { + if name == "MECATL_CLIENT" { + return "secret", true + } + return key, name == "MECATL_KEY" + }}) + if err != nil { + t.Fatalf("existing preregistered HTTP issuer policy changed: %v", err) + } + profiles.Close() +} + func environmentOAuth() *permconfig.MCPOAuthProfile { return &permconfig.MCPOAuthProfile{ Profile: "work", Principal: "principal", Issuer: "https://issuer.example", @@ -277,3 +324,24 @@ func namesOf(configs []mcp.ServerConfig) []string { } return out } + +func TestLoadMCPProfilesInjectsDCRLifecycleServerName(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + profile := permconfig.MCPServerProfile{ + Name: "connector", URL: "https://connector.example/gw/mcp", + Auth: permconfig.MCPAuthProfile{Mode: "oauth", OAuth: &permconfig.MCPOAuthProfile{ + Profile: "connector", Principal: "local-user", Issuer: "https://issuer.example", + Client: permconfig.MCPOAuthClientProfile{Mode: "dcr", DCR: &permconfig.MCPDCRClientProfile{}}, + Credentials: permconfig.MCPOAuthCredentialProfile{Mode: "local", Local: &permconfig.MCPLocalCredentialProfile{Root: filepath.Join(t.TempDir(), "credentials"), KeyEnv: "MECATL_KEY"}}, + Network: &permconfig.MCPOAuthNetworkProfile{}, + }}, + } + profiles, err := LoadMCPProfiles(MCPProfileLoadOptions{Operator: &permconfig.MCPSection{Servers: []permconfig.MCPServerProfile{profile}}, LookupEnv: func(string) (string, bool) { return key, true }}) + if err != nil { + t.Fatal(err) + } + defer profiles.Close() + if got := profiles.Servers[0].OAuth.Client.DCR.ServerName; got != "connector" { + t.Fatalf("DCR lifecycle server name = %q, want connector", got) + } +} diff --git a/mcp/oauthlogin/runtime.go b/mcp/oauthlogin/runtime.go index 6190793301..2537f50e71 100644 --- a/mcp/oauthlogin/runtime.go +++ b/mcp/oauthlogin/runtime.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "net/url" + "strings" "sync" "syscall" "time" @@ -127,6 +128,19 @@ func New(opts Options) (*Runtime, error) { // Authorize runs one loopback authorization interaction. Calls on the same Runtime are serialized. func (r *Runtime) Authorize(ctx context.Context, expectedIssuer string, authorize AuthorizeFunc) error { + return r.authorize(ctx, expectedIssuer, "", authorize) +} + +// AuthorizeWithCallbackPath runs one loopback authorization interaction using a +// registration-bound random callback path and a fresh ephemeral IPv4 port. +func (r *Runtime) AuthorizeWithCallbackPath(ctx context.Context, expectedIssuer, callbackPath string, authorize AuthorizeFunc) error { + if r == nil || r.opts.RedirectURL != "" || !validCallbackPath(callbackPath) { + return errors.New("OAuth callback path is invalid") + } + return r.authorize(ctx, expectedIssuer, callbackPath, authorize) +} + +func (r *Runtime) authorize(ctx context.Context, expectedIssuer, callbackPath string, authorize AuthorizeFunc) error { //nolint:gocyclo // callback lifecycle and cleanup states stay explicit. if ctx == nil { return errors.New("OAuth authorization requires a context") } @@ -154,9 +168,12 @@ func (r *Runtime) Authorize(ctx context.Context, expectedIssuer string, authoriz redirectURL := "" attemptPolicy := attemptMatchingRoute if r.opts.RedirectURL == "" { - path, err = randomCallbackPath(r.random) - if err != nil { - return errors.New("generate OAuth callback path: failed") + path = callbackPath + if path == "" { + path, err = randomCallbackPath(r.random) + if err != nil { + return errors.New("generate OAuth callback path: failed") + } } } else { fixed, _ := fixedRedirect(r.opts.RedirectURL) @@ -310,6 +327,15 @@ func fixedRedirect(raw string) (fixedRedirectConfig, bool) { } } +func validCallbackPath(path string) bool { + if !strings.HasPrefix(path, callbackPrefix) { + return false + } + encoded := strings.TrimPrefix(path, callbackPrefix) + raw, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + return err == nil && len(raw) == callbackBytes && base64.RawURLEncoding.EncodeToString(raw) == encoded +} + func canonicalIssuer(raw string) (string, error) { u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "http" && u.Scheme != "https") { diff --git a/mcp/oauthlogin/runtime_test.go b/mcp/oauthlogin/runtime_test.go index fc05e639d6..175a396d82 100644 --- a/mcp/oauthlogin/runtime_test.go +++ b/mcp/oauthlogin/runtime_test.go @@ -3,6 +3,7 @@ package oauthlogin import ( "bytes" "context" + "encoding/base64" "errors" "fmt" "io" @@ -63,6 +64,53 @@ func runWithLauncher(t *testing.T, launcher BrowserLauncher, authorize Authorize return runtime.Authorize(ctx, testIssuer, authorize) } +func TestADR_0325_RegistrationBoundCallbackPath(t *testing.T) { + path := callbackPrefix + base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, callbackBytes)) + runtime, err := New(Options{Launcher: launcherFunc(func(context.Context, string) error { return nil })}) + if err != nil { + t.Fatal(err) + } + if err := runtime.AuthorizeWithCallbackPath(context.Background(), testIssuer, "/wrong", func(context.Context, string, func(context.Context, string) (Result, error)) error { return nil }); err == nil { + t.Fatal("invalid registration-bound path was accepted") + } + + var redirects []string + for i := 0; i < 2; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = runtime.AuthorizeWithCallbackPath(ctx, testIssuer, path, func(ctx context.Context, redirect string, present func(context.Context, string) (Result, error)) error { + redirects = append(redirects, redirect) + parsed, parseErr := url.Parse(redirect) + if parseErr != nil || parsed.Path != path || parsed.Hostname() != "127.0.0.1" { + return fmt.Errorf("bound redirect = %q: %v", redirect, parseErr) + } + go func() { + req, _ := http.NewRequest(http.MethodGet, callbackURL(redirect, "code", fmt.Sprintf("state-%d", i), testIssuer), nil) + _ = request(t, req) + }() + result, presentErr := present(ctx, "https://as.example.test/authorize?state="+fmt.Sprintf("state-%d", i)) + if presentErr == nil && result.State != fmt.Sprintf("state-%d", i) { + t.Fatalf("callback result = %#v", result) + } + return presentErr + }) + cancel() + if err != nil { + t.Fatal(err) + } + } + if redirects[0] == redirects[1] { + t.Fatalf("ephemeral callback port was reused: %q", redirects[0]) + } + + fixed, err := New(Options{RedirectURL: ExactRedirectURL}) + if err != nil { + t.Fatal(err) + } + if err := fixed.AuthorizeWithCallbackPath(context.Background(), testIssuer, path, func(context.Context, string, func(context.Context, string) (Result, error)) error { return nil }); err == nil { + t.Fatal("registration-bound path conflicted with fixed redirect but was accepted") + } +} + func TestAuthorizeRealLoopbackHappyPath(t *testing.T) { var redirect string var response responseSnapshot diff --git a/user-docs/building/what-you-get/mcp-client.md b/user-docs/building/what-you-get/mcp-client.md index f33b9dc545..e09d1969dd 100644 --- a/user-docs/building/what-you-get/mcp-client.md +++ b/user-docs/building/what-you-get/mcp-client.md @@ -59,8 +59,22 @@ mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] Serving restores the encrypted record at startup and persists refresh-token rotation. It never opens a browser. Environment-backed profiles are read-only; -update their Secret and restart the process to rotate them. See -[MCP OAuth and credentials](/features/mcp-oauth-and-credentials.md#configure-a-profile) +update their Secret and restart the process to rotate them. + +A named direct/global profile may instead use `client: {mode: dcr, dcr: {}}` +with a mutable local credential store. Direct DCR is a public-client, +explicit-consent path: it requests only `openid`, persists the registration +separately from its generation-bound access grant, and never requests or uses +refresh. Restart reuses an unexpired grant. Expiry returns login-required +without browser launch; an explicit `mecated mcp login SERVER` reuses the +registration and obtains a new grant. For an interrupted registration with the +same profile, principal, canonical resource, and exact issuer, use +`--retry-dcr-registration`; pending identity drift is reported as +pending-identity-mismatch and requires restoring that matching configuration +before retry. To replace a valid ready registration and grant use +`--reset-dcr-registration`. These mutually exclusive flags fail closed on the +wrong or corrupt state and never revoke the upstream client. See +[MCP OAuth and credentials](/features/mcp-oauth-and-credentials.md#direct-dynamic-client-registration) for profile configuration and recovery. ### ToolHive discovery diff --git a/user-docs/features/mcp-oauth-and-credentials.md b/user-docs/features/mcp-oauth-and-credentials.md index 0901643d52..19f0098057 100644 --- a/user-docs/features/mcp-oauth-and-credentials.md +++ b/user-docs/features/mcp-oauth-and-credentials.md @@ -90,11 +90,106 @@ mecated mcp login github \ After login, restart the server and verify that the namespaced `mcp__github__*` tools appear. Mecatl restores the encrypted credential and refreshes tokens when -needed. +needed. Preregistered and CIMD profiles persist refresh-token rotation in the +local store so the next restart remains warm. -If the profile's issuer, client, principal, scopes, or resource changes, run -login again. To roll back, replace the whole profile with `static_bearer` or -`none` and restart. +### Direct dynamic client registration + +A named direct/global profile can use `client: {mode: dcr, dcr: {}}` when no +client was preregistered. It requires exact `scopes: [openid]` (or omission), +`request_refresh_token: false` (or omission), and a mutable local credential +store: + +```yaml +mcp: + mode: global + servers: + - name: connector + url: https://connector-gateway.stacklok.dev/gw/mcp + auth: + mode: oauth + oauth: + profile: connector + principal: local-user + issuer: https://connector-gateway.stacklok.dev + client: {mode: dcr, dcr: {}} + scopes: [openid] + request_refresh_token: false + credentials: + mode: local + local: + root: /absolute/owner-only/credentials + key_env: MECATL_MCP_CREDENTIAL_KEY + network: {additional_origins: [], private_origins: [], max_redirects: 0} +``` + +Login dynamically registers a public client and then uses the same browser/PKCE +flow. The durable registration and generation-bound access grant are separate; +no client secret or refresh token is requested or accepted. + +Ordinary startup reuses the unexpired grant and never launches a browser. On +expiry it returns login-required without attempting refresh. Run +`mecated mcp login SERVER` explicitly to obtain a new access grant while reusing +the valid registration. If registration itself was interrupted, use +`--retry-dcr-registration`; to deliberately replace a valid ready registration +and its grant, use `--reset-dcr-registration`. The flags are mutually exclusive +and valid only for DCR. Ready identity drift is reset-required and may be +replaced with `--reset-dcr-registration`. Pending identity drift is reported as +pending-identity-mismatch; it cannot reset or retry until the matching profile, +principal, canonical resource, and exact issuer configuration is restored, +after which use `--retry-dcr-registration`. Corrupt, noncanonical, unsupported, +or grant-mismatched persisted state remains repair-only: neither flag mutates +it. The flags do not revoke an upstream registration. Complete public-client +refresh remains deferred to +[issue #1355](https://github.com/stacklok/mecatl/issues/1355). + +#### Manual local-mecatui qualification + +Run this live procedure only with explicit authorization and an isolated owner-only config +and credential root. Do not paste command output into an issue or PR. + +1. Configure the DCR profile above and export its 32-byte padded-base64 credential key. +2. Run `mecated mcp login connector`; record whether explicit consent appeared, but never + record the URL, code, state, registration response, client ID, or token. +3. Start local `mecatui` with the same settings and key. Invoke only one harmless discovered + read-only tool and record its name and safe success category. +4. Restart `mecatui` before access-token expiry and invoke the same tool without another login. +5. After expiry, reconnect and confirm login-required, no refresh request, and no browser launch. +6. Run `mecated mcp login connector` explicitly, confirm registration reuse, restart `mecatui`, + and invoke the same harmless tool once more. + +Record only: + +```text +canonical_resource: +issuer_origin: +explicit_consent: true|false +harmless_tool_name: +initial_result: success|failure: +restart_reuse_before_expiry: true|false +expiry_result: login-required|failure: +refresh_attempted: false +browser_launched_on_startup_or_expiry: false +explicit_relogin_reused_registration: true|false +relogin_result: success|failure: +``` + +Exclude OAuth and registration secrets, authorization URLs, callback values, raw provider +errors, headers, credential-store contents, and screenshots containing any of them. + +If a valid ready DCR profile's intentional registration binding changes — for example its +issuer, principal, scopes, or resource — run `mecated mcp login SERVER +--reset-dcr-registration`; plain login cannot replace that registration. Pending identity drift +is reported as pending-identity-mismatch and cannot reset or retry: restore the matching profile, +principal, canonical resource, and exact issuer before running `--retry-dcr-registration`. + +For corrupt, noncanonical, unsupported, or grant-mismatched persisted registration state, +preserve the records and configuration: do not edit, delete, or rename them. Contact the +deployment operator or support team with only the server name and redacted command error. +Never send credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. Reset +and retry cannot bypass this state. For non-DCR profiles, run login again after intentional +identity changes. To roll back, replace the whole profile with `static_bearer` or `none` and +restart. ## Environment-backed credentials @@ -171,8 +266,10 @@ environment-variable name. Authorize global profiles locally beforehand or provision an environment credential. A browser may instead complete an already-started ToolHive broker enrollment externally; `mecak8s` does not launch that browser. -- ACP cannot install or authorize OAuth profiles. It can use a global profile - after an operator authorizes it. +- Direct DCR and ACP cannot provide OAuth profiles or install or drive + authorization. An operator may configure and authorize a named global + direct-DCR profile; ACP sessions may then invoke its shared tools under + ordinary permissions. - Client-provided per-session MCP and inline agent MCP servers cannot provide OAuth profiles. The configured ToolHive broker is the exception: it owns its configured multi-upstream OAuth chain, while Mecatl exposes only the aggregate @@ -183,9 +280,12 @@ environment-variable name. permission to call a tool: every namespaced MCP tool still passes through the ordinary permission policy and audit path. - OAuth supports RFC 9728 metadata with one exact resource and authorization - server, S256, and Basic-authenticated confidential clients. When RFC 9207 - issuer validation is advertised, the callback must contain the matching `iss`; - any supplied issuer must match. + server, and S256. Preregistered confidential clients remain + Basic-authenticated; direct DCR clients use the public `none` method with no + refresh. RFC 9207 issuer validation follows authorization-server metadata: if + the server advertises `authorization_response_iss_parameter_supported`, its + callback must include the matching `iss`; otherwise `iss` may be omitted, but + any supplied issuer must still match. - A connection drop can trigger one bounded reconnect and retry. A server-declared tool failure is not replayed automatically because the call may have mutated remote state. The startup tool catalog is retained across diff --git a/user-docs/reference/configuration.md b/user-docs/reference/configuration.md index cc93b1dfb8..4db36e59f7 100644 --- a/user-docs/reference/configuration.md +++ b/user-docs/reference/configuration.md @@ -318,10 +318,10 @@ Strict OPERATOR-TIER Streamable HTTP MCP authority configuration. Mode selects o | `mcp.servers[].auth.oauth.client.preregistered.secret_env` | `string` | `(empty)` | SecretEnv is a MECATL_* environment variable name containing the client secret. | | `mcp.servers[].auth.oauth.client.cimd` | `mcpcimdclientprofile` | `(absent)` | CIMD declares an HTTPS client-id metadata document URL. | | `mcp.servers[].auth.oauth.client.cimd.document_url` | `string` | `(empty)` | DocumentURL is the required HTTPS metadata-document URL. | -| `mcp.servers[].auth.oauth.client.dcr` | `mcpdcrclientprofile` | `(absent)` | DCR declares an RFC 8414 metadata URL for RFC 7591 registration. | -| `mcp.servers[].auth.oauth.client.dcr.discovery_url` | `string` | `(empty)` | DiscoveryURL is the required HTTPS authorization-server metadata URL. | -| `mcp.servers[].auth.oauth.scopes` | `[]string` | `(absent)` | Scopes is the non-empty allowlist of OAuth scopes the client may request. | -| `mcp.servers[].auth.oauth.request_refresh_token` | `bool` | `false` | RequestRefreshToken asks the authorization server for refresh capability. | +| `mcp.servers[].auth.oauth.client.dcr` | `mcpdcrclientprofile` | `(absent)` | DCR selects dynamic registration: direct/global profiles require an empty payload, discover from issuer, and use omitted scopes as openid; broker profiles require discovery_url and nonempty scopes. Ready direct-DCR identity drift is reset-required and uses --reset-dcr-registration; pending identity drift is pending-identity-mismatch and cannot reset or retry until the matching profile, principal, canonical resource, and exact issuer are restored. Corrupt direct-DCR state is not resettable: preserve its records and configuration without editing, deleting, or renaming them, then contact the deployment operator or support team with only the server name and redacted command error—never credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. | +| `mcp.servers[].auth.oauth.client.dcr.discovery_url` | `string` | `(empty)` | DiscoveryURL is required for broker DCR and forbidden for direct/global DCR, which discovers from issuer instead. | +| `mcp.servers[].auth.oauth.scopes` | `[]string` | `(absent)` | Scopes is the non-empty OAuth scope allowlist, except direct/global DCR may omit it and uses exactly openid. | +| `mcp.servers[].auth.oauth.request_refresh_token` | `bool` | `false` | RequestRefreshToken asks the authorization server for refresh capability; direct/global DCR defaults false and rejects true. | | `mcp.servers[].auth.oauth.credentials` | `mcpoauthcredentialprofile` | `(absent)` | Credentials selects one global-mode local or environment credential source and is forbidden in broker mode. | | `mcp.servers[].auth.oauth.credentials.mode` | `string` | `(empty)` | Mode is exactly local or environment. | | `mcp.servers[].auth.oauth.credentials.local` | `mcplocalcredentialprofile` | `(absent)` | Local declares encrypted mutable credentials rooted at an absolute path. |