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.
This commit is contained in:
Santo Shakil authored and Codex committed 2026-11-05 20:35:01 +03:00
1 parent f2f2093e41
commit 63df351cad
5 files changed
+226

No files matched your search

+1
View File
@@ -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) {
@@ -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<String?, Error>) -> Void) {
run(completion) { try await $0.register(options) }
}
func authenticate(options: String, completion: @escaping (Result<String?, Error>) -> Void) {
run(completion) { try await $0.authenticate(options) }
}
private func run(
_ completion: @escaping (Result<String?, Error>) -> 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<ASAuthorization, Error>?
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<T>(_ 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<Response: Encodable>: 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
}
+1
View File
@@ -5,6 +5,7 @@
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
<string>webcredentials:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
@@ -7,6 +7,7 @@
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
<string>webcredentials:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
+2
View File
@@ -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(),