Skip to content

Conversation

@newtondotcom
Copy link

@newtondotcom newtondotcom commented Nov 9, 2025

Summary by CodeRabbit

  • New Features
    • Added Generic OAuth support, including a complete OAuth2 sign-in flow and provider configuration.
    • New UI for signing in/out, provider input, progress and error states, plus a watchOS fallback.
    • Introduced a Generic OAuth plugin and public client APIs to initiate/link OAuth flows.
    • Added request/response models and routing for the Generic OAuth endpoints.

@coderabbitai
Copy link

coderabbitai bot commented Nov 9, 2025

Walkthrough

Adds a Generic OAuth plugin and client APIs, new DTOs and network routes for OAuth2 flows, exposes OAuthHandler methods, registers a new package library target, and provides a SwiftUI example view wired into the app routing to demonstrate OAuth2 sign-in and logout.

Changes

Cohort / File(s) Summary
Plugin Core & Client API
Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift, Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift
New GenericOAuth plugin and factory; adds async oauth2(with:) sign-in flow on BetterAuthClient.SignIn, genericOAuth client accessor, proxy URL helper, network requests, OAuthHandler integration, and session cookie storage.
Models & Routes
Sources/Plugins/GenericOAuth/Models/Payloads.swift, Sources/Plugins/GenericOAuth/Network/Route.swift
Adds request/response DTOs: GenericOAuthSignInRequest/Response, GenericOAuthLinkRequest/Response; introduces BetterAuthGenericOAuthRoute enum with signInOAuth2 and oauth2Link POST routes.
Core Visibility Change
Sources/Core/Session/OAuthHandler.swift
Lowers access restrictions: OAuthHandler class and methods extractScheme(from:), authenticate(authURL:callbackURLScheme:), and presentationAnchor(for:) made publicly accessible (package/public). No logic changes.
Example App Integration
Example/BetterAuthSwiftExample/ContentView.swift, Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift
Adds GenericOAuthView SwiftUI view and wires new genericOAuthView screen into app routing; UI for providerId input, sign-in, logout, progress state, and error display.
Package Manifest
Package.swift
Adds BetterAuthGenericOAuth library product and target pointing to Sources/Plugins/GenericOAuth, with dependency on BetterAuth.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant GenericOAuthView
    participant BetterAuthClient
    participant GenericOAuthPlugin
    participant OAuthHandler
    participant Server
    participant ASWebAuth as ASWebAuthenticationSession

    User->>GenericOAuthView: enters providerId & taps Sign In
    GenericOAuthView->>BetterAuthClient: signIn.genericOAuth.oauth2(request)
    BetterAuthClient->>GenericOAuthPlugin: perform sign-in request
    GenericOAuthPlugin->>Server: POST /sign-in/oauth2 (providerId)
    Server-->>GenericOAuthPlugin: { url?, redirect }

    alt redirect == true
      GenericOAuthPlugin->>GenericOAuthPlugin: makeAuthorizationProxyURL(authURL)
      GenericOAuthPlugin->>OAuthHandler: authenticate(authURL, callbackScheme)
      OAuthHandler->>ASWebAuth: start web auth session
      ASWebAuth->>User: show web consent
      User-->>ASWebAuth: authorize & redirect
      ASWebAuth-->>OAuthHandler: return callback URL
      OAuthHandler-->>GenericOAuthPlugin: deliver auth result
    end

    GenericOAuthPlugin->>BetterAuthClient: store session cookie
    BetterAuthClient-->>GenericOAuthView: return signed-in user/session
    GenericOAuthView->>User: display profile or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Review hotspots:
    • BetterAuthGenericOAuth.swift: async flow, response handling, cookie storage.
    • GenericOAuthPlugin.swift: URL proxy construction, client lifecycle checks, thrown errors.
    • OAuthHandler.swift: public exposure of authentication APIs and presentation anchor.
    • GenericOAuthView.swift: SwiftUI async state, task/cancellation, and platform (#if) branches.
    • Package.swift: ensure target/product wiring matches source layout.

Poem

🐰 I hopped in code with whiskers bright,

A new OAuth path to chase the light,
Plugins stitched and routes aligned,
Sessions kept and errors mined,
Hooray — sign in, and rabbit dance tonight! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: generic oauth plugin' accurately describes the main change: adding a complete Generic OAuth plugin integration to the BetterAuth framework with plugin factory, sign-in flow, models, and example UI components.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Core/Session/OAuthHandler.swift (1)

39-70: Handle failed ASWebAuthenticationSession.start() to avoid hanging awaits.

ASWebAuthenticationSession.start() returns false when the session can’t be presented (for example, restrictions, misconfigured scheme, or missing presentation anchor). In that case our continuation never resumes, so authenticate suspends forever and the new public API surfaces a hang to callers. Please guard the start() result and resume with an error when it’s false.(learn.microsoft.com)

Suggested fix:

       webAuthSession?.presentationContextProvider = self
       webAuthSession?.prefersEphemeralWebBrowserSession = false
-      webAuthSession?.start()
+      guard webAuthSession?.start() == true else {
+        webAuthSession = nil
+        completion = nil
+        continuation.resume(
+          throwing: BetterAuthSwiftError(message: "Failed to start web auth session")
+        )
+        return
+      }
🧹 Nitpick comments (4)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (2)

50-60: Consider handling signOut errors.

The try? operator silently suppresses sign-out errors, leaving users unaware if logout fails. Consider capturing and displaying the error.

Apply this diff:

                    Button {
-                       Task { try? await client.signOut() }
+                       Task {
+                           do {
+                               try await client.signOut()
+                           } catch {
+                               errorMessage = "Failed to sign out: \(error.localizedDescription)"
+                           }
+                       }
                    } label: {

137-142: Consider making callback URL configurable.

The callback URL is hardcoded. While acceptable for example code, consider extracting it to a configuration constant or environment variable for better maintainability.

Example:

private let callbackURL = "betterauthswiftexample://"

// Then in the function:
_ = try await client.signIn.oauth2(
    with: .init(
        providerId: providerId,
        callbackURL: callbackURL
    )
)
Sources/Plugins/GenericOAuth/Models/Payloads.swift (1)

4-33: Add documentation for public API.

The GenericOAuthSignInRequest struct is part of the public API but lacks documentation. Add doc comments explaining the purpose of each property, especially the conditional requirements (e.g., callbackURL is required when redirect is enabled).

Example:

/// Request payload for Generic OAuth2 sign-in.
public struct GenericOAuthSignInRequest: Codable, Sendable {
  /// The OAuth provider identifier (e.g., "google", "github").
  public let providerId: String
  
  /// Callback URL for OAuth redirect. Required when redirect flow is used.
  public let callbackURL: String?
  
  /// URL to redirect to on authentication error.
  public let errorCallbackURL: String?
  
  // ... document remaining properties
}
Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift (1)

26-42: Enhance API documentation.

The method includes basic error documentation but lacks details about the parameters, return value structure, and usage example.

Apply this diff to improve documentation:

-  /// Make a request to **/oauth2/link**.
-  /// - Parameter body: ``GenericOAuthLinkRequest``
-  /// - Returns: ``GenericOAuthLink``
-  /// - Throws: ``/BetterAuth/BetterAuthApiError`` - ``/BetterAuth/BetterAuthSwiftError``
+  /// Link an OAuth2 provider to the current user's account.
+  ///
+  /// This method initiates the OAuth2 account linking flow by making a request to the
+  /// **/oauth2/link** endpoint.
+  ///
+  /// - Parameter body: The link request containing provider ID, callback URL, and optional scopes.
+  /// - Returns: An `APIResource` containing the OAuth URL and redirect flag.
+  /// - Throws: 
+  ///   - `BetterAuthApiError` if the API returns an error response
+  ///   - `BetterAuthSwiftError` if the client has been deallocated
   public func link(with body: GenericOAuthLinkRequest) async throws
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ff90e64 and 46cbf8d.

📒 Files selected for processing (8)
  • Example/BetterAuthSwiftExample/ContentView.swift (5 hunks)
  • Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (1 hunks)
  • Package.swift (2 hunks)
  • Sources/Core/Session/OAuthHandler.swift (4 hunks)
  • Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift (1 hunks)
  • Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift (1 hunks)
  • Sources/Plugins/GenericOAuth/Models/Payloads.swift (1 hunks)
  • Sources/Plugins/GenericOAuth/Network/Route.swift (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift (3)
Sources/Core/Plugins/Events.swift (1)
  • emittingSignal (46-55)
Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift (1)
  • makeAuthorizationProxyURL (44-66)
Sources/Core/Session/OAuthHandler.swift (1)
  • extractScheme (10-22)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (3)
Sources/Core/BetterAuth.swift (1)
  • signOut (78-85)
Example/BetterAuthSwiftExample/Plugins/PasskeyView.swift (1)
  • signIn (130-142)
Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift (1)
  • oauth2 (14-56)
🔇 Additional comments (5)
Sources/Plugins/GenericOAuth/Models/Payloads.swift (2)

35-43: Verify the intentional difference in URL optionality.

The url property is optional in GenericOAuthSignInResponse but required in GenericOAuthLinkResponse. Confirm this difference is intentional and reflects the actual API contract, as it could indicate an inconsistency in the backend responses.

If the responses should be consistent, consider making both optional or both required based on the API specification.


45-62: Good API design for required callback.

The GenericOAuthLinkRequest correctly makes callbackURL required (non-optional) while GenericOAuthSignInRequest has it optional. This distinction makes sense as account linking always requires a callback, whereas sign-in may support non-redirect flows.

Sources/Plugins/GenericOAuth/GenericOAuthPlugin.swift (2)

4-12: LGTM: Clean plugin factory implementation.

The plugin factory follows the standard pattern with proper public access control and initialization.


14-14: @mainactor usage is correct and follows the established architectural pattern.

The @MainActor annotation on the GenericOAuth class is required—the Pluggable protocol itself is marked @MainActor, and all plugin implementations in the codebase consistently use this annotation (Username, TwoFactor, PhoneNumber, EmailOTP, Passkey, MagicLink, Anonymous). This is an intentional architectural pattern applied throughout the SDK (core classes like BetterAuthClient, SessionStore, and OAuthHandler also use it). Async network calls execute off the main thread; only the coordination layer runs on the main thread for thread safety and consistency.

Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (1)

40-43: No action required. The User model defines both email and name as non-optional String properties, making the access pattern in lines 40–43 safe from nil-related crashes.

Likely an incorrect or invalid review comment.

import BetterAuth
import BetterAuthGenericOAuth

struct genericOAuthView: View {
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

Fix type naming convention.

The struct name genericOAuthView violates Swift naming conventions. Type names should use PascalCase.

Apply this diff:

-struct genericOAuthView: View {
+struct GenericOAuthView: View {

Note: The filename should also be updated from genericOAuthView.swift to GenericOAuthView.swift to match.

📝 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
struct genericOAuthView: View {
struct GenericOAuthView: View {
🤖 Prompt for AI Agents
In Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift around line 12,
the struct name `genericOAuthView` violates Swift PascalCase type naming; rename
the struct to `GenericOAuthView` and update all references/usages accordingly,
and also rename the source file from genericOAuthView.swift to
GenericOAuthView.swift to match the new type name.

Comment on lines +49 to +59
var components = URLComponents(
url: client.baseUrl,
resolvingAgainstBaseURL: false
)
components?.path.append(BetterAuthRoute.expoAuthorizationProxy.path)
components?.queryItems = [
URLQueryItem(
name: "authorizationURL",
value: authorizationURL
)
]
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.

Copy link
Owner

@ouwargui ouwargui left a comment

Choose a reason for hiding this comment

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

Hey, great job. Specially considering that I haven't setup any contribution guide yet.

I left a couple comments and I still need to manually test it before approving it, but lgtm so far.

@ouwargui ouwargui self-assigned this Nov 9, 2025
newtondotcom and others added 2 commits November 10, 2025 17:20
Co-authored-by: Guilherme D'Alessandro <guikemon2@gmail.com>
Co-authored-by: Guilherme D'Alessandro <guikemon2@gmail.com>
@newtondotcom
Copy link
Author

Hey, thanks so much! I needed it, so I didn’t want to wait :)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Core/Session/OAuthHandler.swift (1)

24-71: Change authenticate method from public to package func for architectural consistency.

The method is called from both Sources/Core/BetterAuth.swift and Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift, both within the same Swift package structure. Since OAuthHandler is marked package class, the authenticate method should also use package func rather than public func to maintain consistent visibility boundaries. This aligns with the architecture where plugins access Core functionality through the same-package package visibility level.

♻️ Duplicate comments (1)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (1)

2-2: Update filename comment to match Swift naming conventions.

The comment still references genericOAuthView.swift, but the struct has been correctly renamed to GenericOAuthView. Update the filename comment to match the expected filename GenericOAuthView.swift.

Apply this diff:

-//  genericOAuthView.swift
+//  GenericOAuthView.swift
🧹 Nitpick comments (3)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (3)

25-25: Consider making the nil-coalescing more explicit.

The expression URL(string: user.image ?? "") works but relies on URL(string: "") returning nil. For clarity, consider using optional chaining directly on user.image.

Apply this diff:

-                    AsyncImage(url: URL(string: user.image ?? "")) { image in
+                    AsyncImage(url: user.image.flatMap { URL(string: $0) }) { image in

50-60: Consider adding error feedback for sign-out failures.

The try? operator silently ignores sign-out errors, which could leave users confused if the operation fails. Consider capturing and displaying errors similar to the sign-in flow.

Apply this diff:

                     Button {
-                        Task { try? await client.signOut() }
+                        Task {
+                            do {
+                                try await client.signOut()
+                            } catch {
+                                errorMessage = "Failed to sign out: \(error.localizedDescription)"
+                            }
+                        }
                     } label: {

132-148: Consider extracting the hardcoded callback URL.

The callback URL "betterauthswiftexample://" is hardcoded in the sign-in logic. For better maintainability, consider extracting this as a constant at the file or struct level, especially if it needs to be kept in sync with URL scheme configurations.

Apply this diff:

 struct GenericOAuthView: View {
     @EnvironmentObject var client: BetterAuthClient
     
+    private let callbackURL = "betterauthswiftexample://"
     @State private var providerId = ""
     @State private var isSigningIn = false

Then update the function:

                 _ = try await client.signIn.oauth2(
                     with: .init(
                         providerId: providerId,
-                        callbackURL: "betterauthswiftexample://"
+                        callbackURL: callbackURL
                     )
                 )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 46cbf8d and 0543ef6.

📒 Files selected for processing (2)
  • Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (1 hunks)
  • Sources/Core/Session/OAuthHandler.swift (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (2)
Sources/Core/BetterAuth.swift (1)
  • signOut (78-85)
Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift (1)
  • oauth2 (14-56)
🔇 Additional comments (5)
Example/BetterAuthSwiftExample/Plugins/GenericOAuthView.swift (2)

85-109: LGTM!

The OAuth2 form implementation is well-structured with appropriate TextField configuration (disabled autocapitalization and autocorrection for provider ID), correct button disabling logic, and clear error messaging.


19-155: LGTM!

The conditional compilation for platform-specific behavior is well-implemented, providing a clear unsupported message for watchOS while maintaining full functionality on other platforms.

Sources/Core/Session/OAuthHandler.swift (3)

6-6: Past review feedback applied; consider access level consistency.

The package access level has been applied as suggested in the previous review. However, ensure that package is the intended visibility level given that several methods within this class are marked public (lines 10, 24, 89), which creates an access level mismatch. If external modules need to use this class, consider making it public to match the method visibility.

Based on learnings.


89-102: Protocol conformance may require public visibility.

The presentationAnchor method implements the ASWebAuthenticationPresentationContextProviding protocol requirement. Since the protocol is public (from Apple's framework), the implementing method may need to remain public even if the class is package-visible. However, verify this is the intended behavior.


10-22: ****

The review comment's concern is based on a misunderstanding of Swift's package access level. The OAuthHandler class is marked package, and in Swift 5.9+, the package modifier grants access to all targets within the same Swift package.

The Package.swift file confirms that both call sites—Sources/Core/BetterAuth.swift and Sources/Plugins/GenericOAuth/BetterAuthGenericOAuth.swift—belong to different targets (BetterAuth and BetterAuthGenericOAuth) within the same BetterAuthSwift package. Since package is package-scoped (not target-scoped), the package class OAuthHandler is accessible from both targets. The public func extractScheme is the correct access level for a method intended to be part of the class's public API within the package.

There is no access level inconsistency—the current implementation is correct.

Likely an incorrect or invalid review comment.

@ouwargui
Copy link
Owner

@newtondotcom hey, sorry for taking too long to review - i haven't had time to test this yet, could you provide a video of the feature working?

in this video it would be nice to go through:

  • the code you setup
    • client and server
  • quick demo of the feature working

let me know if you can do it, otherwise it'll take a little longer for me to be able to test it and approve

@newtondotcom
Copy link
Author

hey @ouwargui I have been kind of busy lately, I will when I have time !

@will-lp1
Copy link

will-lp1 commented Jan 2, 2026

would love to try this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants