Skip to content

Commit c81680b

Browse files
feat: initial OAuth setup
1 parent 08cfd07 commit c81680b

7 files changed

Lines changed: 404 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// AccountService+OAuth.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 OAuthOperationReauthentication {
13+
var oauthProvider: OAuthProviderSwift { get }
14+
}
15+
16+
extension OAuthOperationReauthentication {
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 oauthProvider.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 OAuthDeleteUserOperation: AuthenticatedOperation,
35+
@preconcurrency OAuthOperationReauthentication {
36+
let oauthProvider: OAuthProviderSwift
37+
init(oauthProvider: OAuthProviderSwift) {
38+
self.oauthProvider = oauthProvider
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+OAuth.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 withOAuthSignIn(_ provider: OAuthProviderSwift) -> AuthService {
27+
registerProvider(providerWithButton: OAuthProviderAuthUI(provider: provider))
28+
return self
29+
}
30+
}
31+
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 SwiftUI
16+
17+
/// Preset configurations for common OAuth providers
18+
public extension OAuthProviderSwift {
19+
20+
/// GitHub OAuth provider
21+
/// - Parameters:
22+
/// - scopes: GitHub scopes (default: ["user"])
23+
/// - Returns: Configured GitHub provider
24+
static func github(scopes: [String] = ["user"]) -> OAuthProviderSwift {
25+
return OAuthProviderSwift(
26+
providerId: "github.com",
27+
scopes: scopes,
28+
displayName: "Sign in with GitHub",
29+
iconSystemName: "chevron.left.forwardslash.chevron.right",
30+
buttonBackgroundColor: .black,
31+
buttonForegroundColor: .white
32+
)
33+
}
34+
35+
/// Microsoft OAuth provider
36+
/// - Parameters:
37+
/// - scopes: Microsoft scopes (default: ["openid", "profile", "email"])
38+
/// - Returns: Configured Microsoft provider
39+
static func microsoft(scopes: [String] = ["openid", "profile", "email"]) -> OAuthProviderSwift {
40+
return OAuthProviderSwift(
41+
providerId: "microsoft.com",
42+
scopes: scopes,
43+
displayName: "Sign in with Microsoft",
44+
iconSystemName: "building.2",
45+
buttonBackgroundColor: Color(red: 0/255, green: 120/255, blue: 212/255),
46+
buttonForegroundColor: .white
47+
)
48+
}
49+
50+
/// Yahoo OAuth provider
51+
/// - Parameters:
52+
/// - scopes: Yahoo scopes (default: [])
53+
/// - Returns: Configured Yahoo provider
54+
static func yahoo(scopes: [String] = []) -> OAuthProviderSwift {
55+
return OAuthProviderSwift(
56+
providerId: "yahoo.com",
57+
scopes: scopes,
58+
displayName: "Sign in with Yahoo",
59+
iconSystemName: "y.circle.fill",
60+
buttonBackgroundColor: Color(red: 80/255, green: 0/255, blue: 155/255),
61+
buttonForegroundColor: .white
62+
)
63+
}
64+
65+
/// LinkedIn OAuth provider
66+
/// - Parameters:
67+
/// - scopes: LinkedIn scopes (default: ["r_liteprofile", "r_emailaddress"])
68+
/// - Returns: Configured LinkedIn provider
69+
static func linkedIn(scopes: [String] = ["r_liteprofile", "r_emailaddress"]) -> OAuthProviderSwift {
70+
return OAuthProviderSwift(
71+
providerId: "linkedin.com",
72+
scopes: scopes,
73+
displayName: "Sign in with LinkedIn",
74+
iconSystemName: "link",
75+
buttonBackgroundColor: Color(red: 0/255, green: 119/255, blue: 181/255),
76+
buttonForegroundColor: .white
77+
)
78+
}
79+
}
80+
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 FirebaseAuth
16+
import FirebaseAuthSwiftUI
17+
import FirebaseCore
18+
import SwiftUI
19+
20+
/// Configuration for a generic OAuth provider
21+
public class OAuthProviderSwift: AuthProviderSwift, DeleteUserSwift {
22+
public let providerId: String
23+
public let scopes: [String]
24+
public let customParameters: [String: String]
25+
26+
// Button appearance
27+
public let displayName: String
28+
public let buttonIcon: Image
29+
public let buttonBackgroundColor: Color
30+
public let buttonForegroundColor: Color
31+
32+
/// Initialize a generic OAuth provider
33+
/// - Parameters:
34+
/// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com")
35+
/// - scopes: OAuth scopes to request
36+
/// - customParameters: Additional OAuth parameters
37+
/// - displayName: Button label (e.g., "Sign in with GitHub")
38+
/// - buttonIcon: Button icon image
39+
/// - buttonBackgroundColor: Button background color
40+
/// - buttonForegroundColor: Button text/icon color
41+
public init(
42+
providerId: String,
43+
scopes: [String] = [],
44+
customParameters: [String: String] = [:],
45+
displayName: String,
46+
buttonIcon: Image,
47+
buttonBackgroundColor: Color = .black,
48+
buttonForegroundColor: Color = .white
49+
) {
50+
self.providerId = providerId
51+
self.scopes = scopes
52+
self.customParameters = customParameters
53+
self.displayName = displayName
54+
self.buttonIcon = buttonIcon
55+
self.buttonBackgroundColor = buttonBackgroundColor
56+
self.buttonForegroundColor = buttonForegroundColor
57+
}
58+
59+
/// Convenience initializer using SF Symbol
60+
/// - Parameters:
61+
/// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com")
62+
/// - scopes: OAuth scopes to request
63+
/// - customParameters: Additional OAuth parameters
64+
/// - displayName: Button label (e.g., "Sign in with GitHub")
65+
/// - iconSystemName: SF Symbol name
66+
/// - buttonBackgroundColor: Button background color
67+
/// - buttonForegroundColor: Button text/icon color
68+
public convenience init(
69+
providerId: String,
70+
scopes: [String] = [],
71+
customParameters: [String: String] = [:],
72+
displayName: String,
73+
iconSystemName: String,
74+
buttonBackgroundColor: Color = .black,
75+
buttonForegroundColor: Color = .white
76+
) {
77+
self.init(
78+
providerId: providerId,
79+
scopes: scopes,
80+
customParameters: customParameters,
81+
displayName: displayName,
82+
buttonIcon: Image(systemName: iconSystemName),
83+
buttonBackgroundColor: buttonBackgroundColor,
84+
buttonForegroundColor: buttonForegroundColor
85+
)
86+
}
87+
88+
@MainActor public func createAuthCredential() async throws -> AuthCredential {
89+
let provider = OAuthProvider(providerID: providerId)
90+
91+
// Set scopes if provided
92+
if !scopes.isEmpty {
93+
provider.scopes = scopes
94+
}
95+
96+
// Set custom parameters if provided
97+
if !customParameters.isEmpty {
98+
provider.customParameters = customParameters
99+
}
100+
101+
return try await withCheckedThrowingContinuation { continuation in
102+
provider.getCredentialWith(nil) { credential, error in
103+
if let error {
104+
continuation.resume(
105+
throwing: AuthServiceError.signInFailed(underlying: error)
106+
)
107+
} else if let credential {
108+
continuation.resume(returning: credential)
109+
} else {
110+
continuation.resume(
111+
throwing: AuthServiceError.invalidCredentials(
112+
"\(self.providerId) did not provide a valid AuthCredential"
113+
)
114+
)
115+
}
116+
}
117+
}
118+
}
119+
120+
public func deleteUser(user: User) async throws {
121+
let operation = OAuthDeleteUserOperation(oauthProvider: self)
122+
try await operation(on: user)
123+
}
124+
}
125+
126+
public class OAuthProviderAuthUI: AuthProviderUI {
127+
public var provider: AuthProviderSwift
128+
129+
public init(provider: AuthProviderSwift) {
130+
self.provider = provider
131+
}
132+
133+
public var id: String {
134+
guard let oauthProvider = provider as? OAuthProviderSwift else {
135+
return "oauth.unknown"
136+
}
137+
return oauthProvider.providerId
138+
}
139+
140+
@MainActor public func authButton() -> AnyView {
141+
AnyView(GenericOAuthButton(provider: provider))
142+
}
143+
}
144+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 generic OAuth sign-in button that adapts to any provider's configuration
19+
@MainActor
20+
public struct GenericOAuthButton {
21+
@Environment(AuthService.self) private var authService
22+
let provider: AuthProviderSwift
23+
24+
public init(provider: AuthProviderSwift) {
25+
self.provider = provider
26+
}
27+
}
28+
29+
extension GenericOAuthButton: View {
30+
public var body: some View {
31+
guard let oauthProvider = provider as? OAuthProviderSwift else {
32+
return AnyView(
33+
Text("Invalid OAuth Provider")
34+
.foregroundColor(.red)
35+
)
36+
}
37+
38+
return AnyView(
39+
Button(action: {
40+
Task {
41+
try await authService.signIn(provider)
42+
}
43+
}) {
44+
HStack {
45+
oauthProvider.buttonIcon
46+
.resizable()
47+
.renderingMode(.template)
48+
.scaledToFit()
49+
.frame(width: 24, height: 24)
50+
.foregroundColor(oauthProvider.buttonForegroundColor)
51+
52+
Text(oauthProvider.displayName)
53+
.fontWeight(.semibold)
54+
.foregroundColor(oauthProvider.buttonForegroundColor)
55+
}
56+
.frame(maxWidth: .infinity, alignment: .leading)
57+
.padding()
58+
.background(oauthProvider.buttonBackgroundColor)
59+
.cornerRadius(8)
60+
}
61+
.accessibilityIdentifier("sign-in-with-\(oauthProvider.providerId)-button")
62+
)
63+
}
64+
}
65+
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 FirebaseOAuthSwiftUI
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)