Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/bluesky-social/atproto/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The @atproto/oauth-client-node package provides a complete OAuth client implementation for Node.js environments. It’s ideal for backend services, traditional web applications, and desktop apps built with Electron.
This package is optimized for server-side OAuth flows with enhanced security features like private_key_jwt authentication and longer token lifetimes.

Installation

npm install @atproto/oauth-client-node

Requirements

  • Node.js 18.7.0 or higher
  • Built-in crypto module support
  • Storage backend (database, Redis, etc.)

Quick Start

Backend Web Application

import { NodeOAuthClient } from '@atproto/oauth-client-node'
import express from 'express'
import { Agent } from '@atproto/api'

const client = new NodeOAuthClient({
  clientMetadata: {
    client_id: 'https://my-app.com/client-metadata.json',
    client_name: 'My App',
    redirect_uris: ['https://my-app.com/callback'],
    scope: 'atproto',
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'private_key_jwt',
    dpop_bound_access_tokens: true,
    jwks_uri: 'https://my-app.com/jwks.json'
  },
  
  keyset: await Promise.all([
    JoseKey.fromImportable(process.env.PRIVATE_KEY_1),
    JoseKey.fromImportable(process.env.PRIVATE_KEY_2)
  ]),
  
  stateStore: myStateStore,
  sessionStore: mySessionStore
})

const app = express()

// Expose metadata
app.get('/client-metadata.json', (req, res) => {
  res.json(client.clientMetadata)
})

app.get('/jwks.json', (req, res) => {
  res.json(client.jwks)
})

// Initiate OAuth flow
app.get('/login', async (req, res) => {
  const handle = req.query.handle
  const authUrl = await client.authorize(handle, {
    state: req.session.id
  })
  res.redirect(authUrl.toString())
})

// Handle callback
app.get('/callback', async (req, res) => {
  const params = new URLSearchParams(req.url.split('?')[1])
  const { session, state } = await client.callback(params)
  
  // Save session association
  req.session.did = session.did
  
  res.redirect('/dashboard')
})

app.listen(3000)

NodeOAuthClient Class

Constructor

new NodeOAuthClient(options: NodeOAuthClientOptions)
Creates a new Node.js OAuth client.
clientMetadata
OAuthClientMetadataInput
required
Client metadata configuration.Backend clients should use:
{
  token_endpoint_auth_method: 'private_key_jwt',
  jwks_uri: 'https://my-app.com/jwks.json'
}
Native/mobile apps should use:
{
  token_endpoint_auth_method: 'none',
  application_type: 'native'
}
keyset
Keyset | Iterable<Key>
Private keys for client authentication. Required when using private_key_jwt.
import { JoseKey } from '@atproto/oauth-client-node'

const keyset = await Promise.all([
  JoseKey.fromImportable(process.env.PRIVATE_KEY_1, 'key1'),
  JoseKey.fromImportable(process.env.PRIVATE_KEY_2, 'key2')
])
responseMode
'query' | 'form_post'
default:"'query'"
OAuth response mode:
  • query: Parameters in URL query string (recommended)
  • form_post: Parameters via HTTP POST body
fragment mode is not supported in Node.js (browser only).
stateStore
NodeSavedStateStore
required
Storage for OAuth state during authorization flows.
interface NodeSavedStateStore {
  set(key: string, state: NodeSavedState): Promise<void>
  get(key: string): Promise<NodeSavedState | undefined>
  del(key: string): Promise<void>
}
State should be deleted after 1 hour.
sessionStore
NodeSavedSessionStore
required
Storage for authenticated sessions.
interface NodeSavedSessionStore {
  set(sub: string, session: NodeSavedSession): Promise<void>
  get(sub: string): Promise<NodeSavedSession | undefined>
  del(sub: string): Promise<void>
}
handleResolver
string | URL | HandleResolver
Handle resolver service. For Node.js, uses DNS resolution by default.
// Use custom resolver
handleResolver: 'https://my-pds.com'

// Or DNS with fallback nameservers
fallbackNameservers: ['8.8.8.8', '1.1.1.1']
requestLock
RuntimeLock
Lock mechanism to prevent concurrent token refreshes across multiple instances.Required for multi-instance deployments (horizontal scaling).
import Redlock from 'redlock'
import Redis from 'ioredis'

const redis = new Redis()
const redlock = new Redlock([redis])

const requestLock = async (key, fn) => {
  const lock = await redlock.lock(key, 45000)
  try {
    return await fn()
  } finally {
    await redlock.unlock(lock)
  }
}
runtimeImplementation
RuntimeImplementation
Custom runtime implementation. Defaults to Node.js crypto.
fetch
Fetch
Custom fetch implementation.
plcDirectoryUrl
string
PLC directory URL for DID resolution.
allowHttp
boolean
default:"false"
Allow HTTP for development. Never use in production.

Methods

The NodeOAuthClient extends OAuthClient and inherits all its methods:

authorize

async authorize(
  input: string,
  options?: AuthorizeOptions
): Promise<URL>
See OAuthClient.authorize for full documentation. Example:
app.get('/login', async (req, res) => {
  const handle = req.query.handle
  const state = req.session.id
  
  try {
    const authUrl = await client.authorize(handle, {
      state,
      ui_locales: req.headers['accept-language']
    })
    
    res.redirect(authUrl.toString())
  } catch (err) {
    res.status(500).send('Failed to initiate login')
  }
})

callback

async callback(
  params: URLSearchParams
): Promise<{ session: OAuthSession; state?: string }>
See OAuthClient.callback for full documentation. Example:
app.get('/callback', async (req, res) => {
  const params = new URLSearchParams(req.url.split('?')[1])
  
  try {
    const { session, state } = await client.callback(params)
    
    // Associate session with user
    await db.users.update(state, { did: session.did })
    
    res.redirect('/dashboard')
  } catch (err) {
    if (err instanceof OAuthCallbackError) {
      const error = err.params.get('error')
      res.status(400).send(`OAuth error: ${error}`)
    } else {
      res.status(500).send('Callback failed')
    }
  }
})

restore

async restore(
  sub: string,
  refresh?: boolean | 'auto'
): Promise<OAuthSession>
Restores a session from storage. See OAuthClient.restore. Example:
// In request handler
const userDid = req.session.did
const session = await client.restore(userDid)
const agent = new Agent(session)

const profile = await agent.getProfile({ actor: agent.did })

revoke

async revoke(sub: string): Promise<void>
Revokes a session. See OAuthClient.revoke. Example:
app.post('/logout', async (req, res) => {
  await client.revoke(req.session.did)
  req.session.destroy()
  res.redirect('/')
})

Properties

jwks
Jwks
Public JWKS for exposing at jwks_uri.
app.get('/jwks.json', (req, res) => {
  res.json(client.jwks)
})
clientMetadata
ClientMetadata
The validated client metadata.
app.get('/client-metadata.json', (req, res) => {
  res.json(client.clientMetadata)
})

Storage Implementation

State Store Example

import { Redis } from 'ioredis'

const redis = new Redis()

const stateStore = {
  async set(key: string, state: NodeSavedState) {
    // Store with 1 hour TTL
    await redis.setex(
      `oauth:state:${key}`,
      3600,
      JSON.stringify(state)
    )
  },
  
  async get(key: string) {
    const data = await redis.get(`oauth:state:${key}`)
    return data ? JSON.parse(data) : undefined
  },
  
  async del(key: string) {
    await redis.del(`oauth:state:${key}`)
  }
}

Session Store Example

import { Pool } from 'pg'

const pool = new Pool()

const sessionStore = {
  async set(sub: string, session: NodeSavedSession) {
    await pool.query(
      `INSERT INTO oauth_sessions (sub, data, updated_at)
       VALUES ($1, $2, NOW())
       ON CONFLICT (sub) DO UPDATE
       SET data = $2, updated_at = NOW()`,
      [sub, JSON.stringify(session)]
    )
  },
  
  async get(sub: string) {
    const result = await pool.query(
      'SELECT data FROM oauth_sessions WHERE sub = $1',
      [sub]
    )
    return result.rows[0]?.data
  },
  
  async del(sub: string) {
    await pool.query(
      'DELETE FROM oauth_sessions WHERE sub = $1',
      [sub]
    )
  }
}

Private Key Management

Generating Keys

import { JoseKey } from '@atproto/oauth-client-node'

// Generate new key
const key = await JoseKey.generate(['RS256'])

// Export for storage
const exportable = await key.export()
console.log(JSON.stringify(exportable))

Loading Keys

// From environment variable (PEM)
const key1 = await JoseKey.fromImportable(
  process.env.OAUTH_PRIVATE_KEY_PEM,
  'key1'
)

// From JWK
const key2 = await JoseKey.fromImportable(
  {
    kty: 'RSA',
    kid: 'key2',
    n: '...',
    e: 'AQAB',
    d: '...',
    // ...
  },
  'key2'
)

// From file
import { readFile } from 'fs/promises'
const keyData = await readFile('./private-key.pem', 'utf-8')
const key3 = await JoseKey.fromImportable(keyData, 'key3')

Key Rotation

const keyset = [
  await JoseKey.fromImportable(process.env.CURRENT_KEY, 'current'),
  await JoseKey.fromImportable(process.env.OLD_KEY, 'old')
]

const client = new NodeOAuthClient({
  keyset,
  // ...
})

// JWKS includes all public keys
console.log(client.jwks)
// { keys: [{ kid: 'current', ... }, { kid: 'old', ... }] }

Complete Express Example

import express from 'express'
import session from 'express-session'
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { Agent } from '@atproto/api'
import { JoseKey } from '@atproto/jwk-jose'
import { createClient } from 'redis'

// Initialize Redis
const redis = createClient()
await redis.connect()

// State store
const stateStore = {
  async set(key, state) {
    await redis.setEx(`state:${key}`, 3600, JSON.stringify(state))
  },
  async get(key) {
    const data = await redis.get(`state:${key}`)
    return data ? JSON.parse(data) : undefined
  },
  async del(key) {
    await redis.del(`state:${key}`)
  }
}

// Session store
const sessionStore = {
  async set(sub, session) {
    await redis.set(`session:${sub}`, JSON.stringify(session))
  },
  async get(sub) {
    const data = await redis.get(`session:${sub}`)
    return data ? JSON.parse(data) : undefined
  },
  async del(sub) {
    await redis.del(`session:${sub}`)
  }
}

// OAuth client
const oauthClient = new NodeOAuthClient({
  clientMetadata: {
    client_id: process.env.CLIENT_ID,
    client_name: 'My App',
    redirect_uris: [process.env.REDIRECT_URI],
    scope: 'atproto',
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'private_key_jwt',
    jwks_uri: `${process.env.PUBLIC_URL}/jwks.json`,
    dpop_bound_access_tokens: true
  },
  
  keyset: await Promise.all([
    JoseKey.fromImportable(process.env.PRIVATE_KEY_1, 'key1'),
    JoseKey.fromImportable(process.env.PRIVATE_KEY_2, 'key2')
  ]),
  
  stateStore,
  sessionStore
})

const app = express()

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false
}))

// Metadata endpoints
app.get('/client-metadata.json', (req, res) => {
  res.json(oauthClient.clientMetadata)
})

app.get('/jwks.json', (req, res) => {
  res.json(oauthClient.jwks)
})

// Login
app.get('/login', async (req, res) => {
  const handle = req.query.handle
  
  if (!handle) {
    return res.status(400).send('Handle required')
  }
  
  try {
    const authUrl = await oauthClient.authorize(handle, {
      state: req.session.id
    })
    res.redirect(authUrl.toString())
  } catch (err) {
    console.error('Login error:', err)
    res.status(500).send('Login failed')
  }
})

// OAuth callback
app.get('/oauth/callback', async (req, res) => {
  const params = new URLSearchParams(req.url.split('?')[1])
  
  try {
    const { session, state } = await oauthClient.callback(params)
    
    // Save DID to session
    req.session.did = session.did
    
    res.redirect('/dashboard')
  } catch (err) {
    console.error('Callback error:', err)
    res.status(500).send('Authentication failed')
  }
})

// Protected route
app.get('/dashboard', async (req, res) => {
  if (!req.session.did) {
    return res.redirect('/login')
  }
  
  try {
    const oauthSession = await oauthClient.restore(req.session.did)
    const agent = new Agent(oauthSession)
    
    const profile = await agent.getProfile({ actor: agent.did })
    
    res.send(`
      <h1>Welcome, ${profile.data.displayName || profile.data.handle}!</h1>
      <p>DID: ${agent.did}</p>
      <a href="/logout">Logout</a>
    `)
  } catch (err) {
    console.error('Dashboard error:', err)
    req.session.did = null
    res.redirect('/login')
  }
})

// Logout
app.get('/logout', async (req, res) => {
  if (req.session.did) {
    try {
      await oauthClient.revoke(req.session.did)
    } catch (err) {
      console.error('Revoke error:', err)
    }
  }
  
  req.session.destroy()
  res.redirect('/')
})

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000')
})

Background Jobs

// Worker process
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { Agent } from '@atproto/api'

const client = new NodeOAuthClient({
  // Same configuration as web server
  // ...
})

async function processUser(userId: string) {
  const user = await db.users.findById(userId)
  
  if (!user.did) {
    return // User not authenticated
  }
  
  try {
    // Restore session
    const session = await client.restore(user.did)
    const agent = new Agent(session)
    
    // Make authenticated requests
    const timeline = await agent.getTimeline({ limit: 50 })
    
    // Process timeline...
  } catch (err) {
    if (err instanceof TokenRefreshError) {
      // Session expired - notify user
      await notifyUser(userId, 'Please re-authenticate')
    }
  }
}

// Process all users
setInterval(async () => {
  const users = await db.users.findAll()
  await Promise.all(users.map(u => processUser(u.id)))
}, 60000)

Event Handling

import { 
  TokenRefreshError,
  TokenRevokedError 
} from '@atproto/oauth-client-node'

client.addEventListener('updated', (event) => {
  const session = event.detail
  console.log('Session refreshed:', session.sub)
  
  // Update analytics, logs, etc.
})

client.addEventListener('deleted', (event) => {
  const { sub, cause } = event.detail
  
  if (cause instanceof TokenRefreshError) {
    console.log('Session expired:', sub)
    // Notify user to re-authenticate
  } else if (cause instanceof TokenRevokedError) {
    console.log('User signed out:', sub)
    // Clean up user data
  }
})

Error Handling

import {
  OAuthCallbackError,
  TokenRefreshError,
  TokenRevokedError,
  OAuthResponseError
} from '@atproto/oauth-client-node'

try {
  const authUrl = await client.authorize(handle)
} catch (err) {
  if (err instanceof OAuthResponseError) {
    // Server returned OAuth error
    console.error('OAuth error:', err.error, err.error_description)
  } else {
    // Network or other error
    console.error('Failed to start authorization:', err)
  }
}

try {
  const { session } = await client.callback(params)
} catch (err) {
  if (err instanceof OAuthCallbackError) {
    const error = err.params.get('error')
    const desc = err.params.get('error_description')
    
    if (error === 'access_denied') {
      // User denied authorization
    } else if (error === 'login_required') {
      // Silent sign-in failed
    }
  }
}

try {
  const session = await client.restore(did)
} catch (err) {
  if (err instanceof TokenRefreshError) {
    // Session expired - need re-auth
    redirectToLogin()
  } else if (err instanceof TokenRevokedError) {
    // Session was revoked
    clearUserSession()
  }
}

Security Best Practices

Private Key Security

Never commit private keys to version control. Use environment variables or key management services.
// Good: Environment variables
const key = await JoseKey.fromImportable(process.env.OAUTH_PRIVATE_KEY)

// Good: Key management service
import { SecretsManager } from 'aws-sdk'
const secrets = new SecretsManager()
const { SecretString } = await secrets.getSecretValue({
  SecretId: 'oauth-private-key'
}).promise()
const key = await JoseKey.fromImportable(SecretString)

// Bad: Hardcoded
const key = await JoseKey.fromImportable('-----BEGIN PRIVATE KEY-----\n...')

Session Storage

// Store sessions securely
const sessionStore = {
  async set(sub, session) {
    // Encrypt before storing
    const encrypted = encrypt(JSON.stringify(session))
    await db.sessions.upsert({ sub, data: encrypted })
  },
  
  async get(sub) {
    const row = await db.sessions.findOne({ sub })
    if (!row) return undefined
    
    // Decrypt when retrieving
    return JSON.parse(decrypt(row.data))
  },
  
  async del(sub) {
    await db.sessions.delete({ sub })
  }
}

Request Lock

// Required for multi-instance deployments
import Redlock from 'redlock'
import Redis from 'ioredis'

const redis = new Redis()
const redlock = new Redlock([redis], {
  retryCount: 10,
  retryDelay: 200
})

const requestLock = async (key, fn) => {
  const lock = await redlock.lock(`locks:oauth:${key}`, 45000)
  try {
    return await fn()
  } finally {
    await redlock.unlock(lock)
  }
}

const client = new NodeOAuthClient({
  requestLock,
  // ...
})

HTTPS Only

// Production configuration
const client = new NodeOAuthClient({
  allowHttp: false, // Default
  clientMetadata: {
    client_id: 'https://my-app.com/client-metadata.json',
    redirect_uris: ['https://my-app.com/callback']
  },
  // ...
})

// Development only
if (process.env.NODE_ENV === 'development') {
  const devClient = new NodeOAuthClient({
    allowHttp: true,
    clientMetadata: {
      client_id: 'http://localhost:3000/client-metadata.json',
      redirect_uris: ['http://localhost:3000/callback']
    },
    // ...
  })
}

Testing

import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { jest } from '@jest/globals'

describe('OAuth Integration', () => {
  let client: NodeOAuthClient
  
  beforeEach(() => {
    const mockStateStore = {
      data: new Map(),
      async set(k, v) { this.data.set(k, v) },
      async get(k) { return this.data.get(k) },
      async del(k) { this.data.delete(k) }
    }
    
    const mockSessionStore = {
      data: new Map(),
      async set(k, v) { this.data.set(k, v) },
      async get(k) { return this.data.get(k) },
      async del(k) { this.data.delete(k) }
    }
    
    client = new NodeOAuthClient({
      clientMetadata: {
        client_id: 'http://localhost/client.json',
        redirect_uris: ['http://localhost/callback'],
        scope: 'atproto',
        grant_types: ['authorization_code', 'refresh_token'],
        response_types: ['code'],
        token_endpoint_auth_method: 'none',
        dpop_bound_access_tokens: true
      },
      stateStore: mockStateStore,
      sessionStore: mockSessionStore,
      allowHttp: true
    })
  })
  
  test('authorize creates valid URL', async () => {
    const url = await client.authorize('alice.test')
    
    expect(url.searchParams.has('code_challenge')).toBe(true)
    expect(url.searchParams.get('client_id')).toBe('http://localhost/client.json')
    expect(url.searchParams.get('response_type')).toBe('code')
  })
  
  test('callback processes valid response', async () => {
    // Mock the callback flow
    // ...
  })
})

@atproto/oauth-client

Core OAuth client library

@atproto/oauth-client-browser

Browser OAuth client

@atproto/crypto

Cryptographic operations and key management

@atproto/api

High-level API client

TypeScript Types

import type {
  NodeOAuthClient,
  NodeOAuthClientOptions,
  NodeSavedState,
  NodeSavedSession,
  NodeSavedStateStore,
  NodeSavedSessionStore,
  RuntimeLock
} from '@atproto/oauth-client-node'

Build docs developers (and LLMs) love