1const b64urlToBytes = (s) => {
2 const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
3 const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
4 return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
5};
6
7async function verifyEs256(token, spkiDer) { // spkiDer: Uint8Array (SPKI)
8 const [header, payload, signature] = token.split(".");
9
10 const { alg } = JSON.parse(new TextDecoder().decode(b64urlToBytes(header)));
11 if (alg !== "ES256") throw new Error(`expected ES256, got ${alg}`);
12
13 const key = await crypto.subtle.importKey(
14 "spki", spkiDer,
15 { name: "ECDSA", namedCurve: "P-256" },
16 false, ["verify"],
17 );
18
19 const ok = await crypto.subtle.verify(
20 { name: "ECDSA", hash: "SHA-256" },
21 key,
22 b64urlToBytes(signature), // raw r‖s — WebCrypto native
23 new TextEncoder().encode(`${header}.${payload}`),
24 );
25 if (!ok) throw new Error("invalid signature");
26
27 const claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(payload)));
28 if (claims.exp && claims.exp < Date.now() / 1000) throw new Error("token expired");
29 return claims;
30}