Skip to content

Commit

Permalink
Add user-linking logic for signing up (#31)
Browse files Browse the repository at this point in the history
# Account Linking

## ♻️ Current situation & Problem
In the `FirebaseEmailPasswordAccountService` of our project, the
`signUp` function solely handles the creation of new user accounts or
signing in with OAuth credentials. This process did not account for
users who are already signed in (possibly anonymously) and wish to link
their accounts with email and password credentials. See
[#54](StanfordBDHG/PediatricAppleWatchStudy#54)
for details.


## ⚙️ Release Notes 
- The `signUp` function in `FirebaseEmailPasswordAccountService` now
supports linking new email and password credentials to already signed-in
users (including anonymous accounts).
- If onboarding starts with an anonymous account, users can expect a
seamless transition when upgrading from said anonymous account to a
permanent one.
- Fixes an issue where the reauthentication alert doesn't work.


## 📚 Documentation
The modification ensures that if a user is already signed in
(anonymously or with OAuth credentials), their account can be upgraded
with email and password credentials by linking these new credentials to
their existing account. See [Firebase
documentation](https://firebase.google.com/docs/auth/ios/anonymous-auth)
for details.


## ✅ Testing
TBD.


## 📝 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: Andreas Bauer <[email protected]>
  • Loading branch information
MatthewTurk247 and Supereg authored Apr 4, 2024
1 parent e05e665 commit 16c1c75
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 6 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ let package = Package(
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.0.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.0.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "1.0.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziAccount", from: "1.2.2"),
.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0")
],
targets: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ enum FirebaseAccountError: LocalizedError {
case notSignedIn
case requireRecentLogin
case appleFailed
case linkFailedDuplicate
case linkFailedAlreadyInUse
case unknown(AuthErrorCode.Code)


Expand All @@ -43,6 +45,10 @@ enum FirebaseAccountError: LocalizedError {
return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR"
case .appleFailed:
return "FIREBASE_APPLE_FAILED"
case .linkFailedDuplicate:
return "FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE"
case .linkFailedAlreadyInUse:
return "FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE"
case .unknown:
return "FIREBASE_ACCOUNT_UNKNOWN"
}
Expand Down Expand Up @@ -72,6 +78,10 @@ enum FirebaseAccountError: LocalizedError {
return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION"
case .appleFailed:
return "FIREBASE_APPLE_FAILED_SUGGESTION"
case .linkFailedDuplicate:
return "FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE_SUGGESTION"
case .linkFailedAlreadyInUse:
return "FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE_SUGGESTION"
case .unknown:
return "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION"
}
Expand Down Expand Up @@ -100,6 +110,10 @@ enum FirebaseAccountError: LocalizedError {
self = .setupError
case .requiresRecentLogin:
self = .requireRecentLogin
case .providerAlreadyLinked:
self = .linkFailedDuplicate
case .credentialAlreadyInUse:
self = .linkFailedAlreadyInUse
default:
self = .unknown(authErrorCode.code)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas
}

try await context.dispatchFirebaseAuthAction(on: self) {
if let currentUser = Auth.auth().currentUser,
currentUser.isAnonymous {
let credential = EmailAuthProvider.credential(withEmail: signupDetails.userId, password: password)
Self.logger.debug("Linking email-password credentials with current anonymous user account ...")
let result = try await currentUser.link(with: credential)

try await context.notifyUserSignIn(user: currentUser, for: self, isNewUser: true)

return
}

let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: password)
Self.logger.debug("createUser(withEmail:password:) for user.")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS
}

try await context.dispatchFirebaseAuthAction(on: self) {
if let currentUser = Auth.auth().currentUser,
currentUser.isAnonymous {
Self.logger.debug("Linking oauth credentials with current anonymous user account ...")
let result = try await currentUser.link(with: credential)

try await context.notifyUserSignIn(user: currentUser, for: self, isNewUser: true)

return result
}

let authResult = try await Auth.auth().signIn(with: credential)
Self.logger.debug("signIn(with:) credential for user.")

Expand Down
6 changes: 6 additions & 0 deletions Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ actor FirebaseContext {
switch update.change {
case let .user(user):
let isNewUser = update.authResult?.additionalUserInfo?.isNewUser ?? false
if user.isAnonymous {
// We explicitly handle anonymous users on every signup and call our state change handler ourselves.
// But generally, we don't care about anonymous users.
return
}

guard let service = update.service else {
Self.logger.error("Failed to dispatch user update due to missing account service identifier on disk!")
do {
Expand Down
64 changes: 62 additions & 2 deletions Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@
"sourceLanguage" : "en",
"strings" : {
"Authentication Required" : {

"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authentication Required"
}
}
}
},
"Cancel" : {

"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"E-Mail Verified" : {
"localizations" : {
Expand All @@ -14,6 +28,12 @@
"state" : "translated",
"value" : "E-Mail verifiziert"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "E-Mail Verified"
}
}
}
},
Expand Down Expand Up @@ -161,6 +181,46 @@
}
}
},
"FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Failed to link account"
}
}
}
},
"FIREBASE_ACCOUNT_LINK_FAILED_ALREADY_IN_USE_SUGGESTION" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The credentials are already used with a different account."
}
}
}
},
"FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Failed to link account"
}
}
}
},
"FIREBASE_ACCOUNT_LINK_FAILED_DUPLICATE_SUGGESTION" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This type of account provider is already linked to this account."
}
}
}
},
"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" : {
"localizations" : {
"de" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ struct ReauthenticationAlertModifier: ViewModifier {
@ValidationState private var validation

@State private var password: String = ""
@State private var isActive = false


private var isPresented: Binding<Bool> {
Binding {
firebaseModel.isPresentingReauthentication
firebaseModel.isPresentingReauthentication && isActive
} set: { newValue in
firebaseModel.isPresentingReauthentication = newValue
}
Expand All @@ -37,6 +38,12 @@ struct ReauthenticationAlertModifier: ViewModifier {

func body(content: Content) -> some View {
content
.onAppear {
isActive = true
}
.onDisappear {
isActive = false
}
.alert(Text("Authentication Required", bundle: .module), isPresented: isPresented, presenting: context) { context in
SecureField(text: $password) {
Text(PasswordFieldType.password.localizedStringResource)
Expand All @@ -46,6 +53,9 @@ struct ReauthenticationAlertModifier: ViewModifier {
.textInputAutocapitalization(.never)
.validate(input: password, rules: .nonEmpty)
.receiveValidation(in: $validation)
.onDisappear {
password = "" // make sure we don't hold onto passwords
}

Button(role: .cancel, action: {
context.continuation.resume(returning: .cancelled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/SpeziAccount",
"state" : {
"revision" : "a7d289ef3be54de62b25dc92e8f7ff1a0f093906",
"version" : "1.2.1"
"revision" : "cb9441e5fe9ca31a17be2507d03817a080e63e9d",
"version" : "1.2.2"
}
},
{
Expand Down

0 comments on commit 16c1c75

Please sign in to comment.