nandi/frqpublic Fork 0
41bcb21f91e3c96a7904248af5109c23fc99e1b2
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

frq_dpop.js · 134 lines · 5.5 KBJavaScript Blame HistoryRaw
A third target, and the seam that was already waiting for it f54ca45 nandi 2d ago1// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE.
2//
3// JavaScript rather than ClojureDart, deliberately. What this does is
4// WebCrypto — generateKey, sign, digest, exportKey — and every one of those
5// speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records.
6// Reaching them from cljd means dart:js_util for each value in both
7// directions, and ArrayBuffer-to-bytes is the kind of conversion that fails
8// at run time rather than at the compiler. Here it is the language's home
9// ground, and what crosses the boundary is a string.
10//
11// So the contract is narrow on purpose: every function below takes strings
12// and returns a string or a Promise of one. `frq.dpop.web` is the other half.
13(function () {
14 'use strict';
15
16 const enc = new TextEncoder();
17
18 const b64u = (buf) =>
19 btoa(String.fromCharCode(...new Uint8Array(buf)))
20 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21
22 const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
23 const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
24
25 // The key pair this client proves it holds. One per sign-in, and it must
26 // outlive a full-page redirect — the authorization leg leaves for the PDS
27 // and comes back as a fresh load — so it is kept as JWK in localStorage
28 // rather than as a non-extractable CryptoKey in IndexedDB.
29 //
30 // That is a deliberate trade and worth naming: an extractable key sits
31 // beside the access token it is bound to, in the same store, and anything
32 // that can read one can read the other. They share a lifetime and a blast
33 // radius, so the key being extractable costs nothing the token does not
34 // already cost — and IndexedDB interop through cljd would cost a great deal.
35 const KEY = 'frq:dpop:jwk';
36
37 let cached = null;
38
39 async function keys() {
40 if (cached) return cached;
41 let jwk = null;
42 try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; }
43 if (!jwk) {
44 const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
45 jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
46 try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ }
47 }
48 const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']);
49 // The public half of the same key, which is what a proof carries in its
50 // header. Derived from the private JWK by dropping the private fields
51 // rather than exported separately, so the two cannot drift apart.
52 const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
53 cached = { priv, pub };
54 return cached;
55 }
56
57 async function jws(header, payload, priv) {
58 const h = b64u(enc.encode(JSON.stringify(header)));
59 const p = b64u(enc.encode(JSON.stringify(payload)));
60 // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants —
61 // no DER unwrapping, unlike most non-browser crypto libraries.
62 const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p));
63 return h + '.' + p + '.' + b64u(sig);
64 }
65
66 // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no
67 // convenient undefined, and an empty string is the honest "not this time".
68 //
69 // `ath` is the access token's SHA-256, and it is what lets a proof be
70 // minted for a request this client will never make: freeq's SASL calls the
71 // PDS's getSession on our behalf, with our token and our proof, and the PDS
72 // checks that the proof names that token and that URL.
73 async function proof(htm, htu, nonce, token) {
74 const { priv, pub } = await keys();
75 const payload = {
76 jti: crypto.randomUUID(),
77 htm: htm,
78 htu: htu,
79 iat: Math.floor(Date.now() / 1000),
80 };
81 if (nonce) payload.nonce = nonce;
82 if (token) {
83 payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token)));
84 }
85 return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv);
86 }
87
88 // PKCE. The verifier is kept by the caller (it has to survive the redirect
89 // and `frq.io` already knows how to keep things); this only makes the pair.
90 function verifier() {
91 return b64u(crypto.getRandomValues(new Uint8Array(32)));
92 }
93
94 async function challenge(verifier) {
95 return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier)));
96 }
97
98 function random(n) {
99 return b64u(crypto.getRandomValues(new Uint8Array(n)));
100 }
101
102 // Forget the key. Called when a session is dropped: a DPoP key outliving
103 // the token it was bound to is a key with nothing to prove.
104 function forget() {
105 cached = null;
106 try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ }
107 }
108
109 // Callbacks rather than Promises, and node-style `cb(err, value)`.
110 //
111 // ClojureDart can only reach JavaScript through `dart:js` here: cljd's
112 // analyzer resolves that library and neither `dart:js_util` nor
113 // `dart:js_interop` ("Can't find Dart lib"), so there is no
114 // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does
115 // give is automatic wrapping of a Dart closure passed as an argument — so
116 // the Promise is unwrapped on this side and the answer handed back through
117 // a function call, which crosses the boundary cleanly.
118 const cbify = (fn) => (...args) => {
119 const cb = args.pop();
120 Promise.resolve(fn(...args)).then(
121 (v) => cb('', v),
122 (e) => cb(String(e && e.message ? e.message : e), ''),
123 );
124 };
125
126 window.frqDpop = {
127 proof: cbify(proof),
128 challenge: cbify(challenge),
129 // Synchronous already: no crypto to await, just random bytes.
130 verifier: verifier,
131 random: random,
132 forget: forget,
133 };
134})();