Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export { createCeremony } from './main/ceremony/index.js';
export type { Ceremony, CreateCeremonyOptions, ShellLike } from './main/ceremony/index.js';

// Deep-link capture matrix + callback URL parsing.
export { parseCallback, registerProtocol, wireDeepLinks } from './main/deep-link.js';
export { isWebScheme, parseCallback, registerProtocol, wireDeepLinks } from './main/deep-link.js';
export type { AppLike, ProcessLike, WireDeepLinksOptions } from './main/deep-link.js';

// IPC handlers + auth-change / auth-error broadcast.
Expand Down
45 changes: 45 additions & 0 deletions src/main/__tests__/create-auth-kit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,51 @@ describe('createAuthKit — wiring', () => {
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledTimes(1);
});

// Regression: schemeFromRedirectUri happily returns "https", which used to
// flow into setAsDefaultProtocolClient('https') — a request to become the
// user's default browser, and a callback that never arrives.
it('registerProtocol throws for an https redirectUri in system-browser mode', () => {
const app = makeApp();
const kit = createAuthKit(
{ clientId: 'client_test', redirectUri: 'https://auth.example.com/callback' },
{
storage: makeStorage(),
client: { userManagement: {} } as unknown as WorkOS,
ipcMain: makeIpcMain(),
app,
process: { argv: ['electron'], execPath: '/e' },
},
);

expect(() => kit.registerProtocol()).toThrow(/workos-auth:\/\/callback|mode: 'window'/);
expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled();
});

it('registerProtocol is a no-op for an https redirectUri in window mode', () => {
const app = makeApp();
const kit = createAuthKit(
{
clientId: 'client_test',
redirectUri: 'https://auth.example.com/callback',
ceremony: { mode: 'window' },
},
{
storage: makeStorage(),
client: { userManagement: {} } as unknown as WorkOS,
ipcMain: makeIpcMain(),
ceremony: { open: vi.fn(), endSession: vi.fn(), onCallback: () => () => {} } as Ceremony,
app,
process: { argv: ['electron'], execPath: '/e' },
},
);

// The window ceremony intercepts the redirect in-window, so there is no
// protocol to claim and no deep-link listener to wire.
expect(() => kit.registerProtocol()).not.toThrow();
expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled();
expect(app.listeners['open-url']).toBeUndefined();
});

it('cleanup removes handlers without throwing', () => {
const kit = createAuthKit(config, {
storage: makeStorage(),
Expand Down
22 changes: 21 additions & 1 deletion src/main/__tests__/deep-link.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { AppLike, ProcessLike } from '../deep-link.js';
import { parseCallback, registerProtocol, wireDeepLinks } from '../deep-link.js';
import { isWebScheme, parseCallback, registerProtocol, wireDeepLinks } from '../deep-link.js';

// The module imports `app` from electron at the top; tests inject their own
// `app`, so this mock just prevents the native binding from loading.
Expand Down Expand Up @@ -89,6 +89,26 @@ describe('registerProtocol', () => {
registerProtocol(SCHEME, { app, process: proc({ defaultApp: false }) });
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('workos-auth');
});

// Regression: an https redirectUri used to reach setAsDefaultProtocolClient,
// which asks the OS to make the app the DEFAULT BROWSER (hostile on Windows)
// and can never deliver an OAuth callback.
it('refuses http/https and never touches setAsDefaultProtocolClient', () => {
for (const scheme of ['http', 'https', 'HTTPS']) {
const app = makeApp();
expect(() => registerProtocol(scheme, { app, process: proc() })).toThrow(/default browser/);
expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled();
}
});
});

describe('isWebScheme', () => {
it('matches http/https case-insensitively and nothing else', () => {
expect(isWebScheme('http')).toBe(true);
expect(isWebScheme('Https')).toBe(true);
expect(isWebScheme('workos-auth')).toBe(false);
expect(isWebScheme('httpsx')).toBe(false);
});
});

describe('wireDeepLinks', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/main/create-auth-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { type Ceremony, type CreateCeremonyOptions, createCeremony } from './cer
import { assertValidClientId, createPublicWorkOS, toAuthKitConfig } from './config.js';
import {
type AppLike,
isWebScheme,
registerProtocol as registerProtocolImpl,
wireDeepLinks,
} from './deep-link.js';
Expand Down Expand Up @@ -47,6 +48,11 @@ export interface CreateAuthKitResult {
* after the first call (the listeners are wired exactly once), so it never
* leaks duplicate `open-url`/`second-instance` handlers. The deep-link
* listeners are removed by {@link CreateAuthKitResult.cleanup}.
*
* With an `http(s)` `redirectUri` there is no custom protocol to register:
* in `ceremony.mode: 'window'` (which captures its callback in-window) this
* is a no-op, and in `system-browser` mode it throws, because nothing would
* ever capture the callback.
*/
registerProtocol(): void;
/** Remove IPC handlers and deep-link listeners. Call on app shutdown. */
Expand Down Expand Up @@ -191,6 +197,22 @@ export function createAuthKit(
if (protocolRegistered) {
return;
}
// An http(s) redirectUri is not a deep link. The window ceremony captures
// it in-window, so registration is simply unnecessary there; the
// system-browser ceremony has no other way to hear the callback, so a
// silent no-op would strand every sign-in — fail loudly instead.
if (isWebScheme(scheme)) {
if (config.ceremony?.mode === 'window') {
return;
}
Comment on lines +205 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}
registerProtocolImpl(scheme, { app: opts.app, process: opts.process });
removeDeepLinks = wireDeepLinks(
scheme,
Expand Down
24 changes: 24 additions & 0 deletions src/main/deep-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,42 @@ export function parseCallback(url: string): {
return result;
}

/** Schemes owned by web browsers — an app must never claim these. */
const WEB_SCHEMES = new Set(['http', 'https']);

/**
* Is `scheme` an `http(s)` scheme (i.e. a browser's, not a deep link's)?
*
* Callers use this to refuse protocol registration for an `https://` redirect
* URI: `setAsDefaultProtocolClient('https')` does not "capture the callback",
* it asks the OS to make this app the user's default browser.
*/
export function isWebScheme(scheme: string): boolean {
return WEB_SCHEMES.has(scheme.toLowerCase());
}

/**
* Register the app as the default client for `scheme://` URLs.
*
* In dev (`process.defaultApp`, i.e. launched via `electron .`), the protocol
* must point at the Electron binary plus the resolved entry script, otherwise
* the OS re-launches a bare Electron. In a packaged app the no-arg form is
* correct.
*
* @throws {Error} if `scheme` is `http`/`https`. Claiming those means becoming
* the default browser, never receiving an OAuth callback.
*/
export function registerProtocol(
scheme: string,
deps: { app?: AppLike; process?: ProcessLike } = {},
): boolean {
if (isWebScheme(scheme)) {
throw new Error(
`Cannot register "${scheme}" as a custom protocol — setAsDefaultProtocolClient("${scheme}") ` +
`asks the OS to make this app the default browser, which is not how an OAuth ` +
`callback is captured. Use a custom scheme (e.g. "workos-auth://callback").`,
);
}
const app = deps.app ?? (electronApp as unknown as AppLike);
const proc = deps.process ?? (globalThis.process as unknown as ProcessLike);
if (proc.defaultApp && proc.argv.length >= 2) {
Expand Down
Loading