diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift new file mode 100644 index 0000000000..101eee7745 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift @@ -0,0 +1,47 @@ +// +// AccountService+Apple.swift +// FirebaseUI +// +// Created by Russell Wheatley on 21/10/2025. +// + +@preconcurrency import FirebaseAuth +import FirebaseAuthSwiftUI +import Observation + +protocol AppleOperationReauthentication { + var appleProvider: AppleProviderSwift { get } +} + +extension AppleOperationReauthentication { + @MainActor func reauthenticate() async throws -> AuthenticationToken { + guard let user = Auth.auth().currentUser else { + throw AuthServiceError.reauthenticationRequired("No user currently signed-in") + } + + do { + let credential = try await appleProvider.createAuthCredential() + try await user.reauthenticate(with: credential) + + return .firebase("") + } catch { + throw AuthServiceError.signInFailed(underlying: error) + } + } +} + +@MainActor +class AppleDeleteUserOperation: AuthenticatedOperation, + @preconcurrency AppleOperationReauthentication { + let appleProvider: AppleProviderSwift + init(appleProvider: AppleProviderSwift) { + self.appleProvider = appleProvider + } + + func callAsFunction(on user: User) async throws { + try await callAsFunction(on: user) { + try await user.delete() + } + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift new file mode 100644 index 0000000000..6ca6ea04cb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -0,0 +1,163 @@ +// 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 AuthenticationServices +import CryptoKit +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +// MARK: - Data Extensions + +extension Data { + var utf8String: String? { + return String(data: self, encoding: .utf8) + } +} + +extension ASAuthorizationAppleIDCredential { + var authorizationCodeString: String? { + return authorizationCode?.utf8String + } + + var idTokenString: String? { + return identityToken?.utf8String + } +} + +// MARK: - Authenticate With Apple Dialog + +private func authenticateWithApple( + scopes: [ASAuthorization.Scope] +) async throws -> (ASAuthorizationAppleIDCredential, String) { + return try await AuthenticateWithAppleDialog(scopes: scopes).authenticate() +} + +private class AuthenticateWithAppleDialog: NSObject { + private var continuation: CheckedContinuation<(ASAuthorizationAppleIDCredential, String), Error>? + private var currentNonce: String? + private let scopes: [ASAuthorization.Scope] + + init(scopes: [ASAuthorization.Scope]) { + self.scopes = scopes + super.init() + } + + func authenticate() async throws -> (ASAuthorizationAppleIDCredential, String) { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = scopes + + do { + let nonce = try CryptoUtils.randomNonceString() + currentNonce = nonce + request.nonce = CryptoUtils.sha256(nonce) + } catch { + continuation.resume(throwing: AuthServiceError.signInFailed(underlying: error)) + return + } + + let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + authorizationController.delegate = self + authorizationController.performRequests() + } + } +} + +extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential { + if let nonce = currentNonce { + continuation?.resume(returning: (appleIDCredential, nonce)) + } else { + continuation?.resume( + throwing: AuthServiceError.signInFailed( + underlying: NSError( + domain: "AppleSignIn", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing nonce"] + ) + ) + ) + } + } else { + continuation?.resume( + throwing: AuthServiceError.invalidCredentials("Missing Apple ID credential") + ) + } + continuation = nil + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + continuation?.resume(throwing: AuthServiceError.signInFailed(underlying: error)) + continuation = nil + } +} + +// MARK: - Apple Provider Swift + +public class AppleProviderSwift: AuthProviderSwift, DeleteUserSwift { + public let scopes: [ASAuthorization.Scope] + let providerId = "apple.com" + + public init(scopes: [ASAuthorization.Scope] = [.fullName, .email]) { + self.scopes = scopes + } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + let (appleIDCredential, nonce) = try await authenticateWithApple(scopes: scopes) + + guard let idTokenString = appleIDCredential.idTokenString else { + throw AuthServiceError.invalidCredentials("Unable to fetch identity token from Apple") + } + + let credential = OAuthProvider.appleCredential( + withIDToken: idTokenString, + rawNonce: nonce, + fullName: appleIDCredential.fullName + ) + + return credential + } + + public func deleteUser(user: User) async throws { + let operation = AppleDeleteUserOperation(appleProvider: self) + try await operation(on: user) + } +} + +public class AppleProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + public let id: String = "apple.com" + + @MainActor public func authButton() -> AnyView { + AnyView(SignInWithAppleButton(provider: provider)) + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift new file mode 100644 index 0000000000..57e6e11576 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift @@ -0,0 +1,32 @@ +// 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. + +// +// AuthService+Apple.swift +// FirebaseUI +// +// Created by Russell Wheatley on 21/10/2025. +// + +import FirebaseAuthSwiftUI + +public extension AuthService { + @discardableResult + func withAppleSignIn(_ provider: AppleProviderSwift? = nil) -> AuthService { + registerProvider(providerWithButton: AppleProviderAuthUI(provider: provider ?? + AppleProviderSwift())) + return self + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift new file mode 100644 index 0000000000..d09fc9bf65 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift @@ -0,0 +1,53 @@ +// 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 Foundation +import CryptoKit + +/// Set of utility APIs for generating cryptographical artifacts. +enum CryptoUtils { + enum NonceGenerationError: Error { + case generationFailure(status: OSStatus) + } + + static func randomNonceString(length: Int = 32) throws -> String { + precondition(length > 0) + var randomBytes = [UInt8](repeating: 0, count: length) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + if errorCode != errSecSuccess { + throw NonceGenerationError.generationFailure(status: errorCode) + } + + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + + let nonce = randomBytes.map { byte in + // Pick a random character from the set, wrapping around if needed. + charset[Int(byte) % charset.count] + } + + return String(nonce) + } + + static func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift new file mode 100644 index 0000000000..df6f4b40f5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -0,0 +1,54 @@ +// 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 FirebaseAuthSwiftUI +import SwiftUI + +/// A button for signing in with Apple +@MainActor +public struct SignInWithAppleButton { + @Environment(AuthService.self) private var authService + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension SignInWithAppleButton: View { + public var body: some View { + Button(action: { + Task { + try await authService.signIn(provider) + } + }) { + HStack { + Image(systemName: "apple.logo") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(.white) + Text("Sign in with Apple") + .fontWeight(.semibold) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.black) + .cornerRadius(8) + } + .accessibilityIdentifier("sign-in-with-apple-button") + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift new file mode 100644 index 0000000000..03a8b65f07 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift @@ -0,0 +1,21 @@ +// 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. + +@testable import FirebaseAppleSwiftUI +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} + diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2ba0ed5a6e..2f6bfcf472 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -107,6 +107,7 @@ public final class AuthService { public let passwordPrompt: PasswordPromptCoordinator = .init() public var currentMFARequired: MFARequired? private var currentMFAResolver: MultiFactorResolver? + private var pendingMFACredential: AuthCredential? // MARK: - Provider APIs @@ -234,6 +235,8 @@ public final class AuthService { if error.code == AuthErrorCode.secondFactorRequired.rawValue { if let resolver = error .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver { + // Preserve the original credential for use after MFA resolution + pendingMFACredential = credentials return handleMFARequiredError(resolver: resolver) } } @@ -847,12 +850,16 @@ public extension AuthService { do { let result = try await resolver.resolveSignIn(with: assertion) - signedInCredential = result.credential + + // After MFA resolution, result.credential is nil, so restore the original credential + // that was used before MFA was triggered + signedInCredential = result.credential ?? pendingMFACredential updateAuthenticationState() // Clear MFA resolution state currentMFARequired = nil currentMFAResolver = nil + pendingMFACredential = nil } catch { throw AuthServiceError diff --git a/Package.swift b/Package.swift index d51a4f9ce4..c26c766008 100644 --- a/Package.swift +++ b/Package.swift @@ -82,6 +82,10 @@ let package = Package( name: "FirebaseTwitterSwiftUI", targets: ["FirebaseTwitterSwiftUI"] ), + .library( + name: "FirebaseAppleSwiftUI", + targets: ["FirebaseAppleSwiftUI"] + ), ], dependencies: [ .package( @@ -326,5 +330,17 @@ let package = Package( dependencies: ["FirebaseTwitterSwiftUI"], path: "FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/" ), + .target( + name: "FirebaseAppleSwiftUI", + dependencies: [ + "FirebaseAuthSwiftUI", + ], + path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources" + ), + .testTarget( + name: "FirebaseAppleSwiftUITests", + dependencies: ["FirebaseAppleSwiftUI"], + path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/" + ), ] ) diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj index 12a8931628..d593bafac1 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 4600E5542DD777BE00EED5F3 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4600E5532DD777BE00EED5F3 /* FirebaseCore */; }; 4607CC9C2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9B2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI */; }; 4607CC9E2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9D2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI */; }; + 4610DD2A2EA796360084B32B /* FirebaseAppleSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */; }; 4681E0002E97F22B00387C88 /* FirebaseTwitterSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */; }; 46CB7B252D773F2100F1FD0A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 46CB7B242D773F2100F1FD0A /* GoogleService-Info.plist */; }; 46F89C392D64B04E000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 46F89C382D64B04E000F8BC0 /* FirebaseAuthSwiftUI */; }; @@ -81,6 +82,7 @@ files = ( 8D808CB72DB0811900D2293F /* FirebaseFacebookSwiftUI in Frameworks */, 46F89C392D64B04E000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */, + 4610DD2A2EA796360084B32B /* FirebaseAppleSwiftUI in Frameworks */, 46F89C4D2D64BB9B000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */, 4607CC9E2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI in Frameworks */, 8D808CB92DB081F900D2293F /* FirebasePhoneAuthSwiftUI in Frameworks */, @@ -164,6 +166,7 @@ 8D808CB62DB0811900D2293F /* FirebaseFacebookSwiftUI */, 8D808CB82DB081F900D2293F /* FirebasePhoneAuthSwiftUI */, 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */, + 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */, ); productName = FirebaseSwiftUIExample; productReference = 46F89C082D64A86C000F8BC0 /* FirebaseSwiftUIExample.app */; @@ -663,6 +666,11 @@ isa = XCSwiftPackageProductDependency; productName = FirebaseGoogleSwiftUI; }; + 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */; + productName = FirebaseAppleSwiftUI; + }; 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */ = { isa = XCSwiftPackageProductDependency; package = 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */; diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift index 4c29bf0073..1101a55e36 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift @@ -24,7 +24,8 @@ import FirebaseAuthSwiftUI import FirebaseFacebookSwiftUI import FirebaseGoogleSwiftUI import FirebasePhoneAuthSwiftUI -import FirebaseTwitterSwiftUI +import FirebaseTwitterSwiftUI +import FirebaseAppleSwiftUI import SwiftUI struct ContentView: View { @@ -49,6 +50,7 @@ struct ContentView: View { ) .withGoogleSignIn() .withPhoneSignIn() + .withAppleSignIn() .withTwitterSignIn() .withFacebookSignIn() .withEmailSignIn() diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements index ea83d33fa9..f817dae27e 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains applinks:flutterfire-e2e-tests.firebaseapp.com diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift index 5c074a2398..04aef85482 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift @@ -24,6 +24,8 @@ import FirebaseAuthSwiftUI import FirebaseFacebookSwiftUI import FirebaseGoogleSwiftUI import FirebasePhoneAuthSwiftUI +import FirebaseAppleSwiftUI +import FirebaseTwitterSwiftUI import SwiftUI struct TestView: View { @@ -56,6 +58,7 @@ struct TestView: View { ) .withGoogleSignIn() .withPhoneSignIn() + .withAppleSignIn() .withTwitterSignIn() .withFacebookSignIn() .withEmailSignIn() diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 524d3511e4..7dbc0dc2f4 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -54,6 +54,13 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { twitterButton.waitForExistence(timeout: 5), "Twitter/X sign-in button should exist" ) + + // Check for Apple sign-in button + let appleButton = app.buttons["sign-in-with-apple-button"] + XCTAssertTrue( + appleButton.waitForExistence(timeout: 5), + "Apple sign-in button should exist" + ) // Check for Google sign-in button let googleButton = app.buttons["sign-in-with-google-button"]