Skip to content

Commit 35bb1b8

Browse files
authored
IdP-Initiated Saml Sign In Implementation (#15291)
1 parent dba582f commit 35bb1b8

File tree

8 files changed

+603
-0
lines changed

8 files changed

+603
-0
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,6 +2341,29 @@ extension Auth: AuthInterop {
23412341
}
23422342
#endif
23432343

2344+
// MARK: IDP Initiated SAML Sign In
2345+
2346+
public func signInWithSamlIdp(ProviderId providerId: String,
2347+
SpAcsUrl spAcsUrl: String,
2348+
SamlResp samlResp: String) async throws -> AuthDataResult {
2349+
let samlRespBody = "SAMLResponse=\(samlResp)&providerId=\(providerId)"
2350+
let request = SignInWithSamlIdpRequest(
2351+
requestUri: spAcsUrl,
2352+
postBody: samlRespBody,
2353+
returnSecureToken: true,
2354+
requestConfiguration: requestConfiguration
2355+
)
2356+
let response = try await backend.call(with: request)
2357+
let user = try await completeSignIn(
2358+
withAccessToken: response.idToken,
2359+
accessTokenExpirationDate: response.expirationDate,
2360+
refreshToken: response.refreshToken,
2361+
anonymous: false
2362+
)
2363+
try await updateCurrentUser(user)
2364+
return AuthDataResult(withUser: user, additionalUserInfo: nil)
2365+
}
2366+
23442367
// MARK: Internal properties
23452368

23462369
/// Allow tests to swap in an alternate mainBundle, including ObjC unit tests via CocoaPods.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
final class SignInWithSamlIdpRequest: AuthRPCRequest {
18+
typealias Response = SignInWithSamlIdpResponse
19+
private let config: AuthRequestConfiguration
20+
private let requestUri: String
21+
private let postBody: String
22+
private let returnSecureToken: Bool
23+
24+
init(requestUri: String,
25+
postBody: String,
26+
returnSecureToken: Bool,
27+
requestConfiguration: AuthRequestConfiguration) {
28+
self.requestUri = requestUri
29+
self.postBody = postBody
30+
self.returnSecureToken = returnSecureToken
31+
config = requestConfiguration
32+
}
33+
34+
func requestConfiguration() -> AuthRequestConfiguration {
35+
return config
36+
}
37+
38+
func requestURL() -> URL {
39+
var comps = URLComponents()
40+
comps.scheme = "https"
41+
comps.host = "identitytoolkit.googleapis.com"
42+
comps.path = "/v1/accounts:signInWithIdp"
43+
comps.queryItems = [URLQueryItem(name: "key", value: config.apiKey)]
44+
return comps.url!
45+
}
46+
47+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
48+
let body: [String: AnyHashable] = [
49+
"requestUri": requestUri,
50+
"postBody": postBody,
51+
"returnSecureToken": returnSecureToken,
52+
]
53+
return body
54+
}
55+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
struct SignInWithSamlIdpResponse: AuthRPCResponse {
18+
/// The user raw access token.
19+
let idToken: String
20+
/// Refresh token for the authenticated user.
21+
let refreshToken: String
22+
/// The provider Identifier
23+
let providerId: String
24+
/// The email id of user
25+
let email: String
26+
/// The calculated date and time when the token expires.
27+
let expirationDate: Date
28+
29+
init(dictionary: [String: AnyHashable]) throws {
30+
guard
31+
let email = dictionary["email"] as? String,
32+
let expiration = dictionary["expiresIn"] as? String,
33+
let idToken = dictionary["idToken"] as? String,
34+
let providerId = dictionary["providerId"] as? String,
35+
let refreshToken = dictionary["refreshToken"] as? String
36+
else {
37+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
38+
}
39+
self.idToken = idToken
40+
self.refreshToken = refreshToken
41+
self.providerId = providerId
42+
self.email = email
43+
let expiresInSec = TimeInterval(expiration)
44+
expirationDate = Date().addingTimeInterval(expiresInSec ?? 3600)
45+
}
46+
}

FirebaseAuth/Tests/SampleSwift/AuthenticationExample/SceneDelegate.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,64 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5050

5151
// Implementing this delegate method is needed when swizzling is disabled.
5252
// Without it, reCAPTCHA's login view controller will not dismiss.
53+
// Without it, IdP Initiated SAML Sign In will not work.
5354
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
5455
for urlContext in URLContexts {
5556
let url = urlContext.url
5657
_ = Auth.auth().canHandle(url)
58+
/// Handle IdP Initiated SAML deep link myapp://saml?resp=<samlResponse>
59+
if url.scheme?.lowercased() == "myapp", /// replace with your custom scheme
60+
url.host?.lowercased() == "saml" { /// replace with your host
61+
let spAcsUrl =
62+
"https://iostemp-8a944.web.app/googleidp-saml/acs" /// replace with your SP ACS URL
63+
if let rawQuery = url.query {
64+
var respValue: String?
65+
for pair in rawQuery.split(separator: "&", omittingEmptySubsequences: false) {
66+
let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
67+
if parts.count == 2, parts[0] == "resp" {
68+
respValue = String(parts[1])
69+
break
70+
}
71+
}
72+
if let resp = respValue {
73+
let alert = UIAlertController(
74+
title: "SAML Sign In",
75+
message: "Enter Provider ID",
76+
preferredStyle: .alert
77+
)
78+
alert.addTextField { tf in
79+
tf.placeholder = "Provider ID"
80+
tf.text = "saml.provider"
81+
tf.autocapitalizationType = .none
82+
tf.autocorrectionType = .no
83+
}
84+
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
85+
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
86+
let providerId = alert.textFields?.first?.text?
87+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
88+
let requestUri = alert.textFields?.last?.text?
89+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
90+
guard !providerId.isEmpty, !requestUri.isEmpty else { return }
91+
Task {
92+
do {
93+
_ = try await AppManager.shared.auth().signInWithSamlIdp(
94+
ProviderId: providerId,
95+
SpAcsUrl: requestUri,
96+
SamlResp: resp
97+
)
98+
} catch {
99+
print("IdP-initiated SAML sign-in failed with error:", error)
100+
}
101+
}
102+
})
103+
var top = window?.rootViewController
104+
while let presented = top?.presentedViewController {
105+
top = presented
106+
}
107+
top?.present(alert, animated: true)
108+
}
109+
}
110+
}
57111
}
58112

59113
// URL not auth related; it should be handled separately.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#if os(iOS)
16+
17+
@testable import FirebaseAuth
18+
import XCTest
19+
20+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
21+
class SignInWithSamlIdpTests: TestsBase {
22+
func testSignInWithSamlFailureInvalidProvider() async throws {
23+
try? await deleteCurrentUserAsync()
24+
let invalidProvider = "saml.invalid"
25+
let spAcsUrl = "https://example.com/saml-acs"
26+
let samlResp = "samlResp"
27+
do {
28+
_ = try await Auth.auth().signInWithSamlIdp(
29+
ProviderId: invalidProvider,
30+
SpAcsUrl: spAcsUrl,
31+
SamlResp: samlResp
32+
)
33+
XCTFail("Expected failure for invalid provider ID")
34+
} catch {
35+
let ns = error as NSError
36+
if let code = AuthErrorCode(rawValue: ns.code) {
37+
XCTAssert([.operationNotAllowed].contains(code),
38+
"Unexpected code: \(code)")
39+
} else {
40+
XCTFail("Unexpected error: \(error)")
41+
}
42+
let desc = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased()
43+
XCTAssert(
44+
desc.contains("THE IDENTITY PROVIDER CONFIGURATION IS NOT FOUND."),
45+
"Expected backend invalid provider message, got: \(desc)"
46+
)
47+
}
48+
XCTAssertNil(Auth.auth().currentUser)
49+
}
50+
51+
func testSignInWithSamlFailureInvalidResponse() async throws {
52+
try? await deleteCurrentUserAsync()
53+
let providerId = "saml.googleidp"
54+
let spAcsUrl = "https://example.com/saml-acs"
55+
let invalidSamlResp = "invalid%25"
56+
do {
57+
_ = try await Auth.auth().signInWithSamlIdp(
58+
ProviderId: providerId,
59+
SpAcsUrl: spAcsUrl,
60+
SamlResp: invalidSamlResp
61+
)
62+
XCTFail("Expected failure for invalid SAMLResponse")
63+
} catch {
64+
let ns = error as NSError
65+
if let code = AuthErrorCode(rawValue: ns.code) {
66+
XCTAssert([.invalidCredential, .internalError].contains(code),
67+
"Unexpected code: \(code)")
68+
} else {
69+
XCTFail("Unexpected error: \(error)")
70+
}
71+
let desc = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased()
72+
XCTAssert(
73+
desc.contains("UNABLE TO PARSE THE SAML TOKEN."),
74+
"Expected backend invalid credential message, got: \(desc)"
75+
)
76+
}
77+
XCTAssertNil(Auth.auth().currentUser)
78+
}
79+
}
80+
81+
#endif

0 commit comments

Comments
 (0)