tauri-plugin-oauth-session 0.1.0

Tauri plugin that drives OAuth flows through ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android.
Documentation
// SPDX-License-Identifier: Apache-2.0

import AuthenticationServices
import Tauri
import UIKit

struct AuthenticateOptions: Decodable {
    let authUrl: String
    let callbackScheme: String
    var prefersEphemeralSession: Bool?
}

class OAuthSessionPlugin: Plugin, ASWebAuthenticationPresentationContextProviding {
    /// ASWebAuthenticationSession is deallocated mid-flight if the caller
    /// doesn't retain it, which silently breaks the flow. Hold the active
    /// session for the duration of the auth attempt.
    private var activeSession: ASWebAuthenticationSession?

    @objc public func authenticate(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(AuthenticateOptions.self)

        guard let url = URL(string: args.authUrl) else {
            invoke.reject("invalid auth URL", code: "INVALID_AUTH_URL")
            return
        }

        let scheme = args.callbackScheme
        guard !scheme.isEmpty, !scheme.contains(":"), !scheme.contains("/") else {
            invoke.reject("invalid callback scheme", code: "INVALID_CALLBACK_SCHEME")
            return
        }

        let prefersEphemeral = args.prefersEphemeralSession ?? true

        DispatchQueue.main.async { [weak self] in
            guard let self = self else {
                invoke.reject("plugin deallocated", code: "SESSION_FAILED")
                return
            }

            // Refuse to start a new session while one is already running:
            // the system would silently dismiss the old one and the original
            // caller would never resolve.
            if self.activeSession != nil {
                invoke.reject("an authentication session is already in progress", code: "SESSION_BUSY")
                return
            }

            let session = ASWebAuthenticationSession(
                url: url,
                callbackURLScheme: scheme
            ) { [weak self] callbackURL, error in
                self?.activeSession = nil

                if let error = error {
                    let nsError = error as NSError
                    if nsError.domain == ASWebAuthenticationSessionErrorDomain,
                       let code = ASWebAuthenticationSessionError.Code(rawValue: nsError.code) {
                        switch code {
                        case .canceledLogin:
                            invoke.reject("user canceled the authentication session", code: "USER_CANCELED")
                            return
                        case .presentationContextNotProvided, .presentationContextInvalid:
                            invoke.reject(
                                "the platform could not present the authentication session",
                                code: "PRESENTATION_FAILED"
                            )
                            return
                        @unknown default:
                            break
                        }
                    }
                    invoke.reject(error.localizedDescription, code: "SESSION_FAILED")
                    return
                }

                guard let callbackURL = callbackURL else {
                    invoke.reject("the authentication session returned no callback URL", code: "SESSION_FAILED")
                    return
                }

                invoke.resolve(["url": callbackURL.absoluteString])
            }

            session.prefersEphemeralWebBrowserSession = prefersEphemeral
            session.presentationContextProvider = self
            self.activeSession = session

            if !session.start() {
                self.activeSession = nil
                invoke.reject(
                    "the platform could not present the authentication session",
                    code: "PRESENTATION_FAILED"
                )
            }
        }
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        // Anchor the sheet to the active webview's window so it lays out
        // correctly under split view / iPad multitasking.
        if let window = self.manager.viewController?.view.window {
            return window
        }
        return UIApplication.shared
            .connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.windows.first(where: \.isKeyWindow) }
            .first ?? ASPresentationAnchor()
    }
}

@_cdecl("init_plugin_oauth_session")
func initPlugin() -> Plugin {
    return OAuthSessionPlugin()
}