@atproto/lex-password-session 0.0.3 → 0.0.5

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.
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-namespace */
2
2
 
3
3
  import { afterAll, assert, beforeAll, describe, expect, it, vi } from 'vitest'
4
- import { Client, XrpcResponseError } from '@atproto/lex-client'
4
+ import { Client, XrpcAuthenticationError } from '@atproto/lex-client'
5
5
  import { l } from '@atproto/lex-schema'
6
6
  import { LexRouter, LexServerAuthError } from '@atproto/lex-server'
7
7
  import { Server, serve } from '@atproto/lex-server/nodejs'
@@ -171,7 +171,7 @@ describe(PasswordSession, () => {
171
171
  const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
172
172
 
173
173
  await expect(
174
- PasswordSession.create({
174
+ PasswordSession.login({
175
175
  ...defaultOptions,
176
176
  service: entrywayOrigin,
177
177
  identifier: 'alice',
@@ -181,7 +181,6 @@ describe(PasswordSession, () => {
181
181
  }),
182
182
  ).rejects.toMatchObject({
183
183
  success: false,
184
- status: 401,
185
184
  error: 'AuthenticationRequired',
186
185
  })
187
186
 
@@ -193,7 +192,7 @@ describe(PasswordSession, () => {
193
192
  const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
194
193
  const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
195
194
 
196
- const result = await PasswordSession.create({
195
+ const result = await PasswordSession.login({
197
196
  ...defaultOptions,
198
197
  service: entrywayOrigin,
199
198
  identifier: 'alice',
@@ -217,7 +216,7 @@ describe(PasswordSession, () => {
217
216
  const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
218
217
  const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
219
218
 
220
- const session = await PasswordSession.create({
219
+ const session = await PasswordSession.login({
221
220
  ...defaultOptions,
222
221
  service: entrywayOrigin,
223
222
  identifier: 'alice',
@@ -253,7 +252,12 @@ describe(PasswordSession, () => {
253
252
 
254
253
  await expect(
255
254
  client.call(app.example.customMethod, { message: 'hello' }),
256
- ).rejects.toThrow('Logged out')
255
+ ).rejects.toMatchObject({
256
+ message: 'Unable to fulfill XRPC request',
257
+ cause: expect.objectContaining({
258
+ message: 'Logged out',
259
+ }),
260
+ })
257
261
  })
258
262
 
259
263
  it('fails to perform unauthenticated call', async () => {
@@ -263,22 +267,21 @@ describe(PasswordSession, () => {
263
267
  })
264
268
 
265
269
  assert(result.success === false)
266
- assert(result instanceof XrpcResponseError)
270
+ assert(result instanceof XrpcAuthenticationError)
267
271
  expect(result).toMatchObject({
268
272
  success: false,
269
- status: 401,
270
273
  error: 'AuthenticationRequired',
271
274
  })
272
- expect(result.headers.get('www-authenticate')).toBe(
273
- 'Bearer realm="access token"',
274
- )
275
+ expect(result.wwwAuthenticate).toEqual({
276
+ Bearer: { realm: 'access token' },
277
+ })
275
278
  })
276
279
 
277
280
  it('refreshes expired token', async () => {
278
281
  const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
279
282
  const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
280
283
 
281
- const session = await PasswordSession.create({
284
+ const session = await PasswordSession.login({
282
285
  ...defaultOptions,
283
286
  service: entrywayOrigin,
284
287
  identifier: 'bob',
@@ -332,7 +335,7 @@ describe(PasswordSession, () => {
332
335
  const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
333
336
  const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
334
337
 
335
- const initialAgent = await PasswordSession.create({
338
+ const initialAgent = await PasswordSession.login({
336
339
  ...defaultOptions,
337
340
  service: entrywayOrigin,
338
341
  identifier: 'carla',
@@ -368,7 +371,6 @@ describe(PasswordSession, () => {
368
371
  await expect(initialAgent.refresh()).rejects.toMatchObject({
369
372
  success: false,
370
373
  error: 'ExpiredToken',
371
- status: 401,
372
374
  })
373
375
 
374
376
  expect(onDeleted).toHaveBeenCalledTimes(1)
@@ -393,7 +395,7 @@ describe(PasswordSession, () => {
393
395
  it('silently ignores expected logout errors', async () => {
394
396
  let sessionData: SessionData | null = null
395
397
 
396
- const session = await PasswordSession.create({
398
+ const session = await PasswordSession.login({
397
399
  ...defaultOptions,
398
400
  service: entrywayOrigin,
399
401
  identifier: 'dave',
@@ -402,7 +404,6 @@ describe(PasswordSession, () => {
402
404
  onUpdated: (data) => {
403
405
  sessionData = structuredClone(data)
404
406
  },
405
- onDeleted: () => {},
406
407
  })
407
408
 
408
409
  assert(sessionData)
@@ -1,22 +1,43 @@
1
1
  import {
2
2
  Agent,
3
- XrpcError,
4
3
  XrpcFailure,
5
4
  buildAgent,
5
+ xrpc,
6
6
  xrpcSafe,
7
7
  } from '@atproto/lex-client'
8
8
  import { LexAuthFactorError } from './error.js'
9
9
  import { com } from './lexicons/index.js'
10
- import { extractPdsUrl, extractXrpcErrorCode, noop } from './util.js'
11
-
10
+ import { extractPdsUrl, extractXrpcErrorCode } from './util.js'
11
+
12
+ /**
13
+ * Represents a failure response when refreshing a session.
14
+ *
15
+ * This type captures the possible error responses from
16
+ * `com.atproto.server.refreshSession`, including both expected errors
17
+ * (e.g., invalid/expired refresh token) and unexpected errors (e.g., network issues).
18
+ */
12
19
  export type RefreshFailure = XrpcFailure<
13
20
  typeof com.atproto.server.refreshSession.main
14
21
  >
15
22
 
23
+ /**
24
+ * Represents a failure response when deleting a session.
25
+ *
26
+ * This type captures the possible error responses from
27
+ * `com.atproto.server.deleteSession`, including both expected errors
28
+ * and unexpected errors (e.g., network issues, server unavailability).
29
+ */
16
30
  export type DeleteFailure = XrpcFailure<
17
31
  typeof com.atproto.server.deleteSession.main
18
32
  >
19
33
 
34
+ /**
35
+ * Persisted session data containing authentication credentials and service information.
36
+ *
37
+ * This type extends the response from `com.atproto.server.createSession` with the
38
+ * service URL used for authentication. Store this data securely to resume sessions
39
+ * later without re-authenticating.
40
+ */
20
41
  export type SessionData = com.atproto.server.createSession.OutputBody & {
21
42
  service: string
22
43
  }
@@ -37,7 +58,7 @@ export type PasswordSessionOptions = {
37
58
  *
38
59
  * @note this function **must** not throw
39
60
  */
40
- onUpdated: (this: PasswordSession, data: SessionData) => void | Promise<void>
61
+ onUpdated?: (this: PasswordSession, data: SessionData) => void | Promise<void>
41
62
 
42
63
  /**
43
64
  * Called whenever the session update fails due to an expected error, such as
@@ -61,7 +82,7 @@ export type PasswordSessionOptions = {
61
82
  *
62
83
  * @note this function **must** not throw
63
84
  */
64
- onDeleted: (this: PasswordSession, data: SessionData) => void | Promise<void>
85
+ onDeleted?: (this: PasswordSession, data: SessionData) => void | Promise<void>
65
86
 
66
87
  /**
67
88
  * Called whenever a session deletion fails due to an unexpected error, such
@@ -84,6 +105,44 @@ export type PasswordSessionOptions = {
84
105
  ) => void | Promise<void>
85
106
  }
86
107
 
108
+ /**
109
+ * Password-based authentication session for AT Protocol services.
110
+ *
111
+ * This class provides session management for CLI tools, scripts, and bots that
112
+ * need to authenticate with AT Protocol services using password credentials.
113
+ * It implements the {@link Agent} interface, allowing it to be used directly
114
+ * with AT Protocol clients.
115
+ *
116
+ * **Security Warning:** It is strongly recommended to use app passwords instead
117
+ * of main account credentials. App passwords provide limited access and can be
118
+ * revoked independently without compromising your main account. For browser-based
119
+ * applications, use OAuth-based authentication instead.
120
+ *
121
+ * @example Basic usage with app password
122
+ * ```ts
123
+ * const session = await PasswordSession.login({
124
+ * service: 'https://bsky.social',
125
+ * identifier: 'alice.bsky.social',
126
+ * password: 'xxxx-xxxx-xxxx-xxxx', // App password
127
+ * onUpdated: (data) => saveToStorage(data),
128
+ * onDeleted: (data) => clearStorage(data.did),
129
+ * })
130
+ *
131
+ * const client = new Client(session)
132
+ * // Use client to make authenticated requests
133
+ * ```
134
+ *
135
+ * @example Resuming a persisted session
136
+ * ```ts
137
+ * const savedData = JSON.parse(fs.readFileSync('session.json', 'utf8'))
138
+ * const session = await PasswordSession.resume(savedData, {
139
+ * onUpdated: (data) => saveToStorage(data),
140
+ * onDeleted: (data) => clearStorage(data.did),
141
+ * })
142
+ * ```
143
+ *
144
+ * @implements {Agent}
145
+ */
87
146
  export class PasswordSession implements Agent {
88
147
  /**
89
148
  * Internal {@link Agent} used for session management towards the
@@ -96,7 +155,7 @@ export class PasswordSession implements Agent {
96
155
 
97
156
  constructor(
98
157
  sessionData: SessionData,
99
- protected readonly options: PasswordSessionOptions,
158
+ protected readonly options: PasswordSessionOptions = {},
100
159
  ) {
101
160
  this.#serviceAgent = buildAgent({
102
161
  service: sessionData.service,
@@ -107,23 +166,59 @@ export class PasswordSession implements Agent {
107
166
  this.#sessionPromise = Promise.resolve(this.#sessionData)
108
167
  }
109
168
 
169
+ /**
170
+ * The DID (Decentralized Identifier) of the authenticated account.
171
+ *
172
+ * @throws {Error} If the session has been destroyed (logged out).
173
+ */
110
174
  get did() {
111
175
  return this.session.did
112
176
  }
113
177
 
178
+ /**
179
+ * The handle (username) of the authenticated account.
180
+ *
181
+ * @throws {Error} If the session has been destroyed (logged out).
182
+ */
114
183
  get handle() {
115
184
  return this.session.handle
116
185
  }
117
186
 
187
+ /**
188
+ * The current session data containing authentication credentials.
189
+ *
190
+ * @throws {Error} If the session has been destroyed (logged out).
191
+ */
118
192
  get session() {
119
193
  if (this.#sessionData) return this.#sessionData
120
- throw new XrpcError('AuthenticationRequired', 'Logged out')
194
+ throw new Error('Logged out')
121
195
  }
122
196
 
197
+ /**
198
+ * Whether this session has been destroyed (logged out).
199
+ *
200
+ * Once destroyed, this session instance can no longer be used for
201
+ * authenticated requests. Create a new session via {@link PasswordSession.login}
202
+ * or {@link PasswordSession.resume}.
203
+ */
123
204
  get destroyed(): boolean {
124
205
  return this.#sessionData === null
125
206
  }
126
207
 
208
+ /**
209
+ * Handles authenticated fetch requests to the user's PDS.
210
+ *
211
+ * This method implements the {@link Agent} interface and is called by
212
+ * AT Protocol clients to make authenticated requests. It automatically:
213
+ * - Adds the access token to request headers
214
+ * - Detects expired tokens and triggers refresh
215
+ * - Retries requests after successful token refresh
216
+ *
217
+ * @param path - The request path (will be resolved against the PDS URL)
218
+ * @param init - Standard fetch RequestInit options (headers, body, etc.)
219
+ * @returns The fetch Response from the PDS
220
+ * @throws {TypeError} If an 'authorization' header is already set in init
221
+ */
127
222
  async fetchHandler(path: string, init: RequestInit): Promise<Response> {
128
223
  const headers = new Headers(init.headers)
129
224
  if (headers.has('authorization')) {
@@ -190,6 +285,21 @@ export class PasswordSession implements Agent {
190
285
  return fetch(fetchUrl(newSessionData, path), { ...init, headers })
191
286
  }
192
287
 
288
+ /**
289
+ * Refreshes the session by obtaining new access and refresh tokens.
290
+ *
291
+ * This method is automatically called by {@link fetchHandler} when the access
292
+ * token expires. You can also call it manually to proactively refresh tokens.
293
+ *
294
+ * On success, the {@link PasswordSessionOptions.onUpdated} callback is invoked
295
+ * with the new session data. On expected failures (invalid session), the
296
+ * {@link PasswordSessionOptions.onDeleted} callback is invoked. On unexpected
297
+ * failures (network issues), the {@link PasswordSessionOptions.onUpdateFailure}
298
+ * callback is invoked and the existing session data is preserved.
299
+ *
300
+ * @returns The refreshed session data
301
+ * @throws {RefreshFailure} If the session is no longer valid (triggers onDeleted)
302
+ */
193
303
  async refresh(): Promise<SessionData> {
194
304
  this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
195
305
  const response = await xrpcSafe(
@@ -200,7 +310,7 @@ export class PasswordSession implements Agent {
200
310
 
201
311
  if (!response.success && response.matchesSchema()) {
202
312
  // Expected errors that indicate the session is no longer valid
203
- await this.options.onDeleted.call(this, sessionData)
313
+ await this.options.onDeleted?.call(this, sessionData)
204
314
 
205
315
  // Update the session promise to a rejected state
206
316
  this.#sessionData = null
@@ -208,6 +318,10 @@ export class PasswordSession implements Agent {
208
318
  }
209
319
 
210
320
  if (!response.success) {
321
+ response.error
322
+ if (response.matchesSchema()) {
323
+ response.error
324
+ }
211
325
  // We failed to refresh the token, assume the session might still be
212
326
  // valid by returning the existing session.
213
327
  await this.options.onUpdateFailure?.call(this, sessionData, response)
@@ -238,7 +352,7 @@ export class PasswordSession implements Agent {
238
352
  service: sessionData.service,
239
353
  }
240
354
 
241
- await this.options.onUpdated.call(this, newSession)
355
+ await this.options.onUpdated?.call(this, newSession)
242
356
 
243
357
  return (this.#sessionData = newSession)
244
358
  })
@@ -246,6 +360,21 @@ export class PasswordSession implements Agent {
246
360
  return this.#sessionPromise
247
361
  }
248
362
 
363
+ /**
364
+ * Logs out by deleting the session on the server.
365
+ *
366
+ * This method invalidates both the access and refresh tokens on the server,
367
+ * preventing any further use of this session. After successful logout, the
368
+ * session is marked as destroyed and the {@link PasswordSessionOptions.onDeleted}
369
+ * callback is invoked.
370
+ *
371
+ * If the logout request fails due to network issues or server unavailability,
372
+ * the {@link PasswordSessionOptions.onDeleteFailure} callback is invoked and
373
+ * the session remains active locally. In this case, you should retry the
374
+ * logout later to ensure the session is properly invalidated on the server.
375
+ *
376
+ * @throws {DeleteFailure} If the logout request fails due to unexpected errors
377
+ */
249
378
  async logout(): Promise<void> {
250
379
  let reason: DeleteFailure | null = null
251
380
 
@@ -257,11 +386,11 @@ export class PasswordSession implements Agent {
257
386
  )
258
387
 
259
388
  if (result.success || result.matchesSchema()) {
260
- await this.options.onDeleted.call(this, sessionData)
389
+ await this.options.onDeleted?.call(this, sessionData)
261
390
 
262
391
  // Update the session promise to a rejected state
263
392
  this.#sessionData = null
264
- throw new XrpcError('AuthenticationRequired', 'Logged out')
393
+ throw new Error('Logged out')
265
394
  } else {
266
395
  // Capture the reason for the failure to re-throw in the outer promise
267
396
  reason = result
@@ -287,34 +416,111 @@ export class PasswordSession implements Agent {
287
416
  }
288
417
 
289
418
  /**
290
- * @note It is **not** recommended to use {@link PasswordSession} with main
291
- * account credentials. Instead, it is strongly advised to use OAuth based
292
- * authentication for main username/password credentials and use
293
- * {@link PasswordSession} with an app-password, for bots, scripts, or similar
294
- * use-cases.
419
+ * Creates a new account and returns an authenticated session.
295
420
  *
296
- * @throws If unable to create a session. In particular, if the server
297
- * requires a 2FA token, a {@link XrpcResponseError} with the
298
- * `AuthFactorTokenRequired` error code will be thrown.
421
+ * This static method registers a new account on the specified service and
422
+ * automatically creates an authenticated session for it.
299
423
  *
424
+ * @param body - Account creation parameters (handle, email, password, etc.)
425
+ * @param options - Session options including the service URL
426
+ * @returns A new PasswordSession for the created account
427
+ * @throws If account creation fails (e.g., handle taken, invalid invite code)
300
428
  *
301
- * @example Handling 2FA errors
429
+ * @example
430
+ * ```ts
431
+ * const session = await PasswordSession.createAccount(
432
+ * {
433
+ * handle: 'alice.bsky.social',
434
+ * email: 'alice@example.com',
435
+ * password: 'secure-password',
436
+ * },
437
+ * {
438
+ * service: 'https://bsky.social',
439
+ * onUpdated: (data) => saveToStorage(data),
440
+ * }
441
+ * )
442
+ * ```
443
+ */
444
+ static async createAccount(
445
+ body: com.atproto.server.createAccount.InputBody,
446
+ {
447
+ service,
448
+ headers,
449
+ ...options
450
+ }: PasswordSessionOptions & {
451
+ headers?: HeadersInit
452
+ service: string | URL
453
+ },
454
+ ): Promise<PasswordSession> {
455
+ const response = await xrpc(
456
+ buildAgent({ service, headers, fetch: options.fetch }),
457
+ com.atproto.server.createAccount.main,
458
+ { body },
459
+ )
460
+
461
+ const data: SessionData = {
462
+ ...response.body,
463
+ service: String(service),
464
+ }
465
+
466
+ const agent = new PasswordSession(data, options)
467
+ await options.onUpdated?.call(agent, data)
468
+ return agent
469
+ }
470
+
471
+ /**
472
+ * Creates a new authenticated session using password credentials.
473
+ *
474
+ * This static method authenticates with the specified service and returns
475
+ * a new PasswordSession instance that can be used for authenticated requests.
476
+ *
477
+ * **Security Warning:** It is strongly recommended to use app passwords instead
478
+ * of main account credentials. App passwords can be created in your account
479
+ * settings and provide limited access that can be revoked independently. For
480
+ * browser-based applications, use OAuth-based authentication instead.
302
481
  *
482
+ * @param options - Login options including service URL, identifier, and password
483
+ * @param options.service - The AT Protocol service URL (e.g., 'https://bsky.social')
484
+ * @param options.identifier - The user's handle or DID
485
+ * @param options.password - The user's password or app password
486
+ * @param options.allowTakendown - If true, allow login to takendown accounts
487
+ * @param options.authFactorToken - 2FA token if required by the server
488
+ * @returns A new authenticated PasswordSession
489
+ * @throws {LexAuthFactorError} If the server requires a 2FA token
490
+ * @throws If authentication fails (invalid credentials, etc.)
491
+ *
492
+ * @example Basic login with app password
493
+ * ```ts
494
+ * const session = await PasswordSession.login({
495
+ * service: 'https://bsky.social',
496
+ * identifier: 'alice.bsky.social',
497
+ * password: 'xxxx-xxxx-xxxx-xxxx', // App password
498
+ * onUpdated: (data) => saveToStorage(data),
499
+ * })
500
+ * ```
501
+ *
502
+ * @example Handling 2FA requirement
303
503
  * ```ts
304
504
  * try {
305
- * const session = await PasswordSession.create({
306
- * service: 'https://example.com',
307
- * identifier: 'alice',
308
- * password: 'correct horse battery staple',
505
+ * const session = await PasswordSession.login({
506
+ * service: 'https://bsky.social',
507
+ * identifier: 'alice.bsky.social',
508
+ * password: 'xxxx-xxxx-xxxx-xxxx',
309
509
  * })
310
510
  * } catch (err) {
311
- * if (err instanceof XrpcResponseError && err.error === 'AuthFactorTokenRequired') {
312
- * // Prompt user for 2FA token and re-attempt session creation
511
+ * if (err instanceof LexAuthFactorError) {
512
+ * const token = await promptUser('Enter 2FA code:')
513
+ * const session = await PasswordSession.login({
514
+ * service: 'https://bsky.social',
515
+ * identifier: 'alice.bsky.social',
516
+ * password: 'xxxx-xxxx-xxxx-xxxx',
517
+ * authFactorToken: token,
518
+ * })
313
519
  * }
314
520
  * }
315
521
  * ```
316
522
  */
317
- static async create({
523
+ static async login({
318
524
  service,
319
525
  identifier,
320
526
  password,
@@ -352,7 +558,7 @@ export class PasswordSession implements Agent {
352
558
  }
353
559
 
354
560
  const agent = new PasswordSession(data, options)
355
- await options.onUpdated.call(agent, data)
561
+ await options.onUpdated?.call(agent, data)
356
562
  return agent
357
563
  }
358
564
 
@@ -387,13 +593,9 @@ export class PasswordSession implements Agent {
387
593
  */
388
594
  static async delete(
389
595
  data: SessionData,
390
- options?: Partial<PasswordSessionOptions>,
596
+ options?: PasswordSessionOptions,
391
597
  ): Promise<void> {
392
- const agent = new PasswordSession(data, {
393
- ...options,
394
- onUpdated: options?.onUpdated ?? noop,
395
- onDeleted: options?.onDeleted ?? noop,
396
- })
598
+ const agent = new PasswordSession(data, options)
397
599
  await agent.logout()
398
600
  }
399
601
  }
package/src/util.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  import { LexMap, LexValue } from '@atproto/lex-client'
2
- import { l } from '@atproto/lex-schema'
3
-
4
- export const noop = () => {}
2
+ import { lexErrorDataSchema } from '@atproto/lex-schema'
5
3
 
6
4
  export async function extractXrpcErrorCode(
7
5
  response: Response,
8
6
  ): Promise<string | null> {
9
7
  const json = await peekJson(response, 10 * 1024) // Avoid reading large bodies
10
8
  if (json === undefined) return null
11
- if (!l.lexErrorData.matches(json)) return null
9
+ if (!lexErrorDataSchema.matches(json)) return null
12
10
  return json.error
13
11
  }
14
12