diff --git a/Package.swift b/Package.swift index cbdfaa9..e096cc9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.9 // // This source file is part of the Stanford Spezi open-source project @@ -15,7 +15,7 @@ let package = Package( name: "SpeziFirebase", defaultLocalization: "en", platforms: [ - .iOS(.v16) + .iOS(.v17), ], products: [ .library(name: "SpeziFirebaseAccount", targets: ["SpeziFirebaseAccount"]), diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift index b8252fa..e95bd71 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift @@ -6,9 +6,11 @@ // SPDX-License-Identifier: MIT // +import AuthenticationServices import FirebaseAuth import OSLog import SpeziAccount +import SwiftUI protocol FirebaseAccountService: AnyActor, AccountService { @@ -24,6 +26,8 @@ protocol FirebaseAccountService: AnyActor, AccountService { /// - Parameter context: The global firebase context func configure(with context: FirebaseContext) async + func inject(authorizationController: AuthorizationController) async + /// This method is called once the account for the given user was removed. /// /// This allows for additional cleanup tasks to be performed. @@ -38,6 +42,11 @@ protocol FirebaseAccountService: AnyActor, AccountService { } +extension FirebaseAccountService { + func inject(authorizationController: AuthorizationController) async {} +} + + // MARK: - Default Account Service Implementations extension FirebaseAccountService { func logout() async throws { diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift index 6fedc78..3c526d7 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import AuthenticationServices import FirebaseAuth import OSLog import SpeziAccount @@ -17,6 +18,7 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") private static let supportedKeys = AccountKeyCollection { + \.accountId \.userId \.password \.name diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift index c1e5897..7966f03 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift @@ -7,7 +7,6 @@ // import AuthenticationServices -import CryptoKit import FirebaseAuth import OSLog import SpeziAccount @@ -33,6 +32,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "IdentityProvider") private static let supportedKeys = AccountKeyCollection { + \.accountId \.userId \.name } @@ -43,6 +43,8 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS let configuration: AccountServiceConfiguration + private var authorizationController: AuthorizationController? + @MainActor @AccountReference var account: Account // property wrappers cannot be non-isolated, so we isolate it to main actor @MainActor private var lastNonce: String? @@ -60,40 +62,23 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } } - // TODO move both somewhere else! - private static func randomNonceString(length: Int) -> String { - precondition(length > 0, "Nonce length must be non-zero") - let nonceCharacters = (0 ..< length).map { _ in - // ASCII alphabet goes from 32 (space) to 126 (~) - let num = Int.random(in: 32...126) // TODO something better? => crypto graphically! - guard let scalar = UnicodeScalar(num) else { - preconditionFailure("Failed to generate ASCII character for nonce!") - } - return Character(scalar) - } - - return String(nonceCharacters) - } - - private static func sha256(_ input: String) -> String { - SHA256.hash(data: Data(input.utf8)) - .compactMap { byte in - String(format: "%02x", byte) - } - .joined() - } - func configure(with context: FirebaseContext) async { self._context.inject(context) await context.share(account: account) } + func inject(authorizationController: AuthorizationController) { + self.authorizationController = authorizationController + } + func handleAccountRemoval(userId: String?) async { // nothing we are doing here } func reauthenticateUser(userId: String, user: User) async { + // TODO how to check if token still valid? + // TODO reauthenticate token https://firebase.google.com/docs/auth/ios/apple } @@ -103,7 +88,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } try await context.dispatchFirebaseAuthAction(on: self) { - let authResult = try await Auth.auth().signIn(with: credential) + let authResult = try await Auth.auth().signIn(with: credential) // TODO review error! Self.logger.debug("signIn(with:) credential for user.") return authResult @@ -119,15 +104,65 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } try await context.dispatchFirebaseAuthAction(on: self) { + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + await onAppleSignInRequest(request: request) + + // TODO verify that controller is non-nil + + guard let result = try await performRequest(request), + case let .appleID(credential) = result else { + return // TODO handle? + } + + guard let _ = await lastNonce else { + fatalError("Invalid state: A login callback was received, but no login request was sent.") + } + + guard let appleAuthCode = credential.authorizationCode else { + print("Unable to fetch authorization code") + return + } + + guard let authCodeString = String(data: appleAuthCode, encoding: .utf8) else { + print("Unable to serialize auth code string from data: \(appleAuthCode.debugDescription)") + return + } // TODO token revocation!!! https://firebase.google.com/docs/auth/ios/apple + + print("Revoking!") + do { + try await Auth.auth().revokeToken(withAuthorizationCode: authCodeString) // TODO review error! + } catch { + // TODO token revocation fails on simulator https://github.com/firebase/firebase-tools/pull/6050 + print(error) + throw error + } + + print("Deleting") try await currentUser.delete() Self.logger.debug("delete() for user.") } } + private func performRequest(_ request: ASAuthorizationAppleIDRequest) async throws -> ASAuthorizationResult? { + guard let authorizationController else { + // TODO throw some error! + return nil + } + + do { + return try await authorizationController.performRequest(request) + } catch { + try await onAppleSignInCompletion(result: .failure(error)) + } + + return nil + } + @MainActor func onAppleSignInRequest(request: ASAuthorizationAppleIDRequest) { - let nonce = Self.randomNonceString(length: 32) + let nonce = CryptoUtils.randomNonceString(length: 32) // we configured userId as `required` in the account service var requestedScopes: [ASAuthorization.Scope] = [.email] @@ -136,7 +171,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS requestedScopes.append(.fullName) } - request.nonce = Self.sha256(nonce) + request.nonce = CryptoUtils.sha256(nonce) request.requestedScopes = requestedScopes self.lastNonce = nonce // save the nonce for later use to be passed to FirebaseAuth diff --git a/Sources/SpeziFirebaseAccount/Utils/CryptoUtils.swift b/Sources/SpeziFirebaseAccount/Utils/CryptoUtils.swift new file mode 100644 index 0000000..6e68cf2 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Utils/CryptoUtils.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CryptoKit +import Foundation + + +enum CryptoUtils { + static func randomNonceString(length: Int) -> String { + precondition(length > 0, "Nonce length must be non-zero") + let nonceCharacters = (0 ..< length).map { _ in + // ASCII alphabet goes from 32 (space) to 126 (~) + let num = Int.random(in: 32...126) // .random(in:) is secure, see https://stackoverflow.com/a/76722233 + guard let scalar = UnicodeScalar(num) else { + preconditionFailure("Failed to generate ASCII character for nonce!") + } + return Character(scalar) + } + + return String(nonceCharacters) + } + + static func sha256(_ input: String) -> String { + SHA256.hash(data: Data(input.utf8)) + .compactMap { byte in + String(format: "%02x", byte) + } + .joined() + } +} diff --git a/Sources/SpeziFirebaseAccount/Storage/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift similarity index 99% rename from Sources/SpeziFirebaseAccount/Storage/FirebaseContext.swift rename to Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift index 76fee37..23e2aff 100644 --- a/Sources/SpeziFirebaseAccount/Storage/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift @@ -265,6 +265,7 @@ actor FirebaseContext { Self.logger.debug("Notifying SpeziAccount with updated user details.") let builder = AccountDetails.Builder() + .set(\.accountId, value: user.uid) .set(\.userId, value: email) .set(\.isEmailVerified, value: user.isEmailVerified) diff --git a/Sources/SpeziFirebaseAccount/Storage/StorageKeys.swift b/Sources/SpeziFirebaseAccount/Utils/StorageKeys.swift similarity index 100% rename from Sources/SpeziFirebaseAccount/Storage/StorageKeys.swift rename to Sources/SpeziFirebaseAccount/Utils/StorageKeys.swift diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift new file mode 100644 index 0000000..f3c66f6 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import AuthenticationServices +import SpeziAccount +import SwiftUI + + +struct FirebaseAccountModifier: ViewModifier { + @EnvironmentObject private var account: Account + + @Environment(\.authorizationController) + private var authorizationController + + func body(content: Content) -> some View { + content + .task { + for service in account.registeredAccountServices { + guard let firebaseService = service as? any FirebaseAccountService else { + continue + } + + await firebaseService.inject(authorizationController: authorizationController) + } + } + } +} + + +extension View { + public func firebaseAccount() -> some View { + modifier(FirebaseAccountModifier()) + } +} diff --git a/Sources/SpeziFirebaseAccount/IdentityProvider/FirebaseSignInWithAppleButton.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift similarity index 93% rename from Sources/SpeziFirebaseAccount/IdentityProvider/FirebaseSignInWithAppleButton.swift rename to Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift index b9565ab..edd551a 100644 --- a/Sources/SpeziFirebaseAccount/IdentityProvider/FirebaseSignInWithAppleButton.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSignInWithAppleButton.swift @@ -22,7 +22,6 @@ struct FirebaseSignInWithAppleButton: View { @State private var viewState: ViewState = .idle var body: some View { - // TODO do we need to control the label? SignInWithAppleButton(onRequest: { request in accountService.onAppleSignInRequest(request: request) }, onCompletion: { result in @@ -44,7 +43,6 @@ struct FirebaseSignInWithAppleButton: View { .frame(height: 55) .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) .viewStateAlert(state: $viewState) - // TODO should we prompt for existing credentials? } diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index f5f99ea..f667969 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -59,6 +59,7 @@ struct FirebaseAccountTestsView: View { } } } + .firebaseAccount() } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index 154706a..09bac8e 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -19,7 +19,7 @@ final class FirebaseAccountTests: XCTestCase { override func setUp() async throws { try await super.setUp() - try disablePasswordAutofill() + // TODO try disablePasswordAutofill() try await FirebaseClient.deleteAllAccounts() try await Task.sleep(for: .seconds(0.5)) diff --git a/Tests/UITests/TestAppUITests/FirebaseClient.swift b/Tests/UITests/TestAppUITests/FirebaseClient.swift index 019f415..3b38da7 100644 --- a/Tests/UITests/TestAppUITests/FirebaseClient.swift +++ b/Tests/UITests/TestAppUITests/FirebaseClient.swift @@ -51,7 +51,7 @@ struct FirestoreAccount: Decodable, Equatable { enum FirebaseClient { - private static let projectId = "spezifirebaseuitests" + private static let projectId = "nams-e43ed" // curl -H "Authorization: Bearer owner" -X DELETE http://localhost:9099/emulator/v1/projects/spezifirebaseuitests/accounts static func deleteAllAccounts() async throws { diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 487be67..0d0f7d8 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -380,7 +380,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -435,7 +435,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -463,7 +463,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +499,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -527,6 +527,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TestAppUITests/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.firebase.testappuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -548,6 +549,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TestAppUITests/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.firebase.testappuitests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 50a6233..368f811 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,7 +114,7 @@ "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { "branch" : "feature/single-sign-on", - "revision" : "239e4be34909dc718573a1ddaf159c05031c0349" + "revision" : "3448b6ddd8449ad283493f67c2b9e3f043d66262" } }, {