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.

+57 -26
+21 -5
CHANGELOG.md
··· 2 3 All notable changes to this project will be documented in this file. 4 5 ## [2.5.0] - 2025-01-09 6 7 ### Added ··· 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 41 ## [2.4.0] - 2025-12-14 42
··· 2 3 All notable changes to this project will be documented in this file. 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 + 16 ## [2.5.0] - 2025-01-09 17 18 ### Added ··· 34 // PWA detects standalone mode and opens OAuth in popup 35 const popup = window.open("/login?handle=user.bsky&pwa=true", "oauth-popup"); 36 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) { 45 // Session cookie is set, reload to pick it up 46 location.reload(); 47 } 48 + } 49 ``` 50 51 ### Security 52 53 - PWA callbacks still set the session cookie for API authentication 54 - The `postMessage` only sends `did` and `handle` (no tokens) 55 + - localStorage data is cleared after successful read 56 57 ## [2.4.0] - 2025-12-14 58
+1 -1
deno.json
··· 1 { 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 "name": "@tijs/atproto-oauth", 4 - "version": "2.5.0", 5 "license": "MIT", 6 "exports": "./mod.ts", 7 "publish": {
··· 1 { 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 "name": "@tijs/atproto-oauth", 4 + "version": "2.5.1", 5 "license": "MIT", 6 "exports": "./mod.ts", 7 "publish": {
+35 -20
src/routes.ts
··· 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 ··· 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>
··· 197 }); 198 } 199 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 203 if (state.pwa) { 204 logger.info(`PWA OAuth complete for ${state.handle}`); 205 ··· 226 border-radius: 8px; 227 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 228 } 229 + .success-icon { 230 + width: 48px; 231 + height: 48px; 232 + background: #10b981; 233 border-radius: 50%; 234 + display: flex; 235 + align-items: center; 236 + justify-content: center; 237 margin: 0 auto 1rem; 238 } 239 + .success-icon svg { 240 + width: 24px; 241 + height: 24px; 242 + fill: white; 243 } 244 </style> 245 </head> 246 <body> 247 <div class="message"> 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> 253 </div> 254 <script> 255 (function() { 256 + // Store success data in localStorage for the opener to read 257 var data = { 258 type: 'oauth-callback', 259 success: true, 260 did: ${JSON.stringify(did)}, 261 + handle: ${JSON.stringify(state.handle)}, 262 + timestamp: Date.now() 263 }; 264 + localStorage.setItem('pwa-oauth-result', JSON.stringify(data)); 265 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 + } 273 } 274 + 275 + // Close popup after a short delay 276 + setTimeout(function() { 277 + window.close(); 278 + }, 1500); 279 })(); 280 </script> 281 </body>