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

Add PWA OAuth support with popup flow and postMessage

- Add pwa=true query parameter to /login for PWA OAuth flows
- Add pwa field to OAuthState interface
- Return HTML callback page with postMessage for PWA mode
- Popup closes automatically after successful auth
- Cookie is still set for API authentication

This enables PWAs running in standalone mode to complete OAuth
without losing their context, by keeping the PWA open while
auth happens in a popup window.

+128 -1
+36
CHANGELOG.md
··· 2 2 3 3 All notable changes to this project will be documented in this file. 4 4 5 + ## [2.5.0] - 2025-01-09 6 + 7 + ### Added 8 + 9 + - **PWA OAuth support**: Added `pwa=true` query parameter for Progressive Web 10 + App OAuth flows. When enabled, the OAuth callback returns an HTML page that 11 + uses `postMessage` to communicate the session back to the opener window, 12 + instead of redirecting. This allows PWAs running in standalone mode to 13 + complete OAuth without losing their context. 14 + 15 + - `pwa=true` query parameter on `/login` - Enables PWA flow 16 + - `pwa` field in `OAuthState` - Tracks PWA flow through OAuth 17 + - HTML callback page with `postMessage` for session transfer 18 + - Automatic popup close after successful auth 19 + 20 + ### Example 21 + 22 + ```typescript 23 + // PWA detects standalone mode and opens OAuth in popup 24 + const popup = window.open("/login?handle=user.bsky&pwa=true", "oauth-popup"); 25 + 26 + // Listen for postMessage from popup 27 + window.addEventListener("message", (event) => { 28 + if (event.data.type === "oauth-callback" && event.data.success) { 29 + // Session cookie is set, reload to pick it up 30 + location.reload(); 31 + } 32 + }); 33 + ``` 34 + 35 + ### Security 36 + 37 + - PWA callbacks still set the session cookie for API authentication 38 + - The `postMessage` only sends `did` and `handle` (no tokens) 39 + - Fallback redirect to home page if `window.opener` is unavailable 40 + 5 41 ## [2.4.0] - 2025-12-14 6 42 7 43 ### Added
+1 -1
deno.json
··· 1 1 { 2 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 3 "name": "@tijs/atproto-oauth", 4 - "version": "2.4.0", 4 + "version": "2.5.0", 5 5 "license": "MIT", 6 6 "exports": "./mod.ts", 7 7 "publish": {
+89
src/routes.ts
··· 63 63 * - handle: User's AT Protocol handle (required) 64 64 * - redirect: Relative path to redirect after OAuth (optional) 65 65 * - mobile: Set to "true" for mobile OAuth flow (redirects to mobileScheme) 66 + * - pwa: Set to "true" for PWA OAuth flow (returns HTML with postMessage) 66 67 */ 67 68 async function handleLogin(request: Request): Promise<Response> { 68 69 const url = new URL(request.url); 69 70 const handle = url.searchParams.get("handle"); 70 71 const redirect = url.searchParams.get("redirect"); 71 72 const mobile = url.searchParams.get("mobile") === "true"; 73 + const pwa = url.searchParams.get("pwa") === "true"; 72 74 73 75 if (!handle || typeof handle !== "string") { 74 76 return new Response("Invalid handle", { status: 400 }); ··· 99 101 state.mobile = true; 100 102 } 101 103 104 + // Track PWA flow for postMessage callback 105 + if (pwa) { 106 + state.pwa = true; 107 + } 108 + 102 109 const authUrl = await oauthClient.authorize(handle, { 103 110 state: JSON.stringify(state), 104 111 scope, ··· 123 130 * For mobile OAuth (state.mobile=true): 124 131 * - Redirects to mobileScheme with session_token, did, and handle 125 132 * - Also sets cookie for fallback API auth 133 + * 134 + * For PWA OAuth (state.pwa=true): 135 + * - Returns HTML page that sends session via postMessage to opener 136 + * - Also sets cookie for API auth 126 137 * 127 138 * For web OAuth: 128 139 * - Redirects to state.redirectPath or "/" with session cookie ··· 181 192 status: 302, 182 193 headers: { 183 194 Location: mobileCallbackUrl.toString(), 195 + "Set-Cookie": setCookieHeader, 196 + }, 197 + }); 198 + } 199 + 200 + // PWA OAuth: return HTML page with postMessage 201 + if (state.pwa) { 202 + logger.info(`PWA OAuth complete for ${state.handle}`); 203 + 204 + const html = `<!DOCTYPE html> 205 + <html> 206 + <head> 207 + <meta charset="utf-8"> 208 + <meta name="viewport" content="width=device-width, initial-scale=1"> 209 + <title>Login Complete</title> 210 + <style> 211 + body { 212 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 213 + display: flex; 214 + align-items: center; 215 + justify-content: center; 216 + min-height: 100vh; 217 + margin: 0; 218 + background: #f5f5f5; 219 + } 220 + .message { 221 + text-align: center; 222 + padding: 2rem; 223 + background: white; 224 + border-radius: 8px; 225 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 226 + } 227 + .spinner { 228 + width: 24px; 229 + height: 24px; 230 + border: 3px solid #e0e0e0; 231 + border-top-color: #FF6B6B; 232 + border-radius: 50%; 233 + animation: spin 1s linear infinite; 234 + margin: 0 auto 1rem; 235 + } 236 + @keyframes spin { 237 + to { transform: rotate(360deg); } 238 + } 239 + </style> 240 + </head> 241 + <body> 242 + <div class="message"> 243 + <div class="spinner"></div> 244 + <p>Completing login...</p> 245 + </div> 246 + <script> 247 + (function() { 248 + var data = { 249 + type: 'oauth-callback', 250 + success: true, 251 + did: ${JSON.stringify(did)}, 252 + handle: ${JSON.stringify(state.handle)} 253 + }; 254 + 255 + // Send to opener (PWA window) if available 256 + if (window.opener) { 257 + window.opener.postMessage(data, '*'); 258 + // Close this popup after a short delay 259 + setTimeout(function() { window.close(); }, 500); 260 + } else { 261 + // Fallback: redirect to home (cookie is set) 262 + window.location.href = '/'; 263 + } 264 + })(); 265 + </script> 266 + </body> 267 + </html>`; 268 + 269 + return new Response(html, { 270 + status: 200, 271 + headers: { 272 + "Content-Type": "text/html; charset=utf-8", 184 273 "Set-Cookie": setCookieHeader, 185 274 }, 186 275 });
+2
src/types.ts
··· 313 313 redirectPath?: string; 314 314 /** Flag for mobile OAuth flow - redirects to mobileScheme instead of web */ 315 315 mobile?: boolean; 316 + /** Flag for PWA OAuth flow - returns HTML page with postMessage instead of redirect */ 317 + pwa?: boolean; 316 318 } 317 319 318 320 // Re-export OAuthStorage from atproto-storage for convenience