usage/cred.bend

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

1import Base
2import cswap-bend-cli@0.1.0.0/http.bend as Http
3import cswap-bend-cli@0.1.0.0/json.bend as Json
4import cswap-bend-cli@0.1.0.0/clock.bend as Clock
5import cswap-bend-cli@0.1.0.0/usage/text.bend as UText
6import cswap-bend-cli@0.1.0.0/usage/norm.bend as UNorm
7import cswap-bend-cli@0.1.0.0/usage/req.bend as UReq
8
9# ---- credential JSON -------------------------------------------------------
10
11# A rotated credential and who the token endpoint said it belongs to
12# (oauth._parse_token_account: {uuid, email, organizationUuid}, or JNull).
13
14type Granted is Data:
15  Granted{cred: String, account: Json.Json}
16
17def parse.get(+k: String, r: Result<&2, &2, String, Json.Json>) -> Maybe<&2, Json.Json>:
18  match r:
19    case Fail{e}:
20      None{}
21    case Done{j}:
22      Json.get(k, j)
23
24def oauth.obj(+m: Maybe<&2, Json.Json>) -> Json.Json:
25  match m:
26    case Some{Json.JObj{f}}:
27      Json.JObj{f}
28    case _:
29      Json.JNull{}
30
31# The claudeAiOauth object of a credential text; JNull when absent or not an
32# object (extract_oauth_data).
33def oauth.of(+cred: String) -> Json.Json:
34  oauth.obj(parse.get("claudeAiOauth", Json.parse(cred)))
35
36def str.at(+k: String, +o: Json.Json) -> Maybe<&2, String>:
37  UNorm.nonempty(Json.as_string(Maybe.default(&2, Json.Json, Json.get(k, o), Json.JNull{})))
38
39def access_token(+o: Json.Json) -> Maybe<&2, String>:
40  str.at("accessToken", o)
41
42def refresh_token(+o: Json.Json) -> Maybe<&2, String>:
43  str.at("refreshToken", o)
44
45def has_refresh(+o: Json.Json) -> Bool:
46  Maybe.is_some(&2, String, refresh_token(o))
47
48def has_profile(+xs: List<&2, Json.Json>) -> Bool:
49  match xs:
50    case Nil{}:
51      False{}
52    case Con{Json.JStr{s}, t}:
53      String.eq(s, "user:profile") || has_profile(t)
54    case Con{h, t}:
55      has_profile(t)
56
57def scopes.check(+xs: List<&2, Json.Json>) -> Bool:
58  match xs:
59    case Nil{}:
60      False{}
61    case Con{h, t}:
62      Bool.not(has_profile(h <> t))
63
64# `scopes` present and non-empty without user:profile: a setup-token.
65def inference_only(+o: Json.Json) -> Bool:
66  scopes.check(Json.to_list(Maybe.default(&2, Json.Json, Json.get("scopes", o), Json.JNull{})))
67
68def exp.secs(+d: String) -> Maybe<&2, U32>:
69  U32.read(String.take(d, Nat.sub(String.length(d), 3n)))
70
71def exp.check(+ok: Bool, +d: String, +now: U32) -> Bool:
72  match ok:
73    case False{}:
74      False{}
75    case True{}:
76      U32.is_ge((now + 300 : U32), Maybe.default(&2, U32, exp.secs(d), 0))
77
78def exp.int(+ps: List<&2, String>, +now: U32) -> Bool:
79  match ps:
80    case Con{+i, t}:
81      exp.check(Clock.all_digits(i) && Bool.not(String.is_empty(i)) && Nat.is_le(String.length(i), 13n), i, now)
82    case Nil{}:
83      False{}
84
85def exp.num(+j: Json.Json, +now: U32) -> Bool:
86  match j:
87    case Json.JNum{t}:
88      exp.int(String.split(t, '.'), now)
89    case _:
90      False{}
91
92# is_oauth_token_expired: now + 5 min >= expiresAt (ms). Missing or
93# non-numeric is not expired.
94def expired(+o: Json.Json, +now: U32) -> Bool:
95  exp.num(Maybe.default(&2, Json.Json, Json.get("expiresAt", o), Json.JNull{}), now)
96
97# ---- token refresh ---------------------------------------------------------
98
99def set.rt(+m: Maybe<&2, String>, +o: Json.Json) -> Json.Json:
100  match m:
101    case None{}:
102      o
103    case Some{r}:
104      Json.set("refreshToken", Json.JStr{r}, o)
105
106def scopes.cons(+skip: Bool, +h: String, +rest: List<&2, Json.Json>) -> List<&2, Json.Json>:
107  match skip:
108    case True{}:
109      rest
110    case False{}:
111      Json.JStr{h} <> rest
112
113def scopes.list(+ps: List<&2, String>) -> List<&2, Json.Json>:
114  match ps:
115    case Nil{}:
116      []
117    case Con{+h, t}:
118      scopes.cons(String.is_empty(h), h, scopes.list(t))
119
120def set.sc(+m: Maybe<&2, String>, +o: Json.Json) -> Json.Json:
121  match m:
122    case None{}:
123      o
124    case Some{s}:
125      Json.set("scopes", Json.arr(scopes.list(String.split(s, ' '))), o)
126
127# The credential's oauth object after a grant: accessToken, expiresAt (ms),
128# then refreshToken and scopes when the response carries them.
129def refresh.fields(+o: Json.Json, +at: String, +ex: U32, +rt: Maybe<&2, String>, +sc: Maybe<&2, String>, +now: U32) -> Json.Json:
130  set.sc(sc, set.rt(rt, Json.set("expiresAt", Json.JNum{U32.show((now + ex : U32)) ++ "000"}, Json.set("accessToken", Json.JStr{at}, o))))
131
132def token.org(+o: Maybe<&2, Json.Json>) -> Json.Json:
133  match o:
134    case Some{org}:
135      Maybe.default(&2, Json.Json, Json.get("uuid", org), Json.JNull{})
136    case None{}:
137      Json.JNull{}
138
139def token.acct3(+blank: Bool, +u: String, +a: Json.Json, +resp: Json.Json) -> Json.Json:
140  match blank:
141    case True{}:
142      Json.JNull{}
143    case False{}:
144      Json.JObj{Json.JField{"uuid", Json.JStr{String.trim(u)}, Json.JField{"email", Maybe.default(&2, Json.Json, Json.get("email_address", a), Json.JNull{}), Json.JField{"organizationUuid", token.org(Json.get("organization", resp)), Json.JNil{}}}}}
145
146def token.acct2(+uuid: Maybe<&2, String>, +a: Json.Json, +resp: Json.Json) -> Json.Json:
147  match uuid:
148    case None{}:
149      Json.JNull{}
150    case Some{u}:
151      token.acct3(String.is_empty(String.trim(u)), u, a, resp)
152
153def token.acct1(+a: Maybe<&2, Json.Json>, +resp: Json.Json) -> Json.Json:
154  match a:
155    case None{}:
156      Json.JNull{}
157    case Some{acct}:
158      token.acct2(str.at("uuid", acct), acct, resp)
159
160def token.account(+resp: Json.Json) -> Json.Json:
161  token.acct1(Json.get("account", resp), resp)
162
163def refresh.build(+j: Json.Json, +o: Json.Json, +resp: Json.Json, +now: U32) -> Maybe<&2, String>:
164  do Maybe<&2, String>:
165    a : Json.Json <- Json.get("access_token", resp)
166    at : String <- Json.as_string(a)
167    e : Json.Json <- Json.get("expires_in", resp)
168    ex : U32 <- Json.as_u32(e)
169    return Json.emit_py(Json.set("claudeAiOauth", refresh.fields(o, at, ex, str.at("refresh_token", resp), str.at("scope", resp), now), j))
170
171def grant.ok(+m: Maybe<&2, String>, +acct: Json.Json) -> Result<&2, &2, String, Granted>:
172  match m:
173    case Some{c}:
174      Done{Granted{c, acct}}
175    case None{}:
176      Fail{"transient"}
177
178def grant.parse(r: Result<&2, &2, String, Json.Json>, +j: Json.Json, +o: Json.Json, +now: U32) -> Result<&2, &2, String, Granted>:
179  match r:
180    case Fail{e}:
181      Fail{"transient"}
182    case Done{+resp}:
183      grant.ok(refresh.build(j, o, resp, now), token.account(resp))
184
185# The verdict is the body's top-level `error`, only on 400/401/403: a dead
186# lineage (invalid_grant) or a rejected client (invalid_client); everything
187# else is transient.
188def grant.err.kind(+hit: Bool, +s: String) -> Result<&2, &2, String, Granted>:
189  match hit:
190    case True{}:
191      Fail{s}
192    case False{}:
193      Fail{"transient"}
194
195def grant.err.pick(+e: Maybe<&2, String>) -> Result<&2, &2, String, Granted>:
196  match e:
197    case Some{+s}:
198      grant.err.kind(String.eq(s, "invalid_grant") || String.eq(s, "invalid_client"), s)
199    case None{}:
200      Fail{"transient"}
201
202def grant.err.body(r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Granted>:
203  match r:
204    case Fail{m}:
205      Fail{"transient"}
206    case Done{b}:
207      grant.err.pick(Json.as_string(Maybe.default(&2, Json.Json, Json.get("error", b), Json.JNull{})))
208
209def rejects(+st: U32) -> Bool:
210  U32.is_eq(st, 400) || U32.is_eq(st, 401) || U32.is_eq(st, 403)
211
212def grant.err(+rejects: Bool, +body: String) -> Result<&2, &2, String, Granted>:
213  match rejects:
214    case True{}:
215      grant.err.body(Json.parse(body))
216    case False{}:
217      Fail{"transient"}
218
219def grant.reply.pick(+ok: Bool, +rj: Bool, +body: String, +j: Json.Json, +o: Json.Json, +now: U32) -> Result<&2, &2, String, Granted>:
220  match ok:
221    case True{}:
222      grant.parse(Json.parse(body), j, o, now)
223    case False{}:
224      grant.err(rj, body)
225
226def grant.reply(+rep: Http.Reply, +j: Json.Json, +o: Json.Json, +now: U32) -> Result<&2, &2, String, Granted>:
227  match rep:
228    case Http.Reply{st, head, body}:
229      grant.reply.pick(UReq.ok2xx(st), rejects(st), body, j, o, now)
230
231def grant.result(r: Result<&2, &2, String, Http.Reply>, +j: Json.Json, +o: Json.Json, +now: U32) -> Result<&2, &2, String, Granted>:
232  match r:
233    case Fail{k}:
234      Fail{"transient"}
235    case Done{rep}:
236      grant.reply(rep, j, o, now)
237
238# refresh: the token endpoint POST of try_refresh_oauth_credentials. Done
239# carries the rotated credential text; Fail is `invalid_grant`,
240# `invalid_client`, `no_refresh_token` or `transient`.
241def refresh.post(+j: Json.Json, +o: Json.Json, +rt: String) -> IO(Result<&2, &2, String, Granted>):
242  do IO<Result<&2, &2, String, Granted>>:
243    now : U32 <- Clock.now_s()
244    r : Result<&2, &2, String, Http.Reply> <- Http.request("POST", UText.token_url(),
245      [("Content-Type", "application/json"), ("User-Agent", UText.user_agent())], UText.refresh.body(rt), 10)
246    return grant.result(r, j, o, now)
247
248def refresh.rt(+j: Json.Json, +o: Json.Json, +m: Maybe<&2, String>) -> IO(Result<&2, &2, String, Granted>):
249  match m:
250    case None{}:
251      IO.pure(Result<&2, &2, String, Granted>, Fail{"no_refresh_token"})
252    case Some{rt}:
253      refresh.post(j, o, rt)
254
255def refresh.top(+j: Json.Json, +o: Json.Json) -> IO(Result<&2, &2, String, Granted>):
256  match j:
257    case Json.JObj{f}:
258      refresh.rt(j, o, refresh_token(o))
259    case _:
260      IO.pure(Result<&2, &2, String, Granted>, Fail{"transient"})
261
262def refresh.parsed(r: Result<&2, &2, String, Json.Json>, +o: Json.Json) -> IO(Result<&2, &2, String, Granted>):
263  match r:
264    case Fail{e}:
265      IO.pure(Result<&2, &2, String, Granted>, Fail{"transient"})
266    case Done{j}:
267      refresh.top(j, o)
268
269# `refresh` with the account identity the token endpoint optionally names.
270def refresh_acct(+cred: String) -> IO(Result<&2, &2, String, Granted>):
271  refresh.parsed(Json.parse(cred), oauth.of(cred))
272
273def granted.cred(r: Result<&2, &2, String, Granted>) -> Result<&2, &2, String, String>:
274  match r:
275    case Fail{k}:
276      Fail{k}
277    case Done{Granted{c, a}}:
278      Done{c}
279
280def refresh(+cred: String) -> IO(Result<&2, &2, String, String>):
281  IO.bind(Result<&2, &2, String, Granted>, Result<&2, &2, String, String>, refresh_acct(cred), r => IO.pure(Result<&2, &2, String, String>, granted.cred(r)))