Browse Source

IdP-Initiated Saml Sign In Implementation (#15291)

Srushti Vaidya 6 months ago
parent
commit
35bb1b8b14

+ 23 - 0
FirebaseAuth/Sources/Swift/Auth/Auth.swift

@@ -2341,6 +2341,29 @@ extension Auth: AuthInterop {
     }
   #endif
 
+  // MARK: IDP Initiated SAML Sign In
+
+  public func signInWithSamlIdp(ProviderId providerId: String,
+                                SpAcsUrl spAcsUrl: String,
+                                SamlResp samlResp: String) async throws -> AuthDataResult {
+    let samlRespBody = "SAMLResponse=\(samlResp)&providerId=\(providerId)"
+    let request = SignInWithSamlIdpRequest(
+      requestUri: spAcsUrl,
+      postBody: samlRespBody,
+      returnSecureToken: true,
+      requestConfiguration: requestConfiguration
+    )
+    let response = try await backend.call(with: request)
+    let user = try await completeSignIn(
+      withAccessToken: response.idToken,
+      accessTokenExpirationDate: response.expirationDate,
+      refreshToken: response.refreshToken,
+      anonymous: false
+    )
+    try await updateCurrentUser(user)
+    return AuthDataResult(withUser: user, additionalUserInfo: nil)
+  }
+
   // MARK: Internal properties
 
   /// Allow tests to swap in an alternate mainBundle, including ObjC unit tests via CocoaPods.

+ 55 - 0
FirebaseAuth/Sources/Swift/Backend/RPC/SignInWithSamlIdpRequest.swift

@@ -0,0 +1,55 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Foundation
+
+final class SignInWithSamlIdpRequest: AuthRPCRequest {
+  typealias Response = SignInWithSamlIdpResponse
+  private let config: AuthRequestConfiguration
+  private let requestUri: String
+  private let postBody: String
+  private let returnSecureToken: Bool
+
+  init(requestUri: String,
+       postBody: String,
+       returnSecureToken: Bool,
+       requestConfiguration: AuthRequestConfiguration) {
+    self.requestUri = requestUri
+    self.postBody = postBody
+    self.returnSecureToken = returnSecureToken
+    config = requestConfiguration
+  }
+
+  func requestConfiguration() -> AuthRequestConfiguration {
+    return config
+  }
+
+  func requestURL() -> URL {
+    var comps = URLComponents()
+    comps.scheme = "https"
+    comps.host = "identitytoolkit.googleapis.com"
+    comps.path = "/v1/accounts:signInWithIdp"
+    comps.queryItems = [URLQueryItem(name: "key", value: config.apiKey)]
+    return comps.url!
+  }
+
+  var unencodedHTTPRequestBody: [String: AnyHashable]? {
+    let body: [String: AnyHashable] = [
+      "requestUri": requestUri,
+      "postBody": postBody,
+      "returnSecureToken": returnSecureToken,
+    ]
+    return body
+  }
+}

+ 46 - 0
FirebaseAuth/Sources/Swift/Backend/RPC/SignInWithSamlIdpResponse.swift

@@ -0,0 +1,46 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Foundation
+
+struct SignInWithSamlIdpResponse: AuthRPCResponse {
+  /// The user raw access token.
+  let idToken: String
+  /// Refresh token for the authenticated user.
+  let refreshToken: String
+  /// The provider Identifier
+  let providerId: String
+  /// The email id of user
+  let email: String
+  /// The calculated date and time when the token expires.
+  let expirationDate: Date
+
+  init(dictionary: [String: AnyHashable]) throws {
+    guard
+      let email = dictionary["email"] as? String,
+      let expiration = dictionary["expiresIn"] as? String,
+      let idToken = dictionary["idToken"] as? String,
+      let providerId = dictionary["providerId"] as? String,
+      let refreshToken = dictionary["refreshToken"] as? String
+    else {
+      throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
+    }
+    self.idToken = idToken
+    self.refreshToken = refreshToken
+    self.providerId = providerId
+    self.email = email
+    let expiresInSec = TimeInterval(expiration)
+    expirationDate = Date().addingTimeInterval(expiresInSec ?? 3600)
+  }
+}

+ 54 - 0
FirebaseAuth/Tests/SampleSwift/AuthenticationExample/SceneDelegate.swift

@@ -50,10 +50,64 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 
   // Implementing this delegate method is needed when swizzling is disabled.
   // Without it, reCAPTCHA's login view controller will not dismiss.
+  // Without it, IdP Initiated SAML Sign In will not work.
   func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
     for urlContext in URLContexts {
       let url = urlContext.url
       _ = Auth.auth().canHandle(url)
+      /// Handle IdP Initiated SAML deep link myapp://saml?resp=<samlResponse>
+      if url.scheme?.lowercased() == "myapp", /// replace with your custom scheme
+         url.host?.lowercased() == "saml" { /// replace with your host
+        let spAcsUrl =
+          "https://iostemp-8a944.web.app/googleidp-saml/acs" /// replace with your SP ACS URL
+        if let rawQuery = url.query {
+          var respValue: String?
+          for pair in rawQuery.split(separator: "&", omittingEmptySubsequences: false) {
+            let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
+            if parts.count == 2, parts[0] == "resp" {
+              respValue = String(parts[1])
+              break
+            }
+          }
+          if let resp = respValue {
+            let alert = UIAlertController(
+              title: "SAML Sign In",
+              message: "Enter Provider ID",
+              preferredStyle: .alert
+            )
+            alert.addTextField { tf in
+              tf.placeholder = "Provider ID"
+              tf.text = "saml.provider"
+              tf.autocapitalizationType = .none
+              tf.autocorrectionType = .no
+            }
+            alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+            alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
+              let providerId = alert.textFields?.first?.text?
+                .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+              let requestUri = alert.textFields?.last?.text?
+                .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+              guard !providerId.isEmpty, !requestUri.isEmpty else { return }
+              Task {
+                do {
+                  _ = try await AppManager.shared.auth().signInWithSamlIdp(
+                    ProviderId: providerId,
+                    SpAcsUrl: requestUri,
+                    SamlResp: resp
+                  )
+                } catch {
+                  print("IdP-initiated SAML sign-in failed with error:", error)
+                }
+              }
+            })
+            var top = window?.rootViewController
+            while let presented = top?.presentedViewController {
+              top = presented
+            }
+            top?.present(alert, animated: true)
+          }
+        }
+      }
     }
 
     // URL not auth related; it should be handled separately.

+ 81 - 0
FirebaseAuth/Tests/SampleSwift/SwiftApiTests/SignInWithSamlIdpTests.swift

@@ -0,0 +1,81 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#if os(iOS)
+
+  @testable import FirebaseAuth
+  import XCTest
+
+  @available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
+  class SignInWithSamlIdpTests: TestsBase {
+    func testSignInWithSamlFailureInvalidProvider() async throws {
+      try? await deleteCurrentUserAsync()
+      let invalidProvider = "saml.invalid"
+      let spAcsUrl = "https://example.com/saml-acs"
+      let samlResp = "samlResp"
+      do {
+        _ = try await Auth.auth().signInWithSamlIdp(
+          ProviderId: invalidProvider,
+          SpAcsUrl: spAcsUrl,
+          SamlResp: samlResp
+        )
+        XCTFail("Expected failure for invalid provider ID")
+      } catch {
+        let ns = error as NSError
+        if let code = AuthErrorCode(rawValue: ns.code) {
+          XCTAssert([.operationNotAllowed].contains(code),
+                    "Unexpected code: \(code)")
+        } else {
+          XCTFail("Unexpected error: \(error)")
+        }
+        let desc = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased()
+        XCTAssert(
+          desc.contains("THE IDENTITY PROVIDER CONFIGURATION IS NOT FOUND."),
+          "Expected backend invalid provider message, got: \(desc)"
+        )
+      }
+      XCTAssertNil(Auth.auth().currentUser)
+    }
+
+    func testSignInWithSamlFailureInvalidResponse() async throws {
+      try? await deleteCurrentUserAsync()
+      let providerId = "saml.googleidp"
+      let spAcsUrl = "https://example.com/saml-acs"
+      let invalidSamlResp = "invalid%25"
+      do {
+        _ = try await Auth.auth().signInWithSamlIdp(
+          ProviderId: providerId,
+          SpAcsUrl: spAcsUrl,
+          SamlResp: invalidSamlResp
+        )
+        XCTFail("Expected failure for invalid SAMLResponse")
+      } catch {
+        let ns = error as NSError
+        if let code = AuthErrorCode(rawValue: ns.code) {
+          XCTAssert([.invalidCredential, .internalError].contains(code),
+                    "Unexpected code: \(code)")
+        } else {
+          XCTFail("Unexpected error: \(error)")
+        }
+        let desc = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased()
+        XCTAssert(
+          desc.contains("UNABLE TO PARSE THE SAML TOKEN."),
+          "Expected backend invalid credential message, got: \(desc)"
+        )
+      }
+      XCTAssertNil(Auth.auth().currentUser)
+    }
+  }
+
+#endif

+ 146 - 0
FirebaseAuth/Tests/Unit/AuthTests.swift

@@ -2287,6 +2287,152 @@ class AuthTests: RPCBaseTests {
     }
   #endif
 
+  // MARK: SAML IdP sign-in
+
+  #if os(iOS)
+
+    static let kSamlProviderId = "saml.idp"
+    static let kSamlAcsUrl = "https://example.com/saml-acs-url"
+    static let kSamlResponse = "BASE64_SAML_ASSERTION"
+    static let kBadSamlResponse = "MALFORMED_OR_TAMPERED_SAML"
+
+    func testSignInWithSamlIdpSuccess() throws {
+      let expectation = self.expectation(description: #function)
+      setFakeGetAccountProvider()
+      setFakeSecureTokenService()
+      rpcIssuer.respondBlock = {
+        let req = try XCTUnwrap(self.rpcIssuer.request as? SignInWithSamlIdpRequest)
+        XCTAssertEqual(req.requestConfiguration().apiKey, AuthTests.kFakeAPIKey)
+        XCTAssertEqual(
+          req.unencodedHTTPRequestBody?["requestUri"] as? String,
+          AuthTests.kSamlAcsUrl
+        )
+        XCTAssertTrue(
+          (req.unencodedHTTPRequestBody?["postBody"] as? String)?.contains(
+            AuthTests.kSamlProviderId
+          ) ?? false
+        )
+        XCTAssertTrue(req.unencodedHTTPRequestBody?["returnSecureToken"] as? Bool ?? false)
+        return try self.rpcIssuer.respond(withJSON: [
+          "idToken": RPCBaseTests.kFakeAccessToken,
+          "refreshToken": self.kRefreshToken,
+          "email": self.kEmail,
+          "providerId": AuthTests.kSamlProviderId,
+          "expiresIn": "3600",
+        ])
+      }
+      try auth.signOut()
+      Task {
+        do {
+          let result = try await self.auth.signInWithSamlIdp(
+            ProviderId: AuthTests.kSamlProviderId,
+            SpAcsUrl: AuthTests.kSamlAcsUrl,
+            SamlResp: AuthTests.kSamlResponse
+          )
+          XCTAssertEqual(result.user.email, self.kEmail)
+          XCTAssertEqual(result.user.refreshToken, self.kRefreshToken)
+          XCTAssertFalse(result.user.isAnonymous)
+          expectation.fulfill()
+        } catch {
+          XCTFail("Unexpected error: \(error)")
+        }
+      }
+      waitForExpectations(timeout: 5)
+    }
+
+    func testSignInWithSamlIdpWithIncorrectUrl() throws {
+      let expectation = self.expectation(description: #function)
+      let kBadSamlAcsUrl = "https://example.com/saml-acs-incorrect-url"
+      rpcIssuer.respondBlock = {
+        let req = try XCTUnwrap(self.rpcIssuer.request as? SignInWithSamlIdpRequest)
+        XCTAssertEqual(req.requestConfiguration().apiKey, AuthTests.kFakeAPIKey)
+        let body = try XCTUnwrap(req.unencodedHTTPRequestBody)
+        XCTAssertEqual(body["requestUri"] as? String, kBadSamlAcsUrl)
+        return try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED")
+      }
+      try auth.signOut()
+      Task {
+        do {
+          _ = try await self.auth.signInWithSamlIdp(
+            ProviderId: AuthTests.kSamlProviderId,
+            SpAcsUrl: kBadSamlAcsUrl,
+            SamlResp: AuthTests.kSamlResponse
+          )
+          XCTFail("Expected OPERATION_NOT_ALLOWED")
+        } catch {
+          let ns = error as NSError
+          XCTAssertEqual(ns.code, AuthErrorCode.operationNotAllowed.rawValue)
+          expectation.fulfill()
+        }
+      }
+      waitForExpectations(timeout: 5)
+      XCTAssertNil(auth.currentUser)
+    }
+
+    func testSignInWithSamlIdpFailureInvalidProviderId() throws {
+      let expectation = self.expectation(description: #function)
+      let badProvider = "saml.non-existent-idp"
+      rpcIssuer.respondBlock = {
+        let req = try XCTUnwrap(self.rpcIssuer.request as? SignInWithSamlIdpRequest)
+        XCTAssertEqual(req.requestConfiguration().apiKey, AuthTests.kFakeAPIKey)
+        let body = try XCTUnwrap(req.unencodedHTTPRequestBody)
+        let postBody = try XCTUnwrap(body["postBody"] as? String)
+        XCTAssertTrue(postBody.contains("providerId=\(badProvider)"))
+        return try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED")
+      }
+      try auth.signOut()
+      Task {
+        do {
+          _ = try await self.auth.signInWithSamlIdp(
+            ProviderId: badProvider, // wrong providerId
+            SpAcsUrl: AuthTests.kSamlAcsUrl,
+            SamlResp: AuthTests.kSamlResponse
+          )
+          XCTFail("Expected OPERATION_NOT_ALLOWED")
+        } catch {
+          let ns = error as NSError
+          XCTAssertEqual(ns.code, AuthErrorCode.operationNotAllowed.rawValue)
+          expectation.fulfill()
+        }
+      }
+      waitForExpectations(timeout: 5)
+      XCTAssertNil(auth.currentUser)
+    }
+
+    func testSignInWithSamlIdpFailureInvalidPostBody() throws {
+      let expectation = self.expectation(description: #function)
+      rpcIssuer.respondBlock = {
+        let req = try XCTUnwrap(self.rpcIssuer.request as? SignInWithSamlIdpRequest)
+        XCTAssertEqual(req.requestConfiguration().apiKey, AuthTests.kFakeAPIKey)
+        let body = try XCTUnwrap(req.unencodedHTTPRequestBody)
+        XCTAssertEqual(body["requestUri"] as? String, AuthTests.kSamlAcsUrl)
+        let postBody = try XCTUnwrap(body["postBody"] as? String)
+        XCTAssertTrue(postBody.contains("SAMLResponse=\(AuthTests.kBadSamlResponse)"))
+        XCTAssertTrue(postBody.contains("providerId=\(AuthTests.kSamlProviderId)"))
+        XCTAssertTrue(body["returnSecureToken"] as? Bool ?? false)
+        return try self.rpcIssuer
+          .respond(underlyingErrorMessage: "INVALID_CREDENTIAL_OR_PROVIDER_ID")
+      }
+      try auth.signOut()
+      Task {
+        do {
+          _ = try await self.auth.signInWithSamlIdp(
+            ProviderId: AuthTests.kSamlProviderId,
+            SpAcsUrl: AuthTests.kSamlAcsUrl,
+            SamlResp: AuthTests.kBadSamlResponse
+          )
+          XCTFail("Expected internalError but got success")
+        } catch {
+          let ns = error as NSError
+          XCTAssertEqual(ns.code, AuthErrorCode.internalError.rawValue)
+          expectation.fulfill()
+        }
+      }
+      waitForExpectations(timeout: 5)
+      XCTAssertNil(auth.currentUser)
+    }
+  #endif
+
   // MARK: Application Delegate tests.
 
   #if os(iOS)

+ 113 - 0
FirebaseAuth/Tests/Unit/SignInWithSamlIdpRequestTests.swift

@@ -0,0 +1,113 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@testable import FirebaseAuth
+import FirebaseCore
+import XCTest
+
+final class SignInWithSamlIdpRequestTests: XCTestCase {
+  let kAPIKey = "TEST_API_KEY"
+  let kAppID = "FAKE_APP_ID"
+  let kRequestUri = "https://example.web.app/sp-acs-url"
+  let kPostBody = "SAMLResponse=BASE64%2BSAFE&providerId=saml.provider"
+  let kComplexUri = "https://host/acs;param?p1=v1&p2=v2#frag"
+  let kRawPostBody =
+    "SAMLResponse=someResponse&providerId=saml.provider"
+
+  var configuration: AuthRequestConfiguration!
+
+  override func setUp() {
+    super.setUp()
+    configuration = AuthRequestConfiguration(apiKey: kAPIKey, appID: kAppID)
+  }
+
+  override func tearDown() {
+    configuration = nil
+    super.tearDown()
+  }
+
+  func testRequestURL() {
+    let request = SignInWithSamlIdpRequest(
+      requestUri: kRequestUri,
+      postBody: kPostBody,
+      returnSecureToken: true,
+      requestConfiguration: configuration
+    )
+
+    let url = request.requestURL()
+    XCTAssertEqual(url.scheme, "https")
+    XCTAssertEqual(url.host, "identitytoolkit.googleapis.com")
+    XCTAssertEqual(url.path, "/v1/accounts:signInWithIdp")
+
+    let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+    XCTAssertEqual(components?.queryItems?.count, 1)
+    XCTAssertEqual(components?.queryItems?.first?.name, "key")
+    XCTAssertEqual(components?.queryItems?.first?.value, kAPIKey)
+  }
+
+  func testRequestConfigurationPassed() {
+    let request = SignInWithSamlIdpRequest(
+      requestUri: kRequestUri,
+      postBody: kPostBody,
+      returnSecureToken: false,
+      requestConfiguration: configuration
+    )
+
+    let returned = request.requestConfiguration()
+    XCTAssertEqual(returned.apiKey, kAPIKey)
+    XCTAssertIdentical(returned.auth, configuration.auth)
+  }
+
+  func testUnencodedHTTPRequestBody() {
+    let request = SignInWithSamlIdpRequest(
+      requestUri: kRequestUri,
+      postBody: kPostBody,
+      returnSecureToken: true,
+      requestConfiguration: configuration
+    )
+
+    guard let body = request.unencodedHTTPRequestBody else {
+      XCTFail("Body must not be nil")
+      return
+    }
+
+    XCTAssertEqual(body.count, 3)
+    XCTAssertEqual(body["requestUri"] as? String, kRequestUri)
+    XCTAssertEqual(body["postBody"] as? String, kPostBody)
+    XCTAssertEqual(body["returnSecureToken"] as? Bool, true)
+  }
+
+  func testUnencodedHTTPRequestPostBody() {
+    let request = SignInWithSamlIdpRequest(
+      requestUri: kRequestUri,
+      postBody: kRawPostBody,
+      returnSecureToken: true,
+      requestConfiguration: configuration
+    )
+
+    let body = request.unencodedHTTPRequestBody
+    XCTAssertEqual(body?["postBody"] as? String, kRawPostBody)
+  }
+
+  func testUnencodedHTTPRequestBody_AllowsComplexRequestUri() throws {
+    let request = SignInWithSamlIdpRequest(
+      requestUri: kComplexUri,
+      postBody: kPostBody,
+      returnSecureToken: true,
+      requestConfiguration: configuration
+    )
+    let body = try XCTUnwrap(request.unencodedHTTPRequestBody)
+    XCTAssertEqual(body["requestUri"] as? String, kComplexUri)
+  }
+}

+ 85 - 0
FirebaseAuth/Tests/Unit/SignInWithSamlIdpResponseTests.swift

@@ -0,0 +1,85 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@testable import FirebaseAuth
+import XCTest
+
+final class SignInWithSamlIdpResponseTests: XCTestCase {
+  private func makeValidDictionary() -> [String: AnyHashable] {
+    return [
+      "email": "user@example.com",
+      "expiresIn": "3600",
+      "idToken": "FAKE_ID_TOKEN",
+      "providerId": "saml.provider",
+      "refreshToken": "FAKE_REFRESH_TOKEN",
+    ]
+  }
+
+  func testInitWithValidDictionaryAllRequiredFields() throws {
+    var dict = makeValidDictionary()
+    dict["email"] = "user1@example.com"
+    dict["idToken"] = "ID.TOKEN"
+    dict["providerId"] = "saml.myidp"
+    dict["refreshToken"] = "REFRESH.TOKEN"
+    let response = try SignInWithSamlIdpResponse(dictionary: dict)
+    XCTAssertEqual(response.email, "user1@example.com")
+    XCTAssertEqual(response.idToken, "ID.TOKEN")
+    XCTAssertEqual(response.providerId, "saml.myidp")
+    XCTAssertEqual(response.refreshToken, "REFRESH.TOKEN")
+  }
+
+  func testInitMissingRequiredFields() {
+    struct Case { let name: String; let keyToRemove: String }
+    let cases: [Case] = [
+      .init(name: "Missing email", keyToRemove: "email"),
+      .init(name: "Missing expiresIn", keyToRemove: "expiresIn"),
+      .init(name: "Missing idToken", keyToRemove: "idToken"),
+      .init(name: "Missing providerId", keyToRemove: "providerId"),
+      .init(name: "Missing refreshToken", keyToRemove: "refreshToken"),
+    ]
+    for c in cases {
+      var dict = makeValidDictionary()
+      dict.removeValue(forKey: c.keyToRemove)
+      XCTAssertThrowsError(try SignInWithSamlIdpResponse(dictionary: dict), c.name) { error in
+        let nsError = error as NSError
+        XCTAssertEqual(nsError.domain, AuthErrorDomain)
+        XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue)
+      }
+    }
+  }
+
+  func testInitIncorrectFieldTypes() {
+    var dict = makeValidDictionary()
+    dict["expiresIn"] = 3600
+    XCTAssertThrowsError(try SignInWithSamlIdpResponse(dictionary: dict)) { error in
+      let nsError = error as NSError
+      XCTAssertEqual(nsError.domain, AuthErrorDomain)
+      XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue)
+    }
+    dict = makeValidDictionary()
+    dict["idToken"] = 123
+    XCTAssertThrowsError(try SignInWithSamlIdpResponse(dictionary: dict)) { error in
+      let nsError = error as NSError
+      XCTAssertEqual(nsError.domain, AuthErrorDomain)
+      XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue)
+    }
+    dict = makeValidDictionary()
+    dict["email"] = NSNull()
+    XCTAssertThrowsError(try SignInWithSamlIdpResponse(dictionary: dict)) { error in
+      let nsError = error as NSError
+      XCTAssertEqual(nsError.domain, AuthErrorDomain)
+      XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue)
+    }
+  }
+}