A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
at main 422 lines 15 kB view raw view rendered
1# @tijs/oauth-client-deno 2 3[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/tijsteulings) 4 5A **Deno-compatible** AT Protocol OAuth client built specifically for Deno environments using Web Crypto API. Built to solve crypto compatibility issues between Node.js-specific implementations and Deno runtime environments. 6 7## 🎯 Opinionated Design 8 9This client makes specific design choices that may or may not fit your use case: 10 11- **Handle-focused**: Accepts AT Protocol handles (`alice.bsky.social`) only, not DIDs or URLs 12- **Slingshot resolver**: Uses Slingshot as the default handle resolver with fallbacks 13- **Deno-first**: Built for Deno runtime, not a drop-in replacement for `@atproto/oauth-client-node` 14- **Web Crypto API**: Uses modern web standards instead of Node.js crypto 15 16**This is perfect if you're building Deno applications with handle-based authentication.** For more flexible input types (DIDs, URLs) or Node.js environments, use `@atproto/oauth-client-node` instead. 17 18## ✨ Key Features 19 20- 🔒 **Complete OAuth 2.0 + DPoP**: Full AT Protocol authentication implementation 21- 🛠️ **Configurable Storage**: Memory, LocalStorage, SQLite, or custom backends 22- 🔄 **Multiple Resolvers**: Slingshot, Bluesky API, direct resolution with fallbacks 23- 🚀 **Production Ready**: Comprehensive error handling, session management, and testing 24- 📦 **Zero Dependencies**: Pure Web Standards implementation 25 26## 🚀 Installation 27 28```bash 29# Using JSR (recommended) 30deno add @tijs/oauth-client-deno 31 32# Or import directly from JSR 33import { OAuthClient, MemoryStorage } from "jsr:@tijs/oauth-client-deno"; 34 35# Pin to a specific version (optional) 36import { OAuthClient, MemoryStorage } from "jsr:@tijs/oauth-client-deno@^0.1.2"; 37``` 38 39> **Note**: This package is designed for JSR and includes proper version pinning. Check the [CHANGELOG](CHANGELOG.md) for version history. If the package hasn't been published to JSR yet, it can be published using `deno publish` from this repository. 40 41## 🔄 vs @atproto/oauth-client-node 42 43| Feature | @tijs/oauth-client-deno | @atproto/oauth-client-node | 44| ---------------- | ------------------------------------ | -------------------------------------- | 45| **Input Types** | AT Protocol handles only | Handles, DIDs, PDS URLs, Entryway URLs | 46| **Runtime** | Deno, Web Crypto API | Node.js, Node crypto | 47| **Return Types** | `URL` objects, `URLSearchParams` | `URL` objects, `URLSearchParams` | 48| **Storage** | Memory, LocalStorage, SQLite, custom | Configurable | 49 50## 📖 Quick Start 51 52### Basic Usage 53 54```typescript 55import { MemoryStorage, OAuthClient } from "jsr:@tijs/oauth-client-deno"; 56 57// Initialize client 58const client = new OAuthClient({ 59 clientId: "https://yourapp.com/client-metadata.json", 60 redirectUri: "https://yourapp.com/oauth/callback", 61 storage: new MemoryStorage(), 62}); 63 64// Start OAuth flow 65const authUrl = await client.authorize("alice.bsky.social"); 66console.log("Redirect user to:", authUrl); 67 68// Handle OAuth callback 69const { session } = await client.callback({ 70 code: "authorization_code_from_callback", 71 state: "state_parameter_from_callback", 72}); 73 74// Make authenticated API requests 75const response = await session.makeRequest( 76 "GET", 77 "https://bsky.social/xrpc/com.atproto.repo.listRecords", 78); 79 80const data = await response.json(); 81console.log("User records:", data); 82``` 83 84### Session Management 85 86```typescript 87// Store session for later use 88const sessionId = "user-123"; 89await client.store(sessionId, session); 90 91// Restore session (with automatic token refresh if needed) 92try { 93 const restoredSession = await client.restore(sessionId); 94 console.log("Welcome back,", restoredSession.handle); 95} catch (error) { 96 if (error instanceof SessionNotFoundError) { 97 console.log("Please log in again"); 98 } else if (error instanceof RefreshTokenExpiredError) { 99 console.log("Session expired, please re-authenticate"); 100 } else { 101 throw error; // Unexpected error 102 } 103} 104 105// Manual token refresh 106if (session.isExpired) { 107 const refreshedSession = await client.refresh(session); 108 await client.store(sessionId, refreshedSession); 109} 110 111// Clean logout 112await client.signOut(sessionId, session); 113``` 114 115## 🔧 Configuration Options 116 117### Storage Backends 118 119Choose from built-in storage options or implement your own: 120 121```typescript 122// In-memory storage (development) 123import { MemoryStorage } from "jsr:@tijs/oauth-client-deno"; 124const storage = new MemoryStorage(); 125 126// SQLite storage (for Deno CLI apps) 127import { SQLiteStorage } from "jsr:@tijs/oauth-client-deno"; 128const storage = new SQLiteStorage(sqlite); 129 130// localStorage (for browsers) 131import { LocalStorage } from "jsr:@tijs/oauth-client-deno"; 132const storage = new LocalStorage(); 133 134// Custom storage implementation 135const customStorage = { 136 async get(key) {/* your logic */}, 137 async set(key, value, options) {/* your logic */}, 138 async delete(key) {/* your logic */}, 139}; 140``` 141 142### Handle Resolution 143 144Configure how AT Protocol handles are resolved to DIDs and PDS URLs. **By default, this client uses Slingshot** (https://slingshot.microcosm.blue) as the primary resolver with automatic fallbacks. 145 146#### Default Behavior (Slingshot-first) 147 148```typescript 149import { CustomResolver, DirectoryResolver, SlingshotResolver } from "jsr:@tijs/oauth-client-deno"; 150 151// Default: Slingshot with fallbacks to directory and direct resolution 152const client = new OAuthClient({ 153 // ... other config 154 // Uses Slingshot resolver automatically with fallbacks 155}); 156 157// Resolution order: 158// 1. Slingshot resolveMiniDoc (https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc) 159// 2. Slingshot standard (https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle) 160// 3. Bluesky API (https://bsky.social/xrpc/com.atproto.identity.resolveHandle) 161// 4. Direct handle lookup (https://handle/.well-known/atproto-did) 162``` 163 164#### Alternative Resolution Strategies 165 166If Slingshot doesn't fit your use case, you can configure alternative resolvers: 167 168```typescript 169// Custom Slingshot URL 170const client = new OAuthClient({ 171 // ... other config 172 slingshotUrl: "https://my-custom-slingshot.example.com", 173}); 174 175// Use Bluesky API-first resolution (avoids Slingshot) 176const client = new OAuthClient({ 177 // ... other config 178 handleResolver: new DirectoryResolver(), // Only uses bsky.social API 179}); 180 181// Completely custom resolution logic (full control) 182const client = new OAuthClient({ 183 // ... other config 184 handleResolver: new CustomResolver(async (handle) => { 185 // Your custom handle resolution logic 186 const did = await customResolveHandleToDid(handle); 187 const pdsUrl = await customResolvePdsUrl(did); 188 return { did, pdsUrl }; 189 }), 190}); 191``` 192 193> **Why Slingshot?** Slingshot is a production-grade cache of AT Protocol data that provides faster handle resolution and better reliability, especially during high-traffic periods. It uses the `resolveMiniDoc` endpoint which returns both DID and PDS URL in a single request, reducing the need for multiple lookups. However, it does introduce a dependency on a third-party service. The fallback mechanisms ensure your application continues to work even if Slingshot is unavailable. 194 195### Logging 196 197Control client logging output by providing a custom logger: 198 199```typescript 200import { ConsoleLogger, type Logger } from "jsr:@tijs/oauth-client-deno"; 201 202// Use built-in console logger for development 203const client = new OAuthClient({ 204 // ... other config 205 logger: new ConsoleLogger(), 206}); 207 208// Or implement custom logger for production 209class ProductionLogger implements Logger { 210 debug(message: string, ...args: unknown[]): void { 211 // Send to your logging service 212 } 213 214 info(message: string, ...args: unknown[]): void { 215 logger.log(message, ...args); 216 } 217 218 warn(message: string, ...args: unknown[]): void { 219 logger.warn(message, ...args); 220 } 221 222 error(message: string, ...args: unknown[]): void { 223 logger.error(message, ...args); 224 } 225} 226 227const client = new OAuthClient({ 228 // ... other config 229 logger: new ProductionLogger(), 230}); 231``` 232 233> **Note**: By default, the client uses a no-op logger that produces no output. 234 235## 🏗️ Advanced Usage 236 237### Error Handling 238 239```typescript 240import { 241 HandleResolutionError, 242 InvalidHandleError, 243 OAuthError, 244 SessionError, 245 TokenExchangeError, 246} from "jsr:@tijs/oauth-client-deno"; 247 248try { 249 const authUrl = await client.authorize("invalid.handle"); 250} catch (error) { 251 if (error instanceof InvalidHandleError) { 252 console.error("Handle format is invalid"); 253 } else if (error instanceof HandleResolutionError) { 254 console.error("Could not resolve handle to DID/PDS"); 255 } else { 256 console.error("Unexpected error:", error); 257 } 258} 259``` 260 261### Mobile App Integration 262 263The client works seamlessly with mobile WebView implementations: 264 265```typescript 266// Mobile-friendly configuration 267const client = new OAuthClient({ 268 clientId: "https://myapp.com/client-metadata.json", 269 redirectUri: "myapp://oauth/callback", // Custom URL scheme 270 storage: new MemoryStorage(), // or secure storage implementation 271}); 272 273// Handle custom redirect in mobile app 274const { session } = await client.callback(parsedCallbackParams); 275``` 276 277## 🔍 API Reference 278 279### OAuthClient 280 281Main OAuth client class for AT Protocol authentication. 282 283#### Constructor Options 284 285```typescript 286interface OAuthClientConfig { 287 clientId: string; // Your OAuth client identifier 288 redirectUri: string; // Where users return after auth 289 storage: Storage; // Session storage implementation 290 handleResolver?: HandleResolver; // Custom handle resolution 291 slingshotUrl?: string; // Custom Slingshot URL 292} 293``` 294 295#### Methods 296 297- `authorize(handle: string, options?: AuthorizationUrlOptions): Promise<string>` 298- `callback(params: CallbackParams): Promise<{ session: Session }>` 299- `store(sessionId: string, session: Session): Promise<void>` 300- `restore(sessionId: string): Promise<Session | null>` 301- `refresh(session: Session): Promise<Session>` 302- `signOut(sessionId: string, session: Session): Promise<void>` 303 304### Session 305 306Authenticated user session with automatic token management. 307 308#### Properties 309 310- `did: string` - User's decentralized identifier 311- `handle: string` - User's AT Protocol handle 312- `pdsUrl: string` - User's Personal Data Server URL 313- `accessToken: string` - Current OAuth access token 314- `refreshToken: string` - OAuth refresh token 315- `isExpired: boolean` - Whether token needs refresh 316 317#### Methods 318 319- `makeRequest(method: string, url: string, options?): Promise<Response>` 320- `toJSON(): SessionData` - Serialize for storage 321- `updateTokens(tokens): void` - Update with refreshed tokens 322 323### Storage Interface 324 325Implement this interface for custom storage backends: 326 327```typescript 328interface Storage { 329 get<T>(key: string): Promise<T | null>; 330 set<T>(key: string, value: T, options?: { ttl?: number }): Promise<void>; 331 delete(key: string): Promise<void>; 332} 333``` 334 335## 🆚 Differences from @atproto/oauth-client-node 336 337| Feature | @atproto/oauth-client-node | @tijs/oauth-client-deno | 338| --------------------- | ------------------------------- | ------------------------------------ | 339| **Runtime** | Node.js only | Deno, Browser, Web Standards | 340| **Crypto** | Node.js crypto APIs | Web Crypto API (cross-platform) | 341| **Primary Use Case** | Server-side Node.js apps | Deno apps, edge workers, browsers | 342| **Dependencies** | Node.js built-ins + jose | Web Standards + jose (JSR) | 343| **Handle Resolution** | Configurable resolvers | Slingshot-first with fallbacks | 344| **Storage** | Flexible sessionStore interface | Simple Storage interface + built-ins | 345 346> **Note**: Both clients provide full AT Protocol OAuth + DPoP support and maintain API compatibility. The main difference is runtime compatibility - choose based on your deployment environment. 347 348## 🔧 Development 349 350```bash 351# Check code 352deno check mod.ts 353 354# Format code 355deno fmt 356 357# Lint code 358deno lint 359 360# Run tests 361deno test --allow-net --allow-read 362``` 363 364## 🤝 Contributing 365 3661. Fork the repository 3672. Create a feature branch: `git checkout -b my-feature` 3683. Make changes and add tests 3694. Ensure all checks pass: `deno task check && deno task fmt && deno task lint` 3705. Commit changes: `git commit -am 'Add my feature'` 3716. Push to branch: `git push origin my-feature` 3727. Create a Pull Request 373 374## 📄 License 375 376MIT License - see [LICENSE](LICENSE) file for details. 377 378## 🧩 Why This Package Exists 379 380The official `@atproto/oauth-client-node` package has fundamental compatibility issues with Deno runtime environments. This package was created to solve these specific problems: 381 382### Root Cause Analysis 383 3841. **Node.js-Specific Dependencies**: The official package relies on Node.js-specific crypto dependencies (`@atproto/jwk-jose`, `@atproto/jwk-webcrypto`) that don't work in Deno. 385 3862. **Jose Library Compatibility**: The underlying jose library (when used through Node.js-specific packages) throws `JOSENotSupported: Unsupported key curve for this operation` errors in Deno, specifically when generating ECDSA P-256 keys for DPoP (Demonstrating Proof of Possession) JWT operations. 387 3883. **DPoP Implementation Problem**: AT Protocol OAuth requires DPoP proofs using ES256 signatures with ECDSA P-256 curves. The Node.js crypto implementations in the official client don't translate to Deno's Web Crypto API properly. 389 390### Our Solution 391 392This package solves these issues by: 393 394- **Using Web Crypto API directly** (`crypto.subtle.generateKey`) instead of Node.js crypto 395- **JSR-native jose imports** (`jsr:@panva/jose`) instead of Node.js-specific versions 396- **Manual ECDSA P-256 key generation** with explicit curve specification (`namedCurve: "P-256"`) 397- **Direct DPoP JWT creation** using Web Crypto compatible `SignJWT` operations 398- **Cross-platform compatibility** that works in Deno, browsers, and other Web Standards environments 399 400The implementation maintains full API compatibility with the original Node.js client while providing a native Web Standards foundation. 401 402## ☕ Support Development 403 404If this package helps your app development, consider [supporting on Ko-fi](https://ko-fi.com/tijsteulings). Your support helps maintain and improve this package. 405 406## Acknowledgments 407 408This package implements the [AT Protocol OAuth specification](https://atproto.com/specs/oauth) 409for Deno environments using Web Crypto APIs. 410 411**Based on specifications and patterns from:** 412 413- [@atproto/oauth-client](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) 414 and [@atproto/oauth-client-node](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node) 415 (Copyright 2022-2025 Bluesky Social PBC, MIT and Apache 2.0) 416- [Bookhive OAuth implementation](https://github.com/nperez0111/bookhive) 417 418Thanks to the Bluesky team for the AT Protocol ecosystem. 419 420## See Also 421 422- **[@tijs/atproto-oauth-hono](https://jsr.io/@tijs/atproto-oauth-hono)**: High-level Hono integration built on top of this client.