Skip to content

Commit

Permalink
Adding support for Sign In with Apple (#18)
Browse files Browse the repository at this point in the history
# 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
Supereg and PSchmiedmayer authored Oct 23, 2023
1 parent 56dd7fb commit 774c5e3
Show file tree
Hide file tree
Showing 23 changed files with 1,206 additions and 453 deletions.
7 changes: 4 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,7 +15,7 @@ let package = Package(
name: "SpeziFirebase",
defaultLocalization: "en",
platforms: [
.iOS(.v16)
.iOS(.v17)
],
products: [
.library(name: "SpeziFirebaseAccount", targets: ["SpeziFirebaseAccount"]),
Expand All @@ -25,7 +25,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")),
.package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.5.1")),
.package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.6.0")),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.4.2")),
.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0")
],
Expand All @@ -36,6 +36,7 @@ let package = Package(
.target(name: "SpeziFirebaseConfiguration"),
.product(name: "Spezi", package: "Spezi"),
.product(name: "SpeziAccount", package: "SpeziAccount"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage"),
.product(name: "SpeziSecureStorage", package: "SpeziStorage"),
.product(name: "FirebaseAuth", package: "firebase-ios-sdk")
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum FirebaseAccountError: LocalizedError {
case setupError
case notSignedIn
case requireRecentLogin
case appleFailed
case unknown(AuthErrorCode.Code)


Expand All @@ -40,6 +41,8 @@ enum FirebaseAccountError: LocalizedError {
return "FIREBASE_ACCOUNT_SIGN_IN_ERROR"
case .requireRecentLogin:
return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR"
case .appleFailed:
return "FIREBASE_APPLE_FAILED"
case .unknown:
return "FIREBASE_ACCOUNT_UNKNOWN"
}
Expand Down Expand Up @@ -67,6 +70,8 @@ enum FirebaseAccountError: LocalizedError {
return "FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION"
case .requireRecentLogin:
return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION"
case .appleFailed:
return "FIREBASE_APPLE_FAILED_SUGGESTION"
case .unknown:
return "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION"
}
Expand Down
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)
}
}
}
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)")
}
}
}
Loading

0 comments on commit 774c5e3

Please sign in to comment.