oauth2.bend

bend-kit-oauth2@0.11.0.1 · 338 lines · 56 definitions

1# OAuth 2.0 client credentials, refresh, and authorization code with PKCE over Hairpin. Source: https://github.com/paymog/bend-kit/tree/main/oauth2
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# RFC 6749 and RFC 7636. Text is a String of code points; form values go out as UTF-8.
13# token: the token endpoint URL. secret: "" for a public client, which sends client_id in the body;
14# a confidential client authenticates with HTTP Basic (RFC 6749 §2.3.1).
15type Config is Data:
16  Config{token: String, id: String, secret: String}
17
18# kind: token_type, as "Bearer". refresh: "" for none. scope: "" when the server did not say.
19# expires: when the access token lapses, counted from just before the request; None without expires_in.
20type Token is Data:
21  Token{access: String, kind: String, refresh: String, scope: String, expires: Maybe<&2, Time.Instant>}
22
23# ErrNet: the request did not complete. ErrOAuth: an RFC 6749 §5.2 error with its status, or a §4.1.2.1
24# error in a callback, status 0. ErrReply: not a Bearer token response, with its status and text. ErrState: the
25# callback's state is not ours (§10.12). ErrCrypto: the random source or SHA-256 failed. ErrExpired: the
26# token lapsed and has no refresh token.
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# ---- base64url
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# Base64url without padding (RFC 4648 §5, RFC 7636 Appendix A).
56def b64url(b: Bytes.Bytes) -> String:
57  b64url.go(Bytes.to_base64(b))
58
59# ---- forms
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# RFC 6749 §2.3.1: id and secret are form-encoded before base64.
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# The form body of a token request from cfg's client.
85def body(+id: String, +secret: String, m: Map<&2, String>) -> String:
86  Url.form(auth.body(id, secret, m))
87
88# ---- token responses (RFC 6749 §5)
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# A string's text, or a number's, as some servers send expires_in as "3600".
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# A 2xx with an access token of type Bearer (RFC 6750 §4, case-insensitive), the only kind request sends.
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# §6: a refresh reply without refresh_token keeps the old one. b: the body, decoded only for an error.
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# A token endpoint's reply at status, requested at now. old: the refresh token to keep when none comes back.
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# POST m to the token endpoint. Hairpin sends a POST once.
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# ---- grants
191
192# RFC 6749 §4.4. scope: "" for the server's default.
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# RFC 6749 §4.1.3 with RFC 7636 §4.5: the code from callback, the same redirect_uri, and the verifier.
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# RFC 6749 §6. scope: "" keeps the granted scope. ErrExpired when tok has no refresh token.
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# Does tok lapse within skew seconds of now? A token without expires_in never does.
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# tok, or a refreshed one when it lapses within skew seconds.
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# tok, or a new client-credentials token when it lapses within skew seconds (§4.4.3: no refresh token).
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# ---- authorization code with PKCE (RFC 7636)
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# n octets from the OS secure random source, as base64url.
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# §4.1: 32 random octets, 43 unreserved chars.
267def verifier() -> IO(Result<&1, &1, Err, String>):
268  random(32)
269
270# An unguessable state for the authorization request (RFC 6749 §10.12).
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# §4.2 S256: BASE64URL(SHA256(ASCII(verifier))).
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# RFC 6749 §4.1.1 with RFC 7636 §4.3: the URL to send the user to. endpoint may carry its own query.
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# §4.1.2: the code in the redirect to redirect_uri, once its state matches ours; the server's error if it sent one.
326def callback(url: String, +state: String) -> Result<&1, &1, Err, String>:
327  callback.q(state, Url.take_to(url, 63))
328
329# ---- bearer requests (RFC 6750 §2.1)
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# Hairpin.request with Authorization: Bearer <access>, over any authorization header in headers.
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)