Files
ComfyUI_frontend/public/auth-sw.js
Christian Byrne 81fc65e59b [bugfix] fix auth service worker to handle cross-origin redirects to GCS (#6265)
## Summary

Fixes CORS errors in HTTPS environments where the auth service worker
blocked cross-origin redirects to Google Cloud Storage.

## Problem

The service worker was using `mode: 'same-origin'` which prevented
following redirects when `/api/view` returns a 302 redirect to GCS:

```
Unsafe attempt to load URL https://storage.googleapis.com/... 
from frame with URL https://testcloud.comfy.org/auth-sw.js. 
Domains, protocols and ports must match.
```

This only occurred in HTTPS/cloud environments where media is served
from GCS. Localhost/HTTP test environments serve files directly without
redirects, so the issue wasn't caught there.

## Solution

Changed redirect handling from automatic to manual:

1. **Initial request to `/api/view`**: Sends WITH auth headers
(validates user access)
2. **Detect redirect response**: Checks for 301/302/opaqueredirect 
3. **Follow redirect to GCS**: Fetches WITHOUT auth headers (signed URL
has built-in auth)

### Key Changes

- Removed `mode: 'same-origin'` (was blocking cross-origin redirects)
- Changed `redirect: event.request.redirect` to `redirect: 'manual'`
- Added manual redirect handling that follows to GCS without Firebase
auth headers

## Why This Works

The two requests have different authentication mechanisms:
- **`/api/view` request**: Uses Firebase auth header (backend validates
user access)
- **GCS request**: Uses signed URL with query params (`Signature=...`,
`GoogleAccessId=...`, `Expires=...`)

The security check still happens on the initial `/api/view` request, but
we allow the redirect to GCS to use its own authentication system.

## Testing

- Typecheck passed
- Should be tested in HTTPS cloud environment with media files stored in
GCS

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6265-bugfix-fix-auth-service-worker-to-handle-cross-origin-redirects-to-GCS-2976d73d365081d0b124db4918f8194e)
by [Unito](https://www.unito.io)
2025-10-24 18:31:38 -07:00

166 lines
4.0 KiB
JavaScript

/**
* @fileoverview Authentication Service Worker
* Intercepts /api/view requests and adds Firebase authentication headers.
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
*/
/**
* @typedef {Object} AuthHeader
* @property {string} Authorization - Bearer token for authentication
*/
/**
* @typedef {Object} CachedAuth
* @property {AuthHeader|null} header
* @property {number} expiresAt - Timestamp when cache expires
*/
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
/** @type {CachedAuth|null} */
let authCache = null
/** @type {Promise<AuthHeader|null>|null} */
let authRequestInFlight = null
self.addEventListener('message', (event) => {
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
authCache = null
authRequestInFlight = null
}
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (
!url.pathname.startsWith('/api/view') &&
!url.pathname.startsWith('/api/viewvideo')
) {
return
}
event.respondWith(
(async () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
return fetch(event.request)
}
const headers = new Headers(event.request.headers)
for (const [key, value] of Object.entries(authHeader)) {
headers.set(key, value)
}
// Fetch with manual redirect to handle cross-origin redirects (e.g., GCS signed URLs)
const response = await fetch(
new Request(event.request.url, {
method: event.request.method,
headers: headers,
credentials: event.request.credentials,
cache: 'no-store',
redirect: 'manual',
referrer: event.request.referrer,
integrity: event.request.integrity
})
)
// If redirected to external storage (GCS), follow without auth headers
// The signed URL contains its own authentication in query params
if (
response.type === 'opaqueredirect' ||
response.status === 302 ||
response.status === 301
) {
const location = response.headers.get('location')
if (location) {
return fetch(location, {
method: 'GET',
redirect: 'follow'
})
}
}
return response
} catch (error) {
console.error('[Auth SW] Request failed:', error)
return fetch(event.request)
}
})()
)
})
/**
* Gets auth header from cache or requests from main thread
* @returns {Promise<AuthHeader|null>}
*/
async function getAuthHeader() {
// Return cached value if valid
if (authCache && authCache.expiresAt > Date.now()) {
return authCache.header
}
// Clear expired cache
if (authCache) {
authCache = null
}
// Deduplicate concurrent requests
if (authRequestInFlight) {
return authRequestInFlight
}
authRequestInFlight = requestAuthHeaderFromMainThread()
const header = await authRequestInFlight
authRequestInFlight = null
// Cache the result
if (header) {
authCache = {
header,
expiresAt: Date.now() + CACHE_TTL_MS
}
}
return header
}
/**
* Requests auth header from main thread via MessageChannel
* @returns {Promise<AuthHeader|null>}
*/
async function requestAuthHeaderFromMainThread() {
const clients = await self.clients.matchAll()
if (clients.length === 0) {
return null
}
const messageChannel = new MessageChannel()
return new Promise((resolve) => {
let timeoutId
messageChannel.port1.onmessage = (event) => {
clearTimeout(timeoutId)
resolve(event.data.authHeader)
}
timeoutId = setTimeout(() => {
console.error(
'[Auth SW] Timeout waiting for auth header from main thread'
)
resolve(null)
}, 1000)
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
messageChannel.port2
])
})
}
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})