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

package app.tauri.oauth_session

import android.app.Activity
import android.content.ActivityNotFoundException
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin

@InvokeArg
class AuthenticateArgs {
    lateinit var authUrl: String
    lateinit var callbackScheme: String

    // Honored on iOS via ASWebAuthenticationSession; ignored on Android because
    // Custom Tabs always shares cookies with the user's default browser. The
    // field is kept in the args schema so the JS payload remains identical
    // across platforms.
    var prefersEphemeralSession: Boolean? = null
}

@TauriPlugin
class OAuthSessionPlugin(private val activity: Activity) : Plugin(activity) {

    // The first onResume after launchUrl() fires while the host activity is
    // still in the foreground (Custom Tabs is a separate task that hasn't yet
    // taken over). Skip exactly that one resume; the next one without a
    // redirect having arrived is the real cancel signal.
    private var skipNextResume = false

    override fun onResume() {
        super.onResume()
        synchronized(stateLock) {
            val invoke = pendingInvoke ?: return
            if (skipNextResume) {
                skipNextResume = false
                return
            }
            // Custom Tabs was dismissed without delivering a redirect.
            pendingInvoke = null
            invoke.reject(
                "user canceled the authentication session",
                ERROR_USER_CANCELED,
            )
        }
    }

    @Command
    fun authenticate(invoke: Invoke) {
        val args = try {
            invoke.parseArgs(AuthenticateArgs::class.java)
        } catch (ex: Exception) {
            invoke.reject(
                "failed to parse authenticate arguments",
                ERROR_SESSION_FAILED,
                ex,
            )
            return
        }

        val url = try {
            args.authUrl.toUri()
        } catch (_: Exception) {
            invoke.reject("invalid auth URL", ERROR_INVALID_AUTH_URL)
            return
        }
        if (url.scheme.isNullOrEmpty()) {
            invoke.reject("invalid auth URL", ERROR_INVALID_AUTH_URL)
            return
        }

        val scheme = args.callbackScheme
        if (scheme.isEmpty() || scheme.contains(':') || scheme.contains('/')) {
            invoke.reject("invalid callback scheme", ERROR_INVALID_CALLBACK_SCHEME)
            return
        }

        activity.runOnUiThread {
            synchronized(stateLock) {
                if (pendingInvoke != null) {
                    invoke.reject(
                        "an authentication session is already in progress",
                        ERROR_SESSION_BUSY,
                    )
                    return@runOnUiThread
                }
                pendingInvoke = invoke
                skipNextResume = true
            }

            val tabs = CustomTabsIntent.Builder().build()
            try {
                tabs.launchUrl(activity, url)
            } catch (ex: ActivityNotFoundException) {
                synchronized(stateLock) {
                    pendingInvoke = null
                    skipNextResume = false
                }
                invoke.reject(
                    "no Custom Tabs-capable browser is available",
                    ERROR_PRESENTATION_FAILED,
                    ex,
                )
            } catch (ex: Exception) {
                synchronized(stateLock) {
                    pendingInvoke = null
                    skipNextResume = false
                }
                invoke.reject(ex.message, ERROR_SESSION_FAILED, ex)
            }
        }
    }

    companion object {
        const val ERROR_USER_CANCELED = "USER_CANCELED"
        const val ERROR_PRESENTATION_FAILED = "PRESENTATION_FAILED"
        const val ERROR_SESSION_FAILED = "SESSION_FAILED"
        const val ERROR_SESSION_BUSY = "SESSION_BUSY"
        const val ERROR_INVALID_AUTH_URL = "INVALID_AUTH_URL"
        const val ERROR_INVALID_CALLBACK_SCHEME = "INVALID_CALLBACK_SCHEME"

        // RedirectActivity has no handle to the active Plugin instance, so it
        // routes the captured URI through these statics. The plugin is a
        // process-wide singleton in practice (one Tauri webview per app), so
        // a single slot is sufficient.
        private val stateLock = Any()
        private var pendingInvoke: Invoke? = null

        /// Called by RedirectActivity once it captures the redirect intent.
        fun deliverRedirect(uri: Uri) {
            val invoke = synchronized(stateLock) {
                pendingInvoke.also { pendingInvoke = null }
            } ?: return

            val response = JSObject().apply { put("url", uri.toString()) }
            invoke.resolve(response)
        }

        /// Called by RedirectActivity when an intent without data is received.
        fun deliverIntentParseFailure() {
            val invoke = synchronized(stateLock) {
                pendingInvoke.also { pendingInvoke = null }
            } ?: return

            invoke.reject(
                "the authentication session returned no callback URL",
                ERROR_SESSION_FAILED,
            )
        }
    }
}