generated from StanfordBDHG/SwiftPackageTemplate
-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adding support for Sign In with Apple (#18)
# Adding support for Sign In with Apple ## ♻️ Current situation & Problem Currently, we only support email- and password-based authentication using Firebase. This PR makes the first step to add single sign on providers by adding support for Sign in with Apple. ## ⚙️ Release Notes * Add support for Sign In with Apple ## 📚 Documentation Documentation was adjusted were required. ## ✅ Testing Testing was added where possible. As we cannot rely reliably test Sign in with Apple, the Firebase Identity Provider Account Service is not fully tested autonomously. ## 📝 Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Paul Schmiedmayer <[email protected]>
- Loading branch information
1 parent
56dd7fb
commit 774c5e3
Showing
23 changed files
with
1,206 additions
and
453 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
// | ||
// 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 FirebaseAuth | ||
import OSLog | ||
import SpeziAccount | ||
import SwiftUI | ||
|
||
|
||
protocol FirebaseAccountService: AnyActor, AccountService { | ||
static var logger: Logger { get } | ||
|
||
var account: Account { get async } | ||
var context: FirebaseContext { get async } | ||
|
||
/// This method is called upon startup to configure the Firebase-based AccountService. | ||
/// | ||
/// - Important: You must call `FirebaseContext/share(account:)` with your `@AccountReference`-acquired | ||
/// `Account` object within this method call. | ||
/// - 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. | ||
/// - Parameter userId: The userId which was removed, or nil if we couldn't retrieve the last user. | ||
func handleAccountRemoval(userId: String?) async | ||
|
||
/// This method is called to re-authenticate the current user credentials. | ||
/// - Parameters: | ||
/// - userId: The current userId (email address of the User). | ||
/// - user: The User instance. | ||
func reauthenticateUser(userId: String, user: User) async throws | ||
} | ||
|
||
|
||
extension FirebaseAccountService { | ||
func inject(authorizationController: AuthorizationController) async {} | ||
} | ||
|
||
|
||
// MARK: - Default Account Service Implementations | ||
extension FirebaseAccountService { | ||
func logout() async throws { | ||
guard Auth.auth().currentUser != nil else { | ||
if await account.signedIn { | ||
try await context.notifyUserRemoval(for: self) | ||
return | ||
} else { | ||
throw FirebaseAccountError.notSignedIn | ||
} | ||
} | ||
|
||
try await context.dispatchFirebaseAuthAction(on: self) { | ||
try Auth.auth().signOut() | ||
try await Task.sleep(for: .milliseconds(10)) | ||
Self.logger.debug("signOut() for user.") | ||
} | ||
} | ||
|
||
func delete() async throws { | ||
guard let currentUser = Auth.auth().currentUser else { | ||
if await account.signedIn { | ||
try await context.notifyUserRemoval(for: self) | ||
} | ||
throw FirebaseAccountError.notSignedIn | ||
} | ||
|
||
try await context.dispatchFirebaseAuthAction(on: self) { | ||
try await currentUser.delete() | ||
Self.logger.debug("delete() for user.") | ||
} | ||
} | ||
|
||
func updateAccountDetails(_ modifications: AccountModifications) async throws { | ||
guard let currentUser = Auth.auth().currentUser else { | ||
if await account.signedIn { | ||
try await context.notifyUserRemoval(for: self) | ||
} | ||
throw FirebaseAccountError.notSignedIn | ||
} | ||
|
||
var changes = false | ||
|
||
// if we modify sensitive credentials and require a recent login | ||
if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil, | ||
let userId = currentUser.email { | ||
// with a future version of SpeziAccount we want to get rid of this workaround and request the password from the user on the fly. | ||
try await reauthenticateUser(userId: userId, user: currentUser) | ||
} | ||
|
||
do { | ||
if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { | ||
Self.logger.debug("updateEmail(to:) for user.") | ||
try await currentUser.updateEmail(to: userId) | ||
changes = true | ||
} | ||
|
||
if let password = modifications.modifiedDetails.password { | ||
Self.logger.debug("updatePassword(to:) for user.") | ||
try await currentUser.updatePassword(to: password) | ||
|
||
if let userId = currentUser.email { // make sure we save the new password | ||
await context.persistCurrentCredentials(userId: userId, password: password, server: StorageKeys.emailPasswordCredentials) | ||
} | ||
} | ||
|
||
if let name = modifications.modifiedDetails.name { | ||
Self.logger.debug("Creating change request for updated display name.") | ||
let changeRequest = currentUser.createProfileChangeRequest() | ||
changeRequest.displayName = name.formatted(.name(style: .long)) | ||
try await changeRequest.commitChanges() | ||
|
||
changes = true | ||
} | ||
|
||
if changes { | ||
// non of the above request will trigger our state change listener, therefore just call it manually. | ||
try await context.notifyUserSignIn(user: currentUser, for: self) | ||
} | ||
} catch let error as NSError { | ||
throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) | ||
} catch { | ||
throw FirebaseAccountError.unknown(.internalError) | ||
} | ||
} | ||
} |
137 changes: 137 additions & 0 deletions
137
Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
// | ||
// 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 FirebaseAuth | ||
import OSLog | ||
import SpeziAccount | ||
import SpeziSecureStorage | ||
import SwiftUI | ||
|
||
|
||
actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, FirebaseAccountService { | ||
static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") | ||
|
||
private static let supportedKeys = AccountKeyCollection { | ||
\.accountId | ||
\.userId | ||
\.password | ||
\.name | ||
} | ||
|
||
static var minimumFirebasePassword: ValidationRule { | ||
// Firebase as a non-configurable limit of 6 characters for an account password. | ||
// Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex | ||
guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { | ||
fatalError("Invalid minimumFirebasePassword regex at construction.") | ||
} | ||
|
||
return ValidationRule( | ||
regex: regex, | ||
message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", | ||
bundle: .module | ||
) | ||
} | ||
|
||
@AccountReference var account: Account | ||
@_WeakInjectable var context: FirebaseContext | ||
|
||
let configuration: AccountServiceConfiguration | ||
|
||
init(passwordValidationRules: [ValidationRule] = [minimumFirebasePassword]) { | ||
self.configuration = AccountServiceConfiguration( | ||
name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), | ||
supportedKeys: .exactly(Self.supportedKeys) | ||
) { | ||
AccountServiceImage(Image(systemName: "envelope.fill")) | ||
RequiredAccountKeys { | ||
\.userId | ||
\.password | ||
} | ||
UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) | ||
|
||
FieldValidationRules(for: \.userId, rules: .minimalEmail) | ||
FieldValidationRules(for: \.password, rules: passwordValidationRules) | ||
} | ||
} | ||
|
||
func configure(with context: FirebaseContext) async { | ||
self._context.inject(context) | ||
await context.share(account: account) | ||
} | ||
|
||
func handleAccountRemoval(userId: String?) { | ||
if let userId { | ||
context.removeCredentials(userId: userId, server: StorageKeys.emailPasswordCredentials) | ||
} | ||
} | ||
|
||
func login(userId: String, password: String) async throws { | ||
Self.logger.debug("Received new login request...") | ||
|
||
try await context.dispatchFirebaseAuthAction(on: self) { | ||
try await Auth.auth().signIn(withEmail: userId, password: password) | ||
Self.logger.debug("signIn(withEmail:password:)") | ||
} | ||
|
||
context.persistCurrentCredentials(userId: userId, password: password, server: StorageKeys.emailPasswordCredentials) | ||
} | ||
|
||
func signUp(signupDetails: SignupDetails) async throws { | ||
Self.logger.debug("Received new signup request...") | ||
|
||
guard let password = signupDetails.password else { | ||
throw FirebaseAccountError.invalidCredentials | ||
} | ||
|
||
try await context.dispatchFirebaseAuthAction(on: self) { | ||
let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password) | ||
Self.logger.debug("createUser(withEmail:password:) for user.") | ||
|
||
Self.logger.debug("Sending email verification link now...") | ||
try await authResult.user.sendEmailVerification() | ||
|
||
if let displayName = signupDetails.name { | ||
Self.logger.debug("Creating change request for display name.") | ||
let changeRequest = authResult.user.createProfileChangeRequest() | ||
changeRequest.displayName = displayName.formatted(.name(style: .medium)) | ||
try await changeRequest.commitChanges() | ||
} | ||
} | ||
|
||
context.persistCurrentCredentials(userId: signupDetails.userId, password: password, server: StorageKeys.emailPasswordCredentials) | ||
} | ||
|
||
func resetPassword(userId: String) async throws { | ||
do { | ||
try await Auth.auth().sendPasswordReset(withEmail: userId) | ||
Self.logger.debug("sendPasswordReset(withEmail:) for user.") | ||
} catch let error as NSError { | ||
let firebaseError = FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) | ||
if case .invalidCredentials = firebaseError { | ||
return // make sure we don't leak any information | ||
} else { | ||
throw firebaseError | ||
} | ||
} catch { | ||
throw FirebaseAccountError.unknown(.internalError) | ||
} | ||
} | ||
|
||
func reauthenticateUser(userId: String, user: User) async { | ||
guard let password = context.retrieveCredential(userId: userId, server: StorageKeys.emailPasswordCredentials) else { | ||
return // nothing we can do | ||
} | ||
|
||
do { | ||
try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) | ||
} catch { | ||
Self.logger.debug("Credential change might fail. Failed to reauthenticate with firebase: \(error)") | ||
} | ||
} | ||
} |
Oops, something went wrong.