From c21d47f240ba8911b237f79cca0ad0ea321fb1fd Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 1 May 2025 06:46:40 +0530 Subject: [PATCH] passkey feature implementation --- .../apple-app-site-association.json | 38 +++++++ FirebaseAuth/Sources/Swift/Auth/Auth.swift | 73 +++++++++++++ .../Sources/Swift/Backend/AuthBackend.swift | 33 ++++++ .../FinalizePasskeyEnrollmentRequest.swift | 51 +++++++++ .../FinalizePasskeyEnrollmentResponse.swift | 28 +++++ .../RPC/FinalizePasskeySignInRequest.swift | 43 ++++++++ .../RPC/FinalizePasskeySignInResponse.swift | 26 +++++ .../Backend/RPC/GetAccountInfoResponse.swift | 12 +++ .../RPC/Proto/AuthProtoPasskeyInfo.swift | 48 +++++++++ .../Backend/RPC/SetAccountInfoRequest.swift | 7 ++ .../RPC/StartPasskeyEnrollmentRequest.swift | 38 +++++++ .../RPC/StartPasskeyEnrollmentResponse.swift | 64 +++++++++++ .../RPC/StartPasskeySignInRequest.swift | 24 +++++ .../RPC/StartPasskeySignInResponse.swift | 25 +++++ FirebaseAuth/Sources/Swift/User/User.swift | 100 +++++++++++++++++ .../Sources/Swift/User/UserInfo.swift | 2 + .../Sources/Swift/User/UserInfoImpl.swift | 2 + .../Models/AuthMenu.swift | 34 +++++- .../Models/UserActions.swift | 1 + .../AccountLinkingViewController.swift | 45 +++++++- .../ViewControllers/AuthViewController.swift | 102 ++++++++++++++++++ .../PasskeyViewController.swift | 76 +++++++++++++ .../ViewControllers/UserViewController.swift | 3 + 23 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 FirebaseAuth/Sources/Public/well-known/apple-app-site-association.json create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProtoPasskeyInfo.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift create mode 100644 FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/PasskeyViewController.swift diff --git a/FirebaseAuth/Sources/Public/well-known/apple-app-site-association.json b/FirebaseAuth/Sources/Public/well-known/apple-app-site-association.json new file mode 100644 index 00000000000..887f2602e4c --- /dev/null +++ b/FirebaseAuth/Sources/Public/well-known/apple-app-site-association.json @@ -0,0 +1,38 @@ +{ + "applinks": { + "details": [ + { + "appID": "EQHXZ8M8AV.com.google.firebaseAuthSDKSampleApp.dev", + "components": [ + { + "#": "no_universal_links", + "exclude": true, + "comment": "Matches any URL with a fragment that equals no_universal_links and instructs the system not to open it as a universal link." + }, + { + "/": "/buy/*", + "comment": "Matches any URL with a path that starts with /buy/." + }, + { + "/": "/help/website/*", + "exclude": true, + "comment": "Matches any URL with a path that starts with /help/website/ and instructs the system not to open it as a universal link." + }, + { + "/": "/help/*", + "?": { "articleNumber": "????" }, + "comment": "Matches any URL with a path that starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly four characters." + } + ] + } + ] + }, + "webcredentials": { + "apps": [ "EQHXZ8M8AV.com.google.firebaseAuthSDKSampleApp.dev" ] + }, + + + "appclips": { + "apps": ["EQHXZ8M8AV.com.google.firebaseAuthSDKSampleApp.dev.Clip"] + } +} diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 5d8050cc891..3eff4e1a0e9 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -18,6 +18,7 @@ import FirebaseAppCheckInterop import FirebaseAuthInterop import FirebaseCore import FirebaseCoreExtension + #if COCOAPODS internal import GoogleUtilities #else @@ -28,6 +29,10 @@ import FirebaseCoreExtension #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) import UIKit #endif +#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + import AuthenticationServices +#endif + // Export the deprecated Objective-C defined globals and typedefs. #if SWIFT_PACKAGE @@ -824,6 +829,62 @@ extension Auth: AuthInterop { } } } + + let sessionId = "sessionId" + + // MARK: Passkeys + @available(iOS 15.0, *) + public func startPasskeySignIn() async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + + let request = StartPasskeySignInRequest( + sessionId: sessionId, + requestConfiguration: requestConfiguration + ) + + let response = try await self.backend.startPasskeySignIn(request: request) + + guard let challengeData = Data(base64Encoded: response.challenge ?? "nil") else { + throw NSError(domain: "com.firebase.auth", code: -3, userInfo: [NSLocalizedDescriptionKey: "Invalid challenge data"]) + } + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID ?? "fir-ios-auth-sample.web.app.com" + ) + let assertionRequest = provider.createCredentialAssertionRequest(challenge: challengeData) + + return assertionRequest + } + + @available(iOS 15.0, *) + public func finalizePasskeySignIn(platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws -> AuthDataResult { + guard let credentialID = platformCredential.credentialID.base64EncodedString(), + let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString(), + let authenticatorData = platformCredential.rawAuthenticatorData.base64EncodedString(), + let signature = platformCredential.signature.base64EncodedString(), + let userID = platformCredential.userID.base64EncodedString() + else { + throw NSError(domain: "com.firebase.auth", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid platform credential data"]) + } + + let request = FinalizePasskeySignInRequest(credentialID: credentialID, + clientDataJson: clientDataJson, + authenticatorData: authenticatorData, + signature: signature, + userID: userID, + requestConfiguration: requestConfiguration) + + let response = try await backend.finalizePasskeySignIn(request: request) + + let user = try await completeSignIn(withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false) + let authDataResult = AuthDataResult(withUser: user, additionalUserInfo: nil) + + try updateCurrentUser(user, byForce: false, savingToDisk: false) + return authDataResult + } + /// Creates and, on success, signs in a user with the given email address and password. /// @@ -2425,3 +2486,15 @@ extension Auth: AuthInterop { /// Mutations should occur within a @synchronized(self) context. private var listenerHandles: NSMutableArray = [] } + +extension Data { + func base64EncodedString() -> String? { + return base64EncodedString() + } +} + +extension String { + func base64EncodedString() -> String? { + return self.data(using: .utf8)?.base64EncodedString() + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 7a0c39340ae..3fd31740f70 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -37,6 +37,39 @@ final class AuthBackend: AuthBackendProtocol { init(rpcIssuer: any AuthBackendRPCIssuerProtocol) { self.rpcIssuer = rpcIssuer } + + public func startPasskeyEnrollment(request: StartPasskeyEnrollmentRequest) async throws -> StartPasskeyEnrollmentResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + throw AuthErrorUtils.error(code: AuthErrorCode.operationNotAllowed, message: "OS version is not supported for this action.") + } + let response = try await call(with: request) as StartPasskeyEnrollmentResponse + return response + } + + public func finalizePasskeyEnrollment(request: FinalizePasskeyEnrollmentRequest) async throws -> FinalizePasskeyEnrollmentResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + throw AuthErrorUtils.error(code: AuthErrorCode.operationNotAllowed, message: "OS version is not supported for this action.") + } + let response = try await call(with: request) as FinalizePasskeyEnrollmentResponse + return response + } + + public func startPasskeySignIn(request: StartPasskeySignInRequest) async throws -> StartPasskeySignInResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + throw AuthErrorUtils.error(code: AuthErrorCode.operationNotAllowed, message: "OS version is not supported for this action.") + } + let response = try await call(with: request) as StartPasskeySignInResponse + return response + } + + public func finalizePasskeySignIn(request: FinalizePasskeySignInRequest) async throws -> FinalizePasskeySignInResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else{ + throw AuthErrorUtils.error(code: AuthErrorCode.operationNotAllowed, message: "OS version is not supported for this action.") + } + let response = try await call(with: request) as FinalizePasskeySignInResponse + return response + } + /// Calls the RPC using HTTP request. /// Possible error responses: diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..5fa3332aee9 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift @@ -0,0 +1,51 @@ +import Foundation +/// Represents the request for the `finalizePasskeyEnrollment` endpoint. +@available(iOS 13, *) +class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeyEnrollmentResponse + var unencodedHTTPRequestBody: [String : AnyHashable]? + + /// GCIP endpoint for finalizePasskeyEnrollment RPC. + let kFinalizePasskeyEnrollmentEndpoint = "accounts/passkeyEnrollment:finalize" + + + /// The raw user access token. + let idToken: String + // name of user or passkey ?.? + let name: String + /// The credential ID. + var credentialID: String = "id" + /// The CollectedClientData object from the authenticator. + var clientDataJson: String = "clientDataJSON" + /// The attestation object from the authenticator. + var attestationObject: String = "response" + + /// The request configuration. + let requestConfiguration: AuthRequestConfiguration? + + + /// Initializes a new `FinalizePasskeyEnrollmentRequest`. + /// + /// - Parameters: + /// - IDToken: The raw user access token. + /// - name: The passkey name. + /// - credentialID: The credential ID. + /// - clientDataJson: The CollectedClientData object from the authenticator. + /// - attestationObject: The attestation object from the authenticator. + /// - requestConfiguration: The request configuration. + init(idToken: String, name: String, credentialID: String, clientDataJson: String, + attestationObject: String, requestConfiguration: AuthRequestConfiguration) { + self.idToken = idToken + self.name = name + self.credentialID = credentialID + self.clientDataJson = clientDataJson + self.attestationObject = attestationObject + self.requestConfiguration = requestConfiguration + super.init( + endpoint: kFinalizePasskeyEnrollmentEndpoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } +} + diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..f3f3a73cc6c --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Represents the response from the `finalizePasskeyEnrollment` endpoint. +@available(iOS 13, *) +struct FinalizePasskeyEnrollmentResponse: AuthRPCResponse { + + /// The ID token for the authenticated user. + public let idToken: String + + /// The refresh token for the authenticated user. + public let refreshToken: String + + private static let kIdTokenKey = "idToken" + private static let kRefreshTokenKey = "refreshToken" + + /// Initializes a new `FinalizePasskeyEnrollmentResponse` from a dictionary. + /// + /// - Parameter dictionary: The dictionary containing the response data. + /// - Throws: An error if parsing fails. + public init(dictionary: [String: AnyHashable]) throws { + guard let idToken = dictionary[Self.kIdTokenKey] as? String, + let refreshToken = dictionary[Self.kRefreshTokenKey] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.idToken = idToken + self.refreshToken = refreshToken + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift new file mode 100644 index 00000000000..7ea4c6bffe8 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift @@ -0,0 +1,43 @@ +import Foundation +import AuthenticationServices + +/// Represents the request for the `finalizePasskeySignIn` endpoint. +@available(iOS 13, *) +class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeySignInResponse + var unencodedHTTPRequestBody: [String : AnyHashable]? + + + /// GCIP endpoint for finalizePasskeySignIn RPC. + private let finalizePasskeySignInEndpoint = "accounts/passkeySignIn:finalize" + + /// The signature from the authenticator. + let signature: String + /// Identifier for the registered credential. + var credentialID: String = "id" + /// The CollectedClientData object from the authenticator. + var clientDataJSON: String = "clientDataJSON" + /// The AuthenticatorData from the authenticator. + var authenticatorData: String = "response" + /// The user ID. + let userID: String + + /// Initializes a new `FinalizePasskeySignInRequest` with platform credential and request configuration. + /// + /// - Parameters: + /// - credentialID: The credential ID. + /// - clientDataJson: The CollectedClientData object from the authenticator. + /// - authenticatorData: The AuthenticatorData from the authenticator. + /// - signature: The signature from the authenticator. + /// - userID: The user ID. + /// - requestConfiguration: An object containing configurations to be added to the request. + init(credentialID: String, clientDataJson: String, authenticatorData: String, signature: String, userID: String, requestConfiguration: AuthRequestConfiguration) { + + self.credentialID = credentialID + self.clientDataJSON = clientDataJson + self.authenticatorData = authenticatorData + self.signature = signature + self.userID = userID + super.init(endpoint: finalizePasskeySignInEndpoint, requestConfiguration: requestConfiguration, useIdentityPlatform: true) + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift new file mode 100644 index 00000000000..f21f60ba537 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Represents the response from the `finalizePasskeySignIn` endpoint. +@available(iOS 13, *) +struct FinalizePasskeySignInResponse: AuthRPCResponse { + + /// The ID token for the authenticated user. + var idToken: String = "idToken" + + /// The refresh token for the authenticated user. + var refreshToken: String = "refreshToken" + + + /// Initializes a new `FinalizePasskeySignInResponse` from a dictionary. + /// + /// - Parameter dictionary: The dictionary containing the response data. + /// - Throws: An error if parsing fails. + init(dictionary: [String: AnyHashable]) throws { + guard let idToken = dictionary[idToken] as? String, + let refreshToken = dictionary[refreshToken] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.idToken = idToken + self.refreshToken = refreshToken + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 4fb5795bcd5..bf605c6fd2c 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -91,6 +91,8 @@ struct GetAccountInfoResponse: AuthRPCResponse { let phoneNumber: String? let mfaEnrollments: [AuthProtoMFAEnrollment]? + + let enrolledPasskeys: [PasskeyInfo]? /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. @@ -133,6 +135,16 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } + if let passkeyEnrollmentData = dictionary["passkeyInfo"] as? [[String: AnyHashable]] { + var enrolledPasskeys = [PasskeyInfo]() + for passkeyInfoDict in passkeyEnrollmentData { + let passkeyInfo = PasskeyInfo(dictionary: passkeyInfoDict) + enrolledPasskeys.append(passkeyInfo) + } + self.enrolledPasskeys = enrolledPasskeys + } else { + self.enrolledPasskeys = nil + } } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProtoPasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProtoPasskeyInfo.swift new file mode 100644 index 00000000000..dc71b6b94a1 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProtoPasskeyInfo.swift @@ -0,0 +1,48 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +#if os(watchOS) +#else +/// Represents information about a Passkey. +public struct PasskeyInfo: Codable, AuthProto { + /// The name of the Passkey. + public let name: String? + + /// The credential ID of the Passkey. + public let credentialID: String? + + /// Creates a `PasskeyInfo` instance from a dictionary. + /// + /// - Parameter dictionary: A dictionary containing the Passkey info. + public init(dictionary: [String: AnyHashable]) { + self.name = dictionary["name"] as? String + self.credentialID = dictionary["credentialId"] as? String + } + + // MARK: - AuthProto conformance + + public func toDictionary() -> [String: AnyHashable] { + var dictionary: [String: AnyHashable] = [:] + if let name = name { + dictionary["name"] = name + } + if let credentialID = credentialID { + dictionary["credentialId"] = credentialID + } + return dictionary + } +} +#endif diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift index 5e310d4a656..2e75d9663f1 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift @@ -70,6 +70,8 @@ private let kDeleteAttributesKey = "deleteAttribute" /// The key for the "deleteProvider" value in the request. private let kDeleteProvidersKey = "deleteProvider" +private let kDeletePasskeysKey = "deletePasskeys" + /// The key for the "returnSecureToken" value in the request. private let kReturnSecureTokenKey = "returnSecureToken" @@ -126,6 +128,8 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { /// The list of identity providers to delete. var deleteProviders: [String]? + + var deletePasskeys: [String]? /// Whether the response should return access token and refresh token directly. /// The default value is `true` . @@ -180,6 +184,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { if let deleteProviders { postBody[kDeleteProvidersKey] = deleteProviders } + if let deletePasskeys { + postBody[kDeletePasskeysKey] = deletePasskeys + } if returnSecureToken { postBody[kReturnSecureTokenKey] = true } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..c2c2f458651 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Represents the parameters for the `startPasskeyEnrollment` endpoint. +@available(iOS 13, *) +class StartPasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeyEnrollmentResponse + var unencodedHTTPRequestBody: [String : AnyHashable]? + + /// The raw user access token. + let idToken: String + + /// The tenant ID for the request. + let tenantId: String? + + /// The endpoint for the request. + private let kStartPasskeyEnrollmentEndpoint = "accounts/passkeyEnrollment:start" + + + /// The request configuration. + let requestConfiguration: AuthRequestConfiguration? + + /// Initializes a new `StartPasskeyEnrollmentRequest`. + /// + /// - Parameters: + /// - idToken: The raw user access token. + /// - requestConfiguration: The request configuration. + /// - tenantId: The tenant ID for the request. + init(idToken: String, requestConfiguration: AuthRequestConfiguration?, tenantId: String? = nil) { + self.idToken = idToken + self.requestConfiguration = requestConfiguration + self.tenantId = tenantId + super.init( + endpoint: kStartPasskeyEnrollmentEndpoint, + requestConfiguration: requestConfiguration!, + useIdentityPlatform: true + ) + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..1ecb18664f8 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Represents the response from the `startPasskeyEnrollment` endpoint. +@available(iOS 13, *) +struct StartPasskeyEnrollmentResponse: AuthRPCResponse { + + /// The RP ID of the FIDO Relying Party. + private(set) var rpID: String = "fir-ios-auth-sample.web.app.com" + + /// The user ID. + private(set) var userID: Data + + /// The FIDO challenge. + private(set) var challenge: Data + + /// The name of the field in the response JSON for CredentialCreationOptions. + private let kOptionsKey = "credentialCreationOptions" + + /// The name of the field in the response JSON for Relying Party. + private let kRpKey = "rp" + + /// The name of the field in the response JSON for User. + private let kUserKey = "user" + + /// The name of the field in the response JSON for ids. + private let kIDKey = "id" + + /// The name of the field in the response JSON for challenge. + private let kChallengeKey = "challenge" + + + /// Initializes a new `StartPasskeyEnrollmentResponse` from a dictionary. + /// + /// - Parameters: + /// - dictionary: The dictionary containing the response data. + /// - Throws: An error if parsing fails. + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary[kOptionsKey] as? [String: AnyHashable] else { + throw NSError(domain: "com.firebase.auth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing credentialCreationOptions"]) + } + + guard let rp = options[kRpKey] as? [String: AnyHashable], + let rpID = rp[kIDKey] as? String, !rpID.isEmpty else { + throw NSError(domain: "com.firebase.auth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing or invalid rpID"]) + } + + guard let user = options[kUserKey] as? [String: AnyHashable], + let userID = user[kIDKey] as? String, !userID.isEmpty, let userIDData = userID.data(using: .utf8) else { + throw NSError(domain: "com.firebase.auth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing or invalid userID"]) + } + + guard let challenge = options[kChallengeKey] as? String, !challenge.isEmpty, let challengeData = challenge.data(using: .utf8) else { + throw NSError(domain: "com.firebase.auth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing or invalid challenge"]) + } + self.rpID = rpID + self.userID = userIDData + self.challenge = challengeData + } + + // MARK: - AuthRPCResponse default implementation + func clientError(shortErrorMessage: String, detailedErrorMessage: String? = nil) -> Error? { + return nil + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift new file mode 100644 index 00000000000..801a80c5e3d --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -0,0 +1,24 @@ +import Foundation +import FirebaseAuthInterop + +/// The request to start passkey sign in. +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeySignInResponse + var unencodedHTTPRequestBody: [String : AnyHashable]? + + private let kStartPasskeySignInEndpoint = "accounts/passkeySignIn:start" + + /// The sessionID + var sessionId: String + /// Designated initializer. + /// - Parameter sessionId: The sessionId for the request. + init(sessionId: String, requestConfiguration: AuthRequestConfiguration) { + self.sessionId = sessionId + super.init( + endpoint: kStartPasskeySignInEndpoint, + requestConfiguration: requestConfiguration + ) + } +} + diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift new file mode 100644 index 00000000000..e46ef76ec81 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -0,0 +1,25 @@ +import Foundation + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +struct StartPasskeySignInResponse: AuthRPCResponse { + /// The relying party ID. + private(set) var rpID: String? = "fir-ios-auth-sample.web.app.com" + /// The FIDO challenge. + private(set) var challenge: String? = "challenge" + + private let options = "options" + + + enum CodingKeys: String, CodingKey { + case credentialRequestOptions = "credentialRequestOptions" + case rpID = "rpId" + case challenge + } + init(dictionary: [String : AnyHashable]) throws { + let options = dictionary["options"] as? [String: AnyHashable] + let rpID = options?["rpId"] as? String + let challenge = options?["challenge"] as? String + self.rpID = rpID + self.challenge = challenge + } +} diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 7817c8cbcda..5e037af7aac 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -13,6 +13,8 @@ // limitations under the License. import Foundation +import AuthenticationServices + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension User: NSSecureCoding {} @@ -57,6 +59,9 @@ extension User: NSSecureCoding {} /// The tenant ID of the current user. `nil` if none is available. @objc public private(set) var tenantID: String? + + /// The list of enrolled passkeys for the user. + public private(set) var enrolledPasskeys: [PasskeyInfo]? #if os(iOS) /// Multi factor object associated with the user. @@ -1046,6 +1051,98 @@ extension User: NSSecureCoding {} } } } + + /// Current user object. + var currentUser: User? + + /// Starts the passkey enrollment flow, creating a platform public key registration request. + /// + /// - Parameter name: The desired name for the passkey. + /// - Returns: The ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest. + @available(iOS 15.0, *) + public func startPasskeyEnrollmentWithName(withName name: String?) async throws -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { + let idToken = rawAccessToken() + let request = StartPasskeyEnrollmentRequest( + idToken: idToken, + requestConfiguration: requestConfiguration, + tenantId: auth?.tenantID + ) + let response = try await backend.startPasskeyEnrollment(request: request) + // Cache the passkey name + passkeyName = name + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: response.rpID) + let registrationRequest = provider.createCredentialRegistrationRequest( + challenge: response.challenge, + name: self.passkeyName ?? "Unnamed account (Apple)", + userID: response.userID + ) + return registrationRequest + } + + @available(iOS 15.0, *) + public func finalizePasskeyEnrollmentWithPlatformCredentials(platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws -> AuthDataResult { + + let credentialID = platformCredential.credentialID.base64EncodedString() ?? "nil" + let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString() ?? "nil" + let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() + + let rawAccessToken = self.rawAccessToken + let request = FinalizePasskeyEnrollmentRequest( + idToken: rawAccessToken(), + name: passkeyName!, + credentialID: credentialID, + clientDataJson: clientDataJson, + attestationObject: attestationObject!, + requestConfiguration: self.auth!.requestConfiguration + ) + + let response = try await backend.finalizePasskeyEnrollment(request: request) + + let user = try await self.auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + + + /// To unenroll a passkey with platform credential. + /// - Parameter credentialID: The passkey credential ID to unenroll. + @objc open func unenrollPasskey(with credentialID: String, completion: ((Error?) -> Void)? = nil) { + kAuthGlobalWorkQueue.async { + self.internalGetToken(backend: self.backend) { accessToken, error in + if let error { + User.callInMainThreadWithError(callback: completion, error: error) + return + } + guard let accessToken = accessToken else { + fatalError("Auth Internal Error: Both error and accessToken are nil") + } + guard let requestConfiguration = self.auth?.requestConfiguration else { + fatalError("Auth Internal Error: Missing request configuration.") + } + + self.executeUserUpdateWithChanges(changeBlock: { user, request in + request.deletePasskeys = [credentialID] + }) { error in + if let error { + User.callInMainThreadWithError(callback: completion, error: error) + return + } + + // Remove passkey from local cache + if let enrolledPasskeys = self.enrolledPasskeys, let index = enrolledPasskeys.firstIndex(where: { $0.credentialID == credentialID }) { + self.enrolledPasskeys?.remove(at: index) + } + + User.callInMainThreadWithError(callback: completion, error: nil) + } + } + } + } + // MARK: Internal implementations below @@ -1111,6 +1208,8 @@ extension User: NSSecureCoding {} /// The name of the user. @objc open var displayName: String? + + open var passkeyName: String? /// The URL of the user's profile photo. @objc open var photoURL: URL? @@ -1302,6 +1401,7 @@ extension User: NSSecureCoding {} } multiFactor.user = self #endif + enrolledPasskeys = user.enrolledPasskeys } #if os(iOS) diff --git a/FirebaseAuth/Sources/Swift/User/UserInfo.swift b/FirebaseAuth/Sources/Swift/User/UserInfo.swift index 93e8066abe4..ea6a21f84c2 100644 --- a/FirebaseAuth/Sources/Swift/User/UserInfo.swift +++ b/FirebaseAuth/Sources/Swift/User/UserInfo.swift @@ -35,4 +35,6 @@ import Foundation /// /// This property is only available for users authenticated via phone number auth. var phoneNumber: String? { get } + + var passkeyName: String? { get } } diff --git a/FirebaseAuth/Sources/Swift/User/UserInfoImpl.swift b/FirebaseAuth/Sources/Swift/User/UserInfoImpl.swift index 8d3b96d0e43..beb834a5ce6 100644 --- a/FirebaseAuth/Sources/Swift/User/UserInfoImpl.swift +++ b/FirebaseAuth/Sources/Swift/User/UserInfoImpl.swift @@ -20,6 +20,8 @@ extension UserInfoImpl: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc(FIRUserInfoImpl) // objc Needed for decoding old versions class UserInfoImpl: NSObject, UserInfo { + var passkeyName: String? + /// A convenience factory method for constructing a `UserInfo` instance from data /// returned by the getAccountInfo endpoint. /// - Parameter providerUserInfo: Data returned by the getAccountInfo endpoint. diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift index 5e9f8af3cf0..06b2ec24fbb 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift @@ -53,6 +53,10 @@ enum AuthMenu: String { case phoneEnroll case totpEnroll case multifactorUnenroll + case passkeySignUp + case passkeySignIn + case passkeyEnroll + case passkeyUnenroll // More intuitively named getter for `rawValue`. var id: String { rawValue } @@ -139,6 +143,15 @@ enum AuthMenu: String { return "TOTP Enroll" case .multifactorUnenroll: return "Multifactor unenroll" + // Passkey + case .passkeySignUp: + return "Sign Up with Passkey" + case .passkeySignIn: + return "Sign In with Passkey" + case .passkeyEnroll: + return "Enroll with Passkey" + case .passkeyUnenroll: + return "Unenroll Passkey" } } @@ -220,6 +233,14 @@ enum AuthMenu: String { self = .totpEnroll case "Multifactor unenroll": self = .multifactorUnenroll + case "Sign Up with Passkey": + self = .passkeySignUp + case "Sign In with Passkey": + self = .passkeySignIn + case "Enroll with Passkey": + self = .passkeyEnroll + case "Unenroll Passkey": + self = .passkeyUnenroll default: return nil } @@ -353,10 +374,21 @@ class AuthMenuData: DataSourceProvidable { ] return Section(headerDescription: header, items: items) } + + static var passkeySection: Section { + let header = "Passkey" + let items: [Item] = [ + Item(title: AuthMenu.passkeySignUp.name), + Item(title: AuthMenu.passkeySignIn.name), + Item(title: AuthMenu.passkeyEnroll.name), + Item(title: AuthMenu.passkeyUnenroll.name), + ] + return Section(headerDescription: header, items: items) + } static let sections: [Section] = [settingsSection, providerSection, emailPasswordSection, otherSection, recaptchaSection, - customAuthDomainSection, appSection, oobSection, multifactorSection] + customAuthDomainSection, appSection, oobSection, multifactorSection, passkeySection] static var authLinkSections: [Section] { let allItems = [providerSection, emailPasswordSection, otherSection].flatMap { $0.items } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift index 6fee389392b..c2207ec31d2 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift @@ -26,4 +26,5 @@ enum UserAction: String { case updatePhoneNumber = "Phone Number" case refreshUserInfo = "Refresh User Info" case updatePassword = "Update Password" + case passkey = "Passkeys" } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift index a21d1e93da1..adb1436f96e 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift @@ -523,6 +523,37 @@ extension AccountLinkingViewController: DataSourceProvidable { } } +// MARK: - Passkey Enrollment +@available(iOS 16.0, *) +private func handlePasskeyEnrollment(platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration){ + let user = Auth.auth().currentUser + Task { + do { + let authResult = try await user?.finalizePasskeyEnrollmentWithPlatformCredentials( + platformCredential: platformCredential + ) + print("Passkey Enrollment succeeded with uid: \(authResult?.user.uid ?? "empty with uid")") + } catch { + print("Passkey Enrollment failed with error: \(error)") + } + } +} + +// MARK: - Passkey Sign-in +@available(iOS 16.0, *) +private func handlePasskeySignIn(platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion){ + Task { + do { + let authResult = try await AppManager.shared.auth().finalizePasskeySignIn(platformCredential: platformCredential) + print("Passkey sign-in succeeded with uid: \(authResult.user.uid)") + + } catch { + print("Passkey sign-in failed with error: \(error)") + } + } +} + + // MARK: - Implementing Sign in with Apple with Firebase extension AccountLinkingViewController: ASAuthorizationControllerDelegate, @@ -541,10 +572,18 @@ extension AccountLinkingViewController: ASAuthorizationControllerDelegate, authorizationController.performRequests() } } + + // MARK: - ASAuthorizationControllerDelegate - func authorizationController(controller: ASAuthorizationController, - didCompleteWithAuthorization authorization: ASAuthorization) { - if case let appleIDCredential as ASAuthorizationAppleIDCredential = authorization.credential { + @available(iOS 13.0, *) + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + if #available(iOS 16.0, *) { + if let platformCredential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + handlePasskeyEnrollment(platformCredential: platformCredential) + } else if let platformAssertionCredential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + handlePasskeySignIn(platformCredential: platformAssertionCredential) + } + } else if case let appleIDCredential as ASAuthorizationAppleIDCredential = authorization.credential { continuation?.resume(returning: appleIDCredential) } else { fatalError("Unexpected authorization credential type.") diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 240346b6975..3547c89c2c9 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -191,6 +191,18 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { case .multifactorUnenroll: mfaUnenroll() + + case .passkeySignUp: + passkeySignUp() + + case .passkeySignIn: + passkeySignIn() + + case .passkeyEnroll: + passkeyEnroll() + + case .passkeyUnenroll: + passkeyUnenroll() } } @@ -921,6 +933,96 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { } } } + + private func passkeySignUp(){ + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + // Sign in anonymously + AppManager.shared.auth().signInAnonymously { [weak self] result, error in + guard let self = self else { return } + if let error = error { + self.showAlert(for:"sign-in anonymously failed") + print("Sign in anonymously first") + } else { + print("sign-in anonymously succeeded.") + if let user = result?.user { + print("User ID : \(user.uid)") + self.passkeyEnroll() + }else{ + self.showAlert(for: "sign-in anonymously failed: User is nil") + print("sign-in anonymously failed: User is nil") + } + } + } + } + + func passkeySignIn() { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + Task { + do { + let request = try await AppManager.shared.auth().startPasskeySignIn() + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests(options: .preferImmediatelyAvailableCredentials) + } catch { + print("Passkey sign-in failed with error: \(error)") + } + } + } + + func passkeyEnroll() { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + guard let user = Auth.auth().currentUser else { + print("Please sign in first.") + return + } + showTextInputPrompt(with: "Passkey name") { [weak self] passkeyName in + let passkeyName: String = passkeyName + print ("Passkey Name is \(passkeyName)") + Task { + do { + let regRequest = try await user.startPasskeyEnrollmentWithName(withName: passkeyName) + print("request done \(regRequest)") + let controller = ASAuthorizationController(authorizationRequests: [regRequest]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } catch { + print("Error during passkey enrollment: \(error)") + } + } + } + } + + func passkeyUnenroll() { + guard let user = Auth.auth().currentUser else { + print("Please sign in first.") + return + } + Task { + guard let credentialID = await showTextInputPrompt(with: "passkey credential ID") else { + print("User cancelled or didn't provide credential ID.") + return + } + user.unenrollPasskey(with: credentialID) { [weak self] error in + guard let self = self else { return } + if let error = error { + print("Withdraw passkey with credential ID: \(credentialID) failed with error: \(error)") + } else { + print("Withdraw passkey with credential ID: \(credentialID) succeeded") + } + } + } + } // MARK: - Private Helpers diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/PasskeyViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/PasskeyViewController.swift new file mode 100644 index 00000000000..94219d08185 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/PasskeyViewController.swift @@ -0,0 +1,76 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseCore +import AuthenticationServices + +class PasskeyViewController: UIViewController{ + +} + +//func passkeySignUp(appManager: AppManager, logFailure: @escaping (String, Error?) -> Void, logSuccess: @escaping (String) -> Void, log: @escaping (String) -> Void, passkeyEnroll: @escaping () -> Void) { +// // Sign in anonymously +// appManager.auth().signInAnonymously { (result: AuthDataResult?, error: Error?) in +// if let error = error { +// logFailure("sign-in anonymously failed", error) +// } else if let user = result?.user { +// logSuccess("sign-in anonymously succeeded.") +// log("User ID : \(user.uid)") +// passkeyEnroll() // Call passkeyEnroll after successful anonymous sign-in +// } else { +// logFailure("sign-in anonymously failed", nil) +// } +// } +//} +// +//private func passkeySignIn(){ +// user?.startPasskeyEnrollmentWithName(withName: <#T##String?#>) +//} +// +//func passkeyEnroll( +// appManager: AppManager, +// logFailure: @escaping (String, Error?) -> Void, +// log: @escaping (String) -> Void, +// showTextInputPrompt: @escaping (String, UIKeyboardType, @escaping (Bool, String?) -> Void) -> Void, +// presentationContextProvider: ASAuthorizationControllerPresentationContextProviding, +// authorizationControllerDelegate: ASAuthorizationControllerDelegate +//) async { +// guard let user = appManager.auth().currentUser else { +// logFailure("Please sign in first.", nil) +// return +// } +// +// guard let passkeyName = await showTextInputPrompt("passkey name", keyboardType: UIKeyboardType = .default) else { +// return +// } +// +// if #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) { +// do { +// let request = try await user.startPasskeyEnrollmentWithName(withName: passkeyName) +// let controller = ASAuthorizationController(authorizationRequests: [request]) +// controller.delegate = authorizationControllerDelegate +// controller.presentationContextProvider = presentationContextProvider +// controller.performRequests() +// } catch { +// logFailure("Passkey enrollment failed", error) +// } +// } else { +// log("OS version is not supported for this action.") +// } +//} +// +//func passkeyUnenroll(){ +// +//} diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift index fc47a2b45a5..8a241eac199 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift @@ -112,6 +112,9 @@ class UserViewController: UIViewController, DataSourceProviderDelegate { case .refreshUserInfo: refreshUserInfo() + + case .passkey: + user?.passkeyName } }