-
Notifications
You must be signed in to change notification settings - Fork 3
OAuth Bridge
Some Chrome extensions sign in with chrome.identity.launchWebAuthFlow or a hardcoded chrome-extension://<id>/… OAuth redirect, and some talk to their service worker from a web page via externally_connectable messaging. Neither survives a naive port to Safari. The OAuth bridge is the part of viaduct-cli that rewires an extension so the page↔service-worker handshake works again in Safari, while being honest that it cannot, by itself, make a real third-party OAuth login complete.
It lives in one module, src/runtime/oauth-bridge.ts (deriveChromeId, applyOAuthBridge), plus three verbatim-copied template scripts under src/templates/. See also Runtime Shim, Manifest Transform, and Conversion Pipeline for where it sits in the flow.
The bridge does not let a broken third-party OAuth login magically succeed in Safari.
An OAuth client is registered on the provider's server against a specific redirect URI. For a Chrome extension that URI encodes the original Chrome extension identity + scheme: e.g. https://<chrome-id>.chromiumapp.org/ or chrome-extension://<chrome-id>/oauth_callback.html. Safari:
- gives web pages no
chromenamespace at all (onlybrowser, keyed by the Safari extension id)(53b8e3c); - assigns each install a different, per-install extension id, so it cannot reproduce the Chrome
chromiumapp.orgorigin the provider has whitelisted(208eaa6).
Full remediation requires the provider to register a Safari-compatible redirect: a Safari extension redirect URI, or a hosted HTTPS callback the app controls. No amount of client-side shimming changes what the authorization server will accept. This limitation is stated the same way in Limitations and FAQ.
It restores the two client-side mechanics that Safari silently breaks, so an extension whose provider config is reachable can complete the round trip:
-
A
chrome.identityshim in the service worker:getRedirectURL,launchWebAuthFlow(tab +chrome.webNavigationwatch), plus stubs forgetAuthToken/removeCachedAuthToken.(53b8e3c) -
A page↔service-worker message bridge: so a page that probes
window.chrome.runtime.sendMessage(theexternally_connectablepath, e.g. theoauth_redirectcallback message) reaches the SW even though Safari never exposeschrometo the page.(53b8e3c)
Everything below is what applyOAuthBridge actually transforms, verified in code.
applyOAuthBridge(stageDir, manifest, chromeId?) mutates the staged manifest in place and emits template files. It is invoked from the pipeline at convert.ts only when the bridge is enabled (opts.oauthBridge !== false) (53b8e3c).
Precondition, it is a no-op without a background service worker. It reads manifest.background.service_worker, strips any leading / (a root-relative "/sw.js" would otherwise make the relative-path math emit a garbage import that kills the SW), and returns early if there is none. Only MV3 service-worker extensions have this handshake. (53b8e3c)
Template (src/templates/) |
World / context | Wired into | Purpose |
|---|---|---|---|
identity-polyfill.js |
service worker | imported at top of the SW entry |
chrome.identity shim + capture/re-dispatch of the SW's onMessageExternal handler |
page-bridge.js |
page MAIN world |
content_scripts (unshifted) and web_accessible_resources
|
fake window.chrome.runtime that relays over window.postMessage
|
page-bridge-cs.js |
content-script isolated world |
content_scripts (unshifted) |
relays page ↔ SW; posts the SW reply back to the page |
All three carry the placeholder __C2S_EXTENSION_ID__; substituteExtId bakes in the real Chrome id when known, otherwise the placeholder stays and each script falls back to the live runtime id at execution time, so the bridge works for any extension, not just one hardcoded id (208eaa6).
-
background: not touched here. The polyfill is pulled in by prependingimport "<rel>/identity-polyfill.js";to the SW file (injectPolyfillImport), becauseconvertServiceWorkerToBackgroundPageruns later in the pipeline and overwritesmanifest.backgroundwholesale with{ page, persistent: false }(shim.tsmanifest.background = { page: BACKGROUND_PAGE_FILENAME, persistent: false }). Abackground.typeset here would just be discarded; the injectedimportis what persists.(53b8e3c)The relative path is computed from the SW's own directory back to the stage root where the polyfill sits, so a SW in a subdir (service-worker/index.js) resolves../identity-polyfill.js, not a 404. -
permissions: if the extension usesidentity(oridentity.*),webNavigationis appended when absent.launchWebAuthFlowwatches the auth tab viachrome.webNavigation.onBeforeNavigate; identity users rarely declare it, and without it the polyfill throws. It is added only for identity users, to avoid an unused permission drawing review scrutiny on every conversion.(53b8e3c) -
externally_connectable.matches: read (not written) to decide the page-bridge target. This is exactly the set of pages allowed to message the extension. If it is missing or a non-array, the page bridge is skipped (the SWchrome.identityshim still lands) and a note is emitted.(53b8e3c) -
content_scripts: two entries areunshifted onto the front (so they run first): the MAIN-worldpage-bridge.jsand the isolated-worldpage-bridge-cs.js, both atrun_at: "document_start",all_frames: false, on theexternally_connectablematches. -
web_accessible_resources:page-bridge.jsis exposed to those matches (normalized to MV3 object form, preserving any existing entries), so agetURL/script-tag fallback works on Safari versions that ignoreworld:"MAIN"content scripts.(53b8e3c)
This is the subtle part. The bridge wants to bake the extension's real Chrome id into the templates (so a page that reads chrome.runtime.id still sees the value it expects). That id is recoverable from the manifest key, but the manifest transform strips key before Safari ever sees it.
So the id must be derived before the strip. The pipeline enforces this ordering:
-
convert.tscomputesconst chromeId = deriveChromeId(manifest)immediately after load, comment: "Compute the real Chrome id NOW, before transformManifest strips thekey."(34d4724) -
transformManifest(src/manifest/manifest.ts) later runsdelete out.key.(34d4724) -
applyOAuthBridge(stageDir, transformed, chromeId)receives the already-derived id and bakes it into the templates.
deriveChromeId reproduces Chrome's own rule (34d4724):
base64 CRX public key (
manifest.key, DER) → SHA-256 → first 16 bytes → each nibble (0through15) mapped to a letter (athroughp).
It returns undefined for an unpacked extension (no key) or a key that decodes to zero bytes; the templates then fall back to the live runtime id. The malformed-key guard is the der.length === 0 check, not a try/catch — Buffer.from(str, "base64") never throws (it decodes what it can and ignores invalid characters), so the old try/catch around it was dead code and was dropped (92fa2ee). The derivation is generic: it was deliberately changed from hardcoding one extension's id to deriving it from the CRX public key so it works for any extension (208eaa6).
The three scripts form a relay chain across two worlds and the SW. Message envelopes are tagged so each hop only handles its own:
- page-bridge → cs:
{ __claudeBridge: "page", reqId, msg }viawindow.postMessage - cs → SW:
{ __bridge: true, payload }viaruntime.sendMessage - cs → page:
{ __claudeBridge: "cs", reqId, response, error }viawindow.postMessage
PAGE (MAIN world) CONTENT SCRIPT (isolated) SERVICE WORKER
page-bridge.js page-bridge-cs.js identity-polyfill.js
─────────────────────────────────────────────────────────────────────────────────────
site calls
chrome.runtime
.sendMessage(msg)
│
│ postMessage
│ {__claudeBridge:"page", reqId, msg}
├───────────────────────────►│
│ │ runtime.sendMessage
│ │ {__bridge:true, payload:msg}
│ ├──────────────────────────────►│
│ │ │ onMessage listener sees
│ │ │ __bridge===true, rebuilds
│ │ │ sender.origin, re-dispatches
│ │ │ payload to the CAPTURED
│ │ │ onMessageExternal listener(s)
│ │ │
│ │ response (Promise) │
│ │◄──────────────────────────────┤
│ postMessage │ │
│ {__claudeBridge:"cs", │ │
│ reqId, response, error} │ │
│◄───────────────────────────┤ │
│ │ │
resolve pending[reqId] │ │
→ page's callback / promise │ │
(on load) cs also sends {type:"ping"} once to confirm the SW is reachable.
identity-polyfill.js is install-once (__g.__c2sIdentityPolyfill guard): background.html loads it as a classic script and the SW module imports it, so the second evaluation must no-op or listeners double-dispatch. (53b8e3c)
-
launchWebAuthFlowopens the auth URL in a tab and waits for that tab's top-level frame only (frameId === 0) to reach the caller's ownredirect_uri(parsed from the authorize URL, not a hardcoded base). It requires a clean boundary after the redirect target (end ///?/#) so a look-alike host like.../cb.html.evil/is not mistaken for the trusted callback. On match it resolves with the redirect URL; on the callback path it honors Chrome's contract (invoke withundefined+ a scopedruntime.lastErroron failure). A timeout and a tab-closed handler prevent hangs, and every exit — including a timeout — closes the tab and records one redacted outcome in__c2sAuthLog. -
How it learns the tab moved. Not from listeners it registers per flow: Safari delivers no
webNavigationevent to a listener the background page added after it finished evaluating, so a flow that attached its own heard nothing at all and every silent re-auth timed out (see Safari Quirks E13a). The observers are installed once at load and routed to whichever flow owns the tab, and three sources feed that router:webNavigation,tabs.onUpdated, and a 250mstabs.getpoll. On Safari 18 the poll is the source that reports the redirect; the events are the fast path elsewhere. -
onMessageExternalcapture. Safari may exposeonMessageExternalas a read-only/native event; under"use strict"a naiveaddListener =reassignment throws and would abort the whole polyfill, leaving the bridge dead. The polyfill replaces the event object viaObject.defineProperty(with assignment fallbacks) so everyaddListenercall lands in one capture sink, and also wrapsaddListeneron the original native object in place (belt-and-suspenders for Safari handing out a distinct reference). Genuine external messages are still forwarded to the native event (deduped, so they don't fire twice). - On a
{ __bridge: true }message theonMessagehandler rebuilds the sender, then re-dispatchesmsg.payloadto the captured listeners. It returns a Promise for the response, because Safari ignoresreturn true: areturn trueplus an asyncsendResponsedrops the reply and the page waits forever. It also callssendResponsefor Chrome callers. With no listener captured it answers{ success: false, error: "bridge: no external listener captured" }. - The origin it synthesizes comes from the page URL first (
sender.url, thensender.tab.url), and from Safari'ssender.originonly when neither is usable. A bridged message is sent by the page, and the page's URL is what Chrome reports on an external sender. What the relay hands over is a content-script sender, whoseoriginis whatever Safari chose to put there, which is not reliably the page's. Trust that field first and an allow-list gets the wrong origin with nothing to show for it: Claude for Chrome gates on["https://claude.ai"].includes(sender.origin), its listener has already returnedtrue, so the channel stays open, nothing throws, nothing logs, and the Authorize button spins until the relay gives up 30 seconds later. An opaque URL (about:blank,data:) parses to the string"null", which counts as no answer rather than as an origin. Seetest/bridge-sender-origin.test.js. - When listeners hold the channel open and none of them answers within 5s, the polyfill logs one
console.errorwith the payload type, the listener count and the origin they were given. Chrome only behaves that way when the extension's own check refused the message, so that line is usually the whole diagnosis. - The bridged sender carries a tab. Chrome always gives an external message from a page a
sender.tab, and handlers act on it: Claude'soauth_redirectfinishes by navigatingsender.tab.idtoclaude.ai/chrome/installed, and that navigation is what dismisses the consent window. Neither relay transport can supply a tab, since a content script cannot read its own tab id and the shim'sselfSenderhas the same gap, so the handler used to seeundefinedand skip the navigation, leaving a freshly logged-in user on a spinner the extension was already done with. The polyfill resolves one from the page URL: it matchestabs.query({})on the query-stripped and fragment-stripped URL, then falls back to the active tab of the last-focused window, then gives up. The lookup is bounded at 1.5s and never throws, so a handler waiting on it cannot become a new way for a page to hang. A sender that already has a tab is used as it is and costs no query. The fallback refuses a candidate whose URL shows a different origin, because a handler acting onsender.tab.idnavigates it, and navigating an unrelated tab is worse than supplying nothing.
page-bridge.js defines window.chrome.runtime with id = the Chrome id (baked or live-fallback), sendMessage (both (id, msg, cb) and (msg, cb) shapes), a scoped lastError getter, and an inert connect() port: long-lived ports are not supported through the Safari bridge, so it returns a no-op port rather than pretending. It only augments an existing window.chrome (sets sendMessage/id if present), never clobbering it. A 30s per-request timeout guards the page→relay leg (if the page messages before the relay's listener attaches, the postMessage is silently dropped).
page-bridge-cs.js runs in the isolated world: page-bridge.js is a separate world:"MAIN" content-script entry specifically to bypass the page's CSP, which the isolated world can't touch. The relay handles both messaging models: it passes a callback (Chrome) and, if runtime.sendMessage returns a thenable (Safari's promise-based form), uses that instead. It keeps a 30s SW timeout per request, whose clock starts when the page asks rather than after the handshake below, so a page waiting on a dead background still hears back.
Safari honors a world:"MAIN" content script from 18.4 and ignores the entry in silence below that, while transformManifest declares strict_min_version: 15.4. With the entry skipped the page has no chrome.runtime at all, so an externally_connectable page messages nobody. Its login button spins and nothing logs anything, because the relay, the background and the polyfill are each healthy on their own. Claude for Chrome's Authorize page failed exactly this way.
applyOAuthBridge makes page-bridge.js web-accessible so the relay can inject it as a plain <script> when that happens; the comment asking for that fallback dates back to the first bridge commit (53b8e3c). The page-world copy answers a probe, since the isolated world cannot read the page's window.chrome across worlds. The relay posts {__claudeBridge:"probe"}, waits 250ms, and appends <script src=getURL("page-bridge.js")> if nothing answers.
Answering a probe beats announcing once. The two content scripts are separate script evaluations, so an eager announcement can be posted before the relay's listener exists; treating that silence as "no bridge" would inject a redundant copy, whose install-once guard returns early and announces nothing, and the relay would then report a page that had a bridge all along. page-bridge.js answers however it arrived, as a content script or as an injected file, and the guard keeps the second copy from double-installing. A page CSP can refuse the tag, which is the thing world:"MAIN" exists to sidestep, and that case gets its own console.error. See test/page-bridge-main-world-fallback.test.js.
raiseMinVersionForMainWorld in manifest.ts raises strict_min_version to 18.4, but only for world:"MAIN" entries this conversion injected and only when they cannot recover on their own. wirePageWorldMainInjection's page-world scripts qualify (d35e3ad). Two cases stay out of it. An entry the extension declared is the author's own compatibility claim, and the analyzer's advice there is to feature-detect and degrade (a40114e), so raising the floor would trade one dead feature for no extension at all on Safari 15.4 through 18.3. page-bridge.js stays out because the injection above covers it. Version comparison is numeric, so "9.0" sorts below "18.4" and "18.10" above it. See test/main-world-min-version.test.js.
Safari's delivery into a converted background (non-persistent, page-based) fails in two unrelated ways, both quiet.
A sendMessage into a suspended background is not reliably delivered or rejected: the promise can simply never settle. The shim's runtime.connect wrapper retries around the same behavior, where Safari either throws "No runtime.onConnect listeners found" or hands back a port that dies a tick later.
On some builds Safari stops delivering content-script messages to an extension page altogether, which is why the shim relays extension-page traffic through chrome.storage.local at all. That failure has history worth reading. Running each tag's shim against a Safari-shaped frozen chrome.runtime (Safari does hand one out; the getURL wrap works around it) shows v1.7.0 and v1.8.0 swapping the global chrome/browser and runtime.onMessage for a Proxy and a facade, because the relay's in-place patch cannot land on a frozen namespace. That swap is what kills delivery, bisected live in 8acef29. v1.9.0 stopped swapping the globals. chrome.runtime itself has been replaced by a mutable clone since v1.6.0, and whether Safari resolves delivery through that identity too has never been established. test/extension-page-api-identity.test.js pins the frozen shape as well as the extensible one.
Sending once over sendMessage loses the whole handshake with no error anywhere, so the relay probes with { __bridgePing: true }: three attempts over sendMessage, each abandoned after 800ms instead of awaited, then three over the storage mailbox. The mailbox speaks the shim relay's protocol as it stands, writing __c2sMbxReq:<id> and bumping the __c2sMbxBell doorbell, then polling __c2sMbxRsp:<id>. Every converted extension page already runs the receiving half: it mirrors incoming records into its own onMessage listeners through storage.onChanged, a 200ms doorbell poll and a scan on wake, then writes the answer back.
Four things about that probe:
The polyfill answers the ping itself and never forwards it to the extension's listeners. A ping aimed at externally_connectable listeners only works for a bundle that implements a ping type (Claude does), and for everything else "no answer" means "ignored an unknown message type", which read as a dead background. An answer from the polyfill also proves more than an awake background: it proves the polyfill is installed and listening, which is the bridge's real precondition.
sendMessage is tried first, and retried, before the fallback. A woken background is the better transport: it carries a real sender and does not need the storage permission.
The payload crosses once, over one transport. An OAuth code is single-use, so a replay would trade a hang for a token exchange that fails the second time. When neither transport answers the ping the payload still goes out over sendMessage, and the 30s timeout reports what happened.
The mailbox record's sender is the page. It carries the content script's location.href and origin, so the polyfill derives the origin an externally_connectable gate expects. Without the storage permission there is no mailbox, and the diagnosis says so.
A background that answers nothing on either transport gets one console.error carrying both attempt counts and a pointer to the background console, which is where a load-time throw shows up. See test/bridge-background-wake.test.js.
Safari rejects runtime.sendMessage with "Invalid call to runtime.sendMessage(). Tab not found." when it cannot resolve the sender's tab. The page is unloading, being discarded, or has just been navigated away by the extension itself, which is what happens at the end of a successful OAuth exchange. The relay reads that as teardown: it stops, posts nothing back, and suppresses every diagnostic from that point on, including the missing-background and missing-page-world reports, since all of them would be describing a page that no longer exists.
Forwarding it as an error instead made page-bridge.js reject the promise it had handed the page, and a page on its way out never catches it, which surfaced as "Unhandled Promise Rejection: Invalid call to runtime.sendMessage(). Tab not found." claude.ai carries that exact string in its own error ignore list, which is a fair measure of how routine the noise is.
-
Idempotent re-injection. Re-running over an already-bridged manifest must not inject the MAIN-world page bridge twice. The
content_scriptsguard checksalreadyBridged(any entry whosejsalready includespage-bridge.js) and skips theunshiftif so.injectPolyfillImportandaddWebAccessibleare likewise no-ops when their target is already present. -
CSP sandbox scoping (sibling fix in the same commit, in
manifest.ts), the remotescript-srcCSP check was scoped toextension_pagesonly, so a remote script legitimately allowed under thesandboxdirective isn't false-flagged. -
Optional-permission host warning (same commit,
manifest.ts), host match patterns misplaced inoptional_permissionsnow warn (pointing atoptional_host_permissions), where before onlypermissionswas checked.
The last two are not bridge code, but they ship alongside the idempotency fix and harden the surrounding manifest handling.
Additional safety already in the bridge module: substituteExtId/injectPolyfillImport/addWebAccessible all guard on existsSync and only write when the placeholder/import is actually absent; malformed externally_connectable / web_accessible_resources values are coerced rather than trusted (unguarded JSON.parse output). Verbose logging in every template is OFF by default because the OAuth redirect URL carries the token/code, logs are gated behind __C2S_DEBUG, and even then the redirect URL is redacted to origin + path.
All three templates read __C2S_DEBUG at call time rather than once at load. Each of them runs before the code it is bridging for: the polyfill ahead of the SW bundle, both page scripts at document_start. A load-time read could therefore only be flipped by editing the build, which is not something a user chasing a stuck Authorize button can do. Reading it per call means self.__C2S_DEBUG = true in an already-open background console, or window.__C2S_DEBUG = true on the page, turns the handshake logs on for the next attempt.
Commit 5d1d542 ("feat: DNR ruleset to pin Origin for api.anthropic.com") added an early src/dnr.ts that wrote a declarativeNetRequest ruleset to set the chrome-extension:// Origin that api.anthropic.com accepts. This is a separate concern from the OAuth login handshake: it pinned the Origin header on backend API requests, not the auth flow, and in the pipeline applyDnr and applyOAuthBridge are independent, adjacent calls in convert.ts.
It has since been superseded. The current DNR module is src/manifest/dnr.ts; the standalone src/dnr.ts no longer exists. applyDnr now ships no CORS/Origin-bypass ruleset, because:
- a
modifyHeadersDNR action crashes Safari's whole DNR rule store (null-deref inloadDeclarativeNetRequestRules→getRulesWithRuleIDs), so such rules are actively stripped; and - the header the Anthropic gate keys on (
sec-fetch-site) is a browser-controlled forbidden header that JS and DNR cannot set in Safari anyway.
The real fix is now the native-host retry: blocked backend requests are retried through the native messaging host (SafariWebExtensionHandler), which sets the Chrome Origin server-side (requires the nativeMessaging permission). applyDnr emits only a note to that effect. So: DNR Origin-pinning was auth-adjacent (API Origin, not the OAuth redirect), it never gated the bridge, and it was replaced by the native-host proxy path.
--no-oauth-bridge turns the whole thing off, the CLI maps it to oauthBridge: !values["no-oauth-bridge"], and convert.ts skips applyOAuthBridge when opts.oauthBridge === false. Use it when the extension has no OAuth/externally_connectable flow, or when you're supplying your own bridge. See CLI Reference.
Cross-references: Runtime Shim · Manifest Transform · Conversion Pipeline · Limitations and FAQ · CLI Reference
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.