Skip to content

Commit 7e0faf7

Browse files
authored
implementing start passkey sign-in (#15168)
1 parent 4e62da1 commit 7e0faf7

13 files changed

+751
-0
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import FirebaseCoreExtension
2929
import UIKit
3030
#endif
3131

32+
#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)
33+
import AuthenticationServices
34+
#endif
35+
3236
// Export the deprecated Objective-C defined globals and typedefs.
3337
#if SWIFT_PACKAGE
3438
@_exported import FirebaseAuthInternal
@@ -1641,6 +1645,60 @@ extension Auth: AuthInterop {
16411645
public static let authStateDidChangeNotification =
16421646
NSNotification.Name(rawValue: "FIRAuthStateDidChangeNotification")
16431647

1648+
// MARK: Passkey Implementation
1649+
1650+
#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)
1651+
1652+
/// starts sign in with passkey retrieving challenge from GCIP and create an assertion request.
1653+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1654+
public func startPasskeySignIn() async throws ->
1655+
ASAuthorizationPlatformPublicKeyCredentialAssertionRequest {
1656+
let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration)
1657+
let response = try await backend.call(with: request)
1658+
guard let challengeInData = Data(base64Encoded: response.challenge) else {
1659+
throw NSError(
1660+
domain: AuthErrorDomain,
1661+
code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue,
1662+
userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."]
1663+
)
1664+
}
1665+
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
1666+
relyingPartyIdentifier: response.rpID
1667+
)
1668+
return provider.createCredentialAssertionRequest(
1669+
challenge: challengeInData
1670+
)
1671+
}
1672+
1673+
/// finalize sign in with passkey with existing credential assertion.
1674+
/// - Parameter platformCredential The existing credential assertion created by device.
1675+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1676+
public func finalizePasskeySignIn(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws
1677+
-> AuthDataResult {
1678+
let credentialID = platformCredential.credentialID.base64EncodedString()
1679+
let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString()
1680+
let authenticatorData = platformCredential.rawAuthenticatorData.base64EncodedString()
1681+
let signature = platformCredential.signature.base64EncodedString()
1682+
let userID = platformCredential.userID.base64EncodedString()
1683+
let request = FinalizePasskeySignInRequest(
1684+
credentialID: credentialID,
1685+
clientDataJSON: clientDataJSON,
1686+
authenticatorData: authenticatorData,
1687+
signature: signature,
1688+
userId: userID,
1689+
requestConfiguration: requestConfiguration
1690+
)
1691+
let response = try await backend.call(with: request)
1692+
let user = try await Auth.auth().completeSignIn(
1693+
withAccessToken: response.idToken,
1694+
accessTokenExpirationDate: nil,
1695+
refreshToken: response.refreshToken,
1696+
anonymous: false
1697+
)
1698+
return AuthDataResult(withUser: user, additionalUserInfo: nil)
1699+
}
1700+
#endif
1701+
16441702
// MARK: Internal methods
16451703

16461704
init(app: FirebaseApp,

FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ final class AuthBackend: AuthBackendProtocol {
440440
return AuthErrorUtils.credentialAlreadyInUseError(
441441
message: serverDetailErrorMessage, credential: credential, email: email
442442
)
443+
case "INVALID_AUTHENTICATOR_RESPONSE": return AuthErrorUtils.invalidAuthenticatorResponse()
443444
default:
444445
if let underlyingErrors = errorDictionary["errors"] as? [[String: String]] {
445446
for underlyingError in underlyingErrors {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/// The GCIP endpoint for finalizePasskeySignIn rpc
18+
private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize"
19+
20+
class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest {
21+
typealias Response = FinalizePasskeySignInResponse
22+
/// The credential ID
23+
let credentialID: String
24+
/// The CollectedClientData object from the authenticator.
25+
let clientDataJSON: String
26+
/// The AuthenticatorData from the authenticator.
27+
let authenticatorData: String
28+
/// The signature from the authenticator.
29+
let signature: String
30+
/// The user handle
31+
let userId: String
32+
33+
init(credentialID: String,
34+
clientDataJSON: String,
35+
authenticatorData: String,
36+
signature: String,
37+
userId: String,
38+
requestConfiguration: AuthRequestConfiguration) {
39+
self.credentialID = credentialID
40+
self.clientDataJSON = clientDataJSON
41+
self.authenticatorData = authenticatorData
42+
self.signature = signature
43+
self.userId = userId
44+
super.init(
45+
endpoint: finalizePasskeySignInEndPoint,
46+
requestConfiguration: requestConfiguration,
47+
useIdentityPlatform: true
48+
)
49+
}
50+
51+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
52+
var postBody: [String: AnyHashable] = [
53+
"authenticatorAssertionResponse": [
54+
"credentialId": credentialID,
55+
"authenticatorAssertionResponse": [
56+
"clientDataJSON": clientDataJSON,
57+
"authenticatorData": authenticatorData,
58+
"signature": signature,
59+
"userHandle": userId,
60+
],
61+
] as [String: AnyHashable],
62+
]
63+
if let tenantID = tenantID {
64+
postBody["tenantId"] = tenantID
65+
}
66+
return postBody
67+
}
68+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
struct FinalizePasskeySignInResponse: AuthRPCResponse {
18+
/// The user raw access token.
19+
let idToken: String
20+
/// Refresh token for the authenticated user.
21+
let refreshToken: String
22+
23+
init(dictionary: [String: AnyHashable]) throws {
24+
guard
25+
let idToken = dictionary["idToken"] as? String,
26+
let refreshToken = dictionary["refreshToken"] as? String
27+
else {
28+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
29+
}
30+
self.idToken = idToken
31+
self.refreshToken = refreshToken
32+
}
33+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/// The GCIP endpoint for startPasskeySignIn rpc
16+
private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start"
17+
18+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
19+
class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest {
20+
typealias Response = StartPasskeySignInResponse
21+
22+
init(requestConfiguration: AuthRequestConfiguration) {
23+
super.init(
24+
endpoint: startPasskeySignInEndpoint,
25+
requestConfiguration: requestConfiguration,
26+
useIdentityPlatform: true
27+
)
28+
}
29+
30+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
31+
guard let tenantID = tenantID else {
32+
return nil
33+
}
34+
return ["tenantId": tenantID]
35+
}
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
16+
struct StartPasskeySignInResponse: AuthRPCResponse {
17+
/// The RP ID of the FIDO Relying Party
18+
let rpID: String
19+
/// The FIDO challenge
20+
let challenge: String
21+
22+
init(dictionary: [String: AnyHashable]) throws {
23+
guard let options = dictionary["credentialRequestOptions"] as? [String: Any] else {
24+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
25+
}
26+
guard let rpID = options["rpId"] as? String,
27+
let challenge = options["challenge"] as? String else {
28+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
29+
}
30+
self.rpID = rpID
31+
self.challenge = challenge
32+
}
33+
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ class AuthErrorUtils {
207207
error(code: .invalidRecaptchaToken)
208208
}
209209

210+
static func invalidAuthenticatorResponse() -> Error {
211+
error(code: .invalidAuthenticatorResponse)
212+
}
213+
210214
static func unauthorizedDomainError(message: String?) -> Error {
211215
error(code: .unauthorizedDomain, message: message)
212216
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ import Foundation
336336
/// Indicates that the reCAPTCHA SDK actions class failed to create.
337337
case recaptchaActionCreationFailed = 17210
338338

339+
/// the authenticator response for passkey signin or enrollment is not parseable, missing required
340+
/// fields, or certain fields are invalid values
341+
case invalidAuthenticatorResponse = 17211
342+
339343
/// Indicates an error occurred while attempting to access the keychain.
340344
case keychainError = 17995
341345

@@ -528,6 +532,8 @@ import Foundation
528532
return kErrorSiteKeyMissing
529533
case .recaptchaActionCreationFailed:
530534
return kErrorRecaptchaActionCreationFailed
535+
case .invalidAuthenticatorResponse:
536+
return kErrorInvalidAuthenticatorResponse
531537
}
532538
}
533539

@@ -719,6 +725,8 @@ import Foundation
719725
return "ERROR_RECAPTCHA_SITE_KEY_MISSING"
720726
case .recaptchaActionCreationFailed:
721727
return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED"
728+
case .invalidAuthenticatorResponse:
729+
return "ERROR_INVALID_AUTHENTICATOR_RESPONSE"
722730
}
723731
}
724732
}
@@ -996,3 +1004,6 @@ private let kErrorSiteKeyMissing =
9961004
private let kErrorRecaptchaActionCreationFailed =
9971005
"The reCAPTCHA SDK action class failed to initialize. See " +
9981006
"https://cloud.google.com/recaptcha-enterprise/docs/instrument-ios-apps"
1007+
1008+
private let kErrorInvalidAuthenticatorResponse =
1009+
"During passkey enrollment and sign in, the authenticator response is not parseable, missing required fields, or certain fields are invalid values that compromise the security of the sign-in or enrollment."

0 commit comments

Comments
 (0)