···11# atsw.js
2233-A minimal OAuth client for atproto using a service worker.
33+A very minimal OAuth client for atproto using a service worker. Make authenticated requests to your PDS using plain `fetch` calls.
44+55+**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.
66+77+## Installation
88+99+Copy and paste atsw.js into your project.
1010+1111+## Usage
1212+1313+### `client-metadata.json`
1414+1515+Create a [`client-metadata.json`](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md#step-1-create-your-client-metadata):
1616+1717+```json
1818+{
1919+ "client_id": "https://example.com/client-metadata.json",
2020+ "client_uri": "https://example.com",
2121+ "redirect_uris": ["https://example.com"],
2222+ "application_type": "native",
2323+ "client_name": "Your App",
2424+ "dpop_bound_access_tokens": true,
2525+ "grant_types": ["authorization_code", "refresh_token"],
2626+ "response_types": ["code"],
2727+ "scope": "atproto repo?collection=com.atproto.server.getSession",
2828+ "token_endpoint_auth_method": "none"
2929+}
3030+```
3131+3232+### Setup
3333+3434+Register `atsw.js` as a service worker:
3535+3636+```js
3737+await navigator.serviceWorker.register("./atsw.js", { type: "module" });
3838+await navigator.serviceWorker.ready;
3939+```
4040+4141+Load your configuration from `client-metadata.json`:
4242+4343+```js
4444+import { configure } from "./atsw.js";
4545+4646+const config = await configure("./client-metadata.json");
4747+```
4848+4949+### Logging in
5050+5151+Pass the configuration and handle to `logIn`. It'll automatically redirect the user to their PDS:
5252+5353+```js
5454+import { logIn } from "./atsw.js";
5555+5656+await logIn(config, "you.bsky.social");
5757+```
5858+5959+### Resuming sessions
6060+6161+When the user is redirected back to your application, they'll be authenticated! You can get the PDS host from the session:
6262+6363+```js
6464+import { listSessions } from "./atsw.js";
6565+6666+const [session] = await listSessions();
6767+```
6868+6969+### Making requests
47055-**This is very experimental** — you probably want to use [atcute](https://codeberg.org/mary-ext/atcute) if you're building something real.
7171+You can make authenticated requests using plain `fetch` calls:
7272+7373+```js
7474+const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`);
7575+const data = await res.json();
7676+```
7777+7878+Any requests to the authenticated user's PDS will automatically include an auth token and handle DPoP retries and token refreshes.
7979+8080+### Logging out
8181+8282+```js
8383+import { logout } from "./atsw.js";
8484+8585+await logout(session.did);
8686+```
8787+8888+## How does it work?
8989+9090+`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.
9191+9292+- When the PDS redirects the user back to your callback URL, the service worker reads the URL and finishes setting up the session.
9393+- 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.
9494+- If the auth token has expired, the service worker will refresh it before making a request.
+29-6
atsw.js
···11+// This Source Code Form is subject to the terms of the Mozilla Public
22+// License, v. 2.0. If a copy of the MPL was not distributed with this
33+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44+//
55+// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/atsw
66+17/**
28 * @typedef {Object} DPoPKey
39 * @property {CryptoKey} privateKey
···129135}
130136131137const DB_NAME = "atproto:oauth";
132132-const DB_VERSION = 1;
138138+const DB_VERSION = 2;
133139134140/** @returns {Promise<IDBDatabase>} */
135141function openDb() {
···139145 const db = req.result;
140146 if (!db.objectStoreNames.contains("authing"))
141147 db.createObjectStore("authing", { keyPath: "state" });
148148+149149+ /** @type {IDBObjectStore} */
150150+ let ssns;
142151 if (!db.objectStoreNames.contains("sessions"))
143143- db.createObjectStore("sessions", { keyPath: "pds" });
152152+ ssns = db.createObjectStore("sessions", { keyPath: "pds" });
153153+ else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions");
154154+155155+ if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true });
144156 };
145157 req.onsuccess = () => resolve(req.result);
146158 req.onerror = () => reject(req.error);
···178190/** @returns {Promise<OAuthSession[]>} */
179191export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll());
180192193193+/** @param {string} did @returns {Promise<OAuthSession | undefined>} */
194194+export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did));
195195+181196/** @param {string} pds @returns {Promise<OAuthSession | undefined>} */
182182-export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds));
197197+const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds));
183198184199/** @param {string} pds */
185185-export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds));
200200+const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds));
201201+202202+/** @param {string} did */
203203+export async function logOut(did) {
204204+ const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did));
205205+ if (!pds) return;
206206+207207+ return removeSessionByPDS(pds);
208208+}
186209187210/**
188211 * @param {string} handle
···247270 * @param {string} handle
248271 * @returns {Promise<void>}
249272 */
250250-export async function login(config, handle) {
273273+export async function logIn(config, handle) {
251274 if (!handle) return;
252275253276 const did = await resolveHandle(handle);
···386409 */
387410async function authedFetch(req) {
388411 const url = new URL(req.url);
389389- const maybeSession = await getSession(url.origin);
412412+ const maybeSession = await getSessionByPDS(url.origin);
390413 if (!maybeSession) return fetch(req);
391414 const session = maybeSession;
392415
+29-6
example/atsw.js
···11+// This Source Code Form is subject to the terms of the Mozilla Public
22+// License, v. 2.0. If a copy of the MPL was not distributed with this
33+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44+//
55+// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/atsw
66+17/**
28 * @typedef {Object} DPoPKey
39 * @property {CryptoKey} privateKey
···129135}
130136131137const DB_NAME = "atproto:oauth";
132132-const DB_VERSION = 1;
138138+const DB_VERSION = 2;
133139134140/** @returns {Promise<IDBDatabase>} */
135141function openDb() {
···139145 const db = req.result;
140146 if (!db.objectStoreNames.contains("authing"))
141147 db.createObjectStore("authing", { keyPath: "state" });
148148+149149+ /** @type {IDBObjectStore} */
150150+ let ssns;
142151 if (!db.objectStoreNames.contains("sessions"))
143143- db.createObjectStore("sessions", { keyPath: "pds" });
152152+ ssns = db.createObjectStore("sessions", { keyPath: "pds" });
153153+ else ssns = /** @type {IDBTransaction} */ (req.transaction).objectStore("sessions");
154154+155155+ if (!ssns.indexNames.contains("did")) ssns.createIndex("did", "did", { unique: true });
144156 };
145157 req.onsuccess = () => resolve(req.result);
146158 req.onerror = () => reject(req.error);
···178190/** @returns {Promise<OAuthSession[]>} */
179191export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll());
180192193193+/** @param {string} did @returns {Promise<OAuthSession | undefined>} */
194194+export const getSession = (did) => idb("readonly", "sessions", (s) => s.index("did").get(did));
195195+181196/** @param {string} pds @returns {Promise<OAuthSession | undefined>} */
182182-export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds));
197197+const getSessionByPDS = (pds) => idb("readonly", "sessions", (s) => s.get(pds));
183198184199/** @param {string} pds */
185185-export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds));
200200+const removeSessionByPDS = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds));
201201+202202+/** @param {string} did */
203203+export async function logOut(did) {
204204+ const pds = await idb("readonly", "sessions", (s) => s.index("did").getKey(did));
205205+ if (!pds) return;
206206+207207+ return removeSessionByPDS(pds);
208208+}
186209187210/**
188211 * @param {string} handle
···247270 * @param {string} handle
248271 * @returns {Promise<void>}
249272 */
250250-export async function login(config, handle) {
273273+export async function logIn(config, handle) {
251274 if (!handle) return;
252275253276 const did = await resolveHandle(handle);
···386409 */
387410async function authedFetch(req) {
388411 const url = new URL(req.url);
389389- const maybeSession = await getSession(url.origin);
412412+ const maybeSession = await getSessionByPDS(url.origin);
390413 if (!maybeSession) return fetch(req);
391414 const session = maybeSession;
392415
+12-4
example/index.html
···2020 <pre id="out"></pre>
2121 </div>
2222 <script type="module">
2323- import { configure, login, listSessions } from "./atsw.js";
2323+ import { configure, logIn, listSessions, logOut } from "./atsw.js";
24242525- // Register the service worker that handles the callback and authenticates PDS requests.
2525+ // register the service worker that handles the callback and authenticates PDS requests
2626 await navigator.serviceWorker.register("./atsw.js", { type: "module" });
2727 await navigator.serviceWorker.ready;
2828···3636 e.preventDefault();
3737 try {
3838 out("Logging in...");
3939- await login(config, handle);
3939+ await logIn(config, handle);
4040 } catch (e) {
4141 out(e.message);
4242 }
···4949 const session = sessions[0];
5050 out(`Logged in as ${session.did} @ ${session.pds}\n\nVerifying session...`);
51515252- // The service worker intercepts this request and signs it with DPoP + auth headers.
5252+ // the service worker intercepts this request and signs it with DPoP + auth headers
5353 const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`);
5454 const data = await res.json();
55555656 out(`Session verified:\n${JSON.stringify(data, null, 2)}`);
5757+5858+ const btn = document.createElement("button");
5959+ btn.textContent = "Log out";
6060+ btn.onclick = async () => {
6161+ await logOut(session.did);
6262+ location.reload();
6363+ };
6464+ document.getElementById("app").appendChild(btn);
5765 }
5866 </script>
5967 </body>