a tiny oauth browser client for atproto using a service worker
0

Configure Feed

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

Add tests

+451 -1
+2 -1
jsconfig.json
··· 13 13 "strict": true, 14 14 "isolatedModules": true, 15 15 "noUncheckedSideEffectImports": true 16 - } 16 + }, 17 + "exclude": ["test"] 17 18 }
+279
test/atsw.test.js
··· 1 + import { test, beforeEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + 4 + import { fakeIndexedDB, resetFakeIDB, getAllRecords } from "./fake-idb.js"; 5 + 6 + // set up the browser-ish globals atsw.js expects, before importing it 7 + globalThis.indexedDB = fakeIndexedDB; 8 + globalThis.location = { href: "https://app.example/" }; 9 + Object.defineProperty(globalThis, "navigator", { 10 + value: { serviceWorker: { getRegistration: async () => ({ scope: "https://app.example/" }) } }, 11 + configurable: true, 12 + }); 13 + 14 + const { logIn, handleCallback, authedFetch, logOut, getSession } = await import("../atsw.js"); 15 + 16 + const HANDLE = "alice.test"; 17 + const DID = "did:plc:test"; 18 + const PDS = "https://pds.example"; 19 + const AUTH = "https://auth.example"; 20 + 21 + const CONFIG = { 22 + clientId: "https://app.example/client-metadata.json", 23 + redirectUri: "https://app.example/", 24 + scope: "atproto transition:generic", 25 + }; 26 + 27 + beforeEach(() => { 28 + resetFakeIDB(); 29 + globalThis.location = { href: "https://app.example/" }; 30 + }); 31 + 32 + /** @param {object} obj @param {number} [status] */ 33 + function json(obj, status = 200) { 34 + return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }); 35 + } 36 + 37 + /** installs a routed fetch mock; returns the array of recorded requests */ 38 + function installFetch(routes) { 39 + const calls = []; 40 + 41 + globalThis.fetch = async (input, init) => { 42 + let url, method, headers, bodyText; 43 + if (input instanceof Request) { 44 + url = input.url; 45 + method = input.method; 46 + headers = input.headers; 47 + bodyText = input.body ? await input.clone().text() : undefined; 48 + } else { 49 + url = String(input); 50 + method = init?.method ?? "GET"; 51 + headers = new Headers(init?.headers ?? {}); 52 + bodyText = init?.body !== undefined ? String(init.body) : undefined; 53 + } 54 + 55 + const call = { url, method, headers, bodyText }; 56 + calls.push(call); 57 + 58 + for (const [prefix, handler] of routes) { 59 + if (url.startsWith(prefix)) return handler(call); 60 + } 61 + throw new Error(`unmocked fetch: ${method} ${url}`); 62 + }; 63 + 64 + return calls; 65 + } 66 + 67 + /** the metadata/discovery/PAR routes shared by every login attempt */ 68 + function baseRoutes({ tokenResponse, revocationEndpoint = `${AUTH}/revoke` } = {}) { 69 + /** @type {[string, (call: any) => Response][]} */ 70 + const routes = [ 71 + ["https://dns.google/resolve", () => json({ Answer: [{ data: `"did=${DID}"` }] })], 72 + [ 73 + "https://plc.directory/", 74 + () => 75 + json({ 76 + alsoKnownAs: [`at://${HANDLE}`], 77 + service: [{ type: "AtprotoPersonalDataServer", serviceEndpoint: PDS }], 78 + }), 79 + ], 80 + [`${PDS}/.well-known/oauth-protected-resource`, () => json({ authorization_servers: [AUTH] })], 81 + [ 82 + `${AUTH}/.well-known/oauth-authorization-server`, 83 + () => 84 + json({ 85 + issuer: AUTH, 86 + authorization_endpoint: `${AUTH}/authorize`, 87 + token_endpoint: `${AUTH}/token`, 88 + pushed_authorization_request_endpoint: `${AUTH}/par`, 89 + revocation_endpoint: revocationEndpoint, 90 + }), 91 + ], 92 + [`${AUTH}/par`, () => json({ request_uri: "urn:request:abc" })], 93 + ]; 94 + 95 + if (tokenResponse) routes.push([`${AUTH}/token`, () => json(tokenResponse)]); 96 + 97 + return routes; 98 + } 99 + 100 + /** runs logIn, then the redirect callback, returning the resulting session */ 101 + async function createSession(overrides = {}) { 102 + installFetch( 103 + baseRoutes({ 104 + tokenResponse: { access_token: "at1", refresh_token: "rt1", sub: DID, scope: CONFIG.scope, ...overrides }, 105 + }), 106 + ); 107 + 108 + await logIn(CONFIG, HANDLE); 109 + const attempt = getAllRecords("attempts")[0]; 110 + 111 + const redirect = new URL(CONFIG.redirectUri); 112 + redirect.searchParams.set("state", attempt.state); 113 + redirect.searchParams.set("code", "test-code"); 114 + redirect.searchParams.set("iss", AUTH); 115 + 116 + await handleCallback(new Request(redirect.href)); 117 + return getSession(DID); 118 + } 119 + 120 + test("logIn resolves a handle and redirects to the authorization endpoint", async () => { 121 + installFetch(baseRoutes()); 122 + 123 + await logIn(CONFIG, HANDLE); 124 + 125 + const url = new URL(location.href); 126 + assert.equal(url.origin + url.pathname, `${AUTH}/authorize`); 127 + assert.equal(url.searchParams.get("request_uri"), "urn:request:abc"); 128 + assert.equal(url.searchParams.get("client_id"), CONFIG.clientId); 129 + 130 + assert.equal(getAllRecords("attempts").length, 1); 131 + }); 132 + 133 + test("handleCallback completes the token exchange and creates a session", async () => { 134 + const session = await createSession(); 135 + 136 + assert.ok(session); 137 + assert.equal(session.accessToken, "at1"); 138 + assert.equal(session.did, DID); 139 + assert.equal(session.pds, PDS); 140 + assert.equal(getAllRecords("attempts").length, 0); 141 + }); 142 + 143 + test("handleCallback rejects a mismatched iss", async () => { 144 + installFetch(baseRoutes()); 145 + await logIn(CONFIG, HANDLE); 146 + const attempt = getAllRecords("attempts")[0]; 147 + 148 + const redirect = new URL(CONFIG.redirectUri); 149 + redirect.searchParams.set("state", attempt.state); 150 + redirect.searchParams.set("code", "test-code"); 151 + redirect.searchParams.set("iss", "https://evil.example"); 152 + 153 + const res = await handleCallback(new Request(redirect.href)); 154 + assert.equal(res.status, 400); 155 + }); 156 + 157 + test("handleCallback rejects a sub mismatch", async () => { 158 + installFetch(baseRoutes({ tokenResponse: { access_token: "at1", sub: "did:plc:other" } })); 159 + await logIn(CONFIG, HANDLE); 160 + const attempt = getAllRecords("attempts")[0]; 161 + 162 + const redirect = new URL(CONFIG.redirectUri); 163 + redirect.searchParams.set("state", attempt.state); 164 + redirect.searchParams.set("code", "test-code"); 165 + redirect.searchParams.set("iss", AUTH); 166 + 167 + const res = await handleCallback(new Request(redirect.href)); 168 + assert.equal(res.status, 400); 169 + }); 170 + 171 + test("handleCallback returns null for an unknown state", async () => { 172 + installFetch(baseRoutes()); 173 + 174 + const redirect = new URL(CONFIG.redirectUri); 175 + redirect.searchParams.set("state", "unknown-state"); 176 + redirect.searchParams.set("code", "test-code"); 177 + redirect.searchParams.set("iss", AUTH); 178 + 179 + const res = await handleCallback(new Request(redirect.href)); 180 + assert.equal(res, null); 181 + }); 182 + 183 + test("authedFetch attaches a DPoP auth header for requests to the session's PDS", async () => { 184 + await createSession(); 185 + 186 + const calls = installFetch([[`${PDS}/xrpc/`, () => json({ ok: true })]]); 187 + 188 + const res = await authedFetch(new Request(`${PDS}/xrpc/com.atproto.server.getSession`)); 189 + assert.equal(res.status, 200); 190 + assert.equal(calls.length, 1); 191 + 192 + const auth = calls[0].headers.get("authorization"); 193 + assert.match(auth ?? "", /^DPoP /); 194 + assert.ok(calls[0].headers.get("dpop")); 195 + }); 196 + 197 + test("authedFetch strips auth headers for a different origin", async () => { 198 + const session = await createSession(); 199 + 200 + const calls = installFetch([["https://other.example/", () => new Response("ok", { status: 200 })]]); 201 + 202 + const req = new Request("https://other.example/xrpc/some.method", { 203 + headers: { "x-atsw-did": session.did }, 204 + }); 205 + const res = await authedFetch(req); 206 + 207 + assert.equal(res.status, 200); 208 + assert.equal(calls.length, 1); 209 + assert.equal(calls[0].headers.get("authorization"), null); 210 + assert.equal(calls[0].headers.get("x-atsw-did"), null); 211 + }); 212 + 213 + test("authedFetch retries with a DPoP nonce after a use_dpop_nonce error", async () => { 214 + await createSession(); 215 + 216 + const calls = installFetch([ 217 + [ 218 + `${PDS}/xrpc/`, 219 + (call) => { 220 + const already = calls.filter((c) => c.url.startsWith(`${PDS}/xrpc/`)).length; 221 + if (already === 1) { 222 + return new Response("", { 223 + status: 401, 224 + headers: { "www-authenticate": 'DPoP error="use_dpop_nonce"', "dpop-nonce": "nonce-1" }, 225 + }); 226 + } 227 + return json({ ok: true }); 228 + }, 229 + ], 230 + ]); 231 + 232 + const res = await authedFetch(new Request(`${PDS}/xrpc/com.atproto.server.getSession`)); 233 + assert.equal(res.status, 200); 234 + assert.equal(calls.length, 2); 235 + 236 + const dpop = calls[1].headers.get("dpop"); 237 + const payload = JSON.parse(Buffer.from(dpop.split(".")[1], "base64url").toString()); 238 + assert.equal(payload.nonce, "nonce-1"); 239 + }); 240 + 241 + test("authedFetch refreshes an expired session before making a request", async () => { 242 + const session = await createSession(); 243 + session.expiresAt = Date.now() - 1000; 244 + 245 + const calls = installFetch([ 246 + [ 247 + `${AUTH}/token`, 248 + () => json({ access_token: "at2", refresh_token: "rt2", expires_in: 3600 }), 249 + ], 250 + [`${PDS}/xrpc/`, () => json({ ok: true })], 251 + ]); 252 + 253 + const res = await authedFetch(new Request(`${PDS}/xrpc/com.atproto.server.getSession`)); 254 + assert.equal(res.status, 200); 255 + 256 + const tokenCall = calls.find((c) => c.url.startsWith(`${AUTH}/token`)); 257 + assert.ok(tokenCall); 258 + const body = new URLSearchParams(tokenCall.bodyText); 259 + assert.equal(body.get("grant_type"), "refresh_token"); 260 + assert.equal(body.get("refresh_token"), "rt1"); 261 + 262 + const xrpcCall = calls.find((c) => c.url.startsWith(`${PDS}/xrpc/`)); 263 + assert.equal(xrpcCall.headers.get("authorization"), "DPoP at2"); 264 + }); 265 + 266 + test("logOut revokes tokens and removes the session", async () => { 267 + const session = await createSession(); 268 + assert.ok(session.revocationEndpoint); 269 + 270 + const calls = installFetch([[session.revocationEndpoint, () => new Response("", { status: 200 })]]); 271 + 272 + await logOut(session.did); 273 + 274 + assert.equal(calls.length, 1); 275 + const body = new URLSearchParams(calls[0].bodyText); 276 + assert.equal(body.get("token"), session.refreshToken); 277 + 278 + assert.equal(await getSession(session.did), undefined); 279 + });
+170
test/fake-idb.js
··· 1 + // Minimal in-memory IndexedDB stub covering exactly the surface atsw.js uses: 2 + // indexedDB.open with onupgradeneeded/onsuccess/onerror; object stores with 3 + // get/put/delete/getAll and single- or compound-key indexes. 4 + 5 + /** @param {any} obj @param {string | string[]} keyPath */ 6 + function keyOf(obj, keyPath) { 7 + return Array.isArray(keyPath) ? keyPath.map((k) => obj[k]) : obj[keyPath]; 8 + } 9 + 10 + const keyStr = (key) => JSON.stringify(key); 11 + 12 + function makeRequest(run) { 13 + const req = { result: undefined, error: undefined, onsuccess: null, onerror: null }; 14 + queueMicrotask(() => { 15 + try { 16 + req.result = run(); 17 + if (req.onsuccess) req.onsuccess(); 18 + } catch (err) { 19 + req.error = err; 20 + if (req.onerror) req.onerror(); 21 + } 22 + }); 23 + return req; 24 + } 25 + 26 + class FakeStore { 27 + /** @param {string} name @param {string | string[]} keyPath */ 28 + constructor(name, keyPath) { 29 + this.name = name; 30 + this.keyPath = keyPath; 31 + this.records = new Map(); 32 + this.indexes = new Map(); 33 + } 34 + 35 + get indexNames() { 36 + const indexes = this.indexes; 37 + return { contains: (name) => indexes.has(name) }; 38 + } 39 + 40 + /** @param {string} name @param {string | string[]} keyPath */ 41 + createIndex(name, keyPath) { 42 + this.indexes.set(name, keyPath); 43 + } 44 + 45 + /** @param {string} name */ 46 + index(name) { 47 + const keyPath = this.indexes.get(name); 48 + if (keyPath === undefined) throw new Error(`no such index: ${name}`); 49 + const records = this.records; 50 + return { 51 + getAll: (query) => 52 + makeRequest(() => [...records.values()].filter((v) => keyStr(keyOf(v, keyPath)) === keyStr(query))), 53 + }; 54 + } 55 + 56 + get(key) { 57 + return makeRequest(() => this.records.get(keyStr(key))); 58 + } 59 + 60 + getAll() { 61 + return makeRequest(() => [...this.records.values()]); 62 + } 63 + 64 + put(value) { 65 + return makeRequest(() => { 66 + this.records.set(keyStr(keyOf(value, this.keyPath)), value); 67 + return keyOf(value, this.keyPath); 68 + }); 69 + } 70 + 71 + delete(key) { 72 + return makeRequest(() => { 73 + this.records.delete(keyStr(key)); 74 + }); 75 + } 76 + } 77 + 78 + class FakeDatabase { 79 + /** @param {string} name */ 80 + constructor(name) { 81 + this.name = name; 82 + this.version = 0; 83 + this.stores = new Map(); 84 + } 85 + 86 + get objectStoreNames() { 87 + const stores = this.stores; 88 + return { contains: (name) => stores.has(name) }; 89 + } 90 + 91 + createObjectStore(name, { keyPath }) { 92 + const store = new FakeStore(name, keyPath); 93 + this.stores.set(name, store); 94 + return store; 95 + } 96 + 97 + deleteObjectStore(name) { 98 + this.stores.delete(name); 99 + } 100 + 101 + transaction(_storeNames, _mode) { 102 + const stores = this.stores; 103 + return { 104 + objectStore(name) { 105 + const store = stores.get(name); 106 + if (!store) throw new Error(`no such store: ${name}`); 107 + return store; 108 + }, 109 + }; 110 + } 111 + } 112 + 113 + const databases = new Map(); 114 + 115 + export const fakeIndexedDB = { 116 + open(name, version) { 117 + const req = { 118 + result: undefined, 119 + error: undefined, 120 + transaction: null, 121 + onsuccess: null, 122 + onerror: null, 123 + onupgradeneeded: null, 124 + }; 125 + 126 + queueMicrotask(() => { 127 + let db = databases.get(name); 128 + const needsUpgrade = !db || db.version < version; 129 + if (!db) { 130 + db = new FakeDatabase(name); 131 + databases.set(name, db); 132 + } 133 + 134 + req.result = db; 135 + 136 + if (needsUpgrade) { 137 + db.version = version; 138 + req.transaction = db.transaction([...db.stores.keys()], "versionchange"); 139 + try { 140 + if (req.onupgradeneeded) req.onupgradeneeded(); 141 + } catch (err) { 142 + req.error = err; 143 + if (req.onerror) req.onerror(); 144 + return; 145 + } 146 + req.transaction = null; 147 + } 148 + 149 + if (req.onsuccess) req.onsuccess(); 150 + }); 151 + 152 + return req; 153 + }, 154 + }; 155 + 156 + /** wipes all stored records, keeping the database/store/index structure intact */ 157 + export function resetFakeIDB() { 158 + for (const db of databases.values()) { 159 + for (const store of db.stores.values()) store.records.clear(); 160 + } 161 + } 162 + 163 + /** test-only escape hatch: read every record currently in a store, across any database */ 164 + export function getAllRecords(storeName) { 165 + for (const db of databases.values()) { 166 + const store = db.stores.get(storeName); 167 + if (store) return [...store.records.values()]; 168 + } 169 + return []; 170 + }