Skip to content

feat(oauth): assistants sign in with OAuth, and the owner approves at home - #19

Closed
frahlg wants to merge 8 commits into
feat/agent-keysfrom
feat/mcp-oauth
Closed

frahlg wants to merge 8 commits into
feat/agent-keysfrom
feat/mcp-oauth

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Second of four stacked PRs. It is based on #18 (keys); review that first.

What this adds

The authorization server for MCP clients (ChatGPT, Claude, Grok), after the MCP authorization spec, revision 2026-07-28. The issuer is https://cloud.ftw.energy.

  • OAuth 2.1 with PKCE S256 only. plain and a missing challenge are refused. Every authorization response, errors too, carries iss (RFC 9207). The RFC 8414 metadata says so, and says client_id_metadata_document_supported.

  • Protected resource metadata (RFC 9728) for https://cloud.ftw.energy/mcp, at the path-inserted and the root well-known URIs.

  • resource (RFC 8707). Only that resource is accepted, at authorize and at the token endpoint. Each token records its audience, and Authenticate checks it.

  • Client ID metadata documents, fetched behind a fence:

    • https on 443, to a DNS name;
    • every resolved address public, checked again on the address the socket connects to;
    • no proxy, no redirects, no cookies;
    • 16 KiB, 5 s, eight at once, and per host four, then one every ten seconds;
    • cached by Cache-Control for between a minute and a day, even with no-store;
    • failures in a small cache of their own, for a minute, so they never push a good document out.

    A document with client_secret in it is refused. A document may list grants this server does not offer, as Claude's lists the JWT bearer grant; it must use the code grant. ::/96 (IPv4-compatible IPv6) joins the blocked ranges.

  • Dynamic registration (RFC 7591), kept for ChatGPT and older clients. A client may be public, or use client_secret_post or client_secret_basic, with the secret kept as a hash. Redirect URIs must be https, or loopback http with any port; custom schemes are refused.

  • Short tokens. Access tokens last 10 minutes. Refresh tokens rotate within a family. A retry within 5 s works once more, since its answer may have been lost; a later reuse revokes the family. A reused code revokes what it got, even after its consent was swept: spent codes' hashes outlive it. Tokens, codes and request ids are kept as SHA-256 only.

  • redirect_uri at the token endpoint is required when authorize named one, and must be the same.

  • Limits per address and for everyone. Authorize, registration and code lookups each have a bucket per address (an IPv6 address by its /64) beside the global one. Authorize takes both before it fetches a document or makes a key. Registered clients that never got an agent go after a day.

  • No token passthrough. The token stays here. The box sees the agent's own Noise session.

  • 401 and 403 with WWW-Authenticate (resource_metadata, scope, and insufficient_scope for step-up).

Built to the agent contract

srcfl/ftw#1642 (docs/agents.md), merged; #18 pins it at bf5bf50:

  • Registry names. Scopes and roles are the registry's constants.
  • Approval comes only from the roster. A consent is approved only by a roster row with role agent, an agent grant whose principal.clientId is the client the offer named, and an end still ahead. An agent row has tun: false: only the keys part opens its session. Support and release rows are not this door's.
  • Only each home's newest roster is applied. A newer roster replaces an older one that has not been applied, so nothing drops and nothing old replays. When the keys part cannot keep an approved key, a retry runs the approval pass alone, on the newest roster: it cannot widen a grant the owner narrowed since, approve a consent the owner removed, or revoke an agent approved since.
  • A key belongs to one home. Agents are unique by their full key, never taken over by another key with the same 48-bit id. A roster that lists, as its agent, a key approved for another home revokes it there and forgets the key.
  • A purge reaches here. uplink.OnPurge calls DropHome: the home's agents, their tokens and keys, and its consents go, and a roster from before the purge is not applied after it.
  • Revoking always deletes the key. A key to forget is queued in oauth.db with the revocation and stays queued, across restarts, until the keys part has forgotten it. Keep and Forget get 5 s each.
  • The grant is the token's and the roster's. Scopes, ops, caps and end are refreshed with every roster. A token allows the scopes it carries that the box still grants, so an owner who narrows a grant narrows the token at once, and a narrowed refresh stays narrow. Wider rights need a new consent. The service stops admitting an agent at expiresAtMs by its own clock.
  • The token's shape is checked first. A bearer token must have this server's shape before the store is asked. The token, revocation and registration endpoints give a client 10 s to send its body.
  • The grant offer is protocol.SignedOffer {o, kid, sig}. o is GrantOffer: the agent's key, the client, clientHost (the metadata document's host, or a registered client's redirect host), verified, the scopes, the ops those scopes open, proposed caps, 30 days, the page's code and exp (at most ten minutes). It has no home, because the owner picks the home.
    • The keys part signs it; kid is keys.OfferKid.
    • The keys part signs it only for its own pending key (feat(keys): ftwkeysd, the one part that can open an agent's session #18).
    • It rides in the link's fragment with the request id (https://app.ftw.energy/a#<request>.<offer>), so neither reaches a server's log, and in the QR.
    • /oauth/requests?code= returns it once, rate limited per address and for everyone, to any origin, since the box's page fetches it from the owner's browser. The code is eight characters, drawn evenly (about 39 bits).
    • /.well-known/ftw-offer-key serves the kid and the key to pin.
  • verified means a client FTW Cloud knows, by the exact URL of its metadata document. The list holds Claude's (https://claude.ai/oauth/mcp-oauth-client-metadata), read at that URL on 15 October 2026. ChatGPT's and Grok's documents were not found, so they show as unverified, by their host, until someone adds their exact URLs. Support's client id is refused here: support offers never come through this page.
  • No redirect for a stranger. A client FTW Cloud does not know gets an error page with a link back to its host, never a 302, before the owner acts (RFC 9700, 4.11.2). A verified client's errors go back at once. On the page, an expiry offers the same link; approval and denial go back at once, since the owner acted.
  • Tokens route without naming their home. A token is ftwat_<route>.<secret>, where the route is the home sealed with a fresh nonce under the route key (oauth-route.key, which every routing instance shares). Any instance opens it (Server.TokenHome) to find the home's shard, as in feat: a seam for shards, a quicker hot path, metrics, deploys without a storm, and a load harness #17, but a client sees no id that is the same in two of its tokens.
  1. The authorize page. /oauth/authorize asks ftwkeysd for the agent's key pair; only the public half leaves it. The page shows:
    • the client's name, checked for a metadata document and marked unchecked for a registration;
    • the host the browser goes back to, with a warning for loopback;
    • the rights, in plain words;
    • a short code, a QR, and the universal link;
    • Strict-Transport-Security, as the error pages have.
  2. What the app and the box read. The app reads /oauth/requests/<id>; the box's page reads /oauth/requests?code=, rate limited. Both get the client, the scopes and the agent's public key.
  3. The page waits on a server-sent event, not a poll. Only the browser that started the request keeps a stream open, two at most, under a cap for the process; anyone else gets the state once.
  4. The signed roster decides. When the box's signed roster lists that key as a device that may open a session, the consent is approved and the key is kept. Only the browser that started the request (by an __Host- cookie) gets a fresh code.
  5. Deny and expiry. A denied or expired request returns access_denied, and the key is forgotten.

A token maps to (shard, home, agent id). The subject has a kind, so a later staff principal fits without changing an agent's token; staff sign-in is not built. The shard is a name, as in the seam from #17. A roster that drops an agent, or the end the box set for it, deletes the agent's tokens and its key.

ftwcloudd mounts it with -keys-socket, and keeps its state in oauth.db beside the registry. server.go and main.go gain only routes and wiring.

What the tests prove

internal/oauth, with a real keys part and an https metadata server:

  • The full flow with a metadata document and PKCE, then refresh with rotation. A reused refresh token kills the family; a reused code kills its tokens.
  • The full flow with dynamic registration, public and confidential. Bad registrations are refused.
  • PKCE is required. A missing or plain challenge gets a link back with invalid_request and iss, and a wrong verifier is refused.
  • No unchecked redirect. An unknown client or a mismatched redirect URI gets an error page, never a redirect. For an unverified client, another resource and an unknown scope get a page with a link back (invalid_target, invalid_scope); for a verified one, a 302.
  • Consent completes only when the roster lists the very key. Not under another id, not for another client, not past its end, not as support, and not as a phone. A stranger's stream gets the state once and closes; after the approval it hears "approved" but gets no code. A third stream of the browser's is refused.
  • The offer opens with protocol.OpenOffer and the key pinned by its kid. Its fields are right (no home; exp within ten minutes). It does not open with another key or at its end. It is in the page's link fragment, and the code finds it once.
  • The grant follows the roster. Narrowing reaches the token at once; widening does not reach an existing token; a renewal extends the agent; a narrowed refresh stays narrow; and the service's own clock stops the agent at its end. A token routes to its home without naming it, two tokens never share a route, a route under another key does not open, a made-up secret with a good route is refused, and a token of the wrong shape never reaches the store.
  • Deny and expiry both redirect with access_denied. A roster after that does not approve.
  • Revoke at the box. The next roster leaves the agent out: the access and refresh tokens fail, the keys part forgets the key, and the MCP side hears it.
  • An expired agent fails, and is swept.
  • Wrong audience is refused, as are a refresh token used as an access token and a token in the query string.
  • The challenge headers are right, and the box's page finds a request by its code as an owner types it.
  • The page is safe. It has a strict CSP and HSTS and escapes a client's name. Authorize is rate limited before it fetches a document, and authorize, registration and lookups each per address, one /64 counting as one caller.
  • Rosters (review_test.go):
    • a retry runs only the approval, on the newest roster;
    • 3,000 homes' rosters at once drop nothing;
    • a purge drops the agents, their tokens, keys and consents, and a roster from before it is not applied after it;
    • a key listed at a second home is revoked and forgotten;
    • a key the keys part could not forget stays queued until it has, and a denied consent's key goes too.
  • Codes and tokens:
    • codes are eight characters, drawn evenly over 20,000 draws;
    • redirect_uri is required at the token endpoint when authorize named it;
    • a code used again after the sweep still revokes;
    • a refresh retried within the grace works once, and a third use revokes the family.
  • Clients:
    • only the exact verified URLs count;
    • support's client id is refused;
    • unused registrations go after a day.
  • internal/store: an agent's, support's or the release check's row is admitted neither as a phone nor by a token. internal/uplink: a purge calls OnPurge each time the box asks.

CIMD SSRF:

  • URL rules: scheme, port, IP literals, dot segments, userinfo, fragment.
  • Inside addresses are refused before a socket opens: loopback, the private ranges, 169.254.169.254, CGNAT, IPv6 ULA and link-local, IPv4-mapped, NAT64, and a name that mixes a public and a private answer. The connect-time check refuses too.
  • A misbehaving server is refused: a redirect to the metadata service, an oversized body, a slow server, an error, a mismatched client_id, a secret in the document, a custom-scheme redirect, private_key_jwt, no name, HTML.
  • Caching follows the rules, and the fetcher reads no proxy. Fetches per host are capped, failures never push a good document out, and Claude's real document, JWT bearer grant and all, is accepted.

internal/server: no assistant route returns a home's readings.

Not in this PR

  • The app does not open the universal link yet. app.ftw.energy's AASA covers /p only, so /a needs a paired change in srcfl/ftw-webapp (public/.well-known/apple-app-site-association) and a handler in the iOS app. The link's fragment is <request>.<offer>. The app then sends agent.add to its box. That is the core-agent-role contract.
  • Consents across shards. With more than one shard, a consent starts on the default host while the approving roster arrives on the home's shard. Both must see one consent store, or the default host must forward. feat: a seam for shards, a quicker hot path, metrics, deploys without a storm, and a load harness #17's follow-up decides which.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:29:50.633839Z a9485fb PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9485fb556

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/oauth/oauth.go Outdated
Comment on lines +252 to +254
default:
// The next roster carries the same devices.
s.log.Warn("roster queue full; an agent change waits for the next roster")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not discard the only copy of a roster change

When more than 1,024 roster events are awaiting processing, this branch silently drops the newest event and assumes another roster will follow. However, internal/uplink/uplink.go invokes OnRoster only when SetRoster accepts a newly received roster, so an unchanged roster is not periodically replayed. During a reconnect or update burst, dropping the event that removes an agent can therefore leave that agent's tokens and retained key valid indefinitely, until the box happens to publish another roster revision.

Useful? React with 👍 / 👎.

Comment thread internal/oauth/token.go Outdated
tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is not valid")
return
}
fresh, err := s.st.rotate(tok)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate refresh scopes before rotating the token

For a refresh request containing a scope not present in the original grant, the token is marked used here before the scope validation at lines 106–115 rejects the request. The client receives no replacement refresh token, and retrying with a valid scope is then treated as token reuse and revokes the whole family, forcing the owner through authorization again. Validate the request first and rotate only as part of issuing the replacement.

Useful? React with 👍 / 👎.

Comment thread internal/oauth/clients.go
Comment on lines +174 to +177
if s.st.countClients() >= maxClients {
registerError(w, http.StatusServiceUnavailable, "invalid_client_metadata", "registration is full")
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent registrations from permanently exhausting capacity

The unauthenticated registration endpoint rejects every new client once this persistent count reaches 50,000, but the added store has no expiry or deletion path for client rows. A caller can consume the capacity at the allowed sustained rate in roughly three days, after which dynamic registration returns 503 permanently across restarts and requires manual database repair. Prune stale or unused registrations rather than imposing a lifetime-only count.

Useful? React with 👍 / 👎.

@frahlg
frahlg force-pushed the feat/mcp-oauth branch 2 times, most recently from fc61829 to cd878ad Compare October 15, 2026 18:13
frahlg added a commit that referenced this pull request Oct 15, 2026
Review of #19 (N3, N4):

- Authenticate grants the intersection of the token's own scopes and the
  agent's in the newest roster, so a narrowed refresh stays narrow and an
  owner's widening needs a new consent.
- A bearer token must have this server's shape (prefix, home, dot, 43
  characters of base64url) before the store is asked.
- The token, revocation and registration endpoints give a client 10 s to
  send its body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 8 commits October 15, 2026 22:52
… home

The authorization server for MCP clients, after the MCP authorization spec,
revision 2026-07-28, with the issuer at https://cloud.ftw.energy:

- OAuth 2.1, authorization code with PKCE S256 only; every authorization
  response, errors too, carries iss (RFC 9207), and the metadata says so
  (RFC 8414).
- Protected resource metadata for https://cloud.ftw.energy/mcp (RFC 9728),
  at the path-inserted and the root well-known URIs.
- resource (RFC 8707): only the MCP resource, at authorize and token; each
  token records its audience and /mcp checks it.
- Client ID metadata documents, fetched behind a fence: https on 443 to a
  DNS name, every resolved address public (checked again as the socket
  connects), no proxy, no redirects, 16 KiB, 5 s, eight at once, cached by
  Cache-Control within a minute and a day, failures for a minute. No
  shared secret may sit in a public document.
- Dynamic registration kept for ChatGPT and older clients: public, or
  client_secret_post/basic with the secret stored as a hash.
- Access tokens for 10 minutes; refresh tokens rotate within a family, and
  a reused one revokes the family; a reused code revokes what it got.
  Tokens, codes and request ids are kept as SHA-256 only.
- 401 and 403 with WWW-Authenticate: resource_metadata, scope, and
  insufficient_scope for step-up.

Consent has no account and no password. /oauth/authorize asks ftwkeysd for
an agent key, then shows the client's name (checked for a metadata
document, marked unchecked for a registration), the host the browser goes
back to (with a warning for loopback), the rights in plain words, a short
code, a QR and https://app.ftw.energy/a/<request>. The app reads
/oauth/requests/<id>, the box's page /oauth/requests?code=. The page waits
on a server-sent event. When the box's signed roster lists the agent's key
as a device that may open a session, the request is approved, the key is
kept, and only the browser that started it gets a fresh code. Denied and
expired requests return access_denied and the key is forgotten.

A token maps to (shard, home, agent id). The subject has a kind, so a
staff principal fits later without changing what an agent's token is. A
roster that drops an agent, or the end the box set for it, deletes its
tokens and its key and tells the MCP side.

ftwcloudd mounts it with -keys-socket; state in oauth.db beside the
registry. No route reads a home (TestOAuthRoutesReadNoHome).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cloud-scale's shard seam (#17) names shards by string, so
the subject carries the shard's name, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box sends its roster when it changes, so an approval whose key the keys
part could not keep at once is tried again every 5 s for two minutes,
rather than waiting for a roster that may not come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This
follows it:

- Scopes are the registry's constants. An agent row is approved only as
  the roster lists it: role agent, a tunnel, its `agent` grant for the
  client the offer named, and an end still ahead. Support and release rows
  are not this door's.
- The grant is the roster's: scopes, ops, caps and end, refreshed with
  every roster, so an owner who narrows, widens or renews a grant changes
  the token at once. The service stops admitting an agent at its end by
  its own clock.
- The consent page builds the grant offer (key, role, client, clientId,
  scopes, the ops those scopes open, proposed caps, 30 days, the code; no
  home, since the owner picks it), has the keys part sign it, and puts it
  in the link's fragment and the QR. /oauth/requests?code= returns it
  once. /.well-known/ftw-offer-key serves the key the app pins.
- verified means a client FTW Cloud knows, by its document's host; any
  other document client is shown by its host.
- Tokens name their home, ftwat_<home>.<secret>, for shard routing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s without tun

srcfl/ftw#1642 at b7d1cc80 (pinned until it merges):

- The offer is protocol.GrantOffer with clientHost (the metadata
  document's host, or a registered client's redirect host), verified and
  exp (the request's end, at most ten minutes), sent as
  protocol.SignedOffer {o, kid, sig}; protocol.OpenOffer opens it with the
  key pinned by keys.OfferKid. Only loadpoint.hold is a hold op now.
- Agent rows carry tun false: approval needs the agent grant, the role,
  the client and an end ahead, not a tunnel.
- The code lookup answers any origin, as the box's page fetches it from
  the owner's browser; the offer key's endpoint names its kid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rKid lands

srcfl/ftw#1642 now derives an offer key's kid the same way keys.OfferKid
does, and Core pins offer keys as a list of kid:pub. A test holds the two
derivations equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #19 (N3, N4):

- Authenticate grants the intersection of the token's own scopes and the
  agent's in the newest roster, so a narrowed refresh stays narrow and an
  owner's widening needs a new consent.
- A bearer token must have this server's shape (prefix, home, dot, 43
  characters of base64url) before the store is asked.
- The token, revocation and registration endpoints give a client 10 s to
  send its body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…direct, exact verified clients

1. Only the newest roster per home waits to be applied, so nothing drops
   and nothing old replays. A retry after the keys part was away runs the
   approval pass alone, on the newest roster.
2. A purge reaches the authorization server (uplink.OnPurge ->
   DropHome): the home's agents, their tokens and keys, and its consents
   go. A roster from before the purge is not applied after it.
3. A client FTW Cloud does not know gets an error page with a link back
   to its host, never a 302 before the owner acts (RFC 9700, 4.11.2).
4. Verified is an exact list of client_id URLs. Claude's is on it, read at
   its URL on 15 Oct 2026; ChatGPT's and Grok's documents were not found.
5. A key listed at a second home is revoked everywhere and forgotten.
   Codes are eight characters, drawn evenly. Lookups have a bucket per
   address. The request id moves into the link's fragment.
6. Per-address buckets beside the global ones, taken before the client's
   document is fetched. CIMD: a document is kept at least a minute,
   failures have a small cache of their own, and fetches are capped per
   host. Only the cookie holder keeps an event stream, two at most, under
   a global cap. Registrations with no agent go after a day.
7. The store admits an agent's, support's or the release check's row
   neither as a phone nor by a token.

And: a key to forget stays queued, across restarts, until the keys part
has forgotten it; Keep and Forget get 5 s. redirect_uri is required at the
token endpoint when authorize named one. A code used again after the sweep
still revokes. A refresh retried within 5 s works once more. Tokens carry
their home sealed with a fresh nonce, so no client sees an id that links
its tokens. Agents are unique by their full key. Support's client id is
not a metadata client. HSTS on the pages. ::/96 is blocked.

Claude's real document lists the JWT bearer grant, which the old check
refused: a client may list grants this server does not offer, as long as
it uses the code grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
#19, #20, #21)

The MCP stack as one change: ftwkeysd, the one part that can open an agent's session (#18); OAuth for assistants with the owner's approval at home (#19); reads at /mcp (#20); control within the owner's grant, and requests that wait for the owner (#21). Reviewed in two passes; merged with main after #17 (scale), go/protocol pinned to srcfl/ftw master f024251.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Included in #21 (squash 2e7afbd), which carries the whole MCP stack.

@frahlg frahlg closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant