| 1 | // @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime. |
| 2 | import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron"; |
| 3 | import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | |
| 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; |
| 12 | import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; |
| 13 | import { macPasskeyBackend } from "./PasskeyBackendMac.ts"; |
| 14 | import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; |
| 15 | |
| 16 | const 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 | }); |
| 24 | const decodePasskeyPackageMetadata = Schema.decodeEffect( |
| 25 | Schema.fromJsonString(PasskeyPackageMetadata), |
| 26 | ); |
| 27 | |
| 28 | export 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 | */ |
| 55 | export 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 | |
| 69 | const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" }; |
| 70 | |
| 71 | const 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 | |
| 76 | const 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 | |
| 82 | const 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 | |
| 102 | const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; |
| 103 | const 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. |
| 107 | const 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 | |
| 112 | const 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. |
| 123 | const 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 | */ |
| 137 | const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => |
| 138 | platform === "darwin" ? macPasskeyBackend : undefined; |
| 139 | |
| 140 | const 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 | |
| 242 | export const layer = Layer.effect(PreviewPasskeys, make); |