a tiny oauth browser client for atproto using a service worker
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Clean up vibe code and formatting

+497 -488
+8
.editorconfig
··· 1 + root = true 2 + 3 + [*] 4 + end_of_line = lf 5 + trim_trailing_whitespace = true 6 + insert_final_newline = true 7 + indent_style = tab 8 + max_line_length = 100
+235 -234
atsw.js
··· 57 57 58 58 /** @param {ArrayBuffer | Uint8Array} buf */ 59 59 const b64url = (buf) => 60 - btoa(String.fromCharCode(...new Uint8Array(buf))) 61 - .replace(/\+/g, "-") 62 - .replace(/\//g, "_") 63 - .replace(/=+$/, ""); 60 + btoa(String.fromCharCode(...new Uint8Array(buf))) 61 + .replace(/\+/g, "-") 62 + .replace(/\//g, "_") 63 + .replace(/=+$/, ""); 64 64 65 65 async function generatePKCE() { 66 - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 67 - const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 68 - return { verifier, challenge }; 66 + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 67 + const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 68 + return { verifier, challenge }; 69 69 } 70 70 71 71 async function generateDPoPKey() { 72 - const { privateKey, publicKey } = await crypto.subtle.generateKey( 73 - { name: "ECDSA", namedCurve: "P-256" }, 74 - true, 75 - ["sign"], 76 - ); 77 - const jwk = await crypto.subtle.exportKey("jwk", publicKey); 72 + const { privateKey, publicKey } = await crypto.subtle.generateKey( 73 + { name: "ECDSA", namedCurve: "P-256" }, 74 + true, 75 + ["sign"], 76 + ); 77 + const jwk = await crypto.subtle.exportKey("jwk", publicKey); 78 78 79 - return { privateKey, jwk }; 79 + return { privateKey, jwk }; 80 80 } 81 81 82 82 /** ··· 87 87 * @param {string} [ath] 88 88 */ 89 89 async function createDPoP(dpopKey, htm, htu, nonce, ath) { 90 - const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 90 + const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 91 91 92 - const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 92 + const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 93 93 94 - /** @type {Record<string, string | number>} */ 95 - const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 96 - if (nonce) payload["nonce"] = nonce; 97 - if (ath) payload["ath"] = ath; 94 + /** @type {Record<string, string | number>} */ 95 + const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 96 + if (nonce) payload["nonce"] = nonce; 97 + if (ath) payload["ath"] = ath; 98 98 99 - const toSign = [ 100 - b64url(enc.encode(JSON.stringify(header)).buffer), 101 - b64url(enc.encode(JSON.stringify(payload)).buffer), 102 - ].join("."); 99 + const toSign = [ 100 + b64url(enc.encode(JSON.stringify(header)).buffer), 101 + b64url(enc.encode(JSON.stringify(payload)).buffer), 102 + ].join("."); 103 103 104 - const sig = await crypto.subtle.sign( 105 - { name: "ECDSA", hash: "SHA-256" }, 106 - dpopKey.privateKey, 107 - enc.encode(toSign), 108 - ); 104 + const sig = await crypto.subtle.sign( 105 + { name: "ECDSA", hash: "SHA-256" }, 106 + dpopKey.privateKey, 107 + enc.encode(toSign), 108 + ); 109 109 110 - return toSign + "." + b64url(sig); 110 + return toSign + "." + b64url(sig); 111 111 } 112 112 113 113 /** ··· 118 118 * @returns {Promise<{ json: any, dpopNonce: string | undefined }>} 119 119 */ 120 120 async function dpopPost(dpopKey, url, body, nonce) { 121 - let dpopNonce = nonce; 122 - for (let attempts = 0; attempts < 2; attempts++) { 123 - const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 124 - const res = await fetch(url, { 125 - method: "POST", 126 - headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 127 - body, 128 - }); 129 - dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 130 - if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 131 - } 121 + let dpopNonce = nonce; 122 + for (let attempts = 0; attempts < 2; attempts++) { 123 + const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 124 + const res = await fetch(url, { 125 + method: "POST", 126 + headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 127 + body, 128 + }); 129 + dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 130 + if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 131 + } 132 132 133 - throw new Error("DPoP nonce retry failed"); 133 + throw new Error("DPoP nonce retry failed"); 134 134 } 135 135 136 136 const DB_NAME = "atproto:oauth"; ··· 138 138 139 139 /** @returns {Promise<IDBDatabase>} */ 140 140 function openDb() { 141 - return new Promise((resolve, reject) => { 142 - const req = indexedDB.open(DB_NAME, DB_VERSION); 143 - req.onupgradeneeded = () => { 144 - const db = req.result; 145 - if (!db.objectStoreNames.contains("authing")) 146 - db.createObjectStore("authing", { keyPath: "state" }); 141 + return new Promise((resolve, reject) => { 142 + const req = indexedDB.open(DB_NAME, DB_VERSION); 143 + req.onupgradeneeded = () => { 144 + const db = req.result; 145 + if (!db.objectStoreNames.contains("authing")) 146 + db.createObjectStore("authing", { keyPath: "state" }); 147 147 148 - /** @type {IDBObjectStore} */ 149 - let ssns; 150 - if (!db.objectStoreNames.contains("sessions")) 151 - ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 152 - else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 148 + /** @type {IDBObjectStore} */ 149 + let ssns; 150 + if (!db.objectStoreNames.contains("sessions")) 151 + ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 152 + else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 153 153 154 - if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 155 - }; 156 - req.onsuccess = () => resolve(req.result); 157 - req.onerror = () => reject(req.error); 158 - }); 154 + if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 155 + }; 156 + req.onsuccess = () => resolve(req.result); 157 + req.onerror = () => reject(req.error); 158 + }); 159 159 } 160 160 161 161 /** ··· 165 165 * @returns {Promise<any>} 166 166 */ 167 167 async function idb(mode, store, fn) { 168 - const db = await openDb(); 169 - return new Promise((resolve, reject) => { 170 - const tx = db.transaction(store, mode); 171 - const req = fn(tx.objectStore(store)); 172 - req.onsuccess = () => resolve(req.result); 173 - req.onerror = () => reject(req.error); 174 - }); 168 + const db = await openDb(); 169 + return new Promise((resolve, reject) => { 170 + const tx = db.transaction(store, mode); 171 + const req = fn(tx.objectStore(store)); 172 + req.onsuccess = () => resolve(req.result); 173 + req.onerror = () => reject(req.error); 174 + }); 175 175 } 176 176 177 177 /** @param {AuthingSession} v */ ··· 200 200 201 201 /** @param {string} did */ 202 202 export async function logOut(did) { 203 - const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 204 - if (!pds) return; 203 + const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 204 + if (!pds) return; 205 205 206 - return removeSessionByPDS(pds); 206 + return removeSessionByPDS(pds); 207 207 } 208 208 209 209 /** ··· 211 211 * @returns {Promise<string>} 212 212 */ 213 213 async function resolveHandle(handle) { 214 - try { 215 - const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 216 - const j = await r.json(); 217 - const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 218 - if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 219 - } catch {} 220 - // HTTP .well-known resolution is blocked by CORS from the browser, so fall 221 - // back to the public AppView which exposes a CORS-enabled resolver. 222 - const r = await fetch( 223 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 224 - ); 225 - const j = await r.json(); 226 - if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 227 - return /** @type {string} */ (j.did); 214 + try { 215 + const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 216 + const j = await r.json(); 217 + const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 218 + if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 219 + } catch {} 220 + // HTTP .well-known resolution is blocked by CORS from the browser, so fall 221 + // back to the public AppView which exposes a CORS-enabled resolver. 222 + const r = await fetch( 223 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 224 + ); 225 + const j = await r.json(); 226 + if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 227 + return /** @type {string} */ (j.did); 228 228 } 229 229 230 230 /** @param {string} did */ 231 231 async function resolvePDS(did) { 232 - const url = did.startsWith("did:web:") 233 - ? `https://${did.split(":")[2]}/.well-known/did.json` 234 - : `https://plc.directory/${did}`; 235 - const doc = await (await fetch(url)).json(); 236 - const endpoint = doc.service?.find( 237 - /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 238 - )?.serviceEndpoint; 239 - if (!endpoint) throw new Error(`No PDS found for ${did}`); 240 - return /** @type {string} */ (endpoint); 232 + const url = did.startsWith("did:web:") 233 + ? `https://${did.split(":")[2]}/.well-known/did.json` 234 + : `https://plc.directory/${did}`; 235 + const doc = await (await fetch(url)).json(); 236 + const endpoint = doc.service?.find( 237 + /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 238 + )?.serviceEndpoint; 239 + if (!endpoint) throw new Error(`No PDS found for ${did}`); 240 + return /** @type {string} */ (endpoint); 241 241 } 242 242 243 243 /** ··· 245 245 * @returns {Promise<AuthServerMetadata>} 246 246 */ 247 247 async function discoverAuthServer(pds) { 248 - const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 249 - const issuer = /** @type {string} */ (res.authorization_servers[0]); 250 - return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 248 + const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 249 + const issuer = /** @type {string} */ (res.authorization_servers[0]); 250 + return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 251 251 } 252 252 253 253 /** ··· 256 256 * @returns {Promise<OAuthConfig>} 257 257 */ 258 258 export async function configure(metadataUrl) { 259 - const m = await (await fetch(metadataUrl)).json(); 260 - return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 259 + const m = await (await fetch(metadataUrl)).json(); 260 + return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 261 261 } 262 262 263 263 /** ··· 270 270 * @returns {Promise<void>} 271 271 */ 272 272 export async function logIn(config, handle) { 273 - if (!handle) return; 273 + if (!handle) return; 274 274 275 - const did = await resolveHandle(handle); 276 - const pds = await resolvePDS(did); 277 - const meta = await discoverAuthServer(pds); 275 + const did = await resolveHandle(handle); 276 + const pds = await resolvePDS(did); 277 + const meta = await discoverAuthServer(pds); 278 278 279 - const pkce = await generatePKCE(); 280 - const dpopKey = await generateDPoPKey(); 281 - const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 279 + const pkce = await generatePKCE(); 280 + const dpopKey = await generateDPoPKey(); 281 + const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 282 282 283 - await putAuthing({ 284 - state, 285 - verifier: pkce.verifier, 286 - dpopKey, 287 - tokenEndpoint: meta.token_endpoint, 288 - issuer: meta.issuer, 289 - did, 290 - pds: new URL(pds).origin, 291 - config, 292 - }); 283 + await putAuthing({ 284 + state, 285 + verifier: pkce.verifier, 286 + dpopKey, 287 + tokenEndpoint: meta.token_endpoint, 288 + issuer: meta.issuer, 289 + did, 290 + pds: new URL(pds).origin, 291 + config, 292 + }); 293 293 294 - const parBody = new URLSearchParams({ 295 - client_id: config.clientId, 296 - redirect_uri: config.redirectUri, 297 - response_type: "code", 298 - scope: config.scope, 299 - state, 300 - code_challenge: pkce.challenge, 301 - code_challenge_method: "S256", 302 - login_hint: handle, 303 - }); 294 + const parBody = new URLSearchParams({ 295 + client_id: config.clientId, 296 + redirect_uri: config.redirectUri, 297 + response_type: "code", 298 + scope: config.scope, 299 + state, 300 + code_challenge: pkce.challenge, 301 + code_challenge_method: "S256", 302 + login_hint: handle, 303 + }); 304 304 305 - const { json: parJson } = await dpopPost( 306 - dpopKey, 307 - meta.pushed_authorization_request_endpoint, 308 - parBody, 309 - ); 310 - if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 305 + const { json: parJson } = await dpopPost( 306 + dpopKey, 307 + meta.pushed_authorization_request_endpoint, 308 + parBody, 309 + ); 310 + if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 311 311 312 - const authUrl = new URL(meta.authorization_endpoint); 313 - authUrl.searchParams.set("client_id", config.clientId); 314 - authUrl.searchParams.set("request_uri", parJson.request_uri); 315 - location.href = authUrl.href; 312 + const authUrl = new URL(meta.authorization_endpoint); 313 + authUrl.searchParams.set("client_id", config.clientId); 314 + authUrl.searchParams.set("request_uri", parJson.request_uri); 315 + location.href = authUrl.href; 316 316 } 317 317 318 318 const sw = globalThis; 319 319 if (typeof ServiceWorkerGlobalScope !== "undefined" && sw instanceof ServiceWorkerGlobalScope) { 320 - sw.addEventListener("install", () => sw.skipWaiting()); 321 - sw.addEventListener("activate", (/** @type {any} */ e) => e.waitUntil(sw.clients.claim())); 320 + sw.oninstall = () => sw.skipWaiting(); 321 + sw.onactivate = (e) => e.waitUntil(sw.clients.claim()); 322 + sw.onfetch = async (e) => { 323 + const url = new URL(e.request.url); 324 + const code = url.searchParams.get("code"); 325 + const state = url.searchParams.get("state"); 326 + if (code && state) { 327 + const authing = await getAuthing(state); 328 + if (authing) return e.respondWith(callback(authing, code, state)); 329 + } 322 330 323 - sw.addEventListener("fetch", (/** @type {any} */ event) => { 324 - const url = new URL(event.request.url); 325 - const code = url.searchParams.get("code"); 326 - const state = url.searchParams.get("state"); 327 - if (code && state) { 328 - event.respondWith(maybeCallback(event.request, code, state)); 329 - return; 330 - } 331 - event.respondWith(authedFetch(event.request)); 332 - }); 331 + e.respondWith(authedFetch(e.request)); 332 + }; 333 333 } 334 334 335 335 /** 336 - * @param {Request} req 336 + * @param {AuthingSession} authing 337 337 * @param {string} code 338 338 * @param {string} state 339 339 */ 340 - async function maybeCallback(req, code, state) { 341 - const authing = await getAuthing(state); 342 - // Not our callback — pass through to the default handler 343 - if (!authing) return authedFetch(req); 340 + async function callback(authing, code, state) { 341 + const body = new URLSearchParams({ 342 + grant_type: "authorization_code", 343 + code, 344 + redirect_uri: authing.config.redirectUri, 345 + client_id: authing.config.clientId, 346 + code_verifier: authing.verifier, 347 + }); 344 348 345 - const body = new URLSearchParams({ 346 - grant_type: "authorization_code", 347 - code, 348 - redirect_uri: authing.config.redirectUri, 349 - client_id: authing.config.clientId, 350 - code_verifier: authing.verifier, 351 - }); 352 - 353 - const { json: tokenJson, dpopNonce } = await dpopPost( 354 - authing.dpopKey, 355 - authing.tokenEndpoint, 356 - body, 357 - ); 358 - if (tokenJson.error) { 359 - return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 360 - } 349 + const { json: tokenJson, dpopNonce } = await dpopPost( 350 + authing.dpopKey, 351 + authing.tokenEndpoint, 352 + body, 353 + ); 354 + if (tokenJson.error) { 355 + return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 356 + } 361 357 362 - /** @type {OAuthSession} */ 363 - const session = { 364 - pds: authing.pds, 365 - did: authing.did, 366 - access_token: tokenJson.access_token, 367 - token_type: tokenJson.token_type, 368 - refresh_token: tokenJson.refresh_token, 369 - dpopKey: authing.dpopKey, 370 - dpopNonce, 371 - tokenEndpoint: authing.tokenEndpoint, 372 - clientId: authing.config.clientId, 373 - expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 374 - }; 375 - await putSession(session); 376 - await deleteAuthing(state); 358 + /** @type {OAuthSession} */ 359 + const session = { 360 + pds: authing.pds, 361 + did: authing.did, 362 + access_token: tokenJson.access_token, 363 + token_type: tokenJson.token_type, 364 + refresh_token: tokenJson.refresh_token, 365 + dpopKey: authing.dpopKey, 366 + dpopNonce, 367 + tokenEndpoint: authing.tokenEndpoint, 368 + clientId: authing.config.clientId, 369 + expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 370 + }; 371 + await putSession(session); 372 + await deleteAuthing(state); 377 373 378 - // Strip the query params and send the browser back to the redirect_uri 379 - const dest = new URL(authing.config.redirectUri); 380 - return Response.redirect(dest.href, 302); 374 + // strip the query params and send the browser back to the redirect_uri 375 + const dest = new URL(authing.config.redirectUri); 376 + return Response.redirect(dest.href, 302); 381 377 } 382 378 383 379 /** @param {OAuthSession} session */ 384 380 async function refresh(session) { 385 - if (!session.refresh_token) throw new Error("No refresh_token in session"); 386 - const body = new URLSearchParams({ 387 - grant_type: "refresh_token", 388 - refresh_token: session.refresh_token, 389 - client_id: session.clientId, 390 - }); 391 - const { json: tokenJson, dpopNonce } = await dpopPost( 392 - session.dpopKey, 393 - session.tokenEndpoint, 394 - body, 395 - session.dpopNonce, 396 - ); 397 - if (tokenJson.error) throw new Error("Refresh error: " + JSON.stringify(tokenJson)); 398 - session.access_token = tokenJson.access_token; 399 - session.expiresAt = Date.now() + (tokenJson.expires_in ?? 3600) * 1000; 400 - if (tokenJson.refresh_token) session.refresh_token = tokenJson.refresh_token; 401 - session.dpopNonce = dpopNonce; 381 + if (!session.refresh_token) throw new Error("No refresh_token in session"); 382 + 383 + const body = new URLSearchParams({ 384 + grant_type: "refresh_token", 385 + refresh_token: session.refresh_token, 386 + client_id: session.clientId, 387 + }); 388 + 389 + const { json, dpopNonce } = await dpopPost( 390 + session.dpopKey, 391 + session.tokenEndpoint, 392 + body, 393 + session.dpopNonce, 394 + ); 395 + 396 + if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 397 + session.access_token = json.access_token; 398 + session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 399 + if (json.refresh_token) session.refresh_token = json.refresh_token; 400 + session.dpopNonce = dpopNonce; 402 401 } 403 402 404 403 /** ··· 407 406 * @param {Request} req 408 407 */ 409 408 async function authedFetch(req) { 410 - const url = new URL(req.url); 411 - const maybeSession = await getSessionByPDS(url.origin); 412 - if (!maybeSession) return fetch(req); 413 - const session = maybeSession; 409 + const url = new URL(req.url); 410 + const maybeSession = await getSessionByPDS(url.origin); 411 + if (!maybeSession) return fetch(req); 412 + const session = maybeSession; 413 + 414 + if (session.expiresAt <= Date.now() && session.refresh_token) { 415 + await refresh(session); 416 + await putSession(session); 417 + } 418 + 419 + const htu = url.origin + url.pathname; 420 + const htm = req.method; 421 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 414 422 415 - if (session.expiresAt <= Date.now() && session.refresh_token) { 416 - await refresh(session); 417 - await putSession(session); 418 - } 423 + async function signedFetch() { 424 + const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 425 + const headers = new Headers(req.headers); 426 + headers.set("authorization", `DPoP ${session.access_token}`); 427 + headers.set("dpop", dpop); 428 + return fetch(new Request(req.clone(), { headers })); 429 + } 419 430 420 - const htu = url.origin + url.pathname; 421 - const htm = req.method; 422 - const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 431 + let res = await signedFetch(); 432 + const nonce = res.headers.get("dpop-nonce"); 433 + if (nonce) { 434 + session.dpopNonce = nonce; 435 + await putSession(session); 436 + } 423 437 424 - async function signedFetch() { 425 - const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 426 - const headers = new Headers(req.headers); 427 - headers.set("authorization", `DPoP ${session.access_token}`); 428 - headers.set("dpop", dpop); 429 - return fetch(new Request(req.clone(), { headers })); 430 - } 438 + if (res.status === 401 && session.dpopNonce) { 439 + res = await signedFetch(); 440 + const nonce = res.headers.get("dpop-nonce"); 441 + if (nonce) { 442 + session.dpopNonce = nonce; 443 + await putSession(session); 444 + } 445 + } 431 446 432 - let res = await signedFetch(); 433 - const nonce = res.headers.get("dpop-nonce"); 434 - if (nonce) { 435 - session.dpopNonce = nonce; 436 - await putSession(session); 437 - } 438 - if (res.status === 401 && session.dpopNonce) { 439 - res = await signedFetch(); 440 - const n2 = res.headers.get("dpop-nonce"); 441 - if (n2) { 442 - session.dpopNonce = n2; 443 - await putSession(session); 444 - } 445 - } 446 - return res; 447 + return res; 447 448 }
+244 -244
example/atsw.js
··· 56 56 const enc = new TextEncoder(); 57 57 58 58 /** @param {ArrayBuffer | Uint8Array} buf */ 59 - export const b64url = (buf) => 60 - btoa(String.fromCharCode(...new Uint8Array(buf))) 61 - .replace(/\+/g, "-") 62 - .replace(/\//g, "_") 63 - .replace(/=+$/, ""); 59 + const b64url = (buf) => 60 + btoa(String.fromCharCode(...new Uint8Array(buf))) 61 + .replace(/\+/g, "-") 62 + .replace(/\//g, "_") 63 + .replace(/=+$/, ""); 64 64 65 65 async function generatePKCE() { 66 - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 67 - const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 68 - return { verifier, challenge }; 66 + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 67 + const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 68 + return { verifier, challenge }; 69 69 } 70 70 71 71 async function generateDPoPKey() { 72 - const { privateKey, publicKey } = await crypto.subtle.generateKey( 73 - { name: "ECDSA", namedCurve: "P-256" }, 74 - true, 75 - ["sign"], 76 - ); 77 - const jwk = await crypto.subtle.exportKey("jwk", publicKey); 72 + const { privateKey, publicKey } = await crypto.subtle.generateKey( 73 + { name: "ECDSA", namedCurve: "P-256" }, 74 + true, 75 + ["sign"], 76 + ); 77 + const jwk = await crypto.subtle.exportKey("jwk", publicKey); 78 78 79 - return { privateKey, jwk }; 79 + return { privateKey, jwk }; 80 80 } 81 81 82 82 /** ··· 86 86 * @param {string} [nonce] 87 87 * @param {string} [ath] 88 88 */ 89 - export async function createDPoP(dpopKey, htm, htu, nonce, ath) { 90 - const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 89 + async function createDPoP(dpopKey, htm, htu, nonce, ath) { 90 + const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 91 91 92 - const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 92 + const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 93 93 94 - /** @type {Record<string, string | number>} */ 95 - const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 96 - if (nonce) payload["nonce"] = nonce; 97 - if (ath) payload["ath"] = ath; 94 + /** @type {Record<string, string | number>} */ 95 + const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 96 + if (nonce) payload["nonce"] = nonce; 97 + if (ath) payload["ath"] = ath; 98 98 99 - const toSign = [ 100 - b64url(enc.encode(JSON.stringify(header)).buffer), 101 - b64url(enc.encode(JSON.stringify(payload)).buffer), 102 - ].join("."); 99 + const toSign = [ 100 + b64url(enc.encode(JSON.stringify(header)).buffer), 101 + b64url(enc.encode(JSON.stringify(payload)).buffer), 102 + ].join("."); 103 103 104 - const sig = await crypto.subtle.sign( 105 - { name: "ECDSA", hash: "SHA-256" }, 106 - dpopKey.privateKey, 107 - enc.encode(toSign), 108 - ); 104 + const sig = await crypto.subtle.sign( 105 + { name: "ECDSA", hash: "SHA-256" }, 106 + dpopKey.privateKey, 107 + enc.encode(toSign), 108 + ); 109 109 110 - return toSign + "." + b64url(sig); 110 + return toSign + "." + b64url(sig); 111 111 } 112 112 113 113 /** 114 - * POST with a DPoP proof, retrying once if the server provides a nonce. 115 114 * @param {DPoPKey} dpopKey 116 115 * @param {string} url 117 116 * @param {URLSearchParams} body 118 117 * @param {string} [nonce] 119 118 * @returns {Promise<{ json: any, dpopNonce: string | undefined }>} 120 119 */ 121 - export async function dpopPost(dpopKey, url, body, nonce) { 122 - let dpopNonce = nonce; 123 - for (let attempts = 0; attempts < 2; attempts++) { 124 - const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 125 - const res = await fetch(url, { 126 - method: "POST", 127 - headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 128 - body, 129 - }); 130 - dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 131 - if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 132 - } 120 + async function dpopPost(dpopKey, url, body, nonce) { 121 + let dpopNonce = nonce; 122 + for (let attempts = 0; attempts < 2; attempts++) { 123 + const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 124 + const res = await fetch(url, { 125 + method: "POST", 126 + headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 127 + body, 128 + }); 129 + dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 130 + if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 131 + } 133 132 134 - throw new Error("DPoP nonce retry failed"); 133 + throw new Error("DPoP nonce retry failed"); 135 134 } 136 135 137 136 const DB_NAME = "atproto:oauth"; ··· 139 138 140 139 /** @returns {Promise<IDBDatabase>} */ 141 140 function openDb() { 142 - return new Promise((resolve, reject) => { 143 - const req = indexedDB.open(DB_NAME, DB_VERSION); 144 - req.onupgradeneeded = () => { 145 - const db = req.result; 146 - if (!db.objectStoreNames.contains("authing")) 147 - db.createObjectStore("authing", { keyPath: "state" }); 141 + return new Promise((resolve, reject) => { 142 + const req = indexedDB.open(DB_NAME, DB_VERSION); 143 + req.onupgradeneeded = () => { 144 + const db = req.result; 145 + if (!db.objectStoreNames.contains("authing")) 146 + db.createObjectStore("authing", { keyPath: "state" }); 148 147 149 - /** @type {IDBObjectStore} */ 150 - let ssns; 151 - if (!db.objectStoreNames.contains("sessions")) 152 - ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 153 - else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 148 + /** @type {IDBObjectStore} */ 149 + let ssns; 150 + if (!db.objectStoreNames.contains("sessions")) 151 + ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 152 + else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 154 153 155 - if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 156 - }; 157 - req.onsuccess = () => resolve(req.result); 158 - req.onerror = () => reject(req.error); 159 - }); 154 + if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 155 + }; 156 + req.onsuccess = () => resolve(req.result); 157 + req.onerror = () => reject(req.error); 158 + }); 160 159 } 161 160 162 161 /** ··· 166 165 * @returns {Promise<any>} 167 166 */ 168 167 async function idb(mode, store, fn) { 169 - const db = await openDb(); 170 - return new Promise((resolve, reject) => { 171 - const tx = db.transaction(store, mode); 172 - const req = fn(tx.objectStore(store)); 173 - req.onsuccess = () => resolve(req.result); 174 - req.onerror = () => reject(req.error); 175 - }); 168 + const db = await openDb(); 169 + return new Promise((resolve, reject) => { 170 + const tx = db.transaction(store, mode); 171 + const req = fn(tx.objectStore(store)); 172 + req.onsuccess = () => resolve(req.result); 173 + req.onerror = () => reject(req.error); 174 + }); 176 175 } 177 176 178 177 /** @param {AuthingSession} v */ 179 - export const putAuthing = (v) => idb("readwrite", "authing", (s) => s.put(v)); 178 + const putAuthing = (v) => idb("readwrite", "authing", (s) => s.put(v)); 180 179 181 180 /** @param {string} state @returns {Promise<AuthingSession | undefined>} */ 182 - export const getAuthing = (state) => idb("readonly", "authing", (s) => s.get(state)); 181 + const getAuthing = (state) => idb("readonly", "authing", (s) => s.get(state)); 183 182 184 183 /** @param {string} state */ 185 - export const deleteAuthing = (state) => idb("readwrite", "authing", (s) => s.delete(state)); 184 + const deleteAuthing = (state) => idb("readwrite", "authing", (s) => s.delete(state)); 186 185 187 186 /** @param {OAuthSession} v */ 188 - export const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 187 + const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 189 188 190 189 /** @returns {Promise<OAuthSession[]>} */ 191 190 export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); ··· 201 200 202 201 /** @param {string} did */ 203 202 export async function logOut(did) { 204 - const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 205 - if (!pds) return; 203 + const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 204 + if (!pds) return; 206 205 207 - return removeSessionByPDS(pds); 206 + return removeSessionByPDS(pds); 208 207 } 209 208 210 209 /** ··· 212 211 * @returns {Promise<string>} 213 212 */ 214 213 async function resolveHandle(handle) { 215 - try { 216 - const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 217 - const j = await r.json(); 218 - const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 219 - if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 220 - } catch {} 221 - // HTTP .well-known resolution is blocked by CORS from the browser, so fall 222 - // back to the public AppView which exposes a CORS-enabled resolver. 223 - const r = await fetch( 224 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 225 - ); 226 - const j = await r.json(); 227 - if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 228 - return /** @type {string} */ (j.did); 214 + try { 215 + const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 216 + const j = await r.json(); 217 + const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 218 + if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 219 + } catch {} 220 + // HTTP .well-known resolution is blocked by CORS from the browser, so fall 221 + // back to the public AppView which exposes a CORS-enabled resolver. 222 + const r = await fetch( 223 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 224 + ); 225 + const j = await r.json(); 226 + if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 227 + return /** @type {string} */ (j.did); 229 228 } 230 229 231 230 /** @param {string} did */ 232 - export async function resolvePDS(did) { 233 - const url = did.startsWith("did:web:") 234 - ? `https://${did.split(":")[2]}/.well-known/did.json` 235 - : `https://plc.directory/${did}`; 236 - const doc = await (await fetch(url)).json(); 237 - const endpoint = doc.service?.find( 238 - /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 239 - )?.serviceEndpoint; 240 - if (!endpoint) throw new Error(`No PDS found for ${did}`); 241 - return /** @type {string} */ (endpoint); 231 + async function resolvePDS(did) { 232 + const url = did.startsWith("did:web:") 233 + ? `https://${did.split(":")[2]}/.well-known/did.json` 234 + : `https://plc.directory/${did}`; 235 + const doc = await (await fetch(url)).json(); 236 + const endpoint = doc.service?.find( 237 + /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 238 + )?.serviceEndpoint; 239 + if (!endpoint) throw new Error(`No PDS found for ${did}`); 240 + return /** @type {string} */ (endpoint); 242 241 } 243 242 244 243 /** 245 244 * @param {string} pds 246 245 * @returns {Promise<AuthServerMetadata>} 247 246 */ 248 - export async function discoverAuthServer(pds) { 249 - const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 250 - const issuer = /** @type {string} */ (res.authorization_servers[0]); 251 - return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 247 + async function discoverAuthServer(pds) { 248 + const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 249 + const issuer = /** @type {string} */ (res.authorization_servers[0]); 250 + return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 252 251 } 253 252 254 253 /** ··· 257 256 * @returns {Promise<OAuthConfig>} 258 257 */ 259 258 export async function configure(metadataUrl) { 260 - const m = await (await fetch(metadataUrl)).json(); 261 - return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 259 + const m = await (await fetch(metadataUrl)).json(); 260 + return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 262 261 } 263 262 264 263 /** ··· 271 270 * @returns {Promise<void>} 272 271 */ 273 272 export async function logIn(config, handle) { 274 - if (!handle) return; 273 + if (!handle) return; 275 274 276 - const did = await resolveHandle(handle); 277 - const pds = await resolvePDS(did); 278 - const meta = await discoverAuthServer(pds); 275 + const did = await resolveHandle(handle); 276 + const pds = await resolvePDS(did); 277 + const meta = await discoverAuthServer(pds); 279 278 280 - const pkce = await generatePKCE(); 281 - const dpopKey = await generateDPoPKey(); 282 - const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 279 + const pkce = await generatePKCE(); 280 + const dpopKey = await generateDPoPKey(); 281 + const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 283 282 284 - await putAuthing({ 285 - state, 286 - verifier: pkce.verifier, 287 - dpopKey, 288 - tokenEndpoint: meta.token_endpoint, 289 - issuer: meta.issuer, 290 - did, 291 - pds: new URL(pds).origin, 292 - config, 293 - }); 283 + await putAuthing({ 284 + state, 285 + verifier: pkce.verifier, 286 + dpopKey, 287 + tokenEndpoint: meta.token_endpoint, 288 + issuer: meta.issuer, 289 + did, 290 + pds: new URL(pds).origin, 291 + config, 292 + }); 294 293 295 - const parBody = new URLSearchParams({ 296 - client_id: config.clientId, 297 - redirect_uri: config.redirectUri, 298 - response_type: "code", 299 - scope: config.scope, 300 - state, 301 - code_challenge: pkce.challenge, 302 - code_challenge_method: "S256", 303 - login_hint: handle, 304 - }); 294 + const parBody = new URLSearchParams({ 295 + client_id: config.clientId, 296 + redirect_uri: config.redirectUri, 297 + response_type: "code", 298 + scope: config.scope, 299 + state, 300 + code_challenge: pkce.challenge, 301 + code_challenge_method: "S256", 302 + login_hint: handle, 303 + }); 305 304 306 - const { json: parJson } = await dpopPost( 307 - dpopKey, 308 - meta.pushed_authorization_request_endpoint, 309 - parBody, 310 - ); 311 - if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 305 + const { json: parJson } = await dpopPost( 306 + dpopKey, 307 + meta.pushed_authorization_request_endpoint, 308 + parBody, 309 + ); 310 + if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 312 311 313 - const authUrl = new URL(meta.authorization_endpoint); 314 - authUrl.searchParams.set("client_id", config.clientId); 315 - authUrl.searchParams.set("request_uri", parJson.request_uri); 316 - location.href = authUrl.href; 312 + const authUrl = new URL(meta.authorization_endpoint); 313 + authUrl.searchParams.set("client_id", config.clientId); 314 + authUrl.searchParams.set("request_uri", parJson.request_uri); 315 + location.href = authUrl.href; 317 316 } 318 317 319 318 const sw = globalThis; 320 319 if (typeof ServiceWorkerGlobalScope !== "undefined" && sw instanceof ServiceWorkerGlobalScope) { 321 - sw.addEventListener("install", () => sw.skipWaiting()); 322 - sw.addEventListener("activate", (/** @type {any} */ e) => e.waitUntil(sw.clients.claim())); 320 + sw.oninstall = () => sw.skipWaiting(); 321 + sw.onactivate = (e) => e.waitUntil(sw.clients.claim()); 322 + sw.onfetch = async (e) => { 323 + const url = new URL(e.request.url); 324 + const code = url.searchParams.get("code"); 325 + const state = url.searchParams.get("state"); 326 + if (code && state) { 327 + const authing = await getAuthing(state); 328 + if (authing) return e.respondWith(callback(authing, code, state)); 329 + } 323 330 324 - sw.addEventListener("fetch", (/** @type {any} */ event) => { 325 - const url = new URL(event.request.url); 326 - const code = url.searchParams.get("code"); 327 - const state = url.searchParams.get("state"); 328 - if (code && state) { 329 - event.respondWith(maybeCallback(event.request, code, state)); 330 - return; 331 - } 332 - event.respondWith(authedFetch(event.request)); 333 - }); 331 + e.respondWith(authedFetch(e.request)); 332 + }; 334 333 } 335 334 336 335 /** 337 - * @param {Request} req 336 + * @param {AuthingSession} authing 338 337 * @param {string} code 339 338 * @param {string} state 340 339 */ 341 - async function maybeCallback(req, code, state) { 342 - const authing = await getAuthing(state); 343 - // Not our callback — pass through to the default handler 344 - if (!authing) return authedFetch(req); 340 + async function callback(authing, code, state) { 341 + const body = new URLSearchParams({ 342 + grant_type: "authorization_code", 343 + code, 344 + redirect_uri: authing.config.redirectUri, 345 + client_id: authing.config.clientId, 346 + code_verifier: authing.verifier, 347 + }); 345 348 346 - const body = new URLSearchParams({ 347 - grant_type: "authorization_code", 348 - code, 349 - redirect_uri: authing.config.redirectUri, 350 - client_id: authing.config.clientId, 351 - code_verifier: authing.verifier, 352 - }); 349 + const { json: tokenJson, dpopNonce } = await dpopPost( 350 + authing.dpopKey, 351 + authing.tokenEndpoint, 352 + body, 353 + ); 354 + if (tokenJson.error) { 355 + return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 356 + } 353 357 354 - const { json: tokenJson, dpopNonce } = await dpopPost( 355 - authing.dpopKey, 356 - authing.tokenEndpoint, 357 - body, 358 - ); 359 - if (tokenJson.error) { 360 - return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 361 - } 362 - 363 - /** @type {OAuthSession} */ 364 - const session = { 365 - pds: authing.pds, 366 - did: authing.did, 367 - access_token: tokenJson.access_token, 368 - token_type: tokenJson.token_type, 369 - refresh_token: tokenJson.refresh_token, 370 - dpopKey: authing.dpopKey, 371 - dpopNonce, 372 - tokenEndpoint: authing.tokenEndpoint, 373 - clientId: authing.config.clientId, 374 - expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 375 - }; 376 - await putSession(session); 377 - await deleteAuthing(state); 358 + /** @type {OAuthSession} */ 359 + const session = { 360 + pds: authing.pds, 361 + did: authing.did, 362 + access_token: tokenJson.access_token, 363 + token_type: tokenJson.token_type, 364 + refresh_token: tokenJson.refresh_token, 365 + dpopKey: authing.dpopKey, 366 + dpopNonce, 367 + tokenEndpoint: authing.tokenEndpoint, 368 + clientId: authing.config.clientId, 369 + expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 370 + }; 371 + await putSession(session); 372 + await deleteAuthing(state); 378 373 379 - // Strip the query params and send the browser back to the redirect_uri 380 - const dest = new URL(authing.config.redirectUri); 381 - return Response.redirect(dest.href, 302); 374 + // strip the query params and send the browser back to the redirect_uri 375 + const dest = new URL(authing.config.redirectUri); 376 + return Response.redirect(dest.href, 302); 382 377 } 383 378 384 379 /** @param {OAuthSession} session */ 385 380 async function refresh(session) { 386 - if (!session.refresh_token) throw new Error("No refresh_token in session"); 387 - const body = new URLSearchParams({ 388 - grant_type: "refresh_token", 389 - refresh_token: session.refresh_token, 390 - client_id: session.clientId, 391 - }); 392 - const { json: tokenJson, dpopNonce } = await dpopPost( 393 - session.dpopKey, 394 - session.tokenEndpoint, 395 - body, 396 - session.dpopNonce, 397 - ); 398 - if (tokenJson.error) throw new Error("Refresh error: " + JSON.stringify(tokenJson)); 399 - session.access_token = tokenJson.access_token; 400 - session.expiresAt = Date.now() + (tokenJson.expires_in ?? 3600) * 1000; 401 - if (tokenJson.refresh_token) session.refresh_token = tokenJson.refresh_token; 402 - session.dpopNonce = dpopNonce; 381 + if (!session.refresh_token) throw new Error("No refresh_token in session"); 382 + 383 + const body = new URLSearchParams({ 384 + grant_type: "refresh_token", 385 + refresh_token: session.refresh_token, 386 + client_id: session.clientId, 387 + }); 388 + 389 + const { json, dpopNonce } = await dpopPost( 390 + session.dpopKey, 391 + session.tokenEndpoint, 392 + body, 393 + session.dpopNonce, 394 + ); 395 + 396 + if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 397 + session.access_token = json.access_token; 398 + session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 399 + if (json.refresh_token) session.refresh_token = json.refresh_token; 400 + session.dpopNonce = dpopNonce; 403 401 } 404 402 405 403 /** ··· 408 406 * @param {Request} req 409 407 */ 410 408 async function authedFetch(req) { 411 - const url = new URL(req.url); 412 - const maybeSession = await getSessionByPDS(url.origin); 413 - if (!maybeSession) return fetch(req); 414 - const session = maybeSession; 409 + const url = new URL(req.url); 410 + const maybeSession = await getSessionByPDS(url.origin); 411 + if (!maybeSession) return fetch(req); 412 + const session = maybeSession; 415 413 416 - if (session.expiresAt <= Date.now() && session.refresh_token) { 417 - await refresh(session); 418 - await putSession(session); 419 - } 414 + if (session.expiresAt <= Date.now() && session.refresh_token) { 415 + await refresh(session); 416 + await putSession(session); 417 + } 418 + 419 + const htu = url.origin + url.pathname; 420 + const htm = req.method; 421 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 422 + 423 + async function signedFetch() { 424 + const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 425 + const headers = new Headers(req.headers); 426 + headers.set("authorization", `DPoP ${session.access_token}`); 427 + headers.set("dpop", dpop); 428 + return fetch(new Request(req.clone(), { headers })); 429 + } 420 430 421 - const htu = url.origin + url.pathname; 422 - const htm = req.method; 423 - const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 431 + let res = await signedFetch(); 432 + const nonce = res.headers.get("dpop-nonce"); 433 + if (nonce) { 434 + session.dpopNonce = nonce; 435 + await putSession(session); 436 + } 424 437 425 - async function signedFetch() { 426 - const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 427 - const headers = new Headers(req.headers); 428 - headers.set("authorization", `DPoP ${session.access_token}`); 429 - headers.set("dpop", dpop); 430 - return fetch(new Request(req.clone(), { headers })); 431 - } 438 + if (res.status === 401 && session.dpopNonce) { 439 + res = await signedFetch(); 440 + const nonce = res.headers.get("dpop-nonce"); 441 + if (nonce) { 442 + session.dpopNonce = nonce; 443 + await putSession(session); 444 + } 445 + } 432 446 433 - let res = await signedFetch(); 434 - const nonce = res.headers.get("dpop-nonce"); 435 - if (nonce) { 436 - session.dpopNonce = nonce; 437 - await putSession(session); 438 - } 439 - if (res.status === 401 && session.dpopNonce) { 440 - res = await signedFetch(); 441 - const n2 = res.headers.get("dpop-nonce"); 442 - if (n2) { 443 - session.dpopNonce = n2; 444 - await putSession(session); 445 - } 446 - } 447 - return res; 447 + return res; 448 448 }
+10 -10
example/client-metadata.json
··· 1 1 { 2 - "client_id": "https://jake.tngl.io/atsw/client-metadata.json", 3 - "client_uri": "https://jake.tngl.io/atsw/", 4 - "redirect_uris": ["https://jake.tngl.io/atsw/"], 5 - "application_type": "native", 6 - "client_name": "atsw", 7 - "dpop_bound_access_tokens": true, 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "scope": "atproto repo?collection=app.bsky.feed.post", 11 - "token_endpoint_auth_method": "none" 2 + "client_id": "https://41c6-66-108-106-210.ngrok-free.app/client-metadata.json", 3 + "client_uri": "https://41c6-66-108-106-210.ngrok-free.app/", 4 + "redirect_uris": ["https://41c6-66-108-106-210.ngrok-free.app/"], 5 + "application_type": "native", 6 + "client_name": "atsw", 7 + "dpop_bound_access_tokens": true, 8 + "grant_types": ["authorization_code", "refresh_token"], 9 + "response_types": ["code"], 10 + "scope": "atproto repo?collection=app.bsky.feed.post", 11 + "token_endpoint_auth_method": "none" 12 12 }