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