Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.

feat: restore mobile OAuth redirect support

Mobile apps using ASWebAuthenticationSession (iOS) or Custom Tabs (Android)
need the callback to redirect to their URL scheme to complete the OAuth flow.

Added back:
- mobileScheme config option for app callback URL
- mobile=true query parameter on /login
- mobile field in OAuthState to track through OAuth
- Mobile callback with session_token, did, and handle query params

Security: mobile redirects always use server-configured mobileScheme.
Client-specified redirect schemes are NOT allowed.

+90 -3
+40
CHANGELOG.md
··· 2 2 3 3 All notable changes to this project will be documented in this file. 4 4 5 + ## [2.1.0] - 2025-11-29 6 + 7 + ### Added 8 + 9 + - **Restored mobile OAuth redirect support**: Mobile apps using 10 + ASWebAuthenticationSession (iOS) or Custom Tabs (Android) need the callback to 11 + redirect to their URL scheme to complete the OAuth flow. 12 + 13 + - `mobileScheme` config option - URL scheme for app callback (e.g., 14 + "myapp://auth-callback") 15 + - `mobile=true` query parameter on `/login` - Enables mobile flow 16 + - `mobile` field in `OAuthState` - Tracks mobile flow through OAuth 17 + - Mobile callback with `session_token`, `did`, and `handle` query params 18 + 19 + ### Example 20 + 21 + ```typescript 22 + const oauth = createATProtoOAuth({ 23 + // ... other config 24 + mobileScheme: "anchor-app://auth-callback", 25 + }); 26 + 27 + // Mobile app opens: /login?handle=user.bsky.social&mobile=true 28 + // After OAuth, redirects to: anchor-app://auth-callback?session_token=...&did=...&handle=... 29 + ``` 30 + 31 + ### Security 32 + 33 + - Mobile redirects always use the server-configured `mobileScheme` 34 + - Client-specified redirect schemes are NOT allowed to prevent OAuth redirect 35 + attacks 36 + - Session cookie is also set as fallback for cookie-based API auth 37 + 38 + Note: This does NOT restore Bearer token authentication - mobile apps use 39 + cookie-based auth for API calls after the initial OAuth redirect. 40 + 41 + ### Changed 42 + 43 + - Updated `@tijs/atproto-sessions` dependency to 2.1.0 44 + 5 45 ## [2.0.0] - 2025-11-29 6 46 7 47 ### Breaking Changes
+2 -2
deno.json
··· 1 1 { 2 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 3 "name": "@tijs/atproto-oauth", 4 - "version": "2.0.0", 4 + "version": "2.1.0", 5 5 "license": "MIT", 6 6 "exports": "./mod.ts", 7 7 "publish": { ··· 11 11 "imports": { 12 12 "@std/assert": "jsr:@std/assert@1.0.16", 13 13 "@tijs/oauth-client-deno": "jsr:@tijs/oauth-client-deno@4.0.2", 14 - "@tijs/atproto-sessions": "jsr:@tijs/atproto-sessions@2.0.0", 14 + "@tijs/atproto-sessions": "jsr:@tijs/atproto-sessions@2.1.0", 15 15 "@tijs/atproto-storage": "jsr:@tijs/atproto-storage@0.1.1", 16 16 "@atproto/syntax": "npm:@atproto/syntax@0.3.0" 17 17 },
+1
src/oauth.ts
··· 140 140 storage: config.storage, 141 141 sessionTtl, 142 142 logger, 143 + mobileScheme: config.mobileScheme, 143 144 }); 144 145 145 146 // Generate client metadata
+37 -1
src/routes.ts
··· 27 27 storage: OAuthStorage; 28 28 sessionTtl: number; 29 29 logger: Logger; 30 + /** URL scheme for mobile app OAuth callback (e.g. "myapp://auth-callback") */ 31 + mobileScheme?: string; 30 32 } 31 33 32 34 /** ··· 48 50 storage, 49 51 sessionTtl, 50 52 logger, 53 + mobileScheme, 51 54 } = config; 52 55 53 56 /** ··· 56 59 * Query parameters: 57 60 * - handle: User's AT Protocol handle (required) 58 61 * - redirect: Relative path to redirect after OAuth (optional) 62 + * - mobile: Set to "true" for mobile OAuth flow (redirects to mobileScheme) 59 63 */ 60 64 async function handleLogin(request: Request): Promise<Response> { 61 65 const url = new URL(request.url); 62 66 const handle = url.searchParams.get("handle"); 63 67 const redirect = url.searchParams.get("redirect"); 68 + const mobile = url.searchParams.get("mobile") === "true"; 64 69 65 70 if (!handle || typeof handle !== "string") { 66 71 return new Response("Invalid handle", { status: 400 }); ··· 84 89 } else { 85 90 logger.warn(`Invalid redirect path ignored: ${redirect}`); 86 91 } 92 + } 93 + 94 + // Track mobile flow for callback redirect 95 + if (mobile) { 96 + state.mobile = true; 87 97 } 88 98 89 99 const authUrl = await oauthClient.authorize(handle, { ··· 105 115 106 116 /** 107 117 * Handle /oauth/callback route - complete OAuth flow 118 + * 119 + * For mobile OAuth (state.mobile=true): 120 + * - Redirects to mobileScheme with session_token, did, and handle 121 + * - Also sets cookie for fallback API auth 122 + * 123 + * For web OAuth: 124 + * - Redirects to state.redirectPath or "/" with session cookie 108 125 */ 109 126 async function handleCallback(request: Request): Promise<Response> { 110 127 try { ··· 146 163 lastAccessed: now, 147 164 }); 148 165 149 - // Redirect to stored path or home 166 + // Mobile OAuth: redirect to app's URL scheme 167 + if (state.mobile && mobileScheme) { 168 + const sealedToken = await sessionManager.sealToken({ did }); 169 + const mobileCallbackUrl = new URL(mobileScheme); 170 + mobileCallbackUrl.searchParams.set("session_token", sealedToken); 171 + mobileCallbackUrl.searchParams.set("did", did); 172 + mobileCallbackUrl.searchParams.set("handle", state.handle); 173 + 174 + logger.info(`Mobile OAuth complete, redirecting to ${mobileScheme}`); 175 + 176 + return new Response(null, { 177 + status: 302, 178 + headers: { 179 + Location: mobileCallbackUrl.toString(), 180 + "Set-Cookie": setCookieHeader, 181 + }, 182 + }); 183 + } 184 + 185 + // Web OAuth: redirect to stored path or home 150 186 const redirectPath = state.redirectPath || "/"; 151 187 152 188 return new Response(null, {
+10
src/types.ts
··· 137 137 * Pass console for standard logging. 138 138 */ 139 139 logger?: Logger; 140 + 141 + /** 142 + * URL scheme for mobile app OAuth callback. 143 + * When mobile=true is passed to /login, the callback will redirect to this 144 + * scheme with session_token and did as query params. 145 + * Example: "myapp://auth-callback" or "anchor-app://auth-callback" 146 + */ 147 + mobileScheme?: string; 140 148 } 141 149 142 150 /** ··· 300 308 timestamp: number; 301 309 /** Redirect path after successful web OAuth */ 302 310 redirectPath?: string; 311 + /** Flag for mobile OAuth flow - redirects to mobileScheme instead of web */ 312 + mobile?: boolean; 303 313 } 304 314 305 315 // Re-export OAuthStorage from atproto-storage for convenience