Skip to content

Commit 683a10d

Browse files
authored
Merge pull request #1869 from entireio/delete-tokens-on-logout
fix(auth): delete jurisdiction tokens on logout
2 parents 8494217 + 67bfdc0 commit 683a10d

10 files changed

Lines changed: 570 additions & 64 deletions

File tree

cmd/entire/cli/auth/context_store.go

Lines changed: 62 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,48 @@ 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+
}
75112
}
76-
if err := tokenstore.Delete(svc, handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
77-
return fmt.Errorf("delete access token: %w", err)
113+
for _, audience := range c.JurisdictionAudiences {
114+
// A blank entry can only come from a hand-edited or corrupted
115+
// contexts.json, and would resolve to the bare service prefix — no
116+
// token lives there, so skip rather than round-trip the keyring.
117+
if strings.TrimSpace(audience) == "" {
118+
continue
119+
}
120+
if err := tokenstore.Delete(tokenstore.JurisdictionService(audience), c.Handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
121+
return fmt.Errorf("delete jurisdiction token for %s: %w", audience, err)
122+
}
123+
}
124+
if c.KeychainService != "" {
125+
if err := tokenstore.Delete(c.KeychainService, c.Handle); err != nil && !errors.Is(err, tokenstore.ErrNotFound) {
126+
return fmt.Errorf("delete access token: %w", err)
127+
}
78128
}
79129
return nil
80130
}

0 commit comments

Comments
 (0)