diff --git a/Example/BetterAuthSwiftExample/ContentView.swift b/Example/BetterAuthSwiftExample/ContentView.swift index abea6aa..eefb9b1 100644 --- a/Example/BetterAuthSwiftExample/ContentView.swift +++ b/Example/BetterAuthSwiftExample/ContentView.swift @@ -13,6 +13,7 @@ import BetterAuthPasskey import BetterAuthPhoneNumber import BetterAuthTwoFactor import BetterAuthUsername +import BetterAuthGenericOAuth import SwiftUI extension String { @@ -32,6 +33,7 @@ enum Screen: String, Hashable, Identifiable, CaseIterable { case magicLink case emailOTP case passkeyView + case genericOAuthView var id: String { rawValue } @@ -51,6 +53,8 @@ enum Screen: String, Hashable, Identifiable, CaseIterable { "Email OTP" case .passkeyView: "Passkey" + case .genericOAuthView: + "Generic OAuth" } } } @@ -62,6 +66,7 @@ struct ContentView: View { plugins: [ TwoFactorPlugin(), UsernamePlugin(), PhoneNumberPlugin(), MagicLinkPlugin(), EmailOTPPlugin(), PasskeyPlugin(), + GenericOAuthPlugin(), ], ) @State private var path: [Screen] = [] @@ -135,6 +140,8 @@ struct ContentView: View { EmailOTPView() case .passkeyView: PasskeyView() + case .genericOAuthView: + GenericOAuthView() } } } diff --git a/Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift b/Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift new file mode 100644 index 0000000..aa6351b --- /dev/null +++ b/Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift @@ -0,0 +1,156 @@ +// +// genericOAuthView.swift +// BetterAuthSwift +// +// Created by Robin Augereau on 07/11/2025. +// + +import SwiftUI +import BetterAuth +import BetterAuthGenericOAuth + +struct GenericOAuthView: View { + @EnvironmentObject var client: BetterAuthClient + + @State private var providerId = "" + @State private var isSigningIn = false + @State private var errorMessage: String? + + #if !os(watchOS) + var body: some View { + VStack(spacing: 24) { + if let user = client.session.data?.user { + // MARK: - Logged-in User + HStack(alignment: .center, spacing: 16) { + AsyncImage(url: URL(string: user.image ?? "")) { image in + image.resizable() + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Image(systemName: "person.fill") + .foregroundColor(.gray) + ) + } + .frame(width: 60, height: 60) + .clipShape(Circle()) + .shadow(radius: 2) + + VStack(alignment: .leading, spacing: 4) { + Text(user.name) + .font(.headline) + .foregroundColor(.primary) + Text(user.email) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + Task { try? await client.signOut() } + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + .font(.subheadline.bold()) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().fill(Color.red.gradient)) + } + .buttonStyle(.plain) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemFill)) + .shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 2) + ) + + Text("Welcome back, \(user.name)!") + .font(.title3) + .fontWeight(.semibold) + } else { + // MARK: - OAuth2 Form + VStack(spacing: 16) { + Image(systemName: "person.badge.key.fill") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .foregroundStyle(.tint) + .padding(.bottom, 6) + + Text("Sign in with OAuth2") + .font(.headline) + + TextField("Provider ID", text: $providerId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(12) + .background(Color(.secondarySystemFill)) + .cornerRadius(8) + + Button { + signInWithOAuth2() + } label: { + Label("Sign In", systemImage: "arrow.right.circle.fill") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 40) + .padding(.vertical, 10) + .background(Capsule().fill(Color.accentColor.gradient)) + } + .disabled(providerId.isEmpty || isSigningIn) + + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.footnote) + .padding(.top, 4) + } + } + .padding() + .frame(maxWidth: 380) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.tertiarySystemFill)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + ) + .overlay(alignment: .center) { + if isSigningIn { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.3) + } + } + } + } + .padding() + .animation(.spring(duration: 0.35), value: client.session.data?.user.id) + } + + // MARK: - Logic + private func signInWithOAuth2() { + Task { + isSigningIn = true + errorMessage = nil + do { + _ = try await client.signIn.oauth2( + with: .init( + providerId: providerId, + callbackURL: "betterauthswiftexample://" + ) + ) + } catch { + errorMessage = "Failed to sign in: \(error.localizedDescription)" + } + isSigningIn = false + } + } + #else + var body: some View { + Text("OAuth2 is not supported on watchOS") + .foregroundColor(.secondary) + .padding() + } + #endif +} \ No newline at end of file diff --git a/Package.swift b/Package.swift index dc9e1c3..c778c36 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,8 @@ let package = Package( .library(name: "BetterAuthPhoneNumber", targets: ["BetterAuthPhoneNumber"]), .library(name: "BetterAuthMagicLink", targets: ["BetterAuthMagicLink"]), .library(name: "BetterAuthEmailOTP", targets: ["BetterAuthEmailOTP"]), - .library(name: "BetterAuthPasskey", targets: ["BetterAuthPasskey"]) + .library(name: "BetterAuthPasskey", targets: ["BetterAuthPasskey"]), + .library(name: "BetterAuthGenericOAuth", targets: ["BetterAuthGenericOAuth"]) ], dependencies: [ .package( @@ -65,9 +66,14 @@ let package = Package( dependencies: ["BetterAuth"], path: "Sources/Plugins/Passkey" ), + .target( + name: "BetterAuthGenericOAuth", + dependencies: ["BetterAuth"], + path: "Sources/Plugins/GenericOAuth" + ), .testTarget( name: "BetterAuthTests", dependencies: ["BetterAuth"] ) ] -) +) \ No newline at end of file diff --git a/Sources/Core/Session/OAuthHandler.swift b/Sources/Core/Session/OAuthHandler.swift index 938175c..eb97111 100644 --- a/Sources/Core/Session/OAuthHandler.swift +++ b/Sources/Core/Session/OAuthHandler.swift @@ -3,11 +3,11 @@ import Foundation #if !os(watchOS) @MainActor -class OAuthHandler: NSObject { +package class OAuthHandler: NSObject { private var webAuthSession: ASWebAuthenticationSession? private var completion: ((Result) -> Void)? - func extractScheme(from callbackURL: String?) throws -> String { + public func extractScheme(from callbackURL: String?) throws -> String { guard let callbackURL = callbackURL, let url = URL(string: callbackURL), let scheme = url.scheme @@ -21,7 +21,7 @@ class OAuthHandler: NSObject { return scheme } - func authenticate(authURL: String, callbackURLScheme: String) async throws + public func authenticate(authURL: String, callbackURLScheme: String) async throws -> String { return try await withCheckedThrowingContinuation { continuation in @@ -86,7 +86,7 @@ class OAuthHandler: NSObject { } extension OAuthHandler: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { #if os(iOS) @@ -101,4 +101,4 @@ extension OAuthHandler: ASWebAuthenticationPresentationContextProviding { #endif } } -#endif +#endif \ No newline at end of file diff --git a/Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift b/Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift new file mode 100644 index 0000000..798f929 --- /dev/null +++ b/Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift @@ -0,0 +1,64 @@ +import BetterAuth +import Foundation + +#if !os(watchOS) + extension BetterAuthClient.SignIn { + public typealias GenericOAuthSignInOAuth2 = APIResource< + GenericOAuthSignInResponse, EmptyContext + > + + /// Make a request to **/sign-in/oauth2**. + /// - Parameter body: ``GenericOAuthSignInRequest`` + /// - Returns: ``GenericOAuthSignInOAuth2`` + /// - Throws: ``/BetterAuth/BetterAuthApiError`` - ``/BetterAuth/BetterAuthSwiftError`` + public func oauth2(with body: GenericOAuthSignInRequest) async throws + -> GenericOAuthSignInOAuth2 + { + guard let client = client else { + throw BetterAuthSwiftError(message: "Client deallocated") + } + + return try await SignalBus.shared.emittingSignal(.signIn) { + let response: GenericOAuthSignInOAuth2 = try await client.httpClient.perform( + route: BetterAuthGenericOAuthRoute.signInOAuth2, + body: body, + responseType: GenericOAuthSignInResponse.self + ) + + guard response.data.redirect, let authURL = response.data.url else { + return response + } + + guard let callbackURL = body.callbackURL else { + throw BetterAuthSwiftError( + message: + "callbackURL is required to handle the OAuth redirect when redirect is enabled." + ) + } + + let proxyURL = try client.genericOAuth.makeAuthorizationProxyURL( + for: authURL + ) + + let handler = OAuthHandler() + let sessionCookie = try await handler.authenticate( + authURL: proxyURL.absoluteString, + callbackURLScheme: try handler.extractScheme(from: callbackURL) + ) + + try client.httpClient.cookieStorage.setCookie( + sessionCookie, + for: client.baseUrl + ) + + return response + } + } + } +#endif + +extension BetterAuthClient { + public var genericOAuth: GenericOAuth { + self.pluginRegistry.get(id: GenericOAuthPlugin.id) + } +} diff --git a/Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift b/Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift new file mode 100644 index 0000000..2c5024a --- /dev/null +++ b/Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift @@ -0,0 +1,67 @@ +import BetterAuth +import Foundation + +public final class GenericOAuthPlugin: PluginFactory { + public static let id: String = "genericOAuth" + + public static func create(client: BetterAuthClient) -> Pluggable { + GenericOAuth(client: client) + } + + public init() {} +} + +@MainActor +public final class GenericOAuth: Pluggable { + private weak var client: BetterAuthClient? + + public init(client: BetterAuthClient) { + self.client = client + } + + public typealias GenericOAuthLink = APIResource< + GenericOAuthLinkResponse, EmptyContext + > + + /// Make a request to **/oauth2/link**. + /// - Parameter body: ``GenericOAuthLinkRequest`` + /// - Returns: ``GenericOAuthLink`` + /// - Throws: ``/BetterAuth/BetterAuthApiError`` - ``/BetterAuth/BetterAuthSwiftError`` + public func link(with body: GenericOAuthLinkRequest) async throws + -> GenericOAuthLink + { + guard let client = client else { + throw BetterAuthSwiftError(message: "Client deallocated") + } + + return try await client.httpClient.perform( + route: BetterAuthGenericOAuthRoute.oauth2Link, + body: body, + responseType: GenericOAuthLinkResponse.self + ) + } + + public func makeAuthorizationProxyURL(for authorizationURL: String) throws -> URL { + guard let client = client else { + throw BetterAuthSwiftError(message: "Client deallocated") + } + + var components = URLComponents( + url: client.baseUrl, + resolvingAgainstBaseURL: false + ) + components?.path.append(BetterAuthRoute.expoAuthorizationProxy.path) + components?.queryItems = [ + URLQueryItem( + name: "authorizationURL", + value: authorizationURL + ) + ] + + guard let url = components?.url else { + throw BetterAuthSwiftError(message: "Failed to create proxy URL") + } + + return url + } +} diff --git a/Sources/Plugins/GenericOAuth/Models/Payloads.swift b/Sources/Plugins/GenericOAuth/Models/Payloads.swift new file mode 100644 index 0000000..45e8665 --- /dev/null +++ b/Sources/Plugins/GenericOAuth/Models/Payloads.swift @@ -0,0 +1,72 @@ +import BetterAuth +import Foundation + +public struct GenericOAuthSignInRequest: Codable, Sendable { + public let providerId: String + public let callbackURL: String? + public let errorCallbackURL: String? + public let newUserCallbackURL: String? + public let disableRedirect: Bool? + public let scopes: [String]? + public let requestSignUp: Bool? + public let additionalData: [String: AnyCodable]? + + public init( + providerId: String, + callbackURL: String? = nil, + errorCallbackURL: String? = nil, + newUserCallbackURL: String? = nil, + disableRedirect: Bool? = nil, + scopes: [String]? = nil, + requestSignUp: Bool? = nil, + additionalData: [String: AnyCodable]? = nil + ) { + self.providerId = providerId + self.callbackURL = callbackURL + self.errorCallbackURL = errorCallbackURL + self.newUserCallbackURL = newUserCallbackURL + self.disableRedirect = disableRedirect + self.scopes = scopes + self.requestSignUp = requestSignUp + self.additionalData = additionalData + } +} + +public struct GenericOAuthSignInResponse: Codable, Sendable { + public let url: String? + public let redirect: Bool + + public init(url: String?, redirect: Bool) { + self.url = url + self.redirect = redirect + } +} + +public struct GenericOAuthLinkRequest: Codable, Sendable { + public let providerId: String + public let callbackURL: String + public let scopes: [String]? + public let errorCallbackURL: String? + + public init( + providerId: String, + callbackURL: String, + scopes: [String]? = nil, + errorCallbackURL: String? = nil + ) { + self.providerId = providerId + self.callbackURL = callbackURL + self.scopes = scopes + self.errorCallbackURL = errorCallbackURL + } +} + +public struct GenericOAuthLinkResponse: Codable, Sendable { + public let url: String + public let redirect: Bool + + public init(url: String, redirect: Bool) { + self.url = url + self.redirect = redirect + } +} diff --git a/Sources/Plugins/GenericOAuth/Network/Route.swift b/Sources/Plugins/GenericOAuth/Network/Route.swift new file mode 100644 index 0000000..4c021b0 --- /dev/null +++ b/Sources/Plugins/GenericOAuth/Network/Route.swift @@ -0,0 +1,23 @@ +import BetterAuth +import Foundation + +public enum BetterAuthGenericOAuthRoute: AuthRoutable { + case signInOAuth2 + case oauth2Link + + public var path: String { + switch self { + case .signInOAuth2: + return "/sign-in/oauth2" + case .oauth2Link: + return "/oauth2/link" + } + } + + public var method: String { + switch self { + case .signInOAuth2, .oauth2Link: + return "POST" + } + } +}