Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Example/BetterAuthSwiftExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import BetterAuthPasskey
import BetterAuthPhoneNumber
import BetterAuthTwoFactor
import BetterAuthUsername
import BetterAuthGenericOAuth
import SwiftUI

extension String {
Expand All @@ -32,6 +33,7 @@ enum Screen: String, Hashable, Identifiable, CaseIterable {
case magicLink
case emailOTP
case passkeyView
case genericOAuthView

var id: String { rawValue }

Expand All @@ -51,6 +53,8 @@ enum Screen: String, Hashable, Identifiable, CaseIterable {
"Email OTP"
case .passkeyView:
"Passkey"
case .genericOAuthView:
"Generic OAuth"
}
}
}
Expand All @@ -62,6 +66,7 @@ struct ContentView: View {
plugins: [
TwoFactorPlugin(), UsernamePlugin(), PhoneNumberPlugin(),
MagicLinkPlugin(), EmailOTPPlugin(), PasskeyPlugin(),
GenericOAuthPlugin(),
],
)
@State private var path: [Screen] = []
Expand Down Expand Up @@ -135,6 +140,8 @@ struct ContentView: View {
EmailOTPView()
case .passkeyView:
PasskeyView()
case .genericOAuthView:
GenericOAuthView()
}
}
}
156 changes: 156 additions & 0 deletions Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 8 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"]
)
]
)
)
10 changes: 5 additions & 5 deletions Sources/Core/Session/OAuthHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Error>) -> 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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -101,4 +101,4 @@ extension OAuthHandler: ASWebAuthenticationPresentationContextProviding {
#endif
}
}
#endif
#endif
64 changes: 64 additions & 0 deletions Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
67 changes: 67 additions & 0 deletions Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift
Original file line number Diff line number Diff line change
@@ -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
)
]
Comment on lines +49 to +59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential path construction issue with trailing slashes.

Using path.append() may not correctly handle cases where client.baseUrl.path or expoAuthorizationProxy.path contain leading/trailing slashes, potentially resulting in malformed URLs (e.g., double slashes or missing separators).

Apply this diff for more robust path handling:

     var components = URLComponents(
       url: client.baseUrl,
       resolvingAgainstBaseURL: false
     )
-    components?.path.append(BetterAuthRoute.expoAuthorizationProxy.path)
+    if let path = components?.path {
+      let separator = path.hasSuffix("/") ? "" : "/"
+      let routePath = BetterAuthRoute.expoAuthorizationProxy.path.hasPrefix("/") 
+        ? String(BetterAuthRoute.expoAuthorizationProxy.path.dropFirst())
+        : BetterAuthRoute.expoAuthorizationProxy.path
+      components?.path = path + separator + routePath
+    }
     components?.queryItems = [

Alternatively, consider using URL(fileURLWithPath:relativeTo:) or appendingPathComponent() if baseUrl supports it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var components = URLComponents(
url: client.baseUrl,
resolvingAgainstBaseURL: false
)
components?.path.append(BetterAuthRoute.expoAuthorizationProxy.path)
components?.queryItems = [
URLQueryItem(
name: "authorizationURL",
value: authorizationURL
)
]
var components = URLComponents(
url: client.baseUrl,
resolvingAgainstBaseURL: false
)
if let path = components?.path {
let separator = path.hasSuffix("/") ? "" : "/"
let routePath = BetterAuthRoute.expoAuthorizationProxy.path.hasPrefix("/")
? String(BetterAuthRoute.expoAuthorizationProxy.path.dropFirst())
: BetterAuthRoute.expoAuthorizationProxy.path
components?.path = path + separator + routePath
}
components?.queryItems = [
URLQueryItem(
name: "authorizationURL",
value: authorizationURL
)
]
🤖 Prompt for AI Agents
In Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift around lines 49 to 59,
the current use of components?.path.append(...) can produce malformed paths when
client.baseUrl.path or BetterAuthRoute.expoAuthorizationProxy.path contain
leading/trailing slashes; replace the append with a robust join: construct the
final path by normalizing both segments (trim trailing slash from base path and
leading slash from the route) and then concatenate with a single "/" between
them, or better, build the URL via client.baseUrl.appendingPathComponent(...) or
URL(fileURLWithPath:relativeTo:) so Apple's path-handling API manages
separators; ensure components?.path is set to the resulting normalized path and
not appended naively.


guard let url = components?.url else {
throw BetterAuthSwiftError(message: "Failed to create proxy URL")
}

return url
}
}
Loading