Skip to content

Commit cc56c75

Browse files
Merge pull request #1278 from firebase/apple-provider
2 parents e838597 + ff554e4 commit cc56c75

File tree

13 files changed

+419
-2
lines changed

13 files changed

+419
-2
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// AccountService+Apple.swift
3+
// FirebaseUI
4+
//
5+
// Created by Russell Wheatley on 21/10/2025.
6+
//
7+
8+
@preconcurrency import FirebaseAuth
9+
import FirebaseAuthSwiftUI
10+
import Observation
11+
12+
protocol AppleOperationReauthentication {
13+
var appleProvider: AppleProviderSwift { get }
14+
}
15+
16+
extension AppleOperationReauthentication {
17+
@MainActor func reauthenticate() async throws -> AuthenticationToken {
18+
guard let user = Auth.auth().currentUser else {
19+
throw AuthServiceError.reauthenticationRequired("No user currently signed-in")
20+
}
21+
22+
do {
23+
let credential = try await appleProvider.createAuthCredential()
24+
try await user.reauthenticate(with: credential)
25+
26+
return .firebase("")
27+
} catch {
28+
throw AuthServiceError.signInFailed(underlying: error)
29+
}
30+
}
31+
}
32+
33+
@MainActor
34+
class AppleDeleteUserOperation: AuthenticatedOperation,
35+
@preconcurrency AppleOperationReauthentication {
36+
let appleProvider: AppleProviderSwift
37+
init(appleProvider: AppleProviderSwift) {
38+
self.appleProvider = appleProvider
39+
}
40+
41+
func callAsFunction(on user: User) async throws {
42+
try await callAsFunction(on: user) {
43+
try await user.delete()
44+
}
45+
}
46+
}
47+
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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 AuthenticationServices
16+
import CryptoKit
17+
import FirebaseAuth
18+
import FirebaseAuthSwiftUI
19+
import FirebaseCore
20+
import SwiftUI
21+
22+
// MARK: - Data Extensions
23+
24+
extension Data {
25+
var utf8String: String? {
26+
return String(data: self, encoding: .utf8)
27+
}
28+
}
29+
30+
extension ASAuthorizationAppleIDCredential {
31+
var authorizationCodeString: String? {
32+
return authorizationCode?.utf8String
33+
}
34+
35+
var idTokenString: String? {
36+
return identityToken?.utf8String
37+
}
38+
}
39+
40+
// MARK: - Authenticate With Apple Dialog
41+
42+
private func authenticateWithApple(
43+
scopes: [ASAuthorization.Scope]
44+
) async throws -> (ASAuthorizationAppleIDCredential, String) {
45+
return try await AuthenticateWithAppleDialog(scopes: scopes).authenticate()
46+
}
47+
48+
private class AuthenticateWithAppleDialog: NSObject {
49+
private var continuation: CheckedContinuation<(ASAuthorizationAppleIDCredential, String), Error>?
50+
private var currentNonce: String?
51+
private let scopes: [ASAuthorization.Scope]
52+
53+
init(scopes: [ASAuthorization.Scope]) {
54+
self.scopes = scopes
55+
super.init()
56+
}
57+
58+
func authenticate() async throws -> (ASAuthorizationAppleIDCredential, String) {
59+
return try await withCheckedThrowingContinuation { continuation in
60+
self.continuation = continuation
61+
62+
let appleIDProvider = ASAuthorizationAppleIDProvider()
63+
let request = appleIDProvider.createRequest()
64+
request.requestedScopes = scopes
65+
66+
do {
67+
let nonce = try CryptoUtils.randomNonceString()
68+
currentNonce = nonce
69+
request.nonce = CryptoUtils.sha256(nonce)
70+
} catch {
71+
continuation.resume(throwing: AuthServiceError.signInFailed(underlying: error))
72+
return
73+
}
74+
75+
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
76+
authorizationController.delegate = self
77+
authorizationController.performRequests()
78+
}
79+
}
80+
}
81+
82+
extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate {
83+
func authorizationController(
84+
controller: ASAuthorizationController,
85+
didCompleteWithAuthorization authorization: ASAuthorization
86+
) {
87+
if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
88+
if let nonce = currentNonce {
89+
continuation?.resume(returning: (appleIDCredential, nonce))
90+
} else {
91+
continuation?.resume(
92+
throwing: AuthServiceError.signInFailed(
93+
underlying: NSError(
94+
domain: "AppleSignIn",
95+
code: -1,
96+
userInfo: [NSLocalizedDescriptionKey: "Missing nonce"]
97+
)
98+
)
99+
)
100+
}
101+
} else {
102+
continuation?.resume(
103+
throwing: AuthServiceError.invalidCredentials("Missing Apple ID credential")
104+
)
105+
}
106+
continuation = nil
107+
}
108+
109+
func authorizationController(
110+
controller: ASAuthorizationController,
111+
didCompleteWithError error: Error
112+
) {
113+
continuation?.resume(throwing: AuthServiceError.signInFailed(underlying: error))
114+
continuation = nil
115+
}
116+
}
117+
118+
// MARK: - Apple Provider Swift
119+
120+
public class AppleProviderSwift: AuthProviderSwift, DeleteUserSwift {
121+
public let scopes: [ASAuthorization.Scope]
122+
let providerId = "apple.com"
123+
124+
public init(scopes: [ASAuthorization.Scope] = [.fullName, .email]) {
125+
self.scopes = scopes
126+
}
127+
128+
@MainActor public func createAuthCredential() async throws -> AuthCredential {
129+
let (appleIDCredential, nonce) = try await authenticateWithApple(scopes: scopes)
130+
131+
guard let idTokenString = appleIDCredential.idTokenString else {
132+
throw AuthServiceError.invalidCredentials("Unable to fetch identity token from Apple")
133+
}
134+
135+
let credential = OAuthProvider.appleCredential(
136+
withIDToken: idTokenString,
137+
rawNonce: nonce,
138+
fullName: appleIDCredential.fullName
139+
)
140+
141+
return credential
142+
}
143+
144+
public func deleteUser(user: User) async throws {
145+
let operation = AppleDeleteUserOperation(appleProvider: self)
146+
try await operation(on: user)
147+
}
148+
}
149+
150+
public class AppleProviderAuthUI: AuthProviderUI {
151+
public var provider: AuthProviderSwift
152+
153+
public init(provider: AuthProviderSwift) {
154+
self.provider = provider
155+
}
156+
157+
public let id: String = "apple.com"
158+
159+
@MainActor public func authButton() -> AnyView {
160+
AnyView(SignInWithAppleButton(provider: provider))
161+
}
162+
}
163+
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
//
16+
// AuthService+Apple.swift
17+
// FirebaseUI
18+
//
19+
// Created by Russell Wheatley on 21/10/2025.
20+
//
21+
22+
import FirebaseAuthSwiftUI
23+
24+
public extension AuthService {
25+
@discardableResult
26+
func withAppleSignIn(_ provider: AppleProviderSwift? = nil) -> AuthService {
27+
registerProvider(providerWithButton: AppleProviderAuthUI(provider: provider ??
28+
AppleProviderSwift()))
29+
return self
30+
}
31+
}
32+
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
import CryptoKit
17+
18+
/// Set of utility APIs for generating cryptographical artifacts.
19+
enum CryptoUtils {
20+
enum NonceGenerationError: Error {
21+
case generationFailure(status: OSStatus)
22+
}
23+
24+
static func randomNonceString(length: Int = 32) throws -> String {
25+
precondition(length > 0)
26+
var randomBytes = [UInt8](repeating: 0, count: length)
27+
let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes)
28+
if errorCode != errSecSuccess {
29+
throw NonceGenerationError.generationFailure(status: errorCode)
30+
}
31+
32+
let charset: [Character] =
33+
Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
34+
35+
let nonce = randomBytes.map { byte in
36+
// Pick a random character from the set, wrapping around if needed.
37+
charset[Int(byte) % charset.count]
38+
}
39+
40+
return String(nonce)
41+
}
42+
43+
static func sha256(_ input: String) -> String {
44+
let inputData = Data(input.utf8)
45+
let hashedData = SHA256.hash(data: inputData)
46+
let hashString = hashedData.compactMap {
47+
String(format: "%02x", $0)
48+
}.joined()
49+
50+
return hashString
51+
}
52+
}
53+
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 FirebaseAuthSwiftUI
16+
import SwiftUI
17+
18+
/// A button for signing in with Apple
19+
@MainActor
20+
public struct SignInWithAppleButton {
21+
@Environment(AuthService.self) private var authService
22+
let provider: AuthProviderSwift
23+
public init(provider: AuthProviderSwift) {
24+
self.provider = provider
25+
}
26+
}
27+
28+
extension SignInWithAppleButton: View {
29+
public var body: some View {
30+
Button(action: {
31+
Task {
32+
try await authService.signIn(provider)
33+
}
34+
}) {
35+
HStack {
36+
Image(systemName: "apple.logo")
37+
.resizable()
38+
.renderingMode(.template)
39+
.scaledToFit()
40+
.frame(width: 24, height: 24)
41+
.foregroundColor(.white)
42+
Text("Sign in with Apple")
43+
.fontWeight(.semibold)
44+
.foregroundColor(.white)
45+
}
46+
.frame(maxWidth: .infinity, alignment: .leading)
47+
.padding()
48+
.background(Color.black)
49+
.cornerRadius(8)
50+
}
51+
.accessibilityIdentifier("sign-in-with-apple-button")
52+
}
53+
}
54+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
@testable import FirebaseAppleSwiftUI
16+
import Testing
17+
18+
@Test func example() async throws {
19+
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
20+
}
21+

0 commit comments

Comments
 (0)