diff --git a/apps/native/ios/Podfile.lock b/apps/native/ios/Podfile.lock index be6ab73..b68a186 100644 --- a/apps/native/ios/Podfile.lock +++ b/apps/native/ios/Podfile.lock @@ -38,6 +38,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - ExpoSystemUI (3.0.7): + - ExpoModulesCore - FBLazyVector (0.74.5) - fmt (9.1.0) - glog (0.3.5) @@ -1236,6 +1238,7 @@ DEPENDENCIES: - ExpoFont (from `../../../node_modules/expo-font/ios`) - ExpoKeepAwake (from `../../../node_modules/expo-keep-awake/ios`) - ExpoModulesCore (from `../../../node_modules/expo-modules-core`) + - ExpoSystemUI (from `../../../node_modules/expo-system-ui/ios`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) @@ -1317,6 +1320,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/expo-keep-awake/ios" ExpoModulesCore: :path: "../../../node_modules/expo-modules-core" + ExpoSystemUI: + :path: "../../../node_modules/expo-system-ui/ios" FBLazyVector: :path: "../../../node_modules/react-native/Libraries/FBLazyVector" fmt: @@ -1436,6 +1441,7 @@ SPEC CHECKSUMS: ExpoFont: 00756e6c796d8f7ee8d211e29c8b619e75cbf238 ExpoKeepAwake: 3b8815d9dd1d419ee474df004021c69fdd316d08 ExpoModulesCore: f30a203ff1863bab3dd9f4421e7fc1564797f18a + ExpoSystemUI: d4f065a016cae6721b324eb659cdee4d4cf0cb26 FBLazyVector: ac12dc084d1c8ec4cc4d7b3cf1b0ebda6dab85af fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f diff --git a/apps/native/ios/native.xcodeproj/project.pbxproj b/apps/native/ios/native.xcodeproj/project.pbxproj index d5c1ac6..c7a1ce7 100644 --- a/apps/native/ios/native.xcodeproj/project.pbxproj +++ b/apps/native/ios/native.xcodeproj/project.pbxproj @@ -275,6 +275,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", ); name = "[CP] Copy Pods Resources"; @@ -282,6 +283,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", ); runOnlyForDeploymentPostprocessing = 0; diff --git a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Constants.kt b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Constants.kt index a6ee7f8..52372e3 100644 --- a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Constants.kt +++ b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Constants.kt @@ -73,31 +73,31 @@ class PublicKeyCred : Record { } data class CreateCredentialOptions( - val rp: RelyingParty, + val challenge: String, - val user: UserEntity = UserEntity(), + val rp: RelyingParty, - val challenge: String, + val user: UserEntity, val pubKeyCredParams: List, val timeout: Int?, - val authenticatorSelection: AuthenticatorSelection, - val attestation: String?, + val authenticatorSelection: AuthenticatorSelection, + val excludeCredentials: List? ) data class GetCredentialOptions( val challenge: String, - val allowCredentials: List, - - val timeout: Int?, + val allowCredentials: List?, val userVerification: String?, - val rpId: String? + val timeout: Int?, + + val rpId: String ) diff --git a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt index 46ed06d..930de09 100644 --- a/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt +++ b/packages/cred-native/android/src/main/java/expo/modules/cosmr1/Cosmr1CredentialHandlerModule.kt @@ -59,11 +59,13 @@ class Cosmr1CredentialHandlerModule : Module() { "onAuthenticationSuccess" ) - AsyncFunction("authenticate") Coroutine { challenge: String, - timeout: Int?, - rpId: String?, - userVerification: String?, - allowCredentials: ExclusiveCredentials? + AsyncFunction("authenticate") Coroutine { + prefersImmediatelyAvailableCred: Boolean, + challenge: String, + timeout: Int?, + rpId: String, + userVerification: String?, + allowCredentials: ExclusiveCredentials? -> val getOptions = parseAssertionOptions( @@ -74,22 +76,22 @@ class Cosmr1CredentialHandlerModule : Module() { userVerification ) val getPublicKeyCredentialOption = GetPublicKeyCredentialOption( - requestJson = Gson().toJson(getOptions), - ) + requestJson = Gson().toJson(getOptions)) val getCredRequest = GetCredentialRequest( - listOf(getPublicKeyCredentialOption) - ) + listOf(getPublicKeyCredentialOption), + preferImmediatelyAvailableCredentials = prefersImmediatelyAvailableCred) return@Coroutine authenticate(getCredRequest) } - AsyncFunction("register") Coroutine { prefersImmediatelyAvailableCred: Boolean, - challenge: String, - rp: RelyingParty, - user: UserEntity, - timeout: Int?, - attestation: String?, - excludeCredentials: ExclusiveCredentials?, - authenticatorSelection: AuthenticatorSelection? + AsyncFunction("register") Coroutine { + prefersImmediatelyAvailableCred: Boolean, + challenge: String, + rp: RelyingParty, + user: UserEntity, + timeout: Int?, + attestation: String?, + excludeCredentials: ExclusiveCredentials?, + authenticatorSelection: AuthenticatorSelection? -> val createOptions = parseAttestationOptions( @@ -113,12 +115,12 @@ class Cosmr1CredentialHandlerModule : Module() { challenge: String, allowCredentials: ExclusiveCredentials?, timeout: Int?, - rpId: String?, + rpId: String, userVerification: String? ): GetCredentialOptions { return GetCredentialOptions( challenge = challenge, - allowCredentials = allowCredentials?.items ?: emptyList(), + allowCredentials = allowCredentials?.items, timeout = timeout ?: Constants.TIMEOUT, rpId = rpId, userVerification = userVerification ?: Constants.USER_VERIFICATION @@ -141,7 +143,7 @@ class Cosmr1CredentialHandlerModule : Module() { pubKeyCredParams = listOf(PublicKeyCred()), timeout = timeout ?: Constants.TIMEOUT, attestation = attestation ?: Constants.ATTESTATION, - excludeCredentials = excludeCredentials?.items ?: emptyList(), + excludeCredentials = excludeCredentials?.items, authenticatorSelection = authenticatorSelection ?: AuthenticatorSelection() ) } @@ -160,7 +162,7 @@ class Cosmr1CredentialHandlerModule : Module() { handleAttestationResult(result) } catch (e: CreateCredentialException) { handleAttestationFailure(e) - null + throw e } } @@ -233,7 +235,7 @@ class Cosmr1CredentialHandlerModule : Module() { handleAssertionResult(result) } catch (e: GetCredentialException) { handleAssertionFailure(e) - null + throw e } } diff --git a/packages/cred-native/ios/Constants.swift b/packages/cred-native/ios/Constants.swift new file mode 100644 index 0000000..ca8c553 --- /dev/null +++ b/packages/cred-native/ios/Constants.swift @@ -0,0 +1,175 @@ +import ExpoModulesCore + +class Konstants { + static let TIMEOUT = 60000 + static let ATTESTATION = "direct" + static let AUTHENTICATOR_ATTACHMENT = "platform" + static let REQUIRE_RESIDENT_KEY = true + static let RESIDENT_KEY = "required" + static let USER_VERIFICATION = "required" + static let PUB_KEY_CRED_PARAM: [String: Any] = [ + "type": "public-key", + "alg": -7 + ] +} + +struct RelyingParty: Record { + @Field + var name: String = "" + + @Field + var id: String = "" +} + +struct UserEntity: Record { + @Field + var id: String = "" + + @Field + var name: String = "" + + @Field + var displayName: String = "" +} + +struct AuthenticatorSelection: Record { + @Field + var authenticatorAttachment: String = Konstants.AUTHENTICATOR_ATTACHMENT + + @Field + var requireResidentKey: Bool = Konstants.REQUIRE_RESIDENT_KEY + + @Field + var residentKey: String = Konstants.RESIDENT_KEY + + @Field + var userVerification: String = Konstants.USER_VERIFICATION +} + +struct PublicKeyCredentialDescriptor: Record { + @Field + var id: String = "" + + @Field + var type: String = "public-key" + + @Field + var transports: [String]? = nil +} + +struct ExclusiveCredentials: Record { + @Field + var items: [PublicKeyCredentialDescriptor] = [] +} + +struct PublicKeyCred: Record { + @Field + var type: String = Konstants.PUB_KEY_CRED_PARAM["type"] as! String + + @Field + var alg: Int = Konstants.PUB_KEY_CRED_PARAM["alg"] as! Int +} + +struct CreateCredentialOptions { + var challenge: String + + var rp: RelyingParty + + var user: UserEntity + + var pubKeyCredParams: [PublicKeyCred] + + var timeout: Int? + + var attestation: String? + + var authenticatorSelection: AuthenticatorSelection + + var excludeCredentials: [PublicKeyCredentialDescriptor]? + + init( + challenge: String, + rp: RelyingParty, + user: UserEntity, + pubKeyCredParams: [PublicKeyCred], + timeout: Int?, + attestation: String?, + authenticatorSelection: AuthenticatorSelection, + excludeCredentials: [PublicKeyCredentialDescriptor]?) + { + self.challenge = challenge + self.rp = rp + self.user = user + self.pubKeyCredParams = pubKeyCredParams + self.timeout = timeout + self.attestation = attestation + self.authenticatorSelection = authenticatorSelection + self.excludeCredentials = excludeCredentials + } + + func toDictionary() -> [String: Any] { + return [ + "challenge": challenge, + "rp": rp.toDictionary(), + "user": user.toDictionary(), + "pubKeyCredParams": pubKeyCredParams.compactMap{ + element in element.toDictionary()}, + "timeout": timeout ?? Konstants.TIMEOUT, + "attestation": attestation ?? Konstants.ATTESTATION, + "authenticatorSelection": authenticatorSelection.toDictionary(), + "excludeCredentials": excludeCredentials?.compactMap{ + element in element.toDictionary()} ?? [] + ] + } + + func toString() -> String { + if let payloadJSONData = try? JSONSerialization.data(withJSONObject: toDictionary(), options: .fragmentsAllowed) { + guard let payloadJSONText = String(data: payloadJSONData, encoding: .utf8) else { return "" } + return payloadJSONText + } + } +} + +struct GetCredentialOptions { + var challenge: String + + var allowCredentials: [PublicKeyCredentialDescriptor]? + + var userVerification: String? + + var timeout: Int? + + var rpId: String + + init( + challenge: String, + allowCredentials: [PublicKeyCredentialDescriptor]?, + userVerification: String?, + timeout: Int?, + rpId: String) + { + self.challenge = challenge + self.allowCredentials = allowCredentials + self.userVerification = userVerification + self.timeout = timeout + self.rpId = rpId + } + + func toDictionary() -> [String: Any] { + return [ + "challenge": challenge, + "allowCredentials": allowCredentials?.compactMap{ + element in element.toDictionary()} ?? [], + "userVerification": userVerification ?? Konstants.USER_VERIFICATION, + "timeout": timeout ?? Konstants.TIMEOUT, + "rpId": rpId + ] + } + + func toString() -> String { + if let payloadJSONData = try? JSONSerialization.data(withJSONObject: toDictionary(), options: .fragmentsAllowed) { + guard let payloadJSONText = String(data: payloadJSONData, encoding: .utf8) else { return "" } + return payloadJSONText + } + } +} diff --git a/packages/cred-native/ios/Cosmr1CredentialHandlerModule.swift b/packages/cred-native/ios/Cosmr1CredentialHandlerModule.swift index 2a17fb8..5cc6373 100644 --- a/packages/cred-native/ios/Cosmr1CredentialHandlerModule.swift +++ b/packages/cred-native/ios/Cosmr1CredentialHandlerModule.swift @@ -1,36 +1,200 @@ import ExpoModulesCore +import AuthenticationServices -public class Cosmr1CredentialHandlerModule: Module { - // Each module class must implement the definition function. The definition consists of components - // that describes the module's functionality and behavior. - // See https://docs.expo.dev/modules/module-api for more details about available components. +@available(iOS 16.0, *) +public class Cosmr1CredentialHandlerModule: Module { + + // MARK: - Properties + private var credentialManager: CredentialManager? + public func definition() -> ModuleDefinition { - // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. - // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. - // The module will be accessible from `requireNativeModule('Cosmr1CredentialHandler')` in JavaScript. Name("Cosmr1CredentialHandler") - - // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary. + + OnCreate { + self.credentialManager = CredentialManager() + } + Constants([ - "PILLL": Double.pi + "TIMEOUT": Konstants.TIMEOUT, + "ATTESTATION": Konstants.ATTESTATION, + "AUTHENTICATOR_ATTACHMENT": Konstants.AUTHENTICATOR_ATTACHMENT, + "REQUIRE_RESIDENT_KEY": Konstants.REQUIRE_RESIDENT_KEY, + "RESIDENT_KEY": Konstants.RESIDENT_KEY, + "USER_VERIFICATION": Konstants.USER_VERIFICATION, + "PUB_KEY_CRED_PARAM": Konstants.PUB_KEY_CRED_PARAM ]) - - // Defines event names that the module can send to JavaScript. - Events("onChange") - - // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. - Function("hello") { - return "Hello world! 👋" + + Events( + "onRegistrationStarted", + "onRegistrationFailed", + "onRegistrationComplete", + "onAuthenticationStarted", + "onAuthenticationFailed", + "onAuthenticationSuccess" + ) + + AsyncFunction("authenticate") { ( + prefersImmediatelyAvailableCred: Bool, + challenge: String, + timeout: Int?, + rpId: String, + userVerification: String?, + allowCredentials: ExclusiveCredentials? + ) in + + let getOptions = self.parseAssertionOptions( + challenge: challenge, + allowCredentials: allowCredentials, + timeout: timeout, + rpId: rpId, + userVerification: userVerification + ) + + return try await authenticate(getOptions: getOptions, prefersImmediatelyAvailableCred: prefersImmediatelyAvailableCred) } - - // Defines a JavaScript function that always returns a Promise and whose native code - // is by default dispatched on the different thread than the JavaScript runtime runs on. - AsyncFunction("setValueAsync") { (value: String) in - // Send an event to JavaScript. - self.sendEvent("onChange", [ - "value": value - ]) + + AsyncFunction("register") { ( + prefersImmediatelyAvailableCred: Bool, + challenge: String, + rp: RelyingParty, + user: UserEntity, + timeout: Int?, + attestation: String?, + excludeCredentials: ExclusiveCredentials?, + authenticatorSelection: AuthenticatorSelection? + ) in + + let createOptions = self.parseAttestationOptions( + challenge: challenge, + rp: rp, + user: user, + timeout: timeout, + attestation: attestation, + excludeCredentials: excludeCredentials, + authenticatorSelection: authenticatorSelection + ) + + return try await register(createOptions: createOptions, prefersImmediatelyAvailableCred: prefersImmediatelyAvailableCred) + } + } + + // MARK: - Helper Methods + private func parseAssertionOptions( + challenge: String, + allowCredentials: ExclusiveCredentials?, + timeout: Int?, + rpId: String, + userVerification: String? + ) -> GetCredentialOptions { + return GetCredentialOptions( + challenge: challenge, + allowCredentials: allowCredentials?.items, + userVerification: userVerification ?? Konstants.USER_VERIFICATION, + timeout: timeout ?? Konstants.TIMEOUT, + rpId: rpId + ) + } + + private func parseAttestationOptions( + challenge: String, + rp: RelyingParty, + user: UserEntity, + timeout: Int?, + attestation: String?, + excludeCredentials: ExclusiveCredentials?, + authenticatorSelection: AuthenticatorSelection? + ) -> CreateCredentialOptions { + return CreateCredentialOptions( + challenge: challenge, + rp: rp, + user: user, + pubKeyCredParams: [PublicKeyCred()], + timeout: timeout ?? Konstants.TIMEOUT, + attestation: attestation ?? Konstants.ATTESTATION, + authenticatorSelection: authenticatorSelection ?? AuthenticatorSelection(), + excludeCredentials: excludeCredentials?.items + ) + } + + // MARK: - Authentication Methods + private func authenticate(getOptions: GetCredentialOptions, prefersImmediatelyAvailableCred: Bool) async throws -> [String: Any]? { + self.sendEvent("onAuthenticationStarted", ["request": getOptions.toString()]) + + do { + let uiAnchor = await MainActor.run { + UIApplication.shared.windows.first { $0.isKeyWindow } + } + guard let credentialManager = self.credentialManager else { + throw CustomErrors.invalidState(state: "Credential manager is not initialized") + } + let result = try await credentialManager.authenticate( + getOptions: getOptions, + preferImmediatelyAvailableCredentials: prefersImmediatelyAvailableCred, + anchor: uiAnchor) + self.sendEvent("onAuthenticationSuccess", result) + return result + } catch { + handleAssertionFailure(error: error) + throw error + } + } + + private func register(createOptions: CreateCredentialOptions, prefersImmediatelyAvailableCred: Bool) async throws -> [String: Any]? { + self.sendEvent("onRegistrationStarted", ["request": createOptions.toString()]) + do { + let uiAnchor = await MainActor.run { + UIApplication.shared.windows.first { $0.isKeyWindow } + } + guard let credentialManager = self.credentialManager else { + throw CustomErrors.invalidState(state: "Credential manager is not initialized") + } + let result = try await credentialManager.register( + createOptions: createOptions, + preferImmediatelyAvailableCredentials: prefersImmediatelyAvailableCred, + anchor: uiAnchor) + self.sendEvent("onRegistrationComplete", result) + return result + } catch { + handleAttestationFailure(error: error) + throw error + } + } + + // MARK: - Error Handling + private func handleAssertionFailure(error: Error) { + var response = ["error": "Unexpected exception", "message" : error.localizedDescription] + if let authError = error as? ASAuthorizationError { + response["error"] = mapASAuthorizationError(authError) + } + self.sendEvent("onAuthenticationFailed", response) + } + + private func handleAttestationFailure(error: Error) { + var response = ["error": "Unexpected exception", "message" : error.localizedDescription] + if let authError = error as? ASAuthorizationError { + response["error"] = mapASAuthorizationError(authError) + } + self.sendEvent("onRegistrationFailed", response) + } + + private func mapASAuthorizationError(_ error: ASAuthorizationError) -> String { + switch error.code { + case .canceled: + return "User canceled the request" + case .unknown: + return "An unknown error occurred" + case .invalidResponse: + return "Invalid response received" + case .notHandled: + return "Request was not handled" + case .failed: + return "Authorization failed" + case .notInteractive: + return "The operation requires user interaction" + case .matchedExcludedCredential: + return "The attempted credential was excluded" + @unknown default: + return "Unknown error occurred" } - } } diff --git a/packages/cred-native/ios/CredentialManager.swift b/packages/cred-native/ios/CredentialManager.swift new file mode 100644 index 0000000..2d9b949 --- /dev/null +++ b/packages/cred-native/ios/CredentialManager.swift @@ -0,0 +1,275 @@ +import AuthenticationServices + + +@available(iOS 16.0, *) +class CredentialManager: NSObject, ASAuthorizationControllerPresentationContextProviding, ASAuthorizationControllerDelegate { + // MARK: - Continuations for Async/Await + private var kontinuation: CheckedContinuation<[String: Any], Error>? + + var authenticationAnchor: ASPresentationAnchor? + + // MARK: - Authentication Method + func authenticate( + getOptions: GetCredentialOptions, + preferImmediatelyAvailableCredentials: Bool, + anchor: ASPresentationAnchor? + ) async throws -> [String: Any] { + self.authenticationAnchor = anchor + + return try await withCheckedThrowingContinuation { continuation in + self.kontinuation = continuation + + guard let challengeData = Data.fromBase64url(base64Data: getOptions.challenge) else { + continuation.resume(throwing: CustomErrors.base64UrlDecodingError(reason: "challenge could not be decoded: Invalid base64url")) + kontinuation = nil + return + } + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: getOptions.rpId) + let request = provider.createCredentialAssertionRequest(challenge: challengeData) + + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference(rawValue: getOptions.userVerification!) + + + let cpProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: getOptions.rpId) + let cpRequest = cpProvider.createCredentialAssertionRequest(challenge: challengeData) + + if let allowCredentials = getOptions.allowCredentials, !allowCredentials.isEmpty { + request.allowedCredentials = parseCredentials( + from: allowCredentials, + descriptorType: ASAuthorizationPlatformPublicKeyCredentialDescriptor.self + ) + cpRequest.allowedCredentials = parseCredentials( + from: allowCredentials, + descriptorType: ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.self + ) + } + + let authController = ASAuthorizationController(authorizationRequests: [request, cpRequest]) + authController.delegate = self + authController.presentationContextProvider = self + + if preferImmediatelyAvailableCredentials { + authController.performRequests(options: .preferImmediatelyAvailableCredentials) + } else { + authController.performRequests() + } + } + } + + // MARK: - Registration Method + func register( + createOptions: CreateCredentialOptions, + preferImmediatelyAvailableCredentials: Bool, + anchor: ASPresentationAnchor? + ) async throws -> [String: Any] { + self.authenticationAnchor = anchor + + return try await withCheckedThrowingContinuation { continuation in + self.kontinuation = continuation + + guard let challengeData = Data.fromBase64url(base64Data: createOptions.challenge) else { + continuation.resume(throwing: CustomErrors.base64UrlDecodingError(reason: "challenge could not be decoded: Invalid base64url")) + kontinuation = nil + return + } + + guard let userIDData = Data.fromBase64url(base64Data: createOptions.user.id) else { + continuation.resume(throwing: CustomErrors.base64UrlDecodingError(reason: "user.id could not be decoded: Invalid base64url")) + kontinuation = nil + return + } + + @MainActor func getRequest() -> ASAuthorizationPublicKeyCredentialRegistrationRequest { + if createOptions.authenticatorSelection.authenticatorAttachment == Konstants.AUTHENTICATOR_ATTACHMENT { + // platfrom request + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: createOptions.rp.id) + let request = provider.createCredentialRegistrationRequest( + challenge: challengeData, + name: createOptions.user.name, + userID: userIDData + ) + + if let excludedCredentials = createOptions.excludeCredentials, !excludedCredentials.isEmpty { + if #available(iOS 17.4, *) { + request.excludedCredentials = parseCredentials( + from: excludedCredentials, + descriptorType: ASAuthorizationPlatformPublicKeyCredentialDescriptor.self + ) + } + } + + return request + } else { + // cross platform request + let provider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: createOptions.rp.id) + let request = provider.createCredentialRegistrationRequest( + challenge: challengeData, + displayName: createOptions.user.displayName, + name: createOptions.user.name, + userID: userIDData + ) + + request.residentKeyPreference = ASAuthorizationPublicKeyCredentialResidentKeyPreference(rawValue: createOptions.authenticatorSelection.residentKey) + request.credentialParameters = createOptions.pubKeyCredParams.map { + credParam in ASAuthorizationPublicKeyCredentialParameters(algorithm: ASCOSEAlgorithmIdentifier(credParam.alg)) + } + + if let excludedCredentials = createOptions.excludeCredentials, !excludedCredentials.isEmpty { + request.excludedCredentials = parseCredentials( + from: excludedCredentials, + descriptorType: ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.self + ) + } + + return request + } + } + + let request = getRequest() + + if let attestation = createOptions.attestation { + request.attestationPreference = ASAuthorizationPublicKeyCredentialAttestationKind(rawValue: attestation) + } + request.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference(rawValue: createOptions.authenticatorSelection.userVerification) + + let authController = ASAuthorizationController(authorizationRequests: [request as! ASAuthorizationRequest]) + authController.delegate = self + authController.presentationContextProvider = self + + if preferImmediatelyAvailableCredentials { + authController.performRequests(options: .preferImmediatelyAvailableCredentials) + } else { + authController.performRequests() + } + } + } + + // MARK: - ASAuthorizationControllerDelegate Methods + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + switch authorization.credential { + case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration: + let attestationObject = credentialRegistration.rawAttestationObject?.base64URLEncode() + let clientDataJSON = credentialRegistration.rawClientDataJSON.base64URLEncode() + let credentialId = credentialRegistration.credentialID.base64URLEncode() + + let response = [ + "attestationObject": attestationObject, + "clientDataJSON": clientDataJSON + ] + + let payload = [ + "rawId": credentialId, + "id": credentialId, + "type": "public-key", + "response": response + ] as [String : Any] + + kontinuation?.resume(returning: payload) + kontinuation = nil + case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion: + let signature = credentialAssertion.signature.base64URLEncode() + let authenticatorData = credentialAssertion.rawAuthenticatorData.base64URLEncode() + let userHandle = credentialAssertion.userID.base64URLEncode() + let clientDataJSON = credentialAssertion.rawClientDataJSON.base64URLEncode() + let credentialId = credentialAssertion.credentialID.base64URLEncode() + + let response = [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": userHandle + ] + + let payload = [ + "rawId": credentialId, + "id": credentialId, + "type": "public-key", + "response": response + ] as [String : Any] + + kontinuation?.resume(returning: payload) + kontinuation = nil + default: + kontinuation?.resume(throwing: CustomErrors.unexpectedAuthorizationResponse(credential: authorization.credential)) + kontinuation = nil + } + + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + if let continuation = kontinuation { + continuation.resume(throwing: error) + kontinuation = nil + } + } + + // MARK: - ASAuthorizationControllerPresentationContextProviding + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + if let anchor = authenticationAnchor { + return anchor + } else { + return ASPresentationAnchor() + } + } + + private func parseCredentials( + from credentials: [PublicKeyCredentialDescriptor], + descriptorType: T.Type + ) -> [T] { + return credentials.compactMap { credential in + guard let credentialID = Data.fromBase64url(base64Data: credential.id) else { + return nil + } + + if descriptorType == ASAuthorizationPlatformPublicKeyCredentialDescriptor.self { + return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialID) as? T + } else if descriptorType == ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.self { + let transports = credential.transports?.compactMap { channel in + ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport(rawValue: channel) + } ?? [.bluetooth, .nfc, .usb] + return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor(credentialID: credentialID, transports: transports) as? T + } else { + return nil + } + } + } + +} + + + +// MARK: - Data Extensions +extension Data { + func base64URLEncode() -> String { + let base64 = self.base64EncodedString() + let base64URL = base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return base64URL + } + + static func fromBase64url (base64Data: String) -> Data? { + var base64 = base64Data + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + + guard let data = Data(base64Encoded: base64) else { + return nil + } + + return data + } +} + +// MARK: - Custom Errors +enum CustomErrors: Error { + case base64UrlDecodingError(reason: String) + case unexpectedAuthorizationResponse(credential: ASAuthorizationCredential) + case invalidState(state: String) +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e5ac3c..3eeeb26 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,8 +1,8 @@ { "name": "@vaariance/ui", "version": "0.0.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "sideEffects": [ "**/*.css" ],