From 63df351cad8c191827749f600ba58262fc6b385f Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 5 Nov 2026 20:47:23 +0600 Subject: [PATCH] feat(mobile): passkey bridge for ios (#32780) * feat(mobile): passkey bridge for ios * fix(mobile): ios passkey bridge in one file * merge Passkeys.swift into PasskeyHostApiImpl.swift * set up the host api on the main engine only * map the json with Codable structs, with base64url in the coder data strategies * throw on a missing attestation object or an unexpected credential type * need iOS 17.4, where a passkey registration can exclude the account's passkeys * fix(mobile): ios passkeys from ios 16 Passkeys are on from iOS 16 again. The platform registration only takes excludedCredentials from iOS 17.4, so that check keeps its own comment. --- mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/Core/PasskeyHostApiImpl.swift | 221 ++++++++++++++++++ mobile/ios/Runner/Runner.entitlements | 1 + mobile/ios/Runner/RunnerProfile.entitlements | 1 + mobile/pigeon/passkey_api.dart | 2 + 5 files changed, 226 insertions(+) create mode 100644 mobile/ios/Runner/Core/PasskeyHostApiImpl.swift diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e1ff3b1f6..33b16c81e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -22,6 +22,7 @@ import native_video_player GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) let messenger = engineBridge.applicationRegistrar.messenger() AppDelegate.registerPlugins(with: engineBridge.pluginRegistry, messenger: messenger) + PasskeyHostApiSetup.setUp(binaryMessenger: messenger, api: PasskeyHostApiImpl()) } public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) { diff --git a/mobile/ios/Runner/Core/PasskeyHostApiImpl.swift b/mobile/ios/Runner/Core/PasskeyHostApiImpl.swift new file mode 100644 index 000000000..f6cb2576e --- /dev/null +++ b/mobile/ios/Runner/Core/PasskeyHostApiImpl.swift @@ -0,0 +1,221 @@ +import AuthenticationServices +import UIKit + +class PasskeyHostApiImpl: PasskeyHostApi { + func isSupported() throws -> Bool { + if #available(iOS 16, *) { + return true + } + + return false + } + + func register(options: String, completion: @escaping (Result) -> Void) { + run(completion) { try await $0.register(options) } + } + + func authenticate(options: String, completion: @escaping (Result) -> Void) { + run(completion) { try await $0.authenticate(options) } + } + + private func run( + _ completion: @escaping (Result) -> Void, + _ operation: @escaping @MainActor (Passkeys) async throws -> String? + ) { + Task { @MainActor in + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + guard let window else { + return completion(.failure(PigeonError(code: "NO_WINDOW", message: "Window not available", details: nil))) + } + + do { + completion(.success(try await operation(Passkeys(anchor: window)))) + } catch { + completion(.failure(error)) + } + } + } +} + +// AuthenticationServices has no WebAuthn JSON API, so the server's options and the credentials are mapped here +@MainActor +private final class Passkeys: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + private static let decoder = { + let decoder = JSONDecoder() + decoder.dataDecodingStrategy = .custom { decoder in + var base64 = try decoder.singleValueContainer().decode(String.self) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Invalid base64url")) + } + + return data + } + return decoder + }() + + private static let encoder = { + let encoder = JSONEncoder() + encoder.dataEncodingStrategy = .custom { data, encoder in + var container = encoder.singleValueContainer() + try container.encode( + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + ) + } + return encoder + }() + + private let anchor: ASPresentationAnchor + private var continuation: CheckedContinuation? + + init(anchor: ASPresentationAnchor) { + self.anchor = anchor + } + + func register(_ json: String) async throws -> String? { + let options = try Self.decoder.decode(CreationOptions.self, from: Data(json.utf8)) + let request = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) + .createCredentialRegistrationRequest(challenge: options.challenge, name: options.user.name, userID: options.user.id) + request.displayName = options.user.displayName + request.userVerificationPreference = .init(rawValue: options.authenticatorSelection.userVerification) + request.attestationPreference = .init(rawValue: options.attestation) + // A platform registration takes excludedCredentials from iOS 17.4. Without it the same device can register the + // account again, and the server keeps adding passkeys for it + if #available(iOS 17.4, *) { + request.excludedCredentials = options.excludeCredentials.map { + ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0.id) + } + } + + guard let credential: ASAuthorizationPlatformPublicKeyCredentialRegistration = try await perform(request) else { + return nil + } + + guard let attestationObject = credential.rawAttestationObject else { + throw ASAuthorizationError(.invalidResponse) + } + + let response = RegistrationResponse(clientDataJSON: credential.rawClientDataJSON, attestationObject: attestationObject) + return try encode(credential, response) + } + + func authenticate(_ json: String) async throws -> String? { + let options = try Self.decoder.decode(RequestOptions.self, from: Data(json.utf8)) + let request = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) + .createCredentialAssertionRequest(challenge: options.challenge) + request.userVerificationPreference = .init(rawValue: options.userVerification) + + guard let credential: ASAuthorizationPlatformPublicKeyCredentialAssertion = try await perform(request) else { + return nil + } + + let response = AssertionResponse( + clientDataJSON: credential.rawClientDataJSON, + authenticatorData: credential.rawAuthenticatorData, + signature: credential.signature, + userHandle: credential.userID + ) + return try encode(credential, response) + } + + private func perform(_ request: ASAuthorizationRequest) async throws -> T? { + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + + do { + let authorization = try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + controller.performRequests() + } + + guard let credential = authorization.credential as? T else { + throw ASAuthorizationError(.invalidResponse) + } + + return credential + } catch ASAuthorizationError.canceled { + return nil + } + } + + private func encode(_ credential: ASPublicKeyCredential, _ response: some Encodable) throws -> String { + let json = PublicKeyCredential(id: credential.credentialID, rawId: credential.credentialID, response: response) + return String(decoding: try Self.encoder.encode(json), as: UTF8.self) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + continuation?.resume(returning: authorization) + continuation = nil + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + continuation?.resume(throwing: error) + continuation = nil + } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + anchor + } +} + +private struct CreationOptions: Decodable { + struct RelyingParty: Decodable { + let id: String + } + + struct User: Decodable { + let id: Data + let name: String + let displayName: String + } + + struct Credential: Decodable { + let id: Data + } + + struct Selection: Decodable { + let userVerification: String + } + + let challenge: Data + let rp: RelyingParty + let user: User + let excludeCredentials: [Credential] + let authenticatorSelection: Selection + let attestation: String +} + +private struct RequestOptions: Decodable { + let challenge: Data + let rpId: String + let userVerification: String +} + +private struct PublicKeyCredential: Encodable { + let id: Data + let rawId: Data + let type = "public-key" + let response: Response + let clientExtensionResults: [String: String] = [:] +} + +private struct RegistrationResponse: Encodable { + let clientDataJSON: Data + let attestationObject: Data +} + +private struct AssertionResponse: Encodable { + let clientDataJSON: Data + let authenticatorData: Data + let signature: Data + let userHandle: Data +} diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index c8a6be9cb..72efa72e7 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -5,6 +5,7 @@ com.apple.developer.associated-domains applinks:my.immich.app + webcredentials:my.immich.app com.apple.developer.networking.wifi-info diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 93a4aab55..f9b1252c9 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -7,6 +7,7 @@ com.apple.developer.associated-domains applinks:my.immich.app + webcredentials:my.immich.app com.apple.developer.networking.wifi-info diff --git a/mobile/pigeon/passkey_api.dart b/mobile/pigeon/passkey_api.dart index a327ce580..7eb6c12b1 100644 --- a/mobile/pigeon/passkey_api.dart +++ b/mobile/pigeon/passkey_api.dart @@ -3,6 +3,8 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon( PigeonOptions( dartOut: 'lib/platform/passkey_api.g.dart', + swiftOut: 'ios/Runner/Core/Passkey.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/passkey/Passkey.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.passkey'), dartOptions: DartOptions(),