1 # OAuth 2.0 client credentials, refresh, and authorization code with PKCE over Hairpin. Source: https://github.com/paymog/bend-kit/tree/main/oauth2 2 import Base 3 import bend-kit-crypto@0.2.2.1/crypto.bend as Crypto 4 import bend-kit-hairpin@0.9.0.0/hairpin.bend as Hairpin 5 import bend-kit-http@0.36.0.0/http.bend as Http 6 import bend-kit-time@0.2.0.0/time.bend as Time 7 import bend-kit-url@0.4.1.0/url.bend as Url 8 import bend-kit-json@0.6.1.1/json.bend as Json 9 import bend-kit-encoding@0.4.0.0/encoding.bend as Enc 10 import bend-kit-bytes@0.3.2.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). 15 type 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. 20 type 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. 27 type 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 37 def 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 48 def 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). 56 def b64url(b: Bytes.Bytes) -> String: 57 b64url.go(Bytes.to_base64(b)) 58 59 # ---- forms 60 61 def utf8(+s: String) -> String: 62 Bytes.to_string(Enc.utf8.encode(s)) 63 64 def field(m: Map<&2, String>, k: String, +v: String) -> Map<&2, String>: 65 Map.set(&2, String, m, k, utf8(v)) 66 67 def 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 70 def 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. 74 def 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 77 def 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 81 def 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. 85 def 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 90 type Got is Data: 91 Got{access: String, kind: String, refresh: String, scope: String, expires: String, error: String, desc: String} 92 93 def 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". 101 def 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 110 def 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 117 def 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 124 def 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 131 def 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. 139 def 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. 144 def 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 153 def 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 160 def 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. 165 def 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 168 def 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 176 def 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. 182 def 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>: 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. 193 def 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. 197 def 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 201 def 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. 209 def 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 213 def 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. 221 def expiring(tok: Token, now: Time.Instant, +skew: U32) -> Bool: 222 Token{a, k, r, s, e} = tok 223 expiring.at(e, now, skew) 224 225 def 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. 233 def fresh(h: Hairpin.Client, cfg: Config, +tok: Token, +skew: U32) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>): 234 do IO>: 235 now : Time.Instant <- Time.now() 236 fresh.go(expiring(tok, now, skew), h, cfg, tok) 237 238 def 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). 246 def fresh.cc(h: Hairpin.Client, cfg: Config, +scope: String, +tok: Token, +skew: U32) -> IO(Hairpin.Client & Result<&1, &1, Err, Token>): 247 do IO>: 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 253 def random.of(r: Result<&1, &1, U32 & String, U32 & Array>) -> 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. 261 def random(+n: U32) -> IO(Result<&1, &1, Err, String>): 262 do IO>: 263 r : Result<&1, &1, U32 & String, U32 & Array> <- Crypto.random.words(n) 264 return random.of(r) 265 266 # §4.1: 32 random octets, 43 unreserved chars. 267 def verifier() -> IO(Result<&1, &1, Err, String>): 268 random(32) 269 270 # An unguessable state for the authorization request (RFC 6749 §10.12). 271 def state() -> IO(Result<&1, &1, Err, String>): 272 random(16) 273 274 def challenge.words(b: Bytes.Bytes) -> IO(Result<&1, &1, Err, String>): 275 Bytes.Bytes{+len, buf} = b 276 do IO>: 277 r : Result<&1, &1, U32 & String, U32 & Array> <- Crypto.sha256.words(len, buf) 278 return random.of(r) 279 280 # §4.2 S256: BASE64URL(SHA256(ASCII(verifier))). 281 def challenge(+verifier: String) -> IO(Result<&1, &1, Err, String>): 282 challenge.words(Bytes.from_string(verifier)) 283 284 def 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. 292 def 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 298 def param.of(r: Map<&2, String> & String) -> String: 299 (n, v) = r 300 v 301 302 def param(+m: Map<&2, String>, k: String) -> String: 303 param.of(Map.get(String, "", m, k)) 304 305 def 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 310 def 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 317 def callback.frag(+state: String, r: String & String) -> Result<&1, &1, Err, String>: 318 (q, f) = r 319 callback.m(state, Url.parse("/?" ++ q)) 320 321 def 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. 326 def 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 331 def 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 , over any authorization header in headers. 336 def 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)