Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
24 changes: 17 additions & 7 deletions internal/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 61 additions & 3 deletions internal/oauth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion internal/oauth/offer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/oauth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 11 additions & 9 deletions internal/oauth/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
Loading