-
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 nibble0–15mapped toa–p.
It returns undefined for an unpacked extension (no key) or a malformed key; the templates then fall back to the live runtime id. 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 watcheswebNavigationon that tab's top-level frame only (frameId === 0) for navigation to 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 120s timeout and anonRemoved(tab-closed) handler prevent hangs. -
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). - The
onMessagehandler, on a{ __bridge: true }message, synthesizessender.origin(fromsender.origin/sender.url/sender.tab.url) so the SW's own origin checks pass, then re-dispatchesmsg.payloadto the captured listeners. It returns a Promise for the response — Safari ignoresreturn true, so areturn true+ asyncsendResponsewould drop the reply and hang the page forever; it also callssendResponsefor Chrome callers. If no listener was captured it responds with{ success: false, error: "bridge: no external listener captured" }.
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 has its own 30s SW timeout and an on-load {type:"ping"} probe so a non-running background surfaces immediately as a console error rather than a mystery hang.
-
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.
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.