fix: refuse to claim http(s) as a deep-link protocol - #11
Conversation
An `https://` redirectUri passed `schemeFromRedirectUri` unchallenged, so
`registerProtocol()` called `setAsDefaultProtocolClient('https')` — which
does not capture an OAuth callback, it asks the OS to make the app the
user's default browser (actively hostile on Windows). The callback then
never arrived and the failure was silent.
`registerProtocol()` in deep-link.ts now throws for `http`/`https`, and
`createAuthKit`'s `registerProtocol()` is mode-aware: a no-op under
`ceremony: { mode: 'window' }` (the window intercepts the redirect itself,
so there is no protocol to claim) and a throw under `system-browser`,
naming both escapes — a custom scheme, or window mode.
| if (isWebScheme(scheme)) { | ||
| if (config.ceremony?.mode === 'window') { | ||
| return; | ||
| } |
There was a problem hiding this comment.
🔍 Window-mode + https now skips the single-instance lock
For an http(s) redirect URI in ceremony.mode: 'window', registerProtocol() returns before calling wireDeepLinks, which is the only place app.requestSingleInstanceLock() / app.quit() are invoked (src/main/deep-link.ts:134-138). Previously (even in window mode) the lock was acquired as a side effect of wiring the deep-link matrix, so apps relying on registerProtocol() for single-instance behavior will now allow multiple instances when configured with an https redirect URI. Worth documenting, or acquiring the lock independently of deep-link wiring.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch — fixed in 9e3172c. The lock was only ever acquired as a side effect of wireDeepLinks, which made it invisible collateral of the early return.
Rather than duplicate the lock logic, I extracted acquireSingleInstanceLock(onSecondInstance, { app }) in deep-link.ts and made wireDeepLinks layer URL extraction on top of it, so there is exactly one owner of the lock. The https + self-capturing-ceremony path now takes the lock, forwards onSecondInstance, and releases it from cleanup() — it just claims no protocol. Covered by a new assertion in the window-mode test plus two unit tests for the helper (forwarding + the duplicate-instance quit() path).
| if (config.ceremony?.mode === 'window') { | ||
| return; | ||
| } | ||
| throw new Error( | ||
| `redirectUri "${config.redirectUri}" uses the "${scheme}" scheme, which the OS ` + | ||
| `hands to the default browser, not to this app — the system-browser ceremony ` + | ||
| `would never receive the callback. Use a custom-protocol redirect URI ` + | ||
| `(e.g. "workos-auth://callback") and register it in the WorkOS Dashboard, or ` + | ||
| `switch to ceremony: { mode: 'window' }, which captures an https redirect in-app.`, | ||
| ); |
There was a problem hiding this comment.
🔍 Mode detection uses config only, ignoring an injected ceremony
The no-op/throw decision keys off config.ceremony?.mode === 'window', not the ceremony instance actually in use. A consumer who passes a custom/window-capable Ceremony via opts.ceremony (exported publicly through CreateAuthKitOptions in src/index.ts) while leaving config.ceremony unset will get a thrown error from registerProtocol() even though their ceremony captures the callback in-app. If opts.ceremony is meant purely as a test seam this is fine; otherwise consider detecting self-capturing ceremonies (or documenting that config.ceremony.mode must be set alongside an injected ceremony).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fair — CreateAuthKitOptions is publicly exported from src/index.ts, so treating opts.ceremony as test-only was an assumption not worth relying on. Fixed in 9e3172c by asking the collaborator instead of a parallel config field.
Ceremony now declares capturesCallback?: boolean — true on the window ceremony (navigation interception means the redirect never escapes to the OS), explicitly false on the system-browser one. registerProtocol() checks ceremony.capturesCallback and no longer reads config.ceremony?.mode at all, so an injected self-capturing ceremony is never told its redirect URI is broken. Optional rather than required so it is not a breaking change for anyone implementing the interface; omitted reads as false, which is the safe default (you get the loud error rather than a silently dead callback). New test injects a bare self-capturing ceremony with an https redirect URI and asserts no throw.
Greptile SummaryThe PR prevents Electron applications from claiming browser-owned HTTP(S) protocols while preserving HTTP(S) callbacks for self-capturing window ceremonies.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
|
Review follow-ups: - `registerProtocol()` used to acquire the single-instance lock only as a side effect of wiring the deep-link matrix, so the https + window-mode early return silently allowed a second app instance. The lock is not a deep-link concern: extract `acquireSingleInstanceLock()` and take it on both paths. `wireDeepLinks` now layers URL extraction on top of it, so the lock has exactly one owner. - The no-op/throw decision keyed off `config.ceremony?.mode`, which ignored a self-capturing ceremony injected through the (publicly exported) `CreateAuthKitOptions.ceremony`. `Ceremony` now declares `capturesCallback`, and the guard asks the ceremony in use. Also stop formatting CHANGELOG.md: release-please generates it in a shape oxfmt rejects, which has had `format:check` failing on main since 9530d70 and would re-break at every release if reformatted by hand.
Problem
schemeFromRedirectUriaccepts anyscheme://prefix, includinghttps. So anhttps://auth.example.com/callbackredirect URI flowed straight intosetAsDefaultProtocolClient('https')— which does not capture an OAuth callback. It asks the OS to make the app the user's default browser (actively hostile on Windows). The callback then never arrived, with no error to explain why.This came up as feedback on the launch thread: WorkOS/macOS now allow
https://callbacks viaASWebAuthenticationSession, so people will reasonably try one here. Today that silently strands sign-in.Change
deep-link.ts: newisWebScheme()(exported viainternals);registerProtocol()throws forhttp/httpsrather than reachingsetAsDefaultProtocolClient.create-auth-kit.ts:registerProtocol()is mode-aware for anhttp(s)redirect URI —ceremony: { mode: 'window' }→ no-op. The window intercepts the redirect before it commits, so there is no protocol to claim and anhttpsredirect URI already works there.system-browser(default) → throws, naming both escapes: a custom scheme (workos-auth://callback) or window mode. Nothing else could ever hear the callback, so a silent no-op would be worse.No behavior change for the custom-scheme path.
Tests
+6 across
deep-link.spec.ts/create-auth-kit.spec.ts: refusal is case-insensitive,setAsDefaultProtocolClientis never reached, and window mode wires noopen-urllistener.typecheck,lint, and all 157 tests pass.Not in scope
Real
ASWebAuthenticationSessionsupport (native addon, aceremony: 'native'mode, macOS 14.4+ with thewebcredentialsassociated-domains entitlement forhttpscallbacks, plus passkeys working in the default browser). Worth a separate issue — Electron has no built-in API for it, so it means shipping a native module from a package that currently has none.