import { isRecord, isTrustedSender, validateFetch, validateSso, isMatchingRedirect, } from './policy'; interface SsoPending { webTabId: number; documentId?: string; authTabId: number; redirectPrefix: string; state: string; expiresAt: number; } type PendingMap = Record; const STORAGE_KEY = 'pmaBridgeSsoPending'; const SSO_LIFETIME = 300000; // Serialize session updates so two login tabs cannot overwrite each other's pending flow. let queue = Promise.resolve(); function withPending(action: (pending: PendingMap) => Promise): Promise { const operation = queue.then(async () => { const stored = await chrome.storage.session.get(STORAGE_KEY); const pending = (stored[STORAGE_KEY] ?? {}) as PendingMap; try { await action(pending); } finally { await chrome.storage.session.set({ [STORAGE_KEY]: pending }); } }); queue = operation.catch(() => {}); return operation; } async function deliver( flowId: string, info: SsoPending, result: { url?: string; error?: string }, ): Promise { await chrome.tabs .sendMessage( info.webTabId, { namespace: 'pma-bridge', kind: 'ssoCode', flowId, ...result }, info.documentId ? { documentId: info.documentId } : { frameId: 0 }, ) .catch(() => {}); } chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => { if (!isRecord(message) || message.namespace !== 'pma-bridge') return; if (!isTrustedSender(sender)) { sendResponse({ ok: false, error: 'Bridge origin rejected' }); return; } if (message.kind === 'fetch') { void proxyFetch(message).then(sendResponse); return true; } if (message.kind === 'sso') { void startSso(message, sender) .then(() => sendResponse({ ok: true })) .catch(() => sendResponse({ error: 'SSO request rejected or tab unavailable' })); return true; } if (message.kind === 'ssoCancel' && typeof message.flowId === 'string') { void cancelSso(message.flowId, sender) .then(() => sendResponse({ ok: true })) .catch(() => sendResponse({ error: 'SSO cancellation failed' })); return true; } }); async function proxyFetch(message: Record): Promise { try { const request = validateFetch(message); const response = await fetch(request.url, { ...request.options, signal: AbortSignal.timeout(30000), }); // Bound even chunked responses. Vaults are larger than login responses. const reader = response.body?.getReader(); const decoder = new TextDecoder(); let body = ''; let size = 0; if (reader) { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > 64 * 1024 * 1024) { await reader.cancel(); throw new Error('Response too large'); } body += decoder.decode(value, { stream: true }); } body += decoder.decode(); } return { ok: response.ok, status: response.status, body }; } catch { // Never reflect credential-bearing URLs, bodies, or browser errors into logs. return { ok: false, error: 'Extension request rejected or network unavailable' }; } } async function startSso( message: Record, sender: chrome.runtime.MessageSender, ): Promise { const request = validateSso(message); const flowId = message.flowId as string; const webTabId = sender.tab!.id!; let createdTabId: number | undefined; try { await withPending(async (pending) => { await expirePending(pending); if (pending[flowId] || Object.values(pending).some((info) => info.webTabId === webTabId)) throw new Error('SSO already active'); // Persist metadata before the auth navigation can redirect. const tab = await chrome.tabs.create({ url: 'about:blank' }); if (tab.id === undefined) throw new Error('SSO tab unavailable'); createdTabId = tab.id; pending[flowId] = { webTabId, documentId: sender.documentId, authTabId: tab.id, redirectPrefix: request.redirectPrefix, state: request.state, expiresAt: Date.now() + SSO_LIFETIME, }; }); } catch (error) { if (createdTabId !== undefined) await chrome.tabs.remove(createdTabId).catch(() => {}); throw error; } await withPending(async (pending) => { const info = pending[flowId]; if (!info) return; try { await chrome.tabs.update(info.authTabId, { url: request.url }); } catch { delete pending[flowId]; await chrome.tabs.remove(info.authTabId).catch(() => {}); throw new Error('SSO navigation failed'); } }); } async function expirePending(pending: PendingMap): Promise { for (const [flowId, info] of Object.entries(pending)) { if (info.expiresAt > Date.now()) continue; delete pending[flowId]; await deliver(flowId, info, { error: 'SSO login timed out' }); await chrome.tabs.remove(info.authTabId).catch(() => {}); } } async function cancelSso(flowId: string, sender: chrome.runtime.MessageSender): Promise { await withPending(async (pending) => { const info = pending[flowId]; if ( !info || info.webTabId !== sender.tab!.id || (info.documentId && info.documentId !== sender.documentId) ) return; delete pending[flowId]; await chrome.tabs.remove(info.authTabId).catch(() => {}); }); } chrome.webRequest.onBeforeRequest.addListener( (details) => { if (details.type !== 'main_frame') return; void withPending(async (pending) => { await expirePending(pending); for (const [flowId, info] of Object.entries(pending)) { if ( info.authTabId !== details.tabId || !isMatchingRedirect(details.url, info.redirectPrefix, info.state) ) continue; delete pending[flowId]; await deliver( flowId, info, new URL(details.url).searchParams.has('error') ? { error: 'SSO sign-in was declined. Try again.' } : { url: details.url }, ); await chrome.tabs.remove(info.authTabId).catch(() => {}); } }).catch(() => {}); }, { urls: ['http://localhost:8065/*', 'http://127.0.0.1:8065/*'], types: ['main_frame'] }, ); chrome.tabs.onRemoved.addListener((tabId) => { void withPending(async (pending) => { await expirePending(pending); for (const [flowId, info] of Object.entries(pending)) { if (info.webTabId !== tabId && info.authTabId !== tabId) continue; delete pending[flowId]; if (info.authTabId === tabId) await deliver(flowId, info, { error: 'SSO tab closed' }); else await chrome.tabs.remove(info.authTabId).catch(() => {}); } }).catch(() => {}); }); // A reload/navigation discards the page's in-memory verifier and master password. chrome.tabs.onUpdated.addListener((tabId, change) => { if (change.status !== 'loading') return; void withPending(async (pending) => { for (const [flowId, info] of Object.entries(pending)) { if (info.webTabId !== tabId) continue; delete pending[flowId]; await chrome.tabs.remove(info.authTabId).catch(() => {}); } }).catch(() => {}); });