swcreds.bend

cswap-bend-cli@0.1.0.0 · 275 lines · 44 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/swbase.bend as B
4import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
5import cswap-bend-cli@0.1.0.0/store/plan.bend as SPlan
6import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
7
8# Switch, part 2: what a credential is and whose it is (pure).
9
10# What the live credential is, compared with the slot it is filed under.
11
12type CKind is Data:
13  KOwnBytes{}
14  KOwnFamily{}
15  KWiped{}
16  KForeignSynced{slot: String}
17  KForeign{slot: String}
18  KAlien{}
19  KUnresolved{}
20
21# Another slot's stored credential.
22type Other is Data:
23  Other{slot: String, creds: String}
24
25def is_shared_key(+k: String) -> Bool:
26  String.eq(k, "mcpOAuth") || String.eq(k, "mcpOAuthClientConfig") || String.eq(k, "mcpXaaIdp") || String.eq(k, "mcpXaaIdpConfig") || String.eq(k, "pluginSecrets")
27
28def keep_field(+drop: Bool, +k: String, +v: Json.Json, +rest: Json.Json) -> Json.Json:
29  match drop:
30    case True{}:
31      rest
32    case False{}:
33      Json.JField{k, v, rest}
34
35# The fields of an object chain that are (or are not) shared across accounts.
36def pick_fields(+fields: Json.Json, +shared: Bool) -> Json.Json:
37  match fields:
38    case Json.JField{k, v, t}:
39      keep_field(Bool.not(SBase.sel.b(shared, is_shared_key(k), Bool.not(is_shared_key(k)))), k, v, pick_fields(t, shared))
40    case _:
41      Json.JNil{}
42
43def append_fields(+a: Json.Json, +b: Json.Json) -> Json.Json:
44  match a:
45    case Json.JField{k, v, t}:
46      Json.JField{k, v, append_fields(t, b)}
47    case _:
48      b
49
50def has_oauth(+fields: Json.Json) -> Bool:
51  match fields:
52    case Json.JField{k, v, t}:
53      String.eq(k, "claudeAiOauth") || has_oauth(t)
54    case _:
55      False{}
56
57def merge3(+oauth: Bool, +lf: Json.Json, +tf: Json.Json, +target_text: String) -> String:
58  match oauth:
59    case True{}:
60      Json.emit_py(Json.JObj{append_fields(pick_fields(tf, False{}), pick_fields(lf, True{}))})
61    case False{}:
62      target_text
63
64def merge2(+live: Json.Json, +target: Json.Json, +target_text: String) -> String:
65  match live target:
66    case Json.JObj{lf} Json.JObj{tf}:
67      merge3(has_oauth(tf), lf, tf, target_text)
68    case _ _:
69      target_text
70
71def merge1(live: Result<&2, &2, String, Json.Json>, target: Result<&2, &2, String, Json.Json>, +target_text: String) -> String:
72  match live target:
73    case Done{l} Done{t}:
74      merge2(l, t, target_text)
75    case _ _:
76      target_text
77
78# The credential to write for `target` while `live` is replaced: Claude Code's
79# account-independent fields (MCP logins, plugin secrets) ride along from the
80# live one. A live API key or non-JSON leaves the target as is.
81def prepare_activation(+target: String, +live: String) -> String:
82  merge1(Json.parse(live), Json.parse(target), target)
83
84def oauth_of(+creds: Json.Json) -> Json.Json:
85  SSeq.get_or("claudeAiOauth", creds, Json.JNull{})
86
87def refresh_of2(r: Result<&2, &2, String, Json.Json>) -> String:
88  match r:
89    case Done{j}:
90      SSeq.field_str(oauth_of(j), "refreshToken")
91    case Fail{e}:
92      ""
93
94def refresh_of(+text: String) -> String:
95  refresh_of2(Json.parse(text))
96
97def access_of2(r: Result<&2, &2, String, Json.Json>) -> String:
98  match r:
99    case Done{j}:
100      SSeq.field_str(oauth_of(j), "accessToken")
101    case Fail{e}:
102      ""
103
104def access_of(+text: String) -> String:
105  access_of2(Json.parse(text))
106
107def is_object(+j: Json.Json) -> Bool:
108  match j:
109    case Json.JObj{f}:
110      True{}
111    case _:
112      False{}
113
114def is_oauth_obj2(r: Result<&2, &2, String, Json.Json>) -> Bool:
115  match r:
116    case Done{j}:
117      is_object(oauth_of(j))
118    case Fail{e}:
119      False{}
120
121def is_oauth_obj(+text: String) -> Bool:
122  is_oauth_obj2(Json.parse(text))
123
124def same_lineage2(+ra: String, +rb: String, +a: String, +b: String) -> Bool:
125  SBase.sel.b(SBase.nonempty(ra) || SBase.nonempty(rb), String.eq(ra, rb), String.eq(a, b))
126
127# The credential fingerprint rule: the refresh token when there is one, else
128# the whole text. Equal fingerprints are equal tokens, so compare the tokens.
129def same_lineage(+a: String, +b: String) -> Bool:
130  same_lineage2(refresh_of(a), refresh_of(b), a, b)
131
132# Claude Code empties both tokens in place when a refresh is rejected.
133def wiped(+text: String) -> Bool:
134  is_oauth_obj(text) && String.is_empty(access_of(text)) && String.is_empty(refresh_of(text))
135
136def foreign_slot(+live: String, +others: List<&2, Other>) -> Maybe<&2, String>:
137  match others:
138    case Nil{}:
139      None{}
140    case Con{Other{s, c}, t}:
141      SSeq.pick_slot(SBase.nonempty(c) && same_lineage(live, c), s, foreign_slot(live, t))
142
143def classify3(+foreign: Maybe<&2, String>) -> CKind:
144  match foreign:
145    case Some{s}:
146      KForeignSynced{s}
147    case None{}:
148      KUnresolved{}
149
150def classify2b(+is_wiped: Bool, +foreign: Maybe<&2, String>) -> CKind:
151  match is_wiped:
152    case True{}:
153      KWiped{}
154    case False{}:
155      classify3(foreign)
156
157def classify2(+backup_nonempty: Bool, +eq: Bool, +family: Bool, +is_wiped: Bool, +foreign: Maybe<&2, String>) -> CKind:
158  match backup_nonempty eq family:
159    case True{} True{} _:
160      KOwnBytes{}
161    case True{} False{} True{}:
162      KOwnFamily{}
163    case _ _ _:
164      classify2b(is_wiped, foreign)
165
166# `_classify_outgoing_credential` without the profile oracle: byte equality,
167# refresh-token lineage, wiped tokens, another slot already holding this
168# lineage, else unresolved.
169def classify(+live: String, +backup: String, +others: List<&2, Other>) -> CKind:
170  classify2(SBase.nonempty(backup), String.eq(backup, live), same_lineage(backup, live), wiped(live), foreign_slot(live, others))
171
172def kind_warning(+k: CKind, +cur: String) -> List<&2, String>:
173  match k:
174    case KWiped{}:
175      ["The live credential's tokens were wiped (Claude Code clears them when a refresh is rejected). Account-" ++ cur ++ "'s stored backup was kept. If the account cannot authenticate after switching back, log in with Claude Code and run: cswap add"]
176    case KForeignSynced{s}:
177      ["Credential ownership mismatch detected. The live credential already matches Account-" ++ s ++ "'s stored backup, so nothing was written into Account-" ++ cur ++ "."]
178    case KForeign{s}:
179      ["Credential ownership mismatch detected. The live credential was preserved and was not written into Account-" ++ cur ++ ". If Account-" ++ s ++ " later cannot authenticate, log in as it and run: cswap add --slot " ++ s]
180    case KAlien{}:
181      ["The live login does not match a managed account. It was preserved and not written into Account-" ++ cur ++ ". If you need that account, log in as it and run: cswap add"]
182    case _:
183      []
184
185def org_compatible(+r_org: String, +own_org: String) -> Bool:
186  String.is_empty(r_org) || String.is_empty(own_org) || String.eq(r_org, own_org)
187
188def sel_ck(+c: Bool, +x: CKind, +y: CKind) -> CKind:
189  match c:
190    case True{}:
191      x
192    case False{}:
193      y
194
195def foreign_or_synced(+n: String, +live: String, +others: List<&2, Other>) -> CKind:
196  sel_ck(String.eq(SSeq.str_or(foreign_slot(live, others), ""), n), KForeignSynced{n}, KForeign{n})
197
198def stored_uuid(+seq: Json.Json, +slot: String) -> String:
199  SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "uuid")
200
201def resolved_kind4(+is_cur: Bool, +n: String, +seq: Json.Json, +live: String, +others: List<&2, Other>, +uuid: String) -> CKind:
202  match is_cur:
203    case True{}:
204      KOwnFamily{}
205    case False{}:
206      sel_ck(SBase.nonempty(uuid) && String.eq(String.trim(stored_uuid(seq, n)), uuid), foreign_or_synced(n, live, others), KAlien{})
207
208def resolved_kind3(+own: Bool, +slot: Maybe<&2, String>, +seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +uuid: String, +email: String, +has_org: Bool) -> CKind:
209  match own slot:
210    case True{} _:
211      KOwnFamily{}
212    case False{} None{}:
213      sel_ck(SBase.nonempty(email) && has_org, KAlien{}, KUnresolved{})
214    case False{} Some{n}:
215      resolved_kind4(String.eq(n, cur), n, seq, live, others, uuid)
216
217# An email match whose stored uuid disagrees is not a match.
218def drop_mismatch(+m: Maybe<&2, String>, +seq: Json.Json, +uuid: String) -> Maybe<&2, String>:
219  match m:
220    case Some{n}:
221      B.sel_ms(SBase.nonempty(uuid) && SBase.nonempty(stored_uuid(seq, n)) && Bool.not(String.eq(stored_uuid(seq, n), uuid)), None{}, Some{n})
222    case None{}:
223      None{}
224
225# The slot holding (uuid, org), if any.
226def uuid_in(+fields: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>:
227  match fields:
228    case Json.JField{k, v, t}:
229      SSeq.pick_slot(SBase.nonempty(SSeq.field_str(v, "uuid")) && String.eq(SSeq.field_str(v, "uuid"), uuid) && String.eq(SSeq.field_str(v, "organizationUuid"), org), k, uuid_in(t, uuid, org))
230    case _:
231      None{}
232
233def resolved_slot3(+m: Maybe<&2, String>, +seq: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>:
234  match m:
235    case Some{n}:
236      Some{n}
237    case None{}:
238      B.sel_ms(SBase.nonempty(uuid), uuid_in(SSeq.fields_of(seq), uuid, org), None{})
239
240def resolved_slot2(+by_email: Maybe<&2, String>, +seq: Json.Json, +uuid: String, +org: String) -> Maybe<&2, String>:
241  resolved_slot3(drop_mismatch(by_email, seq, uuid), seq, uuid, org)
242
243def resolved_slot(+seq: Json.Json, +email: String, +org: String, +uuid: String) -> Maybe<&2, String>:
244  resolved_slot2(B.sel_ms(SBase.nonempty(email), SSeq.find_slot(seq, email, org), None{}), seq, uuid, org)
245
246def stored_org(+seq: Json.Json, +slot: String) -> String:
247  SSeq.field_str(SSeq.get_or(slot, SSeq.accounts_of(seq), Json.JNull{}), "organizationUuid")
248
249def resolved_kind2(+seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +uuid: String, +email: String, +org: String, +has_org: Bool) -> CKind:
250  resolved_kind3(SBase.nonempty(uuid) && String.eq(uuid, String.trim(stored_uuid(seq, cur))) && org_compatible(org, stored_org(seq, cur)),
251    resolved_slot(seq, email, org, uuid), seq, cur, live, others, uuid, email, has_org)
252
253def resolved_kind(+seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +p: SPlan.Prof) -> CKind:
254  match p:
255    case SPlan.ProfNone{}:
256      KUnresolved{}
257    case SPlan.Prof{uuid, email, org}:
258      resolved_kind2(seq, cur, live, others, String.trim(uuid), SSeq.str_or(email, ""), SSeq.str_or(org, ""), B.is_some_str(org))
259
260def final_kind(+base: CKind, +seq: Json.Json, +cur: String, +live: String, +others: List<&2, Other>, +p: SPlan.Prof) -> CKind:
261  match base:
262    case KUnresolved{}:
263      resolved_kind(seq, cur, live, others, p)
264    case _:
265      base
266
267# Alien and foreign live credentials are kept aside, never written into a slot.
268def preserve(+k: CKind) -> Bool:
269  match k:
270    case KAlien{}:
271      True{}
272    case KForeign{s}:
273      True{}
274    case _:
275      False{}