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-provider package provides a complete OAuth 2.0 and OpenID Connect authorization server implementation for Node.js. It’s designed specifically for AT Protocol and includes all required features for PDS (Personal Data Server) implementations.
This package is primarily used by PDS implementations. Most application developers should use the client packages instead.

Installation

npm install @atproto/oauth-provider

Requirements

  • Node.js 18.7.0 or higher
  • Storage backend (database)
  • Redis (optional, for replay protection and distributed deployments)

When to Use This Package

Use @atproto/oauth-provider when:
  • Implementing a PDS (Personal Data Server)
  • Building a custom AT Protocol authorization server
  • Running your own OAuth infrastructure
This is a server-side package for authorization servers. If you’re building a client application, use @atproto/oauth-client-node or @atproto/oauth-client-browser instead.

Quick Start

import { OAuthProvider } from '@atproto/oauth-provider'
import { Keyset } from '@atproto/jwk'
import express from 'express'

const provider = new OAuthProvider({
  issuer: 'https://auth.my-pds.com',
  
  keyset: await Keyset.fromImportable([
    process.env.SIGNING_KEY_1,
    process.env.SIGNING_KEY_2
  ]),
  
  // Store implementations
  accountStore: myAccountStore,
  clientStore: myClientStore,
  deviceStore: myDeviceStore,
  requestStore: myRequestStore,
  tokenStore: myTokenStore,
  
  // Hook implementations
  hooks: {
    onSignedIn: async ({ account, deviceId }) => {
      console.log('User signed in:', account.sub)
    },
    onTokenCreated: async ({ account, client }) => {
      console.log('Token created:', account.sub, client.id)
    }
  }
})

const app = express()

// Mount OAuth routes
app.use(provider.middleware())

app.listen(3000)

OAuthProvider Class

Constructor

new OAuthProvider(config: OAuthProviderConfig)
Creates a new OAuth provider instance.
issuer
string | URL
required
The issuer identifier (base URL) of the authorization server.
issuer: 'https://auth.my-pds.com'
keyset
Keyset | Iterable<Key>
required
Private keys for signing tokens and credentials.
import { JoseKey } from '@atproto/oauth-provider'

keyset: await Promise.all([
  JoseKey.fromImportable(process.env.SIGNING_KEY_1),
  JoseKey.fromImportable(process.env.SIGNING_KEY_2)
])
store
Store
Single store implementation for all storage needs. Can implement multiple interfaces:
  • AccountStore
  • ClientStore
  • DeviceStore
  • RequestStore
  • TokenStore
  • LexiconStore
  • ReplayStore
accountStore
AccountStore
Storage for user accounts. Required if not provided via store.
clientStore
ClientStore
Storage for OAuth clients. Required if not provided via store.
deviceStore
DeviceStore
Storage for device/session information. Required if not provided via store.
requestStore
RequestStore
Storage for authorization requests. Required if not provided via store.
tokenStore
TokenStore
Storage for tokens and refresh tokens. Required if not provided via store.
lexiconStore
LexiconStore
Storage for lexicon schemas (optional).
replayStore
ReplayStore
Storage for replay protection (optional, uses memory by default).
redis
Redis | RedisOptions | string
Redis connection for replay protection and distributed deployments.
redis: 'redis://localhost:6379'
// or
redis: { host: 'localhost', port: 6379 }
// or
redis: redisInstance
hooks
OAuthHooks
Lifecycle hooks for customization and integration.
authenticationMaxAge
number
default:"86400000"
Maximum age (ms) for authentication sessions before requiring re-authentication. Default: 24 hours.
tokenMaxAge
number
default:"3600000"
Maximum age (ms) for access tokens before requiring refresh. Default: 1 hour.
accessTokenMode
AccessTokenMode
default:"AccessTokenMode.stateless"
Token mode:
  • stateless: Self-contained tokens (recommended)
  • light: Token ID only, requires database lookup
metadata
CustomMetadata
Additional metadata for the discovery document.
lexResolver
LexResolver
Lexicon resolver for schema validation.
safeFetch
Fetch
Custom fetch implementation for retrieving client metadata.

Methods

middleware

middleware(options?: {
  errorHandler?: ErrorHandler
}): RequestHandler
Returns Express middleware that handles all OAuth endpoints.
errorHandler
ErrorHandler
Custom error handler for OAuth errors.
Example:
import express from 'express'

const app = express()

app.use(provider.middleware({
  errorHandler: (err, req, res, next) => {
    console.error('OAuth error:', err)
    res.status(err.status || 500).json({
      error: err.error || 'server_error',
      error_description: err.message
    })
  }
}))

authenticateRequest

async authenticateRequest(
  request: Request,
  options?: VerifyTokenPayloadOptions
): Promise<AccessTokenPayload>
Authenticates an incoming HTTP request by validating the access token.
request
Request
required
HTTP request with Authorization header.
options.audience
string[]
Required audience values.
options.scope
string[]
Required scopes.
payload
AccessTokenPayload
Decoded and validated token payload.
Example:
app.get('/api/protected', async (req, res) => {
  try {
    const payload = await provider.authenticateRequest(req, {
      scope: ['atproto'],
      audience: ['https://my-pds.com']
    })
    
    // Request is authenticated
    res.json({
      message: 'Authenticated',
      user: payload.sub
    })
  } catch (err) {
    res.status(401).json({ error: 'Unauthorized' })
  }
})

Storage Interfaces

AccountStore

Manages user accounts and authentication.
interface AccountStore {
  // Account management
  createAccount(data: SignUpData): Promise<Account>
  getAccount(sub: string): Promise<Account | null>
  updateAccount(sub: string, data: Partial<Account>): Promise<void>
  
  // Authentication
  authenticateAccount(credentials: SignInData): Promise<Account>
  
  // Password reset
  requestPasswordReset(input: ResetPasswordRequestInput): Promise<Account | null>
  confirmPasswordReset(input: ResetPasswordConfirmInput): Promise<Account>
  
  // Authorization
  getAuthorizedClients(sub: string): Promise<AuthorizedClientData[]>
  saveAuthorizedClient(sub: string, client: AuthorizedClientData): Promise<void>
  revokeAuthorizedClient(sub: string, clientId: string): Promise<void>
}
Example Implementation:
const accountStore: AccountStore = {
  async createAccount(data) {
    const account = await db.accounts.create({
      email: data.email,
      password: await hash(data.password),
      handle: data.handle
    })
    
    return {
      sub: account.did,
      email: account.email,
      email_verified: false
    }
  },
  
  async getAccount(sub) {
    const account = await db.accounts.findByDid(sub)
    if (!account) return null
    
    return {
      sub: account.did,
      email: account.email,
      email_verified: account.emailVerified
    }
  },
  
  async authenticateAccount(credentials) {
    const account = await db.accounts.findByEmail(credentials.identifier)
    if (!account) throw new Error('Account not found')
    
    const valid = await verify(credentials.password, account.password)
    if (!valid) throw new Error('Invalid password')
    
    return {
      sub: account.did,
      email: account.email,
      email_verified: account.emailVerified
    }
  },
  
  // ... other methods
}

ClientStore

Manages OAuth client registrations.
interface ClientStore {
  getClient(clientId: ClientId): Promise<ClientData | null>
  saveClient(clientId: ClientId, data: ClientData): Promise<void>
}
Example:
const clientStore: ClientStore = {
  async getClient(clientId) {
    // Check cache
    const cached = await redis.get(`client:${clientId}`)
    if (cached) return JSON.parse(cached)
    
    // Fetch from client_id URL
    const response = await fetch(clientId)
    if (!response.ok) return null
    
    const metadata = await response.json()
    
    // Cache for 1 hour
    await redis.setex(`client:${clientId}`, 3600, JSON.stringify({
      metadata,
      jwks: await fetchJwks(metadata.jwks_uri)
    }))
    
    return { metadata, jwks }
  },
  
  async saveClient(clientId, data) {
    await redis.setex(`client:${clientId}`, 3600, JSON.stringify(data))
  }
}

TokenStore

Manages access and refresh tokens.
interface TokenStore {
  // Token management
  createToken(data: TokenData): Promise<void>
  getToken(id: string): Promise<TokenData | null>
  revokeToken(id: string): Promise<void>
  
  // Refresh tokens
  createRefreshToken(data: RefreshTokenData): Promise<void>
  getRefreshToken(id: string): Promise<RefreshTokenData | null>
  revokeRefreshToken(id: string): Promise<void>
  
  // Cleanup
  revokeTokensByAccount(sub: string): Promise<void>
  revokeTokensByClient(sub: string, clientId: string): Promise<void>
}
Example:
const tokenStore: TokenStore = {
  async createToken(data) {
    await db.tokens.create({
      id: data.id,
      sub: data.sub,
      client_id: data.client_id,
      scope: data.scope,
      expires_at: data.expires_at
    })
  },
  
  async getToken(id) {
    const token = await db.tokens.findById(id)
    if (!token) return null
    if (token.expires_at < Date.now()) return null
    return token
  },
  
  async revokeToken(id) {
    await db.tokens.delete(id)
  },
  
  async revokeTokensByAccount(sub) {
    await db.tokens.deleteWhere({ sub })
  },
  
  // ... other methods
}

OAuth Hooks

Hooks provide integration points for custom logic:

onSignedIn

Called when a user successfully signs in.
hooks: {
  async onSignedIn({ account, data, deviceId, deviceMetadata }) {
    // Log sign-in
    await auditLog.create({
      event: 'signin',
      user: account.sub,
      device: deviceId,
      ip: deviceMetadata.ip
    })
    
    // Send notification
    await sendEmail(account.email, 'New sign-in detected')
  }
}

onSignedUp

Called when a new user signs up.
hooks: {
  async onSignedUp({ account, data, deviceId }) {
    // Send welcome email
    await sendWelcomeEmail(account.email)
    
    // Create default data
    await initializeUserData(account.sub)
    
    // Track analytics
    analytics.track('signup', { user: account.sub })
  }
}

onAuthorized

Called when a client is authorized.
hooks: {
  async onAuthorized({ account, client, parameters, deviceId }) {
    // Validate scope
    const requestedScopes = parameters.scope?.split(' ') || []
    const allowedScopes = await getScopesForUser(account.sub)
    
    if (!requestedScopes.every(s => allowedScopes.includes(s))) {
      throw new InvalidScopeError('Requested scope not allowed')
    }
    
    // Log authorization
    await auditLog.create({
      event: 'authorized',
      user: account.sub,
      client: client.id,
      scopes: parameters.scope
    })
  }
}

onTokenCreated

Called when an access token is created.
hooks: {
  async onTokenCreated({ account, client, parameters }) {
    // Rate limiting
    const count = await redis.incr(`tokens:${account.sub}:${client.id}`)
    await redis.expire(`tokens:${account.sub}:${client.id}`, 3600)
    
    if (count > 100) {
      throw new OAuthError('rate_limit_exceeded')
    }
    
    // Metrics
    metrics.increment('tokens.created', {
      client: client.id
    })
  }
}

onTokenRefreshed

Called when a token is refreshed.
hooks: {
  async onTokenRefreshed({ account, client, parameters }) {
    // Check if account is still active
    const user = await db.users.findByDid(account.sub)
    if (user.suspended) {
      throw new OAuthError('account_suspended')
    }
    
    // Update last active timestamp
    await db.users.update(account.sub, {
      last_active: new Date()
    })
  }
}

onCreateToken

Modify token claims before creation.
hooks: {
  async onCreateToken({ account, client, parameters, claims }) {
    // Add custom claims
    return {
      ...claims,
      custom_claim: 'value',
      user_role: await getUserRole(account.sub)
    }
  }
}

onDecodeToken

Modify or validate token after decoding.
hooks: {
  async onDecodeToken({ tokenType, token, payload, dpopProof }) {
    // Validate custom claims
    if (payload.custom_claim !== 'expected_value') {
      throw new InvalidTokenError('Invalid custom claim')
    }
    
    // Add runtime data
    return {
      ...payload,
      realtime_data: await fetchRealtimeData(payload.sub)
    }
  }
}

Customization

Branding

Customize the authorization UI.
const provider = new OAuthProvider({
  customization: {
    branding: {
      name: 'My PDS',
      logoUrl: 'https://my-pds.com/logo.png',
      colors: {
        primary: '#0066cc',
        background: '#ffffff',
        text: '#000000'
      }
    },
    links: {
      termsOfService: 'https://my-pds.com/terms',
      privacyPolicy: 'https://my-pds.com/privacy',
      support: 'https://my-pds.com/support'
    }
  },
  // ...
})

Custom Error Handler

const errorHandler: ErrorHandler = (err, req, res, next) => {
  // Log error
  logger.error('OAuth error:', {
    error: err.error,
    description: err.error_description,
    status: err.status,
    url: req.url
  })
  
  // Send metrics
  metrics.increment('oauth.errors', {
    error: err.error,
    status: err.status
  })
  
  // Custom response
  res.status(err.status || 500).json({
    error: err.error || 'server_error',
    error_description: err.error_description || 'An error occurred',
    error_uri: 'https://my-pds.com/docs/errors/' + err.error
  })
}

app.use(provider.middleware({ errorHandler }))

Complete Example

import { OAuthProvider, AccessTokenMode } from '@atproto/oauth-provider'
import { JoseKey } from '@atproto/jwk-jose'
import express from 'express'
import { createClient } from 'redis'
import { Pool } from 'pg'

// Database
const pool = new Pool({
  connectionString: process.env.DATABASE_URL
})

// Redis
const redis = createClient({
  url: process.env.REDIS_URL
})
await redis.connect()

// Account store
const accountStore = {
  async createAccount(data) {
    const result = await pool.query(
      'INSERT INTO accounts (email, password, handle) VALUES ($1, $2, $3) RETURNING *',
      [data.email, await hash(data.password), data.handle]
    )
    return { sub: result.rows[0].did, email: result.rows[0].email }
  },
  
  async getAccount(sub) {
    const result = await pool.query(
      'SELECT * FROM accounts WHERE did = $1',
      [sub]
    )
    if (result.rows.length === 0) return null
    return { sub: result.rows[0].did, email: result.rows[0].email }
  },
  
  async authenticateAccount(credentials) {
    const result = await pool.query(
      'SELECT * FROM accounts WHERE email = $1',
      [credentials.identifier]
    )
    if (result.rows.length === 0) throw new Error('Account not found')
    
    const account = result.rows[0]
    const valid = await verify(credentials.password, account.password)
    if (!valid) throw new Error('Invalid password')
    
    return { sub: account.did, email: account.email }
  },
  
  // ... other methods
}

// Token store
const tokenStore = {
  async createToken(data) {
    await pool.query(
      'INSERT INTO tokens (id, sub, client_id, scope, expires_at) VALUES ($1, $2, $3, $4, $5)',
      [data.id, data.sub, data.client_id, data.scope, data.expires_at]
    )
  },
  
  async getToken(id) {
    const result = await pool.query(
      'SELECT * FROM tokens WHERE id = $1 AND expires_at > NOW()',
      [id]
    )
    return result.rows[0] || null
  },
  
  async revokeToken(id) {
    await pool.query('DELETE FROM tokens WHERE id = $1', [id])
  },
  
  // ... other methods
}

// Create provider
const provider = new OAuthProvider({
  issuer: process.env.ISSUER_URL,
  
  keyset: await Promise.all([
    JoseKey.fromImportable(process.env.SIGNING_KEY_1),
    JoseKey.fromImportable(process.env.SIGNING_KEY_2)
  ]),
  
  redis,
  
  accountStore,
  tokenStore,
  clientStore: { /* ... */ },
  deviceStore: { /* ... */ },
  requestStore: { /* ... */ },
  
  accessTokenMode: AccessTokenMode.stateless,
  
  hooks: {
    async onSignedIn({ account }) {
      console.log('User signed in:', account.sub)
    },
    
    async onTokenCreated({ account, client }) {
      console.log('Token created:', account.sub, client.id)
    }
  }
})

// Express app
const app = express()

app.use(express.json())
app.use(express.urlencoded({ extended: true }))

// OAuth routes
app.use(provider.middleware())

// Protected API endpoint
app.get('/api/user', async (req, res) => {
  try {
    const payload = await provider.authenticateRequest(req)
    res.json({ user: payload.sub })
  } catch (err) {
    res.status(401).json({ error: 'Unauthorized' })
  }
})

app.listen(3000)

OAuth Endpoints

The provider automatically creates these endpoints:
  • GET /.well-known/oauth-authorization-server - Server metadata
  • GET /.well-known/openid-configuration - OpenID configuration
  • GET /oauth/authorize - Authorization endpoint
  • POST /oauth/token - Token endpoint
  • POST /oauth/revoke - Revocation endpoint
  • POST /oauth/par - Pushed authorization request
  • GET /oauth/jwks - JSON Web Key Set

Security Considerations

Key Management

// Rotate keys regularly
const keyset = [
  await JoseKey.fromImportable(process.env.CURRENT_KEY, 'current'),
  await JoseKey.fromImportable(process.env.PREVIOUS_KEY, 'previous')
]

const provider = new OAuthProvider({ keyset, /* ... */ })

Rate Limiting

import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100
})

app.use('/oauth', limiter)
app.use(provider.middleware())

Replay Protection

const provider = new OAuthProvider({
  redis: process.env.REDIS_URL, // Required for distributed deployments
  // ...
})

@atproto/oauth-client-node

Node.js OAuth client

@atproto/oauth-client-browser

Browser OAuth client

@atproto/pds

Personal Data Server implementation

@atproto/crypto

Cryptographic operations and key management

TypeScript Types

import type {
  OAuthProvider,
  OAuthProviderConfig,
  OAuthHooks,
  AccountStore,
  ClientStore,
  DeviceStore,
  TokenStore,
  RequestStore,
  AccessTokenMode,
  AccessTokenPayload,
  Customization
} from '@atproto/oauth-provider'

Build docs developers (and LLMs) love