AppView in a box as a Vite plugin thing
hatk.dev
1// DPoP key management (IndexedDB) and proof creation
2
3import { randomString, sha256Base64Url, signJwt } from './crypto.js'
4
5const DB_VERSION = 1
6const KEY_STORE = 'dpop-keys'
7const KEY_ID = 'dpop-key'
8
9const dbPromises = new Map()
10
11function openDatabase(namespace) {
12 const existing = dbPromises.get(namespace)
13 if (existing) return existing
14
15 const promise = new Promise((resolve, reject) => {
16 const req = indexedDB.open(`appview-oauth-${namespace}`, DB_VERSION)
17 req.onerror = () => reject(req.error)
18 req.onsuccess = () => resolve(req.result)
19 req.onupgradeneeded = (e) => {
20 const db = e.target.result
21 if (!db.objectStoreNames.contains(KEY_STORE)) {
22 db.createObjectStore(KEY_STORE, { keyPath: 'id' })
23 }
24 }
25 })
26
27 dbPromises.set(namespace, promise)
28 return promise
29}
30
31async function getStoredKey(namespace) {
32 const db = await openDatabase(namespace)
33 return new Promise((resolve, reject) => {
34 const tx = db.transaction(KEY_STORE, 'readonly')
35 const req = tx.objectStore(KEY_STORE).get(KEY_ID)
36 req.onsuccess = () => resolve(req.result || null)
37 req.onerror = () => reject(req.error)
38 })
39}
40
41async function storeKey(namespace, privateKey, publicJwk) {
42 const db = await openDatabase(namespace)
43 return new Promise((resolve, reject) => {
44 const tx = db.transaction(KEY_STORE, 'readwrite')
45 const req = tx.objectStore(KEY_STORE).put({
46 id: KEY_ID,
47 privateKey,
48 publicJwk,
49 createdAt: Date.now(),
50 })
51 req.onsuccess = () => resolve()
52 req.onerror = () => reject(req.error)
53 })
54}
55
56export async function getOrCreateDPoPKey(namespace) {
57 const existing = await getStoredKey(namespace)
58 if (existing) return existing
59
60 const keyPair = await crypto.subtle.generateKey(
61 { name: 'ECDSA', namedCurve: 'P-256' },
62 false, // non-extractable private key
63 ['sign'],
64 )
65
66 const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey)
67 await storeKey(namespace, keyPair.privateKey, publicJwk)
68
69 return { id: KEY_ID, privateKey: keyPair.privateKey, publicJwk, createdAt: Date.now() }
70}
71
72export async function clearDPoPKey(namespace) {
73 const db = await openDatabase(namespace)
74 return new Promise((resolve, reject) => {
75 const tx = db.transaction(KEY_STORE, 'readwrite')
76 const req = tx.objectStore(KEY_STORE).delete(KEY_ID)
77 req.onsuccess = () => resolve()
78 req.onerror = () => reject(req.error)
79 })
80}
81
82export async function createDPoPProof(namespace, method, url, accessToken) {
83 const keyData = await getOrCreateDPoPKey(namespace)
84 const { kty, crv, x, y } = keyData.publicJwk
85
86 const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: { kty, crv, x, y } }
87 const payload = {
88 jti: randomString(16),
89 htm: method,
90 htu: url.split('?')[0],
91 iat: Math.floor(Date.now() / 1000),
92 }
93
94 if (accessToken) {
95 payload.ath = await sha256Base64Url(accessToken)
96 }
97
98 return signJwt(header, payload, keyData.privateKey)
99}