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.

Update API

+161 -18
+91 -2
README.md
··· 1 1 # atsw.js 2 2 3 - A minimal OAuth client for atproto using a service worker. 3 + A very minimal OAuth client for atproto using a service worker. Make authenticated requests to your PDS using plain `‌fetch` calls. 4 + 5 + **This is very experimental** — you should probably use [atcute](https://codeberg.org/mary-ext/atcute/src/branch/trunk/packages/oauth/browser-client) if you're building something real. 6 + 7 + ## Installation 8 + 9 + Copy and paste atsw.js into your project. 10 + 11 + ## Usage 12 + 13 + ### `client-metadata.json` 14 + 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 + 17 + ```json 18 + { 19 + "client_id": "https://example.com/client-metadata.json", 20 + "client_uri": "https://example.com", 21 + "redirect_uris": ["https://example.com"], 22 + "application_type": "native", 23 + "client_name": "Your App", 24 + "dpop_bound_access_tokens": true, 25 + "grant_types": ["authorization_code", "refresh_token"], 26 + "response_types": ["code"], 27 + "scope": "atproto repo?collection=com.atproto.server.getSession", 28 + "token_endpoint_auth_method": "none" 29 + } 30 + ``` 31 + 32 + ### Setup 33 + 34 + Register `‌atsw.js` as a service worker: 35 + 36 + ```js 37 + await navigator.serviceWorker.register("./atsw.js", { type: "module" }); 38 + await navigator.serviceWorker.ready; 39 + ``` 40 + 41 + Load your configuration from `client-metadata.json`: 42 + 43 + ```js 44 + import { configure } from "./atsw.js"; 45 + 46 + const config = await configure("./client-metadata.json"); 47 + ``` 48 + 49 + ### Logging in 50 + 51 + Pass the configuration and handle to `‌logIn`. It'll automatically redirect the user to their PDS: 52 + 53 + ```js 54 + import { logIn } from "./atsw.js"; 55 + 56 + await logIn(config, "you.bsky.social"); 57 + ``` 58 + 59 + ### Resuming sessions 60 + 61 + When the user is redirected back to your application, they'll be authenticated! You can get the PDS host from the session: 62 + 63 + ```js 64 + import { listSessions } from "./atsw.js"; 65 + 66 + const [session] = await listSessions(); 67 + ``` 68 + 69 + ### Making requests 4 70 5 - **This is very experimental** — you probably want to use [atcute](https://codeberg.org/mary-ext/atcute) if you're building something real. 71 + You can make authenticated requests using plain `‌fetch` calls: 72 + 73 + ```js 74 + const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`); 75 + const data = await res.json(); 76 + ``` 77 + 78 + Any requests to the authenticated user's PDS will automatically include an auth token and handle DPoP retries and token refreshes. 79 + 80 + ### Logging out 81 + 82 + ```js 83 + import { logout } from "./atsw.js"; 84 + 85 + await logout(session.did); 86 + ``` 87 + 88 + ## How does it work? 89 + 90 + `‌atsw.js` uses a [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) as an "auth proxy" that intercepts outgoing and incoming requests. 91 + 92 + - When the PDS redirects the user back to your callback URL, the service worker reads the URL and finishes setting up the session. 93 + - When you make a request to the authenticated user's PDS, the service worker adds an `authorization` header with the user's auth token. If the server rejects the DPoP nonce, the service worker will automatically retry with a new one. 94 + - If the auth token has expired, the service worker will refresh it before making a request.
+29 -6
atsw.js
··· 1 + // This Source Code Form is subject to the terms of the Mozilla Public 2 + // License, v. 2.0. If a copy of the MPL was not distributed with this 3 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 + // 5 + // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/atsw 6 + 1 7 /** 2 8 * @typedef {Object} DPoPKey 3 9 * @property {CryptoKey} privateKey ··· 129 135 } 130 136 131 137 const DB_NAME = "atproto:oauth"; 132 - const DB_VERSION = 1; 138 + const DB_VERSION = 2; 133 139 134 140 /** @returns {Promise<IDBDatabase>} */ 135 141 function openDb() { ··· 139 145 const db = req.result; 140 146 if (!db.objectStoreNames.contains("authing")) 141 147 db.createObjectStore("authing", { keyPath: "state" }); 148 + 149 + /** @type {IDBObjectStore} */ 150 + let ssns; 142 151 if (!db.objectStoreNames.contains("sessions")) 143 - db.createObjectStore("sessions", { keyPath: "pds" }); 152 + ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 153 + else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 154 + 155 + if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 144 156 }; 145 157 req.onsuccess = () => resolve(req.result); 146 158 req.onerror = () => reject(req.error); ··· 178 190 /** @returns {Promise<OAuthSession[]>} */ 179 191 export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 180 192 193 + /** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 194 + export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did)); 195 + 181 196 /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 182 - export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 197 + const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 183 198 184 199 /** @param {string} pds */ 185 - export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 200 + const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 201 + 202 + /** @param {string} did */ 203 + export async function logOut(did) { 204 + const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 205 + if (!pds) return; 206 + 207 + return removeSessionByPDS(pds); 208 + } 186 209 187 210 /** 188 211 * @param {string} handle ··· 247 270 * @param {string} handle 248 271 * @returns {Promise<void>} 249 272 */ 250 - export async function login(config, handle) { 273 + export async function logIn(config, handle) { 251 274 if (!handle) return; 252 275 253 276 const did = await resolveHandle(handle); ··· 386 409 */ 387 410 async function authedFetch(req) { 388 411 const url = new URL(req.url); 389 - const maybeSession = await getSession(url.origin); 412 + const maybeSession = await getSessionByPDS(url.origin); 390 413 if (!maybeSession) return fetch(req); 391 414 const session = maybeSession; 392 415
+29 -6
example/atsw.js
··· 1 + // This Source Code Form is subject to the terms of the Mozilla Public 2 + // License, v. 2.0. If a copy of the MPL was not distributed with this 3 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 + // 5 + // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/atsw 6 + 1 7 /** 2 8 * @typedef {Object} DPoPKey 3 9 * @property {CryptoKey} privateKey ··· 129 135 } 130 136 131 137 const DB_NAME = "atproto:oauth"; 132 - const DB_VERSION = 1; 138 + const DB_VERSION = 2; 133 139 134 140 /** @returns {Promise<IDBDatabase>} */ 135 141 function openDb() { ··· 139 145 const db = req.result; 140 146 if (!db.objectStoreNames.contains("authing")) 141 147 db.createObjectStore("authing", { keyPath: "state" }); 148 + 149 + /** @type {IDBObjectStore} */ 150 + let ssns; 142 151 if (!db.objectStoreNames.contains("sessions")) 143 - db.createObjectStore("sessions", { keyPath: "pds" }); 152 + ssns = db.createObjectStore("sessions", { keyPath: "pds" }); 153 + else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions"); 154 + 155 + if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true }); 144 156 }; 145 157 req.onsuccess = () => resolve(req.result); 146 158 req.onerror = () => reject(req.error); ··· 178 190 /** @returns {Promise<OAuthSession[]>} */ 179 191 export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 180 192 193 + /** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 194 + export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did)); 195 + 181 196 /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 182 - export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 197 + const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 183 198 184 199 /** @param {string} pds */ 185 - export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 200 + const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 201 + 202 + /** @param {string} did */ 203 + export async function logOut(did) { 204 + const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did)); 205 + if (!pds) return; 206 + 207 + return removeSessionByPDS(pds); 208 + } 186 209 187 210 /** 188 211 * @param {string} handle ··· 247 270 * @param {string} handle 248 271 * @returns {Promise<void>} 249 272 */ 250 - export async function login(config, handle) { 273 + export async function logIn(config, handle) { 251 274 if (!handle) return; 252 275 253 276 const did = await resolveHandle(handle); ··· 386 409 */ 387 410 async function authedFetch(req) { 388 411 const url = new URL(req.url); 389 - const maybeSession = await getSession(url.origin); 412 + const maybeSession = await getSessionByPDS(url.origin); 390 413 if (!maybeSession) return fetch(req); 391 414 const session = maybeSession; 392 415
+12 -4
example/index.html
··· 20 20 <pre id="out"></pre> 21 21 </div> 22 22 <script type="module"> 23 - import { configure, login, listSessions } from "./atsw.js"; 23 + import { configure, logIn, listSessions, logOut } from "./atsw.js"; 24 24 25 - // Register the service worker that handles the callback and authenticates PDS requests. 25 + // register the service worker that handles the callback and authenticates PDS requests 26 26 await navigator.serviceWorker.register("./atsw.js", { type: "module" }); 27 27 await navigator.serviceWorker.ready; 28 28 ··· 36 36 e.preventDefault(); 37 37 try { 38 38 out("Logging in..."); 39 - await login(config, handle); 39 + await logIn(config, handle); 40 40 } catch (e) { 41 41 out(e.message); 42 42 } ··· 49 49 const session = sessions[0]; 50 50 out(`Logged in as ${session.did} @ ${session.pds}\n\nVerifying session...`); 51 51 52 - // The service worker intercepts this request and signs it with DPoP + auth headers. 52 + // the service worker intercepts this request and signs it with DPoP + auth headers 53 53 const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`); 54 54 const data = await res.json(); 55 55 56 56 out(`Session verified:\n${JSON.stringify(data, null, 2)}`); 57 + 58 + const btn = document.createElement("button"); 59 + btn.textContent = "Log out"; 60 + btn.onclick = async () => { 61 + await logOut(session.did); 62 + location.reload(); 63 + }; 64 + document.getElementById("app").appendChild(btn); 57 65 } 58 66 </script> 59 67 </body>