diff --git a/FirebaseAuth/Sources/Swift/ActionCode/ActionCodeSettings.swift b/FirebaseAuth/Sources/Swift/ActionCode/ActionCodeSettings.swift index f9cb95190d3..427834e9ba5 100644 --- a/FirebaseAuth/Sources/Swift/ActionCode/ActionCodeSettings.swift +++ b/FirebaseAuth/Sources/Swift/ActionCode/ActionCodeSettings.swift @@ -42,6 +42,9 @@ import Foundation /// The Firebase Dynamic Link domain used for out of band code flow. @objc open var dynamicLinkDomain: String? + /// The out of band custom domain for handling code in app. + @objc open var linkDomain: String? + /// Sets the iOS bundle ID. @objc override public init() { iOSBundleID = Bundle.main.bundleIdentifier diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 24726262840..f02a0907614 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -449,6 +449,8 @@ private class AuthBackendRPCImplementation: AuthBackendImplementation { .missingAppCredential(message: serverDetailErrorMessage) case "INVALID_CODE": return AuthErrorUtils .invalidVerificationCodeError(message: serverDetailErrorMessage) + case "INVALID_HOSTING_LINK_DOMAIN": return AuthErrorUtils + .invalidHostingLinkDomainError(message: serverDetailErrorMessage) case "INVALID_SESSION_INFO": return AuthErrorUtils .invalidVerificationIDError(message: serverDetailErrorMessage) case "SESSION_EXPIRED": return AuthErrorUtils diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetOOBConfirmationCodeRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetOOBConfirmationCodeRequest.swift index 7c1fc262c0b..2dfdf433153 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetOOBConfirmationCodeRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetOOBConfirmationCodeRequest.swift @@ -78,6 +78,9 @@ private let kCanHandleCodeInAppKey = "canHandleCodeInApp" /// The key for the "dynamic link domain" value in the request. private let kDynamicLinkDomainKey = "dynamicLinkDomain" +/// The key for the "link domain" value in the request. +private let kLinkDomainKey = "linkDomain" + /// The value for the "PASSWORD_RESET" request type. private let kPasswordResetRequestTypeValue = "PASSWORD_RESET" @@ -140,6 +143,9 @@ class GetOOBConfirmationCodeRequest: IdentityToolkitRequest, AuthRPCRequest { /// The Firebase Dynamic Link domain used for out of band code flow. private(set) var dynamicLinkDomain: String? + /// The Firebase Hosting domain used for out of band code flow. + private(set) var linkDomain: String? + /// Response to the captcha. var captchaResponse: String? @@ -172,6 +178,7 @@ class GetOOBConfirmationCodeRequest: IdentityToolkitRequest, AuthRPCRequest { androidInstallApp = actionCodeSettings?.androidInstallIfNotAvailable ?? false handleCodeInApp = actionCodeSettings?.handleCodeInApp ?? false dynamicLinkDomain = actionCodeSettings?.dynamicLinkDomain + linkDomain = actionCodeSettings?.linkDomain super.init( endpoint: kGetOobConfirmationCodeEndpoint, @@ -274,6 +281,9 @@ class GetOOBConfirmationCodeRequest: IdentityToolkitRequest, AuthRPCRequest { if let dynamicLinkDomain { body[kDynamicLinkDomainKey] = dynamicLinkDomain } + if let linkDomain { + body[kLinkDomainKey] = linkDomain + } if let captchaResponse { body[kCaptchaResponseKey] = captchaResponse } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 6e6a1a74353..efdd9ab9a07 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -366,6 +366,10 @@ class AuthErrorUtils { error(code: .invalidDynamicLinkDomain, message: message) } + static func invalidHostingLinkDomainError(message: String?) -> Error { + error(code: .invalidHostingLinkDomain, message: message) + } + static func missingOrInvalidNonceError(message: String?) -> Error { error(code: .missingOrInvalidNonce, message: message) } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index 7f73876e89c..0f5b4d025f1 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -258,6 +258,9 @@ import Foundation /// unauthorized for the current project. case invalidDynamicLinkDomain = 17074 + /// Indicates that the provided Firebase Hosting Link domain is not owned by the current project. + case invalidHostingLinkDomain = 17214 + /// Indicates that the credential is rejected because it's malformed or mismatching. case rejectedCredential = 17075 @@ -468,6 +471,8 @@ import Foundation return kErrorInvalidProviderID case .invalidDynamicLinkDomain: return kErrorInvalidDynamicLinkDomain + case .invalidHostingLinkDomain: + return kErrorInvalidHostingLinkDomain case .webInternalError: return kErrorWebInternalError case .webSignInUserInteractionFailure: @@ -661,6 +666,8 @@ import Foundation return "ERROR_INVALID_PROVIDER_ID" case .invalidDynamicLinkDomain: return "ERROR_INVALID_DYNAMIC_LINK_DOMAIN" + case .invalidHostingLinkDomain: + return "ERROR_INVALID_HOSTING_LINK_DOMAIN" case .webInternalError: return "ERROR_WEB_INTERNAL_ERROR" case .webSignInUserInteractionFailure: @@ -905,6 +912,9 @@ private let kErrorInvalidProviderID = private let kErrorInvalidDynamicLinkDomain = "The Firebase Dynamic Link domain used is either not configured or is unauthorized for the current project." +private let kErrorInvalidHostingLinkDomain = + "The provided hosting link domain is not configured in Firebase Hosting or is not owned by the current project." + private let kErrorInternalError = "An internal error has occurred, print and inspect the error details for more information." diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift index 923beedcac6..5e9f8af3cf0 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift @@ -43,6 +43,7 @@ enum AuthMenu: String { case deleteApp case actionType case continueURL + case linkDomain case requestVerifyEmail case requestPasswordReset case resetPassword @@ -117,6 +118,8 @@ enum AuthMenu: String { return "Action Type" case .continueURL: return "Continue URL" + case .linkDomain: + return "Link Domain" case .requestVerifyEmail: return "Request Verify Email" case .requestPasswordReset: @@ -197,6 +200,8 @@ enum AuthMenu: String { self = .actionType case "Continue URL": self = .continueURL + case "Link Domain": + self = .linkDomain case "Request Verify Email": self = .requestVerifyEmail case "Request Password Reset": @@ -328,6 +333,7 @@ class AuthMenuData: DataSourceProvidable { let items: [Item] = [ Item(title: AuthMenu.actionType.name, detailTitle: ActionCodeRequestType.inApp.name), Item(title: AuthMenu.continueURL.name, detailTitle: "--", isEditable: true), + Item(title: AuthMenu.linkDomain.name, detailTitle: "--", isEditable: true), Item(title: AuthMenu.requestVerifyEmail.name), Item(title: AuthMenu.requestPasswordReset.name), Item(title: AuthMenu.resetPassword.name), diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift index 1d0e9510d0a..a21d1e93da1 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift @@ -354,6 +354,9 @@ class AccountLinkingViewController: UIViewController, DataSourceProviderDelegate /// Similar to in `PasswordlessViewController`, enter the authorized domain. /// Please refer to this Quickstart's README for more information. private let authorizedDomain: String = "ENTER AUTHORIZED DOMAIN" + + /// This is the replacement for customized dynamic link domain. + private let customDomain: String = "ENTER AUTHORIZED HOSTING DOMAIN" /// Maintain a reference to the email entered for linking user to Passwordless. private var email: String? @@ -380,6 +383,7 @@ class AccountLinkingViewController: UIViewController, DataSourceProviderDelegate // The sign-in operation must be completed in the app. actionCodeSettings.handleCodeInApp = true actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + actionCodeSettings.linkDomain = customDomain AppManager.shared.auth() .sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) { error in diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index fdde2e0757d..71a7b6a8654 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -39,6 +39,7 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { var authStateDidChangeListeners: [AuthStateDidChangeListenerHandle] = [] var IDTokenDidChangeListeners: [IDTokenDidChangeListenerHandle] = [] var actionCodeContinueURL: URL? + var actionCodeLinkDomain: String? var actionCodeRequestType: ActionCodeRequestType = .inApp let spinner = UIActivityIndicatorView(style: .medium) @@ -69,6 +70,7 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { let settings = ActionCodeSettings() settings.url = actionCodeContinueURL settings.handleCodeInApp = (actionCodeRequestType == .inApp) + settings.linkDomain = actionCodeLinkDomain return settings } @@ -156,6 +158,9 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { case .continueURL: changeActionCodeContinueURL(at: indexPath) + case .linkDomain: + changeActionCodeLinkDomain(at: indexPath) + case .requestVerifyEmail: requestVerifyEmail() @@ -552,7 +557,7 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { private func changeActionCodeContinueURL(at indexPath: IndexPath) { showTextInputPrompt(with: "Continue URL:", completion: { newContinueURL in self.actionCodeContinueURL = URL(string: newContinueURL) - print("Successfully set Continue URL to: \(newContinueURL)") + print("Successfully set Continue URL to: \(newContinueURL)") self.dataSourceProvider.updateItem( at: indexPath, item: Item( @@ -565,6 +570,22 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { }) } + private func changeActionCodeLinkDomain(at indexPath: IndexPath) { + showTextInputPrompt(with: "Link Domain:", completion: { newLinkDomain in + self.actionCodeLinkDomain = newLinkDomain + print("Successfully set Link Domain to: \(newLinkDomain)") + self.dataSourceProvider.updateItem( + at: indexPath, + item: Item( + title: AuthMenu.linkDomain.name, + detailTitle: self.actionCodeLinkDomain, + isEditable: true + ) + ) + self.tableView.reloadData() + }) + } + private func requestVerifyEmail() { showSpinner() let completionHandler: ((any Error)?) -> Void = { [weak self] error in diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift index d710f323a6a..da700ab518e 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift @@ -31,7 +31,10 @@ class PasswordlessViewController: OtherAuthViewController { // MARK: - Firebase 🔥 - private let authorizedDomain: String = "ENTER AUTHORIZED DOMAIN" + private let authorizedDomain: String = + "fir-ios-auth-sample.firebaseapp.com" // Enter AUTHORIZED_DOMAIN + private let customDomain: String = + "firebaseiosauthsample.testdomaindonotuse.com" // Enter AUTHORIZED_HOSTING_DOMAIN private func sendSignInLink(to email: String) { let actionCodeSettings = ActionCodeSettings() @@ -42,6 +45,7 @@ class PasswordlessViewController: OtherAuthViewController { // The sign-in operation must be completed in the app. actionCodeSettings.handleCodeInApp = true actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + actionCodeSettings.linkDomain = customDomain AppManager.shared.auth() .sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) { error in diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift index ac8d7953ae3..d7c893d20c8 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift @@ -226,6 +226,89 @@ class AuthenticationExampleUITests: XCTestCase { removeUIInterruptionMonitor(interruptionMonitor) } + func testEmailLinkSentSuccessfully() { + app.staticTexts["Email Link/Passwordless"].tap() + + let testEmail = "test@test.com" + app.textFields["Enter Authentication Email"].tap() + app.textFields["Enter Authentication Email"].typeText(testEmail) + app.buttons["return"].tap() // Dismiss keyboard + app.buttons["Send Sign In Link"].tap() + + // Wait for the error message to appear (if there is an error) + let errorAlert = app.alerts.staticTexts["Error"] + let errorExists = errorAlert.waitForExistence(timeout: 5.0) + + app.swipeDown(velocity: .fast) + + // Assert that there is no error message (success case) + // The email sign in link is sent successfully if no error message appears + XCTAssertFalse(errorExists, "Error") + + // Go back and check that there is no user that is signed in + app.tabBars.firstMatch.buttons.element(boundBy: 1).tap() + wait(forElement: app.navigationBars["User"], timeout: 5.0) + XCTAssertEqual( + app.cells.count, + 0, + "The user shouldn't be signed in and the user view should have no cells." + ) + } + + func testResetPasswordLinkCustomDomain() { + // assuming action type is in-app + continue URL everytime the app launches + + // set Authorized Domain as Continue URL + let testContinueURL = "fir-ios-auth-sample.firebaseapp.com" + app.staticTexts["Continue URL"].tap() + app.alerts.textFields.element.typeText(testContinueURL) + app.buttons["Save"].tap() + + // set Custom Hosting Domain as Link Domain + let testLinkDomain = "http://firebaseiosauthsample.testdomaindonotuse.com" + app.staticTexts["Link Domain"].tap() + app.alerts.textFields.element.typeText(testLinkDomain) + app.buttons["Save"].tap() + + app.staticTexts["Request Password Reset"].tap() + let testEmail = "test@test.com" + app.alerts.textFields.element.typeText(testEmail) + app.buttons["Save"].tap() + + // Go back and check that there is no user that is signed in + app.tabBars.firstMatch.buttons.element(boundBy: 1).tap() + wait(forElement: app.navigationBars["User"], timeout: 5.0) + XCTAssertEqual( + app.cells.count, + 0, + "The user shouldn't be signed in and the user view should have no cells." + ) + } + + func testResetPasswordLinkDefaultDomain() { + // assuming action type is in-app + continue URL everytime the app launches + + // set Authorized Domain as Continue URL + let testContinueURL = "fir-ios-auth-sample.firebaseapp.com" + app.staticTexts["Continue URL"].tap() + app.alerts.textFields.element.typeText(testContinueURL) + app.buttons["Save"].tap() + + app.staticTexts["Request Password Reset"].tap() + let testEmail = "test@test.com" + app.alerts.textFields.element.typeText(testEmail) + app.buttons["Save"].tap() + + // Go back and check that there is no user that is signed in + app.tabBars.firstMatch.buttons.element(boundBy: 1).tap() + wait(forElement: app.navigationBars["User"], timeout: 5.0) + XCTAssertEqual( + app.cells.count, + 0, + "The user shouldn't be signed in and the user view should have no cells." + ) + } + // MARK: - Private Helpers private func signOut() { diff --git a/FirebaseAuth/Tests/Unit/GetOOBConfirmationCodeTests.swift b/FirebaseAuth/Tests/Unit/GetOOBConfirmationCodeTests.swift index b9fe798f57d..5f92932c8bb 100644 --- a/FirebaseAuth/Tests/Unit/GetOOBConfirmationCodeTests.swift +++ b/FirebaseAuth/Tests/Unit/GetOOBConfirmationCodeTests.swift @@ -34,6 +34,7 @@ class GetOOBConfirmationCodeTests: RPCBaseTests { private let kAndroidMinimumVersionKey = "androidMinimumVersion" private let kCanHandleCodeInAppKey = "canHandleCodeInApp" private let kDynamicLinkDomainKey = "dynamicLinkDomain" + private let kLinkDomainKey = "linkDomain" private let kExpectedAPIURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/getOobConfirmationCode?key=APIKey" private let kOOBCodeKey = "oobCode" @@ -66,6 +67,7 @@ class GetOOBConfirmationCodeTests: RPCBaseTests { XCTAssertEqual(decodedRequest[kAndroidInstallAppKey] as? Bool, true) XCTAssertEqual(decodedRequest[kCanHandleCodeInAppKey] as? Bool, true) XCTAssertEqual(decodedRequest[kDynamicLinkDomainKey] as? String, kDynamicLinkDomain) + XCTAssertEqual(decodedRequest[kLinkDomainKey] as? String, kLinkDomain) } } @@ -110,6 +112,7 @@ class GetOOBConfirmationCodeTests: RPCBaseTests { XCTAssertEqual(decodedRequest[kAndroidInstallAppKey] as? Bool, true) XCTAssertEqual(decodedRequest[kCanHandleCodeInAppKey] as? Bool, true) XCTAssertEqual(decodedRequest[kDynamicLinkDomainKey] as? String, kDynamicLinkDomain) + XCTAssertEqual(decodedRequest[kLinkDomainKey] as? String, kLinkDomain) XCTAssertEqual(decodedRequest[kCaptchaResponseKey] as? String, kTestCaptchaResponse) XCTAssertEqual(decodedRequest[kClientTypeKey] as? String, kTestClientType) XCTAssertEqual(decodedRequest[kRecaptchaVersionKey] as? String, kTestRecaptchaVersion) diff --git a/FirebaseAuth/Tests/Unit/ObjCAPITests.m b/FirebaseAuth/Tests/Unit/ObjCAPITests.m index 5af26ba9da9..2cb50ab2766 100644 --- a/FirebaseAuth/Tests/Unit/ObjCAPITests.m +++ b/FirebaseAuth/Tests/Unit/ObjCAPITests.m @@ -65,6 +65,7 @@ - (void)FIRActionCodeSettings_h { s = [codeSettings androidPackageName]; s = [codeSettings androidMinimumVersion]; s = [codeSettings dynamicLinkDomain]; + s = [codeSettings linkDomain]; } - (void)FIRAuthAdditionalUserInfo_h:(FIRAdditionalUserInfo *)additionalUserInfo { @@ -280,6 +281,7 @@ - (void)FIRAuthErrors_h { c = FIRAuthErrorCodeTenantIDMismatch; c = FIRAuthErrorCodeUnsupportedTenantOperation; c = FIRAuthErrorCodeInvalidDynamicLinkDomain; + c = FIRAuthErrorCodeInvalidHostingLinkDomain; c = FIRAuthErrorCodeRejectedCredential; c = FIRAuthErrorCodeGameKitNotLinked; c = FIRAuthErrorCodeSecondFactorRequired; diff --git a/FirebaseAuth/Tests/Unit/RPCBaseTests.swift b/FirebaseAuth/Tests/Unit/RPCBaseTests.swift index 567b3aa4bbd..b27e9a8e80a 100644 --- a/FirebaseAuth/Tests/Unit/RPCBaseTests.swift +++ b/FirebaseAuth/Tests/Unit/RPCBaseTests.swift @@ -38,6 +38,7 @@ class RPCBaseTests: XCTestCase { let kAndroidPackageName = "androidpackagename" let kAndroidMinimumVersion = "3.0" let kDynamicLinkDomain = "test.page.link" + let kLinkDomain = "link.firebaseapp.com" let kTestPhotoURL = "https://host.domain/image" let kCreationDateTimeIntervalInSeconds = 1_505_858_500.0 let kLastSignInDateTimeIntervalInSeconds = 1_505_858_583.0 @@ -304,6 +305,7 @@ class RPCBaseTests: XCTestCase { settings.handleCodeInApp = true settings.url = URL(string: kContinueURL) settings.dynamicLinkDomain = kDynamicLinkDomain + settings.linkDomain = kLinkDomain return settings } diff --git a/FirebaseAuth/Tests/Unit/SwiftAPI.swift b/FirebaseAuth/Tests/Unit/SwiftAPI.swift index 481d5bd92c8..35e6ca04d50 100644 --- a/FirebaseAuth/Tests/Unit/SwiftAPI.swift +++ b/FirebaseAuth/Tests/Unit/SwiftAPI.swift @@ -41,7 +41,8 @@ class AuthAPI_hOnlyTests: XCTestCase { let _: String = codeSettings.iOSBundleID, let _: String = codeSettings.androidPackageName, let _: String = codeSettings.androidMinimumVersion, - let _: String = codeSettings.dynamicLinkDomain {} + let _: String = codeSettings.dynamicLinkDomain, + let _: String = codeSettings.linkDomain {} } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @@ -276,6 +277,7 @@ class AuthAPI_hOnlyTests: XCTestCase { _ = AuthErrorCode.tenantIDMismatch _ = AuthErrorCode.unsupportedTenantOperation _ = AuthErrorCode.invalidDynamicLinkDomain + _ = AuthErrorCode.invalidHostingLinkDomain _ = AuthErrorCode.rejectedCredential _ = AuthErrorCode.gameKitNotLinked _ = AuthErrorCode.secondFactorRequired