Files
Matthieu Sieben ed61c62f31 Add docs in lex SDK packages (#4601)
* Fix `exports` field in package.json

* tidy

* AT Protocol
2026-02-06 14:40:54 +01:00

11 KiB

@atproto/lex-password-session

Password-based session authentication for AT Protocol Lexicons. See the Changelog for version history.

npm install @atproto/lex-password-session
  • Session management with automatic token refresh
  • Hooks for persisting and monitoring session state
  • PDS endpoint discovery from DID documents
  • Two-factor authentication support

Important

This package is currently in preview. The API and features are subject to change before the stable release.

What is this?

@atproto/lex-password-session provides a PasswordSession class that implements the Agent interface from @atproto/lex-client. It handles password-based authentication with AT Protocol services, including:

  1. Creating sessions with username/password credentials
  2. Automatic token refresh when access tokens expire
  3. Session persistence through lifecycle hooks
  4. Graceful logout with server-side session cleanup
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

// Login with credentials
const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated: (data) => saveToStorage(data),
  onDeleted: (data) => clearStorage(data.did),
})

const client = new Client(session)

// Make authenticated requests
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: session.did,
})

Quick Start

1. Install the package

npm install @atproto/lex-password-session @atproto/lex-client

2. Login and make requests

import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'your-handle.bsky.social',
  password: 'your-app-password',
})

const client = new Client(session)

// Make authenticated API calls
console.log('Logged in as:', session.did)

PasswordSession

The PasswordSession class manages password-based authentication sessions.

Login

Create a new session with username and password:

import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social', // handle or email
  password: 'app-password',
  onUpdated: (data) => {
    // Persist session for later restoration
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Logged in as:', session.did)

The login() method throws on failure. For expected errors like invalid credentials, an XrpcResponseError is thrown. For 2FA requirements, a LexAuthFactorError is thrown.

Two-Factor Authentication

Caution

Two-factor authentication only applies when using main account credentials, which is strongly discouraged. Password authentication should be used with app passwords only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via @atproto/oauth-client which provides better security and user control.

If the account has 2FA enabled, login will throw a LexAuthFactorError:

import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'

async function loginWith2FA(
  identifier: string,
  password: string,
  authFactorToken?: string,
): Promise<PasswordSession> {
  try {
    return await PasswordSession.login({
      service: 'https://bsky.social',
      identifier,
      password,
      authFactorToken,
      onUpdated: (data) => saveToStorage(data),
      onDeleted: (data) => removeFromStorage(data.did),
    })
  } catch (err) {
    if (err instanceof LexAuthFactorError && !authFactorToken) {
      // 2FA required - prompt user for code
      const token = await promptUserFor2FACode(err.message)
      return loginWith2FA(identifier, password, token)
    }
    throw err
  }
}

Resume Session

Restore a previously saved session:

import { PasswordSession, SessionData } from '@atproto/lex-password-session'

// Load session from storage
const savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)

// Resume the session (automatically refreshes tokens)
const session = await PasswordSession.resume(savedSession, {
  onUpdated: (data) => {
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Session resumed for:', session.did)

// Access session properties
console.log(session.did) // User's DID
console.log(session.handle) // User's handle
console.log(session.destroyed) // false (session is active)

Note

resume() automatically calls refresh() to ensure the session is valid and tokens are current.

Logout

End the session and notify the server:

await session.logout()

After logout:

  • The onDeleted hook is called
  • The session is marked as destroyed (session.destroyed === true)
  • Further requests will throw 'Logged out'

Static Delete

Delete a session without creating a session instance:

import { PasswordSession, SessionData } from '@atproto/lex-password-session'

const data: SessionData = JSON.parse(localStorage.getItem('session')!)

// Delete the session on the server
await PasswordSession.delete(data)

This is useful for cleanup scenarios where you don't need to make additional requests.

Create Account

Create a new account and get an authenticated session:

import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.createAccount(
  {
    handle: 'alice.bsky.social',
    email: 'alice@example.com',
    password: 'secure-password',
  },
  {
    service: 'https://bsky.social',
    onUpdated: (data) => saveToStorage(data),
    onDeleted: (data) => removeFromStorage(data.did),
  },
)

console.log('Account created:', session.did)

Session Hooks

Hooks provide callbacks for session lifecycle events. All hooks receive the session instance as this context.

onUpdated

Called when the session is successfully created or refreshed:

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated(data) {
    // `this` is the PasswordSession instance
    console.log('Session updated for:', this.did)

    // Persist the updated session
    saveSession(data)
  },
})

Important

Requests are blocked while onUpdated is running. Keep this callback fast to avoid delays.

onUpdateFailure

Called when token refresh fails due to transient errors (network issues, server unavailability):

{
  onUpdateFailure(data, error) {
    console.warn('Token refresh failed:', error.message)
    // Session may still be valid - consider retry logic
  }
}

onDeleted

Called when the session is terminated (logout or server-side invalidation):

{
  onDeleted(data) {
    console.log('Session ended for:', data.did)
    clearPersistedSession(data.did)
    redirectToLogin()
  }
}

onDeleteFailure

Called when logout fails due to transient errors:

{
  onDeleteFailure(data, error) {
    console.error('Logout failed:', error.message)
    // Consider queuing for retry to avoid orphaned sessions
    queueLogoutRetry(data)
  }
}

Warning

Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.

Session Data

The SessionData type contains all data needed to authenticate and restore sessions:

type SessionData = {
  // Session credentials and user info from createSession response
  accessJwt: string
  refreshJwt: string
  did: string
  handle: string
  email?: string
  emailConfirmed?: boolean
  didDoc?: object
  // ... other fields from createSession

  // Original service URL used for login
  service: string
}

Error Handling

The PasswordSession class uses exception-based error handling:

import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'
import { XrpcResponseError } from '@atproto/lex-client'

try {
  const session = await PasswordSession.login({
    service: 'https://bsky.social',
    identifier: 'alice.bsky.social',
    password: 'wrong-password',
  })
} catch (err) {
  if (err instanceof LexAuthFactorError) {
    console.error('2FA required')
  } else if (err instanceof XrpcResponseError) {
    switch (err.error) {
      case 'AuthenticationRequired':
        console.error('Invalid credentials')
        break
      case 'AccountTakedown':
        console.error('Account has been suspended')
        break
      default:
        console.error('Login failed:', err.message)
    }
  } else {
    throw err
  }
}

Common error codes:

Error Code Description
AuthenticationRequired Invalid username or password
AuthFactorTokenRequired 2FA code needed
AccountTakedown Account suspended
ExpiredToken Token has expired (on refresh)
InvalidToken Token is invalid

Using with Client

The PasswordSession implements the Agent interface and can be used directly with Client:

import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
})

const client = new Client(session)

// The client automatically uses the session for authentication
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: client.assertDid,
})

// Tokens are automatically refreshed when expired
const timeline = await client.call(app.bsky.feed.getTimeline, {
  limit: 50,
})

// Create records
await client.create(app.bsky.feed.post, {
  text: 'Hello from lex-password-session!',
  createdAt: new Date().toISOString(),
})

The session handles:

  • Adding Authorization headers to requests
  • Detecting expired tokens (401 responses or ExpiredToken errors)
  • Automatically refreshing tokens and retrying failed requests
  • Routing requests to the correct PDS based on DID document

License

MIT or Apache2