a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(password-session): initial commit

Mary 06ac79f9 b615c7ba

+1130
+111
packages/clients/password-session/README.md
··· 1 + # @atcute/password-session 2 + 3 + password-based session management for AT Protocol services. manages access/refresh token lifecycle, 4 + automatic refresh on 401, and session persistence via callbacks. 5 + 6 + for browser-based applications, prefer OAuth-based authentication. when using password auth, use app 7 + passwords rather than main account credentials. 8 + 9 + ```sh 10 + npm install @atcute/password-session @atcute/client @atcute/bluesky 11 + ``` 12 + 13 + ## usage 14 + 15 + ### login 16 + 17 + ```ts 18 + import { Client, ok } from '@atcute/client'; 19 + import { PasswordSession } from '@atcute/password-session'; 20 + 21 + import type {} from '@atcute/bluesky'; 22 + 23 + const session = await PasswordSession.login( 24 + { service: 'https://bsky.social', identifier: 'you.bsky.social', password: 'your-app-password' }, 25 + { 26 + onUpdate(data) { 27 + // called on login and token refresh — persist the session 28 + localStorage.setItem('session', JSON.stringify(data)); 29 + }, 30 + onDelete(data) { 31 + // called on logout or session invalidation — clean up 32 + localStorage.removeItem('session'); 33 + }, 34 + }, 35 + ); 36 + 37 + const rpc = new Client({ handler: session }); 38 + 39 + const data = await ok(rpc.get('com.atproto.server.getSession')); 40 + console.log(data.did); 41 + ``` 42 + 43 + ### URL shorthand 44 + 45 + for bots and scripts, use URL shorthand with `await using` for automatic logout: 46 + 47 + ```ts 48 + await using session = await PasswordSession.login('https://handle:app-pass@bsky.social'); 49 + const rpc = new Client({ handler: session }); 50 + ``` 51 + 52 + ### resuming sessions 53 + 54 + resume a persisted session without re-entering credentials: 55 + 56 + ```ts 57 + const saved = localStorage.getItem('session'); 58 + if (saved) { 59 + const session = await PasswordSession.resume(JSON.parse(saved), { 60 + onUpdate(data) { 61 + localStorage.setItem('session', JSON.stringify(data)); 62 + }, 63 + onDelete(data) { 64 + localStorage.removeItem('session'); 65 + }, 66 + }); 67 + const rpc = new Client({ handler: session }); 68 + } 69 + ``` 70 + 71 + ### cached session with credential fallback 72 + 73 + for bots with both stored credentials and cached sessions, `login()` can try the cached session 74 + first and fall back to fresh authentication: 75 + 76 + ```ts 77 + const session = await PasswordSession.login('https://handle:app-pass@bsky.social', { 78 + session: loadFromDisk(), 79 + onUpdate(data) { 80 + saveToDisk(data); 81 + }, 82 + }); 83 + ``` 84 + 85 + ### lazy construction 86 + 87 + if you don't need upfront validation, construct directly — tokens refresh lazily on 401: 88 + 89 + ```ts 90 + const session = new PasswordSession(savedData, { onUpdate, onDelete }); 91 + const rpc = new Client({ handler: session }); 92 + ``` 93 + 94 + ### cleanup 95 + 96 + delete an orphaned session server-side without resuming: 97 + 98 + ```ts 99 + await PasswordSession.delete(savedData); 100 + ``` 101 + 102 + ## callbacks 103 + 104 + | callback | when | session state | 105 + | ----------------- | ------------------------------------------ | ----------------- | 106 + | `onUpdate` | login succeeds, tokens refresh successfully | active (updated) | 107 + | `onUpdateFailure` | token refresh fails transiently (network) | active (preserved)| 108 + | `onDelete` | logout succeeds, session invalidated | destroyed | 109 + | `onDeleteFailure` | logout fails transiently (network) | active (preserved)| 110 + 111 + all callbacks receive `this: PasswordSession` context and must not throw.
+365
packages/clients/password-session/lib/agent.test.ts
··· 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import { TestNetwork } from '@atcute/internal-dev-env'; 3 + import type { Handle } from '@atcute/lexicons'; 4 + 5 + import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; 6 + 7 + import { PasswordSession, type PasswordSessionData } from './password-session.ts'; 8 + 9 + let network: TestNetwork; 10 + 11 + beforeAll(async () => { 12 + network = await TestNetwork.create({}); 13 + 14 + const rpc = new Client({ handler: simpleFetchHandler({ service: network.pds.url }) }); 15 + await createAccount(rpc, 'user1.test'); 16 + }); 17 + 18 + afterAll(async () => { 19 + await network.close(); 20 + }); 21 + 22 + it('can connect to a PDS', async () => { 23 + const rpc = new Client({ handler: simpleFetchHandler({ service: network.pds.url }) }); 24 + 25 + const data = await ok(rpc.get('com.atproto.server.describeServer')); 26 + 27 + expect(data).toEqual({ 28 + did: 'did:web:localhost', 29 + availableUserDomains: ['.test', '.example'], 30 + inviteCodeRequired: false, 31 + links: { 32 + privacyPolicy: 'https://bsky.social/about/support/privacy-policy', 33 + termsOfService: 'https://bsky.social/about/support/tos', 34 + }, 35 + contact: {}, 36 + }); 37 + }); 38 + 39 + describe('PasswordSession', () => { 40 + it('can login via static factory', async () => { 41 + const onUpdate = vi.fn(); 42 + 43 + const session = await PasswordSession.login( 44 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 45 + { onUpdate }, 46 + ); 47 + const rpc = new Client({ handler: session }); 48 + 49 + expect(onUpdate).toHaveBeenCalledOnce(); 50 + expect(session.destroyed).toBe(false); 51 + expect(session.session).not.toBe(undefined); 52 + 53 + await expect(ok(rpc.get('com.atproto.server.getSession'))).resolves.not.toBe(undefined); 54 + }); 55 + 56 + it('can login with URL shorthand', async () => { 57 + const url = new URL(network.pds.url); 58 + url.username = 'user1.test'; 59 + url.password = 'password'; 60 + 61 + const session = await PasswordSession.login(url.href); 62 + const rpc = new Client({ handler: session }); 63 + 64 + expect(session.destroyed).toBe(false); 65 + await expect(ok(rpc.get('com.atproto.server.getSession'))).resolves.not.toBe(undefined); 66 + }); 67 + 68 + it('can login with cached session fallback', async () => { 69 + // first, get a valid session 70 + const initial = await PasswordSession.login({ 71 + service: network.pds.url, 72 + identifier: 'user1.test', 73 + password: 'password', 74 + }); 75 + const cachedSession = initial.session; 76 + 77 + const fetch = vi.fn(globalThis.fetch); 78 + 79 + // login with cached session — should resume instead of creating new session 80 + const session = await PasswordSession.login( 81 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 82 + { session: cachedSession, fetch }, 83 + ); 84 + 85 + expect(session.destroyed).toBe(false); 86 + 87 + // should have used getSession (from resume) not createSession 88 + const calls = fetch.mock.calls.map(([input]) => new Request(input).url); 89 + const hasCreate = calls.some((url) => url.includes('createSession')); 90 + expect(hasCreate).toBe(false); 91 + }); 92 + 93 + it('falls back to fresh login when cached session is invalid', async () => { 94 + const invalidSession: PasswordSessionData = { 95 + service: network.pds.url, 96 + accessJwt: 'invalid', 97 + refreshJwt: 'invalid', 98 + handle: 'user1.test', 99 + did: 'did:plc:fake', 100 + active: true, 101 + }; 102 + 103 + const session = await PasswordSession.login( 104 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 105 + { session: invalidSession }, 106 + ); 107 + 108 + expect(session.destroyed).toBe(false); 109 + const rpc = new Client({ handler: session }); 110 + await expect(ok(rpc.get('com.atproto.server.getSession'))).resolves.not.toBe(undefined); 111 + }); 112 + 113 + it('refreshes on 401', async () => { 114 + const fetch = vi.fn(globalThis.fetch); 115 + const onUpdate = vi.fn(); 116 + 117 + const session = await PasswordSession.login( 118 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 119 + { fetch, onUpdate }, 120 + ); 121 + const rpc = new Client({ handler: session }); 122 + 123 + onUpdate.mockClear(); 124 + 125 + const originalJwt = session.session.accessJwt; 126 + 127 + // refreshing now would return the same token due to matching timestamp 128 + await sleep(1_000); 129 + 130 + fetch.mockResolvedValueOnce( 131 + new Response(JSON.stringify({ error: 'ExpiredToken' }), { 132 + status: 400, 133 + headers: { 'content-type': 'application/json' }, 134 + }), 135 + ); 136 + 137 + await ok(rpc.get('com.atproto.server.getSession')); 138 + expect(onUpdate).toHaveBeenCalledOnce(); 139 + 140 + const refreshedJwt = session.session.accessJwt; 141 + expect(refreshedJwt).not.toBe(originalJwt); 142 + }); 143 + 144 + it('deduplicates token refreshes', async () => { 145 + const originalFetch = globalThis.fetch; 146 + const fetch = vi.fn(globalThis.fetch); 147 + const onUpdate = vi.fn(); 148 + 149 + const session = await PasswordSession.login( 150 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 151 + { fetch, onUpdate }, 152 + ); 153 + const rpc = new Client({ handler: session }); 154 + 155 + onUpdate.mockClear(); 156 + 157 + const originalJwt = session.session.accessJwt; 158 + 159 + await sleep(1_000); 160 + 161 + let expiredCalls = 0; 162 + let refreshCalls = 0; 163 + 164 + await fetch.withImplementation( 165 + (input, init) => { 166 + const request = new Request(input, init); 167 + 168 + if (request.headers.get('authorization') === `Bearer ${originalJwt}`) { 169 + expiredCalls++; 170 + 171 + return Promise.resolve( 172 + new Response(JSON.stringify({ error: 'ExpiredToken' }), { 173 + status: 400, 174 + headers: { 'content-type': 'application/json' }, 175 + }), 176 + ); 177 + } 178 + 179 + if (request.url.includes('/xrpc/com.atproto.server.refreshSession')) { 180 + refreshCalls++; 181 + } 182 + 183 + return originalFetch(request); 184 + }, 185 + async () => { 186 + await Promise.all([ 187 + ok(rpc.get('com.atproto.server.getSession')), 188 + ok(rpc.get('com.atproto.server.getSession')), 189 + ok(rpc.get('com.atproto.server.getSession')), 190 + ]); 191 + }, 192 + ); 193 + 194 + expect(expiredCalls).toBe(3); 195 + expect(refreshCalls).toBe(1); 196 + 197 + expect(onUpdate).toHaveBeenCalledOnce(); 198 + 199 + const refreshedJwt = session.session.accessJwt; 200 + expect(refreshedJwt).not.toBe(originalJwt); 201 + }); 202 + 203 + it('preserves session on transient refresh failure', async () => { 204 + const originalFetch = globalThis.fetch; 205 + const fetch = vi.fn(globalThis.fetch); 206 + const onUpdate = vi.fn(); 207 + const onUpdateFailure = vi.fn(); 208 + 209 + const session = await PasswordSession.login( 210 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 211 + { fetch, onUpdate, onUpdateFailure }, 212 + ); 213 + const rpc = new Client({ handler: session }); 214 + 215 + onUpdate.mockClear(); 216 + 217 + const originalJwt = session.session.accessJwt; 218 + 219 + await sleep(1_000); 220 + 221 + await fetch.withImplementation( 222 + (input, init) => { 223 + const request = new Request(input, init); 224 + 225 + if (request.headers.get('authorization') === `Bearer ${originalJwt}`) { 226 + return Promise.resolve( 227 + new Response(JSON.stringify({ error: 'ExpiredToken' }), { 228 + status: 400, 229 + headers: { 'content-type': 'application/json' }, 230 + }), 231 + ); 232 + } 233 + 234 + if (request.url.includes('/xrpc/com.atproto.server.refreshSession')) { 235 + return Promise.resolve(new Response(undefined, { status: 500 })); 236 + } 237 + 238 + return originalFetch(request); 239 + }, 240 + async () => { 241 + const response = await rpc.get('com.atproto.server.getSession'); 242 + 243 + if (response.ok) { 244 + expect.fail('getSession call should not succeed'); 245 + } 246 + 247 + expect(response.data.error).toBe('ExpiredToken'); 248 + }, 249 + ); 250 + 251 + expect(session.destroyed).toBe(false); 252 + expect(session.session.accessJwt).toBe(originalJwt); 253 + 254 + expect(onUpdate).not.toHaveBeenCalled(); 255 + expect(onUpdateFailure).toHaveBeenCalledOnce(); 256 + }); 257 + 258 + it('can resume sessions (quick path)', async () => { 259 + let savedSession: PasswordSessionData; 260 + 261 + { 262 + const session = await PasswordSession.login({ 263 + service: network.pds.url, 264 + identifier: 'user1.test', 265 + password: 'password', 266 + }); 267 + savedSession = session.session; 268 + } 269 + 270 + const fetch = vi.fn(globalThis.fetch); 271 + expect(fetch).not.toHaveBeenCalled(); 272 + 273 + { 274 + const session = await PasswordSession.resume(savedSession, { fetch }); 275 + expect(session.destroyed).toBe(false); 276 + } 277 + 278 + expect(fetch).toHaveBeenCalledOnce(); 279 + expect(fetch.mock.lastCall).not.toBe(undefined); 280 + 281 + { 282 + const lastCall = fetch.mock.lastCall!; 283 + const request = new Request(lastCall[0], lastCall[1]); 284 + 285 + expect(request.url).includes('/xrpc/com.atproto.server.getSession'); 286 + } 287 + }); 288 + 289 + it('can logout', async () => { 290 + const onDelete = vi.fn(); 291 + 292 + const session = await PasswordSession.login( 293 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 294 + { onDelete }, 295 + ); 296 + 297 + expect(session.destroyed).toBe(false); 298 + 299 + await session.logout(); 300 + 301 + expect(session.destroyed).toBe(true); 302 + expect(onDelete).toHaveBeenCalledOnce(); 303 + 304 + expect(() => session.session).toThrow(); 305 + expect(() => session.did).toThrow(); 306 + }); 307 + 308 + it('preserves session on logout network failure', async () => { 309 + const originalFetch = globalThis.fetch; 310 + const fetch = vi.fn(globalThis.fetch); 311 + const onDelete = vi.fn(); 312 + const onDeleteFailure = vi.fn(); 313 + 314 + const session = await PasswordSession.login( 315 + { service: network.pds.url, identifier: 'user1.test', password: 'password' }, 316 + { fetch, onDelete, onDeleteFailure }, 317 + ); 318 + 319 + await fetch.withImplementation( 320 + (input, init) => { 321 + const request = new Request(input, init); 322 + 323 + if (request.url.includes('/xrpc/com.atproto.server.deleteSession')) { 324 + return Promise.resolve(new Response(undefined, { status: 500 })); 325 + } 326 + 327 + return originalFetch(request); 328 + }, 329 + async () => { 330 + await expect(session.logout()).rejects.toThrow(); 331 + }, 332 + ); 333 + 334 + expect(session.destroyed).toBe(false); 335 + expect(onDelete).not.toHaveBeenCalled(); 336 + expect(onDeleteFailure).toHaveBeenCalledOnce(); 337 + }); 338 + 339 + it('can delete session without resuming', async () => { 340 + const session = await PasswordSession.login({ 341 + service: network.pds.url, 342 + identifier: 'user1.test', 343 + password: 'password', 344 + }); 345 + const savedSession = session.session; 346 + 347 + await PasswordSession.delete(savedSession); 348 + }); 349 + }); 350 + 351 + const createAccount = async (rpc: Client, handle: Handle) => { 352 + await ok( 353 + rpc.post('com.atproto.server.createAccount', { 354 + input: { 355 + handle: handle, 356 + email: `user@test.com`, 357 + password: `password`, 358 + }, 359 + }), 360 + ); 361 + }; 362 + 363 + const sleep = (ms: number) => { 364 + return new Promise((resolve) => setTimeout(resolve, ms)); 365 + };
+1
packages/clients/password-session/lib/index.ts
··· 1 + export * from './password-session.ts';
+558
packages/clients/password-session/lib/password-session.ts
··· 1 + import type { ComAtprotoServerCreateSession } from '@atcute/atproto'; 2 + import { 3 + Client, 4 + ClientResponseError, 5 + isXRPCErrorPayload, 6 + ok, 7 + simpleFetchHandler, 8 + type FetchHandlerObject, 9 + } from '@atcute/client'; 10 + import { getPdsEndpoint, type DidDocument } from '@atcute/identity'; 11 + import type { Did } from '@atcute/lexicons'; 12 + 13 + // #region session data 14 + 15 + /** persistable session data */ 16 + export interface PasswordSessionData { 17 + /** authentication service URL */ 18 + service: string; 19 + accessJwt: string; 20 + refreshJwt: string; 21 + handle: string; 22 + did: Did; 23 + /** PDS endpoint derived from DID document */ 24 + pdsUri?: string; 25 + email?: string; 26 + emailConfirmed?: boolean; 27 + emailAuthFactor?: boolean; 28 + active: boolean; 29 + inactiveStatus?: string; 30 + } 31 + 32 + // #endregion 33 + 34 + // #region options 35 + 36 + export interface PasswordSessionOptions { 37 + /** custom fetch implementation */ 38 + fetch?: typeof fetch; 39 + 40 + /** 41 + * called when session is successfully created or refreshed with new 42 + * credentials. use this to persist the updated session. 43 + * receives `this: PasswordSession` context. 44 + * @note must not throw 45 + */ 46 + onUpdate?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>; 47 + 48 + /** 49 + * called when a session refresh fails due to a transient error (network, 50 + * server down). the session is preserved — consider retry logic. 51 + * @note must not throw 52 + */ 53 + onUpdateFailure?: ( 54 + this: PasswordSession, 55 + data: PasswordSessionData, 56 + error: unknown, 57 + ) => void | Promise<void>; 58 + 59 + /** 60 + * called when the session is terminated — either explicit logout or 61 + * server-side invalidation (expired/invalid refresh token). 62 + * use this to clean up persisted session data. 63 + * @note must not throw 64 + */ 65 + onDelete?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>; 66 + 67 + /** 68 + * called when logout network request fails due to a transient error. 69 + * the session stays active locally so you can retry. 70 + * @note must not throw 71 + */ 72 + onDeleteFailure?: ( 73 + this: PasswordSession, 74 + data: PasswordSessionData, 75 + error: unknown, 76 + ) => void | Promise<void>; 77 + } 78 + 79 + /** credentials for login */ 80 + export interface PasswordSessionLoginCredentials { 81 + service: string; 82 + identifier: string; 83 + password: string; 84 + /** two-factor authentication code */ 85 + code?: string; 86 + /** allow signing in even if the account has been taken down */ 87 + allowTakendown?: boolean; 88 + } 89 + 90 + /** options for login — second parameter, behavior config */ 91 + export interface PasswordSessionLoginOptions extends PasswordSessionOptions { 92 + /** cached session to try resuming before falling back to fresh login */ 93 + session?: PasswordSessionData; 94 + } 95 + 96 + // #endregion 97 + 98 + // #region class 99 + 100 + /** 101 + * password-based authentication session for AT Protocol services. 102 + * 103 + * manages access/refresh token lifecycle, automatic refresh on 401, and 104 + * session persistence via callbacks. instances are always in an authenticated 105 + * state — use the static factories for validated construction. 106 + * 107 + * for browser-based applications, prefer OAuth-based authentication instead. 108 + * when using password auth, use app passwords rather than main account credentials. 109 + */ 110 + export class PasswordSession implements FetchHandlerObject, AsyncDisposable { 111 + #sessionData: PasswordSessionData | null; 112 + #sessionPromise: Promise<PasswordSessionData>; 113 + #server: Client; 114 + #fetch: typeof fetch; 115 + 116 + #onUpdate: PasswordSessionOptions['onUpdate']; 117 + #onUpdateFailure: PasswordSessionOptions['onUpdateFailure']; 118 + #onDelete: PasswordSessionOptions['onDelete']; 119 + #onDeleteFailure: PasswordSessionOptions['onDeleteFailure']; 120 + 121 + /** 122 + * construct with existing session data. tokens refresh lazily on 401. 123 + * use static `login()` or `resume()` for validated sessions. 124 + * @param session existing session data 125 + * @param options session options 126 + */ 127 + constructor(session: PasswordSessionData, options: PasswordSessionOptions = {}) { 128 + this.#sessionData = session; 129 + this.#sessionPromise = Promise.resolve(session); 130 + this.#fetch = options.fetch ?? fetch; 131 + 132 + this.#server = new Client({ 133 + handler: simpleFetchHandler({ service: session.service, fetch: this.#fetch }), 134 + }); 135 + 136 + this.#onUpdate = options.onUpdate; 137 + this.#onUpdateFailure = options.onUpdateFailure; 138 + this.#onDelete = options.onDelete; 139 + this.#onDeleteFailure = options.onDeleteFailure; 140 + } 141 + 142 + /** 143 + * account DID 144 + * @throws if the session has been destroyed 145 + */ 146 + get did(): Did { 147 + return this.session.did; 148 + } 149 + 150 + /** whether this session has been destroyed (logged out) */ 151 + get destroyed(): boolean { 152 + return this.#sessionData === null; 153 + } 154 + 155 + /** 156 + * current session data — serialize this for persistence 157 + * @throws if the session has been destroyed 158 + */ 159 + get session(): PasswordSessionData { 160 + if (this.#sessionData) { 161 + return this.#sessionData; 162 + } 163 + throw new Error(`session has been destroyed`); 164 + } 165 + 166 + /** URL to dispatch API requests to (PDS from DID doc, or service URL) */ 167 + get dispatchUrl(): string { 168 + return this.session.pdsUri ?? this.session.service; 169 + } 170 + 171 + // --- static factories --- 172 + 173 + /** 174 + * authenticate with credentials. optionally tries resuming a cached 175 + * session first, falling back to fresh createSession on failure. 176 + * @param credentials login credentials or URL shorthand (`https://handle:pass@service`) 177 + * @param options login options 178 + * @returns authenticated session 179 + */ 180 + static async login( 181 + credentials: PasswordSessionLoginCredentials | string | URL, 182 + options: PasswordSessionLoginOptions = {}, 183 + ): Promise<PasswordSession> { 184 + const creds = 185 + typeof credentials === 'string' || credentials instanceof URL 186 + ? parseLoginUrl(credentials) 187 + : credentials; 188 + 189 + // try cached session first if provided 190 + if (options.session) { 191 + try { 192 + return await PasswordSession.resume(options.session, options); 193 + } catch { 194 + // fall through to fresh login 195 + } 196 + } 197 + 198 + const _fetch = options.fetch ?? fetch; 199 + const server = new Client({ 200 + handler: simpleFetchHandler({ service: creds.service, fetch: _fetch }), 201 + }); 202 + 203 + const data = await ok( 204 + server.post('com.atproto.server.createSession', { 205 + input: { 206 + identifier: creds.identifier, 207 + password: creds.password, 208 + authFactorToken: creds.code, 209 + allowTakendown: creds.allowTakendown, 210 + }, 211 + }), 212 + ); 213 + 214 + const sessionData = buildSessionData(creds.service, data); 215 + const session = new PasswordSession(sessionData, options); 216 + await options.onUpdate?.call(session, sessionData); 217 + return session; 218 + } 219 + 220 + /** 221 + * resume from persisted session data. if the access token is still valid, 222 + * returns immediately and refreshes metadata in the background. 223 + * if expired, refreshes synchronously. throws only if the session is 224 + * definitively invalid. 225 + * @param session persisted session data 226 + * @param options session options 227 + * @returns resumed session 228 + */ 229 + static async resume( 230 + session: PasswordSessionData, 231 + options: PasswordSessionOptions = {}, 232 + ): Promise<PasswordSession> { 233 + const instance = new PasswordSession(session, options); 234 + 235 + const now = Date.now() / 1_000 + 60 * 5; 236 + const accessToken = decodeJwt(session.accessJwt) as { exp: number }; 237 + 238 + if (now >= accessToken.exp) { 239 + // access token expired or expiring soon, refresh synchronously 240 + await instance.refresh(); 241 + } else { 242 + // access token still valid, fetch session metadata in background 243 + instance.#refreshMetadata(session); 244 + } 245 + 246 + if (instance.destroyed) { 247 + throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } }); 248 + } 249 + 250 + return instance; 251 + } 252 + 253 + /** 254 + * delete a session server-side without resuming it. 255 + * useful for cleanup of orphaned sessions. 256 + * @param session session data to delete 257 + * @param options session options 258 + */ 259 + static async delete(session: PasswordSessionData, options: PasswordSessionOptions = {}): Promise<void> { 260 + const instance = new PasswordSession(session, options); 261 + await instance.logout(); 262 + } 263 + 264 + // --- lifecycle --- 265 + 266 + /** refresh the session tokens */ 267 + async refresh(): Promise<void> { 268 + await this.#refresh(); 269 + } 270 + 271 + /** 272 + * sign out — invalidates session server-side. 273 + * on success, the session is destroyed and `onDelete` is called. 274 + * on transient failure (network), `onDeleteFailure` is called and 275 + * the session stays active for retry. 276 + * @throws on transient failure when the session couldn't be deleted 277 + */ 278 + async logout(): Promise<void> { 279 + let failure: unknown = null; 280 + 281 + this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => { 282 + const response = await this.#server.post('com.atproto.server.deleteSession', { 283 + as: null, 284 + headers: { 285 + authorization: `Bearer ${sessionData.refreshJwt}`, 286 + }, 287 + }); 288 + 289 + if (!response.ok) { 290 + const isExpected = 291 + response.status === 401 || 292 + response.data.error === 'InvalidToken' || 293 + response.data.error === 'ExpiredToken'; 294 + 295 + if (!isExpected) { 296 + // transient error — keep session alive 297 + failure = new ClientResponseError(response); 298 + await this.#onDeleteFailure?.(sessionData, failure); 299 + return sessionData; 300 + } 301 + } 302 + 303 + // success or expected error → session is gone 304 + await this.#onDelete?.(sessionData); 305 + this.#sessionData = null; 306 + throw new Error(`session has been destroyed`); 307 + }); 308 + 309 + return this.#sessionPromise.then( 310 + () => { 311 + // resolved means logout failed (transient error) 312 + throw failure!; 313 + }, 314 + () => { 315 + // rejected means session was destroyed (successful logout) 316 + }, 317 + ); 318 + } 319 + 320 + /** AsyncDisposable — calls `logout()` */ 321 + async [Symbol.asyncDispose](): Promise<void> { 322 + await this.logout(); 323 + } 324 + 325 + // --- FetchHandlerObject --- 326 + 327 + async handle(pathname: string, init: RequestInit): Promise<Response> { 328 + const sessionPromise = this.#sessionPromise; 329 + const sessionData = await sessionPromise; 330 + 331 + const url = new URL(pathname, sessionData.pdsUri ?? sessionData.service); 332 + const headers = new Headers(init.headers); 333 + 334 + if (headers.has('authorization')) { 335 + return (0, this.#fetch)(url, init); 336 + } 337 + 338 + headers.set('authorization', `Bearer ${sessionData.accessJwt}`); 339 + 340 + const initialResponse = await (0, this.#fetch)(url, { ...init, headers }); 341 + 342 + if (initialResponse.status !== 401 && !(await isExpiredTokenResponse(initialResponse))) { 343 + return initialResponse; 344 + } 345 + 346 + // refresh unless another call already started one 347 + const refreshPromise = 348 + this.#sessionPromise === sessionPromise ? this.#refresh() : this.#sessionPromise; 349 + 350 + const newSessionData = await refreshPromise.catch(() => null); 351 + 352 + if ( 353 + !newSessionData || 354 + newSessionData.accessJwt === sessionData.accessJwt || 355 + init.signal?.aborted || 356 + init.body instanceof ReadableStream 357 + ) { 358 + return initialResponse; 359 + } 360 + 361 + // cancel initial response to avoid resource leaks 362 + if (!initialResponse.bodyUsed) { 363 + await initialResponse.body?.cancel(); 364 + } 365 + 366 + headers.set('authorization', `Bearer ${newSessionData.accessJwt}`); 367 + return await (0, this.#fetch)(url, { ...init, headers }); 368 + } 369 + 370 + // --- internal --- 371 + 372 + #refresh(): Promise<PasswordSessionData> { 373 + this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => { 374 + const response = await this.#server.post('com.atproto.server.refreshSession', { 375 + headers: { 376 + authorization: `Bearer ${sessionData.refreshJwt}`, 377 + }, 378 + }); 379 + 380 + if (!response.ok) { 381 + const isExpected = 382 + response.status === 401 || 383 + response.data.error === 'ExpiredToken' || 384 + response.data.error === 'InvalidToken'; 385 + 386 + if (isExpected) { 387 + await this.#onDelete?.(sessionData); 388 + this.#sessionData = null; 389 + throw new ClientResponseError(response); 390 + } 391 + 392 + // transient error — preserve session 393 + await this.#onUpdateFailure?.(sessionData, new ClientResponseError(response)); 394 + return sessionData; 395 + } 396 + 397 + // DID must not change during refresh 398 + if (response.data.did !== sessionData.did) { 399 + await this.#onDelete?.(sessionData); 400 + this.#sessionData = null; 401 + throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } }); 402 + } 403 + 404 + const newSession = buildSessionData(sessionData.service, { ...sessionData, ...response.data }); 405 + this.#sessionData = newSession; 406 + await this.#onUpdate?.(newSession); 407 + return newSession; 408 + }); 409 + 410 + return this.#sessionPromise; 411 + } 412 + 413 + #refreshMetadata(session: PasswordSessionData): void { 414 + const promise = ok( 415 + this.#server.get('com.atproto.server.getSession', { 416 + headers: { 417 + authorization: `Bearer ${session.accessJwt}`, 418 + }, 419 + }), 420 + ); 421 + 422 + promise.then( 423 + (next) => { 424 + const existing = this.#sessionData; 425 + if (!existing || existing.did !== next.did) { 426 + return; 427 + } 428 + 429 + const updated = buildSessionData(existing.service, { ...existing, ...next }); 430 + this.#sessionData = updated; 431 + this.#onUpdate?.(updated); 432 + }, 433 + () => { 434 + // ignore background metadata fetch errors 435 + }, 436 + ); 437 + } 438 + } 439 + 440 + // #endregion 441 + 442 + // #region helpers 443 + 444 + const buildSessionData = ( 445 + service: string, 446 + raw: ComAtprotoServerCreateSession.$output & { pdsUri?: string }, 447 + ): PasswordSessionData => { 448 + const didDoc = raw.didDoc as DidDocument | undefined; 449 + 450 + let pdsUri = raw.pdsUri; 451 + if (didDoc) { 452 + pdsUri = getPdsEndpoint(didDoc) ?? pdsUri; 453 + } 454 + 455 + return { 456 + service, 457 + accessJwt: raw.accessJwt, 458 + refreshJwt: raw.refreshJwt, 459 + handle: raw.handle, 460 + did: raw.did, 461 + pdsUri, 462 + email: raw.email, 463 + emailConfirmed: raw.emailConfirmed, 464 + emailAuthFactor: raw.emailAuthFactor, 465 + active: raw.active ?? true, 466 + inactiveStatus: raw.status, 467 + }; 468 + }; 469 + 470 + /** 471 + * parse a login URL into credentials. 472 + * format: `https://identifier:password@service` 473 + * @param input URL string or URL object 474 + * @returns parsed credentials 475 + */ 476 + const parseLoginUrl = (input: string | URL): PasswordSessionLoginCredentials => { 477 + const url = typeof input === 'string' ? new URL(input) : input; 478 + 479 + if (url.pathname !== '/') { 480 + throw new TypeError(`invalid login URL: unexpected pathname`); 481 + } 482 + if (url.hash) { 483 + throw new TypeError(`invalid login URL: unexpected hash`); 484 + } 485 + if (url.search) { 486 + throw new TypeError(`invalid login URL: unexpected search parameters`); 487 + } 488 + if (!url.username || !url.password) { 489 + throw new TypeError(`invalid login URL: missing identifier or password`); 490 + } 491 + 492 + return { 493 + service: url.origin, 494 + identifier: decodeURIComponent(url.username), 495 + password: decodeURIComponent(url.password), 496 + }; 497 + }; 498 + 499 + /** decode a JWT token's payload */ 500 + const decodeJwt = (token: string): unknown => { 501 + const part = token.split('.')[1]; 502 + if (typeof part !== 'string') { 503 + throw new Error(`invalid token: missing part 2`); 504 + } 505 + 506 + let b64 = part.replace(/-/g, '+').replace(/_/g, '/'); 507 + switch (b64.length % 4) { 508 + case 0: 509 + break; 510 + case 2: 511 + b64 += '=='; 512 + break; 513 + case 3: 514 + b64 += '='; 515 + break; 516 + default: 517 + throw new Error(`invalid token: invalid base64 length`); 518 + } 519 + 520 + return JSON.parse(atob(b64)); 521 + }; 522 + 523 + const isExpiredTokenResponse = async (response: Response): Promise<boolean> => { 524 + if (response.status !== 400) { 525 + return false; 526 + } 527 + 528 + if (extractContentType(response.headers) !== 'application/json') { 529 + return false; 530 + } 531 + 532 + // this is nasty as it relies heavily on what the PDS returns, but avoiding 533 + // cloning and reading the request as much as possible is better. 534 + 535 + // {"error":"ExpiredToken","message":"Token has expired"} 536 + // {"error":"ExpiredToken","message":"Token is expired"} 537 + if (extractContentLength(response.headers) > 54 * 1.5) { 538 + return false; 539 + } 540 + 541 + try { 542 + const data = await response.clone().json(); 543 + if (isXRPCErrorPayload(data)) { 544 + return data.error === 'ExpiredToken'; 545 + } 546 + } catch {} 547 + 548 + return false; 549 + }; 550 + 551 + const extractContentType = (headers: Headers) => { 552 + return headers.get('content-type')?.split(';')[0]?.trim(); 553 + }; 554 + const extractContentLength = (headers: Headers) => { 555 + return Number(headers.get('content-length') ?? ';'); 556 + }; 557 + 558 + // #endregion
+39
packages/clients/password-session/package.json
··· 1 + { 2 + "name": "@atcute/password-session", 3 + "version": "0.1.0", 4 + "description": "password-based session management for AT Protocol", 5 + "license": "0BSD", 6 + "repository": { 7 + "url": "https://github.com/mary-ext/atcute", 8 + "directory": "packages/clients/password-session" 9 + }, 10 + "files": [ 11 + "dist/", 12 + "lib/", 13 + "!lib/**/*.bench.ts", 14 + "!lib/**/*.test.ts" 15 + ], 16 + "type": "module", 17 + "exports": { 18 + ".": "./dist/index.js" 19 + }, 20 + "publishConfig": { 21 + "access": "public" 22 + }, 23 + "scripts": { 24 + "build": "tsgo --project tsconfig.build.json", 25 + "test": "vitest run", 26 + "prepublish": "rm -rf dist; pnpm run build" 27 + }, 28 + "dependencies": { 29 + "@atcute/client": "workspace:^", 30 + "@atcute/identity": "workspace:^", 31 + "@atcute/lexicons": "workspace:^" 32 + }, 33 + "devDependencies": { 34 + "@atcute/atproto": "workspace:^", 35 + "@atcute/bluesky": "workspace:^", 36 + "@atcute/internal-dev-env": "workspace:^", 37 + "vitest": "^4.0.18" 38 + } 39 + }
+4
packages/clients/password-session/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["**/*.test.ts"] 4 + }
+27
packages/clients/password-session/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "rootDir": "lib/", 4 + "outDir": "dist/", 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "allowImportingTsExtensions": true, 14 + "rewriteRelativeImportExtensions": true, 15 + "strict": true, 16 + "noImplicitOverride": true, 17 + "noUnusedLocals": true, 18 + "noUnusedParameters": true, 19 + "useDefineForClassFields": false, 20 + "noFallthroughCasesInSwitch": true, 21 + "module": "NodeNext", 22 + "sourceMap": true, 23 + "declaration": true, 24 + "declarationMap": true 25 + }, 26 + "include": ["lib"] 27 + }
+25
pnpm-lock.yaml
··· 214 214 specifier: ^4.41.0 215 215 version: 4.41.0 216 216 217 + packages/clients/password-session: 218 + dependencies: 219 + '@atcute/client': 220 + specifier: workspace:^ 221 + version: link:../client 222 + '@atcute/identity': 223 + specifier: workspace:^ 224 + version: link:../../identity/identity 225 + '@atcute/lexicons': 226 + specifier: workspace:^ 227 + version: link:../../lexicons/lexicons 228 + devDependencies: 229 + '@atcute/atproto': 230 + specifier: workspace:^ 231 + version: link:../../definitions/atproto 232 + '@atcute/bluesky': 233 + specifier: workspace:^ 234 + version: link:../../definitions/bluesky 235 + '@atcute/internal-dev-env': 236 + specifier: workspace:^ 237 + version: link:../../internal/dev-env 238 + vitest: 239 + specifier: ^4.0.18 240 + version: 4.0.18(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 241 + 217 242 packages/clients/tap: 218 243 dependencies: 219 244 '@atcute/identity':