Open History to see the last commit
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
40export class PreviewPasskeys extends Context.Service<
41 PreviewPasskeys,
42 {
43 readonly bridgeEnabled: boolean;
44 /** Runs after app ready. */
45 readonly configure: Effect.Effect<void>;
46 readonly installSessionHandlers: (session: Session) => void;
47 readonly attachGuest: (guest: WebContents) => () => void;
48 }
49>()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {}
50
51const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" };
52
53const accountLabel = (account: Electron.WebAuthnAccount) =>
54 account.displayName && account.name && account.displayName !== account.name
55 ? `${account.displayName} (${account.name})`
56 : (account.name ?? account.displayName ?? "Unnamed passkey");
57
58const ownerWindow = (frame: WebFrameMain | null) => {
59 const contents = frame ? electronWebContents.fromFrame(frame) : undefined;
60 const owner = contents?.hostWebContents ?? contents;
61 return owner ? BrowserWindow.fromWebContents(owner) : null;
62};
63
64const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => {
65 const [onlyAccount] = details.accounts;
66 // The Touch ID prompt that follows already asks the user to confirm.
67 if (details.accounts.length === 1) return onlyAccount?.credentialId;
68 const options: Electron.MessageBoxOptions = {
69 type: "none",
70 message: "Choose a passkey",
71 detail: details.relyingPartyId,
72 buttons: [...details.accounts.map(accountLabel), "Cancel"],
73 cancelId: details.accounts.length,
74 defaultId: 0,
75 noLink: true,
76 };
77 const parent = ownerWindow(details.frame);
78 const { response } = parent
79 ? await dialog.showMessageBox(parent, options)
80 : await dialog.showMessageBox(options);
81 return details.accounts[response]?.credentialId;
82};
83
84const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
85const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000;
86
87// Native ceremonies can remain unsettled; match its deadline clamp with a margin.
88const ceremonyDeadline = (timeout: unknown) =>
89 (typeof timeout === "number" && timeout > 0
90 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS)
91 : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000;
92
93const withDeadline = (ceremony: Promise<PasskeyCeremonyResult>, milliseconds: number) => {
94 let timer: ReturnType<typeof setTimeout> | undefined;
95 const deadline = new Promise<PasskeyCeremonyResult>((resolve) => {
96 timer = setTimeout(() => resolve(notAllowed), milliseconds);
97 });
98 return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer));
99};
100
101// macOS refuses *.localhost even though Chromium trusts it.
102const isTrustworthyOrigin = (origin: string) => {
103 if (!URL.canParse(origin)) return false;
104 const { protocol, hostname } = new URL(origin);
105 return (
106 protocol === "https:" ||
107 (protocol === "http:" &&
108 (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))
109 );
110};
111
112// Chromium already delegates to Windows Hello.
113const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined =>
114 platform === "darwin" ? macPasskeyBackend : undefined;
115
116const make = Effect.gen(function* () {
117 const environment = yield* DesktopEnvironment.DesktopEnvironment;
118 const fileSystem = yield* FileSystem.FileSystem;
119
120 const metadata =
121 environment.platform === "darwin" && environment.isPackaged
122 ? yield* fileSystem
123 .readFileString(environment.path.join(environment.appRoot, "package.json"))
124 .pipe(
125 Effect.flatMap(decodePasskeyPackageMetadata),
126 Effect.map((parsed) => parsed.t3codeWebAuthn),
127 Effect.orElseSucceed(() => undefined),
128 )
129 : undefined;
130 const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup;
131 const backend = metadata?.browserPasskeys === true ? backendFor(environment.platform) : undefined;
132 const bridgeEnabled = backend !== undefined;
133 const sessionsWithHandlers = new WeakSet<Session>();
134
135 return PreviewPasskeys.of({
136 bridgeEnabled,
137 configure: Effect.gen(function* () {
138 if (keychainAccessGroup === undefined) return;
139 yield* Effect.try({
140 try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }),
141 catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }),
142 }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause })));
143 }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")),
144 installSessionHandlers: (session) => {
145 if (sessionsWithHandlers.has(session)) return;
146 sessionsWithHandlers.add(session);
147 session.on("select-webauthn-account", (_event, details, callback) => {
148 // The request stays pending until the callback runs, so it must run once.
149 void chooseAccount(details).then(
150 (credentialId) => callback(credentialId),
151 () => callback(),
152 );
153 });
154 },
155 attachGuest: (guest) => {
156 if (backend === undefined) return () => {};
157 let ceremonyPending = false;
158 const serve = async (
159 event: IpcMainInvokeEvent,
160 publicKey: { readonly timeout?: unknown } | undefined,
161 ceremony: (context: PasskeyCeremonyContext) => Promise<PasskeyCeremonyResult>,
162 ): Promise<PasskeyCeremonyResult> => {
163 // Only the guest's main frame runs the bridge preload.
164 const frame = event.senderFrame;
165 if (!frame || frame !== guest.mainFrame) return notAllowed;
166 // The page shares the preload's JS world; read the frame's committed origin before any await/navigation.
167 const origin = frame.origin;
168 if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) {
169 return notAllowed;
170 }
171 if (typeof publicKey !== "object" || publicKey === null) {
172 return { success: false, error: "TypeError" };
173 }
174 const host = guest.hostWebContents
175 ? BrowserWindow.fromWebContents(guest.hostWebContents)
176 : null;
177 if (!host || host.isDestroyed()) return notAllowed;
178
179 ceremonyPending = true;
180 try {
181 const result = await withDeadline(
182 ceremony({ origin, nativeWindowHandle: host.getNativeWindowHandle() }),
183 ceremonyDeadline(publicKey.timeout),
184 );
185 // A credential minted for a document that navigated away belongs to nobody.
186 const sameDocument =
187 !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin;
188 return sameDocument ? result : notAllowed;
189 } catch {
190 return notAllowed;
191 } finally {
192 ceremonyPending = false;
193 }
194 };
195 const detach = () => {
196 guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL);
197 guest.ipc.removeHandler(PASSKEY_GET_CHANNEL);
198 };
199 detach();
200 guest.ipc.handle(
201 PASSKEY_CREATE_CHANNEL,
202 (event, publicKey: PublicKeyCredentialCreationOptions) =>
203 serve(event, publicKey, (context) => backend.create(publicKey, context)),
204 );
205 guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) =>
206 serve(event, publicKey, (context) => backend.get(publicKey, context)),
207 );
208 return detach;
209 },
210 });
211}).pipe(Effect.withSpan("PreviewPasskeys.make"));
212
213export const layer = Layer.effect(PreviewPasskeys, make);