From ab3039e8fb12c4859b7f685f120f63698f2cac3d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:34:14 +0200 Subject: [PATCH 1/3] Choose read or setup access before signing an omitted-scope connection --- README.md | 20 ++- internal/mcp/mcp.go | 5 +- internal/mcp/setup_test.go | 17 +- internal/mcp/tools.go | 17 +- internal/oauth/authorize.go | 16 +- internal/oauth/invite.go | 4 + internal/oauth/mode.go | 120 +++++++++++++ internal/oauth/mode_test.go | 337 +++++++++++++++++++++++++++++++++++ internal/oauth/oauth.go | 2 + internal/oauth/oauth_test.go | 15 +- internal/oauth/page.go | 55 ++++++ internal/oauth/store.go | 8 +- 12 files changed, 597 insertions(+), 19 deletions(-) create mode 100644 internal/oauth/mode.go create mode 100644 internal/oauth/mode_test.go diff --git a/README.md b/README.md index 48b71c6..26f6729 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,16 @@ to it yet. as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older clients. There is no account and no password: `/oauth/authorize` asks - `ftwkeysd` for a new agent key and has it sign the grant offer + `ftwkeysd` for a new agent key. If the client omits `scope`, the original + browser first chooses read/troubleshoot or read/configure. Nothing exposes + the key, offer, QR or owner code before that choice. Configure adds + `ftw.setup.write` to the read scopes; neither choice adds control. An + explicit scope parameter keeps its existing meaning, including an explicitly + blank value. Only the original cookie and exact issuer Origin can save a + mode, once, without changing the key or deadline. Competing choices read + the saved result; a late signature cannot revive a cancelled request. + Signing uses the same global and address limits as authorization. + Then `ftwkeysd` signs 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 @@ -241,8 +250,13 @@ to it yet. and the readable copy is never an agent's: every read goes through the box, which audits it and enforces revoke. An agent row has no tunnel of its own. A call without its scope gets - 403 `insufficient_scope`, so the client can ask the owner for more. Each - call rides the agent's own Noise session: `ftwkeysd` seals each frame and + 403 `insufficient_scope`, so the client can ask the owner for more. A + setup write asks for both `ftw.diag.run` and `ftw.setup.write`, so a fresh + connection can read its schema and save receipt. Tool metadata and the + runtime OAuth challenge name the same scopes, as required by + [ChatGPT's authentication flow](https://developers.openai.com/plugins/build/auth#triggering-authentication-ui). + Existing token scopes stay in the request; no scope is granted by inference. + Each call rides the agent's own Noise session: `ftwkeysd` seals each frame and the door carries it as `tun`, one forward to the box. The door admits an agent row only to these sessions, never on `/v1/app` or `/v1/app/once`, and only before its end. A session stays warm for 60 s and calls on one diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 9664a3d..e4e0bed 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -372,9 +372,10 @@ func (s *Server) call(w http.ResponseWriter, r *http.Request, id json.RawMessage writeRPCError(w, status, id, codeInvalidParams, "Unknown tool: "+p.Name, nil) return } - if t.scope != "" && !g.Has(t.scope) { + required := t.requiredScopes() + if slices.ContainsFunc(required, func(scope string) bool { return !g.Has(scope) }) { // Step up: the client asks the owner for the right, then retries. - scopes := append(slices.Clone(g.Scopes), t.scope) + scopes := append(slices.Clone(g.Scopes), required...) slices.Sort(scopes) scopes = slices.Compact(scopes) challenge := oauth.InsufficientScope(scopes...) diff --git a/internal/mcp/setup_test.go b/internal/mcp/setup_test.go index da5357c..072d352 100644 --- a/internal/mcp/setup_test.go +++ b/internal/mcp/setup_test.go @@ -72,7 +72,7 @@ func TestSetupScopeUpgradePreservesReadsAndSignalsChatGPT(t *testing.T) { } meta, _ := result["_meta"].(map[string]any) challenge := meta["mcp/www_authenticate"].([]any)[0].(string) - if challenge != r.Header.Get("WWW-Authenticate") || !strings.Contains(challenge, `scope="ftw.config.read ftw.setup.write"`) { + if challenge != r.Header.Get("WWW-Authenticate") || !strings.Contains(challenge, `scope="ftw.config.read ftw.diag.run ftw.setup.write"`) { t.Fatal(challenge) } } @@ -82,9 +82,22 @@ func TestSetupScopeUpgradePreservesReadsAndSignalsChatGPT(t *testing.T) { for _, tool := range w.mcp.listing() { if tool["name"] == "setup_edit" { schemes := tool["securitySchemes"].([]map[string]any) - if schemes[0]["scopes"].([]string)[0] != oauth.ScopeSetupWrite { + if !slices.Equal(schemes[0]["scopes"].([]string), []string{oauth.ScopeDiagRun, oauth.ScopeSetupWrite}) { t.Fatal(schemes) } } } } + +func TestSetupWriteWithoutDiagnosticReadsRequestsBothRights(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + a := w.connect(b, oauth.ScopeSetupWrite, oauth.ScopeSetupWrite) + r := a.rpc("tools/call", map[string]any{"name": "setup_edit", "arguments": map[string]any{"revision": 0, "patch": map[string]any{}}}, false) + if r.Status != http.StatusForbidden || !strings.Contains(r.Header.Get("WWW-Authenticate"), `scope="ftw.diag.run ftw.setup.write"`) { + t.Fatal("a write-only grant bypassed the setup read requirement", r.Status, r.Body) + } + if _, _, asked := b.counts(); len(asked) != 0 { + t.Fatal("a write-only setup reached the box", asked) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index aea80a5..03bf736 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -31,6 +31,18 @@ type tool struct { run func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error) } +// Setup writes need the diagnostic reads that let the assistant understand +// the schema, review a draft and check a save after a lost reply. +func (t *tool) requiredScopes() []string { + if t.scope == oauth.ScopeSetupWrite { + return []string{oauth.ScopeDiagRun, oauth.ScopeSetupWrite} + } + if t.scope == "" { + return []string{} + } + return []string{t.scope} +} + // callCtx is one call's grant and the way to its home. type callCtx struct { s *Server @@ -90,10 +102,7 @@ func (s *Server) tool(name string) *tool { func (s *Server) listing() []map[string]any { out := make([]map[string]any, 0, len(s.tools)) for _, t := range s.tools { - scopes := []string{} - if t.scope != "" { - scopes = append(scopes, t.scope) - } + scopes := t.requiredScopes() schemes := []map[string]any{{"type": "oauth2", "scopes": scopes}} out = append(out, map[string]any{ "name": t.name, diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index 2f06948..2f2bec0 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -119,6 +119,10 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { if len(req.scope) == 0 { req.scope = slices.Clone(ReadScopes) } + chooseMode := !q.Has("scope") + if chooseMode { + req.scope = nil + } // 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. @@ -145,9 +149,11 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) { 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 + if !chooseMode { + 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 { @@ -291,6 +297,10 @@ func (s *Server) serveRequest(w http.ResponseWriter, r *http.Request) { return } allowApp(w, r) + if c.Offer == "" { + writeJSON(w, http.StatusConflict, map[string]string{"status": "mode_required"}, false) + return + } v := s.view(c) v.Request = r.PathValue("rid") writeJSON(w, http.StatusOK, v, false) diff --git a/internal/oauth/invite.go b/internal/oauth/invite.go index fd6232b..700027e 100644 --- a/internal/oauth/invite.go +++ b/internal/oauth/invite.go @@ -49,6 +49,10 @@ func (s *Server) serveInvite(w http.ResponseWriter, r *http.Request) { http.Error(w, "This connection request ended. Connect again from your assistant.", http.StatusGone) return } + if c.Offer == "" { + http.Error(w, "Choose what this connection is for first.", http.StatusConflict) + return + } var body struct { Code string `json:"code"` } diff --git a/internal/oauth/mode.go b/internal/oauth/mode.go new file mode 100644 index 0000000..50715d9 --- /dev/null +++ b/internal/oauth/mode.go @@ -0,0 +1,120 @@ +package oauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "time" +) + +// Only the browser that started a request may choose its default rights. +// A client that names scopes keeps exactly that request, without this step. +func (s *Server) modeBrowser(w http.ResponseWriter, r *http.Request) (string, string, bool) { + w.Header().Set("Cache-Control", "no-store") + rid := r.PathValue("rid") + id, err := base64.RawURLEncoding.DecodeString(rid) + if err != nil || len(id) != 16 || (r.Method == http.MethodPost && r.Header.Get("Origin") != Issuer) { + http.Error(w, "Open this connection page from your assistant.", http.StatusForbidden) + return "", "", false + } + ck, err := r.Cookie(cookieName(rid)) + if err != nil || !s.st.browserMatches(rid, ck.Value) { + http.Error(w, "Return to the browser that started this connection.", http.StatusForbidden) + return "", "", false + } + return rid, ck.Value, true +} + +func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) { + rid, _, ok := s.modeBrowser(w, r) + if !ok { + return + } + c, exists, err := s.st.consentByRID(rid) + if err != nil || !exists { + http.NotFound(w, r) + return + } + if c.Status == statusDenied || !s.now().Before(c.Expires) { + s.authorizePage(w, http.StatusGone, pageError{Title: "Connect again", Text: "This request ended. Start a new connection from your assistant."}) + return + } + s.consentPage(w, rid, c) +} + +func (s *Server) serveMode(w http.ResponseWriter, r *http.Request) { + rid, browser, ok := s.modeBrowser(w, r) + if !ok { + return + } + var body struct { + Mode string `json:"mode"` + } + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256)) + d.DisallowUnknownFields() + if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || (body.Mode != "read" && body.Mode != "setup") { + http.Error(w, "Choose read and troubleshoot, or set up and configure.", http.StatusBadRequest) + return + } + c, exists, err := s.st.consentByRID(rid) + if err != nil || !exists { + http.NotFound(w, r) + return + } + if (c.Status != statusPending && c.Status != statusApproved) || !s.now().Before(c.Expires) { + http.Error(w, "This request ended. Connect again from your assistant.", http.StatusGone) + return + } + scopes := slices.Clone(ReadScopes) + if body.Mode == "setup" { + scopes = append(scopes, ScopeSetupWrite) + } + wanted := strings.Join(scopes, " ") + if c.Status == statusPending && c.Scope == "" && c.Offer == "" { + if s.limited(&s.authorizeLimit, s.authorizeByIP, r) { + w.Header().Set("Retry-After", "10") + http.Error(w, "FTW Cloud is busy connecting assistants. Try the same choice again shortly.", http.StatusTooManyRequests) + return + } + // Signing can wait on the key process. Do it outside SQLite's lock; + // the write below checks the browser, phase and deadline again. + c.Scope = wanted + ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) + defer cancel() + c.Offer, err = s.signOffer(ctx, c.Kid, buildOffer(c)) + if err != nil { + http.Error(w, "The choice was not saved. Try the same choice again.", http.StatusServiceUnavailable) + return + } + _, err = s.st.db.Exec(`UPDATE requests SET scope = ?, offer = ? + WHERE rid = ? AND browser = ? AND scope = '' AND offer = '' AND status = ? AND expires_ms > ?`, + c.Scope, c.Offer, hash(rid), hash(browser), statusPending, s.now().UnixMilli()) + if err != nil { + http.Error(w, "The choice is not confirmed. Try the same choice again.", http.StatusServiceUnavailable) + return + } + c, exists, err = s.st.consentByRID(rid) + if err != nil || !exists { + http.Error(w, "The choice is not confirmed. Try the same choice again.", http.StatusServiceUnavailable) + return + } + } + if (c.Status != statusPending && c.Status != statusApproved) || !s.now().Before(c.Expires) { + http.Error(w, "This request ended. Connect again from your assistant.", http.StatusGone) + return + } + if c.Offer == "" { + http.Error(w, "This request is incomplete. Connect again from your assistant.", http.StatusGone) + return + } + status := http.StatusOK + state := "ready" + if c.Scope != wanted { + status, state = http.StatusConflict, "choice_locked" + } + writeJSON(w, status, map[string]string{"state": state, "connect": "/oauth/requests/" + rid + "/connect"}, false) +} diff --git a/internal/oauth/mode_test.go b/internal/oauth/mode_test.go new file mode 100644 index 0000000..560520c --- /dev/null +++ b/internal/oauth/mode_test.go @@ -0,0 +1,337 @@ +package oauth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +// startUnchosen follows the actual omitted-scope page without choosing yet. +func (g *rig) startUnchosen() (*flow, string) { + g.t.Helper() + q, f := g.authorizeParams(cimdID, cimdBack, "") + 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() + m := ridRE.FindSubmatch(body) + if resp.StatusCode != http.StatusOK || m == nil { + g.t.Fatalf("start mode: %d %s", resp.StatusCode, body) + } + f.rid = string(m[1]) + for _, cookie := range resp.Cookies() { + if cookie.Name == cookieName(f.rid) { + f.cookie = cookie + } + } + if f.cookie == nil { + g.t.Fatal("no original browser cookie") + } + return f, string(body) +} + +func (g *rig) modeRequest(f *flow, mode, origin string, cookie bool) *http.Response { + g.t.Helper() + req, _ := http.NewRequest(http.MethodPost, g.srv.URL+"/oauth/requests/"+f.rid+"/mode", strings.NewReader(`{"mode":"`+mode+`"}`)) + req.Header.Set("Origin", origin) + if cookie { + req.AddCookie(f.cookie) + } + resp, err := noFollow.Do(req) + if err != nil { + g.t.Fatal(err) + } + return resp +} + +func (g *rig) modeConsent(f *flow) consent { + g.t.Helper() + c, ok, err := g.s.st.consentByRID(f.rid) + if err != nil || !ok { + g.t.Fatal("missing consent", err) + } + return c +} + +func TestModeChoiceHidesAndBlocksApprovalUntilSaved(t *testing.T) { + g := newRig(t) + f, page := g.startUnchosen() + c := g.modeConsent(f) + if c.Scope != "" || c.Offer != "" || !strings.Contains(page, "Read and troubleshoot") || + !strings.Contains(page, "Set up and configure") || strings.Contains(page, " Connect an assistant · FTW Cloud

{{.Title}}

{{.Text}}

{{if .Back}}

Go back to {{.BackHost}}

{{end}}
`)) + +func (s *Server) modePage(w http.ResponseWriter, rid string, c consent) { + d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName} + pageHeaders(w, d.Nonce) + w.WriteHeader(http.StatusOK) + _ = modeTmpl.Execute(w, d) +} + +var modeTmpl = template.Must(template.New("mode").Parse(` + +Choose your connection · FTW Cloud
+

How will you use {{.Name}} with FTW?

+

Choose what this connection can ask for. You will approve it for your home in the next step, or use an invitation code from its owner.

+
+ + +
+

Neither choice grants temporary control. The home owner decides the final rights. You can remove access at any time in FTW.

+

+

+

+ +
`)) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 8d54df2..1483832 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -251,7 +251,7 @@ func (s *store) consentByRID(rid string) (consent, bool, error) { // spendCode finds a pending request by its short code, once: a code that // found its request finds nothing again. func (s *store) spendCode(code string, now time.Time) (consent, bool, error) { - res, err := s.db.Exec(`UPDATE requests SET code_used = 1 WHERE code = ? AND code_used = 0 AND status = ? AND expires_ms > ?`, + res, err := s.db.Exec(`UPDATE requests SET code_used = 1 WHERE code = ? AND code_used = 0 AND offer <> '' AND status = ? AND expires_ms > ?`, code, statusPending, now.UnixMilli()) if err != nil { return consent{}, false, err @@ -280,7 +280,7 @@ func (s *store) browserMatches(rid, browser string) bool { // 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 > ?`, + row := s.db.QueryRow(`SELECT rid, `+consentCols+` FROM requests WHERE agent_key = ? AND offer <> '' AND status = ? AND expires_ms > ?`, key, statusPending, now.UnixMilli()) c, err = scanConsent(row, &ridHash) if errors.Is(err, sql.ErrNoRows) { @@ -308,8 +308,8 @@ func (s *store) approve(ridHash []byte, c consent, g agentGrant, now time.Time) 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) + WHERE rid = ? AND offer <> '' AND status = ? AND expires_ms > ?`, + statusApproved, c.Home, c.Agent, c.Granted, c.Shard, now.Add(RequestTTL).UnixMilli(), ridHash, statusPending, now.UnixMilli()) if err != nil { return err } From 39cf2f5717511328d3d82c8d276069606da508a0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:46:15 +0200 Subject: [PATCH 2/3] Show the client address before choosing access and explain normalized drafts --- internal/mcp/setup.go | 2 +- internal/oauth/page.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/mcp/setup.go b/internal/mcp/setup.go index dc24a90..b5b04c5 100644 --- a/internal/mcp/setup.go +++ b/internal/mcp/setup.go @@ -35,7 +35,7 @@ func setupTools() []*tool { {"setup_reset", "Start a new draft from current settings", protocol.MsgSetupReset, "Discard this unfinished draft only when the user asks, or start another after a completed save. Requires its exact current revision. Keeps all current settings and credentials; does not restart the box. Carry useful answers forward when resolving a conflict.", object(map[string]any{"revision": integer("Current draft revision", 0, 9007199254740991, 0)}, "revision"), false}, {"setup_validate", "Check a setup draft", protocol.MsgSetupValidate, - "Validate the exact current draft against Core's own checks. Review changes, assumptions and any required restart with the user. Validation does not prove a device connects, measures or obeys control.", object(checks, "revision", "base_revision", "sha256"), false}, + "Validate the exact current draft against Core's own checks. Core may normalize fields into a new draft revision and checksum; review that returned configuration, changes, warnings, assumptions and any required restart with the user. Confirm and commit only the returned revision and checksum. Validation does not prove a device connects, measures or obeys control.", object(checks, "revision", "base_revision", "sha256"), false}, {"setup_commit", "Save the setup the user confirmed", protocol.MsgSetupCommit, "Save only the exact draft the user reviewed and confirmed after setup_validate. Requires the separately owner-approved setup grant. Uses the box's existing durable Settings save and apply path, with a receipt committed in the same transaction. Retry the same revision and digest after a lost reply; never guess whether a save failed. Report saved, applied, restart_required and hardware_verified separately. This tool does not command hardware or approve Cloud sharing.", object(commit, "revision", "base_revision", "sha256", "confirmed"), false}, {"setup_restart", "Apply saved setup that needs a restart", protocol.MsgSetupRestart, diff --git a/internal/oauth/page.go b/internal/oauth/page.go index d027e22..ff82ace 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -204,7 +204,7 @@ var errorTmpl = template.Must(template.New("error").Parse(` {{if .Back}}

Go back to {{.BackHost}}

{{end}}`)) func (s *Server) modePage(w http.ResponseWriter, rid string, c consent) { - d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName} + d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID)} pageHeaders(w, d.Nonce) w.WriteHeader(http.StatusOK) _ = modeTmpl.Execute(w, d) @@ -217,6 +217,7 @@ var modeTmpl = template.Must(template.New("mode").Parse(` .choice strong,.choice span{display:block}.choice span{font-size:.9rem;margin-top:4px;color:var(--muted)}

How will you use {{.Name}} with FTW?

+

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

Choose what this connection can ask for. You will approve it for your home in the next step, or use an invitation code from its owner.

From f518a8d3403cb80da9d40c8c42f66e6d8cbb4078 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:53:44 +0200 Subject: [PATCH 3/3] Clarify the current setup access choice --- internal/oauth/page.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/oauth/page.go b/internal/oauth/page.go index ff82ace..276325f 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -221,7 +221,7 @@ var modeTmpl = template.Must(template.New("mode").Parse(`

Choose what this connection can ask for. You will approve it for your home in the next step, or use an invitation code from its owner.

- +

Neither choice grants temporary control. The home owner decides the final rights. You can remove access at any time in FTW.