Skip to content

Commit ede9aa6

Browse files
stiakclaude
andcommitted
fix(auth): delete jurisdiction tokens on logout
`git-remote-entire` caches its jurisdiction (data-plane) access token in the credential store at `entire-jurisdiction:<audience>`, keyed by the context handle, so a fresh helper process per git command doesn't re-run the RFC 8693 exchange. Logout deleted only the `entire-core:<core>` access + refresh slots, so that token — a bearer for every repo the account can reach in its jurisdiction, with an 8h server-side TTL — survived `entire logout`, `--everywhere` and `--all-contexts` alike. The credential store has no enumeration API and the audience isn't derivable offline, so track it: `contexts.Context.JurisdictionAudiences` records the audiences a context has a token filed for, and `deleteContextKeychain` walks that list. Deletion order is longest-lived-first (refresh, jurisdiction, access) so a mid-sequence failure leaves behind only the shorter-lived credential, and a failed delete still aborts the logout rather than reporting success over a surviving credential. `jurisdictionTokenSource.persistToken` records the audience *before* writing the token, and skips the write when recording fails: a persisted-but-unrecorded token is invisible to logout, whereas skipping costs one exchange. The recorder being non-nil now also selects the persisted flavour, replacing the `persist` flag, so the two can't disagree — the ENTIRE_TOKEN path passes none and stays in-process-only. `RecordLoginContext` carries the audience list across the re-login upsert, since the tokens are keyed by audience + handle rather than by login session and outlive a fresh login. Deletion is scoped to the outgoing context: only its recorded audiences, only under its own handle, so another account's tokens are untouched. Slots written before this bookkeeping are unreachable and expire with their TTL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01KYNRX5J3PM8D1SD0RQ0ZXCCR
1 parent 8494217 commit ede9aa6

10 files changed

Lines changed: 526 additions & 64 deletions

File tree

cmd/entire/cli/auth/context_store.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package auth
33
import (
44
"errors"
55
"fmt"
6+
"slices"
7+
"strings"
68

79
"github.com/entireio/cli/internal/entireclient/contexts"
810
"github.com/entireio/cli/internal/entireclient/tokenstore"
@@ -34,6 +36,34 @@ func RemoveContext(name string) error {
3436
return nil
3537
}
3638

39+
// RememberJurisdictionAudience adds audience to context `name`'s
40+
// JurisdictionAudiences, so logout can find the matching keyring slot.
41+
// Idempotent: an already-recorded audience rewrites nothing.
42+
//
43+
// Callers MUST record before writing the token to the credential store — a
44+
// persisted-but-unrecorded token is a bearer logout can't find, whereas a
45+
// failed record that aborts the write costs only one token exchange.
46+
func RememberJurisdictionAudience(name, audience string) error {
47+
aud := strings.TrimRight(strings.TrimSpace(audience), "/")
48+
if name == "" || aud == "" {
49+
return errors.New("context name and jurisdiction audience are both required")
50+
}
51+
if err := contexts.Modify(userdirs.Config(), func(f *contexts.File) (bool, error) {
52+
c := f.Find(name)
53+
if c == nil {
54+
return false, fmt.Errorf("no login context named %q", name)
55+
}
56+
if slices.Contains(c.JurisdictionAudiences, aud) {
57+
return false, nil
58+
}
59+
c.JurisdictionAudiences = append(c.JurisdictionAudiences, aud)
60+
return true, nil
61+
}); err != nil {
62+
return fmt.Errorf("record jurisdiction audience %q for context %q: %w", aud, name, err)
63+
}
64+
return nil
65+
}
66+
3767
// removeContextLocked deletes the context selected by pick — keyring slots
3868
// first, then the contexts.json entry — inside a single locked Modify, so
3969
// selection, credential deletion, and entry removal can't interleave with a
@@ -53,28 +83,42 @@ func removeContextLocked(pick func(*contexts.File) *contexts.Context) error {
5383
if c == nil {
5484
return false, nil
5585
}
56-
if err := deleteContextKeychain(c.KeychainService, c.Handle); err != nil {
86+
if err := deleteContextKeychain(c); err != nil {
5787
return false, fmt.Errorf("remove credentials for %q: %w", c.Name, err)
5888
}
5989
f.Delete(c.Name)
6090
return true, nil
6191
})
6292
}
6393

64-
// deleteContextKeychain removes a context's keyring slots. A missing entry
65-
// is fine; any other failure surfaces so logout doesn't claim success over
66-
// surviving credentials. The refresh slot goes first — it's the long-lived
67-
// credential, and if the second delete then fails, the leftover access
68-
// token at least expires on its own.
69-
func deleteContextKeychain(svc, handle string) error {
70-
if svc == "" || handle == "" {
94+
// deleteContextKeychain removes every keyring slot a context owns: the paired
95+
// refresh + access tokens, plus one jurisdiction (data-plane) access token per
96+
// recorded audience — each of those authorizes git against every repo the
97+
// account can reach. A missing entry is fine; any other failure surfaces so
98+
// logout doesn't claim success over surviving credentials.
99+
//
100+
// Deletion runs longest-lived-first — refresh (indefinite), jurisdiction (8h),
101+
// access (an hour at most) — so a mid-sequence failure leaves behind only the
102+
// shorter-lived credential. Unrecorded jurisdiction slots are unreachable (no
103+
// enumeration API) and left to expire.
104+
func deleteContextKeychain(c *contexts.Context) error {
105+
if c == nil || c.Handle == "" {
71106
return nil
72107
}
73-
if err := tokenstore.Delete(tokenstore.RefreshService(svc), handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
74-
return fmt.Errorf("delete refresh token: %w", err)
108+
if c.KeychainService != "" {
109+
if err := tokenstore.Delete(tokenstore.RefreshService(c.KeychainService), c.Handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
110+
return fmt.Errorf("delete refresh token: %w", err)
111+
}
112+
}
113+
for _, audience := range c.JurisdictionAudiences {
114+
if err := tokenstore.Delete(tokenstore.JurisdictionService(audience), c.Handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
115+
return fmt.Errorf("delete jurisdiction token for %s: %w", audience, err)
116+
}
75117
}
76-
if err := tokenstore.Delete(svc, handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
77-
return fmt.Errorf("delete access token: %w", err)
118+
if c.KeychainService != "" {
119+
if err := tokenstore.Delete(c.KeychainService, c.Handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
120+
return fmt.Errorf("delete access token: %w", err)
121+
}
78122
}
79123
return nil
80124
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"path/filepath"
7+
"slices"
8+
"testing"
9+
"time"
10+
11+
"github.com/entireio/cli/internal/entireclient/contexts"
12+
"github.com/entireio/cli/internal/entireclient/tokenstore"
13+
)
14+
15+
// testCoreURL is the login server every context in this file is recorded
16+
// against; seedLoginWithJurisdictionTokens keys its keyring slots off it.
17+
const testCoreURL = "https://core.example.com"
18+
19+
// seedAccountWithJurisdictionTokens records a login context for `handle`
20+
// against testCoreURL, notes `audiences` on it, and files a jurisdiction access
21+
// token in each of those keyring slots under that handle — the state
22+
// git-remote-entire leaves behind after a few git operations. Returns the
23+
// context name and the core keyring service its login tokens live in.
24+
func seedAccountWithJurisdictionTokens(t *testing.T, handle string, audiences ...string) (name, coreService string) {
25+
t.Helper()
26+
27+
exp := time.Now().Add(time.Hour).Unix()
28+
token := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":%q,"exp":%d}`, testCoreURL, handle, exp))
29+
name, err := RecordLoginContext(token, testRefreshToken, true)
30+
if err != nil {
31+
t.Fatalf("RecordLoginContext(%s): %v", handle, err)
32+
}
33+
34+
for _, audience := range audiences {
35+
if err := RememberJurisdictionAudience(name, audience); err != nil {
36+
t.Fatalf("RememberJurisdictionAudience(%q): %v", audience, err)
37+
}
38+
if err := tokenstore.Set(tokenstore.JurisdictionService(audience), handle, "juri-jwt"); err != nil {
39+
t.Fatalf("seed jurisdiction token for %q: %v", audience, err)
40+
}
41+
}
42+
return name, tokenstore.CoreKeyringService(testCoreURL)
43+
}
44+
45+
// seedLoginWithJurisdictionTokens is seedAccountWithJurisdictionTokens for the
46+
// single-account tests, which all use handle "alice".
47+
func seedLoginWithJurisdictionTokens(t *testing.T, audiences ...string) (name, coreService string) {
48+
t.Helper()
49+
return seedAccountWithJurisdictionTokens(t, "alice", audiences...)
50+
}
51+
52+
// TestRemoveContext_DeletesJurisdictionTokens pins the logout contract for
53+
// data-plane credentials: the jurisdiction access tokens git-remote-entire
54+
// filed are bearers for every repo the account can reach, with an 8h
55+
// server-side TTL, so logout must delete them alongside the login slots rather
56+
// than leave them usable on the machine.
57+
func TestRemoveContext_DeletesJurisdictionTokens(t *testing.T) {
58+
cfgDir := t.TempDir()
59+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
60+
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
61+
62+
name, coreService := seedLoginWithJurisdictionTokens(t,
63+
"https://eu.example.io", "https://au.example.io/")
64+
65+
if err := RemoveContext(name); err != nil {
66+
t.Fatalf("RemoveContext: %v", err)
67+
}
68+
69+
for _, audience := range []string{"https://eu.example.io", "https://au.example.io/"} {
70+
svc := tokenstore.JurisdictionService(audience)
71+
if v, err := tokenstore.Get(svc, "alice"); !errors.Is(err, tokenstore.ErrNotFound) {
72+
t.Fatalf("jurisdiction token for %q survived logout: value=%q err=%v", audience, v, err)
73+
}
74+
}
75+
if v, err := tokenstore.Get(coreService, "alice"); !errors.Is(err, tokenstore.ErrNotFound) {
76+
t.Fatalf("access slot survived logout: value=%q err=%v", v, err)
77+
}
78+
if v, err := tokenstore.Get(tokenstore.RefreshService(coreService), "alice"); !errors.Is(err, tokenstore.ErrNotFound) {
79+
t.Fatalf("refresh slot survived logout: value=%q err=%v", v, err)
80+
}
81+
f, err := contexts.Load(cfgDir)
82+
if err != nil {
83+
t.Fatalf("load contexts: %v", err)
84+
}
85+
if f.Find(name) != nil {
86+
t.Fatalf("context %q should have been removed", name)
87+
}
88+
}
89+
90+
// TestRemoveContext_LeavesOtherAccountsJurisdictionTokens pins the scope of the
91+
// sweep: jurisdiction slots are keyed by (audience, handle), and logout only
92+
// deletes the audiences recorded on the context it is removing, under that
93+
// context's own handle. Two accounts sharing a jurisdiction have separate slots,
94+
// so logging one out must leave the other's data-plane token — and its
95+
// bookkeeping — intact.
96+
func TestRemoveContext_LeavesOtherAccountsJurisdictionTokens(t *testing.T) {
97+
cfgDir := t.TempDir()
98+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
99+
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
100+
101+
// Both accounts have a token for the same jurisdiction, plus one audience
102+
// only bob ever reached.
103+
const shared = "https://eu.example.io"
104+
const bobOnly = "https://au.example.io"
105+
aliceName, _ := seedAccountWithJurisdictionTokens(t, "alice", shared)
106+
bobName, _ := seedAccountWithJurisdictionTokens(t, "bob", shared, bobOnly)
107+
108+
if err := RemoveContext(aliceName); err != nil {
109+
t.Fatalf("RemoveContext(%s): %v", aliceName, err)
110+
}
111+
112+
if v, err := tokenstore.Get(tokenstore.JurisdictionService(shared), "alice"); !errors.Is(err, tokenstore.ErrNotFound) {
113+
t.Fatalf("alice's jurisdiction token survived her logout: value=%q err=%v", v, err)
114+
}
115+
for _, audience := range []string{shared, bobOnly} {
116+
if _, err := tokenstore.Get(tokenstore.JurisdictionService(audience), "bob"); err != nil {
117+
t.Fatalf("bob's jurisdiction token for %q was deleted by alice's logout: %v", audience, err)
118+
}
119+
}
120+
f, err := contexts.Load(cfgDir)
121+
if err != nil {
122+
t.Fatalf("load contexts: %v", err)
123+
}
124+
bob := f.Find(bobName)
125+
if bob == nil {
126+
t.Fatalf("context %q was removed by alice's logout", bobName)
127+
}
128+
if !slices.Equal(bob.JurisdictionAudiences, []string{shared, bobOnly}) {
129+
t.Fatalf("bob's recorded audiences = %v, want [%s %s]", bob.JurisdictionAudiences, shared, bobOnly)
130+
}
131+
}
132+
133+
// TestRemoveCurrentContext_DeletesJurisdictionTokens covers the default
134+
// `entire logout` path (active context, not selected by name).
135+
func TestRemoveCurrentContext_DeletesJurisdictionTokens(t *testing.T) {
136+
t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir())
137+
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
138+
139+
const audience = "https://eu.example.io"
140+
seedLoginWithJurisdictionTokens(t, audience)
141+
142+
if err := RemoveCurrentContext(); err != nil {
143+
t.Fatalf("RemoveCurrentContext: %v", err)
144+
}
145+
if v, err := tokenstore.Get(tokenstore.JurisdictionService(audience), "alice"); !errors.Is(err, tokenstore.ErrNotFound) {
146+
t.Fatalf("jurisdiction token survived logout: value=%q err=%v", v, err)
147+
}
148+
}
149+
150+
// TestRemoveContext_JurisdictionDeleteFailureAbortsLogout extends the existing
151+
// keychain-delete contract to the jurisdiction slots: a failed delete must
152+
// surface and leave the context entry in place for a retry, never report
153+
// success over a surviving data-plane bearer.
154+
func TestRemoveContext_JurisdictionDeleteFailureAbortsLogout(t *testing.T) {
155+
cfgDir := t.TempDir()
156+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
157+
path := filepath.Join(t.TempDir(), "tokens.json")
158+
seedRestore := tokenstore.UseFileBackendForTesting(path)
159+
160+
const audience = "https://eu.example.io"
161+
name, coreService := seedLoginWithJurisdictionTokens(t, audience)
162+
seedRestore()
163+
164+
jurisdictionSvc := tokenstore.JurisdictionService(audience)
165+
failJurisdictionDelete := func(service, _ string) bool { return service == jurisdictionSvc }
166+
t.Cleanup(tokenstore.UseFailingDeleteBackendForTesting(path, failJurisdictionDelete))
167+
168+
if err := RemoveContext(name); err == nil {
169+
t.Fatal("RemoveContext: want error when the jurisdiction-slot delete fails")
170+
}
171+
f, err := contexts.Load(cfgDir)
172+
if err != nil {
173+
t.Fatalf("load contexts: %v", err)
174+
}
175+
c := f.Find(name)
176+
if c == nil {
177+
t.Fatal("context entry was removed despite the failed credential delete")
178+
}
179+
if !slices.Contains(c.JurisdictionAudiences, audience) {
180+
t.Fatalf("recorded audiences = %v, want %q retained for the retry", c.JurisdictionAudiences, audience)
181+
}
182+
// The access slot is deleted after the jurisdiction slots, so the abort
183+
// must have left it alone.
184+
if _, err := tokenstore.Get(coreService, "alice"); err != nil {
185+
t.Fatalf("access slot should be untouched by the aborted logout: %v", err)
186+
}
187+
}
188+
189+
func TestRememberJurisdictionAudience(t *testing.T) {
190+
cfgDir := t.TempDir()
191+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
192+
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
193+
194+
exp := time.Now().Add(time.Hour).Unix()
195+
name, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, testCoreURL, exp)), testRefreshToken, true)
196+
if err != nil {
197+
t.Fatalf("RecordLoginContext: %v", err)
198+
}
199+
200+
// Recorded once, trailing slash trimmed so the audience matches the
201+
// keyring service name the writer and logout both derive.
202+
if err := RememberJurisdictionAudience(name, "https://eu.example.io/"); err != nil {
203+
t.Fatalf("first record: %v", err)
204+
}
205+
// Idempotent: the same audience (in either spelling) doesn't duplicate.
206+
if err := RememberJurisdictionAudience(name, "https://eu.example.io"); err != nil {
207+
t.Fatalf("duplicate record: %v", err)
208+
}
209+
if err := RememberJurisdictionAudience(name, "https://au.example.io"); err != nil {
210+
t.Fatalf("second audience: %v", err)
211+
}
212+
213+
f, err := contexts.Load(cfgDir)
214+
if err != nil {
215+
t.Fatalf("load contexts: %v", err)
216+
}
217+
got := f.Find(name).JurisdictionAudiences
218+
want := []string{"https://eu.example.io", "https://au.example.io"}
219+
if !slices.Equal(got, want) {
220+
t.Fatalf("recorded audiences = %v, want %v", got, want)
221+
}
222+
223+
// A context that isn't there can't be recorded against — the caller must
224+
// not then persist a token no logout could find.
225+
if err := RememberJurisdictionAudience("nope", "https://eu.example.io"); err == nil {
226+
t.Fatal("want error for an unknown context")
227+
}
228+
if err := RememberJurisdictionAudience(name, " "); err == nil {
229+
t.Fatal("want error for a blank audience")
230+
}
231+
}
232+
233+
// TestRecordLoginContext_ReloginKeepsJurisdictionAudiences guards the upsert:
234+
// re-logging in replaces the context entry, but the jurisdiction tokens in the
235+
// keychain (keyed by audience + handle, not by login session) survive it — so
236+
// dropping the list would strand them beyond any future logout.
237+
func TestRecordLoginContext_ReloginKeepsJurisdictionAudiences(t *testing.T) {
238+
cfgDir := t.TempDir()
239+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
240+
t.Cleanup(tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")))
241+
242+
const audience = "https://eu.example.io"
243+
name, _ := seedLoginWithJurisdictionTokens(t, audience)
244+
245+
exp := time.Now().Add(2 * time.Hour).Unix()
246+
again, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, testCoreURL, exp)), testRefreshToken, true)
247+
if err != nil {
248+
t.Fatalf("re-login: %v", err)
249+
}
250+
if again != name {
251+
t.Fatalf("re-login produced context %q, want %q", again, name)
252+
}
253+
254+
f, err := contexts.Load(cfgDir)
255+
if err != nil {
256+
t.Fatalf("load contexts: %v", err)
257+
}
258+
if got := f.Find(name).JurisdictionAudiences; !slices.Equal(got, []string{audience}) {
259+
t.Fatalf("recorded audiences after re-login = %v, want [%s]", got, audience)
260+
}
261+
}

cmd/entire/cli/auth/contexts.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,19 @@ func RecordLoginContext(rawToken, refreshToken string, activate bool) (string, e
111111
cfgDir := userdirs.Config()
112112
if modErr := contexts.Modify(cfgDir, func(f *contexts.File) (bool, error) {
113113
name = pickContextName(f, coreURL, handle)
114-
f.Upsert(&contexts.Context{
114+
next := &contexts.Context{
115115
Name: name,
116116
CoreURL: coreURL,
117117
Handle: handle,
118118
KeychainService: keychainService,
119-
})
119+
}
120+
// Upsert replaces the whole entry, so carry the audiences over: the
121+
// jurisdiction tokens are keyed by audience + handle, not by login
122+
// session, so they survive this re-login and must stay findable.
123+
if prev := f.Find(name); prev != nil {
124+
next.JurisdictionAudiences = prev.JurisdictionAudiences
125+
}
126+
f.Upsert(next)
120127
if activate || f.CurrentContext == "" {
121128
f.CurrentContext = name
122129
}

0 commit comments

Comments
 (0)