@atcute/password-session 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2025 Mary
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
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.
@@ -0,0 +1,2 @@
1
+ export * from './password-session.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './password-session.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,136 @@
1
+ import { type FetchHandlerObject } from '@atcute/client';
2
+ import type { Did } from '@atcute/lexicons';
3
+ /** persistable session data */
4
+ export interface PasswordSessionData {
5
+ /** authentication service URL */
6
+ service: string;
7
+ accessJwt: string;
8
+ refreshJwt: string;
9
+ handle: string;
10
+ did: Did;
11
+ /** PDS endpoint derived from DID document */
12
+ pdsUri?: string;
13
+ email?: string;
14
+ emailConfirmed?: boolean;
15
+ emailAuthFactor?: boolean;
16
+ active: boolean;
17
+ inactiveStatus?: string;
18
+ }
19
+ export interface PasswordSessionOptions {
20
+ /** custom fetch implementation */
21
+ fetch?: typeof fetch;
22
+ /**
23
+ * called when session is successfully created or refreshed with new
24
+ * credentials. use this to persist the updated session.
25
+ * receives `this: PasswordSession` context.
26
+ * @note must not throw
27
+ */
28
+ onUpdate?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
29
+ /**
30
+ * called when a session refresh fails due to a transient error (network,
31
+ * server down). the session is preserved — consider retry logic.
32
+ * @note must not throw
33
+ */
34
+ onUpdateFailure?: (this: PasswordSession, data: PasswordSessionData, error: unknown) => void | Promise<void>;
35
+ /**
36
+ * called when the session is terminated — either explicit logout or
37
+ * server-side invalidation (expired/invalid refresh token).
38
+ * use this to clean up persisted session data.
39
+ * @note must not throw
40
+ */
41
+ onDelete?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
42
+ /**
43
+ * called when logout network request fails due to a transient error.
44
+ * the session stays active locally so you can retry.
45
+ * @note must not throw
46
+ */
47
+ onDeleteFailure?: (this: PasswordSession, data: PasswordSessionData, error: unknown) => void | Promise<void>;
48
+ }
49
+ /** credentials for login */
50
+ export interface PasswordSessionLoginCredentials {
51
+ service: string;
52
+ identifier: string;
53
+ password: string;
54
+ /** two-factor authentication code */
55
+ code?: string;
56
+ /** allow signing in even if the account has been taken down */
57
+ allowTakendown?: boolean;
58
+ }
59
+ /** options for login — second parameter, behavior config */
60
+ export interface PasswordSessionLoginOptions extends PasswordSessionOptions {
61
+ /** cached session to try resuming before falling back to fresh login */
62
+ session?: PasswordSessionData;
63
+ }
64
+ /**
65
+ * password-based authentication session for AT Protocol services.
66
+ *
67
+ * manages access/refresh token lifecycle, automatic refresh on 401, and
68
+ * session persistence via callbacks. instances are always in an authenticated
69
+ * state — use the static factories for validated construction.
70
+ *
71
+ * for browser-based applications, prefer OAuth-based authentication instead.
72
+ * when using password auth, use app passwords rather than main account credentials.
73
+ */
74
+ export declare class PasswordSession implements FetchHandlerObject, AsyncDisposable {
75
+ #private;
76
+ /**
77
+ * construct with existing session data. tokens refresh lazily on 401.
78
+ * use static `login()` or `resume()` for validated sessions.
79
+ * @param session existing session data
80
+ * @param options session options
81
+ */
82
+ constructor(session: PasswordSessionData, options?: PasswordSessionOptions);
83
+ /**
84
+ * account DID
85
+ * @throws if the session has been destroyed
86
+ */
87
+ get did(): Did;
88
+ /** whether this session has been destroyed (logged out) */
89
+ get destroyed(): boolean;
90
+ /**
91
+ * current session data — serialize this for persistence
92
+ * @throws if the session has been destroyed
93
+ */
94
+ get session(): PasswordSessionData;
95
+ /** URL to dispatch API requests to (PDS from DID doc, or service URL) */
96
+ get dispatchUrl(): string;
97
+ /**
98
+ * authenticate with credentials. optionally tries resuming a cached
99
+ * session first, falling back to fresh createSession on failure.
100
+ * @param credentials login credentials or URL shorthand (`https://handle:pass@service`)
101
+ * @param options login options
102
+ * @returns authenticated session
103
+ */
104
+ static login(credentials: PasswordSessionLoginCredentials | string | URL, options?: PasswordSessionLoginOptions): Promise<PasswordSession>;
105
+ /**
106
+ * resume from persisted session data. if the access token is still valid,
107
+ * returns immediately and refreshes metadata in the background.
108
+ * if expired, refreshes synchronously. throws only if the session is
109
+ * definitively invalid.
110
+ * @param session persisted session data
111
+ * @param options session options
112
+ * @returns resumed session
113
+ */
114
+ static resume(session: PasswordSessionData, options?: PasswordSessionOptions): Promise<PasswordSession>;
115
+ /**
116
+ * delete a session server-side without resuming it.
117
+ * useful for cleanup of orphaned sessions.
118
+ * @param session session data to delete
119
+ * @param options session options
120
+ */
121
+ static delete(session: PasswordSessionData, options?: PasswordSessionOptions): Promise<void>;
122
+ /** refresh the session tokens */
123
+ refresh(): Promise<void>;
124
+ /**
125
+ * sign out — invalidates session server-side.
126
+ * on success, the session is destroyed and `onDelete` is called.
127
+ * on transient failure (network), `onDeleteFailure` is called and
128
+ * the session stays active for retry.
129
+ * @throws on transient failure when the session couldn't be deleted
130
+ */
131
+ logout(): Promise<void>;
132
+ /** AsyncDisposable — calls `logout()` */
133
+ [Symbol.asyncDispose](): Promise<void>;
134
+ handle(pathname: string, init: RequestInit): Promise<Response>;
135
+ }
136
+ //# sourceMappingURL=password-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"password-session.d.ts","sourceRoot":"","sources":["../lib/password-session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,371 @@
1
+ import { Client, ClientResponseError, isXRPCErrorPayload, ok, simpleFetchHandler, } from '@atcute/client';
2
+ import { getPdsEndpoint } from '@atcute/identity';
3
+ // #endregion
4
+ // #region class
5
+ /**
6
+ * password-based authentication session for AT Protocol services.
7
+ *
8
+ * manages access/refresh token lifecycle, automatic refresh on 401, and
9
+ * session persistence via callbacks. instances are always in an authenticated
10
+ * state — use the static factories for validated construction.
11
+ *
12
+ * for browser-based applications, prefer OAuth-based authentication instead.
13
+ * when using password auth, use app passwords rather than main account credentials.
14
+ */
15
+ export class PasswordSession {
16
+ #sessionData;
17
+ #sessionPromise;
18
+ #server;
19
+ #fetch;
20
+ #onUpdate;
21
+ #onUpdateFailure;
22
+ #onDelete;
23
+ #onDeleteFailure;
24
+ /**
25
+ * construct with existing session data. tokens refresh lazily on 401.
26
+ * use static `login()` or `resume()` for validated sessions.
27
+ * @param session existing session data
28
+ * @param options session options
29
+ */
30
+ constructor(session, options = {}) {
31
+ this.#sessionData = session;
32
+ this.#sessionPromise = Promise.resolve(session);
33
+ this.#fetch = options.fetch ?? fetch;
34
+ this.#server = new Client({
35
+ handler: simpleFetchHandler({ service: session.service, fetch: this.#fetch }),
36
+ });
37
+ this.#onUpdate = options.onUpdate;
38
+ this.#onUpdateFailure = options.onUpdateFailure;
39
+ this.#onDelete = options.onDelete;
40
+ this.#onDeleteFailure = options.onDeleteFailure;
41
+ }
42
+ /**
43
+ * account DID
44
+ * @throws if the session has been destroyed
45
+ */
46
+ get did() {
47
+ return this.session.did;
48
+ }
49
+ /** whether this session has been destroyed (logged out) */
50
+ get destroyed() {
51
+ return this.#sessionData === null;
52
+ }
53
+ /**
54
+ * current session data — serialize this for persistence
55
+ * @throws if the session has been destroyed
56
+ */
57
+ get session() {
58
+ if (this.#sessionData) {
59
+ return this.#sessionData;
60
+ }
61
+ throw new Error(`session has been destroyed`);
62
+ }
63
+ /** URL to dispatch API requests to (PDS from DID doc, or service URL) */
64
+ get dispatchUrl() {
65
+ return this.session.pdsUri ?? this.session.service;
66
+ }
67
+ // --- static factories ---
68
+ /**
69
+ * authenticate with credentials. optionally tries resuming a cached
70
+ * session first, falling back to fresh createSession on failure.
71
+ * @param credentials login credentials or URL shorthand (`https://handle:pass@service`)
72
+ * @param options login options
73
+ * @returns authenticated session
74
+ */
75
+ static async login(credentials, options = {}) {
76
+ const creds = typeof credentials === 'string' || credentials instanceof URL
77
+ ? parseLoginUrl(credentials)
78
+ : credentials;
79
+ // try cached session first if provided
80
+ if (options.session) {
81
+ try {
82
+ return await PasswordSession.resume(options.session, options);
83
+ }
84
+ catch {
85
+ // fall through to fresh login
86
+ }
87
+ }
88
+ const _fetch = options.fetch ?? fetch;
89
+ const server = new Client({
90
+ handler: simpleFetchHandler({ service: creds.service, fetch: _fetch }),
91
+ });
92
+ const data = await ok(server.post('com.atproto.server.createSession', {
93
+ input: {
94
+ identifier: creds.identifier,
95
+ password: creds.password,
96
+ authFactorToken: creds.code,
97
+ allowTakendown: creds.allowTakendown,
98
+ },
99
+ }));
100
+ const sessionData = buildSessionData(creds.service, data);
101
+ const session = new PasswordSession(sessionData, options);
102
+ await options.onUpdate?.call(session, sessionData);
103
+ return session;
104
+ }
105
+ /**
106
+ * resume from persisted session data. if the access token is still valid,
107
+ * returns immediately and refreshes metadata in the background.
108
+ * if expired, refreshes synchronously. throws only if the session is
109
+ * definitively invalid.
110
+ * @param session persisted session data
111
+ * @param options session options
112
+ * @returns resumed session
113
+ */
114
+ static async resume(session, options = {}) {
115
+ const instance = new PasswordSession(session, options);
116
+ const now = Date.now() / 1_000 + 60 * 5;
117
+ const accessToken = decodeJwt(session.accessJwt);
118
+ if (now >= accessToken.exp) {
119
+ // access token expired or expiring soon, refresh synchronously
120
+ await instance.refresh();
121
+ }
122
+ else {
123
+ // access token still valid, fetch session metadata in background
124
+ instance.#refreshMetadata(session);
125
+ }
126
+ if (instance.destroyed) {
127
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
128
+ }
129
+ return instance;
130
+ }
131
+ /**
132
+ * delete a session server-side without resuming it.
133
+ * useful for cleanup of orphaned sessions.
134
+ * @param session session data to delete
135
+ * @param options session options
136
+ */
137
+ static async delete(session, options = {}) {
138
+ const instance = new PasswordSession(session, options);
139
+ await instance.logout();
140
+ }
141
+ // --- lifecycle ---
142
+ /** refresh the session tokens */
143
+ async refresh() {
144
+ await this.#refresh();
145
+ }
146
+ /**
147
+ * sign out — invalidates session server-side.
148
+ * on success, the session is destroyed and `onDelete` is called.
149
+ * on transient failure (network), `onDeleteFailure` is called and
150
+ * the session stays active for retry.
151
+ * @throws on transient failure when the session couldn't be deleted
152
+ */
153
+ async logout() {
154
+ let failure = null;
155
+ this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
156
+ const response = await this.#server.post('com.atproto.server.deleteSession', {
157
+ as: null,
158
+ headers: {
159
+ authorization: `Bearer ${sessionData.refreshJwt}`,
160
+ },
161
+ });
162
+ if (!response.ok) {
163
+ const isExpected = response.status === 401 ||
164
+ response.data.error === 'InvalidToken' ||
165
+ response.data.error === 'ExpiredToken';
166
+ if (!isExpected) {
167
+ // transient error — keep session alive
168
+ failure = new ClientResponseError(response);
169
+ await this.#onDeleteFailure?.(sessionData, failure);
170
+ return sessionData;
171
+ }
172
+ }
173
+ // success or expected error → session is gone
174
+ await this.#onDelete?.(sessionData);
175
+ this.#sessionData = null;
176
+ throw new Error(`session has been destroyed`);
177
+ });
178
+ return this.#sessionPromise.then(() => {
179
+ // resolved means logout failed (transient error)
180
+ throw failure;
181
+ }, () => {
182
+ // rejected means session was destroyed (successful logout)
183
+ });
184
+ }
185
+ /** AsyncDisposable — calls `logout()` */
186
+ async [Symbol.asyncDispose]() {
187
+ await this.logout();
188
+ }
189
+ // --- FetchHandlerObject ---
190
+ async handle(pathname, init) {
191
+ const sessionPromise = this.#sessionPromise;
192
+ const sessionData = await sessionPromise;
193
+ const url = new URL(pathname, sessionData.pdsUri ?? sessionData.service);
194
+ const headers = new Headers(init.headers);
195
+ if (headers.has('authorization')) {
196
+ return (0, this.#fetch)(url, init);
197
+ }
198
+ headers.set('authorization', `Bearer ${sessionData.accessJwt}`);
199
+ const initialResponse = await (0, this.#fetch)(url, { ...init, headers });
200
+ if (initialResponse.status !== 401 && !(await isExpiredTokenResponse(initialResponse))) {
201
+ return initialResponse;
202
+ }
203
+ // refresh unless another call already started one
204
+ const refreshPromise = this.#sessionPromise === sessionPromise ? this.#refresh() : this.#sessionPromise;
205
+ const newSessionData = await refreshPromise.catch(() => null);
206
+ if (!newSessionData ||
207
+ newSessionData.accessJwt === sessionData.accessJwt ||
208
+ init.signal?.aborted ||
209
+ init.body instanceof ReadableStream) {
210
+ return initialResponse;
211
+ }
212
+ // cancel initial response to avoid resource leaks
213
+ if (!initialResponse.bodyUsed) {
214
+ await initialResponse.body?.cancel();
215
+ }
216
+ headers.set('authorization', `Bearer ${newSessionData.accessJwt}`);
217
+ return await (0, this.#fetch)(url, { ...init, headers });
218
+ }
219
+ // --- internal ---
220
+ #refresh() {
221
+ this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
222
+ const response = await this.#server.post('com.atproto.server.refreshSession', {
223
+ headers: {
224
+ authorization: `Bearer ${sessionData.refreshJwt}`,
225
+ },
226
+ });
227
+ if (!response.ok) {
228
+ const isExpected = response.status === 401 ||
229
+ response.data.error === 'ExpiredToken' ||
230
+ response.data.error === 'InvalidToken';
231
+ if (isExpected) {
232
+ await this.#onDelete?.(sessionData);
233
+ this.#sessionData = null;
234
+ throw new ClientResponseError(response);
235
+ }
236
+ // transient error — preserve session
237
+ await this.#onUpdateFailure?.(sessionData, new ClientResponseError(response));
238
+ return sessionData;
239
+ }
240
+ // DID must not change during refresh
241
+ if (response.data.did !== sessionData.did) {
242
+ await this.#onDelete?.(sessionData);
243
+ this.#sessionData = null;
244
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
245
+ }
246
+ const newSession = buildSessionData(sessionData.service, { ...sessionData, ...response.data });
247
+ this.#sessionData = newSession;
248
+ await this.#onUpdate?.(newSession);
249
+ return newSession;
250
+ });
251
+ return this.#sessionPromise;
252
+ }
253
+ #refreshMetadata(session) {
254
+ const promise = ok(this.#server.get('com.atproto.server.getSession', {
255
+ headers: {
256
+ authorization: `Bearer ${session.accessJwt}`,
257
+ },
258
+ }));
259
+ promise.then((next) => {
260
+ const existing = this.#sessionData;
261
+ if (!existing || existing.did !== next.did) {
262
+ return;
263
+ }
264
+ const updated = buildSessionData(existing.service, { ...existing, ...next });
265
+ this.#sessionData = updated;
266
+ this.#onUpdate?.(updated);
267
+ }, () => {
268
+ // ignore background metadata fetch errors
269
+ });
270
+ }
271
+ }
272
+ // #endregion
273
+ // #region helpers
274
+ const buildSessionData = (service, raw) => {
275
+ const didDoc = raw.didDoc;
276
+ let pdsUri = raw.pdsUri;
277
+ if (didDoc) {
278
+ pdsUri = getPdsEndpoint(didDoc) ?? pdsUri;
279
+ }
280
+ return {
281
+ service,
282
+ accessJwt: raw.accessJwt,
283
+ refreshJwt: raw.refreshJwt,
284
+ handle: raw.handle,
285
+ did: raw.did,
286
+ pdsUri,
287
+ email: raw.email,
288
+ emailConfirmed: raw.emailConfirmed,
289
+ emailAuthFactor: raw.emailAuthFactor,
290
+ active: raw.active ?? true,
291
+ inactiveStatus: raw.status,
292
+ };
293
+ };
294
+ /**
295
+ * parse a login URL into credentials.
296
+ * format: `https://identifier:password@service`
297
+ * @param input URL string or URL object
298
+ * @returns parsed credentials
299
+ */
300
+ const parseLoginUrl = (input) => {
301
+ const url = typeof input === 'string' ? new URL(input) : input;
302
+ if (url.pathname !== '/') {
303
+ throw new TypeError(`invalid login URL: unexpected pathname`);
304
+ }
305
+ if (url.hash) {
306
+ throw new TypeError(`invalid login URL: unexpected hash`);
307
+ }
308
+ if (url.search) {
309
+ throw new TypeError(`invalid login URL: unexpected search parameters`);
310
+ }
311
+ if (!url.username || !url.password) {
312
+ throw new TypeError(`invalid login URL: missing identifier or password`);
313
+ }
314
+ return {
315
+ service: url.origin,
316
+ identifier: decodeURIComponent(url.username),
317
+ password: decodeURIComponent(url.password),
318
+ };
319
+ };
320
+ /** decode a JWT token's payload */
321
+ const decodeJwt = (token) => {
322
+ const part = token.split('.')[1];
323
+ if (typeof part !== 'string') {
324
+ throw new Error(`invalid token: missing part 2`);
325
+ }
326
+ let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
327
+ switch (b64.length % 4) {
328
+ case 0:
329
+ break;
330
+ case 2:
331
+ b64 += '==';
332
+ break;
333
+ case 3:
334
+ b64 += '=';
335
+ break;
336
+ default:
337
+ throw new Error(`invalid token: invalid base64 length`);
338
+ }
339
+ return JSON.parse(atob(b64));
340
+ };
341
+ const isExpiredTokenResponse = async (response) => {
342
+ if (response.status !== 400) {
343
+ return false;
344
+ }
345
+ if (extractContentType(response.headers) !== 'application/json') {
346
+ return false;
347
+ }
348
+ // this is nasty as it relies heavily on what the PDS returns, but avoiding
349
+ // cloning and reading the request as much as possible is better.
350
+ // {"error":"ExpiredToken","message":"Token has expired"}
351
+ // {"error":"ExpiredToken","message":"Token is expired"}
352
+ if (extractContentLength(response.headers) > 54 * 1.5) {
353
+ return false;
354
+ }
355
+ try {
356
+ const data = await response.clone().json();
357
+ if (isXRPCErrorPayload(data)) {
358
+ return data.error === 'ExpiredToken';
359
+ }
360
+ }
361
+ catch { }
362
+ return false;
363
+ };
364
+ const extractContentType = (headers) => {
365
+ return headers.get('content-type')?.split(';')[0]?.trim();
366
+ };
367
+ const extractContentLength = (headers) => {
368
+ return Number(headers.get('content-length') ?? ';');
369
+ };
370
+ // #endregion
371
+ //# sourceMappingURL=password-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"password-session.js","sourceRoot":"","sources":["../lib/password-session.ts"],"names":[],"mappings":"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"}
package/lib/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './password-session.ts';
@@ -0,0 +1,557 @@
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 = this.#sessionPromise === sessionPromise ? this.#refresh() : this.#sessionPromise;
348
+
349
+ const newSessionData = await refreshPromise.catch(() => null);
350
+
351
+ if (
352
+ !newSessionData ||
353
+ newSessionData.accessJwt === sessionData.accessJwt ||
354
+ init.signal?.aborted ||
355
+ init.body instanceof ReadableStream
356
+ ) {
357
+ return initialResponse;
358
+ }
359
+
360
+ // cancel initial response to avoid resource leaks
361
+ if (!initialResponse.bodyUsed) {
362
+ await initialResponse.body?.cancel();
363
+ }
364
+
365
+ headers.set('authorization', `Bearer ${newSessionData.accessJwt}`);
366
+ return await (0, this.#fetch)(url, { ...init, headers });
367
+ }
368
+
369
+ // --- internal ---
370
+
371
+ #refresh(): Promise<PasswordSessionData> {
372
+ this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
373
+ const response = await this.#server.post('com.atproto.server.refreshSession', {
374
+ headers: {
375
+ authorization: `Bearer ${sessionData.refreshJwt}`,
376
+ },
377
+ });
378
+
379
+ if (!response.ok) {
380
+ const isExpected =
381
+ response.status === 401 ||
382
+ response.data.error === 'ExpiredToken' ||
383
+ response.data.error === 'InvalidToken';
384
+
385
+ if (isExpected) {
386
+ await this.#onDelete?.(sessionData);
387
+ this.#sessionData = null;
388
+ throw new ClientResponseError(response);
389
+ }
390
+
391
+ // transient error — preserve session
392
+ await this.#onUpdateFailure?.(sessionData, new ClientResponseError(response));
393
+ return sessionData;
394
+ }
395
+
396
+ // DID must not change during refresh
397
+ if (response.data.did !== sessionData.did) {
398
+ await this.#onDelete?.(sessionData);
399
+ this.#sessionData = null;
400
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
401
+ }
402
+
403
+ const newSession = buildSessionData(sessionData.service, { ...sessionData, ...response.data });
404
+ this.#sessionData = newSession;
405
+ await this.#onUpdate?.(newSession);
406
+ return newSession;
407
+ });
408
+
409
+ return this.#sessionPromise;
410
+ }
411
+
412
+ #refreshMetadata(session: PasswordSessionData): void {
413
+ const promise = ok(
414
+ this.#server.get('com.atproto.server.getSession', {
415
+ headers: {
416
+ authorization: `Bearer ${session.accessJwt}`,
417
+ },
418
+ }),
419
+ );
420
+
421
+ promise.then(
422
+ (next) => {
423
+ const existing = this.#sessionData;
424
+ if (!existing || existing.did !== next.did) {
425
+ return;
426
+ }
427
+
428
+ const updated = buildSessionData(existing.service, { ...existing, ...next });
429
+ this.#sessionData = updated;
430
+ this.#onUpdate?.(updated);
431
+ },
432
+ () => {
433
+ // ignore background metadata fetch errors
434
+ },
435
+ );
436
+ }
437
+ }
438
+
439
+ // #endregion
440
+
441
+ // #region helpers
442
+
443
+ const buildSessionData = (
444
+ service: string,
445
+ raw: ComAtprotoServerCreateSession.$output & { pdsUri?: string },
446
+ ): PasswordSessionData => {
447
+ const didDoc = raw.didDoc as DidDocument | undefined;
448
+
449
+ let pdsUri = raw.pdsUri;
450
+ if (didDoc) {
451
+ pdsUri = getPdsEndpoint(didDoc) ?? pdsUri;
452
+ }
453
+
454
+ return {
455
+ service,
456
+ accessJwt: raw.accessJwt,
457
+ refreshJwt: raw.refreshJwt,
458
+ handle: raw.handle,
459
+ did: raw.did,
460
+ pdsUri,
461
+ email: raw.email,
462
+ emailConfirmed: raw.emailConfirmed,
463
+ emailAuthFactor: raw.emailAuthFactor,
464
+ active: raw.active ?? true,
465
+ inactiveStatus: raw.status,
466
+ };
467
+ };
468
+
469
+ /**
470
+ * parse a login URL into credentials.
471
+ * format: `https://identifier:password@service`
472
+ * @param input URL string or URL object
473
+ * @returns parsed credentials
474
+ */
475
+ const parseLoginUrl = (input: string | URL): PasswordSessionLoginCredentials => {
476
+ const url = typeof input === 'string' ? new URL(input) : input;
477
+
478
+ if (url.pathname !== '/') {
479
+ throw new TypeError(`invalid login URL: unexpected pathname`);
480
+ }
481
+ if (url.hash) {
482
+ throw new TypeError(`invalid login URL: unexpected hash`);
483
+ }
484
+ if (url.search) {
485
+ throw new TypeError(`invalid login URL: unexpected search parameters`);
486
+ }
487
+ if (!url.username || !url.password) {
488
+ throw new TypeError(`invalid login URL: missing identifier or password`);
489
+ }
490
+
491
+ return {
492
+ service: url.origin,
493
+ identifier: decodeURIComponent(url.username),
494
+ password: decodeURIComponent(url.password),
495
+ };
496
+ };
497
+
498
+ /** decode a JWT token's payload */
499
+ const decodeJwt = (token: string): unknown => {
500
+ const part = token.split('.')[1];
501
+ if (typeof part !== 'string') {
502
+ throw new Error(`invalid token: missing part 2`);
503
+ }
504
+
505
+ let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
506
+ switch (b64.length % 4) {
507
+ case 0:
508
+ break;
509
+ case 2:
510
+ b64 += '==';
511
+ break;
512
+ case 3:
513
+ b64 += '=';
514
+ break;
515
+ default:
516
+ throw new Error(`invalid token: invalid base64 length`);
517
+ }
518
+
519
+ return JSON.parse(atob(b64));
520
+ };
521
+
522
+ const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
523
+ if (response.status !== 400) {
524
+ return false;
525
+ }
526
+
527
+ if (extractContentType(response.headers) !== 'application/json') {
528
+ return false;
529
+ }
530
+
531
+ // this is nasty as it relies heavily on what the PDS returns, but avoiding
532
+ // cloning and reading the request as much as possible is better.
533
+
534
+ // {"error":"ExpiredToken","message":"Token has expired"}
535
+ // {"error":"ExpiredToken","message":"Token is expired"}
536
+ if (extractContentLength(response.headers) > 54 * 1.5) {
537
+ return false;
538
+ }
539
+
540
+ try {
541
+ const data = await response.clone().json();
542
+ if (isXRPCErrorPayload(data)) {
543
+ return data.error === 'ExpiredToken';
544
+ }
545
+ } catch {}
546
+
547
+ return false;
548
+ };
549
+
550
+ const extractContentType = (headers: Headers) => {
551
+ return headers.get('content-type')?.split(';')[0]?.trim();
552
+ };
553
+ const extractContentLength = (headers: Headers) => {
554
+ return Number(headers.get('content-length') ?? ';');
555
+ };
556
+
557
+ // #endregion
package/package.json ADDED
@@ -0,0 +1,39 @@
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
+ "dependencies": {
24
+ "@atcute/client": "^4.2.1",
25
+ "@atcute/identity": "^1.1.3",
26
+ "@atcute/lexicons": "^1.2.9"
27
+ },
28
+ "devDependencies": {
29
+ "vitest": "^4.0.18",
30
+ "@atcute/atproto": "^3.1.10",
31
+ "@atcute/internal-dev-env": "^1.0.2",
32
+ "@atcute/bluesky": "^3.2.19"
33
+ },
34
+ "scripts": {
35
+ "build": "tsgo --project tsconfig.build.json",
36
+ "test": "vitest run",
37
+ "prepublish": "rm -rf dist; pnpm run build"
38
+ }
39
+ }