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'
}
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' ]
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 )
}
}
Custom runtime implementation. Defaults to Node.js crypto.
Custom fetch implementation.
PLC directory URL for DID resolution.
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
Public JWKS for exposing at jwks_uri. app . get ( '/jwks.json' , ( req , res ) => {
res . json ( client . jwks )
})
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'