[auth] add service worker on cloud distribution to attach auth header to browser native /view requests (#6139)

## Summary

Added Service Worker to inject Firebase auth headers into browser-native
`/api/view` requests (img, video, audio tags) for cloud distribution.

## Changes

- **What**: Implemented [Service
Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
to intercept and authenticate media requests that cannot natively send
custom headers
- **Dependencies**: None (uses native Service Worker API)

## Implementation Details

**Tree-shaking**: Uses compile-time `isCloud` constant - completely
removed from localhost/desktop builds (verified via bundle analysis).
Verify yourself by building the app and `grep -r
"registerAuthServiceWorker\|setupAuth" dist/`
**Caching**: 50-minute auth header cache with automatic invalidation on
login/logout to prevent redundant token fetches.

**Message Flow**:
```mermaid
sequenceDiagram
    participant IMG as Browser
    participant SW as Service Worker
    participant MT as Main Thread
    participant FB as Firebase Auth

    IMG->>SW: GET /api/view/image.png
    SW->>SW: Check cache (50min TTL)
    alt Cache miss
        SW->>MT: REQUEST_AUTH_HEADER
        MT->>FB: getAuthHeader()
        FB-->>MT: Bearer token
        MT-->>SW: AUTH_HEADER_RESPONSE
        SW->>SW: Cache token
    end
    SW->>IMG: Fetch with Authorization header

    Note over SW,MT: On login/logout: INVALIDATE_AUTH_HEADER
```

## Review Focus

- **Same-origin mode**: Service Worker uses `mode: 'same-origin'` to
allow custom headers (browser-native requests default to `no-cors` which
strips headers)
- **Request deduplication**: Prevents concurrent auth header requests
from timing out
- **Build verification**: Confirm `register-*.js` absent in localhost
builds, present (~3.2KB) in cloud builds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6139-auth-add-service-worker-on-cloud-distribution-to-attach-auth-header-to-browser-native--2916d73d3650812698dccd07d943ab3c)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2025-10-19 22:51:37 -07:00
committed by GitHub
parent 7ad1112535
commit 26f587c956
6 changed files with 218 additions and 1 deletions

View File

@@ -60,6 +60,7 @@ export default defineConfig([
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'packages/registry-types/src/comfyRegistryTypes.ts',
'public/auth-sw.js',
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',

View File

@@ -43,7 +43,9 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
// Service worker - registered at runtime via navigator.serviceWorker.register()
'public/auth-sw.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

147
public/auth-sw.js Normal file
View File

@@ -0,0 +1,147 @@
/**
* @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)
}
return fetch(
new Request(event.request.url, {
method: event.request.method,
headers: headers,
mode: 'same-origin',
credentials: event.request.credentials,
cache: 'no-store',
redirect: event.request.redirect,
referrer: event.request.referrer,
integrity: event.request.integrity
})
)
} 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())
})

View File

@@ -13,6 +13,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import '@/platform/auth/serviceWorker'
import router from '@/router'
import App from './App.vue'

View File

@@ -0,0 +1,9 @@
import { isCloud } from '@/platform/distribution/types'
/**
* Auth service worker registration (cloud-only).
* Tree-shaken for desktop/localhost builds via compile-time constant.
*/
if (isCloud) {
void import('./register')
}

View File

@@ -0,0 +1,57 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
/**
* Registers the authentication service worker for cloud distribution.
* Intercepts /api/view requests to add auth headers for browser-native requests.
*/
async function registerAuthServiceWorker(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return
}
try {
await navigator.serviceWorker.register('/auth-sw.js')
setupAuthHeaderProvider()
setupCacheInvalidation()
} catch (error) {
console.error('[Auth SW] Registration failed:', error)
}
}
/**
* Listens for auth header requests from the service worker
*/
function setupAuthHeaderProvider(): void {
navigator.serviceWorker.addEventListener('message', async (event) => {
if (event.data.type === 'REQUEST_AUTH_HEADER') {
const firebaseAuthStore = useFirebaseAuthStore()
const authHeader = await firebaseAuthStore.getAuthHeader()
event.ports[0].postMessage({
type: 'AUTH_HEADER_RESPONSE',
authHeader
})
}
})
}
/**
* Invalidates cached auth header when user logs in/out
*/
function setupCacheInvalidation(): void {
const { isLoggedIn } = useCurrentUser()
watch(isLoggedIn, (newValue, oldValue) => {
if (newValue !== oldValue) {
navigator.serviceWorker.controller?.postMessage({
type: 'INVALIDATE_AUTH_HEADER'
})
}
})
}
void registerAuthServiceWorker()