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.

Support multiple sessions per PDS

+67 -69
+14 -4
README.md
··· 10 10 11 11 ## Usage 12 12 13 - ### `client-metadata.json` 13 + ### Client metadata 14 14 15 15 Create a [`‌client-metadata.json`](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md#step-1-create-your-client-metadata): 16 16 ··· 61 61 When the user is redirected back to your application, they'll be authenticated! You can get the PDS host from the session: 62 62 63 63 ```js 64 - import { listSessions } from "./atsw.js"; 64 + import { getSession, listSessions } from "./atsw.js"; 65 65 66 - const [session] = await listSessions(); 66 + const session = await getSession("did:plc:users-did-goes-here"); 67 + const allSessions = await listSessions(); 67 68 ``` 68 69 69 70 ### Making requests 70 71 71 - You can make authenticated requests using plain `‌fetch` calls: 72 + Make authenticated requests using plain `‌fetch` calls: 72 73 73 74 ```js 74 75 const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`); ··· 76 77 ``` 77 78 78 79 Any requests to the authenticated user's PDS will automatically include an auth token and handle DPoP retries and token refreshes. 80 + 81 + If a user has multiple authenticated sessions with the same PDS, use the `x-atsw-did` header to indicate which user's authentication token to use: 82 + 83 + ```js 84 + const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`, { 85 + headers: { "x-atsw-did": session.did } 86 + }); 87 + const data = await res.json(); 88 + ``` 79 89 80 90 ### Logging out 81 91
+25 -31
atsw.js
··· 134 134 } 135 135 136 136 const DB_NAME = "atproto:oauth"; 137 - const DB_VERSION = 2; 137 + const DB_VERSION = 3; 138 138 139 139 /** @returns {Promise<IDBDatabase>} */ 140 140 function openDb() { ··· 145 145 if (!db.objectStoreNames.contains("authing")) 146 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"); 153 - 154 - if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 148 + if (db.objectStoreNames.contains("sessions")) db.deleteObjectStore("sessions"); 149 + const ssns = db.createObjectStore("sessions", { keyPath: "did" }); 150 + ssns.createIndex("pds", "pds", { unique: false }); 155 151 }; 156 152 req.onsuccess = () => resolve(req.result); 157 153 req.onerror = () => reject(req.error); ··· 190 186 export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 191 187 192 188 /** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 193 - export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did)); 194 - 195 - /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 196 - const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 189 + export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); 197 190 198 - /** @param {string} pds */ 199 - const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 191 + /** @param {string} pds @returns {Promise<OAuthSession[]>} */ 192 + const getSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 200 193 201 194 /** @param {string} did */ 202 - export async function logOut(did) { 203 - const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 204 - if (!pds) return; 205 - 206 - return removeSessionByPDS(pds); 207 - } 195 + export const logOut = (did) => idb("readwrite", "sessions", (s) => s.delete(did)); 208 196 209 - /** 210 - * @param {string} handle 211 - * @returns {Promise<string>} 212 - */ 213 - async function resolveHandle(handle) { 197 + /** @param {string} handle */ 198 + export async function resolveDID(handle) { 214 199 try { 215 200 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 216 201 const j = await r.json(); ··· 270 255 * @returns {Promise<void>} 271 256 */ 272 257 export async function logIn(config, handle) { 273 - if (!handle) return; 274 - 275 - const did = await resolveHandle(handle); 258 + const did = await resolveDID(handle); 276 259 const pds = await resolvePDS(did); 277 260 const meta = await discoverAuthServer(pds); 278 261 ··· 407 390 */ 408 391 async function authedFetch(req) { 409 392 const url = new URL(req.url); 410 - const maybeSession = await getSessionByPDS(url.origin); 411 - if (!maybeSession) return fetch(req); 412 - const session = maybeSession; 393 + const did = req.headers.get("x-atsw-did"); 394 + 395 + /** @type {OAuthSession | undefined} */ 396 + let session; 397 + if (did) session = await getSession(did); 398 + else { 399 + const sessions = await getSessionsByPDS(url.origin); 400 + if (sessions.length > 1) 401 + throw new Error(`Multiple sessions for ${url.origin}; set x-atsw-did header`); 402 + session = sessions[0]; 403 + } 404 + 405 + if (!session) return fetch(req); 413 406 414 407 if (session.expiresAt <= Date.now() && session.refresh_token) { 415 408 await refresh(session); ··· 425 418 const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 426 419 427 420 const headers = new Headers(req.headers); 421 + headers.delete("x-atsw-did"); 428 422 headers.set("authorization", `DPoP ${session.access_token}`); 429 423 headers.set("dpop", dpop); 430 424
+25 -31
example/atsw.js
··· 134 134 } 135 135 136 136 const DB_NAME = "atproto:oauth"; 137 - const DB_VERSION = 2; 137 + const DB_VERSION = 3; 138 138 139 139 /** @returns {Promise<IDBDatabase>} */ 140 140 function openDb() { ··· 145 145 if (!db.objectStoreNames.contains("authing")) 146 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"); 153 - 154 - if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 148 + if (db.objectStoreNames.contains("sessions")) db.deleteObjectStore("sessions"); 149 + const ssns = db.createObjectStore("sessions", { keyPath: "did" }); 150 + ssns.createIndex("pds", "pds", { unique: false }); 155 151 }; 156 152 req.onsuccess = () => resolve(req.result); 157 153 req.onerror = () => reject(req.error); ··· 190 186 export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 191 187 192 188 /** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 193 - export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did)); 194 - 195 - /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 196 - const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 189 + export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); 197 190 198 - /** @param {string} pds */ 199 - const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 191 + /** @param {string} pds @returns {Promise<OAuthSession[]>} */ 192 + const getSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 200 193 201 194 /** @param {string} did */ 202 - export async function logOut(did) { 203 - const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 204 - if (!pds) return; 205 - 206 - return removeSessionByPDS(pds); 207 - } 195 + export const logOut = (did) => idb("readwrite", "sessions", (s) => s.delete(did)); 208 196 209 - /** 210 - * @param {string} handle 211 - * @returns {Promise<string>} 212 - */ 213 - async function resolveHandle(handle) { 197 + /** @param {string} handle */ 198 + export async function resolveDID(handle) { 214 199 try { 215 200 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 216 201 const j = await r.json(); ··· 270 255 * @returns {Promise<void>} 271 256 */ 272 257 export async function logIn(config, handle) { 273 - if (!handle) return; 274 - 275 - const did = await resolveHandle(handle); 258 + const did = await resolveDID(handle); 276 259 const pds = await resolvePDS(did); 277 260 const meta = await discoverAuthServer(pds); 278 261 ··· 407 390 */ 408 391 async function authedFetch(req) { 409 392 const url = new URL(req.url); 410 - const maybeSession = await getSessionByPDS(url.origin); 411 - if (!maybeSession) return fetch(req); 412 - const session = maybeSession; 393 + const did = req.headers.get("x-atsw-did"); 394 + 395 + /** @type {OAuthSession | undefined} */ 396 + let session; 397 + if (did) session = await getSession(did); 398 + else { 399 + const sessions = await getSessionsByPDS(url.origin); 400 + if (sessions.length > 1) 401 + throw new Error(`Multiple sessions for ${url.origin}; set x-atsw-did header`); 402 + session = sessions[0]; 403 + } 404 + 405 + if (!session) return fetch(req); 413 406 414 407 if (session.expiresAt <= Date.now() && session.refresh_token) { 415 408 await refresh(session); ··· 425 418 const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 426 419 427 420 const headers = new Headers(req.headers); 421 + headers.delete("x-atsw-did"); 428 422 headers.set("authorization", `DPoP ${session.access_token}`); 429 423 headers.set("dpop", dpop); 430 424
+3 -3
example/client-metadata.json
··· 1 1 { 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/"], 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 5 "application_type": "native", 6 6 "client_name": "atsw", 7 7 "dpop_bound_access_tokens": true,