diff --git a/README.md b/README.md index f096d50..7b249ac 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,48 @@ 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 and has it sign the grant offer + (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 by kid, which + `/.well-known/ftw-offer-key` serves. The box's page or an owner who types + 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, 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, 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: | Step | What | Where | diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 2bc334f..5e24efe 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,8 +146,11 @@ 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 + // 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. @@ -188,6 +204,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..ff2a6f0 --- /dev/null +++ b/internal/oauth/authorize.go @@ -0,0 +1,466 @@ +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 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}$`) + +// 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 + } + } + // 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() + + // 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") + given := redirect != "" + 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")} + // 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) { + 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") + 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) + } + + // 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) + // 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, + 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, kid, 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 + } + } + if err != nil { + _ = s.st.queueForget(kid, now) + s.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] } + +// 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 { + 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(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)) + 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"` + // 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"` + 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{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), + 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 or in the app, once: a short code cannot carry the agent's key, so +// 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.limited(&s.lookupLimit, s.lookupByIP, r) { + 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.spendCode(code, s.now()) + if err != nil || !ok { + http.NotFound(w, r) + return + } + // 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) +} + +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, s.now()) + if err != nil { + http.Error(w, "could not deny", http.StatusInternalServerError) + return + } + if ok { + s.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") + c, ok, err := s.st.consentByRID(rid) + if 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, 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 + } + 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 + // 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..75e27f4 --- /dev/null +++ b/internal/oauth/cimd.go @@ -0,0 +1,408 @@ +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 a few per host, and the answers cached: +// documents for at least a minute, failures apart for a minute. + +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 + 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. +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{} + // hostBurst and hostRate bound fetches per host. + hostBurst, hostRate float64 + + mu sync.Mutex + cache map[string]cimdEntry + failed map[string]cimdEntry + hosts map[string]*limiter +} + +type cimdEntry struct { + md clientMetadata + err error + 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), + 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. + 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() + 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{}{}: + defer func() { <-f.sem }() + case <-ctx.Done(): + return clientMetadata{}, errCIMDFetch + } + md, ttl, err := f.fetch(ctx, u) + f.mu.Lock() + defer f.mu.Unlock() + 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 +} + +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) + } + // 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. 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 + } + for _, d := range strings.Split(cc, ",") { + d = strings.ToLower(strings.TrimSpace(d)) + switch { + case d == "no-store" || d == "no-cache": + return cimdCacheMin + 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, 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() || + 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", + "::/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)) + } + 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 +} diff --git a/internal/oauth/cimd_test.go b/internal/oauth/cimd_test.go new file mode 100644 index 0000000..1f92cc4 --- /dev/null +++ b/internal/oauth/cimd_test.go @@ -0,0 +1,290 @@ +package oauth + +import ( + "context" + "errors" + "fmt" + "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", + "::a00:1", "::a9fe:a9fe", "::1.1.1.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": cimdCacheMin, + "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") + } +} + +// 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 new file mode 100644 index 0000000..f30fa2d --- /dev/null +++ b/internal/oauth/clients.go @@ -0,0 +1,262 @@ +package oauth + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "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 +// 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 is a client FTW Cloud knows: a metadata document at one of + // verifiedClients. + 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 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: verifiedClient(id), 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.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 + } + readDeadline(w) + 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 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 !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) { + 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..ded9a6b --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,752 @@ +// 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" + "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" +) + +// 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. 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" +) + +// 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 +// (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 = protocol.ScopeLogsRead + ScopeConfigRead = protocol.ScopeConfigRead + ScopeDiagRun = protocol.ScopeDiagRun + ScopeDispatchHold = protocol.ScopeDispatchHold + ScopeGoalsRequest = protocol.ScopeGoalsRequest +) + +// 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 + // 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) +} + +// Server is the authorization server. +type Server struct { + st *store + keys AgentKeys + fetch *fetcher + 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 + + // 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 + + // Shard names the shard a home lives on (shard.Directory). Nil is one + // shard, "". + Shard func(home string) string + // 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() + } + 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, route: route, + hub: hub{subs: map[string]map[chan struct{}]struct{}{}}, + authorizeLimit: newLimiter(30, 1), registerLimit: newLimiter(20, 0.2), lookupLimit: newLimiter(20, 5), + 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 +} + +// 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", "/.well-known/ftw-offer-key", +} + +// 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 /.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) + 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 +// -------------------------------------------------------------------------- + +// 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 + 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. keysTimeout bounds one call +// to the keys part. +var ( + 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, since this runs on +// the box's. +func (s *Server) Roster(home string, r *cloudwire.Roster) { + 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.wake <- struct{}{}: + default: + } +} + +// 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) { + 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 <-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) + } + } +} + +// 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() + listed := map[string]cloudwire.Device{} + 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 && len(d.Key) == 32 && cloudwire.DeviceID(d.Key) == d.ID && + now.Before(time.UnixMilli(d.Agent.ExpiresAtMs)) { + 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) + } + } +} + +// 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 + } + 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 = 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)) + } + 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 + } + } + 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() + } +} + +// 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)} +} + +// 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, a.Kid); err != nil { + s.log.Warn("could not drop an agent", "err", err) + return + } + s.forget(ctx, a.Kid) + if s.OnRevoke != nil { + s.OnRevoke(a.Home, a.Agent) + } +} + +// 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("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.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 { + for _, a := range gone { + s.revoke(ctx, a) + } + } +} + +func (s *Server) shard(home string) string { + if s.Shard == nil { + return "" + } + return s.Shard(home) +} + +// -------------------------------------------------------------------------- +// 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 +} + +// 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 { + 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..fb14ce5 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,1229 @@ +package oauth + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/fxamacker/cbor/v2" + "html" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol" + "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) } + // 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) + 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}) + g.settle() +} + +// settle waits until Run has applied what is pending. +func (g *rig) settle() { + deadline := time.Now().Add(2 * time.Second) + 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(10 * 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 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. 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 { + 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, + 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. +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, 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) + } + 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") + } + 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) + } + 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}, + "redirect_uri": {cimdBack}} + 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()) + if back := backLink(t, resp); back.Query().Get("error") != "invalid_request" || back.Query().Get("iss") != Issuer { + t.Fatalf("without S256 PKCE: %s", back) + } + } + + 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}, + "redirect_uri": {cimdBack}}); status != http.StatusBadRequest { + t.Fatalf("a wrong verifier: %d", status) + } +} + +// 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{ + {"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()) + 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()) + 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", "") + 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, 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}, + "redirect_uri": {"https://grok.example/cb"}}); 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, 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}, + "redirect_uri": {"https://chatgpt.com/connector_platform_oauth_redirect"}} + 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, or keeps a stream open. +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") + } + 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) + key := f.agentKey(t) + wrongID := agentDevice(key, ScopeLiveRead) + wrongID.ID = "someone" + 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{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) + 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)) + 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) + } + 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) + f := g.start(cimdID, cimdBack, "") + 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}, + "redirect_uri": {cimdBack}}) + 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_" + 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) + } + 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 != "" || 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) + } + // 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?", "From example.com. FTW Cloud does not know this client", "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}}

+{{if .Back}}

Go back to {{.BackHost}}

{{end}}
`)) diff --git a/internal/oauth/review_test.go b/internal/oauth/review_test.go new file mode 100644 index 0000000..c961e85 --- /dev/null +++ b/internal/oauth/review_test.go @@ -0,0 +1,401 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/keys" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// pickyKeys is a keys part that will not keep one key, or forget any, for a +// while. +type pickyKeys struct { + AgentKeys + mu sync.Mutex + keepNot string + forgetNo bool +} + +func (p *pickyKeys) Keep(ctx context.Context, kid string) error { + p.mu.Lock() + refuse := kid == p.keepNot + p.mu.Unlock() + if refuse { + return keys.ErrUnavailable + } + return p.AgentKeys.Keep(ctx, kid) +} + +func (p *pickyKeys) Forget(ctx context.Context, kid string) error { + p.mu.Lock() + refuse := p.forgetNo + p.mu.Unlock() + if refuse { + return keys.ErrUnavailable + } + return p.AgentKeys.Forget(ctx, kid) +} + +func (p *pickyKeys) set(f func()) { + p.mu.Lock() + defer p.mu.Unlock() + f() +} + +func (g *rig) kidOf(f *flow) string { + g.t.Helper() + c, ok, err := g.s.st.consentByRID(f.rid) + if err != nil || !ok { + g.t.Fatalf("consent: %v", err) + } + return c.Kid +} + +// opens reports whether the keys part still holds a kept key. +func (g *rig) opens(kid string) bool { + box := make([]byte, 32) + _, _ = rand.Read(box) + _, _, err := g.keys.Open(context.Background(), kid, box) + return err == nil +} + +// A retry after the keys part was away runs only the approval pass, on the +// newest roster. An older roster is never applied again: it cannot widen a +// grant the owner narrowed since, approve a consent the owner removed, or +// revoke an agent approved since. +func TestARetryRunsOnlyTheApprovalOnTheNewestRoster(t *testing.T) { + old := keepRetry + keepRetry = 20 * time.Millisecond + t.Cleanup(func() { keepRetry = old }) + g := newRig(t) + a := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopePlanRead) + tk := g.approve(a, cimdID, ScopeLiveRead, ScopePlanRead) + b := g.start(cimdID, cimdBack, "") + c := g.start(cimdID, cimdBack, "") + picky := &pickyKeys{AgentKeys: g.keys, keepNot: g.kidOf(b)} + g.s.keys = picky + end := g.now().Add(30 * 24 * time.Hour) + + // The first roster lists A wide and B, whose key cannot be kept yet. + g.roster(home, agentFor(a.agentKey(t), cimdID, end, ScopeLiveRead, ScopePlanRead), agentFor(b.agentKey(t), cimdID, end, ScopeLiveRead)) + // The owner narrows A, removes B and approves C. + g.roster(home, agentFor(a.agentKey(t), cimdID, end, ScopeLiveRead), agentFor(c.agentKey(t), cimdID, end, ScopeLiveRead)) + picky.set(func() { picky.keepNot = "" }) + time.Sleep(10 * keepRetry) + + if g.details(b.rid).Status != statusPending { + t.Fatal("a retry approved a consent the newest roster no longer lists") + } + grant, aerr := g.bearer(tk.Access) + if aerr != nil || grant.Has(ScopePlanRead) { + t.Fatalf("a retry widened a narrowed grant: %+v %v", grant, aerr) + } + if _, ok, _ := g.s.st.agent(home, cloudwire.DeviceID(c.agentKey(t))); !ok { + t.Fatal("a retry revoked an agent approved since") + } +} + +// Rosters are never dropped: whatever arrives for many homes at once, each +// home's newest roster is applied. +func TestRostersAreNotDropped(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + for i := range 3000 { + g.s.Roster(fmt.Sprintf("home%05d", i), &cloudwire.Roster{RosterSeq: 1, TTLS: 86400}) + } + g.s.Roster(home, &cloudwire.Roster{RosterSeq: 1, TTLS: 86400, Devices: []cloudwire.Device{agentDevice(f.agentKey(t), ScopeLiveRead)}}) + if ev := next(t, ch); ev.name != "approved" { + t.Fatalf("event %q", ev.name) + } +} + +// A home its box purged loses its agents, their tokens and keys, and its +// consents. A roster from before the purge is not applied after it. +func TestAPurgedHomeLosesItsAgents(t *testing.T) { + g := newRig(t) + var mu sync.Mutex + var revoked []string + g.s.OnRevoke = func(h, agent string) { mu.Lock(); revoked = append(revoked, h+"/"+agent); mu.Unlock() } + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + kid := g.kidOf(f) + pending := g.start(cimdID, cimdBack, "") + + g.s.Roster(home, &cloudwire.Roster{RosterSeq: 2, TTLS: 86400, Devices: []cloudwire.Device{ + agentDevice(f.agentKey(t), ScopeLiveRead), agentDevice(pending.agentKey(t), ScopeLiveRead)}}) + g.s.DropHome(home) + g.settle() + + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("a purged home's token works") + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("a purged home's refresh token: %d", status) + } + if g.opens(kid) { + t.Fatal("a purged home's agent key still opens") + } + if _, ok, _ := g.s.st.consentByRID(f.rid); ok { + t.Fatal("a purged home's consent is still kept") + } + if g.details(pending.rid).Status != statusPending { + t.Fatal("a roster from before the purge was applied after it") + } + mu.Lock() + defer mu.Unlock() + if len(revoked) != 1 || revoked[0] != home+"/"+cloudwire.DeviceID(f.agentKey(t)) { + t.Fatalf("revoked %v", revoked) + } +} + +// A key belongs to one home. A roster that lists, as its agent, a key +// approved for another home revokes it there, forgets the key, and approves +// nothing. +func TestAKeyListedAtASecondHomeIsRevoked(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + kid := g.kidOf(f) + g.roster("otherhomeotherhomeotherho", agentDevice(f.agentKey(t), ScopeLiveRead)) + if _, aerr := g.bearer(tk.Access); aerr == nil { + t.Fatal("an agent listed by two homes still works") + } + if g.opens(kid) { + t.Fatal("its key still opens") + } + if _, ok, _ := g.s.st.agentByKey(f.agentKey(t)); ok { + t.Fatal("the second home got the agent") + } +} + +// Revoking always deletes the key: a key the keys part could not forget +// stays queued, across restarts, until it has. +func TestARevokedKeyIsForgottenEvenIfTheKeysPartWasAway(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + g.approve(f, cimdID) + kid := g.kidOf(f) + picky := &pickyKeys{AgentKeys: g.keys, forgetNo: true} + g.s.keys = picky + g.roster(home) // the owner revoked it + if !g.opens(kid) { + t.Fatal("the key went although the keys part refused") + } + if q, _ := g.s.st.toForget(); len(q) != 1 || q[0] != kid { + t.Fatalf("queued %v", q) + } + picky.set(func() { picky.forgetNo = false }) + // Run does this at its start and every minute. + g.s.forgetPending(context.Background()) + if g.opens(kid) { + t.Fatal("the queued key was not forgotten") + } + if q, _ := g.s.st.toForget(); len(q) != 0 { + t.Fatalf("still queued %v", q) + } + // A denied consent's key goes the same way. + d := g.start(cimdID, cimdBack, "") + dkid := g.kidOf(d) + resp, _ := http.Post(g.srv.URL+"/oauth/requests/"+d.rid+"/deny", "", nil) + resp.Body.Close() + if q, _ := g.s.st.toForget(); len(q) != 0 { + t.Fatalf("a denied key left queued: %v", q) + } + if err := g.keys.Keep(context.Background(), dkid); !errors.Is(err, keys.ErrUnknownKey) { + t.Fatalf("a denied key is still pending: %v", err) + } +} + +// The code is eight characters, drawn evenly, and an owner may type it +// with a dash or in lower case. +func TestTheCodeIsLongAndEven(t *testing.T) { + counts := map[byte]int{} + const n = 20_000 + for range n { + c := shortCode() + if len(c) != codeLen || strings.Trim(c, codeAlphabet) != "" { + t.Fatalf("code %q", c) + } + for i := range len(c) { + counts[c[i]]++ + } + } + want := float64(n*codeLen) / float64(len(codeAlphabet)) + for i := range len(codeAlphabet) { + if got := float64(counts[codeAlphabet[i]]); got < 0.9*want || got > 1.1*want { + t.Errorf("%c drawn %v times, want about %v", codeAlphabet[i], got, want) + } + } + if normalizeCode(strings.ToLower(showCode("ABCD2345"))) != "ABCD2345" || normalizeCode("ABC-234") != "" { + t.Fatal("normalizeCode") + } +} + +// Only the browser that started a request keeps a stream, at most two at +// once; anyone else gets the state once. +func TestEventStreamsAreBounded(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + _, stop1 := g.events(f, true) + defer stop1() + _, stop2 := g.events(f, true) + defer stop2() + time.Sleep(20 * time.Millisecond) + req, _ := http.NewRequest(http.MethodGet, g.srv.URL+"/oauth/requests/"+f.rid+"/events", nil) + req.AddCookie(f.cookie) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("a third stream: %d", resp.StatusCode) + } +} + +// A redirect_uri the authorize request named must be named again at the +// token endpoint, the same. +func TestTheTokenRequestNamesTheRedirect(t *testing.T) { + g := newRig(t) + for _, redirect := range []string{"", "https://example.com/elsewhere"} { + f := g.start(cimdID, cimdBack, "") + 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() + form := url.Values{"grant_type": {grantCode}, "code": {code}, "client_id": {cimdID}, "code_verifier": {f.verifier}} + if redirect != "" { + form.Set("redirect_uri", redirect) + } + if status, _ := g.token(form); status != http.StatusBadRequest { + t.Fatalf("redirect_uri %q: %d", redirect, status) + } + } +} + +// A code used again after its consent was swept still revokes what its +// first use got. +func TestACodeReusedAfterTheSweepStillRevokes(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, agentFor(f.agentKey(t), cimdID, g.now().Add(30*24*time.Hour), 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}, + "redirect_uri": {cimdBack}} + status, tk := g.token(form) + if status != http.StatusOK { + t.Fatalf("first use: %d", status) + } + g.advance(RequestTTL + time.Minute) + g.s.sweep(context.Background()) + if _, ok, _ := g.s.st.consentByRID(f.rid); ok { + t.Fatal("the consent outlived the sweep") + } + if status, _ := g.token(form); status != http.StatusBadRequest { + t.Fatalf("second use: %d", status) + } + if status, _ := g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}}); status != http.StatusBadRequest { + t.Fatalf("the first use's refresh token after the code came back: %d", status) + } +} + +// A refresh retried within a few seconds works once more, since its answer +// may have been lost; a third use revokes the family. +func TestARefreshRetryWithinTheGrace(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) + form := url.Values{"grant_type": {grantRefresh}, "refresh_token": {tk.Refresh}, "client_id": {cimdID}} + status, tk2 := g.token(form) + if status != http.StatusOK { + t.Fatalf("refresh: %d", status) + } + g.advance(refreshGrace / 2) + status, tk3 := g.token(form) + if status != http.StatusOK || tk3.Refresh == tk2.Refresh { + t.Fatalf("a retry within the grace: %d", status) + } + if status, _ := g.token(form); status != http.StatusBadRequest { + t.Fatalf("a third use: %d", status) + } + for _, a := range []string{tk2.Access, tk3.Access} { + if _, aerr := g.bearer(a); aerr == nil { + t.Fatal("a third use left the family alive") + } + } +} + +// The support tooling's client id is not a metadata client: support's +// offers never come through this page. +func TestSupportsClientIDIsRefused(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(protocol.SupportClientID, "https://cloud.ftw.energy/cb", "") + resp, _ := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode()) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("Location") != "" { + t.Fatalf("support's client id: %d", resp.StatusCode) + } +} + +// The pages a person sees ask the browser to use https only. +func TestPagesAskForHTTPS(t *testing.T) { + g := newRig(t) + q, _ := g.authorizeParams(cimdID, cimdBack, "") + for _, target := range []string{"/oauth/authorize?" + q.Encode(), "/oauth/authorize?client_id=nobody"} { + resp, _ := noFollow.Get(g.srv.URL + target) + resp.Body.Close() + if !strings.HasPrefix(resp.Header.Get("Strict-Transport-Security"), "max-age=") { + t.Fatalf("%s: no HSTS", target) + } + } +} + +// A registered client that never got an agent goes after a day; one with +// an agent stays. +func TestUnusedRegistrationsGo(t *testing.T) { + g := newRig(t) + register := func() string { + resp, err := http.Post(g.srv.URL+"/oauth/register", "application/json", + strings.NewReader(`{"client_name":"X","redirect_uris":["https://x.example/cb"]}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return out["client_id"].(string) + } + idle, used := register(), register() + f := g.start(used, "https://x.example/cb", "") + ch, stop := g.events(f, true) + defer stop() + next(t, ch) + g.roster(home, agentFor(f.agentKey(t), used, g.now().Add(30*24*time.Hour), ScopeLiveRead)) + next(t, ch) + g.advance(clientIdle + time.Hour) + g.s.sweep(context.Background()) + if _, ok, _ := g.s.st.client(idle); ok { + t.Fatal("an unused registration stayed") + } + if _, ok, _ := g.s.st.client(used); !ok { + t.Fatal("a registration with an agent went") + } +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go new file mode 100644 index 0000000..da84700 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,678 @@ +package oauth + +import ( + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol" + _ "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, the hashes of tokens and of spent codes, and the keys the +// keys part has yet to forget. 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, + redirect_given INTEGER NOT NULL DEFAULT 1, + 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 TEXT NOT NULL DEFAULT '', + authz BLOB UNIQUE, + authz_ms 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, + agent TEXT NOT NULL, + agent_key BLOB NOT NULL, + 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, + PRIMARY KEY (home, agent) +); +CREATE UNIQUE INDEX IF NOT EXISTS agents_key ON agents(agent_key); +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, + used_ms 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); +CREATE TABLE IF NOT EXISTS spent_codes ( + hash BLOB PRIMARY KEY, + family BLOB NOT NULL, + expires_ms INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS forgets ( + kid TEXT PRIMARY KEY, + at_ms INTEGER NOT NULL +);`) + 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 // a client FTW Cloud knows (verifiedClients) + RedirectURI string + // RedirectGiven is a redirect_uri the authorize request named, which + // the token request must then name too. + RedirectGiven bool + 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 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, redirect_given, state, challenge, scope, + resource, kid, agent_key, created_ms, expires_ms, status, home, agent, granted, shard, offer` + +func scanConsent(row interface{ Scan(...any) error }, extra ...any) (consent, error) { + var c consent + var verified, given int + var created, expires int64 + dest := append(extra, &c.Code, &c.ClientID, &c.ClientName, &c.ClientHost, &verified, &c.RedirectURI, &given, &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.Offer) + err := row.Scan(dest...) + c.Verified, c.RedirectGiven, c.Created, c.Expires = verified == 1, given == 1, time.UnixMilli(created), time.UnixMilli(expires) + return c, err +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (s *store) putConsent(rid, browser string, c consent) error { + _, err := s.db.Exec(`INSERT INTO requests (rid, browser, `+consentCols+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + hash(rid), hash(browser), c.Code, c.ClientID, c.ClientName, c.ClientHost, boolInt(c.Verified), c.RedirectURI, + boolInt(c.RedirectGiven), 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.Offer) + return err +} + +func (s *store) consentByRID(rid string) (consent, bool, error) { + return s.oneConsent(`SELECT `+consentCols+` FROM requests WHERE rid = ?`, hash(rid)) +} + +// 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) +} + +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()) + c, err = scanConsent(row, &ridHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, c, false, nil + } + 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, g agentGrant, now time.Time) error { + caps, _ := json.Marshal(g.Caps) + 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 + } + // An agent is its key: a row is never taken over by another key, even + // one whose 48-bit id is the same. + if _, err := tx.Exec(`INSERT INTO agents (home, agent, agent_key, kid, client_id, scope, ops, caps, shard, expires_ms, created_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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() +} + +// deny ends a pending consent and queues its key to be forgotten. +func (s *store) deny(rid string, now time.Time) (consent, bool, error) { + c, ok, err := s.consentByRID(rid) + if err != nil || !ok || c.Status != statusPending { + return c, false, err + } + tx, err := s.db.Begin() + if err != nil { + return c, false, err + } + defer tx.Rollback() + res, err := tx.Exec(`UPDATE requests SET status = ? WHERE rid = ? AND status = ?`, statusDenied, hash(rid), statusPending) + if err != nil { + return c, false, err + } + if n, _ := res.RowsAffected(); n != 1 { + return c, false, nil + } + if err := queueForget(tx, c.Kid, now); err != nil { + return c, false, err + } + return c, true, tx.Commit() +} + +// 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: +// the spent code's hash outlives its consent for as long as those tokens +// may live. +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 + c, err = scanConsent(tx.QueryRow(`SELECT rid, authz_used, authz_ms, `+consentCols+` FROM requests WHERE authz = ?`, + hash(code)), &ridHash, &used, &ms) + if errors.Is(err, sql.ErrNoRows) { + var family []byte + if tx.QueryRow(`SELECT family FROM spent_codes WHERE hash = ?`, hash(code)).Scan(&family) == nil { + return c, family, true, errGone + } + return c, nil, false, errGone + } + if err != nil { + return c, nil, false, err + } + 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 + } + if _, err := tx.Exec(`INSERT OR REPLACE INTO spent_codes (hash, family, expires_ms) VALUES (?, ?, ?)`, + hash(code), ridHash, now.Add(RefreshTTL).UnixMilli()); 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: the grant is the one the box's newest roster lists. +type agentRow struct { + Home string + Agent string + Key []byte + Kid string + ClientID string + Shard string + agentGrant +} + +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 := 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 + } + return a, err == nil, err +} + +// agentByKey finds the agent a key was approved as, in whatever home. +func (s *store) agentByKey(key []byte) (agentRow, bool, error) { + a, err := scanAgent(s.db.QueryRow(`SELECT `+agentCols+` FROM agents WHERE agent_key = ?`, key)) + if errors.Is(err, sql.ErrNoRows) { + return a, false, nil + } + return a, err == nil, err +} + +func (s *store) agentsOf(home string) ([]agentRow, error) { + 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() { + a, err := scanAgent(rows) + if err != nil { + return nil, err + } + 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, +// and queues its key to be forgotten, in one transaction. +func (s *store) dropAgent(home, agent, kid string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM agents WHERE home = ? AND agent = ?`, home, agent); err != nil { + return err + } + if err := queueForget(tx, kid, time.Now()); err != nil { + return err + } + return tx.Commit() +} + +// dropRequests deletes the consents that ended in a home. +func (s *store) dropRequests(home string) error { + _, err := s.db.Exec(`DELETE FROM requests WHERE home = ?`, home) + return err +} + +// -------------------------------------------------------------------------- +// Keys to forget +// -------------------------------------------------------------------------- + +type execer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +// queueForget records a key the keys part must delete. It stays queued +// until the keys part has done it. +func queueForget(x execer, kid string, now time.Time) error { + if kid == "" { + return nil + } + _, err := x.Exec(`INSERT OR IGNORE INTO forgets (kid, at_ms) VALUES (?, ?)`, kid, now.UnixMilli()) + return err +} + +func (s *store) queueForget(kid string, now time.Time) error { return queueForget(s.db, kid, now) } + +func (s *store) toForget() ([]string, error) { + rows, err := s.db.Query(`SELECT kid FROM forgets ORDER BY at_ms LIMIT 1000`) + if err != nil { + return nil, err + } + defer rows.Close() + var kids []string + for rows.Next() { + var kid string + if err := rows.Scan(&kid); err != nil { + return nil, err + } + kids = append(kids, kid) + } + return kids, rows.Err() +} + +func (s *store) forgotten(kid string) error { + _, err := s.db.Exec(`DELETE FROM forgets WHERE kid = ?`, kid) + 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 <= ?`, 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 +} + +// refreshGrace is how long a spent refresh token works once more: a client +// whose answer was lost on the way may try again. +const refreshGrace = 5 * time.Second + +// rotate spends a refresh token. False means it was spent before, outside +// the grace: a second use of one refresh token is theft or a bug, and the +// caller revokes its family. +func (s *store) rotate(tok string, now time.Time) (bool, error) { + res, err := s.db.Exec(`UPDATE tokens SET used = 1, used_ms = ? WHERE hash = ? AND kind = ? AND used = 0`, + now.UnixMilli(), hash(tok), kindRefresh) + if err != nil { + return false, err + } + if n, _ := res.RowsAffected(); n == 1 { + return true, nil + } + res, err = s.db.Exec(`UPDATE tokens SET used = 2 WHERE hash = ? AND kind = ? AND used = 1 AND used_ms > ?`, + hash(tok), kindRefresh, now.Add(-refreshGrace).UnixMilli()) + 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 +} + +// clientIdle is how long a registered client is kept without an agent. +const clientIdle = 24 * time.Hour + +// sweep deletes what has run out: tokens, spent codes, consents a while +// after they ended, and registered clients that never got an agent. The +// keys of consents that ended without approval are queued to be forgotten. +func (s *store) sweep(now time.Time) error { + ms := now.UnixMilli() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, q := range []string{ + `DELETE FROM tokens WHERE expires_ms <= ?`, + `DELETE FROM spent_codes WHERE expires_ms <= ?`, + } { + if _, err := tx.Exec(q, ms); err != nil { + return err + } + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO forgets (kid, at_ms) + SELECT kid, ? FROM requests WHERE status != ? AND expires_ms <= ?`, ms, statusApproved, ms); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM requests WHERE expires_ms <= ?`, ms); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM clients WHERE created_ms <= ? + AND client_id NOT IN (SELECT client_id FROM agents) AND client_id NOT IN (SELECT client_id FROM requests)`, + now.Add(-clientIdle).UnixMilli()); err != nil { + return err + } + return tx.Commit() +} + +// 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..a67c232 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,427 @@ +package oauth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "regexp" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +var verifierRE = regexp.MustCompile(`^[A-Za-z0-9._~-]{43,128}$`) + +// Token prefixes, so a leaked token is recognisable for what it is. After +// the prefix comes the token's route, then a dot and the secret. The route +// is the home, sealed with a fresh nonce under the service's route key: any +// instance can tell which shard holds a token's home without a lookup, and +// a client sees no id that is the same in two of its tokens, so it cannot +// link one connection to another by it. +const ( + accessPrefix = "ftwat_" + refreshPrefix = "ftwrt_" +) + +// routeKey seals a home into a token's route. Every instance that routes +// tokens holds the same one. +type routeKey struct{ aead cipher.AEAD } + +// loadRouteKey reads the route key, making it on first start. +func loadRouteKey(path string) (routeKey, error) { + key, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return routeKey{}, err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return routeKey{}, err + } + if _, err := f.Write(key); err != nil { + _ = f.Close() + return routeKey{}, err + } + if err := f.Close(); err != nil { + return routeKey{}, err + } + } else if err != nil { + return routeKey{}, err + } + if len(key) != 32 { + return routeKey{}, fmt.Errorf("oauth: %s holds %d bytes, want 32", path, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return routeKey{}, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return routeKey{}, err + } + return routeKey{aead: aead}, nil +} + +// newToken is prefix, the home sealed with a fresh nonce, a dot and 32 +// random bytes. +func (k routeKey) newToken(prefix, home string) string { + nonce := make([]byte, k.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + panic(err) + } + route := k.aead.Seal(nonce, nonce, []byte(home), []byte(prefix)) + return prefix + base64.RawURLEncoding.EncodeToString(route) + "." + randomToken(32) +} + +// home opens a token's route. +func (k routeKey) home(tok string) (string, bool) { + for _, p := range []string{accessPrefix, refreshPrefix} { + if !tokenShape(tok, p) { + continue + } + route, _, _ := strings.Cut(strings.TrimPrefix(tok, p), ".") + raw, err := base64.RawURLEncoding.DecodeString(route) + n := k.aead.NonceSize() + if err != nil || len(raw) < n+k.aead.Overhead() { + return "", false + } + home, err := k.aead.Open(nil, raw[:n], raw[n:], []byte(p)) + if err != nil { + return "", false + } + return string(home), true + } + return "", false +} + +// tokenShape is a token as this server makes one: the prefix, a route of +// base64url, 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 + } + route, secret, ok := strings.Cut(rest, ".") + if !ok || len(route) < 40 || len(route) > 160 || len(secret) != 43 { + return false + } + return base64URL(route) && base64URL(secret) +} + +func base64URL(s string) bool { + for _, c := range s { + 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 is routed to, for a router that sends it to +// the home's shard. It proves nothing: only the stored hash does. +func (s *Server) TokenHome(tok string) (string, bool) { return s.route.home(tok) } + +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") + 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)) + // A redirect_uri the authorize request named must be named again, the + // same; one it left out may be named only as the one it used. + redirect := f.Get("redirect_uri") + if c.ClientID != cl.ID || subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(c.Challenge)) != 1 || + (c.RedirectGiven || redirect != "") && redirect != 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, s.now()) + 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.Before(refreshEnd) { + refreshEnd = a.Expires + } + accessEnd := now.Add(AccessTTL) + if accessEnd.After(refreshEnd) { + accessEnd = refreshEnd + } + access, refresh := s.route.newToken(accessPrefix, a.Home), s.route.newToken(refreshPrefix, a.Home) + 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) +} + +// 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 !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) { + 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") + 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 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. + 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: 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 + Scopes []string + // 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. +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"} + 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 + } + 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: intersect(splitScope(t.Scope), a.Scopes), Ops: a.Ops, Caps: a.Caps, + Expires: t.Expires, AgentExpires: a.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, " ")} +} + +// 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)) +} 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") diff --git a/internal/store/store.go b/internal/store/store.go index 49cd462..79e7de4 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -216,6 +216,11 @@ func (h *home) spendLocked(pair []byte, now time.Time) bool { } // byKey matches the roster entry with a device's Noise static key. +// phoneRow is a roster row a phone, a watch or a token may use. +func phoneRow(d cloudwire.Device) bool { + return d.Agent == nil && d.Role != protocol.RoleAgent && d.Role != protocol.RoleSupport && d.Role != protocol.RoleRelease +} + func byKey(key []byte) func(cloudwire.Device) bool { return func(d cloudwire.Device) bool { return len(key) == 32 && bytes.Equal(d.Key, key) } } @@ -232,6 +237,11 @@ func (s *Store) access(id string, h *home, match func(cloudwire.Device) bool) (c if !match(d) { continue } + // An agent's, support's or the release check's row reaches a home + // only through the keys part, never as a phone, a watch or a token. + if !phoneRow(d) { + return cloudwire.Device{}, nil, false + } if s.suspended != nil && s.suspended(id, d.ID, d.Epoch) { return cloudwire.Device{}, nil, false } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 20055da..5c57e0e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -346,3 +346,35 @@ func TestATokenReadsWithinItsDevicesScope(t *testing.T) { t.Fatalf("a token without live read %q", snap.Envelopes) } } + +// An agent's, support's or the release check's row is never a phone, a +// watch or a token, whatever else it says: it reaches a home only through +// the keys part. +func TestAnAgentRowIsNoPhone(t *testing.T) { + c := &clock{t: time.Unix(1_791_820_800, 0)} + s := New(c.now) + box, _ := s.For(Principal{Kind: Box, Home: "h1"}) + box.Connected() + r := roster(1, protocol.ScopeLiveRead) + keyOf := func(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + r.Devices = append(r.Devices, + cloudwire.Device{ID: "agent", Key: keyOf(3), Role: protocol.RoleAgent, Tun: true, Epoch: 1, + Agent: &cloudwire.Agent{ExpiresAtMs: c.t.Add(time.Hour).UnixMilli()}}, + cloudwire.Device{ID: "support", Key: keyOf(4), Role: protocol.RoleSupport, Tun: true, Epoch: 1}, + cloudwire.Device{ID: "release", Key: keyOf(5), Role: protocol.RoleRelease, Tun: true, Epoch: 1}, + cloudwire.Device{ID: "agentish", Key: keyOf(6), Role: protocol.RoleOwner, Tun: true, Epoch: 1, + Agent: &cloudwire.Agent{ExpiresAtMs: c.t.Add(time.Hour).UnixMilli()}}, + ) + box.SetRoster(r) + for i, id := range []string{"agent", "support", "release", "agentish"} { + if _, err := s.For(Principal{Kind: Device, Home: "h1", DeviceKey: keyOf(byte(3 + i))}); !errors.Is(err, ErrDenied) { + t.Errorf("%s as a phone: %v", id, err) + } + if _, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: id, Epoch: 1}); !errors.Is(err, ErrDenied) { + t.Errorf("%s by a token: %v", id, err) + } + } + if _, err := s.For(Principal{Kind: Device, Home: "h1", DeviceKey: ownerKey}); err != nil { + t.Fatalf("the owner's phone: %v", err) + } +} diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index c82f913..82bd382 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -94,6 +94,10 @@ type Uplink struct { OnHeld func(home, class string, envelope []byte) OnRoster func(home string, r *cloudwire.Roster) OnGone func(home, why string, conn uint64) + // OnPurge hears that a box purged its home, each time it asks, so what + // other parts keep for the home goes with it. It runs on the box's + // goroutine and must not block. Set before serving. + OnPurge func(home string) } // New builds the handler. key is the service key that signs hello_ok and @@ -640,5 +644,8 @@ func (c *boxConn) purge() error { } else if grantID == nil { grantID = c.u.registry.TombstoneGrant(c.home) } + if c.u.OnPurge != nil { + c.u.OnPurge(c.home) + } return c.send(purged{AtMs: now.UnixMilli(), What: cloudwire.PurgedHome, GrantID: grantID}) } diff --git a/internal/uplink/uplink_test.go b/internal/uplink/uplink_test.go index 1321708..483c2e8 100644 --- a/internal/uplink/uplink_test.go +++ b/internal/uplink/uplink_test.go @@ -1,6 +1,7 @@ package uplink import ( + "sync" "bytes" "crypto/ecdsa" "crypto/elliptic" @@ -256,6 +257,9 @@ func TestARosterBeyondFTWCloudIsRefused(t *testing.T) { // service key, which a box whose first receipt was lost can ask for again. func TestPurgeDeletesTheHomeAndSignsAReceipt(t *testing.T) { s := newService(t) + var mu sync.Mutex + var purged []string + s.up.OnPurge = func(home string) { mu.Lock(); purged = append(purged, home); mu.Unlock() } b := newBox(t) _, _ = b.connect(s, nil) r := b.roster(protocol.ScopeLiveRead) @@ -286,6 +290,12 @@ func TestPurgeDeletesTheHomeAndSignsAReceipt(t *testing.T) { } b.send(cloudwire.MsgBye, cloudwire.Bye{Header: b.header(), Why: cloudwire.ByeRevoked, Purge: true}) receipt() + // What other parts keep for the home goes too, each time the box asks. + mu.Lock() + if len(purged) != 2 || purged[0] != b.home || purged[1] != b.home { + t.Fatalf("OnPurge heard %v", purged) + } + mu.Unlock() // A purged home's box may say only bye. _ = b.conn.Close()