picatz / picatz/flowstate

MCP authorization: what `flow mcp` must serve to be an OAuth 2.1 protected resource, and how an agent gets a token at all

Open
#558 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auth design documentation kind/design-record
Dominant language
Go
Stars
9
Forks
0
Avg merge
3h 3m
Merged PRs (30d)
509

Description

#549 says in one sentence what this issue is about: "flow mcp is stdio and in-process today, so it inherits whatever the parent process has; the moment it is reachable over HTTP it needs the OAuth story MCP now expects." This is that slice, worked out against the spec as it actually reads in August 2026 rather than as it read when most implementations were written — and the headline is that the spec moved twice since, and the piece flowstate would have to build is smaller than the piece it already has.

This hangs off #549 (the serving surface it needs), #557 (the principal it produces), and #548 (the vocabulary the resulting policy is written in). None of those are restated here.

What is true on main

Verified at write time, because the gap here is easy to overstate in both directions.

  • flow mcp serves over &mcp.StdioTransport{} only (cmd/flow/mcp.go:262). There is no HTTP listener for it, no session, and no per-caller anything. Every posture decision — egress policy, secret providers, --reveal-sensitive, plugin dir, the run-local timeout — is taken once at process start, which runMCP says is deliberate: "per-call escalation is impossible" because the only caller is the process that spawned it.
  • The surface already documents its own identity hole, in the tool description a model reads: "Over stdio the signal is delivered as this process's own identity, not as the identity of whoever asked for it. Nothing on this transport can attest that a particular human approved anything" (cmd/flow/mcp.go:110). That is correct today and is exactly what an HTTP transport would change.
  • github.com/modelcontextprotocol/go-sdk v1.7.0 is already a direct dependency, and it already ships the resource-server half: mcp.NewStreamableHTTPHandler (mcp/streamable.go:232), auth.RequireBearerToken middleware that emits WWW-Authenticate: Bearer resource_metadata=…, scope=… on 401/403 (auth/auth.go:97), and auth.ProtectedResourceMetadataHandler over oauthex.ProtectedResourceMetadata (auth/auth.go:188). We are not writing RFC 9728 by hand.
  • auth.OIDCVerifier.Verify already performs, per its own contract, every check OAuth 2.1 §5.2 asks a resource server for: signature against a discovered JWKS, alg allowlist with none and HMAC refused, exp/iat/nbf, exact iss match against a trusted issuer, and aud containing an audience that issuer accepts (auth/verifier.go:270-278, policy Audiences at auth/policy.go:81-88). The audience validation the MCP spec is most insistent about is the one piece of this that is finished.
  • serverHandler already has the shape a .well-known document needs: the default route is authenticated, and public documents are mounted explicitly beside it, only when configured (cmd/flow/routing.go:75-105, JWKS and OpenID discovery today). Adding one more unauthenticated well-known route is a two-line change to a function whose doc comment already explains why those routes sit outside the middleware.
  • What is genuinely absent: no .well-known/oauth-protected-resource, no authorization endpoint, no token endpoint, no device endpoint, no PKCE, no flow login command at all (grep finds no Use: "login"), and golang.org/x/oauth2 v0.36.0 is still indirect. auth/exchange_oauth.go implements RFC 8693 token exchange outbound — flowstate's own assertion traded at somebody else's AS — with subject_token and no actor_token anywhere in the tree.

So: flowstate can already verify an MCP-compliant token correctly. It cannot advertise that it wants one, and nobody can get one from it.

The spec as it stands, which is not the spec most implementations were built against

The current revision is 2026-07-28. Two revisions changed the shape of this since the widely-implemented 2025-06-18:

  • Dynamic Client Registration is deprecated. RFC 7591 dropped from SHOULD to MAY, "retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents". The replacement is OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-00): the client_id is an HTTPS URL with a path component, the AS fetches it and MUST validate that the document's client_id matches the URL exactly and that the presented redirect_uri is in the document. Clients pick in priority order: pre-registration, then CIMD if the AS advertises client_id_metadata_document_supported, then DCR, then ask the user.
  • Authorization server metadata is now "at least one of" RFC 8414 or OpenID Connect Discovery 1.0 — but clients MUST support both. An AS publishing only /.well-known/openid-configuration is now conformant.
  • RFC 9207 issuer identification landed: the AS SHOULD return iss in authorization responses (with authorization_response_iss_parameter_supported in metadata), and clients MUST compare it to the recorded issuer by simple string comparison, with normalization explicitly forbidden. The spec says outright that a future revision upgrades this to MUST.
  • Scope challenges and step-up are now normative on the resource server: a scope parameter SHOULD appear in the 401 challenge, 403 with error="insufficient_scope" and the full required scope set SHOULD be the answer to an under-scoped token, and servers SHOULD emit all scopes for an operation in one challenge rather than incrementally.

What did not change, and is the part that binds a resource server: MCP servers MUST implement RFC 9728, MUST use WWW-Authenticate on 401 to point at the resource metadata URL, MUST validate that tokens were issued specifically for them as audience per RFC 8707 §2, MUST NOT accept or transit any other tokens, and MUST NOT pass a client's token through to an upstream API. Clients MUST send resource on both the authorization and token requests, and MUST implement PKCE (S256 where capable). And the stdio guidance is unchanged and explicit: implementations using stdio SHOULD NOT follow this specification, and instead retrieve credentials from the environment.

That last line matters more than it looks: it means today's flow mcp is not out of compliance, it is out of scope. Nothing here is a fix to an existing defect. It is the price of a new transport.

Resource server versus authorization server, and what each costs

Obligation Resource server (flowstate) Authorization server
/.well-known/oauth-protected-resource with authorization_servers, resource, scopes_supported (RFC 9728) MUST
WWW-Authenticate on 401 pointing at that document, with scope MUST / SHOULD
Audience validation of every inbound token (RFC 8707 §2) MUST — ships today
403 + insufficient_scope + required scopes SHOULD
Never forward a client's token upstream MUST
OAuth 2.1 for confidential and public clients MUST
RFC 8414 or OIDC Discovery metadata MUST
Authorization + token endpoints, PKCE S256 verification, exact redirect-URI matching MUST
CIMD fetch, validate, cache (and SSRF-bound that fetch) SHOULD
RFC 9207 iss in authorization responses SHOULD, becoming MUST
Refresh-token rotation for public clients MUST
Device authorization grant (RFC 8628, not part of OAuth 2.1 core) if headless clients are supported

Recommendation: resource-server-first, which is #549's Q2 answer restated with the bill attached. The resource-server column is roughly: one JSON document, one route in serverHandler, an adapter from auth.Verifier to the SDK's TokenVerifier, a scope vocabulary, and tests. The authorization-server column is a permanent obligation to track a spec that has changed its client-registration story twice in a year, plus persistent state for clients, codes, and refresh tokens — which #557 Q3 already identifies as the largest hidden cost in that issue, for the same reason.

What resource-server-first costs, stated plainly rather than buried:

  1. A deployment with no IdP cannot use HTTP MCP at all. --insecure-no-auth covers loopback development and nothing else. This is a real regression against invariant 8 ("a first run needs nothing") for anyone whose first run is an agent on another machine, and it is the argument for eventually adding a device grant.
  2. We do not control the token's shape, so scope semantics, lifetime, and any on_behalf_of claim are whatever the IdP emits. Agent delegation (below) becomes claim-mapping rather than something we mint.
  3. The 403/insufficient_scope path is only as good as the scopes the IdP can issue. A deployment whose IdP cannot express flowstate:signal separately from flowstate:run gets coarse authorization, and the step-up flow degrades to "re-auth and get the same token back".
  4. Confused-deputy risk moves to us anyway. flowstate's tasks make outbound HTTP. The spec's MUST NOT-pass-through rule means the inbound MCP token can never become the outbound credential — which is an argument, from a completely different direction, for #549's credential-injecting egress proxy.

PKCE everywhere, and the grants that are not on the menu

If flowstate ever does serve authorization endpoints, OAuth 2.1 is the floor and #549 is right that it is mostly a list of refusals — but the refusals should be structural, not configuration. The schema should not be able to spell an implicit grant or a password grant, so that "we do not support it" is a compile error rather than a policy check somebody can flip. PKCE is not a boolean on an authorization-code grant; a grant message with a code flow and no verifier is a message that should not exist.

The device authorization grant (RFC 8628) is the interesting one and it is deliberately not part of OAuth 2.1 core — it is a separate, additive grant. It is also the only shape that fits three of this repository's real callers: a CLI on a headless box, an agent host with no browser, and CI. It is the one AS-side capability worth building even under a resource-server-first posture, because it is the one that closes cost (1) above. The client half of it, though, is worth building first and independently: flow login --device against somebody else's AS is useful the day HTTP MCP exists, and needs no AS work from us.

How an agent gets a token

This is the part that has no good precedent, and the wrong answers are both available and tempting.

A service account is wrong because the agent's authority would be a standing grant that outlives the request and names no human — the thing #557 is explicitly trying not to build a fourth spelling of. A browser flow is wrong because there is no browser, and worse, an agent that can drive one is an agent that can be phished on a human's behalf. client_credentials is wrong for the same reason as the service account: the ext-auth SEP-1046 client-credentials handler that ships in the SDK (auth/extauth/client_credentials.go) is explicitly "for service-to-service authentication where the client has pre-registered credentials and does not require user interaction" — the correct tool for a workload, and a category error for an agent acting for a person.

The right answer already has a spelling in this repository's design, and it is #557's: PRINCIPAL_KIND_AGENT with a populated on_behalf_of. The agent's token names the agent as subject and the human as delegator, and both are visible to policy at once, which is what makes "a human may deploy; an agent acting for a human may not" expressible. Concretely, on an HTTP MCP surface:

  • The human authenticates once (device grant, or their IdP's own flow) and the agent never holds the human's token.
  • The agent presents its own credential, and the delegation is carried in the token — either by an IdP that can mint one, or through RFC 8693 token exchange with actor_token, which is the standard's own name for exactly this: subject token = the human, actor token = the agent. The tree implements 8693 already, but only outbound and only with subject_token (auth/exchange_oauth.go:150); an actor_token parameter and the act claim it produces are the missing half.
  • The MCP surface maps the resulting claims onto one Principal with kind and on_behalf_of, and #548's single vocabulary means the egress rule, the secret rule and the MCP tool authorization all read the same identity.on_behalf_of — no fourth notion of who is calling.
  • on_behalf_of is never assertable by the caller, exactly as namespace is not today. It comes from the verified token or it is absent, and absent is refused where a rule requires it.

This also finally answers the caveat flow mcp prints today. An approval delivered over an authenticated HTTP MCP session with identity.kind == "human" and recent evidence can attest that a particular person approved something; an agent's flowstate_signal with kind == "agent" and on_behalf_of set is a materially different, and readable, event. distinct_from_starter becomes checkable against a real identity instead of a process.

What changes when flow mcp becomes reachable over HTTP

The transport change is not "add a listener". It invalidates the design premise of the whole command, which is worth stating because the current code is correct under that premise and would become wrong without changing a line.

runMCP's posture decisions are process-wide because there is exactly one caller and it is the parent process. Over HTTP there are many callers, of several kinds, in several tenants. Every one of these becomes a per-principal question:

  • --egress-policy decides what flowstate_run_local may reach. Process-wide today; per-principal over HTTP, or every caller shares the most permissive tenant's allowlist. This is #548's identity-keyed evaluation, and it is a hard prerequisite, not a nicety.
  • --secret-env / --secret-dir / --auth-policy decide what a local run may resolve. Same problem, sharper: the tenancy lesson in CLAUDE.md is about exactly this, and an HTTP MCP surface with a process-wide secret store is the "A can reach A" test passing while A reaches B.
  • --reveal-sensitive is documented as "one deliberate, written-down decision that every call this process serves shows declared-sensitive values in the clear". Over HTTP that sentence describes a data leak to whoever authenticates.
  • maxMCPResultBytes and run-local-timeout are per-process bounds on a single trusted caller; over HTTP they are per-caller resource bounds and the aggregate is unbounded. Concurrency and total in-flight local runs need their own bound, by the house rule that you bound the resource the peer controls the ratio to.
  • flowstate_run_local and flowstate_test execute submitted code in the server process. On stdio that is the author's own machine. Over HTTP it is remote code execution as a feature, which is #548's compute-and-storage gap arriving with a caller attached.
  • Session binding. The SDK's TokenInfo.UserID exists specifically "to prevent session hijacking by ensuring that all requests for a given session come from the same user" — a streamable-HTTP session must be pinned to the principal that created it, which needs the Principal to map onto that field.

My reading: HTTP MCP should be a separate command or an explicit flag with its own default-off posture, not a transport switch on flow mcp, precisely so that the process-wide posture flags do not silently change meaning. And the local-execution tools (flowstate_run_local, flowstate_test) should be independently gated on that surface — flowstate_test is defensible since a stubbed run reaches nothing by construction, and flowstate_run_local is not.

Sketches

Illustrative, not the landed shape. The configuration extends #549's AuthConfig rather than inventing a parallel one.

// Illustrative, not the landed shape. Field 4 continues #549's AuthConfig.
message AuthConfig {
  bool require_dpop = 1;
  bool require_mtls_binding = 2;
  repeated Grant grants = 3;

  // Present means this deployment serves RFC 9728 protected resource metadata
  // and requires a token on the MCP surface. Absent means the MCP surface is
  // not served over HTTP at all: there is no unauthenticated variant of it.
  ProtectedResource protected_resource = 4;
}

// The RFC 9728 document, as configuration rather than as a hand-written JSON
// file, so a deployment cannot publish a resource identifier that disagrees
// with the audience the verifier actually checks.
message ProtectedResource {
  // The canonical URI of this MCP server (RFC 8707 section 2). No fragment,
  // no trailing slash. This is also the audience every inbound token must
  // carry, which is why it is one field and not two.
  string resource = 1 [
    (buf.validate.field).required = true,
    (buf.validate.field).string.uri = true,
    (buf.validate.field).string.pattern = "^https://[^#]*[^/#]$"
  ];

  // At least one, per the MCP specification's MUST. Each is an issuer
  // identifier that must already appear in the trust policy: advertising an
  // authorization server whose tokens the verifier would reject is a
  // startup failure, not a runtime 401.
  repeated string authorization_servers = 2 [
    (buf.validate.field).repeated.min_items = 1,
    (buf.validate.field).repeated.items.string.uri = true
  ];

  // The minimal set for basic functionality; finer scopes are challenged for
  // per operation via 403 + insufficient_scope.
  repeated string scopes_supported = 3 [
    (buf.validate.field).repeated.items.string.min_len = 1
  ];
}

The Go is mostly an adapter, because both halves already exist:

// MCPTokenVerifier adapts the Verifier that already performs OAuth 2.1 section
// 5.2 validation to the callback the MCP SDK's middleware wants. resource is
// the canonical URI from ProtectedResource; a token whose audience does not
// carry it is refused here rather than reaching a tool, which is the MCP
// specification's MUST and this repository's fail-closed default agreeing.
//
// It never returns a partially verified TokenInfo: the SDK treats a non-nil
// TokenInfo as authenticated, so the only safe error path is a nil one.
func MCPTokenVerifier(v auth.Verifier, resource string) mcpauth.TokenVerifier

// PrincipalFromTokenInfo recovers the Principal a tool handler authorizes
// against, so an MCP tool reads the same identity a Connect RPC handler does
// (#548's one vocabulary), including kind and on_behalf_of (#557).
func PrincipalFromTokenInfo(ctx context.Context) (auth.Principal, bool)

// ProtectedResourceHandler serves RFC 9728 metadata. Mounted beside the JWKS
// route in serverHandler and deliberately outside the authenticator, for the
// reason that file already gives: a client fetches this before it holds any
// credential, and a 401 here is the failure that looks like a signing bug.
func ProtectedResourceHandler(pr *v1.ProtectedResource) http.Handler

What an operator writes, continuing #549's file:

server:
  public:
    address: ":443"
    tls:
      acme:
        hosts: [flowstate.example.com]
  auth:
    protected_resource:
      resource: https://flowstate.example.com/mcp
      authorization_servers:
        - https://acme.okta.com
      scopes_supported: [flowstate:read, flowstate:run]

and the wire, which is the whole of what a compliant client needs from us:

$ curl -i https://flowstate.example.com/mcp
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://flowstate.example.com/.well-known/oauth-protected-resource",
                         scope="flowstate:read"

$ curl -s https://flowstate.example.com/.well-known/oauth-protected-resource
{
  "resource": "https://flowstate.example.com/mcp",
  "authorization_servers": ["https://acme.okta.com"],
  "scopes_supported": ["flowstate:read", "flowstate:run"],
  "bearer_methods_supported": ["header"]
}

$ flow login --server https://flowstate.example.com   # device grant, no browser on this box
open https://acme.okta.com/activate and enter code  WDJB-MJHT
waiting…
logged in as alice@example.com  (expires in 8h)

$ flow mcp --http :8443 --egress-policy ./policy.yaml
serving MCP over HTTP; every caller must present a token for
https://flowstate.example.com/mcp — local-execution tools are off
unless --allow-run-local is given

The sequence, once, because the 401-first bootstrap is the part everyone gets wrong:

sequenceDiagram
    participant A as agent (MCP client)
    participant F as flow mcp --http<br/>(resource server)
    participant AS as authorization server<br/>(Okta / Auth0 / ...)

    A->>F: initialize, no token
    F-->>A: 401 + WWW-Authenticate:<br/>resource_metadata=..., scope="flowstate:read"
    A->>F: GET /.well-known/oauth-protected-resource
    F-->>A: resource + authorization_servers + scopes_supported
    A->>AS: GET metadata (RFC 8414, else OIDC Discovery)
    AS-->>A: token_endpoint, device_authorization_endpoint,<br/>client_id_metadata_document_supported
    Note over A,AS: client_id is the agent's own HTTPS<br/>metadata document URL (CIMD), not DCR
    A->>AS: device code + PKCE S256 + resource=https://flowstate.example.com/mcp
    AS-->>A: access token (aud = that resource, act = the agent)
    A->>F: MCP request + Bearer token
    F->>F: verify: iss, alg, exp, aud == resource<br/>then map claims to Principal{kind, on_behalf_of}
    F-->>A: tool result, authorized against one policy vocabulary (#548)

Constraints

  • Fail closed on the new surfaces. No protected-resource configuration means no HTTP MCP surface, not an unauthenticated one. An authorization_servers entry the trust policy does not already accept is a startup failure, not a per-request 401 — the compile-at-load rule #548 states.
  • Never pass a token through. The MCP spec's MUST NOT and this repository's own instinct point the same way: the inbound MCP token must never become an outbound credential for an http: step. That is the strongest available argument for #549's credential-injecting proxy.
  • Bound every new parser, before authentication. Protected-resource and AS metadata documents, CIMD fetches (which are SSRF by design — an attacker-chosen URL the server fetches), device-code polling, and JWKS. The plugin/transport.go lesson applies verbatim: the cap belongs on the RoundTripper, below whichever library performs the fetch, because a non-200 body is the path a hostile peer takes.
  • Test that A cannot reach B. A token minted for another resource must be refused (audience), a token from an untrusted issuer must be refused, an agent's token whose on_behalf_of names a human in another namespace must be refused, and a session created by one principal must refuse requests carrying another's token.
  • No credential material in history, logs, or an agent's context. A tool result is an untrusted-consumer surface — mcp.go already reasons this way for sensitive: — and a token, a device code, or a PKCE verifier reaching a transcript is the same leak, with the containment shapes invariant 7 requires.
  • Nothing changes for stdio. The spec says stdio SHOULD NOT do any of this and SHOULD take credentials from the environment, which is what flow mcp does today. Whatever lands must leave the stdio path exactly as it is, including its honest identity caveat.

Questions

  1. Resource server only, at least for now? Recommended yes — publish RFC 9728, verify external tokens with the audience check that already ships, and build no authorization endpoints. It is the smallest thing that makes HTTP MCP real, and the AS column is a permanent obligation against a spec that has changed its registration story twice in a year. (Same answer as #549 Q2, now with the four costs above attached.)
  2. Is HTTP MCP a flag on flow mcp or its own command? Recommended its own explicit surface, because runMCP's process-wide posture flags are correct for one trusted caller and silently wrong for many, and a flag makes that reinterpretation invisible.
  3. Are flowstate_run_local and flowstate_test served over HTTP? Recommended flowstate_test yes (a stubbed run reaches nothing by construction, already proven by test), flowstate_run_local no unless separately opted into, since it is remote code execution in the server process.
  4. Does the device authorization grant land as client-only first? Recommended yes — flow login --device against somebody else's AS needs no AS work from us and is the thing that makes a headless box usable on day one. Serving the device endpoint ourselves is the one AS capability worth revisiting later, because it is what closes the no-IdP gap.
  5. Where does agent delegation come from: IdP claims, or RFC 8693 actor_token? Recommended: read it from claims first (pure mapping, no new protocol), and add actor_token to the existing exchanger as the second step, since the exchanger already exists and only lacks that parameter.
  6. Does on_behalf_of gate the approval path immediately? Recommended yes — the one thing HTTP MCP buys that stdio cannot is an attested approver, and shipping the transport without making distinct_from_starter mean something on it would waste the entire point.
  7. Do we implement CIMD, DCR, both, or neither on the client side? Recommended neither for now, since a resource server needs no client registration at all; revisit only alongside Q4's AS work, and prefer CIMD when we do, because DCR is deprecated.

Specs relied on: MCP Authorization 2026-07-28 and its Client Registration section, OAuth 2.1 draft-13, RFC 9728, RFC 8414, RFC 7591, RFC 8707, RFC 9207, RFC 6750, RFC 8628, RFC 8693, draft-ietf-oauth-client-id-metadata-document-00.

Related: #549 (TLS, listeners, DPoP — hard prerequisite), #557 (PrincipalKind, on_behalf_of — the identity this produces), #548 (one policy vocabulary, and the per-principal posture HTTP MCP requires).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with cmd/flow/mcp.go:262 and cmd/flow/routing.go:75-105, then inspect auth/verifier.go and the SDK's auth and StreamableHTTPHandler APIs. Define the resource-server-first boundary, including protected-resource metadata, bearer challenges, scope handling, and verifier adaptation; done means the HTTP surface has a concrete, tested OAuth resource-server plan without adding an authorization server.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api, authentication, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.