diff --git a/docs/mcp/configuration.md b/docs/mcp/configuration.md index bfb3da7..650131c 100644 --- a/docs/mcp/configuration.md +++ b/docs/mcp/configuration.md @@ -84,6 +84,7 @@ and never reaches the agent. Both types use the top-level `platform` block: platform: token_endpoint: ${INITIALIZ_TOKEN_ENDPOINT} # ${VAR}-expanded at use agent_identity: ${FORGE_PLATFORM_TOKEN} # the agent's platform credential + authorize_endpoint: ${INITIALIZ_AUTHORIZE_ENDPOINT} # optional: consent-link resolver (#343) mcp: servers: @@ -114,8 +115,41 @@ mcp: established lazily on the first call. See [Materialized tool schemas](#materialized-tool-schemas-317). - **`ref`** names the platform tool-registry entry the token is authorized against (defaults to the server name). -- **Egress:** the platform token-endpoint host is auto-merged into the - allowlist. +- **Egress:** the platform token-endpoint (and `authorize_endpoint`) hosts are + auto-merged into the allowlist. + +##### Delivering the consent prompt (managed, #343) + +When a `type: user` call parks awaiting consent, Forge can present a +"Connect ``" login link to the requesting user **over its own Slack +connection** — the same bot the agent already runs. Two config pieces: + +- **`platform.authorize_endpoint`** — where Forge fetches the login URL. + Forge POSTs `{"server": , "subject": }` (same Bearer + tenancy + headers as `token_endpoint`) and the platform returns + `{"authorize_url": "https://…"}`. The platform builds that URL with **its own** + `client_id` / `redirect_uri` / `state` and **hosts the callback**, so the + authorization `code` and refresh token land at the platform, never at Forge + (managed invariant). Forge treats the URL as opaque and only delivers it. + Unset ⇒ no managed link; the parked call still surfaces via the + `mcp_auth_required` audit event. +- **A running Slack adapter** (`forge run --with slack`) — Forge DMs the subject + the link (resolving the Slack user by email via `users.lookupByEmail` → + `conversations.open`). Requires the bot scopes `chat:write`, + `users:read.email`, and `im:write`. + +The login link is **always published on the task's A2A `auth-required` +artifact** as a durable record; Slack (when active) is an **additive push** on +top. So a per-subject Slack failure (e.g. the email isn't in the workspace, or +`users.lookupByEmail` is missing a scope) still leaves the user a clickable link +on the task — it's logged, never fatal, and the parked call still resumes when +the callback lands. + +After the user consents at the platform's callback, the platform resumes the +parked call with `POST /mcp/consent {subject, server, granted:true}`. A Slack +"Cancel" button fails the call fast (`granted:false`). Delivery is decoupled from +token custody — the same Slack path serves standalone (Forge-built URL) and +managed (platform-supplied URL). > **Per-user connection isolation.** For `type: user`, Forge establishes one > MCP **connection per requesting user** — the connection's `initialize` runs diff --git a/forge-cli/cmd/run.go b/forge-cli/cmd/run.go index a06130a..648c7f9 100644 --- a/forge-cli/cmd/run.go +++ b/forge-cli/cmd/run.go @@ -380,6 +380,45 @@ func runRun(cmd *cobra.Command, args []string) error { Target: target, }) }) + + // Wire up MCP delegated-consent delivery (#343): route a parked + // type: user call's "Connect " link to the first active + // channel adapter that supports it (Slack DM to the subject). The + // link itself comes from runner.AuthorizeURL — standalone builds it, + // managed fetches it from the platform. When no consent-capable + // adapter is active, SetConsentDeliverer is not called and the link + // falls back to the A2A auth-required artifact (standalone) / + // mcp_auth_required audit event (platform-read). + for _, plugin := range activePlugins { + consentAdapter, ok := plugin.(corechannels.ConsentDeliverer) + if !ok { + continue + } + consentAdapter.SetConsentCanceler(func(ctx context.Context, subject, server string) error { + return postMCPConsent(ctx, agentURL, runner.AuthToken(), subject, server, false) + }) + runner.SetConsentDeliverer(func(ctx context.Context, subject, server, taskID string, deadline time.Time) error { + link, err := runner.AuthorizeURL(ctx, subject, server) + if err != nil { + opsLog.Warn("mcp consent: building authorize URL failed", map[string]any{ + "subject": subject, "server": server, "error": err.Error(), + }) + return err + } + // Always publish the durable A2A-artifact link first, so a + // per-subject Slack failure (e.g. the email isn't in the + // workspace) still leaves the user a link on the task. Slack + // is an additive push on top. + runner.PublishConsentArtifact(taskID, subject, server, link, deadline) + return consentAdapter.DeliverConsent(ctx, corechannels.ConsentPrompt{ + Subject: subject, + Server: server, + AuthorizeURL: link, + Deadline: deadline, + }) + }) + break // one deliverer; first consent-capable adapter wins + } } } @@ -556,6 +595,32 @@ func postDeferDecision(ctx context.Context, agentURL, token string, d corechanne return nil } +// postMCPConsent POSTs a consent resume/refuse signal to the agent's +// POST /mcp/consent endpoint (#330) — the same endpoint a platform would call. +// Used by the Slack "Cancel" button to fail a parked call fast (granted=false). +// Topology-agnostic: works whether the channel adapter runs in-process or out. +func postMCPConsent(ctx context.Context, agentURL, token, subject, server string, granted bool) error { + body, _ := json.Marshal(map[string]any{"subject": subject, "server": server, "granted": granted}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/mcp/consent", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("mcp consent endpoint HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + return nil +} + // buildRateLimitOverride translates the runRateLimit* flag vars into // the resolver's pointer-typed override struct. Only flags that were // explicitly set (non-zero float / non-zero int / non-empty bool diff --git a/forge-cli/runtime/mcp_standalone_consent.go b/forge-cli/runtime/mcp_standalone_consent.go index b0c30b7..c057d58 100644 --- a/forge-cli/runtime/mcp_standalone_consent.go +++ b/forge-cli/runtime/mcp_standalone_consent.go @@ -39,10 +39,62 @@ type AuthorizeURLProvider func(ctx context.Context, subject, server string) (str // wires its own by default; a managed platform sets one that returns its // own authorize URL (its client_id/state/redirect_uri) so Forge never // constructs a managed URL from local config. Must be called before Run(). +// Takes precedence over the config-driven managed/standalone providers. func (r *Runner) SetAuthorizeURLProvider(fn AuthorizeURLProvider) { r.authorizeURLProvider = fn } +// AuthorizeURL returns the delegated-consent login link for {subject, server} +// via the configured provider — standalone builds it locally, managed fetches +// it from the platform, an embedder may inject its own. The consent deliverer +// (e.g. Slack, #343) calls this to populate the prompt. Errors when no provider +// is wired (delivery then falls back to the mcp_auth_required audit event). +func (r *Runner) AuthorizeURL(ctx context.Context, subject, server string) (string, error) { + if r.authorizeURLProvider == nil { + return "", fmt.Errorf("no authorize-URL provider configured: set platform.authorize_endpoint (managed), a standalone type:user server, or SetAuthorizeURLProvider") + } + return r.authorizeURLProvider(ctx, subject, server) +} + +// enableManagedConsentProvider wires the managed (platform-fetched) authorize-URL +// provider (#343): when a platform block sets authorize_endpoint and at least +// one type: user server exists, Forge fetches the consent link from the platform +// (which owns the client_id/redirect_uri/state and hosts the callback) and only +// delivers it. No-op when an embedder or standalone already set a provider, or +// when there's nothing to serve. egressClient routes the fetch through the +// allowlist. Managed mode wires ONLY the provider — the platform hosts the +// callback and brokers the token, so there is no local completer/store here. +func (r *Runner) enableManagedConsentProvider(egressClient *http.Client) { + if r.authorizeURLProvider != nil { + return + } + p := r.cfg.Config.Platform + if p == nil || p.AuthorizeEndpoint == "" { + return + } + hasUser := false + for _, s := range r.cfg.Config.MCP.Servers { + if s.Auth != nil && s.Auth.Type == "user" { + hasUser = true + break + } + } + if !hasUser { + return + } + client := egressClient + if client == nil { + client = http.DefaultClient + } + r.authorizeURLProvider = func(ctx context.Context, subject, server string) (string, error) { + ref := server + if spec, ok := r.mcpServerSpec(server); ok && spec.Auth != nil && spec.Auth.Ref != "" { + ref = spec.Auth.Ref + } + return mcp.FetchAuthorizeURL(ctx, client, p.AuthorizeEndpoint, p.AgentIdentity, ref, subject) + } +} + // standaloneDelegatedServers returns the type: user MCP servers running in // standalone mode (no platform block). Empty ⇒ nothing to wire. func (r *Runner) standaloneDelegatedServers() []types.MCPServer { @@ -196,33 +248,42 @@ func tokenTTL(tok *oauth.Token) time.Duration { return 5 * time.Minute } -// standaloneConsentDeliverer is the ConsentDeliverer that "delivers" the login -// link in-band: it builds the link and writes it onto the parked task's -// auth-required artifact so a UI/A2A client renders a clickable prompt. Channel -// (Slack) delivery is #343; this is the platform-free default. -func (r *Runner) standaloneConsentDeliverer(ctx context.Context, subject, server, taskID string, deadline time.Time) error { - link, err := r.authorizeURLProvider(ctx, subject, server) - if err != nil { - return err - } - if r.taskStore == nil { - return fmt.Errorf("standalone consent: no task store to publish the auth-required artifact") +// PublishConsentArtifact writes the "Connect " login link onto the +// parked task's auth-required artifact — a DURABLE record a UI/A2A client can +// render. It is the always-on backstop: channel delivery (Slack, #343) is an +// additive push on top of it, so a per-subject channel failure never leaves the +// user without a link. nil-safe (no task store / empty link ⇒ no-op). +func (r *Runner) PublishConsentArtifact(taskID, subject, server, authorizeURL string, deadline time.Time) { + if r.taskStore == nil || taskID == "" || authorizeURL == "" { + return } r.SetStatus(taskID, a2a.TaskStatus{ State: a2a.TaskStateAuthRequired, Message: &a2a.Message{ Role: a2a.MessageRoleAgent, Parts: []a2a.Part{ - a2a.NewTextPart(fmt.Sprintf("Authorization required: connect %s (as %s). Open this link to continue: %s", server, subject, link)), + a2a.NewTextPart(fmt.Sprintf("Authorization required: connect %s (as %s). Open this link to continue: %s", server, subject, authorizeURL)), a2a.NewDataPart(map[string]any{ "type": "mcp_auth_required", "server": server, "subject": subject, - "authorize_url": link, + "authorize_url": authorizeURL, "expires_at": deadline.UTC().Format(time.RFC3339), }), }, }, }) +} + +// standaloneConsentDeliverer is the ConsentDeliverer used when no channel +// adapter is active: it builds the link and publishes it on the task artifact. +// (When Slack is active, cmd/run.go's deliverer publishes the same artifact and +// additionally DMs the user.) +func (r *Runner) standaloneConsentDeliverer(ctx context.Context, subject, server, taskID string, deadline time.Time) error { + link, err := r.authorizeURLProvider(ctx, subject, server) + if err != nil { + return err + } + r.PublishConsentArtifact(taskID, subject, server, link, deadline) return nil } diff --git a/forge-cli/runtime/mcp_standalone_consent_test.go b/forge-cli/runtime/mcp_standalone_consent_test.go index b41abe1..ac6da1f 100644 --- a/forge-cli/runtime/mcp_standalone_consent_test.go +++ b/forge-cli/runtime/mcp_standalone_consent_test.go @@ -190,3 +190,60 @@ func TestStandaloneConsent_NotWiredWhenManaged(t *testing.T) { // Sanity: the helper wouldn't create the in-memory store either. _ = mcp.NewInMemorySubjectTokenStore } + +// TestManagedConsentProvider_FetchesFromPlatform proves that in platform mode +// with authorize_endpoint set, Runner.AuthorizeURL fetches the consent link +// from the platform (#343) — and that the standalone loop stays unwired. +func TestManagedConsentProvider_FetchesFromPlatform(t *testing.T) { + var gotBody map[string]string + plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _ = json.NewDecoder(req.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"authorize_url": "https://platform.example/idp/authorize?state=managed"}) + })) + defer plat.Close() + + r := &Runner{ + logger: nopLogger{}, + cfg: RunnerConfig{Config: &types.ForgeConfig{ + Platform: &types.PlatformConfig{ + TokenEndpoint: "https://platform.example/token", + AuthorizeEndpoint: plat.URL, + AgentIdentity: "agent-cred", + }, + MCP: types.MCPConfig{Servers: []types.MCPServer{{ + Name: "atl", Auth: &types.MCPAuth{Type: "user", Ref: "mcp.atl"}, + }}}, + }}, + } + r.enableStandaloneConsent(plat.Client()) // no-op (managed) + r.enableManagedConsentProvider(plat.Client()) // wires the provider + + if r.standaloneSubjectStore != nil || r.callbackCompleter != nil { + t.Error("managed mode must not host a local callback/store") + } + url, err := r.AuthorizeURL(context.Background(), "alice@corp.com", "atl") + if err != nil { + t.Fatalf("AuthorizeURL: %v", err) + } + if url != "https://platform.example/idp/authorize?state=managed" { + t.Errorf("url = %q, want the platform-supplied URL", url) + } + if gotBody["server"] != "mcp.atl" || gotBody["subject"] != "alice@corp.com" { + t.Errorf("platform request = %+v, want {server: ref, subject}", gotBody) + } +} + +// TestAuthorizeURLProvider_EmbedderPrecedence: an explicitly-set provider wins +// over both standalone and managed config. +func TestAuthorizeURLProvider_EmbedderPrecedence(t *testing.T) { + r := standaloneRunner(t, "https://idp.example/token") + r.SetAuthorizeURLProvider(func(context.Context, string, string) (string, error) { + return "https://embedder.example/custom", nil + }) + r.enableManagedConsentProvider(http.DefaultClient) // must not override + url, err := r.AuthorizeURL(context.Background(), "alice@corp.com", "atl") + if err != nil || url != "https://embedder.example/custom" { + t.Fatalf("AuthorizeURL = %q,%v, want the embedder override", url, err) + } +} diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index 8212492..c0ecc0f 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -1052,6 +1052,12 @@ func (r *Runner) Run(ctx context.Context) error { // store) and before the consent endpoints register. No-op unless a // standalone type: user server is configured. r.enableStandaloneConsent(egressClient) + // Managed delegated consent (#343): in platform mode Forge fetches + // the consent link from platform.authorize_endpoint and delivers it + // (e.g. over Slack). Provider only — the platform hosts the callback + // and token custody. No-op unless authorize_endpoint + a type: user + // server are configured. + r.enableManagedConsentProvider(egressClient) // Start MCP servers (Phase 1: HTTP-only) and register their // discovered tools as namespaced "__" entries. diff --git a/forge-cli/runtime/runner_mcp.go b/forge-cli/runtime/runner_mcp.go index e40e47f..a8acf1c 100644 --- a/forge-cli/runtime/runner_mcp.go +++ b/forge-cli/runtime/runner_mcp.go @@ -141,16 +141,22 @@ func (r *Runner) startMCPManager( return mgr, nil } -// platformResolverHost returns the platform token-resolver host for the -// egress allowlist (auth.type=platform servers fetch tokens from it). -// Empty when no platform block is configured. +// platformResolverHost returns the platform resolver hosts for the egress +// allowlist: the token endpoint (auth.type=platform/user fetch tokens from it) +// and the authorize endpoint (managed consent fetches the login URL from it, +// #343). Empty when no platform block is configured. func platformResolverHost(p *types.PlatformConfig) []string { - if p == nil || p.TokenEndpoint == "" { + if p == nil { return nil } - u, err := url.Parse(os.Expand(p.TokenEndpoint, os.Getenv)) - if err != nil || u.Hostname() == "" { - return nil + var hosts []string + for _, ep := range []string{p.TokenEndpoint, p.AuthorizeEndpoint} { + if ep == "" { + continue + } + if u, err := url.Parse(os.Expand(ep, os.Getenv)); err == nil && u.Hostname() != "" { + hosts = append(hosts, u.Hostname()) + } } - return []string{u.Hostname()} + return hosts } diff --git a/forge-core/channels/plugin.go b/forge-core/channels/plugin.go index 90cf209..44e453a 100644 --- a/forge-core/channels/plugin.go +++ b/forge-core/channels/plugin.go @@ -123,3 +123,51 @@ type ApprovalDeliverer interface { // approver acts. The runtime sets this once at startup. SetApprovalResolver(r ApprovalResolver) } + +// --- MCP delegated-consent (#343) delivery ----------------------------------- + +// ConsentPrompt is a pending MCP delegated-consent prompt: a "Connect " +// login link to present to the requesting user so they can authorize the agent +// to act as them. The runtime builds one when a type: user MCP call parks on +// the auth-required gate (#330); a channel adapter presents it. +// +// Delivery is independent of who built the URL and who hosts the callback: +// AuthorizeURL is opaque to the adapter (standalone → Forge-built; managed → +// platform-supplied), so the same delivery code serves both modes. +type ConsentPrompt struct { + Subject string // the requesting user (email preferred) — who to reach + Server string // MCP server name — for the prompt copy + AuthorizeURL string // the login link the user opens (a URL button) + Deadline time.Time // the gate timeout — rendered as "expires …" + Origin *ChannelOrigin // optional: reply in the origin thread if the request came via this channel +} + +// ChannelOrigin locates where a request came from so consent can be presented +// where the user is already talking (an in-thread reply) rather than a cold DM. +// An adapter populates it on inbound; nil ⇒ the deliverer falls back to +// reaching the Subject directly (e.g. Slack DM by email). +type ChannelOrigin struct { + Adapter string // e.g. "slack" + Channel string // native channel / DM id + ThreadTS string // thread to reply in (optional) + UserID string // native user id (skips an email lookup) +} + +// ConsentCanceler is invoked by an adapter when the user cancels a delivered +// consent prompt (e.g. a "Cancel" button). The runtime fails the parked call +// fast instead of idling to the deadline. Wired via +// ConsentDeliverer.SetConsentCanceler at startup; optional. +type ConsentCanceler func(ctx context.Context, subject, server string) error + +// ConsentDeliverer is an OPTIONAL capability. A channel adapter that can present +// an MCP consent login link to a specific user implements it (Slack via a DM / +// in-thread Block Kit message, #343). Adapters that don't implement it simply +// can't deliver consent prompts; the runtime falls back to publishing the link +// on the A2A auth-required artifact. +type ConsentDeliverer interface { + // DeliverConsent presents the consent prompt to req.Subject (or req.Origin). + DeliverConsent(ctx context.Context, req ConsentPrompt) error + // SetConsentCanceler wires the callback the adapter invokes when the user + // cancels. The runtime sets this once at startup; may be a no-op. + SetConsentCanceler(c ConsentCanceler) +} diff --git a/forge-core/mcp/platform_authorize_test.go b/forge-core/mcp/platform_authorize_test.go new file mode 100644 index 0000000..298151c --- /dev/null +++ b/forge-core/mcp/platform_authorize_test.go @@ -0,0 +1,65 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchAuthorizeURL(t *testing.T) { + t.Run("returns the platform-built URL", func(t *testing.T) { + var gotBody map[string]string + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"authorize_url": "https://idp.example/authorize?client_id=platform&state=abc"}) + })) + defer srv.Close() + + url, err := FetchAuthorizeURL(context.Background(), srv.Client(), srv.URL, "agent-cred", "mcp.atlassian", "alice@corp.com") + if err != nil { + t.Fatalf("FetchAuthorizeURL: %v", err) + } + if url != "https://idp.example/authorize?client_id=platform&state=abc" { + t.Errorf("url = %q", url) + } + if gotBody["server"] != "mcp.atlassian" || gotBody["subject"] != "alice@corp.com" { + t.Errorf("request body = %+v, want {server, subject}", gotBody) + } + if gotAuth != "Bearer agent-cred" { + t.Errorf("auth header = %q, want Bearer agent-cred", gotAuth) + } + }) + + t.Run("empty subject is ErrNoToken", func(t *testing.T) { + if _, err := FetchAuthorizeURL(context.Background(), http.DefaultClient, "https://x", "id", "ref", ""); !errors.Is(err, ErrNoToken) { + t.Fatalf("empty subject err = %v, want ErrNoToken", err) + } + }) + + t.Run("non-200 is a protocol error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + if _, err := FetchAuthorizeURL(context.Background(), srv.Client(), srv.URL, "id", "ref", "s"); err == nil { + t.Fatal("non-200 must error") + } + }) + + t.Run("missing authorize_url errors", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + if _, err := FetchAuthorizeURL(context.Background(), srv.Client(), srv.URL, "id", "ref", "s"); err == nil { + t.Fatal("empty authorize_url must error") + } + }) +} diff --git a/forge-core/mcp/platform_token.go b/forge-core/mcp/platform_token.go index 90a123a..a2ad400 100644 --- a/forge-core/mcp/platform_token.go +++ b/forge-core/mcp/platform_token.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strings" "sync" @@ -172,6 +173,74 @@ func doPlatformTokenRequest(ctx context.Context, client *http.Client, rawEndpoin return out.AccessToken, ttl, resp.StatusCode, nil } +// FetchAuthorizeURL asks the platform for a delegated-consent login URL for +// {ref, subject} (managed mode, #343). It POSTs the same shape + auth + tenancy +// headers as the token endpoint — Bearer agent identity, Org-Id/Workspace-Id — +// and expects {"authorize_url": "https://…"} back. The platform builds that URL +// with its own client_id / redirect_uri / state; Forge treats it as opaque and +// only delivers it (e.g. a Slack DM). Endpoint + identity are ${VAR}-expanded at +// request time. Any non-200 is surfaced as an error (there's no "not yet" state +// here — either the platform can mint a consent URL or it can't). +func FetchAuthorizeURL(ctx context.Context, client *http.Client, rawEndpoint, rawIdentity, ref, subject string) (string, error) { + endpoint := expandEnvVars(rawEndpoint) + identity := expandEnvVars(rawIdentity) + if endpoint == "" { + return "", fmt.Errorf("%w: platform.authorize_endpoint is empty (or its env var is unset)", ErrProtocolError) + } + if identity == "" { + return "", fmt.Errorf("%w: platform.agent_identity is empty (or its env var is unset)", ErrProtocolError) + } + if subject == "" { + return "", fmt.Errorf("%w: a consent URL requires a requesting-user subject", ErrNoToken) + } + body, err := json.Marshal(map[string]string{"server": ref, "subject": subject}) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+identity) + if org := os.Getenv("FORGE_ORG_ID"); org != "" { + req.Header.Set("Org-Id", org) + } + if ws := os.Getenv("FORGE_WORKSPACE_ID"); ws != "" { + req.Header.Set("Workspace-Id", ws) + } + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: platform authorize-url request failed: %v", ErrTransportUnavailable, err) + } + defer func() { _ = resp.Body.Close() }() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%w: platform authorize endpoint returned %d for server %q (subject %q)", ErrProtocolError, resp.StatusCode, ref, subject) + } + var out struct { + AuthorizeURL string `json:"authorize_url"` + } + if err := json.Unmarshal(data, &out); err != nil { + return "", fmt.Errorf("%w: parsing platform authorize response: %v", ErrProtocolError, err) + } + if out.AuthorizeURL == "" { + return "", fmt.Errorf("%w: platform authorize response carried no authorize_url", ErrProtocolError) + } + // Defense-in-depth: this URL flows straight to a clickable Slack button, so + // assert it's an absolute https URL — a misconfigured/compromised platform + // can't turn the bot into a phishing relay (mirrors the open-redirect check + // on /mcp/oauth/start). The platform is trusted, so this is a cheap guard. + if u, perr := url.Parse(out.AuthorizeURL); perr != nil || u.Scheme != "https" || u.Host == "" { + return "", fmt.Errorf("%w: platform authorize_url must be an absolute https URL, got %q", ErrProtocolError, out.AuthorizeURL) + } + return out.AuthorizeURL, nil +} + // delegatedTokenSource is the managed resolver for auth.type=user (#317): // a per-REQUESTING-USER access token from the platform token endpoint // (delegated body {server, subject}), cached per subject so distinct users diff --git a/forge-core/types/config.go b/forge-core/types/config.go index 07a6424..76e554d 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -628,6 +628,16 @@ type PlatformConfig struct { // AgentIdentity is the agent's platform credential (typically // ${FORGE_PLATFORM_TOKEN}), sent as the Bearer on token requests. AgentIdentity string `yaml:"agent_identity"` + // AuthorizeEndpoint is the platform's consent-URL resolver for managed + // delegated consent (#343): when a type: user call needs consent, Forge + // POSTs {"server": , "subject": } here (same auth + tenancy + // headers as TokenEndpoint) and the platform returns + // {"authorize_url": "https://…"} — a login URL it built with ITS OWN + // client_id / redirect_uri / state (Forge treats it as opaque and only + // delivers it, e.g. a Slack DM). Optional: unset ⇒ Forge can't fetch a + // managed consent link, so managed Slack delivery is disabled (the parked + // call still surfaces via the mcp_auth_required audit event). + AuthorizeEndpoint string `yaml:"authorize_endpoint,omitempty"` } // MCPAuth declares the authentication mechanism for an MCP server. diff --git a/forge-plugins/channels/slack/approvals.go b/forge-plugins/channels/slack/approvals.go index 406c87c..7d9ff91 100644 --- a/forge-plugins/channels/slack/approvals.go +++ b/forge-plugins/channels/slack/approvals.go @@ -353,6 +353,13 @@ func (p *Plugin) handleInteractive(ctx context.Context, payload []byte) error { // reason-less reject only if the modal can't be opened, so a reject is never // lost to a transient views.open failure). func (p *Plugin) handleBlockAction(ctx context.Context, payload []byte) error { + // MCP consent Cancel (#343) shares the block_actions envelope; route it + // first. The Connect button is a URL button — its click also arrives here + // but parses as neither Cancel nor approval, so it's ignored (the browser + // opened the link). + if handled, err := p.handleConsentCancel(ctx, payload); handled { + return err + } dec, userID, channelID, msgTS, triggerID, ok := parseApprovalInteraction(payload) if !ok { return nil // not a forge approval interaction; ignore quietly diff --git a/forge-plugins/channels/slack/consent.go b/forge-plugins/channels/slack/consent.go new file mode 100644 index 0000000..93ba320 --- /dev/null +++ b/forge-plugins/channels/slack/consent.go @@ -0,0 +1,272 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/initializ/forge/forge-core/channels" +) + +// MCP delegated-consent (#343) delivery over the SAME Socket Mode connection + +// bot token the adapter already uses. The bot presents a "Connect " +// Block Kit message to the requesting user — in the origin thread when the +// request came via Slack, else a DM resolved by email — with a URL button that +// opens the login link. The Connect button is a plain link-open (the OAuth +// callback resolves the gate, not a Slack round-trip); an optional "Cancel" +// button fails the parked call fast via the wired canceler. +var _ channels.ConsentDeliverer = (*Plugin)(nil) + +const ( + // consentConnectActionID tags the URL button. URL buttons still emit a + // block_actions event on click; we ignore it (the browser opens the link). + consentConnectActionID = "forge_mcp_consent_connect" + // consentCancelActionID tags the Cancel button; its click routes to the + // wired ConsentCanceler. + consentCancelActionID = "forge_mcp_consent_cancel" +) + +// SetConsentCanceler wires the callback invoked when the user cancels a consent +// prompt. Called once by the runtime at startup; may be nil (no Cancel button). +func (p *Plugin) SetConsentCanceler(c channels.ConsentCanceler) { + p.consentCanceler = c +} + +// DeliverConsent presents the consent login link to the requesting user. It +// reaches them in the origin thread if the request came via Slack, else opens a +// DM resolved from their email. Delivery failure is returned (the runtime logs +// it, non-fatal — the parked call still resumes when the callback lands, and +// mcp_auth_required was already emitted as the platform-read backstop). +func (p *Plugin) DeliverConsent(ctx context.Context, req channels.ConsentPrompt) error { + if req.AuthorizeURL == "" { + return fmt.Errorf("slack DeliverConsent: empty authorize URL") + } + channelID, threadTS, err := p.consentTarget(ctx, req) + if err != nil { + return fmt.Errorf("slack DeliverConsent: %w", err) + } + payload := buildConsentPayload(req, p.consentCanceler != nil) + payload["channel"] = channelID + if threadTS != "" { + payload["thread_ts"] = threadTS + } + return p.postMessage(payload) +} + +// consentTarget resolves where to post: the origin Slack thread when present, +// else a DM to the subject resolved by email. Returns the channel id and an +// optional thread_ts. +func (p *Plugin) consentTarget(ctx context.Context, req channels.ConsentPrompt) (channelID, threadTS string, err error) { + if o := req.Origin; o != nil && strings.EqualFold(o.Adapter, "slack") && o.Channel != "" { + return o.Channel, o.ThreadTS, nil + } + // Cold DM: email → user id → open DM channel. + if req.Subject == "" { + return "", "", fmt.Errorf("no subject to DM and no slack origin") + } + userID, err := p.lookupUserIDByEmail(ctx, req.Subject) + if err != nil { + return "", "", err + } + dmID, err := p.openDM(ctx, userID) + if err != nil { + return "", "", err + } + return dmID, "", nil +} + +// lookupUserIDByEmail resolves an email → Slack user id via users.lookupByEmail +// (a single call, unlike paging users.list), cached. Requires +// users:read.email. Fails when the email isn't a workspace member. +func (p *Plugin) lookupUserIDByEmail(ctx context.Context, email string) (string, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return "", fmt.Errorf("empty email") + } + p.userMu.Lock() + if id, ok := p.userIDByEmail[email]; ok { + p.userMu.Unlock() + return id, nil + } + p.userMu.Unlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.apiBase+"/users.lookupByEmail?email="+url.QueryEscape(email), nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+p.botToken) + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("users.lookupByEmail decode: %w", err) + } + if !out.OK || out.User.ID == "" { + return "", fmt.Errorf("users.lookupByEmail(%s): %s (need users:read.email; is the user in this workspace?)", email, out.Error) + } + p.userMu.Lock() + if p.userIDByEmail == nil { + p.userIDByEmail = map[string]string{} + } + p.userIDByEmail[email] = out.User.ID + p.userMu.Unlock() + return out.User.ID, nil +} + +// openDM opens (or returns the cached) IM channel with a user via +// conversations.open. Requires im:write. The DM channel id is stable per user, +// so caching it never goes stale. +func (p *Plugin) openDM(ctx context.Context, userID string) (string, error) { + if userID == "" { + return "", fmt.Errorf("empty user id") + } + p.userMu.Lock() + if id, ok := p.dmChannel[userID]; ok { + p.userMu.Unlock() + return id, nil + } + p.userMu.Unlock() + + body, _ := json.Marshal(map[string]any{"users": userID}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/conversations.open", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.botToken) + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("conversations.open decode: %w", err) + } + if !out.OK || out.Channel.ID == "" { + return "", fmt.Errorf("conversations.open: %s (need im:write)", out.Error) + } + p.userMu.Lock() + if p.dmChannel == nil { + p.dmChannel = map[string]string{} + } + p.dmChannel[userID] = out.Channel.ID + p.userMu.Unlock() + return out.Channel.ID, nil +} + +// consentCancelValue is what the Cancel button carries so its click resolves +// the right gate ({subject, server}). +type consentCancelValue struct { + Subject string `json:"subject"` + Server string `json:"server"` +} + +// buildConsentPayload renders the chat.postMessage body (minus the channel, +// which DeliverConsent sets). Pure so tests can assert the Block Kit shape. +// withCancel adds a Cancel button (only when a canceler is wired). +func buildConsentPayload(req channels.ConsentPrompt, withCancel bool) map[string]any { + detail := fmt.Sprintf("*Authorization required* to connect *%s*.\nOpen the link to sign in and grant access.", req.Server) + if !req.Deadline.IsZero() { + detail += fmt.Sprintf("\n_Expires %s._", req.Deadline.UTC().Format(time.RFC1123)) + } + elements := []any{ + map[string]any{ + "type": "button", + "action_id": consentConnectActionID, + "style": "primary", + "text": map[string]any{"type": "plain_text", "text": "Connect " + req.Server}, + "url": req.AuthorizeURL, + }, + } + if withCancel { + val, _ := json.Marshal(consentCancelValue{Subject: req.Subject, Server: req.Server}) + elements = append(elements, map[string]any{ + "type": "button", + "action_id": consentCancelActionID, + "style": "danger", + "text": map[string]any{"type": "plain_text", "text": "Cancel"}, + "value": string(val), + }) + } + return map[string]any{ + "text": fmt.Sprintf("Authorization required to connect %s", req.Server), // fallback / a11y + "blocks": []any{ + map[string]any{"type": "section", "text": map[string]any{"type": "mrkdwn", "text": detail}}, + map[string]any{"type": "actions", "block_id": "forge_mcp_consent", "elements": elements}, + }, + } +} + +// parseConsentCancel extracts {subject, server} + the clicking user from a +// Cancel-button click. Pure + testable. ok=false for any interaction that isn't +// our Cancel button. +func parseConsentCancel(payload []byte) (subject, server, clickerID, channelID, msgTS string, ok bool) { + var in slackInteraction + if err := json.Unmarshal(payload, &in); err != nil { + return "", "", "", "", "", false + } + if in.Type != "block_actions" || len(in.Actions) == 0 { + return "", "", "", "", "", false + } + a := in.Actions[0] + if a.ActionID != consentCancelActionID { + return "", "", "", "", "", false + } + var v consentCancelValue + if err := json.Unmarshal([]byte(a.Value), &v); err != nil || v.Server == "" { + return "", "", "", "", "", false + } + return v.Subject, v.Server, in.User.ID, in.Channel.ID, in.Message.TS, true +} + +// handleConsentCancel routes a Cancel-button click to the wired canceler and +// updates the message. Returns ok=false when the payload isn't our Cancel +// button so the caller falls through to other interaction handlers. +// +// Clicker-identity guard: only the requesting user may cancel their own consent. +// v1 delivers over DMs (only the subject sees the button), so this is latent — +// but it fails closed once origin-thread delivery lands (a shared channel where +// others could click Cancel and DoS a peer's parked call). The check needs the +// clicker's email (users.info) — the same users:read.email scope the DM lookup +// already requires — and fails OPEN only when the email can't be resolved (a +// DM-only deployment without the scope has no cross-user exposure anyway). +func (p *Plugin) handleConsentCancel(ctx context.Context, payload []byte) (handled bool, err error) { + subject, server, clickerID, channelID, msgTS, ok := parseConsentCancel(payload) + if !ok { + return false, nil + } + if email, rErr := p.resolveUserEmail(ctx, clickerID); rErr == nil && !strings.EqualFold(email, subject) { + p.updateApprovalMessage(channelID, msgTS, ":warning: Only the requesting user can cancel this authorization.") + return true, nil + } + if p.consentCanceler == nil { + return true, fmt.Errorf("consent cancel click for %s/%s but no canceler wired", subject, server) + } + if cErr := p.consentCanceler(ctx, subject, server); cErr != nil { + p.updateApprovalMessage(channelID, msgTS, fmt.Sprintf(":warning: could not cancel: %v", cErr)) + return true, cErr + } + p.updateApprovalMessage(channelID, msgTS, ":no_entry: Authorization canceled.") + return true, nil +} diff --git a/forge-plugins/channels/slack/consent_test.go b/forge-plugins/channels/slack/consent_test.go new file mode 100644 index 0000000..8e21c69 --- /dev/null +++ b/forge-plugins/channels/slack/consent_test.go @@ -0,0 +1,219 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/initializ/forge/forge-core/channels" +) + +// mockSlack serves the Slack API calls the consent flow makes, recording the +// last chat.postMessage body. lookupByEmail → user id, conversations.open → DM. +func mockSlack(t *testing.T, postBody *map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/users.lookupByEmail"): + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "user": map[string]any{"id": "U123"}}) + case strings.HasSuffix(r.URL.Path, "/conversations.open"): + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "channel": map[string]any{"id": "D999"}}) + case strings.HasSuffix(r.URL.Path, "/chat.postMessage"): + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + if postBody != nil { + *postBody = b + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } + })) +} + +func consentPrompt() channels.ConsentPrompt { + return channels.ConsentPrompt{ + Subject: "alice@corp.com", Server: "atlassian", + AuthorizeURL: "https://agent.example/mcp/oauth/start?state=xyz", + Deadline: time.Now().Add(time.Hour), + } +} + +func TestBuildConsentPayload(t *testing.T) { + t.Run("connect URL button, no cancel", func(t *testing.T) { + p := buildConsentPayload(consentPrompt(), false) + blocks := p["blocks"].([]any) + actions := blocks[1].(map[string]any)["elements"].([]any) + if len(actions) != 1 { + t.Fatalf("want 1 action (Connect), got %d", len(actions)) + } + btn := actions[0].(map[string]any) + if btn["url"] != "https://agent.example/mcp/oauth/start?state=xyz" { + t.Errorf("connect button url = %v", btn["url"]) + } + if btn["action_id"] != consentConnectActionID { + t.Errorf("connect action_id = %v", btn["action_id"]) + } + }) + t.Run("cancel button present when canceler wired", func(t *testing.T) { + p := buildConsentPayload(consentPrompt(), true) + actions := p["blocks"].([]any)[1].(map[string]any)["elements"].([]any) + if len(actions) != 2 { + t.Fatalf("want Connect + Cancel, got %d", len(actions)) + } + cancel := actions[1].(map[string]any) + if cancel["action_id"] != consentCancelActionID { + t.Fatalf("cancel action_id = %v", cancel["action_id"]) + } + var v consentCancelValue + if err := json.Unmarshal([]byte(cancel["value"].(string)), &v); err != nil { + t.Fatalf("cancel value not JSON: %v", err) + } + if v.Subject != "alice@corp.com" || v.Server != "atlassian" { + t.Errorf("cancel value = %+v", v) + } + }) +} + +func TestDeliverConsent_DMByEmail(t *testing.T) { + var body map[string]any + srv := mockSlack(t, &body) + defer srv.Close() + + p := New() + p.apiBase = srv.URL + p.botToken = "xoxb-test" + + if err := p.DeliverConsent(context.Background(), consentPrompt()); err != nil { + t.Fatalf("DeliverConsent: %v", err) + } + if body["channel"] != "D999" { + t.Errorf("posted to channel %v, want the opened DM D999", body["channel"]) + } + if _, hasThread := body["thread_ts"]; hasThread { + t.Error("a cold DM must not set thread_ts") + } + // Caches: a second delivery reuses the resolved id + DM (still works). + if err := p.DeliverConsent(context.Background(), consentPrompt()); err != nil { + t.Fatalf("second DeliverConsent: %v", err) + } +} + +func TestDeliverConsent_OriginThread(t *testing.T) { + var body map[string]any + lookupHit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/users.lookupByEmail") { + lookupHit = true + } + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/chat.postMessage") { + _ = json.NewDecoder(r.Body).Decode(&body) + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + })) + defer srv.Close() + + p := New() + p.apiBase = srv.URL + p.botToken = "xoxb-test" + + req := consentPrompt() + req.Origin = &channels.ChannelOrigin{Adapter: "slack", Channel: "C555", ThreadTS: "1699.0001"} + if err := p.DeliverConsent(context.Background(), req); err != nil { + t.Fatalf("DeliverConsent: %v", err) + } + if body["channel"] != "C555" || body["thread_ts"] != "1699.0001" { + t.Errorf("origin delivery posted to %v thread %v, want C555/1699.0001", body["channel"], body["thread_ts"]) + } + if lookupHit { + t.Error("origin delivery must not resolve the email") + } +} + +// cancelSlack serves users.info returning clickerEmail, so the clicker-identity +// guard can be exercised. +func cancelSlack(t *testing.T, clickerEmail string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/users.info") { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "user": map[string]any{"profile": map[string]any{"email": clickerEmail}}, + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + })) +} + +func cancelPayload(t *testing.T) []byte { + t.Helper() + val, _ := json.Marshal(consentCancelValue{Subject: "alice@corp.com", Server: "atlassian"}) + payload, _ := json.Marshal(map[string]any{ + "type": "block_actions", + "user": map[string]any{"id": "U1"}, + "channel": map[string]any{"id": "D999"}, + "message": map[string]any{"ts": "1699.1"}, + "actions": []any{map[string]any{"action_id": consentCancelActionID, "value": string(val)}}, + }) + return payload +} + +func TestHandleConsentCancel_InvokesCanceler(t *testing.T) { + srv := cancelSlack(t, "alice@corp.com") // clicker IS the subject + defer srv.Close() + + var gotSubject, gotServer string + p := New() + p.apiBase = srv.URL + p.botToken = "xoxb-test" + p.SetConsentCanceler(func(_ context.Context, subject, server string) error { + gotSubject, gotServer = subject, server + return nil + }) + + // Routed through the shared block-action dispatch. + if err := p.handleBlockAction(context.Background(), cancelPayload(t)); err != nil { + t.Fatalf("handleBlockAction: %v", err) + } + if gotSubject != "alice@corp.com" || gotServer != "atlassian" { + t.Errorf("canceler got %q/%q", gotSubject, gotServer) + } +} + +// A different user clicking Cancel (only reachable once origin-thread delivery +// lands) must NOT cancel the subject's parked call. +func TestHandleConsentCancel_RejectsNonSubject(t *testing.T) { + srv := cancelSlack(t, "mallory@corp.com") // clicker is NOT the subject + defer srv.Close() + + called := false + p := New() + p.apiBase = srv.URL + p.botToken = "xoxb-test" + p.SetConsentCanceler(func(_ context.Context, _, _ string) error { + called = true + return nil + }) + + if err := p.handleBlockAction(context.Background(), cancelPayload(t)); err != nil { + t.Fatalf("handleBlockAction: %v", err) + } + if called { + t.Error("a non-subject Cancel click must not cancel the parked call") + } +} + +// A non-consent block action (e.g. an approval button) must not be swallowed by +// the consent-cancel handler. +func TestHandleConsentCancel_IgnoresOthers(t *testing.T) { + if _, _, _, _, _, ok := parseConsentCancel([]byte(`{"type":"block_actions","actions":[{"action_id":"forge_defer_approve","value":"task-1"}]}`)); ok { + t.Error("an approval action must not parse as a consent cancel") + } +} diff --git a/forge-plugins/channels/slack/slack.go b/forge-plugins/channels/slack/slack.go index 4906cef..738ce94 100644 --- a/forge-plugins/channels/slack/slack.go +++ b/forge-plugins/channels/slack/slack.go @@ -57,9 +57,18 @@ type Plugin struct { chanIDCache map[string]string // userEmailCache memoizes resolved user id → email for the DEFER approver - // allowlist (#313) so a repeated approver skips users.info. + // allowlist (#313) so a repeated approver skips users.info. userIDByEmail + // and dmChannel memoize the reverse (email → user id) and the opened DM + // channel for MCP consent DMs (#343). All guarded by userMu. userMu sync.Mutex userEmailCache map[string]string + userIDByEmail map[string]string + dmChannel map[string]string // user id → opened DM channel id + + // consentCanceler is wired by the runtime (SetConsentCanceler) so a + // "Cancel" click on an MCP consent prompt fails the parked call fast + // (#343). nil when consent delivery isn't wired. + consentCanceler channels.ConsentCanceler } // SetLogger wires a structured ops logger (channels.LoggerAware). Optional.