From 48a3310716489762f46ff0251165a8373873e1a6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:22:48 +0200 Subject: [PATCH 1/4] feat(oauth): an assistant's tokens may last as long as its grant, up to a year The signed offer asks for a year, the new protocol.AgentMaxMs. The owner picks less in the app or on the box page; both start at 30 days and offer a box no more than its agent.list says it takes, or 30 days when it says nothing. A refresh token lasts at most RefreshTTL, now a year, and never past the grant's end in the box's newest roster. Each refresh now takes that end afresh, so a grant the owner renews keeps its tokens; before, a family died at the end it had when the code was swapped, renewed or not. The access token stays at ten minutes. Moves the go/protocol pin to srcfl/ftw claude/agent-grant-year. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 +-- internal/oauth/oauth.go | 7 +++-- internal/oauth/oauth_test.go | 59 ++++++++++++++++++++++++++++++++++-- internal/oauth/offer.go | 4 ++- internal/oauth/store.go | 4 +-- internal/oauth/token.go | 5 +-- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index c461f43..172858b 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-20261016102129-7fb84d6ed573 + github.com/srcfl/ftw/go/protocol v0.0.0-20261017061757-aac7a0f6f589 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 0398da6..94c28fa 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +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-20261017061757-aac7a0f6f589 h1:3qRtBjuBHqGlPdz+Jy+HAHdGycFCBzBh/49RuNrWSGo= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017061757-aac7a0f6f589/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..29925c2 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -64,9 +64,10 @@ 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 caps each refresh token at the longest grant an agent may + // have, and the agent's own end caps it sooner. A refresh takes the end + // the box sets now, so a grant the owner renews keeps its tokens. + RefreshTTL = time.Duration(protocol.AgentMaxMs) * time.Millisecond ) // Scopes. Each is a right the box grants an agent, by its registry name diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index fb14ce5..18310e8 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -802,6 +802,58 @@ func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { } } +// A grant the owner renews keeps its tokens past its first end, and every +// refresh token ends when the box's newest roster says the grant ends, never +// later: not when it is renewed for a year, not when it is cut to an hour. +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}}) + } + endsWithGrant := func(tok string) { + t.Helper() + row, ok, _ := g.s.st.token(tok) + a, _, _ := g.s.st.agent(home, cloudwire.DeviceID(key)) + if !ok || !row.Expires.Equal(a.Expires) { + t.Fatalf("the refresh token ends %v, the grant %v", row.Expires, a.Expires) + } + } + endsWithGrant(tk.Refresh) + + // On day 29 the owner renews it for a year. + g.advance(29 * 24 * time.Hour) + g.roster(home, agentFor(key, cimdID, g.now().Add(365*24*time.Hour), ScopeLiveRead, ScopePlanRead)) + status, tk2 := refresh(tk.Refresh) + if status != http.StatusOK { + t.Fatalf("refresh after the renewal: %d", status) + } + endsWithGrant(tk2.Refresh) + g.advance(2 * 24 * time.Hour) + status, tk3 := refresh(tk2.Refresh) + if status != http.StatusOK { + t.Fatalf("a renewed grant lost its tokens at its first end: %d", status) + } + if _, aerr := g.bearer(tk3.Access); aerr != nil { + t.Fatalf("the token after the first end: %+v", aerr) + } + + // The owner cuts it to an hour: the next refresh token ends with it. + g.roster(home, agentFor(key, cimdID, g.now().Add(time.Hour), ScopeLiveRead, ScopePlanRead)) + status, tk4 := refresh(tk3.Refresh) + if status != http.StatusOK { + t.Fatalf("refresh after the cut: %d", status) + } + endsWithGrant(tk4.Refresh) + g.advance(time.Hour + time.Second) + if status, _ := refresh(tk4.Refresh); status != http.StatusBadRequest { + t.Fatalf("refresh after the grant ended: %d", status) + } +} + // An agent whose time the box set runs out fails, and is swept. func TestAnExpiredAgentFails(t *testing.T) { g := newRig(t) @@ -1061,8 +1113,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) @@ -1082,7 +1135,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..ab7b247 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -236,11 +236,12 @@ 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, s.now().Add(RefreshTTL)) } // 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. +// The refresh token ends no later than refreshEnd or the agent's end as the +// box's newest roster sets it. 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) { From 49d450e48f65e63578d7abd42c408aa1310980ac Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:36:30 +0200 Subject: [PATCH 2/4] chore: pin go/protocol at srcfl/ftw claude/agent-grant-year 70c7719c Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 172858b..4e2e4ee 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-20261017061757-aac7a0f6f589 + github.com/srcfl/ftw/go/protocol v0.0.0-20261017063535-70c7719c8ea4 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 94c28fa..0c797ec 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +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-20261017061757-aac7a0f6f589 h1:3qRtBjuBHqGlPdz+Jy+HAHdGycFCBzBh/49RuNrWSGo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017061757-aac7a0f6f589/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017063535-70c7719c8ea4 h1:Z9QEupcYR0qLAsgvm7CjER5imDBeGekv0pyw4e0GEZA= +github.com/srcfl/ftw/go/protocol v0.0.0-20261017063535-70c7719c8ea4/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= From 21ac9c1e9fee3714100e75babe277d2f2bc7dc58 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:36:26 +0200 Subject: [PATCH 3/4] fix(oauth): a renewed grant keeps an idle assistant's tokens Review of srcfl/ftw-cloud#27: a refresh row was clamped to the grant's end when issued, and setGrant moves only the agent row. An assistant last used on day 10 whose owner renewed for a year on day 29 got 400 on day 31. Now a refresh token lasts RefreshTTL (a year) from issue; an agent past its end is still refused at every refresh and every call (agentGone) and swept with its tokens. The access token stays within ten minutes and never past the grant's end. The service also takes no roster end past a year from now. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/oauth.go | 25 +++++++++----- internal/oauth/oauth_test.go | 63 +++++++++++++++++++----------------- internal/oauth/token.go | 21 ++++++------ 3 files changed, 62 insertions(+), 47 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 29925c2..1e2c0ff 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -64,10 +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 each refresh token at the longest grant an agent may - // have, and the agent's own end caps it sooner. A refresh takes the end - // the box sets now, so a grant the owner renews keeps its tokens. - RefreshTTL = time.Duration(protocol.AgentMaxMs) * time.Millisecond + // 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 @@ -417,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) } } @@ -443,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 } @@ -481,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 f60724e..bf34797 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -802,9 +802,10 @@ func TestRevokeAtTheBoxEndsTheTokens(t *testing.T) { } } -// A grant the owner renews keeps its tokens past its first end, and every -// refresh token ends when the box's newest roster says the grant ends, never -// later: not when it is renewed for a year, not when it is cut to an hour. +// 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, "") @@ -814,46 +815,50 @@ func TestARefreshTokenFollowsTheGrantTheBoxSets(t *testing.T) { g.t.Helper() return g.token(url.Values{"grant_type": {grantRefresh}, "refresh_token": {tok}, "client_id": {cimdID}}) } - endsWithGrant := func(tok string) { - t.Helper() - row, ok, _ := g.s.st.token(tok) - a, _, _ := g.s.st.agent(home, cloudwire.DeviceID(key)) - if !ok || !row.Expires.Equal(a.Expires) { - t.Fatalf("the refresh token ends %v, the grant %v", row.Expires, a.Expires) - } - } - endsWithGrant(tk.Refresh) - // On day 29 the owner renews it for a year. + // 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)) - status, tk2 := refresh(tk.Refresh) - if status != http.StatusOK { - t.Fatalf("refresh after the renewal: %d", status) - } - endsWithGrant(tk2.Refresh) g.advance(2 * 24 * time.Hour) - status, tk3 := refresh(tk2.Refresh) + status, tk2 := refresh(tk.Refresh) if status != http.StatusOK { - t.Fatalf("a renewed grant lost its tokens at its first end: %d", status) + t.Fatalf("an idle assistant lost a renewed grant at its first end: %d", status) } - if _, aerr := g.bearer(tk3.Access); aerr != nil { + if _, aerr := g.bearer(tk2.Access); aerr != nil { t.Fatalf("the token after the first end: %+v", aerr) } - // The owner cuts it to an hour: the next refresh token ends with it. - g.roster(home, agentFor(key, cimdID, g.now().Add(time.Hour), ScopeLiveRead, ScopePlanRead)) - status, tk4 := refresh(tk3.Refresh) - if status != http.StatusOK { - t.Fatalf("refresh after the cut: %d", status) + // 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") } - endsWithGrant(tk4.Refresh) - g.advance(time.Hour + time.Second) - if status, _ := refresh(tk4.Refresh); status != http.StatusBadRequest { + 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) diff --git a/internal/oauth/token.go b/internal/oauth/token.go index ab7b247..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,20 +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, s.now().Add(RefreshTTL)) + 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's end as the -// box's newest roster sets it. -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} From a86382285e9256d9c304d4b2e469a6a2ee96bdc9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:00:06 +0200 Subject: [PATCH 4/4] chore: pin go/protocol at srcfl/ftw#1686's merge commit 8a7bd9c6e597c4396deb05a5b7cd728f6619f97b on srcfl/ftw master. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 26b361b..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-20261017081917-861fa7089ccf + 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 b694d76..06141cc 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +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-20261017081917-861fa7089ccf h1:vDJbAm6dGfBhIrVMtB+1dLEML/c6AkkdzjpGo/dClwc= -github.com/srcfl/ftw/go/protocol v0.0.0-20261017081917-861fa7089ccf/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=