From 5018c96390794df5ee14312deeccefb8acf35c0e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:14:43 +0200 Subject: [PATCH 1/8] feat(oauth): assistants sign in with OAuth, and the owner approves at 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/. The app reads /oauth/requests/, 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 --- README.md | 22 + cmd/ftwcloudd/main.go | 15 + deploy/compose.yml | 1 + go.mod | 1 + go.sum | 2 + internal/oauth/authorize.go | 400 ++++++++++++ internal/oauth/cimd.go | 375 ++++++++++++ internal/oauth/cimd_test.go | 215 +++++++ internal/oauth/clients.go | 249 ++++++++ internal/oauth/oauth.go | 453 ++++++++++++++ internal/oauth/oauth_test.go | 878 +++++++++++++++++++++++++++ internal/oauth/page.go | 154 +++++ internal/oauth/store.go | 512 ++++++++++++++++ internal/oauth/token.go | 283 +++++++++ internal/server/oauth_routes_test.go | 53 ++ internal/server/server.go | 11 +- 16 files changed, 3622 insertions(+), 2 deletions(-) create mode 100644 internal/oauth/authorize.go create mode 100644 internal/oauth/cimd.go create mode 100644 internal/oauth/cimd_test.go create mode 100644 internal/oauth/clients.go create mode 100644 internal/oauth/oauth.go create mode 100644 internal/oauth/oauth_test.go create mode 100644 internal/oauth/page.go create mode 100644 internal/oauth/store.go create mode 100644 internal/oauth/token.go create mode 100644 internal/server/oauth_routes_test.go diff --git a/README.md b/README.md index f096d50..e16bb69 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,28 @@ to it yet. sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn it on. +- **Assistants sign in with OAuth, and the owner approves at home.** The + authorization server follows the MCP authorization spec, revision + 2026-07-28: OAuth 2.1 with PKCE S256 only, metadata at + `/.well-known/oauth-authorization-server` with `iss` in every + authorization response (RFC 9207), protected resource metadata for + `https://cloud.ftw.energy/mcp` (RFC 9728), and tokens bound to that + resource (RFC 8707). A client names itself by a client ID metadata + document, fetched behind a fence (https on 443 to a public address checked + as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or + registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older + clients. There is no account and no password: `/oauth/authorize` asks + `ftwkeysd` for a new agent key, then shows the client's name, where the + browser goes back, the rights asked for, a short code, a QR and the link + `https://app.ftw.energy/a/`. The owner approves in the app or on + the box's page; the box lists the agent's key in the roster it signs, and + when that roster arrives the page, waiting on a server-sent event, returns + to the client with a code. Only the browser that started the request gets + it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one + revokes its family. The service keeps hashes, never tokens. A token is + bound to one agent of one home; a roster without the agent, or the end the + box set for it, ends its tokens and its key. + Not built yet: | Step | What | Where | diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 2bc334f..9dde1cd 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -33,6 +33,8 @@ import ( "github.com/srcfl/ftw-cloud/internal/apns" "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw-cloud/internal/oauth" "github.com/srcfl/ftw-cloud/internal/push" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/server" @@ -73,6 +75,7 @@ func serve(args []string) error { apnsWatch := fs.String("apns-watch-topic", "energy.ftw.app.watchkitapp", "the watch app's bundle id") devPush := fs.String("dev-push-listen", "", "loopback address for registering a token by hand (development builds only)") devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)") + keysSocket := fs.String("keys-socket", "/run/ftw-cloud-keys/keys.sock", "ftwkeysd's socket; assistants can connect only while it answers") _ = fs.Parse(args) harden() @@ -110,6 +113,16 @@ func serve(args []string) error { tokens := token.New(st, reg, log) srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log} + // Assistants: the authorization server, with agent keys in ftwkeysd. + agentKeys := keys.Dial(*keysSocket) + defer agentKeys.Close() + auth, err := oauth.New(filepath.Join(*stateDir, "oauth.db"), agentKeys, log) + if err != nil { + return err + } + defer auth.Close() + srv.OAuth = auth + // Alerts: the box's signed events to its phones through Apple, and // box.unreachable when a box is away. Widgets: a push with no data when // prices or a car change. Without a key the service keeps registrations @@ -133,6 +146,7 @@ func serve(args []string) error { up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) tokens.Roster(home, r) + auth.Roster(home, r) } up.OnGone = pushSvc.BoxGone @@ -188,6 +202,7 @@ func serve(args []string) error { defer stop() go srv.Upkeep(ctx, time.Minute) go pushSvc.Run(ctx) + go auth.Run(ctx) if err := pushSvc.Resume(); err != nil { log.Warn("could not restart the dead man's switch", "err", err) } diff --git a/deploy/compose.yml b/deploy/compose.yml index 96c90e2..352a6b2 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -21,6 +21,7 @@ services: - -service-key=/run/secrets/ftw-cloud-service.key - -acme-email=${FTW_CLOUD_ACME_EMAIL:-} - -noise-key=/run/secrets/noise/noise.key + - -keys-socket=/run/ftw-cloud-keys/keys.sock environment: # /source publishes it, so anyone can compare it with what CI built. FTW_CLOUD_IMAGE: ${FTW_CLOUD_IMAGE} diff --git a/go.mod b/go.mod index 580f8ca..38c7f22 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,5 @@ require ( modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index a5ebb88..73618ac 100644 --- a/go.sum +++ b/go.sum @@ -92,3 +92,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go new file mode 100644 index 0000000..4d906f7 --- /dev/null +++ b/internal/oauth/authorize.go @@ -0,0 +1,400 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// codeAlphabet makes the short code: no 0/O, 1/I or L to misread. +const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +// codeLen gives about 29 bits. A code finds a request for the box's page and +// proves nothing: the owner still approves on their own box. +const codeLen = 6 + +var challengeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + +// authorize request parameters, after the checks that allow a redirect. +type authRequest struct { + client client + redirectURI string + state string + challenge string + scope []string +} + +func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + for k, v := range q { + if len(v) > 1 { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This link is not valid", + Text: fmt.Sprintf("The parameter %s appears more than once.", k)}) + return + } + } + ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) + defer cancel() + + // Until client and redirect URI check out, nothing goes back to the + // client: a bad request could otherwise send the browser anywhere. + c, err := s.resolveClient(ctx, q.Get("client_id")) + if err != nil { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This assistant is not known here", + Text: "The link names a client that FTW Cloud cannot check. Ask the assistant to connect again."}) + return + } + redirect := q.Get("redirect_uri") + if redirect == "" && len(c.RedirectURIs) == 1 { + redirect = c.RedirectURIs[0] + } + if redirect == "" || checkRedirect(redirect) != nil || !redirectMatches(c.RedirectURIs, redirect) { + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This link is not valid", + Text: "The assistant asked to return somewhere it did not register."}) + return + } + req := authRequest{client: c, redirectURI: redirect, state: q.Get("state")} + fail := func(code, desc string) { + http.Redirect(w, r, authorizationError(redirect, code, desc, req.state), http.StatusFound) + } + if len(req.state) > 1024 { + fail("invalid_request", "state is too long") + return + } + if q.Get("response_type") != "code" { + fail("unsupported_response_type", "only response_type=code") + return + } + if q.Get("code_challenge_method") != "S256" || !challengeRE.MatchString(q.Get("code_challenge")) { + fail("invalid_request", "PKCE with code_challenge_method=S256 is required") + return + } + req.challenge = q.Get("code_challenge") + if res := q.Get("resource"); res != "" && !sameResource(res) { + fail("invalid_target", "this server issues tokens only for "+Resource) + return + } + for _, sc := range splitScope(q.Get("scope")) { + switch { + case sc == "offline_access": + // Refresh tokens come anyway. + case validScope(sc): + if !slices.Contains(req.scope, sc) { + req.scope = append(req.scope, sc) + } + default: + fail("invalid_scope", "unknown scope "+sc) + return + } + } + if len(req.scope) == 0 { + req.scope = slices.Clone(ReadScopes) + } + if !s.authorizeLimit.allow(s.now()) { + fail("temporarily_unavailable", "too many requests; try again shortly") + return + } + + // The agent's key pair comes first; only its public half leaves the + // keys part, to be shown to the owner and listed by the box. + kid, pub, err := s.keys.New(ctx) + if err != nil { + s.log.Warn("the keys part could not make an agent key", "err", err) + fail("temporarily_unavailable", "assistants cannot connect right now") + return + } + now := s.now() + rid, browser := randomToken(16), randomToken(32) + con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: c.Host, Verified: c.Verified, RedirectURI: redirect, + State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, + Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} + for try := 0; ; try++ { + con.Code = shortCode() + err = s.st.putConsent(rid, browser, con) + if err == nil || try == 3 { + break + } + } + if err != nil { + _ = s.keys.Forget(ctx, kid) + fail("server_error", "could not start the request") + return + } + http.SetCookie(w, &http.Cookie{Name: cookieName(rid), Value: browser, Path: "/", MaxAge: int(RequestTTL.Seconds()), + Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + s.consentPage(w, rid, con) +} + +// sameResource compares a resource parameter with Resource, taking +// uppercase in the scheme and host, and a trailing slash, as the same. +func sameResource(res string) bool { + u, err := url.Parse(res) + if err != nil || u.Fragment != "" || u.RawQuery != "" { + return false + } + u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host) + u.Path = strings.TrimSuffix(u.Path, "/") + return u.String() == Resource +} + +func validScope(sc string) bool { return slices.Contains(AllScopes, sc) } + +func cookieName(rid string) string { return "__Host-ftw-consent-" + rid[:8] } + +func shortCode() string { + b := make([]byte, codeLen) + if _, err := rand.Read(b); err != nil { + panic(err) + } + for i := range b { + b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] + } + return string(b) +} + +// normalizeCode reads a code as an owner may type it. +func normalizeCode(s string) string { + s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(s)) + if len(s) != codeLen || strings.Trim(s, codeAlphabet) != "" { + return "" + } + return s +} + +// authorizationResponse adds the parameters to the client's redirect URI, +// with iss, as RFC 9207 asks of every response. +func authorizationResponse(redirect string, params url.Values) string { + u, _ := url.Parse(redirect) + q := u.Query() + for k, v := range params { + q[k] = v + } + q.Set("iss", Issuer) + u.RawQuery = q.Encode() + return u.String() +} + +func authorizationError(redirect, code, desc, state string) string { + p := url.Values{"error": {code}, "error_description": {desc}} + if state != "" { + p.Set("state", state) + } + return authorizationResponse(redirect, p) +} + +// -------------------------------------------------------------------------- +// The request, for the app and the box's page +// -------------------------------------------------------------------------- + +type requestView struct { + Request string `json:"request,omitempty"` + Status string `json:"status"` + Code string `json:"code"` + Client clientView `json:"client"` + Redirect redirectView `json:"redirect"` + Scopes []string `json:"scopes"` + Agent agentKeyView `json:"agent"` + Expires int64 `json:"expiresAtMs"` +} + +type clientView struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + Verified bool `json:"verified"` +} + +type redirectView struct { + Host string `json:"host"` + // Local is a client on the owner's own computer. + Local bool `json:"local"` +} + +type agentKeyView struct { + // Key is the agent's X25519 public key, base64url: what the box lists. + Key string `json:"key"` + ID string `json:"id"` +} + +func (s *Server) view(c consent) requestView { + u, _ := url.Parse(c.RedirectURI) + status := c.Status + if status == statusPending && !s.now().Before(c.Expires) { + status = "expired" + } + return requestView{Status: status, Code: c.Code, + Client: clientView{ID: c.ClientID, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified}, + Redirect: redirectView{Host: u.Hostname(), Local: u.Scheme == "http" && isLoopback(u.Hostname())}, + Scopes: splitScope(c.Scope), + Agent: agentKeyView{Key: base64.RawURLEncoding.EncodeToString(c.AgentKey), ID: cloudwire.DeviceID(c.AgentKey)}, + Expires: c.Expires.UnixMilli()} +} + +// serveRequest gives the app what it shows the owner before they approve. +func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { + c, ok, err := s.st.consentByRID(r.PathValue("rid")) + if err != nil || !ok { + http.NotFound(w, r) + return + } + allowApp(w, r) + v := s.view(c) + v.Request = r.PathValue("rid") + writeJSON(w, http.StatusOK, v, false) +} + +// serveRequestByCode finds a request by the code an owner typed on the box's +// page. Lookups are rate limited for everyone together: a code lasts ten +// minutes and has about 29 bits, and finding one only shows a client's name. +func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { + if !s.lookupLimit.allow(s.now()) { + w.Header().Set("Retry-After", "1") + http.Error(w, "too many lookups", http.StatusTooManyRequests) + return + } + code := normalizeCode(r.URL.Query().Get("code")) + if code == "" { + http.NotFound(w, r) + return + } + c, ok, err := s.st.consentByCode(code) + if err != nil || !ok || c.Status != statusPending || !s.now().Before(c.Expires) { + http.NotFound(w, r) + return + } + allowApp(w, r) + writeJSON(w, http.StatusOK, s.view(c), false) +} + +func allowApp(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == AppOrigin { + w.Header().Set("Access-Control-Allow-Origin", AppOrigin) + w.Header().Set("Vary", "Origin") + } +} + +// serveDeny ends a pending request without approval: the owner said no, or +// the person at the computer cancelled. +func (s *Server) serveDeny(w http.ResponseWriter, r *http.Request) { + rid := r.PathValue("rid") + c, ok, err := s.st.deny(rid) + if err != nil { + http.Error(w, "could not deny", http.StatusInternalServerError) + return + } + if ok { + _ = s.keys.Forget(r.Context(), c.Kid) + s.hub.notify(string(hash(rid))) + } + allowApp(w, r) + w.WriteHeader(http.StatusNoContent) +} + +// -------------------------------------------------------------------------- +// The event stream the page waits on +// -------------------------------------------------------------------------- + +// keepAlive is how often a quiet stream sends a comment, and looks at the +// request again in case another process changed it. +const keepAlive = 20 * time.Second + +func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { + rid := r.PathValue("rid") + if _, ok, err := s.st.consentByRID(rid); err != nil || !ok { + http.NotFound(w, r) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + // Only the browser that started the request gets the code. + var browser string + if ck, err := r.Cookie(cookieName(rid)); err == nil && s.st.browserMatches(rid, ck.Value) { + browser = ck.Value + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + wake, done := s.hub.subscribe(string(hash(rid))) + defer done() + tick := time.NewTicker(keepAlive) + defer tick.Stop() + // A pending request's end does not move; once it changes state the + // stream is over. + expiry := time.NewTimer(RequestTTL) + defer expiry.Stop() + for { + c, ok, err := s.st.consentByRID(rid) + if err != nil || !ok { + return + } + if end := s.event(w, rid, browser, c); end { + flusher.Flush() + return + } + flusher.Flush() + expiry.Reset(max(c.Expires.Sub(s.now()), 0)) + select { + case <-r.Context().Done(): + return + case <-wake: + case <-expiry.C: + case <-tick.C: + _, _ = fmt.Fprint(w, ":\n\n") + } + } +} + +// event writes the request's state. end is true when the stream is done. +func (s *Server) event(w http.ResponseWriter, rid, browser string, c consent) (end bool) { + send := func(name, redirect string) { + data := "{}" + if redirect != "" && browser != "" { + data = fmt.Sprintf(`{"redirect":%q}`, redirect) + } + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, data) + } + expired := !s.now().Before(c.Expires) + switch { + case c.Status == statusApproved: + if browser == "" { + send("approved", "") + return true + } + // A new code for each delivery: a browser that reconnects gets one + // that works, and the earlier one stops working. + code := randomToken(32) + if err := s.st.issueCode(rid, code, s.now().Add(CodeTTL)); err != nil { + send("expired", "") + return true + } + p := url.Values{"code": {code}} + if c.State != "" { + p.Set("state", c.State) + } + send("approved", authorizationResponse(c.RedirectURI, p)) + return true + case c.Status == statusDenied: + send("denied", authorizationError(c.RedirectURI, "access_denied", "the owner did not approve", c.State)) + return true + case expired: + send("expired", authorizationError(c.RedirectURI, "access_denied", "the request ran out before the owner approved", c.State)) + return true + default: + _, _ = fmt.Fprintf(w, "event: pending\ndata: {\"expiresAtMs\":%d}\n\n", c.Expires.UnixMilli()) + return false + } +} diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go new file mode 100644 index 0000000..65a38c3 --- /dev/null +++ b/internal/oauth/cimd.go @@ -0,0 +1,375 @@ +package oauth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + "unicode" +) + +// Client ID metadata documents: a client's id is an https URL, and the +// document at that URL names the client and its redirect URIs. The service +// fetches a URL a stranger chose, so the fetch is fenced: https on port 443 +// to a DNS name, only to public addresses (checked on the address actually +// dialled, so a name that changes its answer between lookup and connect +// gains nothing), no proxy, no redirects, no cookies, a small body and a +// short time, a few at once, and the answers cached. + +const ( + cimdMaxBytes = 16 << 10 + cimdTimeout = 5 * time.Second + cimdMaxURL = 2048 + cimdCacheMin = time.Minute + cimdCacheMax = 24 * time.Hour + cimdCacheNone = 10 * time.Minute // with no Cache-Control + cimdCacheFailed = time.Minute + cimdCacheSize = 1024 + cimdConcurrent = 8 +) + +// clientMetadata is the part of a metadata document the service reads. +type clientMetadata struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +// Fetch refusals. Each is a reason to refuse the client, never to retry. +var ( + errCIMDURL = errors.New("client_id is not a URL this service fetches") + errCIMDAddress = errors.New("client_id resolves to an address this service does not fetch from") + errCIMDFetch = errors.New("client metadata could not be fetched") + errCIMDDocument = errors.New("client metadata document is not valid") +) + +// fetcher gets metadata documents. +type fetcher struct { + client *http.Client + // resolve looks a name up. Tests point it at their own server. + resolve func(ctx context.Context, host string) ([]netip.Addr, error) + // public decides which addresses may be dialled. + public func(netip.Addr) bool + // dial connects to an address that passed the checks. Tests point it + // at their own server. + dial func(ctx context.Context, network, addr string) (net.Conn, error) + timeout time.Duration + now func() time.Time + sem chan struct{} + + mu sync.Mutex + cache map[string]cimdEntry +} + +type cimdEntry struct { + md clientMetadata + err error + until time.Time +} + +// newFetcher builds the fenced fetcher. roots is nil for the system's. +func newFetcher(roots *x509.CertPool) *fetcher { + f := &fetcher{ + resolve: func(ctx context.Context, host string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip", host) + }, + public: publicAddr, + timeout: cimdTimeout, + now: time.Now, + sem: make(chan struct{}, cimdConcurrent), + cache: map[string]cimdEntry{}, + } + // The address is checked again as the socket connects, on the address + // itself. + dialer := &net.Dialer{Timeout: 3 * time.Second, Control: func(_, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil || !f.public(ap.Addr()) { + return errCIMDAddress + } + return nil + }} + f.dial = dialer.DialContext + tr := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := f.resolve(ctx, host) + if err != nil { + return nil, errCIMDFetch + } + for _, ip := range ips { + if !f.public(ip) { + // One private answer is enough to refuse: a name that + // mixes them is pointing somewhere it should not. + return nil, errCIMDAddress + } + } + if len(ips) == 0 { + return nil, errCIMDFetch + } + return f.dial(ctx, network, net.JoinHostPort(ips[0].Unmap().String(), port)) + }, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}, + TLSHandshakeTimeout: 3 * time.Second, + ResponseHeaderTimeout: 3 * time.Second, + MaxResponseHeaderBytes: 8 << 10, + DisableKeepAlives: true, + ForceAttemptHTTP2: true, + } + f.client = &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return f +} + +// isCIMD reports whether a client_id is a URL, and so names a metadata +// document rather than a registered client. +func isCIMD(clientID string) bool { return strings.HasPrefix(clientID, "https://") } + +// checkCIMDURL holds a client_id URL to the rules: https, a DNS name on the +// default port, a path that is not the root and has no dot segments, no +// credentials and no fragment. +func checkCIMDURL(raw string) (*url.URL, error) { + if len(raw) > cimdMaxURL { + return nil, errCIMDURL + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || u.RawFragment != "" || + strings.Contains(raw, "#") || u.Opaque != "" { + return nil, errCIMDURL + } + host := u.Hostname() + if host == "" || u.Port() != "" && u.Port() != "443" || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") { + return nil, errCIMDURL + } + if _, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { + return nil, errCIMDURL // an address, not a name + } + if u.Path == "" || u.Path == "/" { + return nil, errCIMDURL + } + for _, seg := range strings.Split(u.Path, "/") { + if seg == "." || seg == ".." { + return nil, errCIMDURL + } + } + return u, nil +} + +// get returns the validated metadata for a client_id URL, from the cache +// when it may. +func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, error) { + u, err := checkCIMDURL(clientID) + if err != nil { + return clientMetadata{}, err + } + now := f.now() + f.mu.Lock() + if e, ok := f.cache[clientID]; ok && now.Before(e.until) { + f.mu.Unlock() + return e.md, e.err + } + f.mu.Unlock() + + select { + case f.sem <- struct{}{}: + defer func() { <-f.sem }() + case <-ctx.Done(): + return clientMetadata{}, errCIMDFetch + } + md, ttl, err := f.fetch(ctx, u) + if err != nil { + ttl = cimdCacheFailed + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.cache) >= cimdCacheSize { + for k, e := range f.cache { + if !now.Before(e.until) || len(f.cache) >= cimdCacheSize { + delete(f.cache, k) + } + if len(f.cache) < cimdCacheSize/2 { + break + } + } + } + if ttl > 0 { + f.cache[clientID] = cimdEntry{md: md, err: err, until: now.Add(ttl)} + } + return md, err +} + +func (f *fetcher) fetch(ctx context.Context, u *url.URL) (clientMetadata, time.Duration, error) { + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return clientMetadata{}, 0, errCIMDURL + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "FTW-Cloud (+https://cloud.ftw.energy/source)") + resp, err := f.client.Do(req) + if err != nil { + if errors.Is(err, errCIMDAddress) { + return clientMetadata{}, 0, errCIMDAddress + } + return clientMetadata{}, 0, errCIMDFetch + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return clientMetadata{}, 0, fmt.Errorf("%w: status %d", errCIMDFetch, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, cimdMaxBytes+1)) + if err != nil { + return clientMetadata{}, 0, errCIMDFetch + } + if len(body) > cimdMaxBytes { + return clientMetadata{}, 0, fmt.Errorf("%w: larger than %d bytes", errCIMDDocument, cimdMaxBytes) + } + md, err := parseMetadata(body, u.String()) + if err != nil { + return clientMetadata{}, 0, err + } + return md, cacheFor(resp.Header.Get("Cache-Control")), nil +} + +// parseMetadata reads and checks one document fetched from clientID. +func parseMetadata(body []byte, clientID string) (clientMetadata, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return clientMetadata{}, fmt.Errorf("%w: not a JSON object", errCIMDDocument) + } + // A document is public: a client that puts a shared secret in it has + // none. + for _, k := range []string{"client_secret", "client_secret_expires_at"} { + if _, ok := fields[k]; ok { + return clientMetadata{}, fmt.Errorf("%w: %s in a public document", errCIMDDocument, k) + } + } + var md clientMetadata + if err := json.Unmarshal(body, &md); err != nil { + return clientMetadata{}, fmt.Errorf("%w: %v", errCIMDDocument, err) + } + if md.ClientID != clientID { + return clientMetadata{}, fmt.Errorf("%w: client_id does not match its URL", errCIMDDocument) + } + md.ClientName = cleanName(md.ClientName) + if md.ClientName == "" { + return clientMetadata{}, fmt.Errorf("%w: no client_name", errCIMDDocument) + } + if len(md.RedirectURIs) == 0 || len(md.RedirectURIs) > 16 { + return clientMetadata{}, fmt.Errorf("%w: redirect_uris", errCIMDDocument) + } + for _, r := range md.RedirectURIs { + if err := checkRedirect(r); err != nil { + return clientMetadata{}, fmt.Errorf("%w: %v", errCIMDDocument, err) + } + } + if m := md.TokenEndpointAuthMethod; m != "" && m != authNone { + return clientMetadata{}, fmt.Errorf("%w: token_endpoint_auth_method %q is not supported", errCIMDDocument, m) + } + if !subset(md.GrantTypes, []string{grantCode, grantRefresh}) || !subset(md.ResponseTypes, []string{"code"}) { + return clientMetadata{}, fmt.Errorf("%w: grant or response types", errCIMDDocument) + } + return md, nil +} + +// cacheFor reads how long a document may be kept, within bounds. +func cacheFor(cc string) time.Duration { + if cc == "" { + return cimdCacheNone + } + for _, d := range strings.Split(cc, ",") { + d = strings.ToLower(strings.TrimSpace(d)) + switch { + case d == "no-store" || d == "no-cache": + return 0 + case strings.HasPrefix(d, "max-age="): + s, err := strconv.Atoi(strings.TrimPrefix(d, "max-age=")) + if err != nil || s < 0 { + return cimdCacheNone + } + return min(max(time.Duration(s)*time.Second, cimdCacheMin), cimdCacheMax) + } + } + return cimdCacheNone +} + +// publicAddr is an address on the public internet: not loopback, private, +// link-local (where the cloud's metadata service answers), multicast, +// shared, reserved, documentation, or a translation prefix that could lead +// back inside. +func publicAddr(a netip.Addr) bool { + a = a.Unmap() + if !a.IsValid() || a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || + a.IsInterfaceLocalMulticast() || a.IsMulticast() || a.IsUnspecified() { + return false + } + for _, p := range blocked { + if p.Contains(a) { + return false + } + } + return true +} + +var blocked = func() []netip.Prefix { + var out []netip.Prefix + for _, s := range []string{ + "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", + "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "255.255.255.255/32", + "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", + } { + out = append(out, netip.MustParsePrefix(s)) + } + return out +}() + +// cleanName keeps a client's name to one short line of printable text. It +// is shown to an owner deciding whom to let in, so nothing in it may pass +// for something the service wrote. +func cleanName(s string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, s) + s = strings.Join(strings.Fields(s), " ") + if r := []rune(s); len(r) > 64 { + s = string(r[:64]) + "…" + } + return s +} + +func subset(have, allowed []string) bool { + for _, h := range have { + if !slices.Contains(allowed, h) { + return false + } + } + return true +} diff --git a/internal/oauth/cimd_test.go b/internal/oauth/cimd_test.go new file mode 100644 index 0000000..db50061 --- /dev/null +++ b/internal/oauth/cimd_test.go @@ -0,0 +1,215 @@ +package oauth + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" +) + +// The client_id URL itself: only https to a DNS name on port 443, with a +// real path. +func TestCIMDURLRules(t *testing.T) { + for _, bad := range []string{ + "http://example.com/client.json", + "https://example.com", + "https://example.com/", + "https://127.0.0.1/client.json", + "https://[::1]/client.json", + "https://169.254.169.254/latest/meta-data", + "https://example.com:8443/client.json", + "https://user:pass@example.com/client.json", + "https://example.com/client.json#frag", + "https://example.com/a/../client.json", + "https://example.com/./client.json", + "https://localhost/client.json", + "https://example.com./client.json", + "https://" + strings.Repeat("a", cimdMaxURL) + ".com/x", + } { + if _, err := checkCIMDURL(bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } + for _, good := range []string{"https://example.com/client.json", "https://claude.ai/oauth/mcp-client-metadata", "https://a.example:443/x"} { + if _, err := checkCIMDURL(good); err != nil { + t.Errorf("%s was refused: %v", good, err) + } + } +} + +func TestPublicAddresses(t *testing.T) { + for _, s := range []string{ + "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.40", "169.254.169.254", "100.64.0.1", "0.0.0.0", + "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "::1", "::", "fe80::1", "fd00::1", "fc00::1", + "::ffff:10.0.0.1", "::ffff:169.254.169.254", "64:ff9b::a00:1", "2002:a00:1::", "2001:db8::1", "ff02::1", + } { + if publicAddr(netip.MustParseAddr(s)) { + t.Errorf("%s counts as public", s) + } + } + for _, s := range []string{"1.1.1.1", "13.50.84.149", "2606:4700:4700::1111", "::ffff:1.1.1.1"} { + if !publicAddr(netip.MustParseAddr(s)) { + t.Errorf("%s does not count as public", s) + } + } +} + +// fenced is the production fence with a resolver the test chooses: every +// name below is refused before a socket opens, and dial counts any socket +// that would have. +func fenced(t *testing.T, answers map[string][]string) (*fetcher, *atomic.Int32) { + f := newFetcher(nil) + var dialled atomic.Int32 + f.resolve = func(_ context.Context, host string) ([]netip.Addr, error) { + var out []netip.Addr + for _, s := range answers[host] { + out = append(out, netip.MustParseAddr(s)) + } + if out == nil { + return nil, errors.New("no such host") + } + return out, nil + } + f.dial = func(context.Context, string, string) (net.Conn, error) { + dialled.Add(1) + return nil, errors.New("test: no network") + } + return f, &dialled +} + +// A name that resolves anywhere inside is refused before connecting: +// loopback, the private ranges, the cloud's metadata service, shared +// address space, IPv6 inside, IPv4 in IPv6, NAT64, and a name that mixes a +// public answer with a private one. +func TestCIMDRefusesInsideAddresses(t *testing.T) { + answers := map[string][]string{ + "loop.example": {"127.0.0.1"}, + "ten.example": {"10.0.0.7"}, + "meta.example": {"169.254.169.254"}, + "cgnat.example": {"100.64.1.1"}, + "v6loop.example": {"::1"}, + "ula.example": {"fd12:3456::1"}, + "mapped.example": {"::ffff:192.168.1.40"}, + "nat64.example": {"64:ff9b::a9fe:a9fe"}, + "mixed.example": {"1.1.1.1", "10.0.0.1"}, + "zero.example": {"0.0.0.0"}, + "linklocal.example": {"fe80::1"}, + } + f, dialled := fenced(t, answers) + for host := range answers { + _, err := f.get(context.Background(), "https://"+host+"/client.json") + if !errors.Is(err, errCIMDAddress) { + t.Errorf("%s: %v", host, err) + } + } + if n := dialled.Load(); n != 0 { + t.Fatalf("%d sockets opened toward inside addresses", n) + } + // The second check, as the socket connects, refuses too: a dialer + // handed an inside address by any route does not connect. + fresh := newFetcher(nil) + if _, err := fresh.dial(context.Background(), "tcp", "127.0.0.1:443"); err == nil || !strings.Contains(err.Error(), errCIMDAddress.Error()) { + t.Fatalf("connect-time check: %v", err) + } +} + +// What the document's server may do: redirect, send too much, be slow, +// answer an error, or serve a document that is not the client's. Each is a +// refusal, and a refusal is cached for a minute. +func TestCIMDServerMisbehaviour(t *testing.T) { + g := newRig(t) + var hits atomic.Int32 + slow := make(chan struct{}) + t.Cleanup(func() { close(slow) }) + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + switch r.URL.Path { + case "/redirect.json": + http.Redirect(w, r, "https://169.254.169.254/latest/meta-data", http.StatusFound) + case "/big.json": + _, _ = w.Write([]byte(`{"client_name":"` + strings.Repeat("a", cimdMaxBytes) + `"}`)) + case "/slow.json": + select { + case <-slow: + case <-r.Context().Done(): + } + case "/error.json": + http.Error(w, "nope", http.StatusInternalServerError) + case "/mismatch.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/other.json","client_name":"X","redirect_uris":["https://example.com/cb"]}`)) + case "/secret.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/secret.json","client_name":"X","redirect_uris":["https://example.com/cb"],"client_secret":"s"}`)) + case "/custom.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/custom.json","client_name":"X","redirect_uris":["myapp://cb"]}`)) + case "/jwt.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/jwt.json","client_name":"X","redirect_uris":["https://example.com/cb"],"token_endpoint_auth_method":"private_key_jwt"}`)) + case "/noname.json": + _, _ = w.Write([]byte(`{"client_id":"https://example.com/noname.json","redirect_uris":["https://example.com/cb"]}`)) + case "/html.json": + _, _ = w.Write([]byte(``)) + case "/cached.json": + w.Header().Set("Cache-Control", "max-age=600") + _, _ = w.Write([]byte(`{"client_id":"https://example.com/cached.json","client_name":"Cached","redirect_uris":["https://example.com/cb"]}`)) + } + }) + g.s.fetch.timeout = 300 * time.Millisecond + for _, p := range []string{"redirect", "big", "slow", "error", "mismatch", "secret", "custom", "jwt", "noname", "html"} { + start := time.Now() + if _, err := g.s.fetch.get(context.Background(), "https://example.com/"+p+".json"); err == nil { + t.Errorf("%s was accepted", p) + } + if time.Since(start) > 2*time.Second { + t.Errorf("%s took %s", p, time.Since(start)) + } + } + // Failures are cached: asking again does not fetch again. + before := hits.Load() + _, _ = g.s.fetch.get(context.Background(), "https://example.com/error.json") + if hits.Load() != before { + t.Fatal("a failed fetch was not cached") + } + // And so is a good document, for as long as it says, within bounds. + for range 3 { + md, err := g.s.fetch.get(context.Background(), "https://example.com/cached.json") + if err != nil || md.ClientName != "Cached" { + t.Fatalf("cached: %v", err) + } + } + if hits.Load() != before+1 { + t.Fatalf("a cacheable document was fetched %d times", hits.Load()-before) + } + g.advance(11 * time.Minute) + _, _ = g.s.fetch.get(context.Background(), "https://example.com/cached.json") + if hits.Load() != before+2 { + t.Fatal("a document was kept past its max-age") + } +} + +func TestCacheFor(t *testing.T) { + for cc, want := range map[string]time.Duration{ + "": cimdCacheNone, + "no-store": 0, + "max-age=0": cimdCacheMin, + "public, max-age=3600": time.Hour, + "max-age=999999999": cimdCacheMax, + "max-age=x": cimdCacheNone, + } { + if got := cacheFor(cc); got != want { + t.Errorf("%q: %s, want %s", cc, got, want) + } + } +} + +// The fence uses no proxy from the environment. +func TestCIMDIgnoresProxies(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + f := newFetcher(nil) + if f.client.Transport.(*http.Transport).Proxy != nil { + t.Fatal("the fetcher reads a proxy") + } +} diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go new file mode 100644 index 0000000..92ca6f2 --- /dev/null +++ b/internal/oauth/clients.go @@ -0,0 +1,249 @@ +package oauth + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// client is a client as the authorize and token endpoints see it, from a +// metadata document or from a registration. +type client struct { + ID string + Name string + // Host is where the name came from: the host of the metadata + // document's URL, which vouches for it, or the client_uri a registration + // claimed, which does not. + Host string + Verified bool + RedirectURIs []string + AuthMethod string + secret []byte // hash, for a registered confidential client +} + +var errUnknownClient = errors.New("unknown client") + +// resolveClient finds the client a client_id names. +func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { + if isCIMD(id) { + md, err := s.fetch.get(ctx, id) + if err != nil { + return client{}, err + } + u, _ := url.Parse(id) + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: true, RedirectURIs: md.RedirectURIs, + AuthMethod: authNone}, nil + } + c, ok, err := s.st.client(id) + if err != nil { + return client{}, err + } + if !ok { + return client{}, errUnknownClient + } + host := "" + if u, err := url.Parse(c.URI); err == nil { + host = u.Hostname() + } + return client{ID: c.ID, Name: c.Name, Host: host, RedirectURIs: c.RedirectURIs, AuthMethod: c.AuthMethod, secret: c.Secret}, nil +} + +// checkRedirect holds a redirect URI to the rules: https, or http to this +// computer's loopback for a client that runs on it. No fragment and no +// credentials. Custom schemes are refused, as the MCP spec asks. +func checkRedirect(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.User != nil || u.Fragment != "" || strings.Contains(raw, "#") || u.Host == "" || len(raw) > 2048 { + return fmt.Errorf("redirect URI %q is not allowed", raw) + } + switch u.Scheme { + case "https": + return nil + case "http": + if isLoopback(u.Hostname()) { + return nil + } + } + return fmt.Errorf("redirect URI %q is neither https nor loopback http", raw) +} + +func isLoopback(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +// redirectMatches compares a redirect URI with the registered ones: exactly, +// or for a loopback URI with any port, since a client on the owner's +// computer takes whatever port is free (RFC 8252). +func redirectMatches(registered []string, given string) bool { + g, err := url.Parse(given) + if err != nil { + return false + } + for _, r := range registered { + if r == given { + return true + } + u, err := url.Parse(r) + if err != nil || u.Scheme != "http" || g.Scheme != "http" || !isLoopback(u.Hostname()) { + continue + } + if u.Hostname() == g.Hostname() && u.Path == g.Path && u.RawQuery == g.RawQuery && g.User == nil && g.Fragment == "" { + return true + } + } + return false +} + +// -------------------------------------------------------------------------- +// Dynamic registration (RFC 7591), kept for clients without metadata +// documents +// -------------------------------------------------------------------------- + +// maxClients bounds registrations kept. +const maxClients = 50_000 + +type registration struct { + RedirectURIs []string `json:"redirect_uris"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + Scope string `json:"scope"` +} + +func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { + if !s.registerLimit.allow(s.now()) { + w.Header().Set("Retry-After", "10") + registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") + return + } + var reg registration + body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10)) + if err != nil || json.Unmarshal(body, ®) != nil { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "the body is not a JSON object") + return + } + if len(reg.RedirectURIs) == 0 || len(reg.RedirectURIs) > 16 { + registerError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris must list between 1 and 16 URIs") + return + } + for _, u := range reg.RedirectURIs { + if err := checkRedirect(u); err != nil { + registerError(w, http.StatusBadRequest, "invalid_redirect_uri", err.Error()) + return + } + } + method := reg.TokenEndpointAuthMethod + if method == "" { + method = authNone + } + if method != authNone && method != authSecretPost && method != authSecretBasic { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method must be none, client_secret_post or client_secret_basic") + return + } + grants := reg.GrantTypes + if len(grants) == 0 { + grants = []string{grantCode} + } + if !subset(grants, []string{grantCode, grantRefresh}) || !subset(reg.ResponseTypes, []string{"code"}) { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "only authorization_code and refresh_token, with response type code") + return + } + for _, sc := range splitScope(reg.Scope) { + if !validScope(sc) { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "unknown scope "+sc) + return + } + } + name := cleanName(reg.ClientName) + if name == "" { + name = "An unnamed assistant" + } + if reg.ClientURI != "" { + if u, err := url.Parse(reg.ClientURI); err != nil || u.Scheme != "https" { + reg.ClientURI = "" + } + } + if s.st.countClients() >= maxClients { + registerError(w, http.StatusServiceUnavailable, "invalid_client_metadata", "registration is full") + return + } + c := dcrClient{ID: "ftwc_" + randomToken(16), AuthMethod: method, Name: name, URI: reg.ClientURI, RedirectURIs: reg.RedirectURIs} + var secret string + if method != authNone { + secret = "ftws_" + randomToken(32) + c.Secret = hash(secret) + } + if err := s.st.putClient(c, s.now()); err != nil { + registerError(w, http.StatusInternalServerError, "invalid_client_metadata", "could not register") + return + } + out := map[string]any{ + "client_id": c.ID, + "client_id_issued_at": s.now().Unix(), + "client_name": c.Name, + "redirect_uris": c.RedirectURIs, + "token_endpoint_auth_method": method, + "grant_types": grants, + "response_types": []string{"code"}, + } + if reg.ClientURI != "" { + out["client_uri"] = reg.ClientURI + } + if secret != "" { + out["client_secret"] = secret + out["client_secret_expires_at"] = 0 + } + writeJSON(w, http.StatusCreated, out, false) +} + +func registerError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}, false) +} + +// authenticateClient checks a token or revocation request's client: its id +// in the form or in Basic, and its secret if it has one. +func (s *Server) authenticateClient(r *http.Request) (client, bool) { + id, secret, basic := r.BasicAuth() + if basic { + id, _ = url.QueryUnescape(id) + secret, _ = url.QueryUnescape(secret) + } else { + id, secret = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret") + } + if id == "" { + return client{}, false + } + var c client + if isCIMD(id) { + // A metadata document's client is public; it was checked when the + // consent began, and its id is bound to the consent. + c = client{ID: id, AuthMethod: authNone} + } else { + dc, ok, err := s.st.client(id) + if err != nil || !ok { + return client{}, false + } + c = client{ID: dc.ID, AuthMethod: dc.AuthMethod, secret: dc.Secret} + } + switch c.AuthMethod { + case authNone: + return c, secret == "" + default: + if secret == "" || len(c.secret) == 0 { + return client{}, false + } + if subtle.ConstantTimeCompare(hash(secret), c.secret) != 1 { + return client{}, false + } + s.st.clientUsed(c.ID, s.now()) + return c, true + } +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 0000000..78a121f --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,453 @@ +// Package oauth is FTW Cloud's authorization server for assistants: ChatGPT, +// Claude, Grok and any MCP client. It follows the MCP authorization spec, +// revision 2026-07-28: OAuth 2.1 with PKCE S256 only, authorization server +// metadata (RFC 8414) with iss in every authorization response (RFC 9207), +// protected resource metadata for /mcp (RFC 9728), audience-bound tokens by +// the resource parameter (RFC 8707), client ID metadata documents, and +// dynamic registration (RFC 7591) kept for clients that need it. +// +// There is no account and no password. The authorize page shows a short +// code, a QR and a link to the FTW app. The owner approves in the app or on +// the box's own page, and the box lists the new agent's key in the roster it +// signs. When that roster arrives, the page, which waits on a server-sent +// event, sends the browser back to the client with a code. +// +// A token is bound to one agent of one home: (shard, home, agent id). It +// never leaves the service. The box sees the agent's own Noise session, +// opened by the keys part, and decides what each request may do. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Addresses. +const ( + // Issuer is the authorization server's identifier, and the origin of + // every endpoint. + Issuer = "https://cloud.ftw.energy" + // Resource is the MCP server's canonical URI: every token's audience. + Resource = Issuer + "/mcp" + // AppLink is where the QR and the button send the owner: a universal + // link that opens the FTW app, followed by the request id. + AppLink = "https://app.ftw.energy/a/" + // AppOrigin may read a request's details from a browser. + AppOrigin = "https://app.ftw.energy" +) + +// Lifetimes. +const ( + // RequestTTL is how long a consent waits for the owner. + RequestTTL = 10 * time.Minute + // CodeTTL is how long an authorization code lasts. + CodeTTL = time.Minute + // AccessTTL is an access token's life. Short: a token that leaks is + // worth little, and a revoked agent loses access at its box anyway. + AccessTTL = 10 * time.Minute + // RefreshTTL caps a refresh token, and an agent's own end caps it + // sooner. + RefreshTTL = 30 * 24 * time.Hour +) + +// Scopes. Each is a right the box grants an agent, by its registry name; a +// token carries those the client asked for and the box granted. +// +// The first five are in contract/registry.yaml today. The rest are the +// agent rights decided on 15 October 2026 and wait for their registry names +// (the core-agent-role contract); they move to protocol constants when it +// lands. +const ( + ScopeLiveRead = protocol.ScopeLiveRead + ScopePlanRead = protocol.ScopePlanRead + ScopeHistoryRead = protocol.ScopeHistoryRead + ScopeHealthRead = protocol.ScopeHealthRead + ScopeEventsRead = protocol.ScopeEventsRead + ScopeLogsRead = "ftw.logs.read" + ScopeConfigRead = "ftw.config.read" + ScopeDiagRun = "ftw.diag.run" + ScopeDispatchHold = "ftw.dispatch.hold" + ScopeGoalsRequest = "ftw.goals.request" +) + +// ReadScopes are what an agent gets by default. +var ReadScopes = []string{ScopeLiveRead, ScopePlanRead, ScopeHistoryRead, ScopeHealthRead, ScopeEventsRead, + ScopeLogsRead, ScopeConfigRead, ScopeDiagRun} + +// ControlScopes need the owner to tick control. +var ControlScopes = []string{ScopeDispatchHold, ScopeGoalsRequest} + +// AllScopes is every scope a client may ask for. +var AllScopes = append(slices.Clone(ReadScopes), ControlScopes...) + +// scopeWords says what each scope lets an assistant do, for the consent +// page. +var scopeWords = map[string]string{ + ScopeLiveRead: "See power, battery and the car right now", + ScopePlanRead: "See the plan and why it looks that way", + ScopeHistoryRead: "See past readings", + ScopeHealthRead: "See how the box and its devices are doing", + ScopeEventsRead: "See alerts and events", + ScopeLogsRead: "Read the box's logs, with secrets removed", + ScopeConfigRead: "Read the settings, with secrets removed", + ScopeDiagRun: "Run checks that change nothing", + ScopeDispatchHold: "Hold the battery or charge the car for a short, capped time", + ScopeGoalsRequest: "Ask for changes to goals and schedules, which wait for your yes", +} + +// Client authentication methods and grants. +const ( + authNone = "none" + authSecretPost = "client_secret_post" + authSecretBasic = "client_secret_basic" + grantCode = "authorization_code" + grantRefresh = "refresh_token" +) + +// AgentKeys is the keys part, as the authorization server uses it. +type AgentKeys interface { + New(ctx context.Context) (kid string, pub []byte, err error) + Keep(ctx context.Context, kid string) error + Forget(ctx context.Context, kid string) error +} + +// Server is the authorization server. +type Server struct { + st *store + keys AgentKeys + fetch *fetcher + log *slog.Logger + now func() time.Time + hub hub + + authorizeLimit, registerLimit, lookupLimit limiter + + roster chan rosterEvent + + // Shard names the shard a home lives on. Nil is one shard, 0. + Shard func(home string) int + // AgentExpiry reads when the box ends an agent, from its roster entry. + // Nil, or a zero time, is no end the service knows of; the box still + // enforces its own. + AgentExpiry func(d cloudwire.Device) time.Time + // OnRevoke hears that an agent is gone: its owner revoked it, or it + // ran out. The MCP server drops its warm session. It must not block. + OnRevoke func(home, agent string) +} + +// New opens the authorization server's state at path. +func New(path string, keys AgentKeys, log *slog.Logger) (*Server, error) { + return newServer(path, keys, log, nil) +} + +func newServer(path string, keys AgentKeys, log *slog.Logger, roots *x509.CertPool) (*Server, error) { + if log == nil { + log = slog.Default() + } + st, err := openStore(path) + if err != nil { + return nil, err + } + s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, + authorizeLimit: newLimiter(30, 1), registerLimit: newLimiter(20, 0.2), lookupLimit: newLimiter(20, 5), + roster: make(chan rosterEvent, 1024)} + return s, nil +} + +// Close closes the state. +func (s *Server) Close() error { return s.st.close() } + +// Routes are the paths the authorization server answers, for the server's +// list. +var Routes = []string{ + "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", "/oauth/authorize", "/oauth/token", "/oauth/register", + "/oauth/revoke", "/oauth/requests", +} + +// Mount adds the authorization server's routes to mux. +func (s *Server) Mount(mux *http.ServeMux) { + mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.serveMetadata) + mux.HandleFunc("GET /.well-known/oauth-protected-resource", s.serveResourceMetadata) + mux.HandleFunc("GET /.well-known/oauth-protected-resource/mcp", s.serveResourceMetadata) + mux.HandleFunc("GET /oauth/authorize", s.serveAuthorize) + mux.HandleFunc("POST /oauth/token", s.serveToken) + mux.HandleFunc("POST /oauth/register", s.serveRegister) + mux.HandleFunc("POST /oauth/revoke", s.serveRevoke) + mux.HandleFunc("GET /oauth/requests", s.serveRequestByCode) + mux.HandleFunc("GET /oauth/requests/{rid}", s.serveRequest) + mux.HandleFunc("GET /oauth/requests/{rid}/events", s.serveEvents) + mux.HandleFunc("POST /oauth/requests/{rid}/deny", s.serveDeny) +} + +// -------------------------------------------------------------------------- +// Metadata +// -------------------------------------------------------------------------- + +// ResourceMetadataURL is where /mcp's protected resource metadata lives, +// named in every challenge. +const ResourceMetadataURL = Issuer + "/.well-known/oauth-protected-resource/mcp" + +func (s *Server) serveMetadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": Issuer, + "authorization_endpoint": Issuer + "/oauth/authorize", + "token_endpoint": Issuer + "/oauth/token", + "registration_endpoint": Issuer + "/oauth/register", + "revocation_endpoint": Issuer + "/oauth/revoke", + "response_types_supported": []string{"code"}, + "response_modes_supported": []string{"query"}, + "grant_types_supported": []string{grantCode, grantRefresh}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{authNone, authSecretPost, authSecretBasic}, + "revocation_endpoint_auth_methods_supported": []string{authNone, authSecretPost, authSecretBasic}, + "scopes_supported": AllScopes, + "client_id_metadata_document_supported": true, + "authorization_response_iss_parameter_supported": true, + "service_documentation": "https://github.com/srcfl/ftw-cloud/blob/main/README.md", + }, true) +} + +func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "resource": Resource, + "authorization_servers": []string{Issuer}, + "scopes_supported": ReadScopes, + "bearer_methods_supported": []string{"header"}, + "resource_name": "FTW Cloud", + "resource_documentation": "https://github.com/srcfl/ftw-cloud/blob/main/README.md", + }, true) +} + +// -------------------------------------------------------------------------- +// The box's roster +// -------------------------------------------------------------------------- + +type rosterEvent struct { + home string + devices []cloudwire.Device +} + +// Roster hears each roster the service keeps. A device whose key a pending +// consent made is that consent's approval; an agent the roster no longer +// lists is revoked. The work happens on Run's goroutine, in order, since +// this runs on the box's. +func (s *Server) Roster(home string, r *cloudwire.Roster) { + ev := rosterEvent{home: home, devices: slices.Clone(r.Devices)} + select { + case s.roster <- ev: + default: + // The next roster carries the same devices. + s.log.Warn("roster queue full; an agent change waits for the next roster") + } +} + +// Run applies rosters and sweeps until ctx ends. +func (s *Server) Run(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case ev := <-s.roster: + s.applyRoster(ctx, ev) + case <-t.C: + s.sweep(ctx) + } + } +} + +func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { + now := s.now() + listed := map[string]cloudwire.Device{} + for _, d := range ev.devices { + if d.Tun { + listed[d.ID] = d + } + } + // Approvals: a pending consent's key in the box's signed roster. + for _, d := range listed { + ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) + if err != nil || !ok { + continue + } + if cloudwire.DeviceID(d.Key) != d.ID { + continue + } + if err := s.keys.Keep(ctx, c.Kid); err != nil { + s.log.Warn("an approved agent's key could not be kept; the next roster tries again", "err", err) + continue + } + c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) + c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Scopes), " ") + if err := s.st.approve(ridHash, c, s.expiry(d), now); err != nil { + continue + } + s.hub.notify(string(ridHash)) + } + // Revocations: an agent the roster no longer lists, or lists with + // another key. + agents, err := s.st.agentsOf(ev.home) + if err != nil { + return + } + for _, a := range agents { + if d, ok := listed[a.Agent]; ok && string(d.Key) == string(a.Key) { + continue + } + s.revoke(ctx, a) + } +} + +func (s *Server) revoke(ctx context.Context, a agentRow) { + if err := s.st.dropAgent(a.Home, a.Agent); err != nil { + s.log.Warn("could not drop an agent", "err", err) + return + } + if err := s.keys.Forget(ctx, a.Kid); err != nil { + s.log.Warn("the keys part could not forget an agent's key", "err", err) + } + if s.OnRevoke != nil { + s.OnRevoke(a.Home, a.Agent) + } +} + +func (s *Server) sweep(ctx context.Context) { + now := s.now() + kids, err := s.st.sweep(now) + if err != nil { + s.log.Warn("oauth upkeep failed", "err", err) + } + for _, kid := range kids { + _ = s.keys.Forget(ctx, kid) + } + gone, err := s.st.expiredAgents(now) + if err == nil { + for _, a := range gone { + s.revoke(ctx, a) + } + } +} + +func (s *Server) shard(home string) int { + if s.Shard == nil { + return 0 + } + return s.Shard(home) +} + +func (s *Server) expiry(d cloudwire.Device) time.Time { + if s.AgentExpiry == nil { + return time.Time{} + } + return s.AgentExpiry(d) +} + +// -------------------------------------------------------------------------- +// Helpers +// -------------------------------------------------------------------------- + +// hub wakes the event streams waiting on a consent. +type hub struct { + mu sync.Mutex + subs map[string]map[chan struct{}]struct{} +} + +func (h *hub) subscribe(ridHash string) (chan struct{}, func()) { + ch := make(chan struct{}, 1) + h.mu.Lock() + if h.subs[ridHash] == nil { + h.subs[ridHash] = map[chan struct{}]struct{}{} + } + h.subs[ridHash][ch] = struct{}{} + h.mu.Unlock() + return ch, func() { + h.mu.Lock() + delete(h.subs[ridHash], ch) + if len(h.subs[ridHash]) == 0 { + delete(h.subs, ridHash) + } + h.mu.Unlock() + } +} + +func (h *hub) notify(ridHash string) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs[ridHash] { + select { + case ch <- struct{}{}: + default: + } + } +} + +// limiter is a token bucket shared by every caller of one endpoint. It +// bounds what strangers can make the service do; it names nobody. +type limiter struct { + mu sync.Mutex + tokens, burst float64 + rate float64 + at time.Time +} + +func newLimiter(burst, perSecond float64) limiter { + return limiter{tokens: burst, burst: burst, rate: perSecond} +} + +func (l *limiter) allow(now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + if !l.at.IsZero() { + l.tokens = min(l.burst, l.tokens+now.Sub(l.at).Seconds()*l.rate) + } + l.at = now + if l.tokens < 1 { + return false + } + l.tokens-- + return true +} + +func randomToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v any, cache bool) { + w.Header().Set("Content-Type", "application/json") + if cache { + w.Header().Set("Cache-Control", "public, max-age=3600") + } else { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func intersect(a, b []string) []string { + var out []string + for _, x := range a { + if slices.Contains(b, x) && !slices.Contains(out, x) { + out = append(out, x) + } + } + return out +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..f091e35 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,878 @@ +package oauth + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// rig is the authorization server with a real keys part behind it, a +// client's metadata document on its own https server, and a clock. +type rig struct { + t *testing.T + s *Server + keys *keys.Client + srv *httptest.Server + cimd *httptest.Server + docs map[string]string + docsMu sync.Mutex + clock struct { + sync.Mutex + now time.Time + } +} + +const ( + cimdID = "https://example.com/oauth/client.json" + cimdBack = "https://example.com/callback" + home = "homehomehomehomehomehomeho" +) + +func newRig(t *testing.T) *rig { + t.Helper() + g := &rig{t: t, docs: map[string]string{}} + g.clock.now = time.Now() + ks, err := keys.NewServer(t.TempDir(), mustFileSealer(t), nil) + if err != nil { + t.Fatal(err) + } + g.keys = ks.Local() + + g.docs["/oauth/client.json"] = `{"client_id":"` + cimdID + `","client_name":"Claude","redirect_uris":["` + cimdBack + `","http://127.0.0.1/cb"]}` + g.cimd = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.docsMu.Lock() + doc, ok := g.docs[r.URL.Path] + g.docsMu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, doc) + })) + t.Cleanup(g.cimd.Close) + pool := x509.NewCertPool() + pool.AddCert(g.cimd.Certificate()) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + g.s, err = newServer(filepath.Join(t.TempDir(), "oauth.db"), g.keys, log, pool) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = g.s.Close() }) + g.s.now = g.now + g.s.fetch.now = g.now + // example.com is the test server, on loopback, which the fence would + // refuse: only this rig lets it through. + g.s.fetch.resolve = func(_ context.Context, host string) ([]netip.Addr, error) { + if host == "example.com" { + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil + } + return nil, errors.New("no such host") + } + g.s.fetch.public = func(a netip.Addr) bool { return a == netip.MustParseAddr("127.0.0.1") || publicAddr(a) } + g.s.fetch.dial = func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, g.cimd.Listener.Addr().String()) + } + mux := http.NewServeMux() + g.s.Mount(mux) + g.srv = httptest.NewServer(mux) + t.Cleanup(g.srv.Close) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go g.s.Run(ctx) + return g +} + +func mustFileSealer(t *testing.T) keys.Sealer { + s, err := keys.FileSealerFromKey(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + return s +} + +func (g *rig) now() time.Time { + g.clock.Lock() + defer g.clock.Unlock() + return g.clock.now +} + +func (g *rig) advance(d time.Duration) { + g.clock.Lock() + g.clock.now = g.clock.now.Add(d) + g.clock.Unlock() +} + +var noFollow = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +func pkce() (verifier, challenge string) { + verifier = randomToken(32) + sum := sha256.Sum256([]byte(verifier)) + return verifier, base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// flow is one authorization in progress, as the client and its browser hold +// it. +type flow struct { + verifier, state string + rid string + cookie *http.Cookie + view requestView +} + +func (g *rig) authorizeParams(clientID, redirect, scope string) (url.Values, *flow) { + verifier, challenge := pkce() + f := &flow{verifier: verifier, state: randomToken(8)} + q := url.Values{"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirect}, + "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {f.state}, "resource": {Resource}} + if scope != "" { + q.Set("scope", scope) + } + return q, f +} + +var ridRE = regexp.MustCompile(`data-rid="([A-Za-z0-9_-]+)"`) + +// start opens the authorize page and reads what the app would. +func (g *rig) start(clientID, redirect, scope string) *flow { + g.t.Helper() + q, f := g.authorizeParams(clientID, redirect, scope) + resp, err := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + if err != nil { + g.t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + g.t.Fatalf("authorize: %d %s %s", resp.StatusCode, resp.Header.Get("Location"), body) + } + m := ridRE.FindSubmatch(body) + if m == nil { + g.t.Fatal("no request id on the page") + } + f.rid = string(m[1]) + for _, c := range resp.Cookies() { + if c.Name == cookieName(f.rid) { + f.cookie = c + } + } + if f.cookie == nil || !f.cookie.Secure || !f.cookie.HttpOnly { + g.t.Fatalf("the browser's cookie: %+v", f.cookie) + } + f.view = g.details(f.rid) + return f +} + +func (g *rig) details(rid string) requestView { + g.t.Helper() + resp, err := http.Get(g.srv.URL + "/oauth/requests/" + rid) + if err != nil { + g.t.Fatal(err) + } + defer resp.Body.Close() + var v requestView + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&v) != nil { + g.t.Fatalf("request details: %d", resp.StatusCode) + } + return v +} + +func (f *flow) agentKey(t *testing.T) []byte { + k, err := base64.RawURLEncoding.DecodeString(f.view.Agent.Key) + if err != nil || len(k) != 32 { + t.Fatalf("agent key %q", f.view.Agent.Key) + } + return k +} + +type event struct{ name, data string } + +// events opens the page's stream, as the browser does (with its cookie) or +// as anyone else could (without). +func (g *rig) events(f *flow, withCookie bool) (<-chan event, func()) { + g.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, g.srv.URL+"/oauth/requests/"+f.rid+"/events", nil) + if withCookie { + req.AddCookie(f.cookie) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + g.t.Fatal(err) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + g.t.Fatalf("event stream content type %q", ct) + } + ch := make(chan event, 16) + go func() { + defer close(ch) + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + var ev event + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event: "): + ev.name = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + ev.data = strings.TrimPrefix(line, "data: ") + case line == "" && ev.name != "": + ch <- ev + ev = event{} + } + } + }() + return ch, cancel +} + +func next(t *testing.T, ch <-chan event) event { + t.Helper() + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("the stream ended") + } + return ev + case <-time.After(3 * time.Second): + t.Fatal("no event") + } + return event{} +} + +// roster is the box's signed roster arriving, as the uplink hands it on +// after checking the box's signature. +func (g *rig) roster(h string, devs ...cloudwire.Device) { + g.s.Roster(h, &cloudwire.Roster{RosterSeq: 1, TTLS: 86400, Devices: devs}) + // Run applies it on its own goroutine; a request on the store after + // this waits for it by draining the queue. + deadline := time.Now().Add(2 * time.Second) + for len(g.s.roster) > 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + time.Sleep(20 * time.Millisecond) +} + +func agentDevice(key []byte, scopes ...string) cloudwire.Device { + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: "agent", Scopes: scopes, Epoch: 1, Tun: true} +} + +// redirectOf reads the client's redirect from an event. +func redirectOf(t *testing.T, ev event) *url.URL { + t.Helper() + var d struct{ Redirect string } + if json.Unmarshal([]byte(ev.data), &d) != nil || d.Redirect == "" { + t.Fatalf("event %s carries no redirect: %s", ev.name, ev.data) + } + u, err := url.Parse(d.Redirect) + if err != nil { + t.Fatal(err) + } + return u +} + +type tokens struct { + Access string `json:"access_token"` + Type string `json:"token_type"` + Expires int `json:"expires_in"` + Refresh string `json:"refresh_token"` + Scope string `json:"scope"` + Error string `json:"error"` +} + +func (g *rig) token(form url.Values, basic ...string) (int, tokens) { + g.t.Helper() + req, _ := http.NewRequest(http.MethodPost, g.srv.URL+"/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if len(basic) == 2 { + req.SetBasicAuth(basic[0], basic[1]) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + g.t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Cache-Control") != "no-store" { + g.t.Fatal("a token response may be cached") + } + var tk tokens + _ = json.NewDecoder(resp.Body).Decode(&tk) + return resp.StatusCode, tk +} + +// approve runs a whole consent: the box lists the agent, the page gets the +// code, the client swaps it. +func (g *rig) approve(f *flow, clientID string, scopes ...string) tokens { + g.t.Helper() + ch, stop := g.events(f, true) + defer stop() + if ev := next(g.t, ch); ev.name != "pending" { + g.t.Fatalf("first event %q", ev.name) + } + if len(scopes) == 0 { + scopes = []string{ScopeLiveRead, ScopePlanRead} + } + g.roster(home, agentDevice(f.agentKey(g.t), scopes...)) + ev := next(g.t, ch) + if ev.name != "approved" { + g.t.Fatalf("event %q after the roster", ev.name) + } + u := redirectOf(g.t, ev) + if u.Query().Get("iss") != Issuer || u.Query().Get("state") != f.state || u.Query().Get("code") == "" { + g.t.Fatalf("redirect %s", u) + } + status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {u.Query().Get("code")}, "client_id": {clientID}, + "code_verifier": {f.verifier}, "redirect_uri": {f.view.redirectURIForTest()}, "resource": {Resource}}) + if status != http.StatusOK || tk.Access == "" || tk.Refresh == "" || tk.Type != "Bearer" { + g.t.Fatalf("token: %d %+v", status, tk) + } + return tk +} + +// redirectURIForTest is the redirect the test flows use; requestView shows +// only its host. +func (v requestView) redirectURIForTest() string { + if v.Redirect.Local { + return "http://127.0.0.1/cb" + } + return cimdBack +} + +func (g *rig) bearer(tok string) (*Grant, *AuthError) { + r := httptest.NewRequest(http.MethodPost, "/mcp", nil) + r.Header.Set("Authorization", "Bearer "+tok) + return g.s.Authenticate(r) +} + +// --------------------------------------------------------------------------- + +func TestMetadataNamesTheIssuerAndTheResource(t *testing.T) { + g := newRig(t) + var as map[string]any + getJSON(t, g.srv.URL+"/.well-known/oauth-authorization-server", &as) + if as["issuer"] != Issuer || as["authorization_response_iss_parameter_supported"] != true || + as["client_id_metadata_document_supported"] != true || as["registration_endpoint"] != Issuer+"/oauth/register" { + t.Fatalf("authorization server metadata: %v", as) + } + if m := as["code_challenge_methods_supported"].([]any); len(m) != 1 || m[0] != "S256" { + t.Fatalf("PKCE methods %v", m) + } + for _, p := range []string{"/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"} { + var pr map[string]any + getJSON(t, g.srv.URL+p, &pr) + if pr["resource"] != Resource || pr["authorization_servers"].([]any)[0] != Issuer { + t.Fatalf("%s: %v", p, pr) + } + } +} + +func getJSON(t *testing.T, u string, v any) { + t.Helper() + resp, err := http.Get(u) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(v) != nil { + t.Fatalf("GET %s: %d", u, resp.StatusCode) + } +} + +// The whole flow with a client ID metadata document and PKCE, then refresh +// with rotation. +func TestTheFullFlowWithAMetadataDocument(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead+" "+ScopeHistoryRead) + if f.view.Client.Name != "Claude" || !f.view.Client.Verified || f.view.Client.Host != "example.com" || + f.view.Redirect.Host != "example.com" || f.view.Status != statusPending || len(f.view.Code) != codeLen { + t.Fatalf("what the app sees: %+v", f.view) + } + // The box grants less than asked: the token says so. + tk := g.approve(f, cimdID, ScopeLiveRead, ScopePlanRead) + if tk.Scope != ScopeLiveRead+" "+ScopePlanRead || tk.Expires <= 0 || tk.Expires > int(AccessTTL.Seconds()) { + t.Fatalf("token: %+v", tk) + } + grant, aerr := g.bearer(tk.Access) + if aerr != nil { + t.Fatalf("a fresh token was refused: %+v", aerr) + } + key := f.agentKey(t) + if grant.Subject.Home != home || grant.Subject.Agent != cloudwire.DeviceID(key) || !bytes.Equal(grant.Subject.Key, key) || + grant.Subject.Kind != SubjectAgent || grant.ClientID != cimdID || !grant.Has(ScopePlanRead) || grant.Has(ScopeHistoryRead) { + t.Fatalf("grant: %+v", grant) + } + + // Refresh rotates: the new pair works, the old refresh token is spent, + // and using it again revokes the whole family. + status, tk2 := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, "resource": {Resource}}) + if status != http.StatusOK || tk2.Refresh == tk.Refresh { + t.Fatalf("refresh: %d %+v", status, tk2) + } + if _, aerr := g.bearer(tk2.Access); aerr != nil { + t.Fatal("the refreshed token was refused") + } + status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}) + if status != http.StatusBadRequest { + t.Fatalf("a spent refresh token: %d", status) + } + if _, aerr := g.bearer(tk2.Access); aerr == nil { + t.Fatal("a reused refresh token left its family alive") + } + status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk2.Refresh}, "client_id": {cimdID}}) + if status != http.StatusBadRequest { + t.Fatalf("the family's newest refresh token: %d", status) + } +} + +// A code works once; presenting it again revokes what it got. +func TestACodeWorksOnce(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}} + status, tk := g.token(form) + if status != http.StatusOK { + t.Fatalf("first use: %d", status) + } + if status, _ := g.token(form); status != http.StatusBadRequest { + t.Fatalf("second use: %d", status) + } + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("tokens from a reused code still work") + } +} + +// PKCE: the verifier must match, and nothing but S256 starts a flow. +func TestPKCEIsRequired(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(cimdID, cimdBack, "") + for _, change := range []func(url.Values){ + func(q url.Values) { q.Del("code_challenge") }, + func(q url.Values) { q.Set("code_challenge_method", "plain") }, + func(q url.Values) { q.Del("code_challenge_method") }, + func(q url.Values) { q.Set("code_challenge", "short") }, + } { + qq := url.Values{} + for k, v := range q { + qq[k] = slices.Clone(v) + } + change(qq) + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + qq.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_request" || loc.Query().Get("iss") != Issuer { + t.Fatalf("without S256 PKCE: %d %s", resp.StatusCode, loc) + } + } + + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + wrong, _ := pkce() + if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}}); status != http.StatusBadRequest { + t.Fatalf("a wrong verifier: %d", status) + } +} + +// Before client and redirect URI check out, nothing redirects; after, every +// error goes back with iss. +func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { + g := newRig(t) + for _, q := range []url.Values{ + {"client_id": {"ftwc_nobody"}, "redirect_uri": {"https://evil.example/cb"}}, + {"client_id": {cimdID}, "redirect_uri": {"https://evil.example/cb"}}, + {"client_id": {cimdID}, "redirect_uri": {"https://example.com/callback/../x"}}, + {"client_id": {"https://example.com/nothing-here.json"}, "redirect_uri": {cimdBack}}, + } { + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" { + t.Fatalf("%v: %d %q", q, resp.StatusCode, resp.Header.Get("Location")) + } + } + q, _ := g.authorizeParams(cimdID, cimdBack, "") + q.Set("resource", "https://cloud.ftw.energy/other") + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if loc.Query().Get("error") != "invalid_target" || loc.Query().Get("iss") != Issuer || loc.Host != "example.com" { + t.Fatalf("another resource: %s", loc) + } + q.Set("resource", Resource) + q.Set("scope", "ftw.live.read admin") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ = url.Parse(resp.Header.Get("Location")) + if loc.Query().Get("error") != "invalid_scope" { + t.Fatalf("an unknown scope: %s", loc) + } + // A loopback redirect may take any port. + q2, _ := g.authorizeParams(cimdID, "http://127.0.0.1:53121/cb", "") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q2.Encode()) + if resp.StatusCode != http.StatusOK { + t.Fatalf("loopback with a port: %d", resp.StatusCode) + } +} + +// Dynamic registration, for clients without a metadata document: public +// and confidential. +func TestTheFullFlowWithDynamicRegistration(t *testing.T) { + g := newRig(t) + register := func(body string) (int, map[string]any) { + resp, err := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out + } + for _, bad := range []string{ + `{"redirect_uris":["myapp://cb"]}`, + `{"redirect_uris":["http://evil.example/cb"]}`, + `{"redirect_uris":[]}`, + `{"redirect_uris":["https://chatgpt.com/cb"],"token_endpoint_auth_method":"private_key_jwt"}`, + `{"redirect_uris":["https://chatgpt.com/cb"],"grant_types":["client_credentials"]}`, + `not json`, + } { + if status, out := register(bad); status != http.StatusBadRequest { + t.Fatalf("%s: %d %v", bad, status, out) + } + } + + status, pub := register(`{"client_name":"Grok‮","redirect_uris":["https://grok.example/cb"]}`) + if status != http.StatusCreated || pub["client_secret"] != nil || pub["client_name"] != "Grok" { + t.Fatalf("public registration: %d %v", status, pub) + } + id := pub["client_id"].(string) + f := g.start(id, "https://grok.example/cb", "") + if f.view.Client.Verified || f.view.Client.Name != "Grok" { + t.Fatalf("a registered client's name is not checked: %+v", f.view.Client) + } + ch, stop := g.events(f, true) + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + stop() + if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { + t.Fatalf("public client token: %d %+v", status, tk) + } + + status, conf := register(`{"client_name":"ChatGPT","redirect_uris":["https://chatgpt.com/connector_platform_oauth_redirect"],"token_endpoint_auth_method":"client_secret_post","grant_types":["authorization_code","refresh_token"]}`) + if status != http.StatusCreated || conf["client_secret"] == nil { + t.Fatalf("confidential registration: %d %v", status, conf) + } + cid, secret := conf["client_id"].(string), conf["client_secret"].(string) + f = g.start(cid, "https://chatgpt.com/connector_platform_oauth_redirect", "") + ch, stop = g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + code = redirectOf(t, next(t, ch)).Query().Get("code") + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} + if status, _ := g.token(form); status != http.StatusUnauthorized { + t.Fatalf("no secret: %d", status) + } + form.Set("client_secret", "ftws_wrong") + if status, _ := g.token(form); status != http.StatusUnauthorized { + t.Fatalf("a wrong secret: %d", status) + } + form.Set("client_secret", secret) + if status, tk := g.token(form); status != http.StatusOK { + t.Fatalf("the right secret: %d %+v", status, tk) + } +} + +// The page finishes only when the box's roster lists the very key this +// request made, as a device that may open a session; and only the browser +// that started it gets the code. +func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + browser, stop := g.events(f, true) + defer stop() + stranger, stop2 := g.events(f, false) + defer stop2() + if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { + t.Fatal("not pending at first") + } + // Another key, the right key without a tunnel, the right key under + // another id: none of them approves. + other := bytes.Repeat([]byte{1}, 32) + key := f.agentKey(t) + noTun := agentDevice(key, ScopeLiveRead) + noTun.Tun = false + wrongID := agentDevice(key, ScopeLiveRead) + wrongID.ID = "someone" + g.roster(home, agentDevice(other, ScopeLiveRead), noTun, wrongID) + select { + case ev := <-browser: + t.Fatalf("event %q before the roster listed the agent", ev.name) + case <-time.After(100 * time.Millisecond): + } + if g.details(f.rid).Status != statusPending { + t.Fatal("approved without the agent in the roster") + } + + g.roster(home, agentDevice(key, ScopeLiveRead)) + if ev := next(t, stranger); ev.name != "approved" || strings.Contains(ev.data, "code") { + t.Fatalf("a stranger's stream got %q %s", ev.name, ev.data) + } + if ev := next(t, browser); ev.name != "approved" || redirectOf(t, ev).Query().Get("code") == "" { + t.Fatal("the browser got no code") + } +} + +// The owner says no, or the request runs out: the page goes back with +// access_denied, and the key is forgotten. +func TestDeniedAndExpiredRequestsEnd(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + resp, _ := http.Post(g.srv.URL+"/oauth/requests/"+f.rid+"/deny", "", nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("deny: %d", resp.StatusCode) + } + ev := next(t, ch) + if u := redirectOf(t, ev); ev.name != "denied" || u.Query().Get("error") != "access_denied" || u.Query().Get("iss") != Issuer { + t.Fatalf("after deny: %s %s", ev.name, ev.data) + } + // A roster that lists it now is too late. + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + if v := g.details(f.rid); v.Status != statusDenied { + t.Fatalf("status after deny and roster: %s", v.Status) + } + + f2 := g.start(cimdID, cimdBack, "") + g.advance(RequestTTL + time.Second) + ch2, stop2 := g.events(f2, true) + defer stop2() + if ev := next(t, ch2); ev.name != "expired" || redirectOf(t, ev).Query().Get("error") != "access_denied" { + t.Fatalf("expired: %s %s", ev.name, ev.data) + } + g.roster(home, agentDevice(f2.agentKey(t), ScopeLiveRead)) + if v := g.details(f2.rid); v.Status != "expired" { + t.Fatalf("an expired request was approved: %s", v.Status) + } +} + +// The owner revokes at the box: the next roster leaves the agent out, its +// tokens stop working at once, its refresh token too, and the keys part +// forgets its key. +func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { + g := newRig(t) + var revoked []string + var mu sync.Mutex + g.s.OnRevoke = func(h, agent string) { + mu.Lock() + revoked = append(revoked, agent) + mu.Unlock() + } + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + grant, aerr := g.bearer(tk.Access) + if aerr != nil { + t.Fatal(aerr) + } + // The keys part holds the key: a session opens. + if _, _, err := g.keys.Open(context.Background(), grant.Subject.Kid, bytes.Repeat([]byte{2}, 32)); err != nil { + t.Fatalf("an approved key does not open: %v", err) + } + + g.roster(home) // the agent is gone + if _, aerr := g.bearer(tk.Access); aerr == nil || aerr.Status != http.StatusUnauthorized || aerr.Code != "invalid_token" { + t.Fatalf("after revoke: %+v", aerr) + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("refresh after revoke: %d", status) + } + if _, _, err := g.keys.Open(context.Background(), grant.Subject.Kid, bytes.Repeat([]byte{2}, 32)); !errors.Is(err, keys.ErrUnknownKey) { + t.Fatalf("the key outlived its agent: %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(revoked) != 1 || revoked[0] != grant.Subject.Agent { + t.Fatalf("revoked %v", revoked) + } +} + +// An agent whose time the box set runs out fails, and is swept. +func TestAnExpiredAgentFails(t *testing.T) { + g := newRig(t) + end := g.now().Add(2 * time.Hour) + g.s.AgentExpiry = func(cloudwire.Device) time.Time { return end } + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + if _, aerr := g.bearer(tk.Access); aerr != nil { + t.Fatal(aerr) + } + g.advance(2*time.Hour + time.Second) + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("an expired agent's token works") + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("refresh after the agent ended: %d", status) + } + g.s.sweep(context.Background()) + if _, ok, _ := g.s.st.agent(home, cloudwire.DeviceID(f.agentKey(t))); ok { + t.Fatal("the sweep left an expired agent") + } +} + +// A token for another resource is refused at /mcp, whatever else is right +// about it; and so is a token that is not an access token. +func TestWrongAudienceIsRefused(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + agent := cloudwire.DeviceID(f.agentKey(t)) + other := "ftwat_" + randomToken(32) + if err := g.s.st.putToken(other, tokenRow{Kind: kindAccess, Family: []byte("f"), ClientID: cimdID, Home: home, + Agent: agent, Scope: ScopeLiveRead, Resource: "https://cloud.ftw.energy/other", Expires: g.now().Add(time.Minute)}); err != nil { + t.Fatal(err) + } + if _, aerr := g.bearer(other); aerr == nil || aerr.Status != http.StatusUnauthorized { + t.Fatal("a token for another resource was accepted") + } + if _, aerr := g.bearer(tk.Refresh); aerr == nil { + t.Fatal("a refresh token was accepted as an access token") + } + status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, + "resource": {"https://elsewhere.example/mcp"}}) + if status != http.StatusBadRequest { + t.Fatalf("refresh for another resource: %d", status) + } + // A token in the query string is not read. + r := httptest.NewRequest(http.MethodPost, "/mcp?access_token="+tk.Access, nil) + if _, aerr := g.s.Authenticate(r); aerr == nil { + t.Fatal("a token in the query string was read") + } +} + +// The challenges: 401 names the resource metadata and the scopes; 403 asks +// for the scope the operation needs. +func TestChallenges(t *testing.T) { + w := httptest.NewRecorder() + Challenge(w, &AuthError{Status: http.StatusUnauthorized, Scope: ScopeLiveRead}) + h := w.Header().Get("WWW-Authenticate") + if w.Code != 401 || !strings.HasPrefix(h, "Bearer ") || !strings.Contains(h, `resource_metadata="`+ResourceMetadataURL+`"`) || + !strings.Contains(h, `scope="ftw.live.read"`) { + t.Fatalf("401: %d %s", w.Code, h) + } + w = httptest.NewRecorder() + Challenge(w, InsufficientScope(ScopeDispatchHold)) + h = w.Header().Get("WWW-Authenticate") + if w.Code != 403 || !strings.Contains(h, `error="insufficient_scope"`) || !strings.Contains(h, `scope="ftw.dispatch.hold"`) || + !strings.Contains(h, "resource_metadata=") { + t.Fatalf("403: %d %s", w.Code, h) + } +} + +// The box's page finds a request by its code, as an owner types it. +func TestTheBoxPageFindsARequestByItsCode(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + typed := strings.ToLower(f.view.Code[:3]) + " - " + f.view.Code[3:] + var v requestView + getJSON(t, g.srv.URL+"/oauth/requests?code="+url.QueryEscape(typed), &v) + if v.Agent.Key != f.view.Agent.Key || v.Request != "" { + t.Fatalf("by code: %+v", v) + } + resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("an unknown code: %d", resp.StatusCode) + } + // Lookups are rate limited for everyone together. + limited := false + for range 60 { + resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") + if resp.StatusCode == http.StatusTooManyRequests { + limited = true + break + } + } + if !limited { + t.Fatal("code lookups are not limited") + } +} + +// The page says who is asking, where the browser goes back, and that a +// loopback redirect is a program on this computer; and it carries a QR and +// the app link, under a strict content security policy. +func TestTheConsentPage(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(cimdID, "http://127.0.0.1:8123/cb", ScopeLiveRead+" "+ScopeDispatchHold) + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + body, _ := io.ReadAll(resp.Body) + page := string(body) + for _, want := range []string{"Let Claude into your home?", "Identified by example.com", "127.0.0.1:8123", + "a program on this computer", "alert(1)","redirect_uris":["https://example.com/cb"]}` + g.docsMu.Unlock() + q, _ = g.authorizeParams("https://example.com/oauth/evil.json", "https://example.com/cb", "") + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + body, _ = io.ReadAll(resp.Body) + if bytes.Contains(body, []byte(" +`)) + +var errorTmpl = template.Must(template.New("error").Parse(` + +Connect an assistant · FTW Cloud +

{{.Title}}

{{.Text}}

`)) diff --git a/internal/oauth/store.go b/internal/oauth/store.go new file mode 100644 index 0000000..92790b5 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,512 @@ +package oauth + +import ( + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// The authorization server's disk state, in a SQLite file of its own: +// clients that registered dynamically, consents in progress, the agents +// owners approved, and the hashes of tokens. No token, code or request id is +// stored as itself: each is a random secret held by its bearer, and the file +// keeps its SHA-256. Deleted rows are overwritten (secure_delete). + +type store struct { + db *sql.DB +} + +func openStore(path string) (*store, error) { + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=secure_delete(ON)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + _, err = db.Exec(` +CREATE TABLE IF NOT EXISTS clients ( + client_id TEXT PRIMARY KEY, + secret BLOB, + auth_method TEXT NOT NULL, + name TEXT NOT NULL, + uri TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + created_ms INTEGER NOT NULL, + used_ms INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS requests ( + rid BLOB PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + browser BLOB NOT NULL, + client_id TEXT NOT NULL, + client_name TEXT NOT NULL, + client_host TEXT NOT NULL, + verified INTEGER NOT NULL, + redirect_uri TEXT NOT NULL, + state TEXT NOT NULL, + challenge TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + kid TEXT NOT NULL, + agent_key BLOB NOT NULL UNIQUE, + created_ms INTEGER NOT NULL, + expires_ms INTEGER NOT NULL, + status TEXT NOT NULL, + home TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT '', + granted TEXT NOT NULL DEFAULT '', + shard INTEGER NOT NULL DEFAULT 0, + authz BLOB UNIQUE, + authz_ms INTEGER NOT NULL DEFAULT 0, + authz_used INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS agents ( + home TEXT NOT NULL, + agent TEXT NOT NULL, + agent_key BLOB NOT NULL, + kid TEXT NOT NULL, + client_id TEXT NOT NULL, + scope TEXT NOT NULL, + shard INTEGER NOT NULL, + expires_ms INTEGER NOT NULL, + created_ms INTEGER NOT NULL, + PRIMARY KEY (home, agent) +); +CREATE TABLE IF NOT EXISTS tokens ( + hash BLOB PRIMARY KEY, + kind TEXT NOT NULL, + family BLOB NOT NULL, + client_id TEXT NOT NULL, + home TEXT NOT NULL, + agent TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + expires_ms INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (home, agent) REFERENCES agents(home, agent) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS tokens_family ON tokens(family); +CREATE INDEX IF NOT EXISTS tokens_agent ON tokens(home, agent);`) + if err != nil { + _ = db.Close() + return nil, err + } + return &store{db: db}, nil +} + +func (s *store) close() error { return s.db.Close() } + +func hash(secret string) []byte { + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +// -------------------------------------------------------------------------- +// Clients registered dynamically +// -------------------------------------------------------------------------- + +type dcrClient struct { + ID string + Secret []byte // hash, nil for a public client + AuthMethod string + Name string + URI string + RedirectURIs []string +} + +func (s *store) putClient(c dcrClient, now time.Time) error { + uris, _ := json.Marshal(c.RedirectURIs) + _, err := s.db.Exec(`INSERT INTO clients (client_id, secret, auth_method, name, uri, redirect_uris, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, c.Secret, c.AuthMethod, c.Name, c.URI, string(uris), now.UnixMilli()) + return err +} + +func (s *store) client(id string) (dcrClient, bool, error) { + var c dcrClient + var uris string + err := s.db.QueryRow(`SELECT client_id, secret, auth_method, name, uri, redirect_uris FROM clients WHERE client_id = ?`, id). + Scan(&c.ID, &c.Secret, &c.AuthMethod, &c.Name, &c.URI, &uris) + if errors.Is(err, sql.ErrNoRows) { + return c, false, nil + } + if err != nil { + return c, false, err + } + err = json.Unmarshal([]byte(uris), &c.RedirectURIs) + return c, err == nil, err +} + +func (s *store) clientUsed(id string, now time.Time) { + _, _ = s.db.Exec(`UPDATE clients SET used_ms = ? WHERE client_id = ?`, now.UnixMilli(), id) +} + +func (s *store) countClients() (n int) { + _ = s.db.QueryRow(`SELECT count(*) FROM clients`).Scan(&n) + return n +} + +// -------------------------------------------------------------------------- +// Consents in progress +// -------------------------------------------------------------------------- + +// Request statuses. +const ( + statusPending = "pending" + statusApproved = "approved" + statusDenied = "denied" +) + +// consent is one authorization request, from the authorize page to the +// token it turns into. +type consent struct { + // Code is the short code the page shows, for an owner to type on the + // box's page. It finds the request and proves nothing. + Code string + ClientID string + ClientName string + ClientHost string + Verified bool // the client's name came from a document at its own https URL + RedirectURI string + State string + Challenge string + Scope string + Resource string + Kid string + AgentKey []byte + Created time.Time + Expires time.Time + Status string + Home string + Agent string + Granted string + Shard int +} + +const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, + kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard` + +func scanConsent(row interface{ Scan(...any) error }) (consent, error) { + var c consent + var verified int + var created, expires int64 + err := row.Scan(&c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + return c, err +} + +func (s *store) putConsent(rid, browser string, c consent) error { + verified := 0 + if c.Verified { + verified = 1 + } + _, err := s.db.Exec(`INSERT INTO requests (rid, browser, `+consentCols+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + hash(rid), hash(browser), c.Code, c.ClientID, c.ClientName, c.ClientHost, verified, c.RedirectURI, c.State, + c.Challenge, c.Scope, c.Resource, c.Kid, c.AgentKey, c.Created.UnixMilli(), c.Expires.UnixMilli(), c.Status, + c.Home, c.Agent, c.Granted, c.Shard) + return err +} + +func (s *store) consentByRID(rid string) (consent, bool, error) { + return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE rid = ?`, hash(rid)) +} + +func (s *store) consentByCode(code string) (consent, bool, error) { + return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE code = ?`, code) +} + +func (s *store) oneConsent(q string, arg any) (consent, bool, error) { + c, err := scanConsent(s.db.QueryRow(q, arg)) + if errors.Is(err, sql.ErrNoRows) { + return c, false, nil + } + return c, err == nil, err +} + +// browserMatches reports whether a browser's cookie secret is the one the +// request was started with. +func (s *store) browserMatches(rid, browser string) bool { + var n int + _ = s.db.QueryRow(`SELECT count(*) FROM requests WHERE rid = ? AND browser = ?`, hash(rid), hash(browser)).Scan(&n) + return n == 1 +} + +// pendingByKey finds the pending consent for an agent key. +func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c consent, ok bool, err error) { + row := s.db.QueryRow(`SELECT rid, `+consentCols+` FROM requests WHERE agent_key = ? AND status = ? AND expires_ms > ?`, + key, statusPending, now.UnixMilli()) + var verified int + var created, expires int64 + err = row.Scan(&ridHash, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + if errors.Is(err, sql.ErrNoRows) { + return nil, c, false, nil + } + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + return ridHash, c, err == nil, err +} + +// approve marks a consent approved for an agent of a home, and records the +// agent, in one transaction. +func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // The page has as long again to hand its browser the code. + res, err := tx.Exec(`UPDATE requests SET status = ?, home = ?, agent = ?, granted = ?, shard = ?, expires_ms = ? + WHERE rid = ? AND status = ?`, + statusApproved, c.Home, c.Agent, c.Granted, c.Shard, now.Add(RequestTTL).UnixMilli(), ridHash, statusPending) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return errGone + } + if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, shard, expires_ms, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (home, agent) DO UPDATE SET agent_key = excluded.agent_key, kid = excluded.kid, + client_id = excluded.client_id, scope = excluded.scope, shard = excluded.shard, expires_ms = excluded.expires_ms`, + c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, c.Granted, c.Shard, agentExpires.UnixMilli(), now.UnixMilli()); err != nil { + return err + } + return tx.Commit() +} + +func (s *store) deny(rid string) (consent, bool, error) { + c, ok, err := s.consentByRID(rid) + if err != nil || !ok || c.Status != statusPending { + return c, false, err + } + res, err := s.db.Exec(`UPDATE requests SET status = ? WHERE rid = ? AND status = ?`, statusDenied, hash(rid), statusPending) + if err != nil { + return c, false, err + } + n, _ := res.RowsAffected() + return c, n == 1, nil +} + +// issueCode gives an approved consent a new authorization code, in place of +// any earlier one that was not used. +func (s *store) issueCode(rid, code string, expires time.Time) error { + res, err := s.db.Exec(`UPDATE requests SET authz = ?, authz_ms = ? WHERE rid = ? AND status = ? AND authz_used = 0`, + hash(code), expires.UnixMilli(), hash(rid), statusApproved) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return errGone + } + return nil +} + +// redeem spends an authorization code once. A code presented a second time +// is reported as reused, and the caller revokes what the first use got. +func (s *store) redeem(code string, now time.Time) (c consent, ridHash []byte, reused bool, err error) { + tx, err := s.db.Begin() + if err != nil { + return c, nil, false, err + } + defer tx.Rollback() + var used int + var ms int64 + row := tx.QueryRow(`SELECT rid, authz_used, authz_ms, `+consentCols+` FROM requests WHERE authz = ?`, hash(code)) + var verified int + var created, expires int64 + err = row.Scan(&ridHash, &used, &ms, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, + &c.State, &c.Challenge, &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, + &c.Agent, &c.Granted, &c.Shard) + if errors.Is(err, sql.ErrNoRows) { + return c, nil, false, errGone + } + if err != nil { + return c, nil, false, err + } + c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) + if used != 0 { + return c, ridHash, true, errGone + } + if now.UnixMilli() >= ms { + return c, ridHash, false, errGone + } + if _, err := tx.Exec(`UPDATE requests SET authz_used = 1 WHERE authz = ?`, hash(code)); err != nil { + return c, nil, false, err + } + return c, ridHash, false, tx.Commit() +} + +var errGone = errors.New("oauth: gone") + +// -------------------------------------------------------------------------- +// Agents and tokens +// -------------------------------------------------------------------------- + +// agentRow is an agent an owner approved, as the authorization server knows +// it. +type agentRow struct { + Home string + Agent string + Key []byte + Kid string + ClientID string + Scope string + Shard int + Expires time.Time // zero when the box set no end +} + +func (s *store) agent(home, agent string) (agentRow, bool, error) { + var a agentRow + var ms int64 + err := s.db.QueryRow(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ? AND agent = ?`, + home, agent).Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms) + if errors.Is(err, sql.ErrNoRows) { + return a, false, nil + } + if ms > 0 { + a.Expires = time.UnixMilli(ms) + } + return a, err == nil, err +} + +func (s *store) agentsOf(home string) ([]agentRow, error) { + rows, err := s.db.Query(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ?`, home) + if err != nil { + return nil, err + } + defer rows.Close() + var out []agentRow + for rows.Next() { + var a agentRow + var ms int64 + if err := rows.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms); err != nil { + return nil, err + } + if ms > 0 { + a.Expires = time.UnixMilli(ms) + } + out = append(out, a) + } + return out, rows.Err() +} + +// dropAgent deletes an agent and, by the foreign key, every token it holds. +func (s *store) dropAgent(home, agent string) error { + _, err := s.db.Exec(`DELETE FROM agents WHERE home = ? AND agent = ?`, home, agent) + return err +} + +func (s *store) expiredAgents(now time.Time) ([]agentRow, error) { + rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms > 0 AND expires_ms <= ?`, now.UnixMilli()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []agentRow + for rows.Next() { + var a agentRow + if err := rows.Scan(&a.Home, &a.Agent, &a.Kid); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// Token kinds. +const ( + kindAccess = "access" + kindRefresh = "refresh" +) + +type tokenRow struct { + Kind string + Family []byte + ClientID string + Home string + Agent string + Scope string + Resource string + Expires time.Time + Used bool +} + +func (s *store) putToken(tok string, t tokenRow) error { + _, err := s.db.Exec(`INSERT INTO tokens (hash, kind, family, client_id, home, agent, scope, resource, expires_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash(tok), t.Kind, t.Family, t.ClientID, t.Home, t.Agent, t.Scope, + t.Resource, t.Expires.UnixMilli()) + return err +} + +func (s *store) token(tok string) (tokenRow, bool, error) { + var t tokenRow + var ms int64 + var used int + err := s.db.QueryRow(`SELECT kind, family, client_id, home, agent, scope, resource, expires_ms, used FROM tokens WHERE hash = ?`, + hash(tok)).Scan(&t.Kind, &t.Family, &t.ClientID, &t.Home, &t.Agent, &t.Scope, &t.Resource, &ms, &used) + if errors.Is(err, sql.ErrNoRows) { + return t, false, nil + } + t.Expires, t.Used = time.UnixMilli(ms), used == 1 + return t, err == nil, err +} + +// rotate spends a refresh token. False means it was spent before: a second +// use of one refresh token is theft or a bug, and the caller revokes its +// family. +func (s *store) rotate(tok string) (bool, error) { + res, err := s.db.Exec(`UPDATE tokens SET used = 1 WHERE hash = ? AND kind = ? AND used = 0`, hash(tok), kindRefresh) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (s *store) revokeFamily(family []byte) error { + _, err := s.db.Exec(`DELETE FROM tokens WHERE family = ?`, family) + return err +} + +func (s *store) revokeToken(tok string) error { + t, ok, err := s.token(tok) + if err != nil || !ok { + return err + } + if t.Kind == kindRefresh { + return s.revokeFamily(t.Family) + } + _, err = s.db.Exec(`DELETE FROM tokens WHERE hash = ?`, hash(tok)) + return err +} + +// sweep deletes what has run out: tokens, and consents a while after they +// ended. It returns the key ids of consents that ended without approval, +// for the keys part to forget. +func (s *store) sweep(now time.Time) ([]string, error) { + if _, err := s.db.Exec(`DELETE FROM tokens WHERE expires_ms <= ?`, now.UnixMilli()); err != nil { + return nil, err + } + rows, err := s.db.Query(`SELECT kid FROM requests WHERE status != ? AND expires_ms <= ?`, statusApproved, now.UnixMilli()) + if err != nil { + return nil, err + } + var kids []string + for rows.Next() { + var kid string + if rows.Scan(&kid) == nil { + kids = append(kids, kid) + } + } + _ = rows.Close() + _, err = s.db.Exec(`DELETE FROM requests WHERE expires_ms <= ?`, now.UnixMilli()) + return kids, err +} + +// splitScope reads a space-separated scope parameter. +func splitScope(s string) []string { return strings.Fields(s) } diff --git a/internal/oauth/token.go b/internal/oauth/token.go new file mode 100644 index 0000000..582d8b8 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,283 @@ +package oauth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "slices" + "strings" + "time" +) + +var verifierRE = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) + +// Token prefixes, so a leaked token is recognisable for what it is. +const ( + accessPrefix = "ftwat_" + refreshPrefix = "ftwrt_" +) + +func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := r.ParseForm(); err != nil { + tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") + return + } + for k, v := range r.PostForm { + if len(v) > 1 { + tokenError(w, http.StatusBadRequest, "invalid_request", k+" appears more than once") + return + } + } + c, ok := s.authenticateClient(r) + if !ok { + if _, _, basic := r.BasicAuth(); basic { + w.Header().Set("WWW-Authenticate", `Basic realm="FTW Cloud"`) + } + tokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + if res := r.PostForm.Get("resource"); res != "" && !sameResource(res) { + tokenError(w, http.StatusBadRequest, "invalid_target", "this server issues tokens only for "+Resource) + return + } + switch r.PostForm.Get("grant_type") { + case grantCode: + s.exchangeCode(w, r, c) + case grantRefresh: + s.refresh(w, r, c) + default: + tokenError(w, http.StatusBadRequest, "unsupported_grant_type", "authorization_code or refresh_token") + } +} + +func (s *Server) exchangeCode(w http.ResponseWriter, r *http.Request, cl client) { + f := r.PostForm + verifier := f.Get("code_verifier") + if !verifierRE.MatchString(verifier) { + tokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier is required") + return + } + c, ridHash, reused, err := s.st.redeem(f.Get("code"), s.now()) + if reused { + // The code was used before: whoever holds the first tokens may not + // be the client. They go. + _ = s.st.revokeFamily(ridHash) + } + if err != nil { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the code is not valid") + return + } + sum := sha256.Sum256([]byte(verifier)) + if c.ClientID != cl.ID || subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(c.Challenge)) != 1 || + (f.Get("redirect_uri") != "" && f.Get("redirect_uri") != c.RedirectURI) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the code is not valid") + return + } + a, ok, err := s.st.agent(c.Home, c.Agent) + if err != nil || !ok || s.agentGone(a) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the owner removed this assistant") + return + } + s.issue(w, ridHash, cl.ID, a, c.Granted, s.now().Add(RefreshTTL)) +} + +func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { + tok := r.PostForm.Get("refresh_token") + t, ok, err := s.st.token(tok) + if err != nil || !ok || t.Kind != kindRefresh || t.ClientID != cl.ID || !s.now().Before(t.Expires) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is not valid") + return + } + fresh, err := s.st.rotate(tok) + if err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not refresh") + return + } + if !fresh { + // A refresh token used twice: one of the two holders stole it. + _ = s.st.revokeFamily(t.Family) + tokenError(w, http.StatusBadRequest, "invalid_grant", "the refresh token was used before") + return + } + scope := t.Scope + if asked := splitScope(r.PostForm.Get("scope")); len(asked) > 0 { + have := splitScope(t.Scope) + for _, sc := range asked { + if !slices.Contains(have, sc) { + tokenError(w, http.StatusBadRequest, "invalid_scope", "a refresh cannot widen the scope") + return + } + } + scope = strings.Join(asked, " ") + } + a, ok, err := s.st.agent(t.Home, t.Agent) + if err != nil || !ok || s.agentGone(a) { + tokenError(w, http.StatusBadRequest, "invalid_grant", "the owner removed this assistant") + return + } + s.issue(w, t.Family, cl.ID, a, scope, t.Expires) +} + +// issue writes a new access token and a new refresh token of one family. +// The refresh token ends no later than refreshEnd or the agent. +func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a agentRow, scope string, refreshEnd time.Time) { + now := s.now() + if !a.Expires.IsZero() && a.Expires.Before(refreshEnd) { + refreshEnd = a.Expires + } + accessEnd := now.Add(AccessTTL) + if accessEnd.After(refreshEnd) { + accessEnd = refreshEnd + } + access, refresh := accessPrefix+randomToken(32), refreshPrefix+randomToken(32) + base := tokenRow{Family: family, ClientID: clientID, Home: a.Home, Agent: a.Agent, Scope: scope, Resource: Resource} + at, rt := base, base + at.Kind, at.Expires = kindAccess, accessEnd + rt.Kind, rt.Expires = kindRefresh, refreshEnd + if err := s.st.putToken(access, at); err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not issue a token") + return + } + if err := s.st.putToken(refresh, rt); err != nil { + tokenError(w, http.StatusInternalServerError, "server_error", "could not issue a token") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": int(accessEnd.Sub(now).Seconds()), + "refresh_token": refresh, + "scope": scope, + }, false) +} + +func (s *Server) agentGone(a agentRow) bool { + return !a.Expires.IsZero() && !s.now().Before(a.Expires) +} + +func tokenError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}, false) +} + +// serveRevoke is RFC 7009: revoking a refresh token revokes its family. It +// answers 200 whether or not the token was known. +func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := r.ParseForm(); err != nil { + tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") + return + } + c, ok := s.authenticateClient(r) + if !ok { + tokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + tok := r.PostForm.Get("token") + if t, ok, err := s.st.token(tok); err == nil && ok && t.ClientID == c.ID { + _ = s.st.revokeToken(tok) + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) +} + +// -------------------------------------------------------------------------- +// The resource server's side: checking a bearer token at /mcp +// -------------------------------------------------------------------------- + +// Subject is who a token acts as. Today that is always an agent of one home. +// A later Sourceful staff principal fits here with Kind "staff", a staff id +// and no home of its own: it reaches only homes with an active support +// grant, each through that grant's agent. +type Subject struct { + Kind string // SubjectAgent + Shard int + Home string + // Agent is the roster device id; Key the agent's X25519 public key, + // as the roster lists it; Kid its key in the keys part. + Agent string + Key []byte + Kid string +} + +// SubjectAgent is a personal agent an owner approved. +const SubjectAgent = "agent" + +// Grant is what one valid access token allows. +type Grant struct { + Subject Subject + ClientID string + Scopes []string + Expires time.Time +} + +// Has reports whether the grant carries a scope. +func (g *Grant) Has(scope string) bool { return slices.Contains(g.Scopes, scope) } + +// AuthError is why a request carries no usable token. +type AuthError struct { + Status int // 401 or 403 + Code string // "", invalid_token, insufficient_scope + Desc string + Scope string // the scopes the operation needs +} + +// Authenticate checks a request's bearer token for /mcp: an access token +// this server issued, not expired, for this resource, whose agent the owner +// has not removed. Tokens in the query string are not read. +func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { + h := r.Header.Get("Authorization") + scheme, tok, ok := strings.Cut(h, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || tok == "" { + return nil, &AuthError{Status: http.StatusUnauthorized, Scope: strings.Join(ReadScopes, " ")} + } + invalid := &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is not valid"} + t, ok, err := s.st.token(strings.TrimSpace(tok)) + if err != nil || !ok || t.Kind != kindAccess || !s.now().Before(t.Expires) { + return nil, invalid + } + if t.Resource != Resource { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is for another resource"} + } + a, ok, err := s.st.agent(t.Home, t.Agent) + if err != nil || !ok { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the owner removed this assistant"} + } + if s.agentGone(a) { + return nil, &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "this assistant's access ran out"} + } + return &Grant{ + Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, + ClientID: t.ClientID, Scopes: splitScope(t.Scope), Expires: t.Expires, + }, nil +} + +// Challenge writes a 401 or 403 with its WWW-Authenticate header, and a +// JSON body for clients that show one. +func Challenge(w http.ResponseWriter, e *AuthError) { + parts := []string{fmt.Sprintf("resource_metadata=%q", ResourceMetadataURL)} + if e.Code != "" { + parts = append(parts, fmt.Sprintf("error=%q", e.Code)) + } + if e.Desc != "" { + parts = append(parts, fmt.Sprintf("error_description=%q", e.Desc)) + } + if e.Scope != "" { + parts = append(parts, fmt.Sprintf("scope=%q", e.Scope)) + } + w.Header().Set("WWW-Authenticate", "Bearer "+strings.Join(parts, ", ")) + body := map[string]string{"error": e.Code, "error_description": e.Desc} + if e.Code == "" { + body = map[string]string{"error": "unauthorized", "error_description": "a bearer token from " + Issuer + " is required"} + } + writeJSON(w, e.Status, body, false) +} + +// InsufficientScope is the 403 that asks the client to step up. +func InsufficientScope(scopes ...string) *AuthError { + return &AuthError{Status: http.StatusForbidden, Code: "insufficient_scope", + Desc: "this needs more rights; ask the owner to approve them", Scope: strings.Join(scopes, " ")} +} diff --git a/internal/server/oauth_routes_test.go b/internal/server/oauth_routes_test.go new file mode 100644 index 0000000..fcf5d4b --- /dev/null +++ b/internal/server/oauth_routes_test.go @@ -0,0 +1,53 @@ +package server + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw-cloud/internal/oauth" +) + +// The authorization server's routes read no home either, whatever they are +// asked: they know agents and tokens, never readings. +func TestOAuthRoutesReadNoHome(t *testing.T) { + s, home := newServer(t) + sealer, _ := keys.FileSealerFromKey(bytes.Repeat([]byte{3}, 32)) + ks, err := keys.NewServer(t.TempDir(), sealer, nil) + if err != nil { + t.Fatal(err) + } + auth, err := oauth.New(filepath.Join(t.TempDir(), "oauth.db"), ks.Local(), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = auth.Close() }) + s.OAuth = auth + h := s.Handler() + paths := append(append([]string{}, oauth.Routes...), "/oauth/requests/"+home, "/oauth/requests?code="+home[:6], + "/oauth/authorize?client_id="+home, "/.well-known/oauth-protected-resource/"+home) + for _, p := range paths { + for _, m := range []string{http.MethodGet, http.MethodPost} { + w := httptest.NewRecorder() + r := httptest.NewRequest(m, p, nil) + h.ServeHTTP(w, r) + if bytes.Contains(w.Body.Bytes(), []byte("CANARY")) { + t.Fatalf("%s %s returned a home's readings", m, p) + } + if w.Header().Get("Link") == "" { + t.Fatalf("%s %s has no source link", m, p) + } + } + } + // The metadata is there. + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) + if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte(oauth.Issuer)) { + t.Fatalf("metadata: %d", w.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 6f78aad..4b15e08 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ // /v1/now a widget's token read (package token) // /source the source of the running service, as AGPL-3.0 asks // /healthz liveness, and nothing about any home +// /oauth/*, /.well-known/oauth-* the authorization server for assistants (package oauth) // // There is no route for staff. Reading a home needs its box's key, a phone in // its roster, or a token bound to one. @@ -21,6 +22,7 @@ import ( "time" "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/oauth" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/store" "github.com/srcfl/ftw-cloud/internal/token" @@ -31,7 +33,7 @@ import ( const SourceArchive = "/usr/share/ftw-cloud/source.tar.gz" // Routes are every path the service answers. -var Routes = []string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/source", "/healthz"} +var Routes = append([]string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/source", "/healthz"}, oauth.Routes...) // devRoutes adds a local inspection route in binaries built with // -tags ftwclouddev. It is nil in every other build. @@ -48,7 +50,9 @@ type Server struct { // Tokens answers /v1/now. Tokens come only over the app session, so // without a Noise key every token read is refused. Tokens *token.Service - Log *slog.Logger + // OAuth is the authorization server for assistants. Nil serves none. + OAuth *oauth.Server + Log *slog.Logger } // Handler is the whole HTTP surface. @@ -67,6 +71,9 @@ func (s *Server) Handler() http.Handler { } else { mux.HandleFunc("/v1/now", notYet) } + if s.OAuth != nil { + s.OAuth.Mount(mux) + } mux.HandleFunc("/source", source) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") From 8d50688830268fbcd83e320dc49cde652e05d141 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:25:34 +0200 Subject: [PATCH 2/8] fix(oauth): a token names its shard as the shard directory does cloud-scale's shard seam (srcfl/ftw-cloud#17) names shards by string, so the subject carries the shard's name, not a number. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/oauth.go | 9 +++++---- internal/oauth/store.go | 8 ++++---- internal/oauth/token.go | 6 ++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 78a121f..5a2c6d0 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -136,8 +136,9 @@ type Server struct { roster chan rosterEvent - // Shard names the shard a home lives on. Nil is one shard, 0. - Shard func(home string) int + // Shard names the shard a home lives on (shard.Directory). Nil is one + // shard, "". + Shard func(home string) string // AgentExpiry reads when the box ends an agent, from its roster entry. // Nil, or a zero time, is no end the service knows of; the box still // enforces its own. @@ -342,9 +343,9 @@ func (s *Server) sweep(ctx context.Context) { } } -func (s *Server) shard(home string) int { +func (s *Server) shard(home string) string { if s.Shard == nil { - return 0 + return "" } return s.Shard(home) } diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 92790b5..980dcc9 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS requests ( home TEXT NOT NULL DEFAULT '', agent TEXT NOT NULL DEFAULT '', granted TEXT NOT NULL DEFAULT '', - shard INTEGER NOT NULL DEFAULT 0, + shard TEXT NOT NULL DEFAULT '', authz BLOB UNIQUE, authz_ms INTEGER NOT NULL DEFAULT 0, authz_used INTEGER NOT NULL DEFAULT 0 @@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS agents ( kid TEXT NOT NULL, client_id TEXT NOT NULL, scope TEXT NOT NULL, - shard INTEGER NOT NULL, + shard TEXT NOT NULL, expires_ms INTEGER NOT NULL, created_ms INTEGER NOT NULL, PRIMARY KEY (home, agent) @@ -183,7 +183,7 @@ type consent struct { Home string Agent string Granted string - Shard int + Shard string } const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, @@ -356,7 +356,7 @@ type agentRow struct { Kid string ClientID string Scope string - Shard int + Shard string Expires time.Time // zero when the box set no end } diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 582d8b8..26255a0 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -193,8 +193,10 @@ func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { // and no home of its own: it reaches only homes with an active support // grant, each through that grant's agent. type Subject struct { - Kind string // SubjectAgent - Shard int + Kind string // SubjectAgent + // Shard names the shard that held the home when the owner approved, + // so a router can send the token on; "" with one shard. + Shard string Home string // Agent is the roster device id; Key the agent's X25519 public key, // as the roster lists it; Kid its key in the keys part. From 078329033453ed360392738d21ab499209c11ce5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:32:14 +0200 Subject: [PATCH 3/8] fix(oauth): an approval outlasts a keys part that is briefly away 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 --- internal/oauth/oauth.go | 24 ++++++++++++++++++++++- internal/oauth/oauth_test.go | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 5a2c6d0..82424f1 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -239,8 +239,19 @@ func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { type rosterEvent struct { home string devices []cloudwire.Device + // tries counts how often the event came back because the keys part + // could not keep an approved key. + tries int } +// keepRetry is how long an approval waits before it tries the keys part +// again, and keepTries how often. A box sends its roster when it changes, +// so the approval cannot wait for the next one. +var ( + keepRetry = 5 * time.Second + keepTries = 24 +) + // Roster hears each roster the service keeps. A device whose key a pending // consent made is that consent's approval; an agent the roster no longer // lists is revoked. The work happens on Run's goroutine, in order, since @@ -273,6 +284,7 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() + retry := false listed := map[string]cloudwire.Device{} for _, d := range ev.devices { if d.Tun { @@ -289,7 +301,8 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { continue } if err := s.keys.Keep(ctx, c.Kid); err != nil { - s.log.Warn("an approved agent's key could not be kept; the next roster tries again", "err", err) + s.log.Warn("an approved agent's key could not be kept; trying again", "err", err) + retry = true continue } c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) @@ -299,6 +312,15 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { } s.hub.notify(string(ridHash)) } + if retry && ev.tries < keepTries { + ev.tries++ + time.AfterFunc(keepRetry, func() { + select { + case s.roster <- ev: + default: + } + }) + } // Revocations: an agent the roster no longer lists, or lists with // another key. agents, err := s.st.agentsOf(ev.home) diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index f091e35..c2b5e8b 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -876,3 +876,40 @@ func TestAuthorizeIsRateLimited(t *testing.T) { t.Fatal("authorize is not limited") } } + +// flakyKeys is a keys part that is away for a while. +type flakyKeys struct { + AgentKeys + mu sync.Mutex + down int +} + +func (f *flakyKeys) Keep(ctx context.Context, kid string) error { + f.mu.Lock() + if f.down > 0 { + f.down-- + f.mu.Unlock() + return keys.ErrUnavailable + } + f.mu.Unlock() + return f.AgentKeys.Keep(ctx, kid) +} + +// A box sends its roster when it changes, not again: an approval the keys +// part could not take at once is tried again without a new roster. +func TestApprovalOutlastsAKeysPartThatIsAway(t *testing.T) { + old := keepRetry + keepRetry = 30 * time.Millisecond + t.Cleanup(func() { keepRetry = old }) + g := newRig(t) + flaky := &flakyKeys{AgentKeys: g.keys, down: 3} + g.s.keys = flaky + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + if ev := next(t, ch); ev.name != "approved" { + t.Fatalf("event %q", ev.name) + } +} From d892bf3f35a1133922657b81a46f9cae8fb93e55 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:46:37 +0200 Subject: [PATCH 4/8] feat(oauth): build to the agent contract, with a signed grant offer 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_., for shard routing. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 +++--- internal/oauth/authorize.go | 20 +++-- internal/oauth/clients.go | 6 +- internal/oauth/oauth.go | 75 ++++++++-------- internal/oauth/oauth_test.go | 160 ++++++++++++++++++++++++++++++++--- internal/oauth/offer.go | 123 +++++++++++++++++++++++++++ internal/oauth/page.go | 12 +-- internal/oauth/store.go | 102 +++++++++++++++------- internal/oauth/token.go | 40 +++++++-- 9 files changed, 455 insertions(+), 111 deletions(-) create mode 100644 internal/oauth/offer.go diff --git a/README.md b/README.md index e16bb69..c448b19 100644 --- a/README.md +++ b/README.md @@ -135,16 +135,24 @@ to it yet. as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older clients. There is no account and no password: `/oauth/authorize` asks - `ftwkeysd` for a new agent key, then shows the client's name, where the - browser goes back, the rights asked for, a short code, a QR and the link - `https://app.ftw.energy/a/`. The owner approves in the app or on - the box's page; the box lists the agent's key in the roster it signs, and - when that roster arrives the page, waiting on a server-sent event, returns - to the client with a code. Only the browser that started the request gets - it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one - revokes its family. The service keeps hashes, never tokens. A token is - bound to one agent of one home; a roster without the agent, or the end the - box set for it, ends its tokens and its key. + `ftwkeysd` for a new agent key and has it sign the grant offer + (srcfl/ftw `docs/agents.md`): the key, the client, the scopes, ops and + caps asked for, and the page's code, with no home, since the owner picks + it. The page shows the client's name, where the browser goes back, the + rights, the code, a QR and `https://app.ftw.energy/a/#`; + the app checks the offer against the offer key it pins, which + `/.well-known/ftw-offer-key` serves. The box's page or an owner who types + the code finds the offer once at `/oauth/requests?code=`. The owner + approves in the app or on the box's page; the box lists the agent's key, + for that client, in the roster it signs, and when that roster arrives the + page, waiting on a server-sent event, returns to the client with a code. + Only the browser that started the request gets it. Access tokens last 10 + minutes; refresh tokens rotate, and a reused one revokes its family. A + token names its home (`ftwat_.`) so any instance can route + it; the service keeps hashes, never tokens. A token carries the grant the + newest roster lists, narrowed or widened at once, and stops at the + agent's end by the service's own clock. A roster without the agent ends + its tokens and its key. Not built yet: diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 4d906f7..280a341 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -119,6 +119,10 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { con.Code = shortCode() + if con.Offer, err = s.signOffer(ctx, buildOffer(con)); err != nil { + s.log.Warn("the keys part could not sign an offer", "err", err) + break + } err = s.st.putConsent(rid, browser, con) if err == nil || try == 3 { break @@ -196,7 +200,9 @@ func authorizationError(redirect, code, desc, state string) string { // -------------------------------------------------------------------------- type requestView struct { - Request string `json:"request,omitempty"` + Request string `json:"request,omitempty"` + // Offer is the signed grant offer, base64url, as the link carries it. + Offer string `json:"offer"` Status string `json:"status"` Code string `json:"code"` Client clientView `json:"client"` @@ -231,7 +237,7 @@ func (s *Server) view(c consent) requestView { if status == statusPending && !s.now().Before(c.Expires) { status = "expired" } - return requestView{Status: status, Code: c.Code, + return requestView{Offer: c.Offer, Status: status, Code: c.Code, Client: clientView{ID: c.ClientID, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified}, Redirect: redirectView{Host: u.Hostname(), Local: u.Scheme == "http" && isLoopback(u.Hostname())}, Scopes: splitScope(c.Scope), @@ -253,8 +259,10 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { } // serveRequestByCode finds a request by the code an owner typed on the box's -// page. Lookups are rate limited for everyone together: a code lasts ten -// minutes and has about 29 bits, and finding one only shows a client's name. +// page or in the app, once: a short code cannot carry the agent's key, so +// it fetches the signed offer. Lookups are rate limited for everyone +// together, and a code works once: it lasts ten minutes and has about 29 +// bits, and finding one shows only an offer the owner must still approve. func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { if !s.lookupLimit.allow(s.now()) { w.Header().Set("Retry-After", "1") @@ -266,8 +274,8 @@ func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - c, ok, err := s.st.consentByCode(code) - if err != nil || !ok || c.Status != statusPending || !s.now().Before(c.Expires) { + c, ok, err := s.st.spendCode(code, s.now()) + if err != nil || !ok { http.NotFound(w, r) return } diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 92ca6f2..0cf55c7 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -20,7 +20,9 @@ type client struct { // Host is where the name came from: the host of the metadata // document's URL, which vouches for it, or the client_uri a registration // claimed, which does not. - Host string + Host string + // Verified is a client FTW Cloud knows: a metadata document on one of + // verifiedHosts. Verified bool RedirectURIs []string AuthMethod string @@ -37,7 +39,7 @@ func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { return client{}, err } u, _ := url.Parse(id) - return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: true, RedirectURIs: md.RedirectURIs, + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedHost(u.Hostname()), RedirectURIs: md.RedirectURIs, AuthMethod: authNone}, nil } c, ok, err := s.st.client(id) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 82424f1..a8445d3 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -62,24 +62,20 @@ const ( RefreshTTL = 30 * 24 * time.Hour ) -// Scopes. Each is a right the box grants an agent, by its registry name; a -// token carries those the client asked for and the box granted. -// -// The first five are in contract/registry.yaml today. The rest are the -// agent rights decided on 15 October 2026 and wait for their registry names -// (the core-agent-role contract); they move to protocol constants when it -// lands. +// Scopes. Each is a right the box grants an agent, by its registry name +// (docs/agents.md in srcfl/ftw); a token carries those the client asked for +// and the box granted. const ( ScopeLiveRead = protocol.ScopeLiveRead ScopePlanRead = protocol.ScopePlanRead ScopeHistoryRead = protocol.ScopeHistoryRead ScopeHealthRead = protocol.ScopeHealthRead ScopeEventsRead = protocol.ScopeEventsRead - ScopeLogsRead = "ftw.logs.read" - ScopeConfigRead = "ftw.config.read" - ScopeDiagRun = "ftw.diag.run" - ScopeDispatchHold = "ftw.dispatch.hold" - ScopeGoalsRequest = "ftw.goals.request" + ScopeLogsRead = protocol.ScopeLogsRead + ScopeConfigRead = protocol.ScopeConfigRead + ScopeDiagRun = protocol.ScopeDiagRun + ScopeDispatchHold = protocol.ScopeDispatchHold + ScopeGoalsRequest = protocol.ScopeGoalsRequest ) // ReadScopes are what an agent gets by default. @@ -121,6 +117,8 @@ type AgentKeys interface { New(ctx context.Context) (kid string, pub []byte, err error) Keep(ctx context.Context, kid string) error Forget(ctx context.Context, kid string) error + SignOffer(ctx context.Context, offer []byte) ([]byte, error) + OfferKey(ctx context.Context) ([]byte, error) } // Server is the authorization server. @@ -136,13 +134,11 @@ type Server struct { roster chan rosterEvent + offerKey offerKey + // Shard names the shard a home lives on (shard.Directory). Nil is one // shard, "". Shard func(home string) string - // AgentExpiry reads when the box ends an agent, from its roster entry. - // Nil, or a zero time, is no end the service knows of; the box still - // enforces its own. - AgentExpiry func(d cloudwire.Device) time.Time // OnRevoke hears that an agent is gone: its owner revoked it, or it // ran out. The MCP server drops its warm session. It must not block. OnRevoke func(home, agent string) @@ -175,7 +171,7 @@ func (s *Server) Close() error { return s.st.close() } var Routes = []string{ "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/oauth/authorize", "/oauth/token", "/oauth/register", - "/oauth/revoke", "/oauth/requests", + "/oauth/revoke", "/oauth/requests", "/.well-known/ftw-offer-key", } // Mount adds the authorization server's routes to mux. @@ -187,6 +183,7 @@ func (s *Server) Mount(mux *http.ServeMux) { mux.HandleFunc("POST /oauth/token", s.serveToken) mux.HandleFunc("POST /oauth/register", s.serveRegister) mux.HandleFunc("POST /oauth/revoke", s.serveRevoke) + mux.HandleFunc("GET /.well-known/ftw-offer-key", s.serveOfferKey) mux.HandleFunc("GET /oauth/requests", s.serveRequestByCode) mux.HandleFunc("GET /oauth/requests/{rid}", s.serveRequest) mux.HandleFunc("GET /oauth/requests/{rid}/events", s.serveEvents) @@ -285,19 +282,21 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() retry := false + // The rows this service serves: a personal agent's, with its grant, a + // tunnel and an end still ahead. Support and release rows reach their + // homes another way. listed := map[string]cloudwire.Device{} for _, d := range ev.devices { - if d.Tun { + if d.Tun && d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { listed[d.ID] = d } } - // Approvals: a pending consent's key in the box's signed roster. + // Approvals: a pending consent's key in the box's signed roster, for + // the client the offer named. for _, d := range listed { ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) - if err != nil || !ok { - continue - } - if cloudwire.DeviceID(d.Key) != d.ID { + if err != nil || !ok || d.Agent.Principal.ClientID != c.ClientID { continue } if err := s.keys.Keep(ctx, c.Kid); err != nil { @@ -306,8 +305,8 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { continue } c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) - c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Scopes), " ") - if err := s.st.approve(ridHash, c, s.expiry(d), now); err != nil { + c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Agent.Scopes), " ") + if err := s.st.approve(ridHash, c, grantOf(d), now); err != nil { continue } s.hub.notify(string(ridHash)) @@ -321,20 +320,31 @@ func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { } }) } - // Revocations: an agent the roster no longer lists, or lists with - // another key. + // Every agent the roster lists keeps the grant the box gives it now: + // an owner may widen, narrow or renew it. One it no longer lists, or + // lists with another key, is revoked. agents, err := s.st.agentsOf(ev.home) if err != nil { return } for _, a := range agents { - if d, ok := listed[a.Agent]; ok && string(d.Key) == string(a.Key) { + d, ok := listed[a.Agent] + if !ok || string(d.Key) != string(a.Key) { + s.revoke(ctx, a) continue } - s.revoke(ctx, a) + if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { + s.log.Warn("could not update an agent's grant", "err", err) + } } } +// grantOf is an agent row's grant, as the box signed it in the roster. +func grantOf(d cloudwire.Device) agentGrant { + return agentGrant{Scopes: slices.Clone(d.Agent.Scopes), Ops: slices.Clone(d.Agent.Ops), Caps: d.Agent.Caps, + Expires: time.UnixMilli(d.Agent.ExpiresAtMs)} +} + func (s *Server) revoke(ctx context.Context, a agentRow) { if err := s.st.dropAgent(a.Home, a.Agent); err != nil { s.log.Warn("could not drop an agent", "err", err) @@ -372,13 +382,6 @@ func (s *Server) shard(home string) string { return s.Shard(home) } -func (s *Server) expiry(d cloudwire.Device) time.Time { - if s.AgentExpiry == nil { - return time.Time{} - } - return s.AgentExpiry(d) -} - // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index c2b5e8b..0524b6d 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "github.com/fxamacker/cbor/v2" "io" "log/slog" "net" @@ -25,6 +26,7 @@ import ( "time" "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -276,8 +278,25 @@ func (g *rig) roster(h string, devs ...cloudwire.Device) { time.Sleep(20 * time.Millisecond) } +// agentDevice is an agent row as the box lists it in its roster, for the +// metadata-document client, ending in 30 days. func agentDevice(key []byte, scopes ...string) cloudwire.Device { - return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: "agent", Scopes: scopes, Epoch: 1, Tun: true} + return agentFor(key, cimdID, time.Now().Add(30*24*time.Hour), scopes...) +} + +// agentFor is an agent row for a client, with the end the box set. The +// readable copy's scopes are empty, as the contract has it for agents. +func agentFor(key []byte, clientID string, end time.Time, scopes ...string) cloudwire.Device { + var ops []string + for op, sc := range protocol.RegistryAgentOps { + if slices.Contains(scopes, sc) { + ops = append(ops, op) + } + } + slices.Sort(ops) + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Tun: true, + Agent: &cloudwire.Agent{Scopes: scopes, Ops: ops, Caps: protocol.Caps{HoldS: 600, MaxW: 5000, PerMin: 6}, + ExpiresAtMs: end.UnixMilli(), Principal: protocol.Principal{Client: "Claude", ClientID: clientID}}} } // redirectOf reads the client's redirect from an event. @@ -335,7 +354,7 @@ func (g *rig) approve(f *flow, clientID string, scopes ...string) tokens { if len(scopes) == 0 { scopes = []string{ScopeLiveRead, ScopePlanRead} } - g.roster(home, agentDevice(f.agentKey(g.t), scopes...)) + g.roster(home, agentFor(f.agentKey(g.t), clientID, g.now().Add(30*24*time.Hour), scopes...)) ev := next(g.t, ch) if ev.name != "approved" { g.t.Fatalf("event %q after the roster", ev.name) @@ -406,7 +425,7 @@ func getJSON(t *testing.T, u string, v any) { func TestTheFullFlowWithAMetadataDocument(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead+" "+ScopeHistoryRead) - if f.view.Client.Name != "Claude" || !f.view.Client.Verified || f.view.Client.Host != "example.com" || + if f.view.Client.Name != "Claude" || f.view.Client.Verified || f.view.Client.Host != "example.com" || f.view.Redirect.Host != "example.com" || f.view.Status != statusPending || len(f.view.Code) != codeLen { t.Fatalf("what the app sees: %+v", f.view) } @@ -578,7 +597,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { } ch, stop := g.events(f, true) next(t, ch) - g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + g.roster(home, agentFor(f.agentKey(t), id, time.Now().Add(time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") stop() if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { @@ -594,7 +613,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { ch, stop = g.events(f, true) defer stop() next(t, ch) - g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) + g.roster(home, agentFor(f.agentKey(t), cid, time.Now().Add(time.Hour), ScopeLiveRead)) code = redirectOf(t, next(t, ch)).Query().Get("code") form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} if status, _ := g.token(form); status != http.StatusUnauthorized { @@ -623,15 +642,24 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } - // Another key, the right key without a tunnel, the right key under - // another id: none of them approves. + // Another key; the right key without a tunnel, under another id, for + // another client, past its end, as support, or as a phone: none of them + // approves. other := bytes.Repeat([]byte{1}, 32) key := f.agentKey(t) noTun := agentDevice(key, ScopeLiveRead) noTun.Tun = false wrongID := agentDevice(key, ScopeLiveRead) wrongID.ID = "someone" - g.roster(home, agentDevice(other, ScopeLiveRead), noTun, wrongID) + otherClient := agentFor(key, "https://evil.example/client.json", time.Now().Add(time.Hour), ScopeLiveRead) + ended := agentFor(key, cimdID, g.now().Add(-time.Second), ScopeLiveRead) + support := agentDevice(key, ScopeLiveRead) + support.Role = protocol.RoleSupport + phone := agentDevice(key, ScopeLiveRead) + phone.Agent = nil + for _, d := range []cloudwire.Device{noTun, wrongID, otherClient, ended, support, phone} { + g.roster(home, agentDevice(other, ScopeLiveRead), d) + } select { case ev := <-browser: t.Fatalf("event %q before the roster listed the agent", ev.name) @@ -728,10 +756,13 @@ func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { // An agent whose time the box set runs out fails, and is swept. func TestAnExpiredAgentFails(t *testing.T) { g := newRig(t) - end := g.now().Add(2 * time.Hour) - g.s.AgentExpiry = func(cloudwire.Device) time.Time { return end } f := g.start(cimdID, cimdBack, "") - tk := g.approve(f, cimdID) + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentFor(f.agentKey(t), cimdID, g.now().Add(2*time.Hour), ScopeLiveRead)) + code := redirectOf(t, next(t, ch)).Query().Get("code") + _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}}) if _, aerr := g.bearer(tk.Access); aerr != nil { t.Fatal(aerr) } @@ -755,7 +786,7 @@ func TestWrongAudienceIsRefused(t *testing.T) { f := g.start(cimdID, cimdBack, "") tk := g.approve(f, cimdID) agent := cloudwire.DeviceID(f.agentKey(t)) - other := "ftwat_" + randomToken(32) + other := "ftwat_" + home + "." + randomToken(32) if err := g.s.st.putToken(other, tokenRow{Kind: kindAccess, Family: []byte("f"), ClientID: cimdID, Home: home, Agent: agent, Scope: ScopeLiveRead, Resource: "https://cloud.ftw.energy/other", Expires: g.now().Add(time.Minute)}); err != nil { t.Fatal(err) @@ -804,9 +835,13 @@ func TestTheBoxPageFindsARequestByItsCode(t *testing.T) { typed := strings.ToLower(f.view.Code[:3]) + " - " + f.view.Code[3:] var v requestView getJSON(t, g.srv.URL+"/oauth/requests?code="+url.QueryEscape(typed), &v) - if v.Agent.Key != f.view.Agent.Key || v.Request != "" { + if v.Agent.Key != f.view.Agent.Key || v.Request != "" || v.Offer == "" || v.Offer != f.view.Offer { t.Fatalf("by code: %+v", v) } + // Once. + if resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=" + f.view.Code); resp.StatusCode != http.StatusNotFound { + t.Fatalf("a code used twice: %d", resp.StatusCode) + } resp, _ := http.Get(g.srv.URL + "/oauth/requests?code=ZZZZZZ") if resp.StatusCode != http.StatusNotFound { t.Fatalf("an unknown code: %d", resp.StatusCode) @@ -834,7 +869,7 @@ func TestTheConsentPage(t *testing.T) { resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) body, _ := io.ReadAll(resp.Body) page := string(body) - for _, want := range []string{"Let Claude into your home?", "Identified by example.com", "127.0.0.1:8123", + for _, want := range []string{"Let Claude into your home?", "From example.com. FTW Cloud does not know this client", "127.0.0.1:8123", "a program on this computer", " 0 { + caps.PerMin = protocol.PerMinDefault + } + return protocol.GrantOffer{V: protocol.GrantOfferVersion, Key: c.AgentKey, Role: protocol.RoleAgent, + Client: c.ClientName, ClientID: c.ClientID, Scopes: scopes, Ops: ops, Caps: caps, + TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code} +} + +// signOffer encodes and signs an offer, for the link's fragment. +func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { + raw, err := detEnc.Marshal(o) + if err != nil { + return "", err + } + sig, err := s.keys.SignOffer(ctx, raw) + if err != nil { + return "", err + } + out, err := detEnc.Marshal(signedOffer{Offer: raw, Sig: sig}) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(out), nil +} + +// appLink is the universal link the QR and the button carry: the request +// id in the path, for the app's answer, and the signed offer in the +// fragment, which no server sees. +func appLink(rid, offer string) string { return AppLink + rid + "#" + offer } + +// verifiedHosts are clients FTW Cloud knows by the host of their metadata +// document. A client from one of them is shown as verified; any other is +// shown by its host and the name it gives itself. +var verifiedHosts = []string{"claude.ai", "claude.com", "anthropic.com", "chatgpt.com", "openai.com", "grok.com", "x.ai"} + +func verifiedHost(host string) bool { + host = strings.ToLower(host) + for _, h := range verifiedHosts { + if host == h || strings.HasSuffix(host, "."+h) { + return true + } + } + return false +} + +// offerKey serves the offer key's public half, so anyone can check what the +// app pins. +type offerKey struct { + mu sync.Mutex + pub []byte +} + +func (s *Server) serveOfferKey(w http.ResponseWriter, r *http.Request) { + s.offerKey.mu.Lock() + pub := s.offerKey.pub + s.offerKey.mu.Unlock() + if pub == nil { + p, err := s.keys.OfferKey(r.Context()) + if err != nil { + http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) + return + } + s.offerKey.mu.Lock() + s.offerKey.pub, pub = p, p + s.offerKey.mu.Unlock() + } + writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": "ftw.offer.v1", + "pub": base64.RawURLEncoding.EncodeToString(pub)}, true) +} diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 6fabee0..5996afc 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -21,6 +21,7 @@ type pageData struct { Name string Host string Verified bool + FromDocument bool RedirectHost string Local bool Scopes []string @@ -38,9 +39,9 @@ type pageError struct { func (s *Server) consentPage(w http.ResponseWriter, rid string, c consent) { u, _ := url.Parse(c.RedirectURI) - d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, + d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID), RedirectHost: u.Host, Local: u.Scheme == "http" && isLoopback(u.Hostname()), - Code: c.Code[:3] + "-" + c.Code[3:], Link: AppLink + rid} + Code: c.Code[:3] + "-" + c.Code[3:], Link: appLink(rid, c.Offer)} for _, sc := range splitScope(c.Scope) { d.Scopes = append(d.Scopes, scopeWords[sc]) if !slices.Contains(ReadScopes, sc) { @@ -74,7 +75,8 @@ func pageHeaders(w http.ResponseWriter, nonce string) { // qrSVG draws text as a QR code, dark on light whatever the page's theme, // with the quiet zone the standard asks for. func qrSVG(text string) template.HTML { - code, err := qr.Encode(text, qr.M) + // Level L: the link carries the signed offer, and a screen is clean. + code, err := qr.Encode(text, qr.L) if err != nil { return "" } @@ -100,7 +102,7 @@ const pageStyle = ` main{max-width:560px;margin:0 auto;padding:32px 16px}h1{font-size:1.5rem;line-height:1.25;margin:0 0 8px}h2{font-size:1rem;margin:24px 0 8px} .muted{color:var(--muted)}.warn{color:var(--warn)}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px} ul{margin:0;padding-left:20px}.approve{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:24px} -.qr{width:168px;height:168px;flex:none;background:#fff;border-radius:8px;padding:4px}.qr svg{width:100%;height:100%} +.qr{width:232px;height:232px;flex:none;background:#fff;border-radius:8px;padding:4px}.qr svg{width:100%;height:100%} .code{font:600 1.75rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;margin:4px 0 0} a.button{display:inline-block;background:var(--accent);color:#fff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:600} @media (prefers-color-scheme:dark){a.button{color:#0b1a12}} @@ -113,7 +115,7 @@ var consentTmpl = template.Must(template.New("consent").Parse(` Connect an assistant · FTW Cloud

Let {{.Name}} into your home?

-

{{if .Verified}}Identified by {{.Host}}.{{else}}This name is what the assistant registered with. FTW Cloud has not checked it.{{end}}

+

{{if .Verified}}A client FTW Cloud knows, from {{.Host}}.{{else if .FromDocument}}From {{.Host}}. FTW Cloud does not know this client: check the name and the address.{{else}}This name is what the assistant registered with. FTW Cloud has not checked it.{{end}}

After you approve, this window goes back to {{.RedirectHost}}.{{if .Local}} That is a program on this computer. Approve only if you started it.{{end}}

It asks to

diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 980dcc9..c8f1ac5 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/srcfl/ftw/go/protocol" _ "modernc.org/sqlite" ) @@ -62,7 +63,9 @@ CREATE TABLE IF NOT EXISTS requests ( shard TEXT NOT NULL DEFAULT '', authz BLOB UNIQUE, authz_ms INTEGER NOT NULL DEFAULT 0, - authz_used INTEGER NOT NULL DEFAULT 0 + authz_used INTEGER NOT NULL DEFAULT 0, + offer TEXT NOT NULL DEFAULT '', + code_used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS agents ( home TEXT NOT NULL, @@ -71,6 +74,8 @@ CREATE TABLE IF NOT EXISTS agents ( kid TEXT NOT NULL, client_id TEXT NOT NULL, scope TEXT NOT NULL, + ops TEXT NOT NULL, + caps TEXT NOT NULL, shard TEXT NOT NULL, expires_ms INTEGER NOT NULL, created_ms INTEGER NOT NULL, @@ -169,7 +174,7 @@ type consent struct { ClientID string ClientName string ClientHost string - Verified bool // the client's name came from a document at its own https URL + Verified bool // a client FTW Cloud knows (verifiedHosts) RedirectURI string State string Challenge string @@ -184,17 +189,19 @@ type consent struct { Agent string Granted string Shard string + // Offer is the signed grant offer, base64url, as the link carries it. + Offer string } const consentCols = `code, client_id, client_name, client_host, verified, redirect_uri, state, challenge, scope, resource, - kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard` + kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard, offer` func scanConsent(row interface{ Scan(...any) error }) (consent, error) { var c consent var verified int var created, expires int64 err := row.Scan(&c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, - &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard, &c.Offer) c.Verified, c.Created, c.Expires = verified == 1, time.UnixMilli(created), time.UnixMilli(expires) return c, err } @@ -205,10 +212,10 @@ func (s *store) putConsent(rid, browser string, c consent) error { verified = 1 } _, err := s.db.Exec(`INSERT INTO requests (rid, browser, `+consentCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash(rid), hash(browser), c.Code, c.ClientID, c.ClientName, c.ClientHost, verified, c.RedirectURI, c.State, c.Challenge, c.Scope, c.Resource, c.Kid, c.AgentKey, c.Created.UnixMilli(), c.Expires.UnixMilli(), c.Status, - c.Home, c.Agent, c.Granted, c.Shard) + c.Home, c.Agent, c.Granted, c.Shard, c.Offer) return err } @@ -216,7 +223,17 @@ func (s *store) consentByRID(rid string) (consent, bool, error) { return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE rid = ?`, hash(rid)) } -func (s *store) consentByCode(code string) (consent, bool, error) { +// spendCode finds a pending request by its short code, once: a code that +// found its request finds nothing again. +func (s *store) spendCode(code string, now time.Time) (consent, bool, error) { + res, err := s.db.Exec(`UPDATE requests SET code_used = 1 WHERE code = ? AND code_used = 0 AND status = ? AND expires_ms > ?`, + code, statusPending, now.UnixMilli()) + if err != nil { + return consent{}, false, err + } + if n, _ := res.RowsAffected(); n != 1 { + return consent{}, false, nil + } return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE code = ?`, code) } @@ -243,7 +260,7 @@ func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c conse var verified int var created, expires int64 err = row.Scan(&ridHash, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, - &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard) + &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, &c.Agent, &c.Granted, &c.Shard, &c.Offer) if errors.Is(err, sql.ErrNoRows) { return nil, c, false, nil } @@ -251,9 +268,18 @@ func (s *store) pendingByKey(key []byte, now time.Time) (ridHash []byte, c conse return ridHash, c, err == nil, err } +// agentGrant is what the box's roster grants an agent now. +type agentGrant struct { + Scopes []string + Ops []string + Caps protocol.Caps + Expires time.Time +} + // approve marks a consent approved for an agent of a home, and records the // agent, in one transaction. -func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) error { +func (s *store) approve(ridHash []byte, c consent, g agentGrant, now time.Time) error { + caps, _ := json.Marshal(g.Caps) tx, err := s.db.Begin() if err != nil { return err @@ -269,11 +295,13 @@ func (s *store) approve(ridHash []byte, c consent, agentExpires, now time.Time) if n, _ := res.RowsAffected(); n != 1 { return errGone } - if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, shard, expires_ms, created_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, ops, caps, shard, expires_ms, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (home, agent) DO UPDATE SET agent_key = excluded.agent_key, kid = excluded.kid, - client_id = excluded.client_id, scope = excluded.scope, shard = excluded.shard, expires_ms = excluded.expires_ms`, - c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, c.Granted, c.Shard, agentExpires.UnixMilli(), now.UnixMilli()); err != nil { + client_id = excluded.client_id, scope = excluded.scope, ops = excluded.ops, caps = excluded.caps, + shard = excluded.shard, expires_ms = excluded.expires_ms`, + c.Home, c.Agent, c.AgentKey, c.Kid, c.ClientID, strings.Join(g.Scopes, " "), strings.Join(g.Ops, " "), string(caps), + c.Shard, g.Expires.UnixMilli(), now.UnixMilli()); err != nil { return err } return tx.Commit() @@ -321,7 +349,7 @@ func (s *store) redeem(code string, now time.Time) (c consent, ridHash []byte, r var created, expires int64 err = row.Scan(&ridHash, &used, &ms, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &c.State, &c.Challenge, &c.Scope, &c.Resource, &c.Kid, &c.AgentKey, &created, &expires, &c.Status, &c.Home, - &c.Agent, &c.Granted, &c.Shard) + &c.Agent, &c.Granted, &c.Shard, &c.Offer) if errors.Is(err, sql.ErrNoRows) { return c, nil, false, errGone } @@ -348,53 +376,65 @@ var errGone = errors.New("oauth: gone") // -------------------------------------------------------------------------- // agentRow is an agent an owner approved, as the authorization server knows -// it. +// it: the grant is the one the box's newest roster lists. type agentRow struct { Home string Agent string Key []byte Kid string ClientID string - Scope string Shard string - Expires time.Time // zero when the box set no end + agentGrant } -func (s *store) agent(home, agent string) (agentRow, bool, error) { +const agentCols = `home, agent, agent_key, kid, client_id, scope, ops, caps, shard, expires_ms` + +func scanAgent(row interface{ Scan(...any) error }) (agentRow, error) { var a agentRow + var scope, ops, caps string var ms int64 - err := s.db.QueryRow(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ? AND agent = ?`, - home, agent).Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms) + err := row.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &scope, &ops, &caps, &a.Shard, &ms) + if err != nil { + return a, err + } + a.Scopes, a.Ops, a.Expires = splitScope(scope), splitScope(ops), time.UnixMilli(ms) + _ = json.Unmarshal([]byte(caps), &a.Caps) + return a, nil +} + +func (s *store) agent(home, agent string) (agentRow, bool, error) { + a, err := scanAgent(s.db.QueryRow(`SELECT `+agentCols+` FROM agents WHERE home = ? AND agent = ?`, home, agent)) if errors.Is(err, sql.ErrNoRows) { return a, false, nil } - if ms > 0 { - a.Expires = time.UnixMilli(ms) - } return a, err == nil, err } func (s *store) agentsOf(home string) ([]agentRow, error) { - rows, err := s.db.Query(`SELECT home, agent, agent_key, kid, client_id, scope, shard, expires_ms FROM agents WHERE home = ?`, home) + rows, err := s.db.Query(`SELECT `+agentCols+` FROM agents WHERE home = ?`, home) if err != nil { return nil, err } defer rows.Close() var out []agentRow for rows.Next() { - var a agentRow - var ms int64 - if err := rows.Scan(&a.Home, &a.Agent, &a.Key, &a.Kid, &a.ClientID, &a.Scope, &a.Shard, &ms); err != nil { + a, err := scanAgent(rows) + if err != nil { return nil, err } - if ms > 0 { - a.Expires = time.UnixMilli(ms) - } out = append(out, a) } return out, rows.Err() } +// setGrant records the grant the newest roster lists for an agent. +func (s *store) setGrant(home, agent string, g agentGrant) error { + caps, _ := json.Marshal(g.Caps) + _, err := s.db.Exec(`UPDATE agents SET scope = ?, ops = ?, caps = ?, expires_ms = ? WHERE home = ? AND agent = ?`, + strings.Join(g.Scopes, " "), strings.Join(g.Ops, " "), string(caps), g.Expires.UnixMilli(), home, agent) + return err +} + // dropAgent deletes an agent and, by the foreign key, every token it holds. func (s *store) dropAgent(home, agent string) error { _, err := s.db.Exec(`DELETE FROM agents WHERE home = ? AND agent = ?`, home, agent) @@ -402,7 +442,7 @@ func (s *store) dropAgent(home, agent string) error { } func (s *store) expiredAgents(now time.Time) ([]agentRow, error) { - rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms > 0 AND expires_ms <= ?`, now.UnixMilli()) + rows, err := s.db.Query(`SELECT home, agent, kid FROM agents WHERE expires_ms <= ?`, now.UnixMilli()) if err != nil { return nil, err } diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 26255a0..7ebfabb 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "github.com/srcfl/ftw/go/protocol" "net/http" "regexp" "slices" @@ -14,12 +15,27 @@ import ( var verifierRE = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) -// Token prefixes, so a leaked token is recognisable for what it is. +// Token prefixes, so a leaked token is recognisable for what it is. After +// the prefix comes the home, then a dot and the secret, so any instance can +// tell which shard holds a token's home without a lookup. const ( accessPrefix = "ftwat_" refreshPrefix = "ftwrt_" ) +// TokenHome is the home a token names, or "" for something else. It +// proves nothing: only the stored hash does. +func TokenHome(tok string) string { + for _, p := range []string{accessPrefix, refreshPrefix} { + if rest, ok := strings.CutPrefix(tok, p); ok { + if home, _, ok := strings.Cut(rest, "."); ok { + return home + } + } + } + return "" +} + func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { @@ -126,14 +142,14 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { // The refresh token ends no later than refreshEnd or the agent. func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a agentRow, scope string, refreshEnd time.Time) { now := s.now() - if !a.Expires.IsZero() && a.Expires.Before(refreshEnd) { + if a.Expires.Before(refreshEnd) { refreshEnd = a.Expires } accessEnd := now.Add(AccessTTL) if accessEnd.After(refreshEnd) { accessEnd = refreshEnd } - access, refresh := accessPrefix+randomToken(32), refreshPrefix+randomToken(32) + access, refresh := accessPrefix+a.Home+"."+randomToken(32), refreshPrefix+a.Home+"."+randomToken(32) base := tokenRow{Family: family, ClientID: clientID, Home: a.Home, Agent: a.Agent, Scope: scope, Resource: Resource} at, rt := base, base at.Kind, at.Expires = kindAccess, accessEnd @@ -155,8 +171,10 @@ func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a }, false) } +// agentGone is an agent past the end its box set. The service stops +// admitting it by its own clock, without waiting for a roster. func (s *Server) agentGone(a agentRow) bool { - return !a.Expires.IsZero() && !s.now().Before(a.Expires) + return !s.now().Before(a.Expires) } func tokenError(w http.ResponseWriter, status int, code, desc string) { @@ -208,12 +226,20 @@ type Subject struct { // SubjectAgent is a personal agent an owner approved. const SubjectAgent = "agent" -// Grant is what one valid access token allows. +// Grant is what one valid access token allows: the rights the box grants +// its agent now, as the newest roster lists them. An owner who narrows the +// grant narrows the token at once; one who widens it widens the token too. type Grant struct { Subject Subject ClientID string Scopes []string - Expires time.Time + // Ops and Caps are the grant's operations and caps. The service may + // hold a call to them before the box does; it never loosens them. + Ops []string + Caps protocol.Caps + Expires time.Time + // AgentExpires is when the box ends the agent. + AgentExpires time.Time } // Has reports whether the grant carries a scope. @@ -253,7 +279,7 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { } return &Grant{ Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, - ClientID: t.ClientID, Scopes: splitScope(t.Scope), Expires: t.Expires, + ClientID: t.ClientID, Scopes: a.Scopes, Ops: a.Ops, Caps: a.Caps, Expires: t.Expires, AgentExpires: a.Expires, }, nil } From 0224dd8ae52dff9e15c74baa3911389b0fdc9146 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:13:27 +0200 Subject: [PATCH 5/8] feat(oauth): the final agent contract: SignedOffer {o, kid, sig}, rows 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 --- README.md | 12 ++++--- internal/oauth/authorize.go | 15 +++++++-- internal/oauth/oauth.go | 10 +++--- internal/oauth/oauth_test.go | 64 +++++++++++++++++++++--------------- internal/oauth/offer.go | 59 +++++++++++++++++++-------------- 5 files changed, 98 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c448b19..4e45f41 100644 --- a/README.md +++ b/README.md @@ -136,15 +136,17 @@ to it yet. registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older clients. There is no account and no password: `/oauth/authorize` asks `ftwkeysd` for a new agent key and has it sign the grant offer - (srcfl/ftw `docs/agents.md`): the key, the client, the scopes, ops and - caps asked for, and the page's code, with no home, since the owner picks - it. The page shows the client's name, where the browser goes back, the + (srcfl/ftw `docs/agents.md`), sent as `{o, kid, sig}`: the key, the + client, the host it is known by and whether FTW Cloud knows it, the + scopes, ops and caps asked for, the page's code, and an end ten minutes + out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the rights, the code, a QR and `https://app.ftw.energy/a/#`; - the app checks the offer against the offer key it pins, which + the app checks the offer against the offer key it pins by kid, which `/.well-known/ftw-offer-key` serves. The box's page or an owner who types the code finds the offer once at `/oauth/requests?code=`. The owner approves in the app or on the box's page; the box lists the agent's key, - for that client, in the roster it signs, and when that roster arrives the + for that client, in the roster it signs (an agent row, with no tunnel of + its own), and when that roster arrives the page, waiting on a server-sent event, returns to the client with a code. Only the browser that started the request gets it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one revokes its family. A diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 280a341..932b78d 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -114,7 +114,16 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } now := s.now() rid, browser := randomToken(16), randomToken(32) - con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: c.Host, Verified: c.Verified, RedirectURI: redirect, + // The host the owner is shown: the metadata document's, or for a + // registered client the redirect URI's, since the name it registered + // proves nothing. + host := c.Host + if !isCIMD(c.ID) { + if u, err := url.Parse(redirect); err == nil { + host = u.Hostname() + } + } + con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: host, Verified: c.Verified, RedirectURI: redirect, State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { @@ -279,7 +288,9 @@ func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - allowApp(w, r) + // The box's page fetches it from the owner's browser, on whatever + // address the box has at home. + w.Header().Set("Access-Control-Allow-Origin", "*") writeJSON(w, http.StatusOK, s.view(c), false) } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index a8445d3..2d118e0 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -282,12 +282,14 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { now := s.now() retry := false - // The rows this service serves: a personal agent's, with its grant, a - // tunnel and an end still ahead. Support and release rows reach their - // homes another way. + // The rows this service serves: a personal agent's, with its grant and + // an end still ahead. Support and release rows reach their homes + // another way. listed := map[string]cloudwire.Device{} for _, d := range ev.devices { - if d.Tun && d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + // An agent row has no tunnel of its own (tun is false): only the + // keys part opens its session. + if d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { listed[d.ID] = d } diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 0524b6d..cbed988 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -284,8 +284,9 @@ func agentDevice(key []byte, scopes ...string) cloudwire.Device { return agentFor(key, cimdID, time.Now().Add(30*24*time.Hour), scopes...) } -// agentFor is an agent row for a client, with the end the box set. The -// readable copy's scopes are empty, as the contract has it for agents. +// agentFor is an agent row for a client, with the end the box set. Its +// copy scopes are empty and it has no tunnel of its own, as the contract +// has it for agents. func agentFor(key []byte, clientID string, end time.Time, scopes ...string) cloudwire.Device { var ops []string for op, sc := range protocol.RegistryAgentOps { @@ -294,7 +295,7 @@ func agentFor(key []byte, clientID string, end time.Time, scopes ...string) clou } } slices.Sort(ops) - return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Tun: true, + return cloudwire.Device{ID: cloudwire.DeviceID(key), Key: key, Role: protocol.RoleAgent, Scopes: []string{}, Epoch: 1, Agent: &cloudwire.Agent{Scopes: scopes, Ops: ops, Caps: protocol.Caps{HoldS: 600, MaxW: 5000, PerMin: 6}, ExpiresAtMs: end.UnixMilli(), Principal: protocol.Principal{Client: "Claude", ClientID: clientID}}} } @@ -642,13 +643,10 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } - // Another key; the right key without a tunnel, under another id, for - // another client, past its end, as support, or as a phone: none of them - // approves. + // Another key; the right key under another id, for another client, + // past its end, as support, or as a phone: none of them approves. other := bytes.Repeat([]byte{1}, 32) key := f.agentKey(t) - noTun := agentDevice(key, ScopeLiveRead) - noTun.Tun = false wrongID := agentDevice(key, ScopeLiveRead) wrongID.ID = "someone" otherClient := agentFor(key, "https://evil.example/client.json", time.Now().Add(time.Hour), ScopeLiveRead) @@ -657,7 +655,7 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { support.Role = protocol.RoleSupport phone := agentDevice(key, ScopeLiveRead) phone.Agent = nil - for _, d := range []cloudwire.Device{noTun, wrongID, otherClient, ended, support, phone} { + for _, d := range []cloudwire.Device{wrongID, otherClient, ended, support, phone} { g.roster(home, agentDevice(other, ScopeLiveRead), d) } select { @@ -949,9 +947,10 @@ func TestApprovalOutlastsAKeysPartThatIsAway(t *testing.T) { } } -// The offer: signed by the keys part's offer key, carrying the agent's key, -// the client, what it asks for and the code, and no home; in the QR, the -// link and the request's details alike. +// The offer: the contract's SignedOffer, which protocol.OpenOffer opens +// with the offer key pinned by its kid. It carries the agent's key, the +// client and its host, what it asks for, the code and an end within ten +// minutes, and no home; in the QR, the link and the request's details alike. func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopeDispatchHold) @@ -959,25 +958,28 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { if err != nil { t.Fatal(err) } - var so signedOffer + var so protocol.SignedOffer if err := cbor.Unmarshal(raw, &so); err != nil { t.Fatal(err) } pub, _ := g.keys.OfferKey(context.Background()) - if err := keys.VerifyOffer(pub, so.Offer, so.Sig); err != nil { - t.Fatalf("the offer does not verify: %v", err) - } - var o protocol.GrantOffer - if err := cbor.Unmarshal(so.Offer, &o); err != nil { - t.Fatal(err) + o, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, so, g.now().UnixMilli()) + if err != nil { + t.Fatalf("the offer does not open: %v", err) } - if o.V != protocol.GrantOfferVersion || o.Home != "" || !bytes.Equal(o.Key, f.agentKey(t)) || o.Role != protocol.RoleAgent || - o.Client != "Claude" || o.ClientID != cimdID || o.Code != f.view.Code || - !slices.Equal(o.Scopes, []string{ScopeDispatchHold, ScopeLiveRead}) || - !slices.Equal(o.Ops, []string{"battery.hold", "loadpoint.boost", "loadpoint.hold"}) || o.Caps.HoldS == 0 || - o.TTLS != uint64(protocol.AgentMaxMs/1000) { + if o.Home != "" || !bytes.Equal(o.Key, f.agentKey(t)) || o.Role != protocol.RoleAgent || o.Client != "Claude" || + o.ClientID != cimdID || o.ClientHost != "example.com" || o.Verified || o.Code != f.view.Code || + !slices.Equal(o.Scopes, []string{ScopeDispatchHold, ScopeLiveRead}) || !slices.Equal(o.Ops, []string{"loadpoint.hold"}) || + o.Caps.HoldS == 0 || o.TTLS != uint64(protocol.AgentMaxMs/1000) || o.ExpMs > g.now().UnixMilli()+protocol.OfferTTLMs { t.Fatalf("offer %+v", o) } + // Another key does not open it, and it ends. + if _, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): bytes.Repeat([]byte{4}, 65)}, so, g.now().UnixMilli()); err == nil { + t.Fatal("an offer opened with another key") + } + if _, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, so, o.ExpMs); err == nil { + t.Fatal("an offer opened at its end") + } // The page's link and QR carry the same offer, in the fragment. q, _ := g.authorizeParams(cimdID, cimdBack, "") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) @@ -985,14 +987,22 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { if !regexp.MustCompile(`href="https://app\.ftw\.energy/a/[A-Za-z0-9_-]{22}#[A-Za-z0-9_-]{200,}"`).Match(page) { t.Fatal("the app link does not carry the offer in its fragment") } + // A registered client is shown by its redirect URI's host. + resp2, _ := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(`{"client_name":"X","redirect_uris":["https://x.example/cb"]}`)) + var reg map[string]any + _ = json.NewDecoder(resp2.Body).Decode(®) + f2 := g.start(reg["client_id"].(string), "https://x.example/cb", "") + if f2.view.Client.Host != "x.example" { + t.Fatalf("a registered client's host: %q", f2.view.Client.Host) + } } func TestTheOfferKeyIsPublished(t *testing.T) { g := newRig(t) - var v struct{ Alg, Tag, Pub string } + var v struct{ Alg, Tag, Kid, Pub string } getJSON(t, g.srv.URL+"/.well-known/ftw-offer-key", &v) pub, _ := g.keys.OfferKey(context.Background()) - if v.Alg != "ES256" || v.Tag != keys.OfferTag || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { + if v.Alg != "ES256" || v.Tag != protocol.OfferTag || v.Kid != keys.OfferKid(pub) || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { t.Fatalf("offer key %+v", v) } } @@ -1027,7 +1037,7 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } g.roster(home, agentFor(key, cimdID, g.now().Add(3*time.Hour), ScopeLiveRead, ScopePlanRead, ScopeDispatchHold)) grant, _ = g.bearer(tk.Access) - if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "battery.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { + if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "loadpoint.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { t.Fatalf("after widening and renewing: %+v", grant) } g.advance(3*time.Hour + time.Second) diff --git a/internal/oauth/offer.go b/internal/oauth/offer.go index eedb7a4..d2437dd 100644 --- a/internal/oauth/offer.go +++ b/internal/oauth/offer.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw-cloud/internal/keys" "github.com/srcfl/ftw/go/protocol" ) @@ -18,13 +19,6 @@ import ( // fragment; the short code finds it once, for the box's page or an owner // who types it. -// signedOffer travels base64url without padding: the offer's deterministic -// CBOR and the offer key's signature over keys.OfferBytes of it. -type signedOffer struct { - Offer []byte `cbor:"offer"` - Sig []byte `cbor:"sig"` -} - var detEnc = func() cbor.EncMode { m, err := cbor.CoreDetEncOptions().EncMode() if err != nil { @@ -56,12 +50,18 @@ func buildOffer(c consent) protocol.GrantOffer { caps.PerMin = protocol.PerMinDefault } return protocol.GrantOffer{V: protocol.GrantOfferVersion, Key: c.AgentKey, Role: protocol.RoleAgent, - Client: c.ClientName, ClientID: c.ClientID, Scopes: scopes, Ops: ops, Caps: caps, - TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code} + Client: c.ClientName, ClientID: c.ClientID, ClientHost: c.ClientHost, Verified: c.Verified, + Scopes: scopes, Ops: ops, Caps: caps, TTLS: uint64(protocol.AgentMaxMs / 1000), Code: c.Code, + ExpMs: min(c.Expires.UnixMilli(), c.Created.UnixMilli()+protocol.OfferTTLMs)} } -// signOffer encodes and signs an offer, for the link's fragment. +// signOffer encodes and signs an offer as the contract's SignedOffer +// {o, kid, sig}, base64url, for the link's fragment and the code lookup. func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { + pub, err := s.offerPub(ctx) + if err != nil { + return "", err + } raw, err := detEnc.Marshal(o) if err != nil { return "", err @@ -70,13 +70,31 @@ func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, if err != nil { return "", err } - out, err := detEnc.Marshal(signedOffer{Offer: raw, Sig: sig}) + out, err := detEnc.Marshal(protocol.SignedOffer{O: raw, Kid: keys.OfferKid(pub), Sig: sig}) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(out), nil } +// offerPub is the offer key's public half, asked of the keys part once. +func (s *Server) offerPub(ctx context.Context) ([]byte, error) { + s.offerKey.mu.Lock() + pub := s.offerKey.pub + s.offerKey.mu.Unlock() + if pub != nil { + return pub, nil + } + pub, err := s.keys.OfferKey(ctx) + if err != nil { + return nil, err + } + s.offerKey.mu.Lock() + s.offerKey.pub = pub + s.offerKey.mu.Unlock() + return pub, nil +} + // appLink is the universal link the QR and the button carry: the request // id in the path, for the app's answer, and the signed offer in the // fragment, which no server sees. @@ -105,19 +123,12 @@ type offerKey struct { } func (s *Server) serveOfferKey(w http.ResponseWriter, r *http.Request) { - s.offerKey.mu.Lock() - pub := s.offerKey.pub - s.offerKey.mu.Unlock() - if pub == nil { - p, err := s.keys.OfferKey(r.Context()) - if err != nil { - http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) - return - } - s.offerKey.mu.Lock() - s.offerKey.pub, pub = p, p - s.offerKey.mu.Unlock() + pub, err := s.offerPub(r.Context()) + if err != nil { + http.Error(w, "the offer key is not available", http.StatusServiceUnavailable) + return } - writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": "ftw.offer.v1", + w.Header().Set("Access-Control-Allow-Origin", "*") + writeJSON(w, http.StatusOK, map[string]any{"alg": "ES256", "tag": protocol.OfferTag, "kid": keys.OfferKid(pub), "pub": base64.RawURLEncoding.EncodeToString(pub)}, true) } From 41d6aaecb106e4576c3cbe23844a993f6b20ad0f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:44:22 +0200 Subject: [PATCH 6/8] chore(oauth): pin the agent contract at e2559632, where protocol.OfferKid 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 --- internal/oauth/oauth_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index cbed988..eb04cb7 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -1002,6 +1002,10 @@ func TestTheOfferKeyIsPublished(t *testing.T) { var v struct{ Alg, Tag, Kid, Pub string } getJSON(t, g.srv.URL+"/.well-known/ftw-offer-key", &v) pub, _ := g.keys.OfferKey(context.Background()) + // The kid is the contract's, so Core's pin list takes it as it comes. + if keys.OfferKid(pub) != protocol.OfferKid(pub) { + t.Fatal("the keys part's kid is not the contract's") + } if v.Alg != "ES256" || v.Tag != protocol.OfferTag || v.Kid != keys.OfferKid(pub) || v.Pub != base64.RawURLEncoding.EncodeToString(pub) { t.Fatalf("offer key %+v", v) } From 79e7c1c3ee5cd6d6caf01ad9ece105fade05c4ae Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:25:51 +0200 Subject: [PATCH 7/8] fix(oauth): a token allows only what it carries and the box still grants Review of srcfl/ftw-cloud#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 --- README.md | 7 ++--- internal/oauth/clients.go | 1 + internal/oauth/oauth_test.go | 38 ++++++++++++++++++++++++--- internal/oauth/token.go | 51 ++++++++++++++++++++++++++++++++---- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4e45f41..947de8b 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,10 @@ to it yet. Only the browser that started the request gets it. Access tokens last 10 minutes; refresh tokens rotate, and a reused one revokes its family. A token names its home (`ftwat_.`) so any instance can route - it; the service keeps hashes, never tokens. A token carries the grant the - newest roster lists, narrowed or widened at once, and stops at the - agent's end by the service's own clock. A roster without the agent ends + it; the service keeps hashes, never tokens. A token allows the scopes it + carries that the newest roster still grants, so an owner who narrows a + grant narrows it at once, and it stops at the agent's end by the + service's own clock. A roster without the agent ends its tokens and its key. Not built yet: diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 0cf55c7..1b8a82b 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -126,6 +126,7 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") return } + readDeadline(w) var reg registration body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10)) if err != nil || json.Unmarshal(body, ®) != nil { diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index eb04cb7..7d13546 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -1026,9 +1026,10 @@ func TestTokensNameTheirHome(t *testing.T) { } } -// The grant is what the newest roster lists: an owner who narrows it -// narrows the token at once, one who renews it extends it, and an end -// reached by the service's own clock stops the agent without a roster. +// The grant is what the token carries and the newest roster still lists: +// an owner who narrows it narrows the token at once, wider rights need a new +// consent, a renewal extends the agent, and an end reached by the service's +// own clock stops it without a roster. func TestTheGrantFollowsTheRoster(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead) @@ -1041,7 +1042,8 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } g.roster(home, agentFor(key, cimdID, g.now().Add(3*time.Hour), ScopeLiveRead, ScopePlanRead, ScopeDispatchHold)) grant, _ = g.bearer(tk.Access) - if !grant.Has(ScopeDispatchHold) || !slices.Contains(grant.Ops, "loadpoint.hold") || grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { + if grant.Has(ScopeDispatchHold) || !grant.Has(ScopePlanRead) || !slices.Contains(grant.Ops, "loadpoint.hold") || + grant.AgentExpires.Before(g.now().Add(2*time.Hour)) { t.Fatalf("after widening and renewing: %+v", grant) } g.advance(3*time.Hour + time.Second) @@ -1050,6 +1052,34 @@ func TestTheGrantFollowsTheRoster(t *testing.T) { } } +// A refresh that narrows the scope narrows what the new token allows. +func TestARefreshCanNarrow(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead) + tk := g.approve(f, cimdID, ScopeLiveRead, ScopePlanRead) + status, tk2 := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}, "scope": {ScopeLiveRead}}) + if status != http.StatusOK { + t.Fatalf("refresh: %d", status) + } + grant, aerr := g.bearer(tk2.Access) + if aerr != nil || grant.Has(ScopePlanRead) || !grant.Has(ScopeLiveRead) { + t.Fatalf("a narrowed token: %+v %v", grant, aerr) + } +} + +func TestTokenShape(t *testing.T) { + good := accessPrefix + home + "." + randomToken(32) + if !tokenShape(good, accessPrefix) { + t.Fatal("a token this server makes") + } + for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), refreshPrefix + home + "." + randomToken(32), + accessPrefix + "HOME." + randomToken(32), accessPrefix + home + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { + if tokenShape(bad, accessPrefix) { + t.Errorf("%q passed", bad) + } + } +} + // Only clients FTW Cloud knows, by their document's host, are verified. func TestVerifiedHosts(t *testing.T) { for host, want := range map[string]bool{"claude.ai": true, "www.claude.ai": true, "chatgpt.com": true, "grok.com": true, diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 7ebfabb..e47a6fa 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -23,6 +23,31 @@ const ( refreshPrefix = "ftwrt_" ) +// tokenShape is a token as this server makes one: the prefix, a home id, a +// dot and 43 characters of base64url. Anything else is refused before the +// store is asked. +func tokenShape(tok, prefix string) bool { + rest, ok := strings.CutPrefix(tok, prefix) + if !ok { + return false + } + home, secret, ok := strings.Cut(rest, ".") + if !ok || len(home) == 0 || len(home) > 64 || len(secret) != 43 { + return false + } + for _, c := range home { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { + return false + } + } + for _, c := range secret { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { + return false + } + } + return true +} + // TokenHome is the home a token names, or "" for something else. It // proves nothing: only the stored hash does. func TokenHome(tok string) string { @@ -37,6 +62,7 @@ func TokenHome(tok string) string { } func (s *Server) serveToken(w http.ResponseWriter, r *http.Request) { + readDeadline(w) r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") @@ -184,6 +210,7 @@ func tokenError(w http.ResponseWriter, status int, code, desc string) { // serveRevoke is RFC 7009: revoking a refresh token revokes its family. It // answers 200 whether or not the token was known. func (s *Server) serveRevoke(w http.ResponseWriter, r *http.Request) { + readDeadline(w) r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { tokenError(w, http.StatusBadRequest, "invalid_request", "the body is not a form") @@ -226,9 +253,11 @@ type Subject struct { // SubjectAgent is a personal agent an owner approved. const SubjectAgent = "agent" -// Grant is what one valid access token allows: the rights the box grants -// its agent now, as the newest roster lists them. An owner who narrows the -// grant narrows the token at once; one who widens it widens the token too. +// Grant is what one valid access token allows: the scopes the token +// carries that the box still grants its agent, as the newest roster lists +// them. An owner who narrows the grant narrows the token at once; wider +// rights need a new consent, so a token never holds more than its client +// was given. type Grant struct { Subject Subject ClientID string @@ -263,7 +292,11 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { return nil, &AuthError{Status: http.StatusUnauthorized, Scope: strings.Join(ReadScopes, " ")} } invalid := &AuthError{Status: http.StatusUnauthorized, Code: "invalid_token", Desc: "the access token is not valid"} - t, ok, err := s.st.token(strings.TrimSpace(tok)) + tok = strings.TrimSpace(tok) + if !tokenShape(tok, accessPrefix) { + return nil, invalid + } + t, ok, err := s.st.token(tok) if err != nil || !ok || t.Kind != kindAccess || !s.now().Before(t.Expires) { return nil, invalid } @@ -279,7 +312,8 @@ func (s *Server) Authenticate(r *http.Request) (*Grant, *AuthError) { } return &Grant{ Subject: Subject{Kind: SubjectAgent, Shard: a.Shard, Home: a.Home, Agent: a.Agent, Key: a.Key, Kid: a.Kid}, - ClientID: t.ClientID, Scopes: a.Scopes, Ops: a.Ops, Caps: a.Caps, Expires: t.Expires, AgentExpires: a.Expires, + ClientID: t.ClientID, Scopes: intersect(splitScope(t.Scope), a.Scopes), Ops: a.Ops, Caps: a.Caps, + Expires: t.Expires, AgentExpires: a.Expires, }, nil } @@ -309,3 +343,10 @@ func InsufficientScope(scopes ...string) *AuthError { return &AuthError{Status: http.StatusForbidden, Code: "insufficient_scope", Desc: "this needs more rights; ask the owner to approve them", Scope: strings.Join(scopes, " ")} } + +// readDeadline bounds how long a client may take to send its request's +// body. The server sets no ReadTimeout of its own, since its WebSockets set +// their own deadlines. +func readDeadline(w http.ResponseWriter) { + _ = http.NewResponseController(w).SetReadDeadline(time.Now().Add(10 * time.Second)) +} From fccb46d0ca068352974fbec1ab6c4de8cc6469e1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 23:04:44 +0200 Subject: [PATCH 8/8] fix(oauth): review of #19: the newest roster only, purges, no open redirect, 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 --- README.md | 21 +- cmd/ftwcloudd/main.go | 2 + internal/oauth/authorize.go | 113 ++++++--- internal/oauth/cimd.go | 115 +++++---- internal/oauth/cimd_test.go | 77 +++++- internal/oauth/clients.go | 26 ++- internal/oauth/oauth.go | 411 +++++++++++++++++++++++++++------ internal/oauth/oauth_test.go | 232 +++++++++++++++---- internal/oauth/offer.go | 34 ++- internal/oauth/page.go | 27 ++- internal/oauth/review_test.go | 401 ++++++++++++++++++++++++++++++++ internal/oauth/store.go | 280 ++++++++++++++++------ internal/oauth/token.go | 133 ++++++++--- internal/store/store.go | 10 + internal/store/store_test.go | 32 +++ internal/uplink/uplink.go | 7 + internal/uplink/uplink_test.go | 10 + 17 files changed, 1591 insertions(+), 340 deletions(-) create mode 100644 internal/oauth/review_test.go diff --git a/README.md b/README.md index 947de8b..7b249ac 100644 --- a/README.md +++ b/README.md @@ -140,22 +140,31 @@ to it yet. client, the host it is known by and whether FTW Cloud knows it, the scopes, ops and caps asked for, the page's code, and an end ten minutes out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the - rights, the code, a QR and `https://app.ftw.energy/a/#`; + rights, the code, a QR and `https://app.ftw.energy/a#.`; the app checks the offer against the offer key it pins by kid, which `/.well-known/ftw-offer-key` serves. The box's page or an owner who types - the code finds the offer once at `/oauth/requests?code=`. The owner + the eight-character code finds the offer once at `/oauth/requests?code=`. + A client FTW Cloud knows by the exact URL of its document is shown as + verified, and an error goes back to it at once; any other gets a page + with a link back. Each endpoint a stranger can call is limited per + address and for everyone, before anything is fetched. The owner approves in the app or on the box's page; the box lists the agent's key, for that client, in the roster it signs (an agent row, with no tunnel of its own), and when that roster arrives the page, waiting on a server-sent event, returns to the client with a code. Only the browser that started the request gets it. Access tokens last 10 - minutes; refresh tokens rotate, and a reused one revokes its family. A - token names its home (`ftwat_.`) so any instance can route - it; the service keeps hashes, never tokens. A token allows the scopes it + minutes; refresh tokens rotate, a retry within 5 s works once more, and a + later reuse revokes the family, as does a code used twice, even after its + consent is gone. A token carries its home sealed with a fresh nonce under + the route key (`oauth-route.key`, shared by every instance that routes), + so any instance can route it and no client sees an id that links its + tokens; the service keeps hashes, never tokens. A token allows the scopes it carries that the newest roster still grants, so an owner who narrows a grant narrows it at once, and it stops at the agent's end by the service's own clock. A roster without the agent ends - its tokens and its key. + its tokens and its key, and so does a second home listing the same key, + or the box purging its home; a key the keys part could not forget stays + queued until it has. Only each home's newest roster is applied. Not built yet: diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 9dde1cd..5e24efe 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -149,6 +149,8 @@ func serve(args []string) error { auth.Roster(home, r) } up.OnGone = pushSvc.BoxGone + // A purged home's agents, their tokens and keys, and its consents go. + up.OnPurge = auth.DropHome // Phones register their tokens over their app session, so without a // Noise key only the development route registers one. diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 932b78d..ff2a6f0 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -18,9 +18,9 @@ import ( // codeAlphabet makes the short code: no 0/O, 1/I or L to misread. const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -// codeLen gives about 29 bits. A code finds a request for the box's page and -// proves nothing: the owner still approves on their own box. -const codeLen = 6 +// codeLen gives about 39 bits. A code finds a request for the box's page +// once, and proves nothing: the owner still approves on their own box. +const codeLen = 8 var challengeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) @@ -42,6 +42,14 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } } + // The limits come before anything is fetched or made: each request may + // fetch a client's document and makes a key. + if s.limited(&s.authorizeLimit, s.authorizeByIP, r) { + w.Header().Set("Retry-After", "10") + s.authorizePage(w, http.StatusTooManyRequests, pageError{Title: "Too many requests", + Text: "FTW Cloud is busy connecting assistants. Try again in a little while."}) + return + } ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) defer cancel() @@ -54,6 +62,7 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } redirect := q.Get("redirect_uri") + given := redirect != "" if redirect == "" && len(c.RedirectURIs) == 1 { redirect = c.RedirectURIs[0] } @@ -63,8 +72,19 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { return } req := authRequest{client: c, redirectURI: redirect, state: q.Get("state")} + // An error goes back to a client FTW Cloud knows at once. Any other + // client gets a page with a link back to its host, and the person + // decides: no stranger's URL can bounce a browser through here before + // the owner acts (RFC 9700, 4.11.2). fail := func(code, desc string) { - http.Redirect(w, r, authorizationError(redirect, code, desc, req.state), http.StatusFound) + back := authorizationError(redirect, code, desc, req.state) + if c.Verified { + http.Redirect(w, r, back, http.StatusFound) + return + } + u, _ := url.Parse(redirect) + s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This request cannot go on", + Text: "FTW Cloud cannot connect this assistant: " + desc + ".", Back: back, BackHost: u.Host}) } if len(req.state) > 1024 { fail("invalid_request", "state is too long") @@ -99,10 +119,6 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { if len(req.scope) == 0 { req.scope = slices.Clone(ReadScopes) } - if !s.authorizeLimit.allow(s.now()) { - fail("temporarily_unavailable", "too many requests; try again shortly") - return - } // The agent's key pair comes first; only its public half leaves the // keys part, to be shown to the owner and listed by the box. @@ -124,11 +140,11 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } } con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: host, Verified: c.Verified, RedirectURI: redirect, - State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), Resource: Resource, - Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} + RedirectGiven: given, State: req.state, Challenge: req.challenge, Scope: strings.Join(req.scope, " "), + Resource: Resource, Kid: kid, AgentKey: pub, Created: now, Expires: now.Add(RequestTTL), Status: statusPending} for try := 0; ; try++ { con.Code = shortCode() - if con.Offer, err = s.signOffer(ctx, buildOffer(con)); err != nil { + if con.Offer, err = s.signOffer(ctx, kid, buildOffer(con)); err != nil { s.log.Warn("the keys part could not sign an offer", "err", err) break } @@ -138,7 +154,8 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { } } if err != nil { - _ = s.keys.Forget(ctx, kid) + _ = s.st.queueForget(kid, now) + s.forget(ctx, kid) fail("server_error", "could not start the request") return } @@ -163,17 +180,28 @@ func validScope(sc string) bool { return slices.Contains(AllScopes, sc) } func cookieName(rid string) string { return "__Host-ftw-consent-" + rid[:8] } +// shortCode draws codeLen characters evenly from codeAlphabet: a byte at or +// above the last whole multiple of the alphabet's size is drawn again. func shortCode() string { - b := make([]byte, codeLen) - if _, err := rand.Read(b); err != nil { - panic(err) - } - for i := range b { - b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] + const limit = 256 - 256%len(codeAlphabet) + out := make([]byte, 0, codeLen) + b := make([]byte, 2*codeLen) + for len(out) < codeLen { + if _, err := rand.Read(b); err != nil { + panic(err) + } + for _, x := range b { + if int(x) < limit && len(out) < codeLen { + out = append(out, codeAlphabet[int(x)%len(codeAlphabet)]) + } + } } - return string(b) + return string(out) } +// showCode is the code as the page shows it, in two halves. +func showCode(code string) string { return code[:codeLen/2] + "-" + code[codeLen/2:] } + // normalizeCode reads a code as an owner may type it. func normalizeCode(s string) string { s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(s)) @@ -269,11 +297,12 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { // serveRequestByCode finds a request by the code an owner typed on the box's // page or in the app, once: a short code cannot carry the agent's key, so -// it fetches the signed offer. Lookups are rate limited for everyone -// together, and a code works once: it lasts ten minutes and has about 29 -// bits, and finding one shows only an offer the owner must still approve. +// it fetches the signed offer. Lookups are rate limited per address and for +// everyone together, and a code works once: it lasts ten minutes and has +// about 39 bits, and finding one shows only an offer the owner must still +// approve. func (s *Server) serveRequestByCode(w http.ResponseWriter, r *http.Request) { - if !s.lookupLimit.allow(s.now()) { + if s.limited(&s.lookupLimit, s.lookupByIP, r) { w.Header().Set("Retry-After", "1") http.Error(w, "too many lookups", http.StatusTooManyRequests) return @@ -305,13 +334,13 @@ func allowApp(w http.ResponseWriter, r *http.Request) { // the person at the computer cancelled. func (s *Server) serveDeny(w http.ResponseWriter, r *http.Request) { rid := r.PathValue("rid") - c, ok, err := s.st.deny(rid) + c, ok, err := s.st.deny(rid, s.now()) if err != nil { http.Error(w, "could not deny", http.StatusInternalServerError) return } if ok { - _ = s.keys.Forget(r.Context(), c.Kid) + s.forget(r.Context(), c.Kid) s.hub.notify(string(hash(rid))) } allowApp(w, r) @@ -328,7 +357,8 @@ const keepAlive = 20 * time.Second func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { rid := r.PathValue("rid") - if _, ok, err := s.st.consentByRID(rid); err != nil || !ok { + c, ok, err := s.st.consentByRID(rid) + if err != nil || !ok { http.NotFound(w, r) return } @@ -337,18 +367,35 @@ func (s *Server) serveEvents(w http.ResponseWriter, r *http.Request) { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } - // Only the browser that started the request gets the code. + // Only the browser that started the request gets the code, and keeps a + // stream open: anyone else gets the state once. var browser string if ck, err := r.Cookie(cookieName(rid)); err == nil && s.st.browserMatches(rid, ck.Value) { browser = ck.Value } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Accel-Buffering", "no") - w.WriteHeader(http.StatusOK) - - wake, done := s.hub.subscribe(string(hash(rid))) + head := func() { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + } + if browser == "" { + head() + s.event(w, rid, "", c) + flusher.Flush() + return + } + done, ok := s.streams.open(rid) + if !ok { + w.Header().Set("Retry-After", "5") + http.Error(w, "too many streams", http.StatusTooManyRequests) + return + } defer done() + head() + + wake, unsubscribe := s.hub.subscribe(string(hash(rid))) + defer unsubscribe() tick := time.NewTicker(keepAlive) defer tick.Stop() // A pending request's end does not move; once it changes state the diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go index 65a38c3..75e27f4 100644 --- a/internal/oauth/cimd.go +++ b/internal/oauth/cimd.go @@ -27,7 +27,8 @@ import ( // to a DNS name, only to public addresses (checked on the address actually // dialled, so a name that changes its answer between lookup and connect // gains nothing), no proxy, no redirects, no cookies, a small body and a -// short time, a few at once, and the answers cached. +// short time, a few at once and a few per host, and the answers cached: +// documents for at least a minute, failures apart for a minute. const ( cimdMaxBytes = 16 << 10 @@ -38,7 +39,12 @@ const ( cimdCacheNone = 10 * time.Minute // with no Cache-Control cimdCacheFailed = time.Minute cimdCacheSize = 1024 + cimdFailedSize = 256 cimdConcurrent = 8 + // Per host: a few fetches at once, then one every ten seconds. + cimdHostBurst = 4 + cimdHostRate = 0.1 + cimdHosts = 4096 ) // clientMetadata is the part of a metadata document the service reads. @@ -73,9 +79,13 @@ type fetcher struct { timeout time.Duration now func() time.Time sem chan struct{} + // hostBurst and hostRate bound fetches per host. + hostBurst, hostRate float64 - mu sync.Mutex - cache map[string]cimdEntry + mu sync.Mutex + cache map[string]cimdEntry + failed map[string]cimdEntry + hosts map[string]*limiter } type cimdEntry struct { @@ -84,17 +94,39 @@ type cimdEntry struct { until time.Time } +// put keeps an entry in a bounded cache: what has run out goes first, then +// whatever the map yields. +func put(cache map[string]cimdEntry, size int, key string, e cimdEntry, now time.Time) { + if len(cache) >= size { + for k, old := range cache { + if !now.Before(old.until) { + delete(cache, k) + } + } + for k := range cache { + if len(cache) < size { + break + } + delete(cache, k) + } + } + cache[key] = e +} + // newFetcher builds the fenced fetcher. roots is nil for the system's. func newFetcher(roots *x509.CertPool) *fetcher { f := &fetcher{ resolve: func(ctx context.Context, host string) ([]netip.Addr, error) { return net.DefaultResolver.LookupNetIP(ctx, "ip", host) }, - public: publicAddr, - timeout: cimdTimeout, - now: time.Now, - sem: make(chan struct{}, cimdConcurrent), - cache: map[string]cimdEntry{}, + public: publicAddr, + timeout: cimdTimeout, + now: time.Now, + sem: make(chan struct{}, cimdConcurrent), + hostBurst: cimdHostBurst, hostRate: cimdHostRate, + cache: map[string]cimdEntry{}, + failed: map[string]cimdEntry{}, + hosts: map[string]*limiter{}, } // The address is checked again as the socket connects, on the address // itself. @@ -188,11 +220,26 @@ func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, err } now := f.now() f.mu.Lock() - if e, ok := f.cache[clientID]; ok && now.Before(e.until) { - f.mu.Unlock() - return e.md, e.err + for _, c := range []map[string]cimdEntry{f.cache, f.failed} { + if e, ok := c[clientID]; ok && now.Before(e.until) { + f.mu.Unlock() + return e.md, e.err + } + } + host := strings.ToLower(u.Hostname()) + hl := f.hosts[host] + if hl == nil { + if len(f.hosts) >= cimdHosts { + clear(f.hosts) + } + l := newLimiter(f.hostBurst, f.hostRate) + hl = &l + f.hosts[host] = hl } f.mu.Unlock() + if !hl.allow(now) { + return clientMetadata{}, fmt.Errorf("%w: too many fetches from %s", errCIMDFetch, host) + } select { case f.sem <- struct{}{}: @@ -201,23 +248,12 @@ func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, err return clientMetadata{}, errCIMDFetch } md, ttl, err := f.fetch(ctx, u) - if err != nil { - ttl = cimdCacheFailed - } f.mu.Lock() defer f.mu.Unlock() - if len(f.cache) >= cimdCacheSize { - for k, e := range f.cache { - if !now.Before(e.until) || len(f.cache) >= cimdCacheSize { - delete(f.cache, k) - } - if len(f.cache) < cimdCacheSize/2 { - break - } - } - } - if ttl > 0 { - f.cache[clientID] = cimdEntry{md: md, err: err, until: now.Add(ttl)} + if err != nil { + put(f.failed, cimdFailedSize, clientID, cimdEntry{err: err, until: now.Add(cimdCacheFailed)}, now) + } else { + put(f.cache, cimdCacheSize, clientID, cimdEntry{md: md, until: now.Add(ttl)}, now) } return md, err } @@ -291,13 +327,19 @@ func parseMetadata(body []byte, clientID string) (clientMetadata, error) { if m := md.TokenEndpointAuthMethod; m != "" && m != authNone { return clientMetadata{}, fmt.Errorf("%w: token_endpoint_auth_method %q is not supported", errCIMDDocument, m) } - if !subset(md.GrantTypes, []string{grantCode, grantRefresh}) || !subset(md.ResponseTypes, []string{"code"}) { + // A client may list grants this server does not offer (Claude lists + // the JWT bearer grant); it gets the code and refresh grants only. It + // must use the code grant with a code response. + if len(md.GrantTypes) > 0 && !slices.Contains(md.GrantTypes, grantCode) || + len(md.ResponseTypes) > 0 && !slices.Contains(md.ResponseTypes, "code") { return clientMetadata{}, fmt.Errorf("%w: grant or response types", errCIMDDocument) } return md, nil } -// cacheFor reads how long a document may be kept, within bounds. +// cacheFor reads how long a document may be kept, within bounds. Even a +// document that asks not to be kept is kept a minute, so a page that loads +// it again and again cannot make the service fetch it each time. func cacheFor(cc string) time.Duration { if cc == "" { return cimdCacheNone @@ -306,7 +348,7 @@ func cacheFor(cc string) time.Duration { d = strings.ToLower(strings.TrimSpace(d)) switch { case d == "no-store" || d == "no-cache": - return 0 + return cimdCacheMin case strings.HasPrefix(d, "max-age="): s, err := strconv.Atoi(strings.TrimPrefix(d, "max-age=")) if err != nil || s < 0 { @@ -320,8 +362,8 @@ func cacheFor(cc string) time.Duration { // publicAddr is an address on the public internet: not loopback, private, // link-local (where the cloud's metadata service answers), multicast, -// shared, reserved, documentation, or a translation prefix that could lead -// back inside. +// shared, reserved, documentation, IPv4-compatible IPv6, or a translation +// prefix that could lead back inside. func publicAddr(a netip.Addr) bool { a = a.Unmap() if !a.IsValid() || a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || @@ -341,7 +383,7 @@ var blocked = func() []netip.Prefix { for _, s := range []string{ "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "255.255.255.255/32", - "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", + "::/96", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", "2001:db8::/32", "2002::/16", "fec0::/10", } { out = append(out, netip.MustParsePrefix(s)) } @@ -364,12 +406,3 @@ func cleanName(s string) string { } return s } - -func subset(have, allowed []string) bool { - for _, h := range have { - if !slices.Contains(allowed, h) { - return false - } - } - return true -} diff --git a/internal/oauth/cimd_test.go b/internal/oauth/cimd_test.go index db50061..1f92cc4 100644 --- a/internal/oauth/cimd_test.go +++ b/internal/oauth/cimd_test.go @@ -3,6 +3,7 @@ package oauth import ( "context" "errors" + "fmt" "net" "net/http" "net/netip" @@ -47,6 +48,7 @@ func TestPublicAddresses(t *testing.T) { "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.40", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "192.0.2.1", "::1", "::", "fe80::1", "fd00::1", "fc00::1", "::ffff:10.0.0.1", "::ffff:169.254.169.254", "64:ff9b::a00:1", "2002:a00:1::", "2001:db8::1", "ff02::1", + "::a00:1", "::a9fe:a9fe", "::1.1.1.1", } { if publicAddr(netip.MustParseAddr(s)) { t.Errorf("%s counts as public", s) @@ -193,7 +195,7 @@ func TestCIMDServerMisbehaviour(t *testing.T) { func TestCacheFor(t *testing.T) { for cc, want := range map[string]time.Duration{ "": cimdCacheNone, - "no-store": 0, + "no-store": cimdCacheMin, "max-age=0": cimdCacheMin, "public, max-age=3600": time.Hour, "max-age=999999999": cimdCacheMax, @@ -213,3 +215,76 @@ func TestCIMDIgnoresProxies(t *testing.T) { t.Fatal("the fetcher reads a proxy") } } + +// Fetches are capped per host, so one host cannot make the service fetch +// for it again and again; a refusal of that kind is not cached. +func TestCIMDFetchesPerHostAreCapped(t *testing.T) { + g := newRig(t) + g.s.fetch.hostBurst, g.s.fetch.hostRate = 2, 0.0001 + var errs []error + for i := range 3 { + _, err := g.s.fetch.get(context.Background(), fmt.Sprintf("https://example.com/c%d.json", i)) + errs = append(errs, err) + } + if errs[2] == nil || !strings.Contains(errs[2].Error(), "too many fetches") { + t.Fatalf("the third fetch from one host: %v", errs) + } + // A cached document needs no fetch. + if _, err := g.s.fetch.get(context.Background(), "https://example.com/c0.json"); err == nil || + strings.Contains(err.Error(), "too many fetches") { + t.Fatalf("a cached failure was fetched again: %v", err) + } +} + +// Failures have a small cache of their own: many of them do not push a +// good document out, and the good one is not fetched again. +func TestFailuresDoNotPushOutDocuments(t *testing.T) { + g := newRig(t) + var hits atomic.Int32 + inner := g.cimd.Config.Handler + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + inner.ServeHTTP(w, r) + }) + if _, err := g.s.fetch.get(context.Background(), cimdID); err != nil { + t.Fatal(err) + } + for i := range cimdFailedSize * 2 { + _, _ = g.s.fetch.get(context.Background(), fmt.Sprintf("https://example.com/missing%d.json", i)) + } + before := hits.Load() + if _, err := g.s.fetch.get(context.Background(), cimdID); err != nil || hits.Load() != before { + t.Fatalf("the good document was pushed out: %v, %d fetches", err, hits.Load()-before) + } + g.s.fetch.mu.Lock() + n := len(g.s.fetch.failed) + g.s.fetch.mu.Unlock() + if n > cimdFailedSize { + t.Fatalf("%d failures kept", n) + } +} + +// Claude's own document, as it was served on 15 Oct 2026, lists a grant +// this server does not offer. It is accepted; the client gets the code and +// refresh grants. +func TestClaudesDocumentIsAccepted(t *testing.T) { + const id = "https://claude.ai/oauth/mcp-oauth-client-metadata" + doc := `{"client_id":"https://claude.ai/oauth/mcp-oauth-client-metadata","client_name":"Claude","client_uri":"https://claude.ai",` + + `"redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"grant_types":["authorization_code","refresh_token",` + + `"urn:ietf:params:oauth:grant-type:jwt-bearer"],"response_types":["code"],"token_endpoint_auth_method":"none"}` + md, err := parseMetadata([]byte(doc), id) + if err != nil || md.ClientName != "Claude" || md.RedirectURIs[0] != "https://claude.ai/api/mcp/auth_callback" { + t.Fatalf("%+v %v", md, err) + } + if !verifiedClient(id) { + t.Fatal("Claude is not on the list") + } + for _, bad := range []string{ + `{"client_id":"` + id + `","client_name":"X","redirect_uris":["https://x.example/cb"],"grant_types":["client_credentials"]}`, + `{"client_id":"` + id + `","client_name":"X","redirect_uris":["https://x.example/cb"],"response_types":["token"]}`, + } { + if _, err := parseMetadata([]byte(bad), id); err == nil { + t.Errorf("accepted %s", bad) + } + } +} diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index 1b8a82b..f30fa2d 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -9,7 +9,10 @@ import ( "io" "net/http" "net/url" + "slices" "strings" + + "github.com/srcfl/ftw/go/protocol" ) // client is a client as the authorize and token endpoints see it, from a @@ -21,8 +24,8 @@ type client struct { // document's URL, which vouches for it, or the client_uri a registration // claimed, which does not. Host string - // Verified is a client FTW Cloud knows: a metadata document on one of - // verifiedHosts. + // Verified is a client FTW Cloud knows: a metadata document at one of + // verifiedClients. Verified bool RedirectURIs []string AuthMethod string @@ -33,13 +36,18 @@ var errUnknownClient = errors.New("unknown client") // resolveClient finds the client a client_id names. func (s *Server) resolveClient(ctx context.Context, id string) (client, error) { + if id == protocol.SupportClientID { + // Support's offers come from its own tooling, never through this + // page: the box and the app trust that client id for support. + return client{}, errUnknownClient + } if isCIMD(id) { md, err := s.fetch.get(ctx, id) if err != nil { return client{}, err } u, _ := url.Parse(id) - return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedHost(u.Hostname()), RedirectURIs: md.RedirectURIs, + return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedClient(id), RedirectURIs: md.RedirectURIs, AuthMethod: authNone}, nil } c, ok, err := s.st.client(id) @@ -121,7 +129,7 @@ type registration struct { } func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { - if !s.registerLimit.allow(s.now()) { + if s.limited(&s.registerLimit, s.registerByIP, r) { w.Header().Set("Retry-After", "10") registerError(w, http.StatusTooManyRequests, "invalid_client_metadata", "too many registrations; try again shortly") return @@ -151,12 +159,14 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) { registerError(w, http.StatusBadRequest, "invalid_client_metadata", "token_endpoint_auth_method must be none, client_secret_post or client_secret_basic") return } - grants := reg.GrantTypes - if len(grants) == 0 { + // Grants this server does not offer are left out of what it registers + // (RFC 7591 lets it replace them), as for a metadata document. + grants := intersect(reg.GrantTypes, []string{grantCode, grantRefresh}) + if len(reg.GrantTypes) == 0 { grants = []string{grantCode} } - if !subset(grants, []string{grantCode, grantRefresh}) || !subset(reg.ResponseTypes, []string{"code"}) { - registerError(w, http.StatusBadRequest, "invalid_client_metadata", "only authorization_code and refresh_token, with response type code") + if !slices.Contains(grants, grantCode) || len(reg.ResponseTypes) > 0 && !slices.Contains(reg.ResponseTypes, "code") { + registerError(w, http.StatusBadRequest, "invalid_client_metadata", "the authorization_code grant, with response type code, is required") return } for _, sc := range splitScope(reg.Scope) { diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 2d118e0..ded9a6b 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -23,13 +23,19 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "errors" "log/slog" + "net" "net/http" + "net/netip" + "path/filepath" "slices" "strings" "sync" + "sync/atomic" "time" + "github.com/srcfl/ftw-cloud/internal/keys" "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) @@ -42,8 +48,9 @@ const ( // Resource is the MCP server's canonical URI: every token's audience. Resource = Issuer + "/mcp" // AppLink is where the QR and the button send the owner: a universal - // link that opens the FTW app, followed by the request id. - AppLink = "https://app.ftw.energy/a/" + // link that opens the FTW app. Its fragment carries the request id and + // the signed offer, so neither reaches a server's log. + AppLink = "https://app.ftw.energy/a" // AppOrigin may read a request's details from a browser. AppOrigin = "https://app.ftw.energy" ) @@ -117,7 +124,8 @@ type AgentKeys interface { New(ctx context.Context) (kid string, pub []byte, err error) Keep(ctx context.Context, kid string) error Forget(ctx context.Context, kid string) error - SignOffer(ctx context.Context, offer []byte) ([]byte, error) + // SignOffer signs an offer for key kid, which must still be pending. + SignOffer(ctx context.Context, kid string, offer []byte) ([]byte, error) OfferKey(ctx context.Context) ([]byte, error) } @@ -129,10 +137,21 @@ type Server struct { log *slog.Logger now func() time.Time hub hub + route routeKey + // Each endpoint a stranger can call has a bucket for everyone and one + // per address. authorizeLimit, registerLimit, lookupLimit limiter + authorizeByIP, registerByIP, lookupByIP *ipLimiter + streams streams - roster chan rosterEvent + // The newest roster, or purge, per home, not applied yet; and homes + // whose approval waits for the keys part. + pmu sync.Mutex + pending map[string]*homeEvent + retries map[string]*approvalRetry + wake chan struct{} + busy atomic.Int32 offerKey offerKey @@ -153,13 +172,20 @@ func newServer(path string, keys AgentKeys, log *slog.Logger, roots *x509.CertPo if log == nil { log = slog.Default() } + route, err := loadRouteKey(filepath.Join(filepath.Dir(path), "oauth-route.key")) + if err != nil { + return nil, err + } st, err := openStore(path) if err != nil { return nil, err } - s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, + s := &Server{st: st, keys: keys, fetch: newFetcher(roots), log: log, now: time.Now, route: route, + hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, authorizeLimit: newLimiter(30, 1), registerLimit: newLimiter(20, 0.2), lookupLimit: newLimiter(20, 5), - roster: make(chan rosterEvent, 1024)} + authorizeByIP: newIPLimiter(10, 0.2), registerByIP: newIPLimiter(5, 1.0/60), lookupByIP: newIPLimiter(10, 1), + streams: streams{per: map[string]int{}}, + pending: map[string]*homeEvent{}, retries: map[string]*approvalRetry{}, wake: make(chan struct{}, 1)} return s, nil } @@ -233,111 +259,223 @@ func (s *Server) serveResourceMetadata(w http.ResponseWriter, _ *http.Request) { // The box's roster // -------------------------------------------------------------------------- -type rosterEvent struct { - home string +// homeEvent is what waits to be applied for one home: the newest roster +// only, and whether the home was purged first. A newer roster replaces an +// older one, so nothing old is ever applied and nothing is dropped. +type homeEvent struct { + purge bool devices []cloudwire.Device - // tries counts how often the event came back because the keys part - // could not keep an approved key. - tries int + roster bool +} + +// approvalRetry is a home whose approval the keys part could not take yet. +// A retry runs the approval pass alone, on the newest roster. +type approvalRetry struct { + devices []cloudwire.Device + tries int + at time.Time } // keepRetry is how long an approval waits before it tries the keys part // again, and keepTries how often. A box sends its roster when it changes, -// so the approval cannot wait for the next one. +// so the approval cannot wait for the next one. keysTimeout bounds one call +// to the keys part. var ( - keepRetry = 5 * time.Second - keepTries = 24 + keepRetry = 5 * time.Second + keepTries = 24 + keysTimeout = 5 * time.Second ) // Roster hears each roster the service keeps. A device whose key a pending // consent made is that consent's approval; an agent the roster no longer -// lists is revoked. The work happens on Run's goroutine, in order, since -// this runs on the box's. +// lists is revoked. The work happens on Run's goroutine, since this runs on +// the box's. func (s *Server) Roster(home string, r *cloudwire.Roster) { - ev := rosterEvent{home: home, devices: slices.Clone(r.Devices)} + s.pmu.Lock() + ev := s.pending[home] + if ev == nil { + ev = &homeEvent{} + s.pending[home] = ev + } + ev.devices, ev.roster = slices.Clone(r.Devices), true + s.pmu.Unlock() + s.poke() +} + +// DropHome forgets a home its box purged: its agents, their tokens and keys, +// and its consents. A roster that came before it is not applied. +func (s *Server) DropHome(home string) { + s.pmu.Lock() + s.pending[home] = &homeEvent{purge: true} + delete(s.retries, home) + s.pmu.Unlock() + s.poke() +} + +func (s *Server) poke() { select { - case s.roster <- ev: + case s.wake <- struct{}{}: default: - // The next roster carries the same devices. - s.log.Warn("roster queue full; an agent change waits for the next roster") } } -// Run applies rosters and sweeps until ctx ends. +// Run applies rosters, retries approvals and forgets, and sweeps, until +// ctx ends. It first forgets the keys whose agents went while the keys part +// was away. func (s *Server) Run(ctx context.Context) { - t := time.NewTicker(time.Minute) - defer t.Stop() + s.forgetPending(ctx) + sweep := time.NewTicker(time.Minute) + defer sweep.Stop() + retry := time.NewTicker(min(keepRetry, time.Second)) + defer retry.Stop() for { select { case <-ctx.Done(): return - case ev := <-s.roster: - s.applyRoster(ctx, ev) - case <-t.C: + case <-s.wake: + s.applyPending(ctx) + case <-retry.C: + s.retryApprovals(ctx) + case <-sweep.C: s.sweep(ctx) + s.forgetPending(ctx) + } + } +} + +func (s *Server) applyPending(ctx context.Context) { + s.busy.Add(1) + defer s.busy.Add(-1) + s.pmu.Lock() + evs := s.pending + s.pending = map[string]*homeEvent{} + s.pmu.Unlock() + for home, ev := range evs { + if ev.purge { + s.dropHome(ctx, home) + } + if ev.roster { + s.applyRoster(ctx, home, ev.devices) } } } -func (s *Server) applyRoster(ctx context.Context, ev rosterEvent) { +// listedAgents are the rows this service serves: a personal agent's, with +// its grant and an end still ahead, by its full key. Support and release +// rows reach their homes another way. +func (s *Server) listedAgents(devices []cloudwire.Device) map[string]cloudwire.Device { now := s.now() - retry := false - // The rows this service serves: a personal agent's, with its grant and - // an end still ahead. Support and release rows reach their homes - // another way. listed := map[string]cloudwire.Device{} - for _, d := range ev.devices { + for _, d := range devices { // An agent row has no tunnel of its own (tun is false): only the // keys part opens its session. - if d.Agent != nil && d.Role == protocol.RoleAgent && cloudwire.DeviceID(d.Key) == d.ID && + if d.Agent != nil && d.Role == protocol.RoleAgent && len(d.Key) == 32 && cloudwire.DeviceID(d.Key) == d.ID && now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { - listed[d.ID] = d + listed[string(d.Key)] = d + } + } + return listed +} + +func (s *Server) applyRoster(ctx context.Context, home string, devices []cloudwire.Device) { + listed := s.listedAgents(devices) + // A key belongs to one home. A roster that lists, as its agent, a key + // approved for another home has a copy it should not: the agent goes + // everywhere, and its key with it. + for key := range listed { + if a, ok, err := s.st.agentByKey([]byte(key)); err == nil && ok && a.Home != home { + s.log.Warn("a roster listed an agent approved for another home; revoking it") + s.revoke(ctx, a) + delete(listed, key) + } + } + if s.approve(ctx, home, listed) { + s.pmu.Lock() + r := s.retries[home] + if r == nil { + r = &approvalRetry{} + s.retries[home] = r + } + r.devices, r.at = devices, time.Now().Add(keepRetry) + s.pmu.Unlock() + } else { + s.pmu.Lock() + delete(s.retries, home) + s.pmu.Unlock() + } + // Every agent the roster lists keeps the grant the box gives it now: + // an owner may widen, narrow or renew it. One it no longer lists, or + // lists under another id, is revoked. + agents, err := s.st.agentsOf(home) + if err != nil { + return + } + for _, a := range agents { + d, ok := listed[string(a.Key)] + if !ok || d.ID != a.Agent { + s.revoke(ctx, a) + continue + } + if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { + s.log.Warn("could not update an agent's grant", "err", err) } } - // Approvals: a pending consent's key in the box's signed roster, for - // the client the offer named. +} + +// approve is the approval pass: a pending consent's key in the box's signed +// roster, for the client the offer named. It reports whether an approval +// waits for the keys part. +func (s *Server) approve(ctx context.Context, home string, listed map[string]cloudwire.Device) (retry bool) { + now := s.now() for _, d := range listed { ridHash, c, ok, err := s.st.pendingByKey(d.Key, now) if err != nil || !ok || d.Agent.Principal.ClientID != c.ClientID { continue } - if err := s.keys.Keep(ctx, c.Kid); err != nil { + kctx, cancel := context.WithTimeout(ctx, keysTimeout) + err = s.keys.Keep(kctx, c.Kid) + cancel() + if err != nil { s.log.Warn("an approved agent's key could not be kept; trying again", "err", err) retry = true continue } - c.Home, c.Agent, c.Shard = ev.home, d.ID, s.shard(ev.home) + c.Home, c.Agent, c.Shard = home, d.ID, s.shard(home) c.Granted = strings.Join(intersect(splitScope(c.Scope), d.Agent.Scopes), " ") if err := s.st.approve(ridHash, c, grantOf(d), now); err != nil { + s.log.Warn("could not record an approval", "err", err) continue } s.hub.notify(string(ridHash)) } - if retry && ev.tries < keepTries { - ev.tries++ - time.AfterFunc(keepRetry, func() { - select { - case s.roster <- ev: - default: - } - }) - } - // Every agent the roster lists keeps the grant the box gives it now: - // an owner may widen, narrow or renew it. One it no longer lists, or - // lists with another key, is revoked. - agents, err := s.st.agentsOf(ev.home) - if err != nil { - return - } - for _, a := range agents { - d, ok := listed[a.Agent] - if !ok || string(d.Key) != string(a.Key) { - s.revoke(ctx, a) - continue + return retry +} + +// retryApprovals runs the approval pass again for homes that wait on the +// keys part, on each home's newest roster. It revokes nothing: that is the +// roster's own pass, which already ran. +func (s *Server) retryApprovals(ctx context.Context) { + now := time.Now() + s.pmu.Lock() + due := map[string][]cloudwire.Device{} + for home, r := range s.retries { + if !now.Before(r.at) { + due[home] = r.devices } - if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { - s.log.Warn("could not update an agent's grant", "err", err) + } + s.pmu.Unlock() + for home, devices := range due { + retry := s.approve(ctx, home, s.listedAgents(devices)) + s.pmu.Lock() + if r := s.retries[home]; r != nil { + r.tries++ + if !retry || r.tries >= keepTries { + delete(s.retries, home) + } else { + r.at = now.Add(keepRetry) + } } + s.pmu.Unlock() } } @@ -347,27 +485,60 @@ func grantOf(d cloudwire.Device) agentGrant { Expires: time.UnixMilli(d.Agent.ExpiresAtMs)} } +// revoke drops an agent and its tokens, and has its key forgotten. func (s *Server) revoke(ctx context.Context, a agentRow) { - if err := s.st.dropAgent(a.Home, a.Agent); err != nil { + if err := s.st.dropAgent(a.Home, a.Agent, a.Kid); err != nil { s.log.Warn("could not drop an agent", "err", err) return } - if err := s.keys.Forget(ctx, a.Kid); err != nil { - s.log.Warn("the keys part could not forget an agent's key", "err", err) - } + s.forget(ctx, a.Kid) if s.OnRevoke != nil { s.OnRevoke(a.Home, a.Agent) } } -func (s *Server) sweep(ctx context.Context) { - now := s.now() - kids, err := s.st.sweep(now) +// dropHome revokes every agent of a purged home and deletes its consents. +func (s *Server) dropHome(ctx context.Context, home string) { + agents, err := s.st.agentsOf(home) if err != nil { - s.log.Warn("oauth upkeep failed", "err", err) + s.log.Warn("could not read a purged home's agents", "err", err) + return + } + for _, a := range agents { + s.revoke(ctx, a) + } + if err := s.st.dropRequests(home); err != nil { + s.log.Warn("could not delete a purged home's consents", "err", err) + } +} + +// forget asks the keys part to delete a key the store already queued for +// forgetting, and dequeues it once done. A key the keys part could not +// forget now is tried again every minute and at the next start. +func (s *Server) forget(ctx context.Context, kid string) { + kctx, cancel := context.WithTimeout(ctx, keysTimeout) + defer cancel() + if err := s.keys.Forget(kctx, kid); err != nil && !errors.Is(err, keys.ErrUnknownKey) { + s.log.Warn("the keys part could not forget a key; trying again later", "err", err) + return + } + _ = s.st.forgotten(kid) +} + +func (s *Server) forgetPending(ctx context.Context) { + kids, err := s.st.toForget() + if err != nil { + return } for _, kid := range kids { - _ = s.keys.Forget(ctx, kid) + s.forget(ctx, kid) + } +} + +func (s *Server) sweep(ctx context.Context) { + now := s.now() + if err := s.st.sweep(now); err != nil { + s.log.Warn("oauth upkeep failed", "err", err) } gone, err := s.st.expiredAgents(now) if err == nil { @@ -450,6 +621,106 @@ func (l *limiter) allow(now time.Time) bool { return true } +// ipLimiter is a token bucket per address, an IPv6 address by its /64, so +// one caller cannot spend everyone's bucket. A full bucket carries nothing, +// so it is the first to go when the table fills. +type ipLimiter struct { + mu sync.Mutex + burst, rate float64 + m map[netip.Prefix]*limiter +} + +// maxAddresses bounds the per-address table. +const maxAddresses = 50_000 + +func newIPLimiter(burst, perSecond float64) *ipLimiter { + return &ipLimiter{burst: burst, rate: perSecond, m: map[netip.Prefix]*limiter{}} +} + +func (l *ipLimiter) allow(r *http.Request, now time.Time) bool { + key, ok := clientPrefix(r) + if !ok { + return false + } + l.mu.Lock() + b := l.m[key] + if b == nil { + if len(l.m) >= maxAddresses { + for k, v := range l.m { + if v.tokens+now.Sub(v.at).Seconds()*v.rate >= v.burst { + delete(l.m, k) + } + } + if len(l.m) >= maxAddresses { + l.mu.Unlock() + return false + } + } + nl := newLimiter(l.burst, l.rate) + b = &nl + l.m[key] = b + } + l.mu.Unlock() + return b.allow(now) +} + +// clientPrefix is the caller's address as a bucket's key. TLS ends in this +// process, so RemoteAddr is the caller's own. +func clientPrefix(r *http.Request) (netip.Prefix, bool) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + a, err := netip.ParseAddr(host) + if err != nil { + return netip.Prefix{}, false + } + a = a.Unmap() + bits := 32 + if a.Is6() { + bits = 64 + } + p, err := a.Prefix(bits) + return p, err == nil +} + +// limited takes one token from both of an endpoint's buckets. +func (s *Server) limited(global *limiter, byIP *ipLimiter, r *http.Request) bool { + now := s.now() + return !byIP.allow(r, now) || !global.allow(now) +} + +// streams bounds the consent pages' event streams: a few per request, and +// a total. +type streams struct { + mu sync.Mutex + per map[string]int + total int +} + +const ( + maxStreamsPerRequest = 2 + maxStreams = 4096 +) + +func (st *streams) open(rid string) (func(), bool) { + st.mu.Lock() + defer st.mu.Unlock() + if st.per[rid] >= maxStreamsPerRequest || st.total >= maxStreams { + return nil, false + } + st.per[rid]++ + st.total++ + return func() { + st.mu.Lock() + defer st.mu.Unlock() + st.total-- + if st.per[rid]--; st.per[rid] <= 0 { + delete(st.per, rid) + } + }, true +} + func randomToken(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 7d13546..fb14ce5 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -9,7 +9,9 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "github.com/fxamacker/cbor/v2" + "html" "io" "log/slog" "net" @@ -22,6 +24,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" @@ -95,10 +98,15 @@ func newRig(t *testing.T) *rig { return nil, errors.New("no such host") } g.s.fetch.public = func(a netip.Addr) bool { return a == netip.MustParseAddr("127.0.0.1") || publicAddr(a) } + // Every test client lives on example.com. + g.s.fetch.hostBurst, g.s.fetch.hostRate = 1e4, 1e4 g.s.fetch.dial = func(ctx context.Context, network, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, network, g.cimd.Listener.Addr().String()) } + // One test client is one address: its own limits are looser than a + // stranger's, and TestLimitsPerAddress checks those. + g.s.authorizeByIP, g.s.registerByIP, g.s.lookupByIP = newIPLimiter(1e4, 1e4), newIPLimiter(1e4, 1e4), newIPLimiter(1e4, 1e4) mux := http.NewServeMux() g.s.Mount(mux) g.srv = httptest.NewServer(mux) @@ -269,13 +277,22 @@ func next(t *testing.T, ch <-chan event) event { // after checking the box's signature. func (g *rig) roster(h string, devs ...cloudwire.Device) { g.s.Roster(h, &cloudwire.Roster{RosterSeq: 1, TTLS: 86400, Devices: devs}) - // Run applies it on its own goroutine; a request on the store after - // this waits for it by draining the queue. + g.settle() +} + +// settle waits until Run has applied what is pending. +func (g *rig) settle() { deadline := time.Now().Add(2 * time.Second) - for len(g.s.roster) > 0 && time.Now().Before(deadline) { + for time.Now().Before(deadline) { + g.s.pmu.Lock() + n := len(g.s.pending) + g.s.pmu.Unlock() + if n == 0 && g.s.busy.Load() == 0 { + break + } time.Sleep(time.Millisecond) } - time.Sleep(20 * time.Millisecond) + time.Sleep(10 * time.Millisecond) } // agentDevice is an agent row as the box lists it in its roster, for the @@ -454,6 +471,7 @@ func TestTheFullFlowWithAMetadataDocument(t *testing.T) { if _, aerr := g.bearer(tk2.Access); aerr != nil { t.Fatal("the refreshed token was refused") } + g.advance(refreshGrace + time.Second) status, _ = g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}) if status != http.StatusBadRequest { t.Fatalf("a spent refresh token: %d", status) @@ -476,7 +494,8 @@ func TestACodeWorksOnce(t *testing.T) { next(t, ch) g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") - form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}} + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}, + "redirect_uri": {cimdBack}} status, tk := g.token(form) if status != http.StatusOK { t.Fatalf("first use: %d", status) @@ -505,9 +524,8 @@ func TestPKCEIsRequired(t *testing.T) { } change(qq) resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + qq.Encode()) - loc, _ := url.Parse(resp.Header.Get("Location")) - if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_request" || loc.Query().Get("iss") != Issuer { - t.Fatalf("without S256 PKCE: %d %s", resp.StatusCode, loc) + if back := backLink(t, resp); back.Query().Get("error") != "invalid_request" || back.Query().Get("iss") != Issuer { + t.Fatalf("without S256 PKCE: %s", back) } } @@ -518,13 +536,31 @@ func TestPKCEIsRequired(t *testing.T) { g.roster(home, agentDevice(f.agentKey(t), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") wrong, _ := pkce() - if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}}); status != http.StatusBadRequest { + if status, _ := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {wrong}, + "redirect_uri": {cimdBack}}); status != http.StatusBadRequest { t.Fatalf("a wrong verifier: %d", status) } } -// Before client and redirect URI check out, nothing redirects; after, every -// error goes back with iss. +// backLink is the link an error page offers back to the client, with no +// redirect of its own. +func backLink(t *testing.T, resp *http.Response) *url.URL { + t.Helper() + body, _ := io.ReadAll(resp.Body) + m := regexp.MustCompile(`Go back to `).FindSubmatch(body) + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" || m == nil { + t.Fatalf("not an error page with a link back: %d %q", resp.StatusCode, resp.Header.Get("Location")) + } + u, err := url.Parse(html.UnescapeString(string(m[1]))) + if err != nil { + t.Fatal(err) + } + return u +} + +// Before client and redirect URI check out, nothing redirects. After, an +// error goes back with iss: at once for a client FTW Cloud knows, and by a +// link the person follows for any other (RFC 9700, 4.11.2). func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { g := newRig(t) for _, q := range []url.Values{ @@ -541,16 +577,24 @@ func TestBadRequestsNeverRedirectSomewhereUnchecked(t *testing.T) { q, _ := g.authorizeParams(cimdID, cimdBack, "") q.Set("resource", "https://cloud.ftw.energy/other") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - loc, _ := url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") != "invalid_target" || loc.Query().Get("iss") != Issuer || loc.Host != "example.com" { - t.Fatalf("another resource: %s", loc) + back := backLink(t, resp) + if back.Query().Get("error") != "invalid_target" || back.Query().Get("iss") != Issuer || back.Host != "example.com" { + t.Fatalf("another resource: %s", back) } q.Set("resource", Resource) q.Set("scope", "ftw.live.read admin") resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - loc, _ = url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") != "invalid_scope" { - t.Fatalf("an unknown scope: %s", loc) + if back := backLink(t, resp); back.Query().Get("error") != "invalid_scope" { + t.Fatalf("an unknown scope: %s", back) + } + // A client FTW Cloud knows goes back at once. + old := verifiedClients + verifiedClients = append(slices.Clone(old), cimdID) + t.Cleanup(func() { verifiedClients = old }) + resp, _ = noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusFound || loc.Query().Get("error") != "invalid_scope" || loc.Query().Get("iss") != Issuer { + t.Fatalf("a known client's error: %d %s", resp.StatusCode, loc) } // A loopback redirect may take any port. q2, _ := g.authorizeParams(cimdID, "http://127.0.0.1:53121/cb", "") @@ -601,7 +645,8 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { g.roster(home, agentFor(f.agentKey(t), id, time.Now().Add(time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") stop() - if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}}); status != http.StatusOK { + if status, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {id}, "code_verifier": {f.verifier}, + "redirect_uri": {"https://grok.example/cb"}}); status != http.StatusOK { t.Fatalf("public client token: %d %+v", status, tk) } @@ -616,7 +661,8 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { next(t, ch) g.roster(home, agentFor(f.agentKey(t), cid, time.Now().Add(time.Hour), ScopeLiveRead)) code = redirectOf(t, next(t, ch)).Query().Get("code") - form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}} + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cid}, "code_verifier": {f.verifier}, + "redirect_uri": {"https://chatgpt.com/connector_platform_oauth_redirect"}} if status, _ := g.token(form); status != http.StatusUnauthorized { t.Fatalf("no secret: %d", status) } @@ -632,7 +678,7 @@ func TestTheFullFlowWithDynamicRegistration(t *testing.T) { // The page finishes only when the box's roster lists the very key this // request made, as a device that may open a session; and only the browser -// that started it gets the code. +// that started it gets the code, or keeps a stream open. func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, "") @@ -643,6 +689,9 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { if next(t, browser).name != "pending" || next(t, stranger).name != "pending" { t.Fatal("not pending at first") } + if _, open := <-stranger; open { + t.Fatal("a stranger's stream stayed open") + } // Another key; the right key under another id, for another client, // past its end, as support, or as a phone: none of them approves. other := bytes.Repeat([]byte{1}, 32) @@ -668,6 +717,8 @@ func TestConsentCompletesOnlyWhenTheRosterListsTheAgent(t *testing.T) { } g.roster(home, agentDevice(key, ScopeLiveRead)) + stranger, stop3 := g.events(f, false) + defer stop3() if ev := next(t, stranger); ev.name != "approved" || strings.Contains(ev.data, "code") { t.Fatalf("a stranger's stream got %q %s", ev.name, ev.data) } @@ -760,7 +811,8 @@ func TestAnExpiredAgentFails(t *testing.T) { next(t, ch) g.roster(home, agentFor(f.agentKey(t), cimdID, g.now().Add(2*time.Hour), ScopeLiveRead)) code := redirectOf(t, next(t, ch)).Query().Get("code") - _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}}) + _, tk := g.token(url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}, + "redirect_uri": {cimdBack}}) if _, aerr := g.bearer(tk.Access); aerr != nil { t.Fatal(aerr) } @@ -890,19 +942,32 @@ func TestTheConsentPage(t *testing.T) { } // Requests for keys are bounded: the authorize endpoint is rate limited -// for everyone together, since each request makes a key. +// for everyone together, since each request makes a key, and the limit +// comes before the client's document is fetched. func TestAuthorizeIsRateLimited(t *testing.T) { g := newRig(t) + var hits atomic.Int32 + inner := g.cimd.Config.Handler + g.cimd.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + inner.ServeHTTP(w, r) + }) limited := false - for range 40 { - q, _ := g.authorizeParams(cimdID, cimdBack, "") + for i := range 40 { + // A new document each time, so the cache does not hide a fetch. + id := fmt.Sprintf("https://example.com/oauth/c%d.json", i) + g.docsMu.Lock() + g.docs[fmt.Sprintf("/oauth/c%d.json", i)] = `{"client_id":"` + id + `","client_name":"C","redirect_uris":["` + cimdBack + `"]}` + g.docsMu.Unlock() + q, _ := g.authorizeParams(id, cimdBack, "") + before := hits.Load() resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) - if resp.StatusCode == http.StatusFound { - loc, _ := url.Parse(resp.Header.Get("Location")) - if loc.Query().Get("error") == "temporarily_unavailable" { - limited = true - break + if resp.StatusCode == http.StatusTooManyRequests { + if hits.Load() != before { + t.Fatal("a limited request fetched a document") } + limited = true + break } } if !limited { @@ -910,6 +975,53 @@ func TestAuthorizeIsRateLimited(t *testing.T) { } } +// One address cannot spend everyone's bucket: authorize, registration and +// code lookups each have one per address, an IPv6 address by its /64. +func TestLimitsPerAddress(t *testing.T) { + g := newRig(t) + g.s.authorizeByIP, g.s.registerByIP, g.s.lookupByIP = newIPLimiter(3, 0.001), newIPLimiter(2, 0.001), newIPLimiter(5, 0.001) + mux := http.NewServeMux() + g.s.Mount(mux) + do := func(addr, target string) int { + method := http.MethodGet + if strings.HasPrefix(target, "/oauth/register") { + method = http.MethodPost + } + r := httptest.NewRequest(method, target, strings.NewReader(`{"redirect_uris":["https://x.example/cb"]}`)) + r.RemoteAddr = addr + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + return w.Code + } + q, _ := g.authorizeParams(cimdID, cimdBack, "") + for name, c := range map[string]struct { + target string + n int + }{ + "authorize": {"/oauth/authorize?" + q.Encode(), 3}, + "register": {"/oauth/register", 2}, + "lookup": {"/oauth/requests?code=AAAA-AAAA", 5}, + } { + for i := range c.n { + if code := do("198.51.100.7:4000", c.target); code == http.StatusTooManyRequests { + t.Fatalf("%s: limited after %d", name, i) + } + } + if code := do("198.51.100.7:4001", c.target); code != http.StatusTooManyRequests { + t.Fatalf("%s: not limited per address: %d", name, code) + } + if code := do("[2001:db8:1:2::9]:4000", c.target); code == http.StatusTooManyRequests { + t.Fatalf("%s: another address was limited", name) + } + for range c.n - 1 { + do("[2001:db8:1:2::a]:4000", c.target) + } + if code := do("[2001:db8:1:2:ffff::1]:4000", c.target); code != http.StatusTooManyRequests { + t.Fatalf("%s: one /64 counts as many callers: %d", name, code) + } + } +} + // flakyKeys is a keys part that is away for a while. type flakyKeys struct { AgentKeys @@ -984,8 +1096,8 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { q, _ := g.authorizeParams(cimdID, cimdBack, "") resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) page, _ := io.ReadAll(resp.Body) - if !regexp.MustCompile(`href="https://app\.ftw\.energy/a/[A-Za-z0-9_-]{22}#[A-Za-z0-9_-]{200,}"`).Match(page) { - t.Fatal("the app link does not carry the offer in its fragment") + if !regexp.MustCompile(`href="https://app\.ftw\.energy/a#[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{200,}"`).Match(page) { + t.Fatal("the app link does not carry the request and the offer in its fragment") } // A registered client is shown by its redirect URI's host. resp2, _ := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(`{"client_name":"X","redirect_uris":["https://x.example/cb"]}`)) @@ -1011,18 +1123,33 @@ func TestTheOfferKeyIsPublished(t *testing.T) { } } -// A token names its home, so any instance can tell where it belongs; the -// name proves nothing by itself. -func TestTokensNameTheirHome(t *testing.T) { +// A token is routed to its home, so any instance can tell where it belongs, +// but it shows no id a client could link across tokens: the home is sealed +// with a fresh nonce each time. The route proves nothing by itself. +func TestTokensRouteToTheirHomeWithoutNamingIt(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, "") tk := g.approve(f, cimdID) - if TokenHome(tk.Access) != home || TokenHome(tk.Refresh) != home || !strings.HasPrefix(tk.Access, accessPrefix+home+".") { - t.Fatalf("tokens %s %s", tk.Access, tk.Refresh) + for _, tok := range []string{tk.Access, tk.Refresh} { + if h, ok := g.s.TokenHome(tok); !ok || h != home { + t.Fatalf("token %s routes to %q", tok, h) + } + if strings.Contains(tok, home) { + t.Fatalf("token %s names its home", tok) + } + } + route := func(tok string) string { r, _, _ := strings.Cut(tok[len(accessPrefix):], "."); return r } + if route(tk.Access) == route(tk.Refresh) { + t.Fatal("two tokens share a route") } - forged := accessPrefix + home + "." + randomToken(32) + // A route made with another key, or edited, does not open. + other, _ := loadRouteKey(filepath.Join(t.TempDir(), "k")) + if _, ok := g.s.TokenHome(other.newToken(accessPrefix, home)); ok { + t.Fatal("a route under another key opened") + } + forged := g.s.route.newToken(accessPrefix, home) if _, aerr := g.bearer(forged); aerr == nil { - t.Fatal("a token with the right home and a made-up secret was accepted") + t.Fatal("a token with a good route and a made-up secret was accepted") } } @@ -1068,24 +1195,35 @@ func TestARefreshCanNarrow(t *testing.T) { } func TestTokenShape(t *testing.T) { - good := accessPrefix + home + "." + randomToken(32) + k, _ := loadRouteKey(filepath.Join(t.TempDir(), "k")) + good := k.newToken(accessPrefix, home) if !tokenShape(good, accessPrefix) { t.Fatal("a token this server makes") } - for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), refreshPrefix + home + "." + randomToken(32), - accessPrefix + "HOME." + randomToken(32), accessPrefix + home + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { + for _, bad := range []string{"", good + "x", strings.Replace(good, ".", "", 1), k.newToken(refreshPrefix, home), + accessPrefix + home + "." + randomToken(32), strings.Replace(good, ".", "!.", 1), + accessPrefix + strings.Repeat("a", 60) + "." + strings.Repeat("!", 43), "ftwat_" + strings.Repeat("a", 5000)} { if tokenShape(bad, accessPrefix) { t.Errorf("%q passed", bad) } } } -// Only clients FTW Cloud knows, by their document's host, are verified. -func TestVerifiedHosts(t *testing.T) { - for host, want := range map[string]bool{"claude.ai": true, "www.claude.ai": true, "chatgpt.com": true, "grok.com": true, - "evilclaude.ai": false, "claude.ai.evil.example": false, "example.com": false} { - if verifiedHost(host) != want { - t.Errorf("%s: %v", host, !want) +// Only clients FTW Cloud knows, by the exact URL of their document, are +// verified: not another document on the same host, and not a host that +// merely ends like it. +func TestVerifiedClients(t *testing.T) { + for id, want := range map[string]bool{ + "https://claude.ai/oauth/mcp-oauth-client-metadata": true, + "https://claude.ai/oauth/mcp-oauth-client-metadata/": false, + "https://claude.ai/other": false, + "https://www.claude.ai/oauth/mcp-oauth-client-metadata": false, + "https://evilclaude.ai/oauth/mcp-oauth-client-metadata": false, + "https://claude.ai.evil.example/oauth/mcp-oauth-client-metadata": false, + "https://chatgpt.com/oauth/client.json": false, + } { + if verifiedClient(id) != want { + t.Errorf("%s: %v", id, !want) } } } diff --git a/internal/oauth/offer.go b/internal/oauth/offer.go index d2437dd..753fc18 100644 --- a/internal/oauth/offer.go +++ b/internal/oauth/offer.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "net/http" "slices" - "strings" "sync" "github.com/fxamacker/cbor/v2" @@ -57,7 +56,7 @@ func buildOffer(c consent) protocol.GrantOffer { // signOffer encodes and signs an offer as the contract's SignedOffer // {o, kid, sig}, base64url, for the link's fragment and the code lookup. -func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, error) { +func (s *Server) signOffer(ctx context.Context, kid string, o protocol.GrantOffer) (string, error) { pub, err := s.offerPub(ctx) if err != nil { return "", err @@ -66,7 +65,7 @@ func (s *Server) signOffer(ctx context.Context, o protocol.GrantOffer) (string, if err != nil { return "", err } - sig, err := s.keys.SignOffer(ctx, raw) + sig, err := s.keys.SignOffer(ctx, kid, raw) if err != nil { return "", err } @@ -95,26 +94,21 @@ func (s *Server) offerPub(ctx context.Context) ([]byte, error) { return pub, nil } -// appLink is the universal link the QR and the button carry: the request -// id in the path, for the app's answer, and the signed offer in the -// fragment, which no server sees. -func appLink(rid, offer string) string { return AppLink + rid + "#" + offer } +// appLink is the universal link the QR and the button carry. Its fragment, +// which no server sees, holds the request id, a dot, and the signed offer; +// neither is base64url's alphabet's dot. +func appLink(rid, offer string) string { return AppLink + "#" + rid + "." + offer } -// verifiedHosts are clients FTW Cloud knows by the host of their metadata -// document. A client from one of them is shown as verified; any other is -// shown by its host and the name it gives itself. -var verifiedHosts = []string{"claude.ai", "claude.com", "anthropic.com", "chatgpt.com", "openai.com", "grok.com", "x.ai"} - -func verifiedHost(host string) bool { - host = strings.ToLower(host) - for _, h := range verifiedHosts { - if host == h || strings.HasSuffix(host, "."+h) { - return true - } - } - return false +// verifiedClients are clients FTW Cloud knows, by the exact URL of their +// metadata document. A client on the list is shown as verified; any other +// is shown by its host and the name it gives itself. An entry is added only +// once its document has been read at that URL and names it. +var verifiedClients = []string{ + "https://claude.ai/oauth/mcp-oauth-client-metadata", // read 15 Oct 2026 } +func verifiedClient(id string) bool { return slices.Contains(verifiedClients, id) } + // offerKey serves the offer key's public half, so anyone can check what the // app pins. type offerKey struct { diff --git a/internal/oauth/page.go b/internal/oauth/page.go index 5996afc..a1595cb 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -35,13 +35,17 @@ type pageError struct { Nonce string Title string Text string + // Back is the client's error response, as a link the person may follow + // to BackHost. + Back string + BackHost string } func (s *Server) consentPage(w http.ResponseWriter, rid string, c consent) { u, _ := url.Parse(c.RedirectURI) d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID), RedirectHost: u.Host, Local: u.Scheme == "http" && isLoopback(u.Hostname()), - Code: c.Code[:3] + "-" + c.Code[3:], Link: appLink(rid, c.Offer)} + Code: showCode(c.Code), Link: appLink(rid, c.Offer)} for _, sc := range splitScope(c.Scope) { d.Scopes = append(d.Scopes, scopeWords[sc]) if !slices.Contains(ReadScopes, sc) { @@ -70,6 +74,7 @@ func pageHeaders(w http.ResponseWriter, nonce string) { h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") + h.Set("Strict-Transport-Security", "max-age=31536000") } // qrSVG draws text as a QR code, dark on light whatever the page's theme, @@ -113,7 +118,7 @@ button{font:inherit;background:none;border:1px solid var(--line);color:var(--fg) var consentTmpl = template.Must(template.New("consent").Parse(` Connect an assistant · FTW Cloud -
+

Let {{.Name}} into your home?

{{if .Verified}}A client FTW Cloud knows, from {{.Host}}.{{else if .FromDocument}}From {{.Host}}. FTW Cloud does not know this client: check the name and the address.{{else}}This name is what the assistant registered with. FTW Cloud has not checked it.{{end}}

@@ -132,17 +137,22 @@ var consentTmpl = template.Must(template.New("consent").Parse(`

Waiting for your approval…

+