1// @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime.
2import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron";
3import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
12import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts";
13import { macPasskeyBackend } from "./PasskeyBackendMac.ts";
14import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";
15
16const PasskeyPackageMetadata = Schema.Struct({
17 t3codeWebAuthn: Schema.optional(
18 Schema.Struct({
19 touchIdKeychainAccessGroup: Schema.optional(Schema.String),
20 browserPasskeys: Schema.optional(Schema.Boolean),
21 }),
22 ),
23});
24const decodePasskeyPackageMetadata = Schema.decodeEffect(
25 Schema.fromJsonString(PasskeyPackageMetadata),
26);
27
28export class PreviewPasskeysConfigureError extends Schema.TaggedError<PreviewPasskeysConfigureError>()(
29 "PreviewPasskeysConfigureError",
30 {
31 keychainAccessGroup: Schema.String,
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return `Failed to enable Touch ID passkeys for the in-app browser (keychain group ${this.keychainAccessGroup}).`;
37 }
38}
39
40/**
41 * Passkeys for in-app browser pages on macOS, where Electron has no WebAuthn UI
42 * of its own. Signed builds record which entitlements their provisioning
43 * profile granted (scripts/build-desktop-artifact.ts), and each path turns on
44 * only when its entitlement is present:
45 *
46 * - Touch ID: Electron's built-in platform authenticator. Passkeys it creates
47 * stay on this Mac, in T3 Code's keychain group.
48 * - Browser passkeys: Apple's managed browser entitlement lets the system sheet
49 * (iCloud Keychain, password managers, phones, security keys) serve any
50 * site. The guest preload hands each ceremony to this process, which pins
51 * the requesting frame's real origin.
52 *
53 * Windows needs neither: Chromium already uses Windows Hello there.
54 */
55export class PreviewPasskeys extends Context.Service<
56 PreviewPasskeys,
57 {
58 /** Whether preview pages route WebAuthn through the system passkey sheet. */
59 readonly bridgeEnabled: boolean;
60 /** Turns on Touch ID passkeys when this build is entitled to them. Runs after app ready. */
61 readonly configure: Effect.Effect<void>;
62 /** Lets the user choose between several passkeys for one site. Idempotent. */
63 readonly installSessionHandlers: (session: Session) => void;
64 /** Serves a preview guest's passkey ceremonies. Returns the detach function. */
65 readonly attachGuest: (guest: WebContents) => () => void;
66 }
67>()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {}
68
69const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" };
70
71const accountLabel = (account: Electron.WebAuthnAccount) =>
72 account.displayName && account.name && account.displayName !== account.name
73 ? `${account.displayName} (${account.name})`
74 : (account.name ?? account.displayName ?? "Unnamed passkey");
75
76const ownerWindow = (frame: WebFrameMain | null) => {
77 const contents = frame ? electronWebContents.fromFrame(frame) : undefined;
78 const owner = contents?.hostWebContents ?? contents;
79 return owner ? BrowserWindow.fromWebContents(owner) : null;
80};
81
82const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => {
83 const [onlyAccount] = details.accounts;
84 // The Touch ID prompt that follows already asks the user to confirm.
85 if (details.accounts.length === 1) return onlyAccount?.credentialId;
86 const options: Electron.MessageBoxOptions = {
87 type: "none",
88 message: "Choose a passkey",
89 detail: details.relyingPartyId,
90 buttons: [...details.accounts.map(accountLabel), "Cancel"],
91 cancelId: details.accounts.length,
92 defaultId: 0,
93 noLink: true,
94 };
95 const parent = ownerWindow(details.frame);
96 const { response } = parent
97 ? await dialog.showMessageBox(parent, options)
98 : await dialog.showMessageBox(options);
99 return details.accounts[response]?.credentialId;
100};
101
102const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
103const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000;
104
105// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony
106// unsettled, and the page must still get an answer.
107const ceremonyDeadline = (timeout: unknown) =>
108 (typeof timeout === "number" && timeout > 0
109 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS)
110 : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000;
111
112const withDeadline = (ceremony: Promise<PasskeyCeremonyResult>, milliseconds: number) => {
113 let timer: ReturnType<typeof setTimeout> | undefined;
114 const deadline = new Promise<PasskeyCeremonyResult>((resolve) => {
115 timer = setTimeout(() => resolve(notAllowed), milliseconds);
116 });
117 return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer));
118};
119
120// Secure contexts only: https, or http on this machine. Chromium also trusts
121// `*.localhost`, but the macOS layer refuses it, so it is turned away here
122// instead of failing deeper with a less useful error.
123const isTrustworthyOrigin = (origin: string) => {
124 if (!URL.canParse(origin)) return false;
125 const { protocol, hostname } = new URL(origin);
126 return (
127 protocol === "https:" ||
128 (protocol === "http:" &&
129 (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))
130 );
131};
132
133/**
134 * The platform that serves preview pages' passkey ceremonies, or none. Windows
135 * needs none: Chromium already hands WebAuthn to Windows Hello there.
136 */
137const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined =>
138 platform === "darwin" ? macPasskeyBackend : undefined;
139
140const make = Effect.gen(function* () {
141 const environment = yield* DesktopEnvironment.DesktopEnvironment;
142 const fileSystem = yield* FileSystem.FileSystem;
143
144 const metadata =
145 environment.platform === "darwin" && environment.isPackaged
146 ? yield* fileSystem
147 .readFileString(environment.path.join(environment.appRoot, "package.json"))
148 .pipe(
149 Effect.flatMap(decodePasskeyPackageMetadata),
150 Effect.map((parsed) => parsed.t3codeWebAuthn),
151 Effect.orElseSucceed(() => undefined),
152 )
153 : undefined;
154 const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup;
155 const backend = metadata?.browserPasskeys === true ? backendFor(environment.platform) : undefined;
156 const bridgeEnabled = backend !== undefined;
157 const sessionsWithHandlers = new WeakSet<Session>();
158
159 return PreviewPasskeys.of({
160 bridgeEnabled,
161 configure: Effect.gen(function* () {
162 if (keychainAccessGroup === undefined) return;
163 yield* Effect.try({
164 try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }),
165 catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }),
166 }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause })));
167 }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")),
168 installSessionHandlers: (session) => {
169 if (sessionsWithHandlers.has(session)) return;
170 sessionsWithHandlers.add(session);
171 session.on("select-webauthn-account", (_event, details, callback) => {
172 // The request stays pending until the callback runs, so it must run once.
173 void chooseAccount(details).then(
174 (credentialId) => callback(credentialId),
175 () => callback(),
176 );
177 });
178 },
179 attachGuest: (guest) => {
180 if (backend === undefined) return () => {};
181 let ceremonyPending = false;
182 const serve = async (
183 event: IpcMainInvokeEvent,
184 publicKey: { readonly timeout?: unknown } | undefined,
185 ceremony: (context: PasskeyCeremonyContext) => Promise<PasskeyCeremonyResult>,
186 ): Promise<PasskeyCeremonyResult> => {
187 // Only the guest's main frame runs the bridge preload.
188 const frame = event.senderFrame;
189 if (!frame || frame !== guest.mainFrame) return notAllowed;
190 // The page shares a JS world with the preload, so nothing it sends can
191 // be trusted for the origin. Read the committed origin before any await:
192 // the frame object outlives a navigation.
193 const origin = frame.origin;
194 // Like Chromium, only a focused page may open the sheet, one at a time.
195 if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) {
196 return notAllowed;
197 }
198 if (typeof publicKey !== "object" || publicKey === null) {
199 return { success: false, error: "TypeError" };
200 }
201 const host = guest.hostWebContents
202 ? BrowserWindow.fromWebContents(guest.hostWebContents)
203 : null;
204 if (!host || host.isDestroyed()) return notAllowed;
205
206 ceremonyPending = true;
207 try {
208 const result = await withDeadline(
209 ceremony({ origin, nativeWindowHandle: host.getNativeWindowHandle() }),
210 ceremonyDeadline(publicKey.timeout),
211 );
212 // A credential minted for a document that navigated away belongs to nobody.
213 const sameDocument =
214 !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin;
215 return sameDocument ? result : notAllowed;
216 } catch {
217 return notAllowed;
218 } finally {
219 ceremonyPending = false;
220 }
221 };
222 const detach = () => {
223 guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL);
224 guest.ipc.removeHandler(PASSKEY_GET_CHANNEL);
225 };
226 detach();
227 // Option shapes are the page's to get wrong: the native layer validates
228 // them and answers with a TypeError.
229 guest.ipc.handle(
230 PASSKEY_CREATE_CHANNEL,
231 (event, publicKey: PublicKeyCredentialCreationOptions) =>
232 serve(event, publicKey, (context) => backend.create(publicKey, context)),
233 );
234 guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) =>
235 serve(event, publicKey, (context) => backend.get(publicKey, context)),
236 );
237 return detach;
238 },
239 });
240}).pipe(Effect.withSpan("PreviewPasskeys.make"));
241
242export const layer = Layer.effect(PreviewPasskeys, make);