oauth2.bend
bend-kit-oauth2@0.11.0.1 · 338 lines · 56 definitions
1
2import Base
3import bend-kit-crypto@0.2.2.2/crypto.bend as Crypto
4import bend-kit-hairpin@0.13.0.1/hairpin.bend as Hairpin
5import bend-kit-http@0.38.1.1/http.bend as Http
6import bend-kit-time@0.3.0.0/time.bend as Time
7import bend-kit-url@0.4.1.0/url.bend as Url
8import bend-kit-json@0.7.0.0/json.bend as Json
9import bend-kit-encoding@0.5.0.0/encoding.bend as Enc
10import bend-kit-bytes@0.3.3.0/bytes.bend as Bytes
11
12
13
14
15type Config is Data:
16 Config{token: String, id: String, secret: String}
17
18
19
20type Token is Data:
21 Token{access: String, kind: String, refresh: String, scope: String, expires: Maybe<&2, Time.Instant>}
22
23
24
25
26
27type Err is Data:
28 ErrNet{e: Hairpin.Err}
29 ErrOAuth{status: U32, code: String, why: String}
30 ErrReply{status: U32, body: String}
31 ErrState{}
32 ErrCrypto{code: U32, why: String}
33 ErrExpired{}
34
35
36
37def b64url.ch(+c: U32, t: String) -> String:
38 match c:
39 case 43:
40 SCon{Chr{45}, t}
41 case 47:
42 SCon{Chr{95}, t}
43 case 61:
44 t
45 case _:
46 SCon{Chr{c}, t}
47
48def b64url.go(s: String) -> String:
49 match s:
50 case SNil{}:
51 SNil{}
52 case SCon{Chr{+c}, t}:
53 b64url.ch(c, b64url.go(t))
54
55
56def b64url(b: Bytes.Bytes) -> String:
57 b64url.go(Bytes.to_base64(b))
58
59
60
61def utf8(+s: String) -> String:
62 Bytes.to_string(Enc.utf8.encode(s))
63
64def field(m: Map<&2, String>, k: String, +v: String) -> Map<&2, String>:
65 Map.set(&2, String, m, k, utf8(v))
66
67def scoped(+scope: String, +m: Map<&2, String>) -> Map<&2, String>:
68 Bool.pick(Map<&2, String>, String.is_empty(scope), m, field(m, "scope", scope))
69
70def form(+k: String, +v: String) -> Map<&2, String>:
71 field(Map.new(&2, String), k, v)
72
73
74def basic(+id: String, +secret: String) -> String:
75 "Basic " ++ Bytes.to_base64(Bytes.from_string(Url.pct.encode_q(utf8(id)) ++ ":" ++ Url.pct.encode_q(utf8(secret))))
76
77def auth.headers(+id: String, +secret: String) -> Map<&2, List<&2, String>>:
78 +h = Http.set(Http.set(Http.empty(), "content-type", "application/x-www-form-urlencoded"), "accept", "application/json")
79 Bool.pick(Map<&2, List<&2, String>>, String.is_empty(secret), h, Http.set(h, "authorization", basic(id, secret)))
80
81def auth.body(+id: String, +secret: String, +m: Map<&2, String>) -> Map<&2, String>:
82 Bool.pick(Map<&2, String>, String.is_empty(secret), field(m, "client_id", id), m)
83
84
85def body(+id: String, +secret: String, m: Map<&2, String>) -> String:
86 Url.form(auth.body(id, secret, m))
87
88
89
90type Got is Data:
91 Got{access: String, kind: String, refresh: String, scope: String, expires: String, error: String, desc: String}
92
93def got.put(g: Got, +k: String, +v: String) -> Got:
94 Got{a, t, r, s, e, er, d} = g
95 Got{Bool.pick(String, String.eq(k, "access_token"), v, a), Bool.pick(String, String.eq(k, "token_type"), v, t),
96 Bool.pick(String, String.eq(k, "refresh_token"), v, r), Bool.pick(String, String.eq(k, "scope"), v, s),
97 Bool.pick(String, String.eq(k, "expires_in"), v, e), Bool.pick(String, String.eq(k, "error"), v, er),
98 Bool.pick(String, String.eq(k, "error_description"), v, d)}
99
100
101def got.text(v: Json.Val) -> String:
102 match v:
103 case Json.Str{s}:
104 Enc.utf8.decode(s)
105 case Json.Num{s}:
106 Bytes.to_string(s)
107 case other:
108 ""
109
110def got.go(kvs: List<&1, Bytes.Bytes & Json.Val>, g: Got) -> Got:
111 match kvs:
112 case Nil{}:
113 g
114 case Con{(k, v), t}:
115 got.go(t, got.put(g, Bytes.to_string(k), got.text(v)))
116
117def got.obj(v: Json.Val) -> Maybe<&2, Got>:
118 match v:
119 case Json.Obj{kvs}:
120 Some{got.go(kvs, Got{"", "", "", "", "", "", ""})}
121 case other:
122 None{}
123
124def got(m: Maybe<&1, Json.Val>) -> Maybe<&2, Got>:
125 match m:
126 case None{}:
127 None{}
128 case Some{v}:
129 got.obj(v)
130
131def expiry(now: Time.Instant, m: Maybe<&2, U32>) -> Maybe<&2, Time.Instant>:
132 match m:
133 case None{}:
134 None{}
135 case Some{n}:
136 Some{Time.Instant.add(now, Time.Duration.of_secs(Time.i64(n)))}
137
138
139def good(+status: U32, g: Got) -> Bool:
140 Got{a, t, r, s, e, er, d} = g
141 Bool.and(Bool.and(U32.is_le(200, status), U32.is_lt(status, 300)), Bool.and(Bool.not(String.is_empty(a)), String.eq(String.to_lower(t), "bearer")))
142
143
144def token.pick(ok: Bool, +status: U32, +old: String, now: Time.Instant, b: Bytes.Bytes, g: Got) -> Result<&1, &1, Err, Token>:
145 match ok:
146 case True{}:
147 Got{a, t, +r, s, e, er, d} = g
148 Done{Token{a, t, Bool.pick(String, String.is_empty(r), old, r), s, expiry(now, Json.u32.num(e))}}
149 case False{}:
150 Got{a, t, r, s, e, +er, d} = g
151 Fail{Bool.pick(Err, String.is_empty(er), ErrReply{status, Enc.utf8.decode(b)}, ErrOAuth{status, er, d})}
152
153def token.of(+status: U32, +old: String, now: Time.Instant, b: Bytes.Bytes, m: Maybe<&2, Got>) -> Result<&1, &1, Err, Token>:
154 match m:
155 case None{}:
156 Fail{ErrReply{status, Enc.utf8.decode(b)}}
157 case Some{+g}:
158 token.pick(good(status, g), status, old, now, b, g)
159
160def parse.copy(+status: U32, +old: String, now: Time.Instant, r: Bytes.Bytes & Bytes.Bytes) -> Result<&1, &1, Err, Token>:
161 (b, c) = r
162 token.of(status, old, now, b, got(Json.parse.bytes(c)))
163
164
165def parse(+status: U32, +old: String, now: Time.Instant, b: Bytes.Bytes) -> Result<&1, &1, Err, Token>:
166 parse.copy(status, old, now, Bytes.slice(b, 0, 4294967295))
167
168def reply(+old: String, now: Time.Instant, r: Result<&1, &1, Hairpin.Err, Http.Res>) -> Result<&1, &1, Err, Token>:
169 match r:
170 case Fail{e}:
171 Fail{ErrNet{e}}
172 case Done{res}:
173 Http.Res{+status, h, b} = res
174 parse(status, old, now, b)
175
176def grant.done(+old: String, now: Time.Instant, x: Hairpin.Client & Result<&1, &1, Hairpin.Err, Http.Res>) ->
177 Hairpin.Client & Result<&1, &1, Err, Token>:
178 (h, r) = x
179 (h, reply(old, now, r))
180
181
182def grant(h: Hairpin.Client, cfg: Config, m: Map<&2, String>, +old: String) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
183 Config{url, +id, +secret} = cfg
184 do IO<Hairpin.Client & Result<&1, &1, Err, Token>>:
185 now : Time.Instant <- Time.now()
186 x : Hairpin.Client & Result<&1, &1, Hairpin.Err, Http.Res> <-
187 Hairpin.request(h, "POST", url, auth.headers(id, secret), Http.from_string(body(id, secret, m)))
188 return grant.done(old, now, x)
189
190
191
192
193def client_credentials(h: Hairpin.Client, cfg: Config, +scope: String) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
194 grant(h, cfg, scoped(scope, form("grant_type", "client_credentials")), "")
195
196
197def code(h: Hairpin.Client, cfg: Config, +code: String, +redirect: String, +verifier: String) ->
198 IO(Hairpin.Client & Result<&1, &1, Err, Token>):
199 grant(h, cfg, field(field(field(form("grant_type", "authorization_code"), "code", code), "redirect_uri", redirect), "code_verifier", verifier), "")
200
201def refresh.go(none: Bool, h: Hairpin.Client, cfg: Config, +r: String, +scope: String) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
202 match none:
203 case True{}:
204 IO.pure(Hairpin.Client & Result<&1, &1, Err, Token>, (h, Fail{ErrExpired{}}))
205 case False{}:
206 grant(h, cfg, scoped(scope, field(form("grant_type", "refresh_token"), "refresh_token", r)), r)
207
208
209def refresh(h: Hairpin.Client, cfg: Config, tok: Token, +scope: String) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
210 Token{a, k, +r, s, e} = tok
211 refresh.go(String.is_empty(r), h, cfg, r, scope)
212
213def expiring.at(e: Maybe<&2, Time.Instant>, now: Time.Instant, +skew: U32) -> Bool:
214 match e:
215 case None{}:
216 False{}
217 case Some{at}:
218 Bool.not(Cmp.is_lt(Time.Instant.cmp(Time.Instant.add(now, Time.Duration.of_secs(Time.i64(skew))), at)))
219
220
221def expiring(tok: Token, now: Time.Instant, +skew: U32) -> Bool:
222 Token{a, k, r, s, e} = tok
223 expiring.at(e, now, skew)
224
225def fresh.go(due: Bool, h: Hairpin.Client, cfg: Config, +tok: Token) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
226 match due:
227 case True{}:
228 refresh(h, cfg, tok, "")
229 case False{}:
230 IO.pure(Hairpin.Client & Result<&1, &1, Err, Token>, (h, Done{tok}))
231
232
233def fresh(h: Hairpin.Client, cfg: Config, +tok: Token, +skew: U32) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
234 do IO<Hairpin.Client & Result<&1, &1, Err, Token>>:
235 now : Time.Instant <- Time.now()
236 fresh.go(expiring(tok, now, skew), h, cfg, tok)
237
238def fresh.cc.go(due: Bool, h: Hairpin.Client, cfg: Config, +scope: String, +tok: Token) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
239 match due:
240 case True{}:
241 client_credentials(h, cfg, scope)
242 case False{}:
243 IO.pure(Hairpin.Client & Result<&1, &1, Err, Token>, (h, Done{tok}))
244
245
246def fresh.cc(h: Hairpin.Client, cfg: Config, +scope: String, +tok: Token, +skew: U32) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>):
247 do IO<Hairpin.Client & Result<&1, &1, Err, Token>>:
248 now : Time.Instant <- Time.now()
249 fresh.cc.go(expiring(tok, now, skew), h, cfg, scope, tok)
250
251
252
253def random.of(r: Result<&1, &1, U32 & String, U32 & Array<U32>>) -> Result<&1, &1, Err, String>:
254 match r:
255 case Fail{(c, why)}:
256 Fail{ErrCrypto{c, why}}
257 case Done{(len, buf)}:
258 Done{b64url(Bytes.Bytes{len, buf})}
259
260
261def random(+n: U32) -> IO(Result<&1, &1, Err, String>):
262 do IO<Result<&1, &1, Err, String>>:
263 r : Result<&1, &1, U32 & String, U32 & Array<U32>> <- Crypto.random.words(n)
264 return random.of(r)
265
266
267def verifier() -> IO(Result<&1, &1, Err, String>):
268 random(32)
269
270
271def state() -> IO(Result<&1, &1, Err, String>):
272 random(16)
273
274def challenge.words(b: Bytes.Bytes) -> IO(Result<&1, &1, Err, String>):
275 Bytes.Bytes{+len, buf} = b
276 do IO<Result<&1, &1, Err, String>>:
277 r : Result<&1, &1, U32 & String, U32 & Array<U32>> <- Crypto.sha256.words(len, buf)
278 return random.of(r)
279
280
281def challenge(+verifier: String) -> IO(Result<&1, &1, Err, String>):
282 challenge.words(Bytes.from_string(verifier))
283
284def qmark(s: String) -> Bool:
285 match s:
286 case SNil{}:
287 False{}
288 case SCon{Chr{+c}, t}:
289 Bool.or(U32.is_eq(c, 63), qmark(t))
290
291
292def authorize.url(cfg: Config, +endpoint: String, +redirect: String, +scope: String, +state: String, +challenge: String) -> String:
293 Config{t, +id, s} = cfg
294 +q = field(field(field(field(field(form("response_type", "code"), "client_id", id), "redirect_uri", redirect), "state", state),
295 "code_challenge", challenge), "code_challenge_method", "S256")
296 endpoint ++ Bool.pick(String, qmark(endpoint), "&", "?") ++ Url.form(scoped(scope, q))
297
298def param.of(r: Map<&2, String> & String) -> String:
299 (n, v) = r
300 v
301
302def param(+m: Map<&2, String>, k: String) -> String:
303 param.of(Map.get(String, "", m, k))
304
305def callback.pick(+err: String, desc: String, +got: String, +code: String, +state: String) -> Result<&1, &1, Err, String>:
306 Bool.pick(Result<&1, &1, Err, String>, Bool.not(String.eq(got, state)), Fail{ErrState{}},
307 Bool.pick(Result<&1, &1, Err, String>, Bool.not(String.is_empty(err)), Fail{ErrOAuth{0, err, desc}},
308 Bool.pick(Result<&1, &1, Err, String>, String.is_empty(code), Fail{ErrReply{0, "no code"}}, Done{code})))
309
310def callback.m(+state: String, m: Maybe<&2, Url.Url>) -> Result<&1, &1, Err, String>:
311 match m:
312 case None{}:
313 Fail{ErrReply{0, "bad callback query"}}
314 case Some{Url.Url{p, +q}}:
315 callback.pick(param(q, "error"), param(q, "error_description"), param(q, "state"), param(q, "code"), state)
316
317def callback.frag(+state: String, r: String & String) -> Result<&1, &1, Err, String>:
318 (q, f) = r
319 callback.m(state, Url.parse("/?" ++ q))
320
321def callback.q(+state: String, r: String & String) -> Result<&1, &1, Err, String>:
322 (p, rest) = r
323 callback.frag(state, Url.take_to(rest, 35))
324
325
326def callback(url: String, +state: String) -> Result<&1, &1, Err, String>:
327 callback.q(state, Url.take_to(url, 63))
328
329
330
331def bearer(tok: Token, h: Map<&2, List<&2, String>>) -> Map<&2, List<&2, String>>:
332 Token{a, k, r, s, e} = tok
333 Http.set(h, "authorization", "Bearer " ++ a)
334
335
336def request(c: Hairpin.Client, tok: Token, +method: String, url: String, headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
337 IO(Hairpin.Client & Result<&1, &1, Hairpin.Err, Http.Res>):
338 Hairpin.request(c, method, url, bearer(tok, headers), body)