A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
1# @tijs/oauth-client-deno
2
3[](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.