blob: 6dd416216623071b9efd2cc58155ac10622c2857 [file] [log] [blame]
/*
* 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
* private var authClient: RemoteAuthClient
*
* override public fun onCreate(b: Bundle) {
* super.onCreate(b);
* authClient = RemoteAuthClient.create(this);
* ...
* }
*
* 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.sendAuthorizationRequest(
* OAuthRequest.Builder(this.applicationContext.packageName)
* .setAuthProviderUrl(Uri.parse("https://...."))
* .setCodeChallenge(CodeChallenge(codeVerifier))
* .build(),
* 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(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(@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 [WEAR_REDIRECT_URL_PREFIX]
*
* @Throws RuntimeException if the service has error to open the request
*/
@UiThread
@SuppressLint("ExecutorRegistration")
public fun sendAuthorizationRequest(request: OAuthRequest, clientCallback: Callback) {
require(packageName == request.getPackageName()) {
"The request's package name is different from the auth client's package name."
}
if (connectionState == STATE_DISCONNECTED) {
connect()
}
whenConnected(
Runnable {
val callback = RequestCallback(request, clientCallback)
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
) : 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.getErrorCode()
uiThreadExecutor.execute(
Runnable {
removePendingCallback(this@RequestCallback)
if (error == NO_ERROR) {
clientCallback.onAuthorizationResponse(request, response)
} else {
clientCallback.onAuthorizationError(response.getErrorCode())
}
}
)
}
}
/** 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
}
}
}