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)
OAuthClientMetadataInput
required
Client metadata configuration.Backend clients should use:Native/mobile apps should use:
{
token_endpoint_auth_method: 'private_key_jwt',
jwks_uri: 'https://my-app.com/jwks.json'
}
{
token_endpoint_auth_method: 'none',
application_type: 'native'
}
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')
])
'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).NodeSavedStateStore
required
Storage for OAuth state during authorization flows.State should be deleted after 1 hour.
interface NodeSavedStateStore {
set(key: string, state: NodeSavedState): Promise<void>
get(key: string): Promise<NodeSavedState | undefined>
del(key: string): Promise<void>
}
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>
}
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']
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
Custom runtime implementation. Defaults to Node.js crypto.
Fetch
Custom fetch implementation.
string
PLC directory URL for DID resolution.
boolean
default:"false"
Allow HTTP for development. Never use in production.
Methods
TheNodeOAuthClient extends OAuthClient and inherits all its methods:
authorize
async authorize(
input: string,
options?: AuthorizeOptions
): Promise<URL>
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 }>
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>
// 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>
app.post('/logout', async (req, res) => {
await client.revoke(req.session.did)
req.session.destroy()
res.redirect('/')
})
Properties
Jwks
Public JWKS for exposing at
jwks_uri.app.get('/jwks.json', (req, res) => {
res.json(client.jwks)
})
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
// ...
})
})
Related Packages
@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'