@@ -3,6 +3,8 @@ package auth
33import (
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