diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index c56a56251f73..1822d6e0b310 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -2,6 +2,7 @@ included: - ios/T3Code - modules/t3-composer-editor/ios - modules/t3-native-controls/ios + - modules/t3-passkeys/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 20f98c6c9e34..9533d4402c2b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -129,6 +129,31 @@ For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` as EAS environment variables. Expo config maps the canonical values into the mobile build. +### Passkeys on the Browser page + +The Browser page shows a page that runs in the server's headless browser, which has no +authenticator. iOS builds can answer that page's passkey requests with the phone's own passkey +sheet (iCloud Keychain, password managers, security keys), but iOS lets an app use passkeys for any +site only with Apple's managed default-browser entitlement, `com.apple.developer.web-browser`. +The Mac counterpart that the desktop app uses, `com.apple.developer.web-browser.public-key-credential`, +does not exist on iOS. + +Builds leave this off. `T3CODE_IOS_BROWSER_PASSKEYS=1` adds the entitlement, and the app then +offers passkeys to the server. Set it only for a bundle identifier whose provisioning profile grants +the entitlement: a profile without it cannot sign the build. To get it: + +- The Account Holder of the T3 Tools Apple Developer organization requests it for the app's App ID + through Apple's + [default browser entitlement request form](https://developer.apple.com/contact/request/default-browser-entitlement/). +- Apple's [criteria](https://developer.apple.com/documentation/xcode/preparing-your-app-to-be-the-default-browser) + make this a product decision to settle first. The app must declare the `http` and `https` URL + schemes, open such links straight to the page, and offer a URL field on launch, so T3 Code + becomes a default browser choice in iOS Settings. iOS also ignores an entitled app's own + Universal Links, which the app's `applinks:` domain relies on today. +- After approval, regenerate the profiles and build with `T3CODE_IOS_BROWSER_PASSKEYS=1`. + +Simulator builds are not signed against a profile, so they accept the flag for local testing. + Create a PR preview dev-client build manually: ```bash diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 57717d2d8458..63902d8c0db9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -10,6 +10,10 @@ Object.assign(process.env, repoEnv); const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; +// Passkeys for Browser page sites need Apple's managed default-browser entitlement. +// A profile without it cannot sign the build, so it stays off until Apple grants it +// (README.md, "Passkeys on the Browser page"). +const iosBrowserPasskeys = repoEnv.T3CODE_IOS_BROWSER_PASSKEYS === "1"; const runtimeVersionPolicy = process.env.MOBILE_VERSION_POLICY ?? (APP_VARIANT === "development" ? "appVersion" : "fingerprint"); @@ -262,8 +266,11 @@ const config: ExpoConfig = { ], entitlements: { "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], + ...(iosBrowserPasskeys ? { "com.apple.developer.web-browser": true } : {}), }, infoPlist: { + // Read by modules/t3-passkeys, so the app offers passkeys only when signed for them. + ...(iosBrowserPasskeys ? { T3BrowserPasskeys: true } : {}), UISupportedInterfaceOrientations: [ "UIInterfaceOrientationPortrait", "UIInterfaceOrientationLandscapeLeft", diff --git a/apps/mobile/modules/t3-passkeys/expo-module.config.json b/apps/mobile/modules/t3-passkeys/expo-module.config.json new file mode 100644 index 000000000000..912fa67ba83e --- /dev/null +++ b/apps/mobile/modules/t3-passkeys/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["apple"], + "apple": { "modules": ["T3PasskeysModule"] } +} diff --git a/apps/mobile/modules/t3-passkeys/ios/T3Passkeys.podspec b/apps/mobile/modules/t3-passkeys/ios/T3Passkeys.podspec new file mode 100644 index 000000000000..4b823d76b308 --- /dev/null +++ b/apps/mobile/modules/t3-passkeys/ios/T3Passkeys.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'T3Passkeys' + s.version = '1.0.0' + s.summary = 'Passkeys for T3 Code server browser pages.' + s.description = 'Answers a server browser page\'s WebAuthn requests with the system passkey sheet.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '18.0', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'AuthenticationServices' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' +end diff --git a/apps/mobile/modules/t3-passkeys/ios/T3PasskeysModule.swift b/apps/mobile/modules/t3-passkeys/ios/T3PasskeysModule.swift new file mode 100644 index 000000000000..41691f23ab1b --- /dev/null +++ b/apps/mobile/modules/t3-passkeys/ios/T3PasskeysModule.swift @@ -0,0 +1,294 @@ +import AuthenticationServices +import ExpoModulesCore +import UIKit + +/// Answers a server browser page's WebAuthn request with the system passkey +/// sheet, for the page's own origin. AuthenticationServices serves any site only +/// to apps that hold Apple's browser entitlement, so builds without it report +/// unavailable and are never asked. +public final class T3PasskeysModule: Module { + private var ceremony: T3PasskeyCeremony? + + public func definition() -> ModuleDefinition { + Name("T3Passkeys") + + Constants { + // app.config.ts sets this only in builds signed with the entitlement. + ["available": Bundle.main.object(forInfoDictionaryKey: "T3BrowserPasskeys") as? Bool == true] + } + + // Both run on the main queue, where AuthenticationServices presents its sheet. + AsyncFunction("perform") { (id: String, kind: String, origin: String, options: String, promise: Promise) in + MainActor.assumeIsolated { + // One sheet at a time: a newer request replaces one still unanswered. + self.ceremony?.cancel() + // The ceremony drops this completion once it runs, which ends the cycle back to the module. + let ceremony = T3PasskeyCeremony(id: id, kind: kind, origin: origin, options: options) { result in + if self.ceremony?.id == id { self.ceremony = nil } + promise.resolve(result) + } + self.ceremony = ceremony + ceremony.start() + } + }.runOnQueue(.main) + + AsyncFunction("cancel") { (id: String) in + MainActor.assumeIsolated { + if self.ceremony?.id == id { self.ceremony?.cancel() } + } + }.runOnQueue(.main) + + OnDestroy { + let ceremony = self.ceremony + DispatchQueue.main.async { ceremony?.cancel() } + } + } +} + +/// One WebAuthn ceremony. The server has checked the origin and RP ID; the +/// result goes back as a `PreviewStreamPasskeyResult` in JSON. +final class T3PasskeyCeremony: NSObject, ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding +{ + let id: String + private let kind: String + private let origin: String + private let options: String + private var completion: ((String) -> Void)? + private var controller: ASAuthorizationController? + + private static let notAllowed: [String: Any] = ["success": false, "error": "NotAllowedError"] + private static let transports: [String: ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport] = [ + "usb": .usb, "nfc": .nfc, "ble": .bluetooth, + ] + + init(id: String, kind: String, origin: String, options: String, completion: @escaping (String) -> Void) { + self.id = id + self.kind = kind + self.origin = origin + self.options = options + self.completion = completion + } + + func start() { + guard let requests = makeRequests(), !requests.isEmpty else { + finish(["success": false, "error": "TypeError"]) + return + } + let controller = ASAuthorizationController(authorizationRequests: requests) + controller.delegate = self + controller.presentationContextProvider = self + self.controller = controller + controller.performRequests() + } + + func cancel() { + controller?.cancel() + finish(Self.notAllowed) + } + + // Passkeys on the device and security keys are offered together, as Safari does, + // unless the page asked for one kind of authenticator. + private func makeRequests() -> [ASAuthorizationRequest]? { + let json = Data(options.utf8) + if kind == "create" { + guard + let options = try? JSONDecoder().decode(CreationOptions.self, from: json), + let challenge = Data(base64URL: options.challenge), + let userID = Data(base64URL: options.user.id) + else { return nil } + let clientData = ASPublicKeyCredentialClientData(challenge: challenge, origin: origin) + let selection = options.authenticatorSelection + let verification = ASAuthorizationPublicKeyCredentialUserVerificationPreference( + rawValue: selection?.userVerification ?? "preferred") + let attestation = ASAuthorizationPublicKeyCredentialAttestationKind(rawValue: options.attestation ?? "none") + let excluded = (options.excludeCredentials ?? []).compactMap { descriptor in + Data(base64URL: descriptor.id).map { (descriptor, $0) } + } + var requests: [ASAuthorizationRequest] = [] + if selection?.authenticatorAttachment != "cross-platform" { + let request = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) + .createCredentialRegistrationRequest(clientData: clientData, name: options.user.name, userID: userID) + request.displayName = options.user.displayName + request.userVerificationPreference = verification + request.attestationPreference = attestation + request.excludedCredentials = excluded.map { + ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0.1) + } + requests.append(request) + } + if selection?.authenticatorAttachment != "platform" { + let request = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) + .createCredentialRegistrationRequest( + clientData: clientData, + displayName: options.user.displayName ?? options.user.name, + name: options.user.name, + userID: userID + ) + request.credentialParameters = [ASAuthorizationPublicKeyCredentialParameters(algorithm: .ES256)] + request.excludedCredentials = excluded.map { securityKey($0.0, id: $0.1) } + request.residentKeyPreference = ASAuthorizationPublicKeyCredentialResidentKeyPreference( + rawValue: selection?.residentKey ?? (selection?.requireResidentKey == true ? "required" : "discouraged")) + request.userVerificationPreference = verification + request.attestationPreference = attestation + requests.append(request) + } + return requests + } + guard + let options = try? JSONDecoder().decode(RequestOptions.self, from: json), + let challenge = Data(base64URL: options.challenge) + else { return nil } + let clientData = ASPublicKeyCredentialClientData(challenge: challenge, origin: origin) + let verification = ASAuthorizationPublicKeyCredentialUserVerificationPreference( + rawValue: options.userVerification ?? "preferred") + let allowed = (options.allowCredentials ?? []).compactMap { descriptor in + Data(base64URL: descriptor.id).map { (descriptor, $0) } + } + let platform = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) + .createCredentialAssertionRequest(clientData: clientData) + platform.allowedCredentials = allowed.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0.1) } + platform.userVerificationPreference = verification + let key = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) + .createCredentialAssertionRequest(clientData: clientData) + key.allowedCredentials = allowed.map { securityKey($0.0, id: $0.1) } + key.userVerificationPreference = verification + return [platform, key] + } + + private func securityKey(_ descriptor: Descriptor, id: Data) -> ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor { + let transports = (descriptor.transports ?? []).compactMap { Self.transports[$0] } + return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor( + credentialID: id, + transports: transports.isEmpty ? ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported : transports + ) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + switch authorization.credential { + case let registration as ASAuthorizationPlatformPublicKeyCredentialRegistration: + guard let attestation = registration.rawAttestationObject else { return finish(Self.notAllowed) } + finish(succeeded([ + "id": registration.credentialID.base64URL, + "clientDataJSON": registration.rawClientDataJSON.base64URL, + "attestationObject": attestation.base64URL, + "authenticatorAttachment": Self.attachment(registration.attachment), + "transports": ["hybrid", "internal"], + ])) + case let registration as ASAuthorizationSecurityKeyPublicKeyCredentialRegistration: + guard let attestation = registration.rawAttestationObject else { return finish(Self.notAllowed) } + finish(succeeded([ + "id": registration.credentialID.base64URL, + "clientDataJSON": registration.rawClientDataJSON.base64URL, + "attestationObject": attestation.base64URL, + "authenticatorAttachment": "cross-platform", + "transports": registration.transports.map(\.rawValue), + ])) + case let assertion as ASAuthorizationPlatformPublicKeyCredentialAssertion: + finish(succeeded([ + "id": assertion.credentialID.base64URL, + "clientDataJSON": assertion.rawClientDataJSON.base64URL, + "authenticatorData": assertion.rawAuthenticatorData.base64URL, + "signature": assertion.signature.base64URL, + "userHandle": assertion.userID.base64URL, + "authenticatorAttachment": Self.attachment(assertion.attachment), + ])) + case let assertion as ASAuthorizationSecurityKeyPublicKeyCredentialAssertion: + finish(succeeded([ + "id": assertion.credentialID.base64URL, + "clientDataJSON": assertion.rawClientDataJSON.base64URL, + "authenticatorData": assertion.rawAuthenticatorData.base64URL, + "signature": assertion.signature.base64URL, + "userHandle": assertion.userID.base64URL, + "authenticatorAttachment": "cross-platform", + ])) + default: + finish(Self.notAllowed) + } + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + // WebAuthn reports a passkey this site already has as InvalidStateError; anything else stays opaque. + let excluded = (error as? ASAuthorizationError)?.code == .matchedExcludedCredential + finish(excluded ? ["success": false, "error": "InvalidStateError"] : Self.notAllowed) + } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) ?? ASPresentationAnchor() + } + + private func succeeded(_ credential: [String: Any]) -> [String: Any] { + ["success": true, "credential": credential] + } + + private static func attachment(_ attachment: ASAuthorizationPublicKeyCredentialAttachment) -> String { + attachment == .crossPlatform ? "cross-platform" : "platform" + } + + private func finish(_ result: [String: Any]) { + guard let completion else { return } + self.completion = nil + controller = nil + let data = (try? JSONSerialization.data(withJSONObject: result)) + ?? Data(#"{"success":false,"error":"NotAllowedError"}"#.utf8) + completion(String(decoding: data, as: UTF8.self)) + } +} + +private struct Descriptor: Decodable { + let id: String + let transports: [String]? +} + +/// The members of `PublicKeyCredentialCreationOptionsJSON` the system sheet uses. +private struct CreationOptions: Decodable { + struct RelyingParty: Decodable { + let id: String + } + + struct User: Decodable { + let id: String + let name: String + let displayName: String? + } + + struct Selection: Decodable { + let authenticatorAttachment: String? + let residentKey: String? + let requireResidentKey: Bool? + let userVerification: String? + } + + let rp: RelyingParty + let user: User + let challenge: String + let excludeCredentials: [Descriptor]? + let authenticatorSelection: Selection? + let attestation: String? +} + +/// The members of `PublicKeyCredentialRequestOptionsJSON` the system sheet uses. +private struct RequestOptions: Decodable { + let rpId: String + let challenge: String + let allowCredentials: [Descriptor]? + let userVerification: String? +} + +private extension Data { + init?(base64URL: String) { + var base64 = base64URL.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + self.init(base64Encoded: base64) + } + + var base64URL: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 122ad1658e27..087a5a1b1161 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -30,6 +30,7 @@ import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; +import IconClipboard from "@tabler/icons-react-native/IconClipboard"; import IconCloud from "@tabler/icons-react-native/IconCloud"; import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; @@ -65,6 +66,7 @@ import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import IconHammer from "@tabler/icons-react-native/IconHammer"; import IconHome from "@tabler/icons-react-native/IconHome"; import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; +import IconKey from "@tabler/icons-react-native/IconKey"; import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; import IconLock from "@tabler/icons-react-native/IconLock"; @@ -164,6 +166,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "chevron.up": IconChevronUp, desktopcomputer: IconDeviceDesktop, doc: IconFileText, + "doc.on.clipboard": IconClipboard, "doc.on.doc": IconCopy, "doc.text": IconFileText, ellipsis: IconDots, @@ -180,6 +183,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { hammer: IconHammer, house: IconHome, "info.circle": IconInfoCircle, + key: IconKey, internaldrive: IconDatabase, keyboard: IconKeyboard, laptopcomputer: IconDeviceLaptop, diff --git a/apps/mobile/src/features/browser/BrowserClipboardMenu.tsx b/apps/mobile/src/features/browser/BrowserClipboardMenu.tsx new file mode 100644 index 000000000000..7aebdc37c843 --- /dev/null +++ b/apps/mobile/src/features/browser/BrowserClipboardMenu.tsx @@ -0,0 +1,42 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; + +const ACTIONS: MenuAction[] = [ + { id: "paste", title: "Paste from clipboard", image: "doc.on.clipboard" }, + { id: "copy", title: "Copy selection", image: "doc.on.doc" }, + { id: "password", title: "Fill password", image: "key" }, +]; + +/** + * Clipboard and password tools for the page field that has the keyboard. The + * page runs on the environment, out of reach of this device's paste menu and + * AutoFill. + */ +export function BrowserClipboardMenu(props: { + readonly onPaste: () => void; + readonly onCopy: () => void; + /** Null on a page without an http(s) origin, which no login belongs to. */ + readonly onFillPassword: (() => void) | null; +}) { + const { onFillPassword } = props; + return ( + action.id !== "password")} + onPressAction={({ nativeEvent }) => { + if (nativeEvent.event === "paste") props.onPaste(); + else if (nativeEvent.event === "copy") props.onCopy(); + else if (nativeEvent.event === "password") onFillPassword?.(); + }} + > + + + ); +} diff --git a/apps/mobile/src/features/browser/BrowserPasswordFill.tsx b/apps/mobile/src/features/browser/BrowserPasswordFill.tsx new file mode 100644 index 000000000000..231d0fc7880a --- /dev/null +++ b/apps/mobile/src/features/browser/BrowserPasswordFill.tsx @@ -0,0 +1,94 @@ +import { useRef, useState } from "react"; +import { Pressable, View, type TextInputInstance } from "react-native"; + +import { AppText, AppTextInput } from "../../components/AppText"; + +export interface BrowserLogin { + readonly username: string; + readonly password: string; +} + +/** + * Takes a saved login from this device's AutoFill (the Passwords key above the + * iOS keyboard, or Android's autofill service) and hands it to the page. + */ +export function BrowserPasswordFill(props: { + /** The page's origin when the card opened, the only site the login goes to. */ + readonly origin: string; + readonly onFill: (login: BrowserLogin) => void; + readonly onCancel: () => void; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const passwordInput = useRef(null); + // Empty the fields, then close on the next frame: iOS offers to save a password + // field's value when the field leaves the screen, and this is not a login here. + const close = (fill: boolean) => { + const login = { username, password }; + setUsername(""); + setPassword(""); + requestAnimationFrame(() => (fill ? props.onFill(login) : props.onCancel())); + }; + const canFill = username.length > 0 || password.length > 0; + // An https site shows as its host; http keeps its scheme, so it reads as unencrypted. + const site = props.origin.replace(/^https:\/\//, ""); + return ( + // Below the address bar and control row, where the page's dialog cards sit. + + + Fill password + {site} + + Choose a saved login above the keyboard. Only this site gets it: the username, then the + password, which only goes into a password field. + + + passwordInput.current?.focus()} + /> + canFill && close(true)} + /> + + close(false)}> + Cancel + + close(true)} + > + Fill + + + + ); +} diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index 1d4f3fb3ea28..036365debd2a 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -6,9 +6,12 @@ import { ThreadId, type PreviewSessionSnapshot, } from "@t3tools/contracts"; +import type { PreviewStreamInput } from "@t3tools/client-runtime/preview/server-browser-stream"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import * as Clipboard from "expo-clipboard"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native"; +import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ControlPill"; @@ -16,8 +19,10 @@ import { ScreenHeader } from "../../components/ScreenHeader"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useThreadServerBrowserTabs } from "../../state/preview"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { BrowserClipboardMenu } from "./BrowserClipboardMenu"; +import { BrowserPasswordFill, type BrowserLogin } from "./BrowserPasswordFill"; import { BrowserTabMenu } from "./BrowserTabMenu"; -import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; +import { browserTabOrigin, browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; import { PreviewStreamWebView, type PreviewPictureInPictureState, @@ -27,6 +32,8 @@ import { const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>(); const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false }; +// Cmd+C runs as the page's copy command, and the page sends its selection back. +const COPY_KEY = { key: "c", code: "KeyC", keyCode: 67, modifiers: 4 } as const; type BrowserPreviewRouteScreenProps = StaticScreenProps<{ readonly environmentId: string; @@ -84,6 +91,11 @@ function BrowserPreviewScreen({ // Address bar commands only reach a page that is streaming. const [streaming, setStreaming] = useState(false); const [canControl, setCanControl] = useState(false); + // A page field has this device's keyboard. + const [typing, setTyping] = useState(false); + // The site a saved login goes to, fixed when the fill card opens; null while it is closed. + const [fillOrigin, setFillOrigin] = useState(null); + const keyboardVisible = useKeyboardState((state) => state.isVisible); const streamRef = useRef(null); const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true }); const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs); @@ -91,6 +103,7 @@ function BrowserPreviewScreen({ // Pin the fallback so another tab's activity does not switch the view. if (tabId !== null && tabId !== selectedTabId) { setPictureInPicture(NO_PICTURE_IN_PICTURE); + setFillOrigin(null); setSelectedTabId(tabId); } useEffect(() => { @@ -108,10 +121,28 @@ function BrowserPreviewScreen({ }, []); const selectTab = (next: string) => { setPictureInPicture(NO_PICTURE_IN_PICTURE); + setFillOrigin(null); setSelectedTabId(next); }; // System picture in picture keeps showing this stream after the app leaves the foreground. const live = (focused && foreground) || pictureInPicture.active; + const send = (input: PreviewStreamInput) => streamRef.current?.command(input); + const pasteFromClipboard = async () => { + // Reading can wait on the paste prompt; if the tab changes meanwhile, nothing is pasted. + const stream = streamRef.current; + const text = await Clipboard.getStringAsync().catch(() => ""); + if (text && stream && streamRef.current === stream) stream.command({ type: "text", text }); + }; + const copySelection = () => { + send({ type: "key", action: "down", ...COPY_KEY }); + send({ type: "key", action: "up", ...COPY_KEY }); + }; + const pageOrigin = tab ? browserTabOrigin(tab) : null; + const fillLogin = (login: BrowserLogin) => { + if (fillOrigin === null) return; + setFillOrigin(null); + send({ type: "fillLogin", origin: fillOrigin, ...login }); + }; return ( @@ -187,11 +218,34 @@ function BrowserPreviewScreen({ background={themeVariables["--color-sheet-solid"]} onPictureInPicture={onPictureInPicture} onStreamingChange={setStreaming} + onPageInput={setTyping} onControl={(control) => setCanControl(control?.controller === "you")} /> ) : ( )} + {live && canControl && fillOrigin !== null ? ( + setFillOrigin(null)} + /> + ) : null} + {live && canControl && typing && keyboardVisible && fillOrigin === null ? ( + + + void pasteFromClipboard()} + onCopy={copySelection} + onFillPassword={pageOrigin === null ? null : () => setFillOrigin(pageOrigin)} + /> + + + ) : null} ) : ( diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 27063f24f1b3..7e8c0ccc333a 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -24,9 +24,15 @@ import * as Clipboard from "expo-clipboard"; import { AppText } from "../../components/AppText"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { beginForegroundHandoff } from "../../lib/foreground-handoff"; import { usePreviewStreamAccess } from "../../state/preview"; +import { + browserPasskeysAvailable, + cancelBrowserPasskey, + performBrowserPasskey, +} from "./browserPasskeys"; import { previewStreamDocument, previewStreamMessage, @@ -55,6 +61,8 @@ type NativeStreamBridge = { readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void; /** True while frames show, so commands reach the page. Pass a stable function. */ readonly onStreamingChange?: (streaming: boolean) => void; + /** A page field took or lost this device's keyboard. Pass a stable function. */ + readonly onPageInput?: (focused: boolean) => void; /** The floating player shows a spinner without text or a reconnect button. */ readonly compact?: boolean; }; @@ -114,7 +122,7 @@ function offerDownload(download: PreviewStreamDownload) { const MAX_REFUSALS = 3; export function PreviewStreamWebView( - props: Omit & + props: Omit & Omit & { readonly environmentId: EnvironmentId; readonly paused?: boolean; @@ -161,7 +169,7 @@ export function PreviewStreamWebView( function AuthorizedPreviewStream({ ref, ...props -}: PreviewStreamConfiguration & NativeStreamBridge) { +}: Omit & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); const [previousAccess, setPreviousAccess] = useState(props.access); // Wait for refreshed access, including cookie credentials with unchanged JSON. @@ -184,6 +192,7 @@ function AuthorizedPreviewStream({ tabId: props.tabId, interactive: props.interactive, background: props.background, + passkeys: props.interactive && browserPasskeysAvailable, } satisfies PreviewStreamConfiguration); return ( () => onStreamingChange?.(false), [onStreamingChange]); + useEffect(() => () => onPageInput?.(false), [onPageInput]); useEffect(() => () => controlChanged(null), []); const processTerminated = () => { if (!active.current || failed.current) return; @@ -394,7 +406,10 @@ function PreviewStreamDocumentView({ onViewport?.(message); return; case "clipboard": - void Clipboard.setStringAsync(message.text).catch(() => undefined); + copyTextWithHaptic(message.text, { target: "browser page selection" }); + return; + case "input": + onPageInput?.(message.focused); return; case "download": offerDownload(message); @@ -402,6 +417,16 @@ function PreviewStreamDocumentView({ case "fileChooser": setFileChooser(message.chooser); return; + case "passkey": { + const { request } = message; + void performBrowserPasskey(request).then((result) => + command({ type: "passkeyResult", id: request.id, result }), + ); + return; + } + case "passkeyCancel": + cancelBrowserPasskey(message.id); + return; case "pictureInPicture": onPictureInPicture?.(message, message.detail); return; diff --git a/apps/mobile/src/features/browser/browserPasskeys.ts b/apps/mobile/src/features/browser/browserPasskeys.ts new file mode 100644 index 000000000000..897a8600e27a --- /dev/null +++ b/apps/mobile/src/features/browser/browserPasskeys.ts @@ -0,0 +1,50 @@ +import type { + PreviewStreamPasskeyRequest, + PreviewStreamPasskeyResult, +} from "@t3tools/client-runtime/preview/server-browser-stream"; +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; + +interface T3Passkeys { + /** Set only in builds signed with Apple's browser entitlement; see app.config.ts. */ + readonly available: boolean; + /** Answers with a `PreviewStreamPasskeyResult` in JSON. */ + readonly perform: ( + id: string, + kind: "create" | "get", + origin: string, + options: string, + ) => Promise; + readonly cancel: (id: string) => Promise; +} + +const native = Platform.OS === "ios" ? requireOptionalNativeModule("T3Passkeys") : null; + +/** This device answers server browser pages' passkey requests with its own passkeys. */ +export const browserPasskeysAvailable = native?.available === true; + +const NOT_ALLOWED: PreviewStreamPasskeyResult = { success: false, error: "NotAllowedError" }; + +/** Runs a page's passkey request through the system passkey sheet. */ +export async function performBrowserPasskey( + request: PreviewStreamPasskeyRequest, +): Promise { + if (!native?.available) return NOT_ALLOWED; + try { + const result = await native.perform( + request.id, + request.kind, + request.origin, + JSON.stringify(request.publicKey), + ); + // The server checks the credential before the page sees it. + return JSON.parse(result) as PreviewStreamPasskeyResult; + } catch { + return NOT_ALLOWED; + } +} + +/** Closes the sheet for a request the page gave up on. */ +export function cancelBrowserPasskey(id: string) { + void native?.cancel(id).catch(() => undefined); +} diff --git a/apps/mobile/src/features/browser/browserTabs.ts b/apps/mobile/src/features/browser/browserTabs.ts index 509ddad5dcf7..f2eaf0bc7f64 100644 --- a/apps/mobile/src/features/browser/browserTabs.ts +++ b/apps/mobile/src/features/browser/browserTabs.ts @@ -12,6 +12,16 @@ export function browserTabUrl(tab: PreviewSessionSnapshot) { return tab.navStatus._tag === "Idle" ? "" : tab.navStatus.url; } +/** The page's http(s) origin, the only site a saved login can be filled into. */ +export function browserTabOrigin(tab: PreviewSessionSnapshot) { + try { + const url = new URL(browserTabUrl(tab)); + return url.protocol === "http:" || url.protocol === "https:" ? url.origin : null; + } catch { + return null; + } +} + export function browserTabTitle(tab: PreviewSessionSnapshot) { if (tab.navStatus._tag === "Idle") return "New tab"; if (tab.navStatus.title.trim()) return tab.navStatus.title; diff --git a/apps/mobile/src/features/browser/preview-stream-document.ts b/apps/mobile/src/features/browser/preview-stream-document.ts index 8b404825707b..1e7a91322422 100644 --- a/apps/mobile/src/features/browser/preview-stream-document.ts +++ b/apps/mobile/src/features/browser/preview-stream-document.ts @@ -4,6 +4,7 @@ import type { PreviewStreamControl, PreviewStreamDownload, PreviewStreamFileChooser, + PreviewStreamPasskeyRequest, } from "@t3tools/client-runtime/preview/server-browser-stream"; export interface PreviewStreamConfiguration { @@ -13,6 +14,8 @@ export interface PreviewStreamConfiguration { /** Taps, scrolls, keys, and `resize` to the view size. The floating player only watches. */ readonly interactive: boolean; readonly background: string; + /** This device answers the page's passkey requests; offered to the server with control. */ + readonly passkeys: boolean; } /** Messages the WebView document posts to the native view. */ @@ -28,8 +31,12 @@ export type PreviewStreamMessage = | ({ readonly type: "hostSetup" } & PreviewStreamHostSetup) | { readonly type: "viewport"; readonly width: number; readonly height: number } | { readonly type: "clipboard"; readonly text: string } + /** The input that types into page fields gained or lost focus. */ + | { readonly type: "input"; readonly focused: boolean } | ({ readonly type: "download" } & PreviewStreamDownload) | { readonly type: "fileChooser"; readonly chooser: PreviewStreamFileChooser | null } + | { readonly type: "passkey"; readonly request: PreviewStreamPasskeyRequest } + | { readonly type: "passkeyCancel"; readonly id: string } | { readonly type: "pictureInPicture"; readonly supported: boolean; diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 91fa5d7a7085..aff2fbef3230 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -179,6 +179,7 @@ export function start(configuration: PreviewStreamConfiguration) { maxWidth: cap.width, maxHeight: cap.height, interactive, + passkeys: configuration.passkeys, }, { onFrame: (jpeg) => painter.paint(jpeg), @@ -186,6 +187,8 @@ export function start(configuration: PreviewStreamConfiguration) { onClipboard: (text) => post({ type: "clipboard", text }), onDownload: (download) => post({ type: "download", ...download }), onFileChooser: (chooser) => post({ type: "fileChooser", chooser }), + onPasskey: (request) => post({ type: "passkey", request }), + onPasskeyCancel: (id) => post({ type: "passkeyCancel", id }), onViewport: (page) => { if (viewport?.width === page.width && viewport.height === page.height) return; viewport = page; @@ -610,6 +613,9 @@ export function start(configuration: PreviewStreamConfiguration) { input.addEventListener("input", onInput); input.addEventListener("compositionend", onCompositionEnd); input.addEventListener("focus", resetInput); + // Native shows its clipboard and password tools while a page field has the keyboard. + input.addEventListener("focus", () => post({ type: "input", focused: true })); + input.addEventListener("blur", () => post({ type: "input", focused: false })); // Copying the input would put its sentinel on this device's clipboard. input.addEventListener("copy", preventDefault); input.addEventListener("cut", preventDefault); diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e..e57a4e086a8d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -58,6 +58,7 @@ "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", "stream-json": "3.6.0", + "tldts": "catalog:", "yaml": "catalog:", "yauzl": "^3.4.0" }, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index f66e255c51b4..99f120717183 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -20,7 +20,7 @@ import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import type { BrowserContext, Page } from "playwright-core"; -import { beforeEach, expect, vi } from "vite-plus/test"; +import { assert, beforeEach, expect, vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -118,6 +118,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { throw new Error("Unexpected locator action"); }), isClosed: () => closed, + isDetached: () => closed, close: vi.fn(async () => { if (!closed) { closed = true; @@ -130,8 +131,9 @@ function makeContext(onClose?: (context: BrowserContext) => void) { sessions, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), - exposeBinding: vi.fn(async (_name: string, binding: ClipboardBinding) => { - clipboardBinding = binding; + exposeBinding: vi.fn(async (name: string, binding: unknown) => { + if (name === "__t3PreviewClipboard") clipboardBinding = binding as ClipboardBinding; + if (name === "__t3PreviewPasskey") passkeyBinding = binding as PasskeyBinding; }), addInitScript: vi.fn(async () => {}), newCDPSession: async () => { @@ -155,6 +157,13 @@ const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; type ClipboardBinding = (source: { page: unknown }, text: unknown) => void; let clipboardBinding: ClipboardBinding | null = null; +type PasskeyBinding = ( + source: { page: unknown; frame: unknown }, + kind: unknown, + ticket?: unknown, + options?: unknown, +) => Promise; +let passkeyBinding: PasskeyBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; @@ -258,10 +267,11 @@ const stallViewer = ( const output = viewer.output as unknown as Queue.Queue; while (Queue.offerUnsafe(output, item)); }; -const viewerInput = (tabId: string, canOperate: boolean) => ({ +const viewerInput = (tabId: string, canOperate: boolean, passkeys = false) => ({ threadId: scope.thread.threadId, tabId, canOperate, + passkeys, maxWidth: 1280, maxHeight: 800, quality: 70, @@ -1035,6 +1045,227 @@ it.live("page copies reach only the controlling viewer right after its input", ( ).pipe(Effect.provide(layer)), ); +it.live.each([ + { + name: "a username field gets the username, then Tab, then the password field the password", + url: "https://example.com/login", + focused: "field", + afterTab: "password", + typed: ["nick", "hunter2"], + tabs: 2, + }, + { + name: "the password is never typed where Tab lands outside a password field", + url: "https://example.com/login", + focused: "field", + afterTab: "field", + typed: ["nick"], + tabs: 2, + }, + { + name: "a focused password field gets only the password", + url: "https://example.com/login", + focused: "password", + afterTab: "password", + typed: ["hunter2"], + tabs: 0, + }, + { + name: "a page that left the confirmed site gets nothing", + url: "https://example.net/login", + focused: "password", + afterTab: "password", + typed: [], + tabs: 0, + }, + { + name: "a field in a cross-origin frame gets nothing", + url: "https://example.com/login", + focused: "frame", + afterTab: "password", + typed: [], + tabs: 0, + }, +] as const)("$name", ({ url, focused, afterTab, typed, tabs }) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + yield* Effect.promise(() => contexts[0]!.page.goto(url)); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const cdp = contexts[0]!.sessions.at(-1)!; + const send = cdp.send.getMockImplementation()!; + // Stands in for the page: Tab moves focus to the next field. + let field: "field" | "password" | "frame" = focused; + cdp.send.mockImplementation(async (method, input) => { + const params = (input ?? {}) as { expression?: string; type?: string; key?: string }; + if (method === "Runtime.evaluate" && params.expression?.includes("activeElement")) { + return { result: { value: field } }; + } + if ( + method === "Input.dispatchKeyEvent" && + params.key === "Tab" && + params.type === "keyUp" + ) { + field = afterTab; + } + return send(method, input); + }); + yield* viewer.input({ + type: "fillLogin", + origin: "https://example.com", + username: "nick", + password: "hunter2", + }); + const inserted = cdp.send.mock.calls + .filter(([method]) => method === "Input.insertText") + .map(([, input]) => (input as { text: string }).text); + expect(inserted).toEqual(typed); + const tabPresses = cdp.send.mock.calls.filter( + ([method, input]) => + method === "Input.dispatchKeyEvent" && (input as { key?: string }).key === "Tab", + ); + expect(tabPresses).toHaveLength(tabs); + }), + ).pipe(Effect.provide(layer)), +); + +/** SHA-256 of `example.com`, which authenticator data starts with. */ +const EXAMPLE_RP_ID_HASH = "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947"; +/** What the phone's authenticator would sign for a sign-in on example.com. */ +const exampleAssertion = (challenge: string, origin = "https://example.com") => ({ + success: true, + credential: { + id: "Y3JlZA", + clientDataJSON: Buffer.from( + JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false }), + ).toString("base64url"), + authenticatorData: Buffer.from(`${EXAMPLE_RP_ID_HASH}0500000001`, "hex").toString("base64url"), + signature: "c2ln", + userHandle: "dXNlcg", + authenticatorAttachment: "platform", + }, +}); + +/** Opens example.com with a viewer in control, then touches the page so it may ask. */ +const passkeyViewer = (passkeys: boolean) => + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + yield* Effect.promise(() => page.goto("https://example.com/login")); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true, passkeys)); + yield* viewer.input({ type: "takeControl" }); + yield* viewer.input({ type: "mouse", action: "move", x: 1, y: 1, button: "none", buttons: 0 }); + return { page, viewer }; + }); + +const nextPasskeyOutput = (viewer: ServerBrowser.ServerBrowserViewer) => + Effect.gen(function* () { + let output = yield* Queue.take(viewer.output); + while (output._tag !== "passkey" && output._tag !== "passkeyCancel") { + output = yield* Queue.take(viewer.output); + } + return output; + }); + +it.live("a page's passkey request goes to the controlling viewer's device and back", () => + Effect.scoped( + Effect.gen(function* () { + const { page, viewer } = yield* passkeyViewer(true); + expect(yield* Effect.promise(() => passkeyBinding!({ page, frame: page }, "available"))).toBe( + true, + ); + const answer = passkeyBinding!({ page, frame: page }, "get", 1, { + challenge: "Y2hhbGxlbmdl", + userVerification: "required", + }); + const request = yield* nextPasskeyOutput(viewer); + assert(request._tag === "passkey"); + expect(request).toMatchObject({ + kind: "get", + origin: "https://example.com", + publicKey: { rpId: "example.com", challenge: "Y2hhbGxlbmdl", userVerification: "required" }, + }); + yield* viewer.input({ + type: "passkeyResult", + id: request.id, + result: exampleAssertion("Y2hhbGxlbmdl"), + }); + expect(yield* Effect.promise(() => answer)).toMatchObject({ + credential: { + id: "Y3JlZA", + rawId: "Y3JlZA", + type: "public-key", + authenticatorAttachment: "platform", + response: { signature: "c2ln", userHandle: "dXNlcg" }, + }, + }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("without a passkey device in control the page keeps Chromium's own WebAuthn", () => + Effect.scoped( + Effect.gen(function* () { + const { page } = yield* passkeyViewer(false); + expect(yield* Effect.promise(() => passkeyBinding!({ page, frame: page }, "available"))).toBe( + false, + ); + expect( + yield* Effect.promise(() => + passkeyBinding!({ page, frame: page }, "get", 1, { challenge: "Y2hhbGxlbmdl" }), + ), + ).toEqual({ native: true }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("only a secure top-level page asks, and the device's answer must be for it", () => + Effect.scoped( + Effect.gen(function* () { + const { page, viewer } = yield* passkeyViewer(true); + const ask = (frame: unknown, options: unknown) => + Effect.promise(() => passkeyBinding!({ page, frame }, "get", 1, options)); + const notAllowed = { error: "NotAllowedError" }; + // A frame inside the page cannot open the sheet. + expect(yield* ask({}, { challenge: "Y2hhbGxlbmdl" })).toEqual(notAllowed); + // Another site's RP ID is refused before the device is asked. + expect(yield* ask(page, { challenge: "Y2hhbGxlbmdl", rpId: "example.net" })).toEqual({ + error: "SecurityError", + }); + // An answer signed for another origin never reaches the page. + const fiber = yield* Effect.forkScoped(ask(page, { challenge: "Y2hhbGxlbmdl" })); + const request = yield* nextPasskeyOutput(viewer); + assert(request._tag === "passkey"); + yield* viewer.input({ + type: "passkeyResult", + id: request.id, + result: exampleAssertion("Y2hhbGxlbmdl", "https://example.net"), + }); + expect(yield* Fiber.join(fiber)).toEqual(notAllowed); + // Plain http is not a secure context. + yield* Effect.promise(() => page.goto("http://example.com/login")); + expect(yield* ask(page, { challenge: "Y2hhbGxlbmdl" })).toEqual(notAllowed); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("releasing control cancels the request on the device and refuses the page", () => + Effect.scoped( + Effect.gen(function* () { + const { page, viewer } = yield* passkeyViewer(true); + const answer = passkeyBinding!({ page, frame: page }, "get", 1, { + challenge: "Y2hhbGxlbmdl", + }); + const request = yield* nextPasskeyOutput(viewer); + assert(request._tag === "passkey"); + yield* viewer.input({ type: "releaseControl" }); + expect(yield* nextPasskeyOutput(viewer)).toEqual({ _tag: "passkeyCancel", id: request.id }); + expect(yield* Effect.promise(() => answer)).toEqual({ error: "NotAllowedError" }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a page download is saved, offered to the controller, and listed for the agent", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 072a022f90c0..8e290f83490a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -66,6 +66,7 @@ import type { Dialog, Download, FileChooser, + Frame, Page, } from "playwright-core"; @@ -77,6 +78,7 @@ import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; import * as PreviewManager from "./Manager.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; +import * as ServerBrowserPasskeys from "./ServerBrowserPasskeys.ts"; import * as PreviewBrowser from "./PreviewBrowser.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; @@ -121,6 +123,32 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]; return !nonText.includes(element.type) && !element.disabled && !element.readOnly; }`; +// Where a viewer's saved login would land, found through shadow roots and same-origin +// frames: "password" for a password field, "field" for anything else, "frame" when focus is +// in a cross-origin frame, which stays opaque and gets nothing. +const FOCUSED_FIELD_SCRIPT = `() => { + let element = document.activeElement; + while (element) { + if (element.tagName === "IFRAME" || element.tagName === "FRAME") { + if (!element.contentDocument) return "frame"; + element = element.contentDocument.activeElement; + continue; + } + const inner = element.shadowRoot?.activeElement; + if (!inner || inner === element) break; + element = inner; + } + const password = element?.tagName === "INPUT" && element.type === "password" && !element.disabled && !element.readOnly; + return password ? "password" : "field"; +}`; +const httpOrigin = (url: string) => { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null; + } catch { + return null; + } +}; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; @@ -191,6 +219,15 @@ export type ServerBrowserViewerOutput = readonly accept: string; } | { readonly _tag: "fileChooserClosed"; readonly id: string } + /** The page asked for a passkey; only a controlling viewer that offered passkeys gets it. */ + | { + readonly _tag: "passkey"; + readonly id: string; + readonly kind: "create" | "get"; + readonly origin: string; + readonly publicKey: Readonly>; + } + | { readonly _tag: "passkeyCancel"; readonly id: string } /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */ | { readonly _tag: "popup"; readonly tabId: string } | { @@ -218,6 +255,8 @@ export class ServerBrowser extends Context.Service< readonly maxHeight: number; readonly quality: number; readonly canOperate: boolean; + /** The viewer's device answers the page's passkey requests while it has control. */ + readonly passkeys?: boolean; }) => Effect.Effect< ServerBrowserViewer, ServerBrowserTabNotFoundError | ServerBrowserLaunchError, @@ -264,6 +303,8 @@ interface ViewerState { scrolledAt: number; /** Last input from this viewer; page copies reach its clipboard only right after. */ inputAt: number; + /** This viewer's device answers the page's passkey requests while it has control. */ + readonly passkeys: boolean; /** Panel bounds, retained in fixed mode; passive viewers never request a size. */ requestedSize: { width: number; height: number; order: number } | null; } @@ -317,6 +358,15 @@ interface ServerTab { readonly accept: string; } | null; dialog: Dialog | null; + /** A page's passkey request, waiting on the controlling viewer's device. */ + passkey: { + readonly id: string; + readonly ticket: unknown; + readonly frame: Frame; + readonly viewerId: string; + readonly request: ServerBrowserPasskeys.PasskeyRequest; + readonly settle: (answer: ServerBrowserPasskeys.PasskeyAnswer) => void; + } | null; setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; @@ -524,7 +574,19 @@ const make = Effect.gen(function* () { } : null; + /** Ends the tab's passkey request, closing its viewer's sheet. The page gets NotAllowedError. */ + const cancelPasskey = (tab: ServerTab) => { + const pending = tab.passkey; + if (!pending) return; + tab.passkey = null; + for (const viewer of tab.viewers) + if (viewer.id === pending.viewerId) viewer.push({ _tag: "passkeyCancel", id: pending.id }); + pending.settle({ error: "NotAllowedError" }); + }; + const broadcastControl = (tab: ServerTab) => { + // A request belongs to the viewer that had control when the page asked. + if (tab.passkey && tab.passkey.viewerId !== tab.control.controller) cancelPasskey(tab); for (const viewer of tab.viewers) viewer.push({ _tag: "control", @@ -685,6 +747,7 @@ const make = Effect.gen(function* () { tabs.delete(key); tab.closing = true; clearAbortedNavigation(tab); + cancelPasskey(tab); // A desktop page outlives the connection unless its session closed with it. const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; for (const viewer of tab.viewers) viewer.push({ _tag: end }); @@ -790,6 +853,7 @@ const make = Effect.gen(function* () { downloads: [], fileChooser: null, dialog: null, + passkey: null, setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, @@ -1100,6 +1164,71 @@ const make = Effect.gen(function* () { : Option.some({ path: download.path, fileName: download.fileName }); }); + /** + * Serves a page's passkey request with the controlling viewer's device. With + * no such viewer, the page keeps Chromium's own WebAuthn. + */ + const answerPasskey = async ( + page: Page, + frame: Frame, + kind: unknown, + ticket: unknown, + options: unknown, + ): Promise => { + const tab = [...tabs.values()].find((candidate) => candidate.page === page); + if (kind === "abort") { + const pending = tab?.passkey; + if (tab && pending && pending.ticket === ticket && pending.frame === frame) { + cancelPasskey(tab); + } + return null; + } + // Pages often check once, on load, so a device watching the tab counts before it takes control. + if (kind === "available") { + return tab !== undefined && [...tab.viewers].some((viewer) => viewer.passkeys); + } + const controller = tab + ? [...tab.viewers].find((viewer) => viewer.id === tab.control.controller && viewer.passkeys) + : undefined; + if (!tab || !controller) return { native: true }; + if (kind !== "create" && kind !== "get") return { error: "TypeError" }; + // Chromium names the origin, never the page. Like Chromium's own sheet, only a + // top-level page asks, one request at a time, right after the person touched it. + const origin = + frame === page.mainFrame() ? ServerBrowserPasskeys.passkeyOrigin(frame.url()) : null; + if ( + origin === null || + tab.passkey !== null || + Date.now() - controller.inputAt > ServerBrowserPasskeys.PASSKEY_GESTURE_MS + ) { + return { error: "NotAllowedError" }; + } + const request = ServerBrowserPasskeys.passkeyRequest(kind, origin, options); + if ("error" in request) return request; + const id = NodeCrypto.randomUUID(); + const answer = await new Promise((resolve) => { + const timer = setTimeout(() => cancelPasskey(tab), request.timeoutMs); + tab.passkey = { + id, + ticket, + frame, + viewerId: controller.id, + request, + settle: (next) => { + clearTimeout(timer); + if (tab.passkey?.id === id) tab.passkey = null; + resolve(next); + }, + }; + controller.push({ _tag: "passkey", id, kind, origin, publicKey: request.publicKey }); + }); + // A credential for a page that navigated away belongs to nobody. + return "credential" in answer && + (frame.isDetached() || ServerBrowserPasskeys.passkeyOrigin(frame.url()) !== origin) + ? { error: "NotAllowedError" } + : answer; + }; + const preparedContexts = new WeakSet(); const prepareContext = async (context: BrowserContext) => { if (preparedContexts.has(context)) return; @@ -1115,6 +1244,12 @@ const make = Effect.gen(function* () { controller.push({ _tag: "clipboard", text: text.slice(0, CLIPBOARD_TEXT_LIMIT) }); }); await context.addInitScript(CLIPBOARD_SCRIPT); + await context.exposeBinding( + ServerBrowserPasskeys.PASSKEY_BINDING, + ({ page, frame }, kind: unknown, ticket: unknown, options: unknown) => + answerPasskey(page, frame, kind, ticket, options), + ); + await context.addInitScript(ServerBrowserPasskeys.PASSKEY_SCRIPT); }; const adoptPopup = async (opener: ServerTab, popup: Page) => { @@ -2100,6 +2235,42 @@ const make = Effect.gen(function* () { await session.send("Input.insertText", { text: message.text.slice(0, 10_000) }); } return; + case "fillLogin": { + const username = typeof message.username === "string" ? message.username : ""; + const password = typeof message.password === "string" ? message.password : ""; + // The site the viewer confirmed. The page's own URL decides, which its scripts cannot fake. + const origin = typeof message.origin === "string" ? message.origin : ""; + const target = async () => { + if (!origin || httpOrigin(tab.page.url()) !== origin) return null; + const result = await session.send("Runtime.evaluate", { + expression: `(${FOCUSED_FIELD_SCRIPT})()`, + returnByValue: true, + }); + const field: unknown = result.result.value; + return field === "password" || field === "field" ? field : null; + }; + const first = await target(); + if (first === null) return; + if (first === "password") { + if (password) await session.send("Input.insertText", { text: password.slice(0, 10_000) }); + return; + } + if (username) await session.send("Input.insertText", { text: username.slice(0, 10_000) }); + if (!password) return; + for (const type of ["rawKeyDown", "keyUp"] as const) { + await session.send("Input.dispatchKeyEvent", { + type, + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + }); + } + // A password never lands in a field that would show it. + if ((await target()) === "password") { + await session.send("Input.insertText", { text: password.slice(0, 10_000) }); + } + return; + } case "resize": { const width = Math.min(Math.round(num(message.width)), 3840); const height = Math.min(Math.round(num(message.height)), 2160); @@ -2209,13 +2380,24 @@ const make = Effect.gen(function* () { if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + else if ( + next._tag === "gone" || + next._tag === "control" || + next._tag === "fileChooser" || + next._tag === "passkey" || + next._tag === "passkeyCancel" + ) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; Queue.offerUnsafe(output, next); // The controller's open picker may have been in the dropped backlog. const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + // So may a passkey request the page still waits on; sent twice, it would open twice. + const request = dropped.value.find((item) => item._tag === "passkey"); + if (request && next._tag !== "passkeyCancel" && tab.passkey?.id === request.id) { + Queue.offerUnsafe(output, request); + } for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, @@ -2228,6 +2410,7 @@ const make = Effect.gen(function* () { resume: () => startScreencast(screencastScale), scrolledAt: 0, inputAt: 0, + passkeys: input.canOperate && input.passkeys === true, requestedSize: null, }; yield* Effect.acquireRelease( @@ -2347,6 +2530,13 @@ const make = Effect.gen(function* () { ? { promptText: message.promptText } : {}), }); + } else if (message.type === "passkeyResult") { + const pending = tab.passkey; + if (pending && pending.id === message.id && pending.viewerId === viewer.id) { + pending.settle( + ServerBrowserPasskeys.passkeyAnswer(pending.request, message.result), + ); + } } else { await tab.control.human(viewer.id, () => dispatchViewerInput(tab, session, viewer, message), diff --git a/apps/server/src/preview/ServerBrowserPasskeys.test.ts b/apps/server/src/preview/ServerBrowserPasskeys.test.ts new file mode 100644 index 000000000000..778a917a1663 --- /dev/null +++ b/apps/server/src/preview/ServerBrowserPasskeys.test.ts @@ -0,0 +1,271 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds real P-256 keys and hashes, as an authenticator would. +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + PASSKEY_SCRIPT, + passkeyAnswer, + passkeyOrigin, + passkeyRequest, + relyingPartyIdError, + type PasskeyRequest, +} from "./ServerBrowserPasskeys.ts"; + +const base64Url = (bytes: Uint8Array | string) => Buffer.from(bytes).toString("base64url"); + +/** Encodes the CBOR subset attestation objects use. */ +const cbor = (value: unknown): Buffer => { + const head = (major: number, length: number) => + length < 24 + ? Buffer.from([(major << 5) | length]) + : length < 256 + ? Buffer.from([(major << 5) | 24, length]) + : Buffer.from([(major << 5) | 25, length >> 8, length & 255]); + if (typeof value === "number") return value >= 0 ? head(0, value) : head(1, -1 - value); + if (typeof value === "string") { + const bytes = Buffer.from(value); + return Buffer.concat([head(3, bytes.length), bytes]); + } + if (value instanceof Uint8Array) return Buffer.concat([head(2, value.length), value]); + if (value instanceof Map) { + return Buffer.concat([ + head(5, value.size), + ...[...value].flatMap(([key, member]) => [cbor(key), cbor(member)]), + ]); + } + throw new Error("Unsupported CBOR value"); +}; + +const rpIdHash = (rpId: string) => NodeCrypto.createHash("sha256").update(rpId).digest(); +const clientData = (type: string, challenge: string, origin = "https://example.com") => + base64Url(JSON.stringify({ type, challenge, origin, crossOrigin: false })); + +const request = (kind: "create" | "get", options: Record) => { + const result = passkeyRequest(kind, "https://example.com", options); + if ("error" in result) throw new Error(result.error); + return result; +}; + +const creation = { + challenge: "Y2hhbGxlbmdl", + rp: { name: "Example" }, + user: { id: base64Url("user-1"), name: "alice", displayName: "Alice" }, + pubKeyCredParams: [ + { type: "public-key", alg: -257 }, + { type: "public-key", alg: -7 }, + ], +}; + +describe("passkeyOrigin", () => { + it("allows https and loopback http only", () => { + expect(passkeyOrigin("https://example.com/login?next=1")).toBe("https://example.com"); + expect(passkeyOrigin("http://localhost:5173/")).toBe("http://localhost:5173"); + expect(passkeyOrigin("http://127.0.0.1:3000/a")).toBe("http://127.0.0.1:3000"); + expect(passkeyOrigin("http://example.com/")).toBeNull(); + expect(passkeyOrigin("about:blank")).toBeNull(); + expect(passkeyOrigin("not a url")).toBeNull(); + }); +}); + +describe("relyingPartyIdError", () => { + it("accepts the host and its registrable parents", () => { + expect(relyingPartyIdError("https://example.com", "example.com")).toBeNull(); + expect(relyingPartyIdError("https://login.example.com", "example.com")).toBeNull(); + expect(relyingPartyIdError("http://localhost:5173", "localhost")).toBeNull(); + }); + + it("refuses public suffixes, other sites, and lookalikes", () => { + expect(relyingPartyIdError("https://alice.github.io", "github.io")).toBe("SecurityError"); + expect(relyingPartyIdError("https://example.com", "com")).toBe("SecurityError"); + expect(relyingPartyIdError("https://example.com", "example.net")).toBe("SecurityError"); + expect(relyingPartyIdError("https://notexample.com", "example.com")).toBe("SecurityError"); + expect(relyingPartyIdError("https://example.com", "login.example.com")).toBe("SecurityError"); + }); +}); + +describe("passkeyRequest", () => { + it("fills in a sign-in's RP ID and clamps its timeout", () => { + const signIn = request("get", { + challenge: "Y2hhbGxlbmdl", + timeout: 1, + allowCredentials: [ + { type: "public-key", id: "Y3JlZA" }, + { type: "other", id: "eA" }, + ], + }); + expect(signIn).toMatchObject({ + rpId: "example.com", + publicKey: { rpId: "example.com" }, + timeoutMs: 10_000, + allowCredentials: ["Y3JlZA"], + }); + }); + + it("refuses options a browser would", () => { + expect(passkeyRequest("get", "https://example.com", { challenge: 5 })).toEqual({ + error: "TypeError", + }); + expect( + passkeyRequest("get", "https://example.com", { challenge: "eA", rpId: "example.net" }), + ).toEqual({ error: "SecurityError" }); + expect( + passkeyRequest("create", "https://example.com", { + ...creation, + user: { ...creation.user, id: "" }, + }), + ).toEqual({ error: "TypeError" }); + expect( + passkeyRequest("create", "https://example.com", { + ...creation, + pubKeyCredParams: [{ type: "public-key", alg: -257 }], + }), + ).toEqual({ error: "NotSupportedError" }); + }); + + it("asks the device for a P-256 key for the page's site", () => { + expect(request("create", creation)).toMatchObject({ + rpId: "example.com", + publicKey: { + rp: { id: "example.com", name: "Example" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + }, + }); + }); +}); + +describe("passkeyAnswer", () => { + const signIn = request("get", { challenge: "Y2hhbGxlbmdl", allowCredentials: [] }); + const assertion = (overrides: Record = {}) => ({ + success: true, + credential: { + id: "Y3JlZA", + clientDataJSON: clientData("webauthn.get", "Y2hhbGxlbmdl"), + authenticatorData: base64Url( + Buffer.concat([rpIdHash("example.com"), Buffer.from([0x05, 0, 0, 0, 1])]), + ), + signature: "c2ln", + userHandle: "dXNlcg", + authenticatorAttachment: "platform", + ...overrides, + }, + }); + + it("hands the page a sign-in signed for this ceremony", () => { + expect(passkeyAnswer(signIn, assertion())).toEqual({ + credential: { + id: "Y3JlZA", + rawId: "Y3JlZA", + type: "public-key", + authenticatorAttachment: "platform", + response: { + clientDataJSON: clientData("webauthn.get", "Y2hhbGxlbmdl"), + authenticatorData: assertion().credential.authenticatorData, + signature: "c2ln", + userHandle: "dXNlcg", + }, + clientExtensionResults: {}, + }, + }); + }); + + it("drops a credential signed for another page, ceremony, or site", () => { + const notAllowed = { error: "NotAllowedError" }; + const wrong = [ + { clientDataJSON: clientData("webauthn.get", "Y2hhbGxlbmdl", "https://example.net") }, + { clientDataJSON: clientData("webauthn.get", "b3RoZXI") }, + { clientDataJSON: clientData("webauthn.create", "Y2hhbGxlbmdl") }, + { authenticatorData: base64Url(Buffer.concat([rpIdHash("example.net"), Buffer.alloc(5)])) }, + { signature: undefined }, + ]; + for (const overrides of wrong) { + expect(passkeyAnswer(signIn, assertion(overrides))).toEqual(notAllowed); + } + const listed = request("get", { + challenge: "Y2hhbGxlbmdl", + allowCredentials: [{ type: "public-key", id: "b3RoZXI" }], + }); + expect(passkeyAnswer(listed, assertion())).toEqual(notAllowed); + }); + + it("passes the device's WebAuthn errors through and hides anything else", () => { + expect(passkeyAnswer(signIn, { success: false, error: "InvalidStateError" })).toEqual({ + error: "InvalidStateError", + }); + expect(passkeyAnswer(signIn, { success: false, error: "ASAuthorizationError 1004" })).toEqual({ + error: "NotAllowedError", + }); + expect(passkeyAnswer(signIn, "garbage")).toEqual({ error: "NotAllowedError" }); + }); + + it("reads a new passkey's public key out of its attestation", () => { + const signUp: PasskeyRequest = request("create", { + ...creation, + extensions: { credProps: true }, + }); + const keys = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = keys.publicKey.export({ format: "jwk" }); + const credentialId = Buffer.from("credential-1"); + const coseKey = new Map([ + [1, 2], + [3, -7], + [-1, 1], + [-2, Buffer.from(jwk.x!, "base64url")], + [-3, Buffer.from(jwk.y!, "base64url")], + ]); + const authData = Buffer.concat([ + rpIdHash("example.com"), + Buffer.from([0x45, 0, 0, 0, 0]), + Buffer.alloc(16), + Buffer.from([0, credentialId.length]), + credentialId, + cbor(coseKey), + ]); + const attestationObject = base64Url( + cbor( + new Map([ + ["fmt", "none"], + ["attStmt", new Map()], + ["authData", authData], + ]), + ), + ); + const registration = (id: string) => ({ + success: true, + credential: { + id, + clientDataJSON: clientData("webauthn.create", "Y2hhbGxlbmdl"), + attestationObject, + authenticatorAttachment: "platform", + transports: ["hybrid", "internal", 5], + }, + }); + expect(passkeyAnswer(signUp, registration(base64Url(credentialId)))).toEqual({ + credential: { + id: base64Url(credentialId), + rawId: base64Url(credentialId), + type: "public-key", + authenticatorAttachment: "platform", + response: { + clientDataJSON: clientData("webauthn.create", "Y2hhbGxlbmdl"), + attestationObject, + authenticatorData: base64Url(authData), + transports: ["hybrid", "internal"], + publicKeyAlgorithm: -7, + publicKey: base64Url(keys.publicKey.export({ format: "der", type: "spki" })), + }, + clientExtensionResults: { credProps: { rk: true } }, + }, + }); + // The credential ID the device reports must be the one it attested. + expect(passkeyAnswer(signUp, registration(base64Url("another")))).toEqual({ + error: "NotAllowedError", + }); + }); +}); + +describe("PASSKEY_SCRIPT", () => { + it("parses, and leaves a page without WebAuthn alone", () => { + expect(() => new Function(PASSKEY_SCRIPT)()).not.toThrow(); + }); +}); diff --git a/apps/server/src/preview/ServerBrowserPasskeys.ts b/apps/server/src/preview/ServerBrowserPasskeys.ts new file mode 100644 index 000000000000..2e8edc26a68a --- /dev/null +++ b/apps/server/src/preview/ServerBrowserPasskeys.ts @@ -0,0 +1,486 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs inside Playwright binding callbacks, outside the Effect runtime. +import * as NodeCrypto from "node:crypto"; +import { getPublicSuffix, parse } from "tldts"; + +/** + * Passkeys for server browser pages, served by the controlling viewer's device. + * Headless Chromium has no authenticator, so a page's WebAuthn request travels + * to the viewer, whose system passkey sheet answers it. This module holds the + * page script and the checks a browser owes the relying party: the origin comes + * from Chromium, never from the page, the RP ID must belong to it, and a + * credential must be the one this ceremony asked for. + */ + +export const PASSKEY_BINDING = "__t3PreviewPasskey"; +/** A page's request reaches a viewer only this soon after it last touched the page. */ +export const PASSKEY_GESTURE_MS = 10_000; +// Chromium clamps WebAuthn timeouts to the same range. +const MIN_TIMEOUT_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 5 * 60_000; +const MAX_TIMEOUT_MS = 10 * 60_000; +// Passkeys on Apple devices are P-256 keys. +const ES256 = -7; +const ERROR_NAMES = [ + "AbortError", + "InvalidStateError", + "NotAllowedError", + "NotSupportedError", + "SecurityError", + "TypeError", +]; + +/** + * Routes the page's WebAuthn ceremonies through the binding. The server answers + * `native` when no viewer holds passkeys, and the page keeps Chromium's own + * WebAuthn. Conditional requests (autofill, automatic upgrades) always stay + * native: the device's sheet must not open on its own. + */ +export const PASSKEY_SCRIPT = `(() => { + if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") return; + const ask = (...args) => new Promise((resolve) => resolve(globalThis.${PASSKEY_BINDING}(...args))); + const bytesOf = (value) => + value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + const toBase64Url = (value) => { + let binary = ""; + for (const byte of bytesOf(value)) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); + }; + const fromBase64Url = (text) => { + const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((text.length + 3) % 4)); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; + }; + // Options cross in WebAuthn's JSON form: binary members as base64url. + const toJson = (value) => { + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return toBase64Url(value); + if (Array.isArray(value)) return value.map(toJson); + if (typeof value !== "object" || value === null) return value; + const json = {}; + for (const [key, member] of Object.entries(value)) + if (typeof member !== "function" && member !== undefined) json[key] = toJson(member); + return json; + }; + const failure = (name) => + name === "TypeError" + ? new TypeError("The passkey request options are invalid.") + : new DOMException( + "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.", + ${JSON.stringify(ERROR_NAMES)}.includes(name) ? name : "NotAllowedError", + ); + // Own properties shadow the native getters, which only work on objects Chromium made. + const withValues = (target, values) => { + for (const [key, value] of Object.entries(values)) + Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); + return target; + }; + const credentialFrom = (json) => { + const response = json.response; + return withValues(Object.create(PublicKeyCredential.prototype), { + id: json.id, + rawId: fromBase64Url(json.rawId), + type: "public-key", + authenticatorAttachment: json.authenticatorAttachment, + response: + "attestationObject" in response + ? withValues(Object.create(AuthenticatorAttestationResponse.prototype), { + clientDataJSON: fromBase64Url(response.clientDataJSON), + attestationObject: fromBase64Url(response.attestationObject), + getTransports: () => [...response.transports], + getAuthenticatorData: () => fromBase64Url(response.authenticatorData), + getPublicKey: () => (response.publicKey === undefined ? null : fromBase64Url(response.publicKey)), + getPublicKeyAlgorithm: () => response.publicKeyAlgorithm, + }) + : withValues(Object.create(AuthenticatorAssertionResponse.prototype), { + clientDataJSON: fromBase64Url(response.clientDataJSON), + authenticatorData: fromBase64Url(response.authenticatorData), + signature: fromBase64Url(response.signature), + userHandle: response.userHandle === undefined ? null : fromBase64Url(response.userHandle), + }), + getClientExtensionResults: () => structuredClone(json.clientExtensionResults), + toJSON: () => structuredClone(json), + }); + }; + let tickets = 0; + const run = async (native, self, kind, options) => { + const signal = options.signal; + const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + if (signal?.aborted) throw abortReason(); + let request; + try { + request = toJson(options.publicKey); + } catch { + throw failure("TypeError"); + } + const ticket = ++tickets; + const answered = ask(kind, ticket, request).catch(() => ({ error: "NotAllowedError" })); + let onAbort = null; + const aborted = signal + ? new Promise((resolve) => { + onAbort = () => resolve({ aborted: true }); + signal.addEventListener("abort", onAbort, { once: true }); + }) + : null; + const answer = await (aborted ? Promise.race([answered, aborted]) : answered); + if (onAbort) signal.removeEventListener("abort", onAbort); + if (answer?.aborted) { + void ask("abort", ticket).catch(() => {}); + throw abortReason(); + } + if (answer?.native) return Reflect.apply(native, self, [options]); + if (answer?.credential) return credentialFrom(answer.credential); + throw failure(answer?.error); + }; + const container = CredentialsContainer.prototype; + for (const [kind, native] of [["create", container.create], ["get", container.get]]) { + Object.defineProperty(container, kind, { + configurable: true, + writable: true, + value: function (options) { + return options?.publicKey && options.mediation !== "conditional" + ? run(native, this, kind, options) + : Reflect.apply(native, this, [options]); + }, + }); + } + const available = () => ask("available").then((value) => value === true, () => false); + const nativeAvailable = PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable; + if (typeof nativeAvailable === "function") { + Object.defineProperty(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", { + configurable: true, + writable: true, + value: async () => (await available()) || Reflect.apply(nativeAvailable, PublicKeyCredential, []), + }); + } + const nativeCapabilities = PublicKeyCredential.getClientCapabilities; + if (typeof nativeCapabilities === "function") { + Object.defineProperty(PublicKeyCredential, "getClientCapabilities", { + configurable: true, + writable: true, + value: async () => { + const capabilities = await Reflect.apply(nativeCapabilities, PublicKeyCredential, []); + return (await available()) + ? { ...capabilities, passkeyPlatformAuthenticator: true, userVerifyingPlatformAuthenticator: true } + : capabilities; + }, + }); + } +})();`; + +type Json = Readonly>; + +/** A page's request that passed the server's checks, ready for the viewer. */ +export interface PasskeyRequest { + readonly kind: "create" | "get"; + readonly origin: string; + readonly rpId: string; + readonly challenge: string; + /** The page's options in WebAuthn's JSON form, with the RP ID filled in. */ + readonly publicKey: Json; + readonly timeoutMs: number; + /** Credential IDs a sign-in accepts; empty means any of this site's. */ + readonly allowCredentials: ReadonlyArray; + readonly credProps: boolean; +} + +/** What the page script receives: a credential in `PublicKeyCredential.toJSON()` form, or an error name. */ +export type PasskeyAnswer = { readonly error: string } | { readonly credential: Json }; + +const NOT_ALLOWED = { error: "NotAllowedError" } as const; +const TYPE_ERROR = { error: "TypeError" } as const; + +const record = (value: unknown): Json | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Json) + : undefined; + +const isBase64Url = (value: unknown): value is string => + typeof value === "string" && /^[A-Za-z0-9_-]*$/u.test(value); + +const fromBase64Url = (value: string) => Buffer.from(value, "base64url"); + +const isPublicSuffix = (domain: string) => + getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; + +/** The origin of a page that may use passkeys: https, or http on this machine. Null otherwise. */ +export const passkeyOrigin = (url: string) => { + if (!URL.canParse(url)) return null; + const { protocol, hostname, origin } = new URL(url); + const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + return protocol === "https:" || (protocol === "http:" && loopback) ? origin : null; +}; + +/** + * WebAuthn's rule: the RP ID is the page's host or a registrable parent of it, + * and never a public suffix, so one github.io site cannot sign in for another. + */ +export const relyingPartyIdError = (origin: string, rpId: string) => { + const host = new URL(origin).hostname; + if (rpId === host) return host === "localhost" || !isPublicSuffix(host) ? null : "SecurityError"; + return !parse(host).isIp && host.endsWith(`.${rpId}`) && !isPublicSuffix(rpId) + ? null + : "SecurityError"; +}; + +const credentialIds = (list: unknown) => + Array.isArray(list) + ? list.flatMap((item) => { + const descriptor = record(item); + return descriptor?.type === "public-key" && isBase64Url(descriptor.id) + ? [descriptor.id] + : []; + }) + : []; + +/** Checks a page's options and settles what the viewer's device is asked for. */ +export const passkeyRequest = ( + kind: "create" | "get", + origin: string, + options: unknown, +): PasskeyRequest | { readonly error: string } => { + const publicKey = record(options); + if (!publicKey || !isBase64Url(publicKey.challenge) || publicKey.challenge.length === 0) { + return TYPE_ERROR; + } + const host = new URL(origin).hostname; + const timeoutMs = + typeof publicKey.timeout === "number" && Number.isFinite(publicKey.timeout) + ? Math.min(Math.max(publicKey.timeout, MIN_TIMEOUT_MS), MAX_TIMEOUT_MS) + : DEFAULT_TIMEOUT_MS; + if (kind === "get") { + const rpId = publicKey.rpId ?? host; + if (typeof rpId !== "string") return TYPE_ERROR; + const rpError = relyingPartyIdError(origin, rpId); + if (rpError) return { error: rpError }; + return { + kind, + origin, + rpId, + challenge: publicKey.challenge, + publicKey: { ...publicKey, rpId }, + timeoutMs, + allowCredentials: credentialIds(publicKey.allowCredentials), + credProps: false, + }; + } + const rp = record(publicKey.rp); + const user = record(publicKey.user); + if (!rp || !user || typeof rp.name !== "string" || typeof user.name !== "string") { + return TYPE_ERROR; + } + const userIdLength = isBase64Url(user.id) ? fromBase64Url(user.id).length : 0; + if (userIdLength < 1 || userIdLength > 64) return TYPE_ERROR; + const rpId = rp.id ?? host; + if (typeof rpId !== "string") return TYPE_ERROR; + const rpError = relyingPartyIdError(origin, rpId); + if (rpError) return { error: rpError }; + const params = publicKey.pubKeyCredParams; + if (params !== undefined && !Array.isArray(params)) return TYPE_ERROR; + const allowsEs256 = + !Array.isArray(params) || + params.length === 0 || + params.some((param) => record(param)?.alg === ES256); + if (!allowsEs256) return { error: "NotSupportedError" }; + return { + kind, + origin, + rpId, + challenge: publicKey.challenge, + publicKey: { + ...publicKey, + rp: { ...rp, id: rpId }, + pubKeyCredParams: [{ type: "public-key", alg: ES256 }], + }, + timeoutMs, + allowCredentials: [], + credProps: record(publicKey.extensions)?.credProps === true, + }; +}; + +/** Decodes the first CBOR item, in the definite-length subset authenticators emit. */ +const decodeCbor = (bytes: Uint8Array): unknown => { + let offset = 0; + const malformed = () => new Error("Malformed CBOR"); + const item = (depth: number): unknown => { + const initial = bytes[offset++]; + if (initial === undefined || depth > 16) throw malformed(); + const major = initial >> 5; + const info = initial & 31; + let length = info; + if (info >= 24) { + if (info > 27) throw malformed(); + const size = 1 << (info - 24); + if (offset + size > bytes.length) throw malformed(); + length = 0; + for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0); + } + switch (major) { + case 0: + return length; + case 1: + return -1 - length; + case 2: + case 3: { + if (offset + length > bytes.length) throw malformed(); + const value = bytes.subarray(offset, offset + length); + offset += length; + return major === 2 ? value : new TextDecoder().decode(value); + } + case 4: + case 5: { + // Every member takes at least a byte, which also bounds hostile lengths. + if (length > bytes.length - offset) throw malformed(); + if (major === 4) return Array.from({ length }, () => item(depth + 1)); + const map = new Map(); + for (let index = 0; index < length; index++) map.set(item(depth + 1), item(depth + 1)); + return map; + } + case 6: + return item(depth + 1); + default: + return info === 21 ? true : info === 20 ? false : null; + } + }; + try { + return item(0); + } catch { + return undefined; + } +}; + +// SubjectPublicKeyInfo header for an uncompressed P-256 point, the form Chromium's getPublicKey() returns. +const P256_SPKI_PREFIX = Buffer.from( + "3059301306072a8648ce3d020106082a8648ce3d03010703420004", + "hex", +); + +/** Reads the attested credential out of registration authenticator data. */ +const attestedCredential = (authData: Uint8Array) => { + // rpIdHash 32, flags 1, sign count 4, AAGUID 16, then a length-prefixed credential ID and its COSE key. + if (authData.length < 55 || ((authData[32] ?? 0) & 0x40) === 0) return undefined; + const idLength = ((authData[53] ?? 0) << 8) | (authData[54] ?? 0); + const credentialId = authData.subarray(55, 55 + idLength); + if (credentialId.length !== idLength) return undefined; + const key = decodeCbor(authData.subarray(55 + idLength)); + const algorithm = key instanceof Map ? key.get(3) : undefined; + if (!(key instanceof Map) || typeof algorithm !== "number") return undefined; + const x = key.get(-2); + const y = key.get(-3); + const p256 = + key.get(1) === 2 && + key.get(-1) === 1 && + x instanceof Uint8Array && + y instanceof Uint8Array && + x.length === 32 && + y.length === 32; + return { + credentialId: Buffer.from(credentialId).toString("base64url"), + algorithm, + publicKey: p256 ? Buffer.concat([P256_SPKI_PREFIX, x, y]).toString("base64url") : undefined, + }; +}; + +const parseJson = (text: string) => { + try { + return record(JSON.parse(text)); + } catch { + return undefined; + } +}; + +/** + * Turns the device's result into what the page receives. The device must have + * signed this ceremony for this page: its client data names the page's origin + * and challenge, and its authenticator data the RP ID's hash. + */ +export const passkeyAnswer = (request: PasskeyRequest, result: unknown): PasskeyAnswer => { + const answer = record(result); + if (answer?.success !== true) { + const error = answer?.error; + return typeof error === "string" && ERROR_NAMES.includes(error) ? { error } : NOT_ALLOWED; + } + const credential = record(answer.credential); + if ( + !credential || + !isBase64Url(credential.id) || + credential.id.length === 0 || + !isBase64Url(credential.clientDataJSON) + ) { + return NOT_ALLOWED; + } + const clientData = parseJson(fromBase64Url(credential.clientDataJSON).toString("utf8")); + if ( + clientData?.type !== (request.kind === "create" ? "webauthn.create" : "webauthn.get") || + clientData.origin !== request.origin || + clientData.challenge !== request.challenge + ) { + return NOT_ALLOWED; + } + const rpIdHash = NodeCrypto.createHash("sha256").update(request.rpId).digest(); + const authenticatorAttachment = + credential.authenticatorAttachment === "platform" || + credential.authenticatorAttachment === "cross-platform" + ? credential.authenticatorAttachment + : null; + + if (request.kind === "create") { + if (!isBase64Url(credential.attestationObject)) return NOT_ALLOWED; + const attestation = decodeCbor(fromBase64Url(credential.attestationObject)); + const authData = attestation instanceof Map ? attestation.get("authData") : undefined; + if (!(authData instanceof Uint8Array) || !rpIdHash.equals(authData.subarray(0, 32))) { + return NOT_ALLOWED; + } + const attested = attestedCredential(authData); + if (attested?.credentialId !== credential.id) return NOT_ALLOWED; + return { + credential: { + id: credential.id, + rawId: credential.id, + type: "public-key", + authenticatorAttachment, + response: { + clientDataJSON: credential.clientDataJSON, + attestationObject: credential.attestationObject, + authenticatorData: Buffer.from(authData).toString("base64url"), + transports: Array.isArray(credential.transports) + ? credential.transports.filter((transport) => typeof transport === "string") + : [], + publicKeyAlgorithm: attested.algorithm, + ...(attested.publicKey === undefined ? {} : { publicKey: attested.publicKey }), + }, + // Passkeys on Apple devices are always discoverable. + clientExtensionResults: + request.credProps && authenticatorAttachment === "platform" + ? { credProps: { rk: true } } + : {}, + }, + }; + } + + if ( + !isBase64Url(credential.authenticatorData) || + !isBase64Url(credential.signature) || + (credential.userHandle !== undefined && !isBase64Url(credential.userHandle)) + ) { + return NOT_ALLOWED; + } + const authData = fromBase64Url(credential.authenticatorData); + if (authData.length < 37 || !rpIdHash.equals(authData.subarray(0, 32))) return NOT_ALLOWED; + // A security key can answer with a credential the page did not list. + if (request.allowCredentials.length > 0 && !request.allowCredentials.includes(credential.id)) { + return NOT_ALLOWED; + } + return { + credential: { + id: credential.id, + rawId: credential.id, + type: "public-key", + authenticatorAttachment, + response: { + clientDataJSON: credential.clientDataJSON, + authenticatorData: credential.authenticatorData, + signature: credential.signature, + ...(credential.userHandle ? { userHandle: credential.userHandle } : {}), + }, + clientExtensionResults: {}, + }, + }; +}; diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 0654ea62a2b8..118903fff092 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -110,7 +110,8 @@ it.effect.each([ ); const server = Context.get(services, HttpServer.HttpServer); const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws"); - const resource = `/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket${interactive ? "" : "&interactive=false"}`; + // Every viewer offers passkeys here; only one that may operate the page keeps them. + const resource = `/api/preview-stream/ws?threadId=thread&tabId=tab&passkeys=true&wsTicket=one-use-ticket${interactive ? "" : "&interactive=false"}`; const received = Promise.withResolvers(); const socket = yield* Effect.acquireRelease( Effect.sync(() => new WebSocket(`${origin}${resource}`)), @@ -146,6 +147,7 @@ it.effect.each([ maxHeight: 800, quality: 70, canOperate, + passkeys: canOperate, }, ]); // In particular, a one-use ticket must never be authenticated a second diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 964f143f5ef6..2d27233130b4 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -86,6 +86,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => threadId, tabId, canOperate, + passkeys: canOperate && params.get("passkeys") === "true", maxWidth: intParam(params, "maxWidth", 1280, 7680), maxHeight: intParam(params, "maxHeight", 800, 4320), quality: intParam(params, "quality", DEFAULT_QUALITY, 100), @@ -156,6 +157,8 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => case "download": case "fileChooser": case "fileChooserClosed": + case "passkey": + case "passkeyCancel": case "popup": case "pointer": case "probe": { diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 231823f13dcc..bcbfcb37500f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -205,6 +205,17 @@ picker, and a finished download is offered for you to save. Popups such as sign-in windows open as their own tabs. Downloads stay on the host until the tab closes. Audio does not play on your device. +On a phone, while you type into the page, the clipboard button above the +keyboard pastes your clipboard, copies the page's selection, or fills a login +saved in your password manager. The fill card shows the site the login goes to, +and nothing is filled if the page has left that site by then. The password only +goes into a password field. + +The host's browser has no passkeys of its own. On an iPhone build that carries +Apple's browser entitlement, a page's passkey sign-in or sign-up opens your +phone's passkey sheet while you have control, for that page's site only. Other +builds, and Android, leave passkeys to the host's browser. + On a phone, tap the floating preview's corner dot to show its controls, then **Pop into separate window** to keep watching in picture-in-picture over other apps. diff --git a/packages/client-runtime/src/preview/serverBrowserStream.test.ts b/packages/client-runtime/src/preview/serverBrowserStream.test.ts index 8a644b22d649..a983fce7e916 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.test.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.test.ts @@ -172,6 +172,43 @@ describe("preview stream downloads", () => { }); }); +describe("preview stream passkeys", () => { + beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket)); + afterEach(() => vi.unstubAllGlobals()); + + it("offers passkeys only when asked, and hands over well-formed requests", () => { + const events = { + onFrame: vi.fn(), + onViewport: vi.fn(), + onConnectedChange: vi.fn(), + onUnauthorized: vi.fn(), + }; + createPreviewStreamClient(target, events).stop(); + expect(new URL(FakeSocket.current.url).searchParams.has("passkeys")).toBe(false); + const onPasskey = vi.fn(); + const onPasskeyCancel = vi.fn(); + const client = createPreviewStreamClient( + { ...target, passkeys: true }, + { ...events, onPasskey, onPasskeyCancel }, + ); + const socket = FakeSocket.current; + expect(new URL(socket.url).searchParams.get("passkeys")).toBe("true"); + const request = { + id: "p1", + kind: "get", + origin: "https://example.com", + publicKey: { challenge: "Y2hhbGxlbmdl", rpId: "example.com" }, + }; + socket.message(JSON.stringify({ type: "passkey", ...request, kind: "register" })); + socket.message(JSON.stringify({ type: "passkey", ...request, publicKey: null })); + socket.message(JSON.stringify({ type: "passkey", ...request })); + socket.message(JSON.stringify({ type: "passkeyCancel", id: "p1" })); + expect(onPasskey).toHaveBeenCalledExactlyOnceWith(request); + expect(onPasskeyCancel).toHaveBeenCalledExactlyOnceWith("p1"); + client.stop(); + }); +}); + describe("preview stream agent pointer", () => { beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket)); afterEach(() => vi.unstubAllGlobals()); diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 34a3786976d0..e9250d0e3862 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -87,6 +87,24 @@ export type PreviewStreamInput = readonly modifiers: number; } | { readonly type: "text"; readonly text: string } + /** + * A saved login from the viewer's AutoFill, for the page only while it is on + * `origin`, the site the viewer confirmed. A focused password field gets the + * password; another field gets the username, then Tab, then the password only + * if Tab reached a password field. A field in a cross-origin frame gets nothing. + */ + | { + readonly type: "fillLogin"; + readonly origin: string; + readonly username: string; + readonly password: string; + } + /** Answer to a `passkey` request (see `PreviewStreamTarget.passkeys`). */ + | { + readonly type: "passkeyResult"; + readonly id: string; + readonly result: PreviewStreamPasskeyResult; + } | { readonly type: "resize"; readonly width: number; readonly height: number } | { readonly type: "navigate"; readonly url: string } | { readonly type: "history"; readonly delta: -1 | 1 } @@ -153,6 +171,39 @@ export const previewStreamDownloadUrl = ( target.access, ); +/** + * A page's WebAuthn request, for the controlling viewer's own passkeys. The + * server has checked the origin and RP ID. Binary members are base64url, as in + * WebAuthn's JSON forms of the options. + */ +export interface PreviewStreamPasskeyRequest { + readonly id: string; + readonly kind: "create" | "get"; + /** The page's origin, as the server's browser reports it, never as the page claims it. */ + readonly origin: string; + /** `PublicKeyCredentialCreationOptionsJSON` or `PublicKeyCredentialRequestOptionsJSON`. */ + readonly publicKey: Readonly>; +} + +/** What the device's authenticator returned, base64url throughout. */ +export interface PreviewStreamPasskeyCredential { + readonly id: string; + readonly clientDataJSON: string; + /** Registrations only. */ + readonly attestationObject?: string; + /** Assertions only. */ + readonly authenticatorData?: string; + readonly signature?: string; + readonly userHandle?: string; + readonly authenticatorAttachment?: "platform" | "cross-platform"; + readonly transports?: ReadonlyArray; +} + +export type PreviewStreamPasskeyResult = + | { readonly success: true; readonly credential: PreviewStreamPasskeyCredential } + /** A WebAuthn DOMException name, such as `NotAllowedError`. */ + | { readonly success: false; readonly error: string }; + /** Answer to a `probe`, echoing its point. */ export interface PreviewStreamProbe { readonly x: number; @@ -189,6 +240,8 @@ export interface PreviewStreamTarget { readonly maxHeight: number; /** Passive viewers reduce their own access, including automatic control grants. */ readonly interactive?: boolean; + /** This device answers the page's passkey requests while it has control. */ + readonly passkeys?: boolean; } export interface PreviewStreamEvents { @@ -207,6 +260,10 @@ export interface PreviewStreamEvents { readonly onPopup?: (tabId: string) => void; /** The page opened a file picker (`null` once answered or replaced). */ readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; + /** The page asked for a passkey; answer with a `passkeyResult` input. */ + readonly onPasskey?: (request: PreviewStreamPasskeyRequest) => void; + /** The page gave up on a passkey request: it aborted, timed out, navigated, or control moved. */ + readonly onPasskeyCancel?: (id: string) => void; /** Input sent while disconnected is dropped. */ readonly onConnectedChange: (connected: boolean) => void; /** The upgrade was refused; refresh access and start a new client. */ @@ -235,6 +292,7 @@ export function createPreviewStreamClient( maxHeight: String(Math.max(1, Math.round(target.maxHeight))), }); if (target.interactive === false) query.set("interactive", "false"); + if (target.passkeys) query.set("passkeys", "true"); const url = withDeviceHubQuery(`${target.access.wsBase}/ws?${query.toString()}`, target.access); let stopped = false; let socket: WebSocket | null = null; @@ -290,8 +348,27 @@ export function createPreviewStreamClient( phase, sequence, tabId, + kind, + origin, + publicKey, } = message as Record; if ( + type === "passkey" && + typeof id === "string" && + (kind === "create" || kind === "get") && + typeof origin === "string" && + typeof publicKey === "object" && + publicKey !== null + ) { + events.onPasskey?.({ + id, + kind, + origin, + publicKey: publicKey as Readonly>, + }); + } else if (type === "passkeyCancel" && typeof id === "string") { + events.onPasskeyCancel?.(id); + } else if ( type === "fileChooser" && typeof id === "string" && typeof multiple === "boolean" && diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31..c71d040599f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,6 +687,9 @@ importers: stream-json: specifier: 3.6.0 version: 3.6.0 + tldts: + specifier: 'catalog:' + version: 7.4.2 yaml: specifier: ^2.9.0 version: 2.9.0