···
1
1
import { Component, render } from "preact";
2
2
import { html } from "htm/preact";
3
3
import { signal } from "@preact/signals";
4
4
-
import { logIn, listSessions, logOut } from "./atsw.js";
4
4
+
import { configure, logIn, listSessions, logOut } from "./atsw.js";
5
5
import {
6
6
session,
7
7
listUri,
···
31
31
"repo?collection=io.tngl.jake.atodo.tombstone",
32
32
};
33
33
34
34
+
configure(config);
35
35
+
34
36
const ready = signal(false);
35
37
36
38
class LoginForm extends Component {
···
45
47
this.busy.value = true;
46
48
this.error.value = "";
47
49
try {
48
48
-
await logIn(config, handle);
50
50
+
await logIn(handle);
49
51
} catch (err) {
50
52
this.error.value = err.message;
51
53
this.busy.value = false;
···
74
74
try {
75
75
body = JSON.parse(text);
76
76
} catch {}
77
77
-
const err = new Error(`${label} ${res.status}: ${body?.error ?? text}`);
77
77
+
const detail = body ? [body.error, body.message].filter(Boolean).join(": ") : text;
78
78
+
const err = new Error(`${label} ${res.status}: ${detail}`);
78
79
err.status = res.status;
79
80
err.atproto = body;
80
81
return err;
···
100
100
return toSign + "." + b64url(sig);
101
101
}
102
102
103
103
-
const MAX_DPOP_RETRIES = 2;
103
103
+
// 3, not 2: recovering from a 401 that comes back with *no* fresh nonce
104
104
+
// (server didn't think it owed us one) takes a full extra round trip — one
105
105
+
// attempt to discover that, one to run the standard nonceless bootstrap
106
106
+
// handshake, one to actually use the nonce that handshake returns.
107
107
+
const MAX_DPOP_RETRIES = 3;
104
108
const DEFAULT_TOKEN_TTL = 3600;
105
109
const DID_HEADER = "x-atsw-did";
106
110
···
133
137
}
134
138
135
139
const DB_NAME = "atproto:oauth";
136
136
-
const DB_VERSION = 4;
140
140
+
const DB_VERSION = 5;
137
141
138
142
/** @type {Promise<IDBDatabase> | null} */
139
143
let dbPromise = null;
···
154
158
? req.transaction.objectStore("sessions")
155
159
: db.createObjectStore("sessions", { keyPath: "did" });
156
160
if (!ssns.indexNames.contains("pds")) ssns.createIndex("pds", "pds", { unique: false });
161
161
+
// IndexedDB is per-origin: a session belonging to a different OAuth
162
162
+
// client (e.g. an older deploy at another path) can end up sharing this
163
163
+
// store. Index on clientId so callers can ask for only their own.
164
164
+
if (!ssns.indexNames.contains("clientId")) ssns.createIndex("clientId", "clientId", { unique: false });
157
165
};
158
166
req.onsuccess = () => resolve(req.result);
159
167
req.onerror = () => {
···
194
202
const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v));
195
203
196
204
/** @returns {Promise<OAuthSession[]>} */
197
197
-
export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll());
205
205
+
export const listSessions = () => {
206
206
+
if (!oauthConfig) throw new Error("call configure() before listSessions()");
207
207
+
return idb("readonly", "sessions", (s) => s.index("clientId").getAll(oauthConfig.clientId));
208
208
+
};
198
209
199
210
/** @param {string} did @returns {Promise<OAuthSession | undefined>} */
200
211
export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did));
···
245
256
* @returns {Promise<AuthServerMetadata>}
246
257
*/
247
258
async function discoverAuthServer(pds) {
259
259
+
let meta;
248
260
try {
249
261
const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then((res) => res.json());
250
262
const issuer = /** @type {string} */ (res.authorization_servers[0]);
251
251
-
return await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => res.json());
263
263
+
meta = await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => res.json());
252
264
} catch {
253
253
-
return await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json());
265
265
+
meta = await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json());
254
266
}
267
267
+
268
268
+
// a failed/unexpected response can still be valid JSON (e.g. a 404 body),
269
269
+
// so check for the fields we actually need rather than letting `undefined`
270
270
+
// silently flow into a later `new URL(...)` call with a confusing error.
271
271
+
if (!meta?.authorization_endpoint || !meta.token_endpoint || !meta.pushed_authorization_request_endpoint) {
272
272
+
throw new Error(`Could not discover OAuth endpoints for ${pds}`);
273
273
+
}
274
274
+
275
275
+
return meta;
276
276
+
}
277
277
+
278
278
+
/** @type {OAuthConfig | null} */
279
279
+
let oauthConfig = null;
280
280
+
281
281
+
/** @param {OAuthConfig} config */
282
282
+
export function configure(config) {
283
283
+
oauthConfig = config;
255
284
}
256
285
257
286
/**
···
259
288
* redirects the browser to the authorization server. When the auth server
260
289
* redirects back, the service worker will intercept the callback and complete
261
290
* the token exchange.
262
262
-
* @param {OAuthConfig} config
263
291
* @param {string} handle
264
292
* @returns {Promise<void>}
265
293
*/
266
266
-
export async function logIn(config, handle) {
294
294
+
export async function logIn(handle) {
295
295
+
if (!oauthConfig) throw new Error("call configure() before logIn()");
296
296
+
const config = oauthConfig;
297
297
+
267
298
const did = await resolveDID(handle);
268
299
const pds = await resolvePDS(did);
269
300
const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]);
···
344
375
});
345
376
346
377
const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body);
347
347
-
if (json.error) return new Response("token error: " + JSON.stringify(json), { status: 400 });
378
378
+
if (json.error || !json.access_token)
379
379
+
return new Response("token error: " + JSON.stringify(json), { status: 400 });
348
380
349
381
/** @type {OAuthSession} */
350
382
const session = {
···
370
402
371
403
/** @param {OAuthSession} session */
372
404
async function ensureFresh(session) {
373
373
-
if (session.expiresAt > Date.now() || !session.refreshToken) return session;
405
405
+
if (session.accessToken && session.expiresAt > Date.now()) return session;
406
406
+
if (!session.refreshToken) return session;
374
407
375
408
// see if this session is already being refreshed
376
409
const lock = refreshLocks.get(session.did);
···
386
419
const { tokenEndpoint, dpopKey, refreshToken: refresh_token, clientId: client_id } = session;
387
420
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id });
388
421
const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce);
389
389
-
if (json.error) throw new Error("Refresh error: " + JSON.stringify(json));
422
422
+
if (json.error || !json.access_token) throw new Error("Refresh error: " + JSON.stringify(json));
390
423
391
424
session.accessToken = json.access_token;
392
425
session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000;
···
405
438
}
406
439
}
407
440
408
408
-
/** @param {Request} req */
409
409
-
async function authedFetch(req) {
410
410
-
const url = new URL(req.url);
411
411
-
const did = req.headers.get(DID_HEADER);
441
441
+
/** @type {Map<string, Promise<any>>} */
442
442
+
const requestQueues = new Map();
412
443
413
413
-
/** @type {OAuthSession | undefined} */
414
414
-
let session;
415
415
-
if (did) session = await getSession(did);
416
416
-
else {
417
417
-
const sessions = await listSessionsByPDS(url.origin);
418
418
-
if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`);
419
419
-
session = sessions[0];
420
420
-
}
444
444
+
/**
445
445
+
* Serialize fetches per DID. The server hands out a fresh DPoP nonce on
446
446
+
* every response, so it must be used in lockstep: if concurrent requests
447
447
+
* (e.g. an offline-queue flush racing a user action right after the app
448
448
+
* wakes from idle) all sign with the same stale nonce, only one can win
449
449
+
* each round trip and the rest exhaust MAX_DPOP_RETRIES and fail outright.
450
450
+
* @param {string} did
451
451
+
* @param {() => Promise<Response>} fn
452
452
+
*/
453
453
+
function withRequestLock(did, fn) {
454
454
+
const prev = requestQueues.get(did) ?? Promise.resolve();
455
455
+
const next = prev.then(fn, fn);
456
456
+
requestQueues.set(did, next.catch(() => {}));
457
457
+
return next;
458
458
+
}
421
459
422
422
-
if (!session) return fetch(req);
423
423
-
session = await ensureFresh(session);
424
424
-
460
460
+
/**
461
461
+
* @param {Request} req
462
462
+
* @param {OAuthSession} session
463
463
+
*/
464
464
+
async function doAuthedFetch(req, session) {
465
465
+
const url = new URL(req.url);
425
466
const htu = url.origin + url.pathname;
426
467
const htm = req.method;
427
468
···
429
470
for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) {
430
471
if (attempt > 0) session = (await getSession(session.did)) ?? session;
431
472
432
432
-
const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.accessToken)));
433
433
-
const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath);
473
473
+
// snapshot once — `session` is a shared, mutable object (ensureFresh can
474
474
+
// rewrite session.accessToken on it from a concurrent caller), so reading
475
475
+
// it twice across the awaits below risks signing the proof's "ath" against
476
476
+
// a different token than the one that ends up in the Authorization header.
477
477
+
const accessToken = session.accessToken;
478
478
+
const dpopNonce = session.dpopNonce;
479
479
+
480
480
+
const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken)));
481
481
+
const dpop = await createDPoP(session.dpopKey, htm, htu, dpopNonce, ath);
434
482
435
483
const headers = new Headers(req.headers);
436
484
headers.delete(DID_HEADER);
437
437
-
headers.set("authorization", `DPoP ${session.accessToken}`);
485
485
+
headers.set("authorization", `DPoP ${accessToken}`);
438
486
headers.set("dpop", dpop);
439
487
440
488
res = await fetch(new Request(req.clone(), { headers }));
441
489
const nonce = res.headers.get("dpop-nonce");
442
442
-
if (nonce && nonce !== session.dpopNonce) {
490
490
+
if (res.status === 401) {
491
491
+
// our proof was rejected, so the nonce we signed with (if any) is
492
492
+
// proven bad — force-refresh it. Use whatever the server handed
493
493
+
// back, or drop it entirely so the next attempt sends no nonce and
494
494
+
// triggers the standard "use_dpop_nonce" bootstrap handshake.
495
495
+
if (nonce !== dpopNonce) {
496
496
+
session.dpopNonce = nonce || undefined;
497
497
+
await putSession(session);
498
498
+
}
499
499
+
} else if (nonce && nonce !== dpopNonce) {
443
500
session.dpopNonce = nonce;
444
501
await putSession(session);
445
502
}
···
447
504
if (res.status !== 401) break;
448
505
}
449
506
450
450
-
return /** @type {Response} */ (res);
507
507
+
return res;
508
508
+
}
509
509
+
510
510
+
/** @param {Request} req */
511
511
+
async function authedFetch(req) {
512
512
+
const url = new URL(req.url);
513
513
+
const did = req.headers.get(DID_HEADER);
514
514
+
515
515
+
/** @type {OAuthSession | undefined} */
516
516
+
let session;
517
517
+
if (did) session = await getSession(did);
518
518
+
else if (url.pathname.startsWith("/xrpc/")) {
519
519
+
// XRPC calls may omit the DID header as a convenience when there's
520
520
+
// only one session for the PDS. Anything else (e.g. OAuth metadata
521
521
+
// discovery under /.well-known/) is never meant to be authenticated,
522
522
+
// so don't guess a session for it just because the origin matches.
523
523
+
const sessions = await listSessionsByPDS(url.origin);
524
524
+
if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`);
525
525
+
session = sessions[0];
526
526
+
}
527
527
+
528
528
+
if (!session) return fetch(req);
529
529
+
session = await ensureFresh(session);
530
530
+
531
531
+
return withRequestLock(session.did, () => doAuthedFetch(req, /** @type {OAuthSession} */ (session)));
451
532
}
452
533
453
534
sw.oninstall = () => sw.skipWaiting();