diff --git a/go.mod b/go.mod index af38224..ba59d5a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261017070253-cbdd8a587105 + github.com/srcfl/ftw/go/protocol v0.0.0-20261017085842-8a7bd9c6e597 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index a605893..06141cc 100644 --- a/go.sum +++ b/go.sum @@ -52,10 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016102129-7fb84d6ed573 h1:1OOC4RdeERT9r6cLMTlcmTjg8+ML2kU6hCvhx/x+8xI= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016102129-7fb84d6ed573/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017070253-cbdd8a587105 h1:56AF3FQxXwfTVacD+AtJQM96TdzntTeQy1f4yiN54ic= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017070253-cbdd8a587105/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017085842-8a7bd9c6e597 h1:oQkKbo+YEhcUYE6IwzSUR5xDvkDOAa/SNQfsI2JJGqQ= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017085842-8a7bd9c6e597/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index ded9a6b..1e2c0ff 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -64,9 +64,13 @@ const ( // 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 + // RefreshTTL is a refresh token's life: the longest grant an agent may + // have. The agent's end does not shorten it, so a grant the owner renews + // while its client is idle keeps its tokens; an agent past its end is + // refused at every refresh and swept. + RefreshTTL = maxGrant + // maxGrant is the longest end the service takes from a roster. + maxGrant = time.Duration(protocol.AgentMaxMs) * time.Millisecond ) // Scopes. Each is a right the box grants an agent, by its registry name @@ -416,7 +420,7 @@ func (s *Server) applyRoster(ctx context.Context, home string, devices []cloudwi s.revoke(ctx, a) continue } - if err := s.st.setGrant(a.Home, a.Agent, grantOf(d)); err != nil { + if err := s.st.setGrant(a.Home, a.Agent, grantOf(d, s.now())); err != nil { s.log.Warn("could not update an agent's grant", "err", err) } } @@ -442,7 +446,7 @@ func (s *Server) approve(ctx context.Context, home string, listed map[string]clo } 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 { + if err := s.st.approve(ridHash, c, grantOf(d, now), now); err != nil { s.log.Warn("could not record an approval", "err", err) continue } @@ -480,9 +484,15 @@ func (s *Server) retryApprovals(ctx context.Context) { } // grantOf is an agent row's grant, as the box signed it in the roster. -func grantOf(d cloudwire.Device) agentGrant { +// The service never takes an end past the longest grant from now, whatever a +// roster says. +func grantOf(d cloudwire.Device, now time.Time) agentGrant { + end := time.UnixMilli(d.Agent.ExpiresAtMs) + if most := now.Add(maxGrant); end.After(most) { + end = most + } return agentGrant{Scopes: slices.Clone(d.Agent.Scopes), Ops: slices.Clone(d.Agent.Ops), Caps: d.Agent.Caps, - Expires: time.UnixMilli(d.Agent.ExpiresAtMs)} + Expires: end} } // revoke drops an agent and its tokens, and has its key forgotten. diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index afabddb..bf34797 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -802,6 +802,63 @@ func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { } } +// A grant the owner renews keeps its tokens past its first end, even when +// the assistant made no call in between: the refresh token does not end with +// the grant it was issued under. An access token never outlives the grant, +// and once the grant the box sets has ended, a refresh fails. +func TestARefreshTokenFollowsTheGrantTheBoxSets(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + tk := g.approve(f, cimdID) // the box grants 30 days + key := f.agentKey(t) + refresh := func(tok string) (int, tokens) { + g.t.Helper() + return g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tok}, "client_id": {cimdID}}) + } + + // The assistant goes quiet after day 0. On day 29 the owner renews the + // grant for a year; on day 31 the assistant comes back with the refresh + // token it was given on day 0. + g.advance(29 * 24 * time.Hour) + g.roster(home, agentFor(key, cimdID, g.now().Add(365*24*time.Hour), ScopeLiveRead, ScopePlanRead)) + g.advance(2 * 24 * time.Hour) + status, tk2 := refresh(tk.Refresh) + if status != http.StatusOK { + t.Fatalf("an idle assistant lost a renewed grant at its first end: %d", status) + } + if _, aerr := g.bearer(tk2.Access); aerr != nil { + t.Fatalf("the token after the first end: %+v", aerr) + } + + // The owner cuts it to five minutes: the access token ends with it. + g.roster(home, agentFor(key, cimdID, g.now().Add(5*time.Minute), ScopeLiveRead, ScopePlanRead)) + status, tk3 := refresh(tk2.Refresh) + if status != http.StatusOK || tk3.Expires > 300 { + t.Fatalf("refresh after the cut: %d, access for %d s", status, tk3.Expires) + } + g.advance(5*time.Minute + time.Second) + if _, aerr := g.bearer(tk3.Access); aerr == nil { + t.Fatal("an access token outlived the grant") + } + if status, _ := refresh(tk3.Refresh); status != http.StatusBadRequest { + t.Fatalf("refresh after the grant ended: %d", status) + } +} + +// A roster that sets an end past the longest grant is taken as a year from +// now. +func TestARosterEndPastAYearIsCut(t *testing.T) { + g := newRig(t) + f := g.start(cimdID, cimdBack, "") + g.approve(f, cimdID) + key := f.agentKey(t) + g.roster(home, agentFor(key, cimdID, g.now().Add(2*365*24*time.Hour), ScopeLiveRead, ScopePlanRead)) + a, ok, _ := g.s.st.agent(home, cloudwire.DeviceID(key)) + if most := g.now().Add(365 * 24 * time.Hour); !ok || a.Expires.After(most) { + t.Fatalf("the agent ends %v, past %v", a.Expires, most) + } +} + // An agent whose time the box set runs out fails, and is swept. func TestAnExpiredAgentFails(t *testing.T) { g := newRig(t) @@ -1071,8 +1128,9 @@ func TestApprovalOutlastsAKeysPartThatIsAway(t *testing.T) { // The offer: the contract's SignedOffer, which protocol.OpenOffer opens // with the offer key pinned by its kid. It carries the agent's key, the -// client and its host, what it asks for, the code and an end within ten -// minutes, and no home; in the QR, the link and the request's details alike. +// client and its host, what it asks for, a year as the longest the owner may +// grant, the code and an end within ten minutes, and no home; in the QR, the +// link and the request's details alike. func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { g := newRig(t) f := g.start(cimdID, cimdBack, ScopeLiveRead+" "+ScopeDispatchHold) @@ -1092,7 +1150,7 @@ func TestTheOfferIsSignedAndCarriesTheConsent(t *testing.T) { if o.Home != "" || !bytes.Equal(o.Key, f.agentKey(t)) || o.Role != protocol.RoleAgent || o.Client != "Claude" || o.ClientID != cimdID || o.ClientHost != "example.com" || o.Verified || o.Code != f.view.Code || !slices.Equal(o.Scopes, []string{ScopeDispatchHold, ScopeLiveRead}) || !slices.Equal(o.Ops, []string{"loadpoint.hold"}) || - o.Caps.HoldS == 0 || o.TTLS != uint64(protocol.AgentMaxMs/1000) || o.ExpMs > g.now().UnixMilli()+protocol.OfferTTLMs { + o.Caps.HoldS == 0 || o.TTLS != 365*24*60*60 || o.ExpMs > g.now().UnixMilli()+protocol.OfferTTLMs { t.Fatalf("offer %+v", o) } // Another key does not open it, and it ends. diff --git a/internal/oauth/offer.go b/internal/oauth/offer.go index 753fc18..6704b18 100644 --- a/internal/oauth/offer.go +++ b/internal/oauth/offer.go @@ -32,7 +32,9 @@ var proposedHoldCaps = protocol.Caps{HoldS: protocol.HoldSMax, MaxW: 11_000, Per // buildOffer is the offer for one consent: the agent's key, what its client // asks for, and the code on the page. The home is left empty: the owner -// picks it, in the app or on the box's page. +// picks it, in the app or on the box's page. So the offer asks for the +// longest an agent may have; the app and the box page start at 30 days and +// offer a box no more than it takes (docs/agents.md, Mixed versions). func buildOffer(c consent) protocol.GrantOffer { scopes := slices.Sorted(slices.Values(splitScope(c.Scope))) var ops []string diff --git a/internal/oauth/store.go b/internal/oauth/store.go index da84700..b8ee4db 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -363,8 +363,8 @@ func (s *store) issueCode(rid, code string, expires time.Time) error { // 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. +// the spent code's hash outlives its consent for as long as one refresh +// token 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 { diff --git a/internal/oauth/token.go b/internal/oauth/token.go index a67c232..c611dff 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -199,7 +199,7 @@ func (s *Server) exchangeCode(w http.ResponseWriter, r *http.Request, cl client) 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)) + s.issue(w, ridHash, cl.ID, a, c.Granted) } func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { @@ -236,19 +236,21 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request, cl client) { tokenError(w, http.StatusBadRequest, "invalid_grant", "the owner removed this assistant") return } - s.issue(w, t.Family, cl.ID, a, scope, t.Expires) + s.issue(w, t.Family, cl.ID, a, scope) } // 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) { +// The access token ends within AccessTTL and never after the agent's end as +// the box's newest roster sets it. The refresh token lasts RefreshTTL and is +// not clamped to that end: the owner may renew the grant while the client is +// idle, and an agent past its end is refused at every refresh and every call +// (agentGone) and swept with its tokens. +func (s *Server) issue(w http.ResponseWriter, family []byte, clientID string, a agentRow, scope string) { now := s.now() - if a.Expires.Before(refreshEnd) { - refreshEnd = a.Expires - } + refreshEnd := now.Add(RefreshTTL) accessEnd := now.Add(AccessTTL) - if accessEnd.After(refreshEnd) { - accessEnd = refreshEnd + if accessEnd.After(a.Expires) { + accessEnd = a.Expires } 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}