RemoteAuthClient.kt

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.phone.interactions.authentication

import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.support.wearable.authentication.IAuthenticationRequestCallback
import android.support.wearable.authentication.IAuthenticationRequestService
import androidx.annotation.IntDef
import androidx.annotation.UiThread
import java.util.ArrayDeque
import java.util.Queue
import java.util.concurrent.Executor

/**
 * Provides a client for supporting remote authentication on Wear. The authentication session
 * will be opened on the user's paired phone.
 *
 * * The following example triggers an authorization session to open on the phone.
 * ```
 * // PKCE (Proof Key for Code Exchange) is required for the auth
 * private var codeVerifier: CodeVerifier
 * // Late initialization in place where it's used, or to be initialized in onCreate()
 * private var lateinit authClient: RemoteAuthClient
 *
 * override public fun onDestroy() {
 *   authClient.close();
 *   super.onDestroy();
 * }
 *
 * public fun startAuthFlow() {
 *    // PKCE (Proof Key for Code Exchange) is required, store this code verifier here .
 *    // To access the resource later, both the auth token ans code verifier are needed.
 *    codeVerifier = CodeVerifier()
 *
 *   // Construct your auth request.
 *   authClient = RemoteAuthClient.create(this);
 *   authClient.sendAuthorizationRequest(
 *      OAuthRequest.Builder(this.applicationContext.packageName)
 *          .setAuthProviderUrl(Uri.parse("https://...."))
 *          .setCodeChallenge(CodeChallenge(codeVerifier))
 *          .build(),
 *      Executors.newSingleThreadExecutor()
 *      new MyAuthCallback()
 *   );
 * }
 *
 * private  class MyAuthCallback: RemoteAuthClient.Callback {
 *   override public fun onAuthorizationResponse(
 *      request: OAuthRequest,
 *      response: OAuthResponse
 *    ) {
 *     // Parse the result token out of the response and store it, e.g. in SharedPreferences,
 *     // so you can use it later (Note, use together with code verifier from version R)
 *     // You'll also want to display a success UI.
 *     ...
 *   }
 *
 *   override public fun onAuthorizationError(request: OAuthRequest, errorCode: int) {
 *     // Compare against codes available in RemoteAuthClient.ErrorCode
 *     // You'll also want to display an error UI.
 *     ...
 *   }
 * }
 * ```
 */
public class RemoteAuthClient internal constructor(
    private val serviceBinder: ServiceBinder,
    private val uiThreadExecutor: Executor,
    private val packageName: String
) : AutoCloseable {
    public companion object {
        /**
         * The URL to be opened in a web browser on the companion.
         * Value type: Uri
         */
        internal const val KEY_REQUEST_URL: String = "requestUrl"

        /**
         * The package name obtained from calling getPackageName() on the context passed into
         * [create].
         * Value type: String
         */
        internal const val KEY_PACKAGE_NAME: String = "packageName"

        /**
         * The URL that the web browser is directed to that triggered the companion to open.
         * Value type: Uri
         */
        internal const val KEY_RESPONSE_URL: String = "responseUrl"

        /**
         * The error code explaining why the request failed.
         * Value type: [ErrorCode]
         */
        internal const val KEY_ERROR_CODE: String = "errorCode"

        /**
         * Package name for the service provider on Wearable.
         * Home app for Wear 2, and Wear Core Service for wear 3
         */
        internal const val WEARABLE_PACKAGE_NAME: String = "com.google.android.wearable.app"

        /**
         * Triggering a service that will prompt a user for authorization credential on the phone
         * For backwards compatibility, leave this action name as "OAUTH", so 3p app using this new
         * androidx class can still send request to the service in clockwork home with WSL.
         */
        internal const val ACTION_AUTH: String =
            "android.support.wearable.authentication.action.OAUTH"

        /** Indicates 3p authentication is finished without error  */
        public const val NO_ERROR: Int = -1

        /** Indicates 3p authentication isn't supported by Wear OS  */
        public const val ERROR_UNSUPPORTED: Int = 0

        /** Indicates no phone is connected, or the phone connected doesn't support 3p auth */
        public const val ERROR_PHONE_UNAVAILABLE: Int = 1

        /** Errors returned in [Callback.onAuthorizationError].  */
        @Retention(AnnotationRetention.SOURCE)
        @IntDef(NO_ERROR, ERROR_UNSUPPORTED, ERROR_PHONE_UNAVAILABLE)
        internal annotation class ErrorCode

        /** service connection status */
        private const val STATE_DISCONNECTED: Int = 0
        private const val STATE_CONNECTING: Int = 1
        private const val STATE_CONNECTED: Int = 2

        /** Return a client that can be used to make async remote authorization requests */
        @JvmStatic
        public fun create(context: Context): RemoteAuthClient {
            val appContext: Context = context.applicationContext
            return RemoteAuthClient(
                object : ServiceBinder {
                    override fun bindService(
                        intent: Intent?,
                        connection: ServiceConnection?,
                        flags: Int
                    ): Boolean {
                        return appContext.bindService(intent, connection!!, flags)
                    }

                    override fun unbindService(connection: ServiceConnection?) {
                        appContext.unbindService(connection!!)
                    }
                },
                { command -> Handler(appContext.mainLooper).post(command) },
                context.packageName
            )
        }
    }

    private var allocationSite: Throwable? =
        Throwable("Explicit termination method 'close' not called")
    private var connectionState: Int = STATE_DISCONNECTED
    private var service: IAuthenticationRequestService? = null
    private val outstandingRequests: MutableSet<RequestCallback> = HashSet()
    private val queuedRunnables: Queue<Runnable> = ArrayDeque()
    private val connection: ServiceConnection = RemoteAuthConnection()

    /**
     * This callback is notified when an async remote authentication request completes.
     *
     * Typically, your app should update its UI to let the user aware of the success or failure.
     */
    public abstract class Callback {

        /**
         * Called when an async remote authentication request completes successfully.
         *
         * see [sendAuthorizationRequest]
         */
        @UiThread
        public abstract fun onAuthorizationResponse(request: OAuthRequest, response: OAuthResponse)

        /**
         * Called when an async remote authentication request fails.
         *
         * see [sendAuthorizationRequest]
         */
        @UiThread
        public abstract fun onAuthorizationError(request: OAuthRequest, @ErrorCode errorCode: Int)
    }

    /**
     * Send a remote auth request. This will cause an authorization UI to be presented on
     * the user's phone.
     * This request is asynchronous; the callback provided will be be notified when the request
     * completes.
     *
     * @param request Request that will be sent to the phone. The auth response should redirect
     * to the Wear OS companion. See [OAuthRequest.WEAR_REDIRECT_URL_PREFIX]
     * @param executor The executor that callback will called on.
     * @param clientCallback The callback that will be notified when request is completed.
     *
     * @Throws RuntimeException if the service has error to open the request
     */
    @UiThread
    public fun sendAuthorizationRequest(
        request: OAuthRequest,
        executor: Executor,
        clientCallback: Callback
    ) {
        require(packageName == request.packageName) {
            "The request's package name is different from the auth client's package name."
        }

        if (connectionState == STATE_DISCONNECTED) {
            connect()
        }
        whenConnected {
            val callback = RequestCallback(request, clientCallback, executor)
            outstandingRequests.add(callback)
            try {
                service!!.openUrl(request.toBundle(), callback)
            } catch (e: Exception) {
                removePendingCallback(callback)
                throw RuntimeException(e)
            }
        }
    }

    /**
     * Check that the explicit termination method 'close' is called
     *
     *  @Throws RuntimeException if the 'close' method was not called
     */
    protected fun finalize() {
        if (allocationSite != null) {
            throw RuntimeException(
                "A RemoteAuthClient was acquired at the attached stack trace but never released" +
                    " Call RemoteAuthClient.close()"
            )
        }
    }

    /**
     * Frees any resources used by the client, dropping any outstanding requests. The client
     * cannot be used to make requests thereafter.
     */
    @UiThread
    override fun close() {
        allocationSite = null
        queuedRunnables.clear()
        outstandingRequests.clear()
        disconnect()
    }

    internal interface ServiceBinder {
        /** See [Context.bindService].  */
        fun bindService(intent: Intent?, connection: ServiceConnection?, flags: Int): Boolean

        /** See [Context.unbindService].  */
        fun unbindService(connection: ServiceConnection?)
    }

    /**
     * Runs the given runnable immediately if already connected, or queues it for later if a
     * connection has not yet been fully established.
     */
    private fun whenConnected(runnable: Runnable) {
        if (connectionState == STATE_CONNECTED) {
            runnable.run()
        } else {
            queuedRunnables.add(runnable)
        }
    }

    private fun removePendingCallback(requestCallback: RequestCallback) {
        outstandingRequests.remove(requestCallback)
        if (outstandingRequests.isEmpty() && service != null) {
            disconnect()
        }
    }

    private fun connect() {
        check(connectionState == STATE_DISCONNECTED) { "State is $connectionState" }
        val intent =
            Intent(ACTION_AUTH).setPackage(WEARABLE_PACKAGE_NAME)
        val success: Boolean =
            serviceBinder.bindService(intent, connection, Context.BIND_AUTO_CREATE)
        if (success) {
            connectionState = STATE_CONNECTING
        } else {
            throw RuntimeException("Failed to bind to Auth service")
        }
    }

    private fun disconnect() {
        if (connectionState != STATE_DISCONNECTED) {
            serviceBinder.unbindService(connection)
            service = null
            connectionState = STATE_DISCONNECTED
        }
    }

    /** Receives results of async requests to the remote auth service.  */
    internal inner class RequestCallback internal constructor(
        private val request: OAuthRequest,
        private val clientCallback: Callback,
        private val executor: Executor
    ) : IAuthenticationRequestCallback.Stub() {

        override fun getApiVersion(): Int = IAuthenticationRequestCallback.API_VERSION

        /**
         * Called when an aync remote authentication request is completed.
         *
         * Bundle contents:
         * <ul><li>"responseUrl": the response URL from the Auth request (Uri)
         * <ul><li>"error": an error code explaining why the request failed (int)
         */
        override fun onResult(result: Bundle) {
            val errorCode = result.getInt(KEY_ERROR_CODE, NO_ERROR)
            val responseUrl: Uri? = result.getParcelable(KEY_RESPONSE_URL)
            onResult(OAuthResponse(errorCode, responseUrl))
        }

        @SuppressLint("SyntheticAccessor")
        private fun onResult(response: OAuthResponse) {
            @ErrorCode val error = response.errorCode
            uiThreadExecutor.execute(
                Runnable {
                    removePendingCallback(this@RequestCallback)
                    if (error == NO_ERROR) {
                        executor.execute {
                            clientCallback.onAuthorizationResponse(request, response)
                        }
                    } else {
                        executor.execute {
                            clientCallback.onAuthorizationError(request, response.errorCode)
                        }
                    }
                }
            )
        }
    }

    /** Manages the connection with Wearable Auth service.  */
    private inner class RemoteAuthConnection : ServiceConnection {
        @UiThread
        override fun onServiceConnected(name: ComponentName, boundService: IBinder) {
            service = IAuthenticationRequestService.Stub.asInterface(boundService)
            connectionState = STATE_CONNECTED
            // Run all queued runnables
            while (!queuedRunnables.isEmpty()) {
                queuedRunnables.poll()!!.run()
            }
        }

        @UiThread
        override fun onServiceDisconnected(name: ComponentName) {
            service = null
        }
    }
}