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

Add localStorage fallback for PWA OAuth

When navigating through external OAuth providers, window.opener is lost,
causing postMessage to fail. The callback now stores the result in
localStorage as a fallback mechanism.

Changed files
+57 -26
src
+21 -5
CHANGELOG.md
··· 2 2 3 3 All notable changes to this project will be documented in this file. 4 4 5 + ## [2.5.1] - 2025-01-09 6 + 7 + ### Fixed 8 + 9 + - **PWA OAuth localStorage fallback**: Added localStorage-based communication as 10 + a fallback for PWA OAuth flows. When navigating through external OAuth 11 + providers (like bsky.social), the `window.opener` reference is lost, causing 12 + `postMessage` to fail. The callback now stores the result in localStorage, 13 + which the opener can read via the `storage` event or by checking localStorage 14 + when the popup closes. 15 + 5 16 ## [2.5.0] - 2025-01-09 6 17 7 18 ### Added ··· 23 34 // PWA detects standalone mode and opens OAuth in popup 24 35 const popup = window.open("/login?handle=user.bsky&pwa=true", "oauth-popup"); 25 36 26 - // Listen for postMessage from popup 27 - window.addEventListener("message", (event) => { 28 - if (event.data.type === "oauth-callback" && event.data.success) { 37 + // Listen for both postMessage and localStorage 38 + window.addEventListener("message", handleOAuthResult); 39 + window.addEventListener("storage", (e) => { 40 + if (e.key === "pwa-oauth-result") handleOAuthResult(JSON.parse(e.newValue)); 41 + }); 42 + 43 + function handleOAuthResult(data) { 44 + if (data.type === "oauth-callback" && data.success) { 29 45 // Session cookie is set, reload to pick it up 30 46 location.reload(); 31 47 } 32 - }); 48 + } 33 49 ``` 34 50 35 51 ### Security 36 52 37 53 - PWA callbacks still set the session cookie for API authentication 38 54 - The `postMessage` only sends `did` and `handle` (no tokens) 39 - - Fallback redirect to home page if `window.opener` is unavailable 55 + - localStorage data is cleared after successful read 40 56 41 57 ## [2.4.0] - 2025-12-14 42 58
+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.5.0", 4 + "version": "2.5.1", 5 5 "license": "MIT", 6 6 "exports": "./mod.ts", 7 7 "publish": {
+35 -20
src/routes.ts
··· 197 197 }); 198 198 } 199 199 200 - // PWA OAuth: return HTML page with postMessage 200 + // PWA OAuth: return HTML page that signals completion via localStorage 201 + // We use localStorage instead of postMessage because window.opener 202 + // is lost after navigating through external OAuth providers 201 203 if (state.pwa) { 202 204 logger.info(`PWA OAuth complete for ${state.handle}`); 203 205 ··· 224 226 border-radius: 8px; 225 227 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 226 228 } 227 - .spinner { 228 - width: 24px; 229 - height: 24px; 230 - border: 3px solid #e0e0e0; 231 - border-top-color: #FF6B6B; 229 + .success-icon { 230 + width: 48px; 231 + height: 48px; 232 + background: #10b981; 232 233 border-radius: 50%; 233 - animation: spin 1s linear infinite; 234 + display: flex; 235 + align-items: center; 236 + justify-content: center; 234 237 margin: 0 auto 1rem; 235 238 } 236 - @keyframes spin { 237 - to { transform: rotate(360deg); } 239 + .success-icon svg { 240 + width: 24px; 241 + height: 24px; 242 + fill: white; 238 243 } 239 244 </style> 240 245 </head> 241 246 <body> 242 247 <div class="message"> 243 - <div class="spinner"></div> 244 - <p>Completing login...</p> 248 + <div class="success-icon"> 249 + <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> 250 + </div> 251 + <p>Login successful!</p> 252 + <p style="color: #666; font-size: 14px;">You can close this window.</p> 245 253 </div> 246 254 <script> 247 255 (function() { 256 + // Store success data in localStorage for the opener to read 248 257 var data = { 249 258 type: 'oauth-callback', 250 259 success: true, 251 260 did: ${JSON.stringify(did)}, 252 - handle: ${JSON.stringify(state.handle)} 261 + handle: ${JSON.stringify(state.handle)}, 262 + timestamp: Date.now() 253 263 }; 264 + localStorage.setItem('pwa-oauth-result', JSON.stringify(data)); 254 265 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 = '/'; 266 + // Try postMessage first (works if opener is still available) 267 + if (window.opener && !window.opener.closed) { 268 + try { 269 + window.opener.postMessage(data, '*'); 270 + } catch (e) { 271 + // Ignore cross-origin errors 272 + } 263 273 } 274 + 275 + // Close popup after a short delay 276 + setTimeout(function() { 277 + window.close(); 278 + }, 1500); 264 279 })(); 265 280 </script> 266 281 </body>