A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node

feat!: match @atproto/oauth-client interface exactly

BREAKING CHANGES:
- authorize() now returns URL instead of string
- callback() now takes URLSearchParams instead of object
- callback() return format changed to { session: OAuthSession, state: string | null }
- Session class now implements OAuthSession interface with sub/aud properties
- Removed legacy interfaces (AuthorizationUrlOptions, CallbackParams, CallbackResult)
- Added AuthorizeOptions, CallbackOptions to match @atproto exactly

This makes @tijs/oauth-client-deno a 100% drop-in replacement for @atproto/oauth-client-node

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1 -1
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "0.1.2", 3 + "version": "1.0.0", 4 4 "description": "AT Protocol OAuth client for Deno - drop-in replacement for @atproto/oauth-client-node with Web Crypto API compatibility", 5 5 "license": "MIT", 6 6 "repository": {
+4 -3
mod.ts
··· 41 41 SlingshotResolver, 42 42 } from "./src/resolvers.ts"; 43 43 export type { 44 - AuthorizationUrlOptions, 45 - CallbackParams, 46 - CallbackResult, 44 + AuthorizeOptions, 45 + CallbackOptions, 47 46 HandleResolver, 48 47 OAuthClientConfig, 48 + OAuthSession, 49 + OAuthStorage, 49 50 } from "./src/types.ts"; 50 51 export * from "./src/errors.ts";
+24 -19
src/client.ts
··· 5 5 6 6 import { isValidHandle } from "npm:@atproto/syntax@0.4.0"; 7 7 import type { 8 - AuthorizationUrlOptions, 9 - CallbackParams, 10 - CallbackResult, 8 + AuthorizeOptions, 9 + CallbackOptions, 11 10 OAuthClientConfig, 11 + OAuthSession, 12 12 OAuthStorage, 13 13 } from "./types.ts"; 14 14 import { Session, type SessionData } from "./session.ts"; ··· 124 124 * ``` 125 125 */ 126 126 async authorize( 127 - handle: string, 128 - options?: AuthorizationUrlOptions, 129 - ): Promise<string> { 130 - if (!isValidHandle(handle)) { 131 - throw new InvalidHandleError(handle); 127 + input: string, 128 + options?: AuthorizeOptions, 129 + ): Promise<URL> { 130 + if (!isValidHandle(input)) { 131 + throw new InvalidHandleError(input); 132 132 } 133 133 134 134 try { 135 135 // Resolve handle to get user's PDS and DID 136 - const resolved = await this.handleResolver(handle); 136 + const resolved = await this.handleResolver(input); 137 137 138 138 // Discover OAuth endpoints from the PDS 139 139 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(resolved.pdsUrl); ··· 148 148 await this.storage.set(`pkce:${state}`, { 149 149 codeVerifier, 150 150 authServer, 151 - handle, 151 + handle: input, 152 152 did: resolved.did, 153 153 pdsUrl: resolved.pdsUrl, 154 154 }, { ttl: 600 }); // 10 minutes ··· 160 160 codeChallenge, 161 161 state, 162 162 scope: options?.scope ?? "atproto transition:generic", 163 - loginHint: options?.loginHint ?? handle, 163 + loginHint: options?.loginHint ?? input, 164 164 }, 165 165 ); 166 166 167 - return parUrl; 167 + return new URL(parUrl); 168 168 } catch (error) { 169 169 if (error instanceof OAuthError) { 170 170 throw error; ··· 202 202 * console.log("Authenticated as:", session.handle); 203 203 * ``` 204 204 */ 205 - async callback(params: CallbackParams): Promise<CallbackResult> { 206 - if (params.error) { 207 - throw new AuthorizationError(params.error, params.error_description); 205 + async callback( 206 + params: URLSearchParams, 207 + _options?: CallbackOptions, 208 + ): Promise<{ session: OAuthSession; state: string | null }> { 209 + const error = params.get("error"); 210 + if (error) { 211 + throw new AuthorizationError(error, params.get("error_description") || undefined); 208 212 } 209 213 210 - if (!params.code) { 214 + const code = params.get("code"); 215 + if (!code) { 211 216 throw new OAuthError("Missing authorization code in callback"); 212 217 } 213 218 214 - const state = params.state ?? ""; 219 + const state = params.get("state") || ""; 215 220 216 221 // Retrieve PKCE data 217 222 const pkceData = await this.storage.get<{ ··· 232 237 // Exchange authorization code for tokens 233 238 const tokens = await this.exchangeCodeForTokens( 234 239 pkceData.authServer, 235 - params.code, 240 + code, 236 241 pkceData.codeVerifier, 237 242 dpopKeys, 238 243 ); ··· 254 259 // Clean up PKCE data 255 260 await this.storage.delete(`pkce:${state}`); 256 261 257 - return { session: session.toJSON() }; 262 + return { session: session as OAuthSession, state: params.get("state") }; 258 263 } catch (error) { 259 264 // Clean up PKCE data even on error 260 265 await this.storage.delete(`pkce:${state}`);
+18 -7
src/session.ts
··· 3 3 * @module 4 4 */ 5 5 6 - import type { SessionData } from "./types.ts"; 6 + import type { OAuthSession, SessionData } from "./types.ts"; 7 7 import { importPrivateKeyFromJWK, makeDPoPRequest } from "./dpop.ts"; 8 8 import { SessionError } from "./errors.ts"; 9 9 ··· 45 45 * const restored = Session.fromJSON(sessionData); 46 46 * ``` 47 47 */ 48 - export class Session { 48 + export class Session implements OAuthSession { 49 49 constructor(private data: SessionData) {} 50 50 51 51 /** ··· 63 63 } 64 64 65 65 /** 66 + * Subject (same as DID for AT Protocol) 67 + */ 68 + get sub(): string { 69 + return this.data.did; 70 + } 71 + 72 + /** 73 + * Audience (PDS URL) 74 + */ 75 + get aud(): string { 76 + return this.data.pdsUrl; 77 + } 78 + 79 + /** 66 80 * User's PDS (Personal Data Server) URL 67 81 */ 68 82 get pdsUrl(): string { ··· 140 154 async makeRequest( 141 155 method: string, 142 156 url: string, 143 - options?: { 144 - body?: string; 145 - headers?: HeadersInit; 146 - }, 157 + options?: RequestInit, 147 158 ): Promise<Response> { 148 159 try { 149 160 // Import private key for signing ··· 157 168 this.data.accessToken, 158 169 privateKey, 159 170 this.data.dpopPublicKeyJWK, 160 - options?.body, 171 + options?.body as string, 161 172 options?.headers, 162 173 ); 163 174 } catch (error) {
+26 -20
src/types.ts
··· 101 101 slingshotUrl?: string; 102 102 } 103 103 104 - export interface AuthorizationUrlOptions { 104 + /** 105 + * Authorization options matching @atproto/oauth-client interface 106 + */ 107 + export interface AuthorizeOptions { 105 108 /** 106 109 * State parameter for CSRF protection (optional, auto-generated if not provided) 107 110 */ ··· 116 119 * Login hint for the authorization server 117 120 */ 118 121 loginHint?: string; 119 - } 120 122 121 - export interface CallbackParams { 122 123 /** 123 - * Authorization code from OAuth callback 124 + * AbortSignal for canceling authorization 124 125 */ 125 - code: string; 126 + signal?: AbortSignal; 127 + } 126 128 127 - /** 128 - * State parameter for CSRF validation 129 - */ 130 - state?: string; 131 - 132 - /** 133 - * Error code if authorization failed 134 - */ 135 - error?: string; 136 - 129 + /** 130 + * Callback options matching @atproto/oauth-client interface 131 + */ 132 + export interface CallbackOptions { 137 133 /** 138 - * Error description if authorization failed 134 + * Redirect URI override (optional) 139 135 */ 140 - error_description?: string; 136 + redirect_uri?: string; 141 137 } 142 138 143 - export interface CallbackResult { 139 + /** 140 + * OAuth session interface matching @atproto/oauth-client 141 + */ 142 + export interface OAuthSession { 143 + did: string; 144 + handle?: string; 145 + accessToken: string; 146 + refreshToken?: string; 147 + sub: string; 148 + aud: string; 149 + 144 150 /** 145 - * Authenticated session 151 + * Make authenticated request with automatic DPoP header 146 152 */ 147 - session: SessionData; 153 + makeRequest(method: string, url: string, options?: RequestInit): Promise<Response>; 148 154 }
+5
tests/session_test.ts
··· 42 42 assertEquals(session.accessToken, "test_access_token"); 43 43 assertEquals(session.refreshToken, "test_refresh_token"); 44 44 }); 45 + 46 + await t.step("should expose OAuthSession interface properties", () => { 47 + assertEquals(session.sub, "did:plc:test123"); // same as DID 48 + assertEquals(session.aud, "https://test.bsky.social"); // same as pdsUrl 49 + }); 45 50 }); 46 51 47 52 Deno.test("Session - Expiration Logic", async (t) => {