Skip to content

OAuth Bridge

magicelk235 edited this page Jul 22, 2026 · 6 revisions

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.


What does NOT work (be honest about this)

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 chrome namespace at all (only browser, keyed by the Safari extension id) (53b8e3c);
  • assigns each install a different, per-install extension id, so it cannot reproduce the Chrome chromiumapp.org origin 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.

What the bridge DOES do

It restores the two client-side mechanics that Safari silently breaks, so an extension whose provider config is reachable can complete the round trip:

  1. A chrome.identity shim in the service workergetRedirectURL, launchWebAuthFlow (tab + chrome.webNavigation watch), plus stubs for getAuthToken / removeCachedAuthToken. (53b8e3c)
  2. A page↔service-worker message bridge — so a page that probes window.chrome.runtime.sendMessage (the externally_connectable path, e.g. the oauth_redirect callback message) reaches the SW even though Safari never exposes chrome to the page. (53b8e3c)

Everything below is what applyOAuthBridge actually transforms, verified in code.


What applyOAuthBridge transforms

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 scripts injected (and where)

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).

Manifest fields touched

  • backgroundnot touched here. The polyfill is pulled in by prepending import "<rel>/identity-polyfill.js"; to the SW file (injectPolyfillImport), because convertServiceWorkerToBackgroundPage runs later in the pipeline and overwrites manifest.background wholesale with { page, persistent: false } (shim.ts manifest.background = { page: BACKGROUND_PAGE_FILENAME, persistent: false }). A background.type set here would just be discarded; the injected import is 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 uses identity (or identity.*), webNavigation is appended when absent. launchWebAuthFlow watches the auth tab via chrome.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 SW chrome.identity shim still lands) and a note is emitted. (53b8e3c)
  • content_scripts — two entries are unshifted onto the front (so they run first): the MAIN-world page-bridge.js and the isolated-world page-bridge-cs.js, both at run_at: "document_start", all_frames: false, on the externally_connectable matches.
  • web_accessible_resourcespage-bridge.js is exposed to those matches (normalized to MV3 object form, preserving any existing entries), so a getURL/script-tag fallback works on Safari versions that ignore world:"MAIN" content scripts. (53b8e3c)

The id-derivation ordering invariant

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:

  1. convert.ts computes const chromeId = deriveChromeId(manifest) immediately after load — comment: "Compute the real Chrome id NOW, before transformManifest strips the key." (34d4724)
  2. transformManifest (src/manifest/manifest.ts) later runs delete out.key. (34d4724)
  3. 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-256first 16 bytes → each nibble 0–15 mapped to a–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 page ↔ cs ↔ extension handshake

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 } via window.postMessage
  • cs → SW: { __bridge: true, payload } via runtime.sendMessage
  • cs → page: { __claudeBridge: "cs", reqId, response, error } via window.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 (SW side) — the tricky bits

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)

  • launchWebAuthFlow opens the auth URL in a tab and watches webNavigation on that tab's top-level frame only (frameId === 0) for navigation to the caller's own redirect_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 with undefined + a scoped runtime.lastError on failure). A 120s timeout and an onRemoved (tab-closed) handler prevent hangs.
  • onMessageExternal capture. Safari may expose onMessageExternal as a read-only/native event; under "use strict" a naive addListener = reassignment throws and would abort the whole polyfill, leaving the bridge dead. The polyfill replaces the event object via Object.defineProperty (with assignment fallbacks) so every addListener call lands in one capture sink, and also wraps addListener on 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 onMessage handler, on a { __bridge: true } message, synthesizes sender.origin (from sender.origin / sender.url / sender.tab.url) so the SW's own origin checks pass, then re-dispatches msg.payload to the captured listeners. It returns a Promise for the response — Safari ignores return true, so a return true + async sendResponse would drop the reply and hang the page forever; it also calls sendResponse for Chrome callers. If no listener was captured it responds with { success: false, error: "bridge: no external listener captured" }.

Page bridge (MAIN world) & content-script relay

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 worldpage-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.


Idempotency & safety (0fe0447)

  • Idempotent re-injection. Re-running over an already-bridged manifest must not inject the MAIN-world page bridge twice. The content_scripts guard checks alreadyBridged (any entry whose js already includes page-bridge.js) and skips the unshift if so. injectPolyfillImport and addWebAccessible are likewise no-ops when their target is already present.
  • CSP sandbox scoping (sibling fix in the same commit, in manifest.ts) — the remote script-src CSP check was scoped to extension_pages only, so a remote script legitimately allowed under the sandbox directive isn't false-flagged.
  • Optional-permission host warning (same commit, manifest.ts) — host match patterns misplaced in optional_permissions now warn (pointing at optional_host_permissions), where before only permissions was 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.


Relationship to DNR Origin-pinning

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 modifyHeaders DNR action crashes Safari's whole DNR rule store (null-deref in loadDeclarativeNetRequestRulesgetRulesWithRuleIDs), 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.


Disabling the bridge

--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

Clone this wiki locally