From d1f561c9921b430f97872a86995452ba27e8d754 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 11:09:27 +0100 Subject: [PATCH 1/8] feat: initial implementation of apple architecture --- .../Services/AccountService+Apple.swift | 49 +++++++++++++ .../Services/AppleProviderAuthUI.swift | 68 +++++++++++++++++++ .../Sources/Services/AuthService+Apple.swift | 33 +++++++++ .../Sources/Views/SignInWithAppleButton.swift | 56 +++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift new file mode 100644 index 0000000000..7005395a1f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift @@ -0,0 +1,49 @@ +// +// 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 { + // TODO: Implement Apple reauthentication + 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 { + // TODO: Implement delete user operation + 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..b3ee81c7dc --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -0,0 +1,68 @@ +// 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 FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +public class AppleProviderSwift: AuthProviderSwift, DeleteUserSwift { + public let scopes: [String] + let providerId = "apple.com" + + public init(scopes: [String] = []) { + self.scopes = scopes + } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + // TODO: Implement Apple Sign In credential creation + // This will need to use ASAuthorizationAppleIDProvider + let provider = OAuthProvider(providerID: providerId) + return try await withCheckedThrowingContinuation { continuation in + provider.getCredentialWith(nil) { credential, error in + if let error { + continuation + .resume(throwing: AuthServiceError.signInFailed(underlying: error)) + } else if let credential { + continuation.resume(returning: credential) + } else { + continuation + .resume(throwing: AuthServiceError + .invalidCredentials("Apple did not provide a valid AuthCredential")) + } + } + } + } + + public func deleteUser(user: User) async throws { + // TODO: Implement delete user functionality + 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..ae63f99046 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift @@ -0,0 +1,33 @@ +// 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 { + // TODO: Register Apple provider with authentication service + registerProvider(providerWithButton: AppleProviderAuthUI(provider: provider ?? + AppleProviderSwift())) + return self + } +} + diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift new file mode 100644 index 0000000000..47691bf016 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -0,0 +1,56 @@ +// 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: { + // TODO: Implement sign in with Apple action + Task { + try await authService.signIn(provider) + } + }) { + HStack { + // TODO: Add Apple logo image + 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") + } +} + From 35d55ba324184e446c92ee5dd49c8454f9919c39 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 11:21:11 +0100 Subject: [PATCH 2/8] chore: setup package.swift with apple package --- .../FirebaseAppleSwiftUITests.swift | 21 +++++++++++++++++++ Package.swift | 16 ++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift 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/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/" + ), ] ) From 5ccebec51a43f8f3f3303e36484dfe63ebcd4d41 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 11:37:43 +0100 Subject: [PATCH 3/8] chore: import apple to test app --- .../FirebaseSwiftUIExample.xcodeproj/project.pbxproj | 8 ++++++++ .../FirebaseSwiftUIExample/ContentView.swift | 4 +++- .../FirebaseSwiftUIExample/TestView.swift | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) 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/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() From 7f6be5506b8b4fb35a5a223b17ca1cf6efb81518 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 12:07:40 +0100 Subject: [PATCH 4/8] feat: support apple sign in --- .../Services/AccountService+Apple.swift | 2 - .../Services/AppleProviderAuthUI.swift | 121 +++++++++++++++--- .../Sources/Services/AuthService+Apple.swift | 1 - .../Sources/Services/CryptoUtils.swift | 53 ++++++++ .../Sources/Views/SignInWithAppleButton.swift | 2 - 5 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift index 7005395a1f..101eee7745 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift @@ -20,7 +20,6 @@ extension AppleOperationReauthentication { } do { - // TODO: Implement Apple reauthentication let credential = try await appleProvider.createAuthCredential() try await user.reauthenticate(with: credential) @@ -40,7 +39,6 @@ class AppleDeleteUserOperation: AuthenticatedOperation, } func callAsFunction(on user: User) async throws { - // TODO: Implement delete user operation 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 index b3ee81c7dc..1524526a16 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -12,11 +12,103 @@ // 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() async throws -> (ASAuthorizationAppleIDCredential, String) { + return try await AuthenticateWithAppleDialog().authenticate() +} + +private class AuthenticateWithAppleDialog: NSObject { + private var continuation: CheckedContinuation<(ASAuthorizationAppleIDCredential, String), Error>? + private var currentNonce: String? + + func authenticate() async throws -> (ASAuthorizationAppleIDCredential, String) { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + + 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: [String] let providerId = "apple.com" @@ -26,27 +118,22 @@ public class AppleProviderSwift: AuthProviderSwift, DeleteUserSwift { } @MainActor public func createAuthCredential() async throws -> AuthCredential { - // TODO: Implement Apple Sign In credential creation - // This will need to use ASAuthorizationAppleIDProvider - let provider = OAuthProvider(providerID: providerId) - return try await withCheckedThrowingContinuation { continuation in - provider.getCredentialWith(nil) { credential, error in - if let error { - continuation - .resume(throwing: AuthServiceError.signInFailed(underlying: error)) - } else if let credential { - continuation.resume(returning: credential) - } else { - continuation - .resume(throwing: AuthServiceError - .invalidCredentials("Apple did not provide a valid AuthCredential")) - } - } + let (appleIDCredential, nonce) = try await authenticateWithApple() + + 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 { - // TODO: Implement delete user functionality let operation = AppleDeleteUserOperation(appleProvider: self) try await operation(on: user) } diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift index ae63f99046..57e6e11576 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift @@ -24,7 +24,6 @@ import FirebaseAuthSwiftUI public extension AuthService { @discardableResult func withAppleSignIn(_ provider: AppleProviderSwift? = nil) -> AuthService { - // TODO: Register Apple provider with authentication service 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 index 47691bf016..df6f4b40f5 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -28,13 +28,11 @@ public struct SignInWithAppleButton { extension SignInWithAppleButton: View { public var body: some View { Button(action: { - // TODO: Implement sign in with Apple action Task { try await authService.signIn(provider) } }) { HStack { - // TODO: Add Apple logo image Image(systemName: "apple.logo") .resizable() .renderingMode(.template) From 0cbc68e1e95f87be3ffa93bbfd47898438d9332b Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 12:37:19 +0100 Subject: [PATCH 5/8] chore: example app has apple sign in capability --- .../FirebaseSwiftUIExample.entitlements | 4 ++++ 1 file changed, 4 insertions(+) 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 From d595a8b3bacb67113d96f6963d0506fc5e597d29 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 13:08:39 +0100 Subject: [PATCH 6/8] fix: store pending credential to allow deletion of user --- .../Sources/Services/AuthService.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From e92dc97ee07abb97310ed1129ffe508fef696779 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 13:35:36 +0100 Subject: [PATCH 7/8] test: test the existence of apple button --- .../FirebaseSwiftUIExampleUITests.swift | 7 +++++++ 1 file changed, 7 insertions(+) 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"] From ff554e4c506870a833550ccd4aa5c469920e9b6d Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 21 Oct 2025 13:45:01 +0100 Subject: [PATCH 8/8] fix: pass in apple scopes --- .../Services/AppleProviderAuthUI.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift index 1524526a16..6ca6ea04cb 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -39,13 +39,21 @@ extension ASAuthorizationAppleIDCredential { // MARK: - Authenticate With Apple Dialog -private func authenticateWithApple() async throws -> (ASAuthorizationAppleIDCredential, String) { - return try await AuthenticateWithAppleDialog().authenticate() +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 @@ -53,7 +61,7 @@ private class AuthenticateWithAppleDialog: NSObject { let appleIDProvider = ASAuthorizationAppleIDProvider() let request = appleIDProvider.createRequest() - request.requestedScopes = [.fullName, .email] + request.requestedScopes = scopes do { let nonce = try CryptoUtils.randomNonceString() @@ -110,15 +118,15 @@ extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate { // MARK: - Apple Provider Swift public class AppleProviderSwift: AuthProviderSwift, DeleteUserSwift { - public let scopes: [String] + public let scopes: [ASAuthorization.Scope] let providerId = "apple.com" - public init(scopes: [String] = []) { + public init(scopes: [ASAuthorization.Scope] = [.fullName, .email]) { self.scopes = scopes } @MainActor public func createAuthCredential() async throws -> AuthCredential { - let (appleIDCredential, nonce) = try await authenticateWithApple() + let (appleIDCredential, nonce) = try await authenticateWithApple(scopes: scopes) guard let idTokenString = appleIDCredential.idTokenString else { throw AuthServiceError.invalidCredentials("Unable to fetch identity token from Apple")