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
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.
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 )
])
Single store implementation for all storage needs. Can implement multiple interfaces:
AccountStore
ClientStore
DeviceStore
RequestStore
TokenStore
LexiconStore
ReplayStore
Storage for user accounts. Required if not provided via store.
Storage for OAuth clients. Required if not provided via store.
Storage for device/session information. Required if not provided via store.
Storage for authorization requests. Required if not provided via store.
Storage for tokens and refresh tokens. Required if not provided via store.
Storage for lexicon schemas (optional).
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
Lifecycle hooks for customization and integration.
Maximum age (ms) for authentication sessions before requiring re-authentication.
Default: 24 hours.
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
Additional metadata for the discovery document.
Lexicon resolver for schema validation.
Custom fetch implementation for retrieving client metadata.
Methods
middleware
middleware ( options ?: {
errorHandler? : ErrorHandler
}): RequestHandler
Returns Express middleware that handles all OAuth endpoints.
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.
HTTP request with Authorization header.
Required audience values.
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'