From b18239c308d85548efc8afaee440e233e794e9a3 Mon Sep 17 00:00:00 2001 From: Augusto Pertence Date: Wed, 17 Feb 2021 11:56:47 -0300 Subject: [PATCH] Kotlin app sample --- appkotlin/AndroidManifest.xml | 62 +++ appkotlin/build.gradle | 65 +++ .../appauthdemokotlin/AuthStateManager.kt | 133 ++++++ .../openid/appauthdemokotlin/Configuration.kt | 219 +++++++++ .../openid/appauthdemokotlin/LoginActivity.kt | 438 ++++++++++++++++++ .../openid/appauthdemokotlin/TokenActivity.kt | 394 ++++++++++++++++ appkotlin/res/drawable/appauth_96dp.xml | 23 + appkotlin/res/drawable/unknown_user_48dp.xml | 16 + appkotlin/res/layout/activity_login.xml | 174 +++++++ appkotlin/res/layout/activity_token.xml | 195 ++++++++ appkotlin/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2720 bytes appkotlin/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2059 bytes appkotlin/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3374 bytes appkotlin/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 4580 bytes appkotlin/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5934 bytes appkotlin/res/raw/auth_config.json | 12 + appkotlin/res/values/colors.xml | 7 + appkotlin/res/values/dimens.xml | 7 + appkotlin/res/values/strings.xml | 31 ++ appkotlin/res/values/styles.xml | 7 + build.gradle | 1 + settings.gradle | 2 +- 22 files changed, 1785 insertions(+), 1 deletion(-) create mode 100644 appkotlin/AndroidManifest.xml create mode 100644 appkotlin/build.gradle create mode 100644 appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt create mode 100644 appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt create mode 100644 appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt create mode 100644 appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt create mode 100644 appkotlin/res/drawable/appauth_96dp.xml create mode 100644 appkotlin/res/drawable/unknown_user_48dp.xml create mode 100644 appkotlin/res/layout/activity_login.xml create mode 100644 appkotlin/res/layout/activity_token.xml create mode 100644 appkotlin/res/mipmap-hdpi/ic_launcher.png create mode 100644 appkotlin/res/mipmap-mdpi/ic_launcher.png create mode 100644 appkotlin/res/mipmap-xhdpi/ic_launcher.png create mode 100644 appkotlin/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 appkotlin/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 appkotlin/res/raw/auth_config.json create mode 100644 appkotlin/res/values/colors.xml create mode 100644 appkotlin/res/values/dimens.xml create mode 100644 appkotlin/res/values/strings.xml create mode 100644 appkotlin/res/values/styles.xml diff --git a/appkotlin/AndroidManifest.xml b/appkotlin/AndroidManifest.xml new file mode 100644 index 00000000..5197f241 --- /dev/null +++ b/appkotlin/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appkotlin/build.gradle b/appkotlin/build.gradle new file mode 100644 index 00000000..c7940836 --- /dev/null +++ b/appkotlin/build.gradle @@ -0,0 +1,65 @@ +apply plugin: 'com.android.application' +apply plugin: 'checkstyle' +apply plugin: 'kotlin-android' +apply from: '../config/android-common.gradle' +apply from: '../config/keystore.gradle' + +android { + defaultConfig { + applicationId 'net.openid.appauthdemokotlin' + project.archivesBaseName = 'appauth-demoapp-kotlin' + vectorDrawables.useSupportLibrary = true + + // Make sure this is consistent with the redirect URI used in res/raw/auth_config.json, + // or specify additional redirect URIs in AndroidManifest.xml + manifestPlaceholders = [ + 'appAuthRedirectScheme': 'net.openid.appauthdemo' + ] + } + + buildFeatures { + viewBinding true + } + + signingConfigs { + debugAndRelease { + storeFile file("${rootDir}/appauth.keystore") + storePassword "appauth" + keyAlias "appauth" + keyPassword "appauth" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildTypes { + debug { + signingConfig signingConfigs.debugAndRelease + } + release { + signingConfig signingConfigs.debugAndRelease + } + } +} + +dependencies { + implementation project(':library') + implementation "androidx.appcompat:appcompat:${project.androidXVersions.appcompat}" + implementation "androidx.annotation:annotation:${project.androidXVersions.annotation}" + implementation "com.google.android.material:material:${project.googleVersions.material}" + implementation "com.github.bumptech.glide:glide:${project.googleVersions.glide}" + implementation "com.squareup.okio:okio:${project.okioVersion}" + implementation "joda-time:joda-time:${project.jodaVersion}" + + annotationProcessor "com.github.bumptech.glide:compiler:${project.googleVersions.glide}" + implementation "androidx.core:core-ktx:+" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} + +apply from: '../config/style.gradle' +repositories { + mavenCentral() +} diff --git a/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt b/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt new file mode 100644 index 00000000..b48d4374 --- /dev/null +++ b/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * 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 net.openid.appauthdemokotlin + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.annotation.AnyThread +import org.json.JSONException +import net.openid.appauth.* +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantLock + +class AuthStateManager private constructor(context: Context) { + private val preferences: SharedPreferences = context.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE) + private val preferencesLock: ReentrantLock = ReentrantLock() + private val currentAuthState: AtomicReference = AtomicReference() + + @get:AnyThread + val current: AuthState + get() { + if (currentAuthState.get() != null) { + return currentAuthState.get() + } + val state = readState() + return if (currentAuthState.compareAndSet(null, state)) { + state + } else { + currentAuthState.get() + } + } + + @AnyThread + fun replace(state: AuthState): AuthState { + writeState(state) + currentAuthState.set(state) + return state + } + + @AnyThread + fun updateAfterAuthorization( + response: AuthorizationResponse?, + ex: AuthorizationException?): AuthState { + val current = current + current.update(response, ex) + return replace(current) + } + + @AnyThread + fun updateAfterTokenResponse( + response: TokenResponse?, + ex: AuthorizationException?): AuthState { + val current = current + current.update(response, ex) + return replace(current) + } + + @AnyThread + fun updateAfterRegistration( + response: RegistrationResponse?, + ex: AuthorizationException?): AuthState { + val current = current + if (ex != null) { + return current + } + current.update(response) + return replace(current) + } + + @AnyThread + private fun readState(): AuthState { + preferencesLock.lock() + return try { + val currentState = preferences.getString(KEY_STATE, null) + ?: return AuthState() + try { + AuthState.jsonDeserialize(currentState) + } catch (ex: JSONException) { + Log.w(TAG, "Failed to deserialize stored auth state - discarding") + AuthState() + } + } finally { + preferencesLock.unlock() + } + } + + @AnyThread + private fun writeState(state: AuthState?) { + preferencesLock.lock() + try { + val editor = preferences.edit() + if (state == null) { + editor.remove(KEY_STATE) + } else { + editor.putString(KEY_STATE, state.jsonSerializeString()) + } + check(editor.commit()) { "Failed to write state to shared prefs" } + } finally { + preferencesLock.unlock() + } + } + + companion object { + private val INSTANCE_REF = AtomicReference(WeakReference(null)) + private const val TAG = "AuthStateManager" + private const val STORE_NAME = "AuthState" + private const val KEY_STATE = "state" + + @AnyThread + fun getInstance(context: Context): AuthStateManager { + var manager = INSTANCE_REF.get().get() + if (manager == null) { + manager = AuthStateManager(context.applicationContext) + INSTANCE_REF.set(WeakReference(manager)) + } + return manager + } + } + +} diff --git a/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt b/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt new file mode 100644 index 00000000..a261d573 --- /dev/null +++ b/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * 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 net.openid.appauthdemokotlin + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Resources +import android.net.Uri +import android.text.TextUtils +import okio.Buffer +import okio.Okio +import net.openid.appauth.connectivity.ConnectionBuilder +import net.openid.appauth.connectivity.DefaultConnectionBuilder +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.lang.ref.WeakReference +import java.nio.charset.Charset + +/** + * Reads and validates the demo app configuration from `res/raw/auth_config.json`. Configuration + * changes are detected by comparing the hash of the last known configuration to the read + * configuration. When a configuration change is detected, the app state is reset. + */ +class Configuration(private val mContext: Context) { + private val preferences: SharedPreferences = mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val resources: Resources = mContext.resources + private var configJson: JSONObject? = null + private var configHash: String? = null + + /** + * Returns a description of the configuration error, if the configuration is invalid. + */ + var configurationError: String? = null + var clientId: String? = null + var scope: String? = null + var redirectUri: Uri? = null + var endSessionUri: Uri? = null + var discoveryUri: Uri? = null + var authEndpointUri: Uri? = null + var tokenEndpointUri: Uri? = null + var endSessionEndpoint: Uri? = null + var registrationEndpointUri: Uri? = null + var userInfoEndpointUri: Uri? = null + private var isHttpsRequired = false + + /** + * Indicates whether the configuration has changed from the last known valid state. + */ + fun hasConfigurationChanged(): Boolean { + val lastHash = lastKnownConfigHash + return configHash != lastHash + } + + /** + * Indicates whether the current configuration is valid. + */ + val isValid: Boolean + get() = configurationError == null + + /** + * Indicates that the current configuration should be accepted as the "last known valid" + * configuration. + */ + fun acceptConfiguration() { + preferences.edit().putString(KEY_LAST_HASH, configHash).apply() + } + + val connectionBuilder: ConnectionBuilder + get() = DefaultConnectionBuilder.INSTANCE + + private val lastKnownConfigHash: String? + get() = preferences.getString(KEY_LAST_HASH, null) + + @Throws(InvalidConfigurationException::class) + private fun readConfiguration() { + val configSource = Okio.buffer(Okio.source(resources.openRawResource(R.raw.auth_config))) + val configData = Buffer() + configJson = try { + configSource.readAll(configData) + JSONObject(configData.readString(Charset.forName("UTF-8"))) + } catch (ex: IOException) { + throw InvalidConfigurationException( + "Failed to read configuration: " + ex.message) + } catch (ex: JSONException) { + throw InvalidConfigurationException( + "Unable to parse configuration: " + ex.message) + } + configHash = configData.sha256().base64() + clientId = getConfigString("client_id") + scope = getRequiredConfigString("authorization_scope") + redirectUri = getRequiredConfigUri("redirect_uri") + endSessionUri = getRequiredConfigUri("end_session_uri") + if (!isRedirectUriRegistered) { + throw InvalidConfigurationException( + "redirect_uri is not handled by any activity in this app! " + + "Ensure that the appAuthRedirectScheme in your build.gradle file " + + "is correctly configured, or that an appropriate intent filter " + + "exists in your app manifest.") + } + if (getConfigString("discovery_uri") == null) { + authEndpointUri = getRequiredConfigWebUri("authorization_endpoint_uri") + tokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri") + userInfoEndpointUri = getRequiredConfigWebUri("user_info_endpoint_uri") + endSessionEndpoint = getRequiredConfigUri("end_session_endpoint") + if (clientId == null) { + registrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri") + } + } else { + discoveryUri = getRequiredConfigWebUri("discovery_uri") + } + isHttpsRequired = configJson!!.optBoolean("https_required", true) + } + + private fun getConfigString(propName: String?): String? { + var value = configJson!!.optString(propName) ?: return null + value = value.trim { it <= ' ' } + return if (TextUtils.isEmpty(value)) { + null + } else value + } + + @Throws(InvalidConfigurationException::class) + fun getRequiredConfigString(propName: String): String { + return getConfigString(propName) + ?: throw InvalidConfigurationException( + "$propName is required but not specified in the configuration") + } + + @Throws(InvalidConfigurationException::class) + fun getRequiredConfigUri(propName: String): Uri { + val uriStr = getRequiredConfigString(propName) + val uri: Uri = try { + Uri.parse(uriStr) + } catch (ex: Throwable) { + throw InvalidConfigurationException("$propName could not be parsed", ex) + } + if (!uri.isHierarchical || !uri.isAbsolute) { + throw InvalidConfigurationException( + "$propName must be hierarchical and absolute") + } + if (!TextUtils.isEmpty(uri.encodedUserInfo)) { + throw InvalidConfigurationException("$propName must not have user info") + } + if (!TextUtils.isEmpty(uri.encodedQuery)) { + throw InvalidConfigurationException("$propName must not have query parameters") + } + if (!TextUtils.isEmpty(uri.encodedFragment)) { + throw InvalidConfigurationException("$propName must not have a fragment") + } + return uri + } + + @Throws(InvalidConfigurationException::class) + fun getRequiredConfigWebUri(propName: String): Uri { + val uri = getRequiredConfigUri(propName) + val scheme = uri.scheme + if (TextUtils.isEmpty(scheme) || !("http" == scheme || "https" == scheme)) { + throw InvalidConfigurationException( + "$propName must have an http or https scheme") + } + return uri + } + + // ensure that the redirect URI declared in the configuration is handled by some activity + // in the app, by querying the package manager speculatively + private val isRedirectUriRegistered: Boolean + get() { + // ensure that the redirect URI declared in the configuration is handled by some activity + // in the app, by querying the package manager speculatively + val redirectIntent = Intent() + redirectIntent.setPackage(mContext.packageName) + redirectIntent.action = Intent.ACTION_VIEW + redirectIntent.addCategory(Intent.CATEGORY_BROWSABLE) + redirectIntent.data = redirectUri + return mContext.packageManager.queryIntentActivities(redirectIntent, 0).isNotEmpty() + } + + class InvalidConfigurationException : Exception { + internal constructor(reason: String?) : super(reason) + internal constructor(reason: String?, cause: Throwable?) : super(reason, cause) + } + + companion object { + //private const val TAG = "Configuration" + private const val PREFS_NAME = "config" + private const val KEY_LAST_HASH = "lastHash" + private var sInstance = WeakReference(null) + fun getInstance(context: Context): Configuration { + var config = sInstance.get() + if (config == null) { + config = Configuration(context) + sInstance = WeakReference(config) + } + return config + } + } + + init { + try { + readConfiguration() + } catch (ex: InvalidConfigurationException) { + configurationError = ex.message + } + } +} \ No newline at end of file diff --git a/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt b/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt new file mode 100644 index 00000000..ff17fbbb --- /dev/null +++ b/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt @@ -0,0 +1,438 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * 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 net.openid.appauthdemokotlin + +import android.app.PendingIntent +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import androidx.browser.customtabs.CustomTabsIntent +import com.google.android.material.snackbar.Snackbar +import net.openid.appauth.* +import net.openid.appauth.browser.AnyBrowserMatcher +import net.openid.appauth.browser.BrowserMatcher +import net.openid.appauthdemokotlin.databinding.ActivityLoginBinding +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Demonstrates the usage of the AppAuth to authorize a user with an OAuth2 / OpenID Connect + * provider. Based on the configuration provided in `res/raw/auth_config.json`, the code + * contained here will: + * + * - Retrieve an OpenID Connect discovery document for the provider, or use a local static + * configuration. + * - Utilize dynamic client registration, if no static client id is specified. + * - Initiate the authorization request using the built-in heuristics or a user-selected browser. + * + * _NOTE_: From a clean checkout of this project, the authorization service is not configured. + * Edit `res/values/auth_config.xml` to provide the required configuration properties. See the + * README.md in the app/ directory for configuration instructions, and the adjacent IDP-specific + * instructions. + */ +class LoginActivity : AppCompatActivity() { + private var authService: AuthorizationService? = null + private lateinit var authStateManager: AuthStateManager + private lateinit var configuration: Configuration + private val clientId = AtomicReference() + private val authRequest = AtomicReference() + private val authIntent = AtomicReference() + private var authIntentLatch = CountDownLatch(1) + private lateinit var executor: ExecutorService + private var usePendingIntents = false + private var browserMatcher: BrowserMatcher = AnyBrowserMatcher.INSTANCE + private lateinit var binding: ActivityLoginBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + executor = Executors.newSingleThreadExecutor() + authStateManager = AuthStateManager.getInstance(this) + configuration = Configuration.getInstance(this) + if (authStateManager.current.isAuthorized + && !configuration.hasConfigurationChanged() + ) { + Log.i(TAG, "User is already authenticated, proceeding to token activity") + startActivity(Intent(this, TokenActivity::class.java)) + finish() + return + } + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + setContentView(R.layout.activity_login) + binding.retry.setOnClickListener { executor.submit { initializeAppAuth() } } + binding.startAuth.setOnClickListener { startAuth() } + binding.loginHintValue.addTextChangedListener( + LoginHintChangeHandler() + ) + if (!configuration.isValid) { + displayError(configuration.configurationError, false) + return + } + if (configuration.hasConfigurationChanged()) { + // discard any existing authorization state due to the change of configuration + Log.i(TAG, "Configuration change detected, discarding old state") + authStateManager.replace(AuthState()) + configuration.acceptConfiguration() + } + if (intent.getBooleanExtra(EXTRA_FAILED, false)) { + Snackbar.make(binding.coordinator, "Authorization canceled", Snackbar.LENGTH_SHORT) + .show() + } + displayLoading("Initializing") + executor.submit { initializeAppAuth() } + } + + override fun onStart() { + super.onStart() + if (executor.isShutdown) { + executor = Executors.newSingleThreadExecutor() + } + } + + override fun onStop() { + var n = 10 + var i = false + super.onStop() + executor.shutdownNow() + } + + override fun onDestroy() { + super.onDestroy() + if (authService != null) { + authService!!.dispose() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + displayAuthOptions() + if (resultCode == RESULT_CANCELED) { + Snackbar.make(binding.coordinator, "Authorization canceled", Snackbar.LENGTH_SHORT) + .show() + } else { + val intent = Intent(this, TokenActivity::class.java) + intent.putExtras(data!!.extras!!) + startActivity(intent) + } + } + + @MainThread + fun startAuth() { + displayLoading("Making authorization request") + usePendingIntents = binding.pendingIntentsCheckbox.isChecked + executor.submit { doAuth() } + } + + /** + * Initializes the authorization service configuration if necessary, either from the local + * static values or by retrieving an OpenID discovery document. + */ + @WorkerThread + private fun initializeAppAuth() { + Log.i(TAG, "Initializing AppAuth") + recreateAuthorizationService() + if (authStateManager.current.authorizationServiceConfiguration != null) { + // configuration is already created, skip to client initialization + Log.i(TAG, "auth config already established") + initializeClient() + return + } + + // if we are not using discovery, build the authorization service configuration directly + // from the static configuration values. + if (configuration.discoveryUri == null) { + Log.i(TAG, "Creating auth config from res/raw/auth_config.json") + val config = AuthorizationServiceConfiguration( + configuration.authEndpointUri!!, + configuration.tokenEndpointUri!!, + configuration.endSessionEndpoint, + configuration.registrationEndpointUri + ) + authStateManager.replace(AuthState(config)) + initializeClient() + return + } + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + runOnUiThread { displayLoading("Retrieving discovery document") } + Log.i(TAG, "Retrieving OpenID discovery doc") + AuthorizationServiceConfiguration.fetchFromUrl( + configuration.discoveryUri!!, + { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? -> + handleConfigurationRetrievalResult( + config, + ex + ) + }, + configuration.connectionBuilder + ) + } + + @MainThread + private fun handleConfigurationRetrievalResult( + config: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { + if (config == null) { + Log.i(TAG, "Failed to retrieve discovery document", ex) + displayError("Failed to retrieve discovery document: " + ex!!.message, true) + return + } + Log.i(TAG, "Discovery document retrieved") + authStateManager.replace(AuthState(config)) + executor.submit { initializeClient() } + } + + /** + * Initiates a dynamic registration request if a client ID is not provided by the static + * configuration. + */ + @WorkerThread + private fun initializeClient() { + if (configuration.clientId != null) { + Log.i(TAG, "Using static client ID: " + configuration.clientId) + // use a statically configured client ID + clientId.set(configuration.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + val lastResponse = authStateManager.current.lastRegistrationResponse + if (lastResponse != null) { + Log.i(TAG, "Using dynamic client ID: " + lastResponse.clientId) + clientId.set(lastResponse.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + + runOnUiThread { displayLoading("Dynamically registering client") } + Log.i(TAG, "Dynamically registering client") + val registrationRequest = RegistrationRequest.Builder( + authStateManager.current.authorizationServiceConfiguration!!, + listOf(configuration.redirectUri) + ) + .setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME) + .build() + authService!!.performRegistrationRequest( + registrationRequest + ) { response: RegistrationResponse?, ex: AuthorizationException? -> + handleRegistrationResponse( + response, + ex + ) + } + } + + @MainThread + private fun handleRegistrationResponse( + response: RegistrationResponse?, + ex: AuthorizationException? + ) { + authStateManager.updateAfterRegistration(response, ex) + if (response == null) { + Log.i(TAG, "Failed to dynamically register client", ex) + runOnUiThread { displayError("Failed to register client: " + ex!!.message, true) } + return + } + Log.i(TAG, "Dynamically registered client: " + response.clientId) + clientId.set(response.clientId) + initializeAuthRequest() + } + + + /** + * Performs the authorization request, using the browser selected in the spinner, + * and a user-provided `login_hint` if available. + */ + @WorkerThread + private fun doAuth() { + try { + authIntentLatch.await() + } catch (ex: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for auth intent") + } + if (usePendingIntents) { + val completionIntent = Intent(this, TokenActivity::class.java) + val cancelIntent = Intent(this, LoginActivity::class.java) + cancelIntent.putExtra(EXTRA_FAILED, true) + cancelIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + authService!!.performAuthorizationRequest( + authRequest.get()!!, + PendingIntent.getActivity(this, 0, completionIntent, 0), + PendingIntent.getActivity(this, 0, cancelIntent, 0), + authIntent.get()!! + ) + } else { + val intent = authService!!.getAuthorizationRequestIntent( + authRequest.get()!!, + authIntent.get()!! + ) + startActivityForResult(intent, RC_AUTH) + } + } + + private fun recreateAuthorizationService() { + if (authService != null) { + Log.i(TAG, "Discarding existing AuthService instance") + authService!!.dispose() + } + authService = createAuthorizationService() + authRequest.set(null) + authIntent.set(null) + } + + private fun createAuthorizationService(): AuthorizationService { + Log.i(TAG, "Creating authorization service") + val builder = AppAuthConfiguration.Builder() + builder.setBrowserMatcher(browserMatcher) + builder.setConnectionBuilder(configuration.connectionBuilder) + return AuthorizationService(this, builder.build()) + } + + @MainThread + private fun displayLoading(loadingMessage: String) { + binding.loadingContainer.visibility = View.VISIBLE + binding.authContainer.visibility = View.GONE + binding.errorContainer.visibility = View.GONE + binding.loadingDescription.text = loadingMessage + } + + @MainThread + private fun displayError(error: String?, recoverable: Boolean) { + binding.errorContainer.visibility = View.VISIBLE + binding.loadingContainer.visibility = View.GONE + binding.authContainer.visibility = View.GONE + binding.errorDescription.text = error + binding.retry.visibility = if (recoverable) View.VISIBLE else View.GONE + } + + @MainThread + private fun initializeAuthRequest() { + createAuthRequest(loginHint) + warmUpBrowser() + displayAuthOptions() + } + + @MainThread + private fun displayAuthOptions() { + binding.authContainer.visibility = View.VISIBLE + binding.loadingContainer.visibility = View.GONE + binding.errorContainer.visibility = View.GONE + val state = authStateManager.current + val config = state.authorizationServiceConfiguration + var authEndpointStr: String = if (config!!.discoveryDoc != null) { + "Discovered auth endpoint: \n" + } else { + "Static auth endpoint: \n" + } + authEndpointStr += config.authorizationEndpoint + binding.authEndpoint.text = authEndpointStr + var clientIdStr: String = if (state.lastRegistrationResponse != null) { + "Dynamic client ID: \n" + } else { + "Static client ID: \n" + } + clientIdStr += clientId + binding.clientId.text = clientIdStr + } + + private fun warmUpBrowser() { + authIntentLatch = CountDownLatch(1) + executor.execute { + Log.i(TAG, "Warming up browser instance for auth request") + val intentBuilder = + authService!!.createCustomTabsIntentBuilder(authRequest.get()!!.toUri()) + authIntent.set(intentBuilder.build()) + authIntentLatch.countDown() + } + } + + private fun createAuthRequest(loginHint: String?) { + Log.i(TAG, "Creating auth request for login hint: $loginHint") + val authRequestBuilder = AuthorizationRequest.Builder( + authStateManager.current.authorizationServiceConfiguration!!, + clientId.get()!!, + ResponseTypeValues.CODE, + configuration.redirectUri!! + ) + .setScope(configuration.scope) + if (!TextUtils.isEmpty(loginHint)) { + authRequestBuilder.setLoginHint(loginHint) + } + authRequest.set(authRequestBuilder.build()) + } + + private val loginHint: String + get() = binding.loginHintValue + .text + .toString() + .trim { it <= ' ' } + + /** + * Responds to changes in the login hint. After a "debounce" delay, warms up the browser + * for a request with the new login hint; this avoids constantly re-initializing the + * browser while the user is typing. + */ + inner class LoginHintChangeHandler internal constructor() : TextWatcher { + private val debounceDelayMs = 500 + private val mHandler: Handler = Handler(Looper.getMainLooper()) + private var task: RecreateAuthRequestTask + override fun beforeTextChanged(cs: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(cs: CharSequence, start: Int, before: Int, count: Int) { + task.cancel() + task = RecreateAuthRequestTask() + mHandler.postDelayed(task, debounceDelayMs.toLong()) + } + + override fun afterTextChanged(ed: Editable) {} + + init { + task = RecreateAuthRequestTask() + } + } + + inner class RecreateAuthRequestTask : Runnable { + private val mCanceled = AtomicBoolean() + override fun run() { + if (mCanceled.get()) { + return + } + createAuthRequest(loginHint) + warmUpBrowser() + } + + fun cancel() { + mCanceled.set(true) + } + } + + companion object { + private const val TAG = "LoginActivity" + private const val EXTRA_FAILED = "failed" + private const val RC_AUTH = 100 + } +} \ No newline at end of file diff --git a/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt b/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt new file mode 100644 index 00000000..d9aef76f --- /dev/null +++ b/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt @@ -0,0 +1,394 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * 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 net.openid.appauthdemokotlin + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import com.bumptech.glide.Glide +import com.google.android.material.snackbar.Snackbar +import net.openid.appauth.* +import net.openid.appauth.AuthorizationService.TokenResponseCallback +import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod +import net.openid.appauthdemokotlin.databinding.ActivityTokenBinding +import okio.Okio +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +/** + * Displays the authorized state of the user. This activity is provided with the outcome of the + * authorization flow, which it uses to negotiate the final authorized state, + * by performing an authorization code exchange if necessary. After this, the activity provides + * additional post-authorization operations if available, such as fetching user info and refreshing + * access tokens. + */ +class TokenActivity : AppCompatActivity() { + private lateinit var authService: AuthorizationService + private lateinit var stateManager: AuthStateManager + private val userInfoJson = AtomicReference() + private lateinit var executor: ExecutorService + private lateinit var configuration: Configuration + private lateinit var binding: ActivityTokenBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + stateManager = AuthStateManager.getInstance(this) + executor = Executors.newSingleThreadExecutor() + configuration = Configuration.getInstance(this) + val config: Configuration = Configuration.getInstance(this) + if (config.hasConfigurationChanged()) { + Toast.makeText( + this, + "Configuration change detected", + Toast.LENGTH_SHORT + ) + .show() + signOut() + return + } + authService = AuthorizationService( + this, + AppAuthConfiguration.Builder() + .setConnectionBuilder(config.connectionBuilder) + .build() + ) + binding = ActivityTokenBinding.inflate(layoutInflater) + setContentView(binding.root) + displayLoading("Restoring state...") + if (savedInstanceState != null) { + try { + userInfoJson.set(JSONObject(savedInstanceState.getString(KEY_USER_INFO)!!)) + } catch (ex: JSONException) { + Log.e(TAG, "Failed to parse saved user info JSON, discarding", ex) + } + } + } + + override fun onStart() { + super.onStart() + if (executor.isShutdown) { + executor = Executors.newSingleThreadExecutor() + } + if (stateManager.current.isAuthorized) { + displayAuthorized() + return + } + + // the stored AuthState is incomplete, so check if we are currently receiving the result of + // the authorization flow from the browser. + val response = AuthorizationResponse.fromIntent(intent) + val ex = AuthorizationException.fromIntent(intent) + if (response != null || ex != null) { + stateManager.updateAfterAuthorization(response, ex) + } + when { + response?.authorizationCode != null -> { + // authorization code exchange is required + stateManager.updateAfterAuthorization(response, ex) + exchangeAuthorizationCode(response) + } + ex != null -> { + displayNotAuthorized("Authorization flow failed: " + ex.message) + } + else -> { + displayNotAuthorized("No authorization state retained - reauthorization required") + } + } + } + + override fun onSaveInstanceState(state: Bundle) { + super.onSaveInstanceState(state) + // user info is retained to survive activity restarts, such as when rotating the + // device or switching apps. This isn't essential, but it helps provide a less + // jarring UX when these events occur - data does not just disappear from the view. + if (userInfoJson.get() != null) { + state.putString(KEY_USER_INFO, userInfoJson.toString()) + } + } + + override fun onDestroy() { + super.onDestroy() + authService.dispose() + executor.shutdownNow() + } + + @MainThread + private fun displayNotAuthorized(explanation: String) { + binding.notAuthorized.visibility = View.VISIBLE + binding.authorized.visibility = View.GONE + binding.loadingContainer.visibility = View.GONE + binding.explanation.text = explanation + binding.reauth.setOnClickListener { signOut() } + } + + @MainThread + private fun displayLoading(message: String) { + binding.loadingContainer.visibility = View.VISIBLE + binding.authorized.visibility = View.GONE + binding.notAuthorized.visibility = View.GONE + binding.loadingDescription.text = message + } + + @MainThread + private fun displayAuthorized() { + binding.authorized.visibility = View.VISIBLE + binding.notAuthorized.visibility = View.GONE + binding.loadingContainer.visibility = View.GONE + val state = stateManager.current + val refreshTokenInfoView = binding.refreshTokenInfo + refreshTokenInfoView.setText(if (state.refreshToken == null) R.string.no_refresh_token_returned else R.string.refresh_token_returned) + val idTokenInfoView = binding.idTokenInfo + idTokenInfoView.setText(if (state.idToken == null) R.string.no_id_token_returned else R.string.id_token_returned) + val accessTokenInfoView = binding.accessTokenInfo + if (state.accessToken == null) { + accessTokenInfoView.setText(R.string.no_access_token_returned) + } else { + val expiresAt = state.accessTokenExpirationTime + when { + expiresAt == null -> { + accessTokenInfoView.setText(R.string.no_access_token_expiry) + } + expiresAt < System.currentTimeMillis() -> { + accessTokenInfoView.setText(R.string.access_token_expired) + } + else -> { + val template = resources.getString(R.string.access_token_expires_at) + accessTokenInfoView.text = String.format( + template, + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(expiresAt) + ) + } + } + } + val refreshTokenButton = binding.refreshToken + refreshTokenButton.visibility = if (state.refreshToken != null) View.VISIBLE else View.GONE + refreshTokenButton.setOnClickListener { refreshAccessToken() } + val viewProfileButton = binding.viewProfile + val discoveryDoc = state.authorizationServiceConfiguration!!.discoveryDoc + if ((discoveryDoc == null || discoveryDoc.userinfoEndpoint == null) + && configuration.userInfoEndpointUri == null + ) { + viewProfileButton.visibility = View.GONE + } else { + viewProfileButton.visibility = View.VISIBLE + viewProfileButton.setOnClickListener { fetchUserInfo() } + } + binding.signOut.setOnClickListener { endSession() } + val userInfoCard = binding.userinfoCard + val userInfo = userInfoJson.get() + if (userInfo == null) { + userInfoCard.visibility = View.INVISIBLE + } else { + try { + var name: String? = "???" + if (userInfo.has("name")) { + name = userInfo.getString("name") + } + binding.userinfoName.text = name + if (userInfo.has("picture")) { + Glide.with(this@TokenActivity) + .load(Uri.parse(userInfo.getString("picture"))) + .fitCenter() + .into(binding.userinfoProfile) + } + binding.userinfoJson.text = userInfoJson.toString() + userInfoCard.visibility = View.VISIBLE + } catch (ex: JSONException) { + Log.e(TAG, "Failed to read userinfo JSON", ex) + } + } + } + + @MainThread + private fun refreshAccessToken() { + displayLoading("Refreshing access token") + performTokenRequest(stateManager.current.createTokenRefreshRequest()) { tokenResponse: TokenResponse?, + authException: AuthorizationException? -> + handleAccessTokenResponse(tokenResponse, authException) + } + } + + @MainThread + private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { + displayLoading("Exchanging authorization code") + performTokenRequest( + authorizationResponse.createTokenExchangeRequest() + ) { tokenResponse: TokenResponse?, + authException: AuthorizationException? -> + handleCodeExchangeResponse(tokenResponse, authException) + } + } + + @MainThread + private fun performTokenRequest( + request: TokenRequest, + callback: TokenResponseCallback + ) { + val clientAuthentication: ClientAuthentication = try { + stateManager.current.clientAuthentication + } catch (ex: UnsupportedAuthenticationMethod) { + Log.d( + TAG, "Token request cannot be made, client authentication for the token " + + "endpoint could not be constructed (%s)", ex + ) + displayNotAuthorized("Client authentication method is unsupported") + return + } + authService.performTokenRequest( + request, + clientAuthentication, + callback + ) + } + + @WorkerThread + private fun handleAccessTokenResponse( + tokenResponse: TokenResponse?, + authException: AuthorizationException? + ) { + stateManager.updateAfterTokenResponse(tokenResponse, authException) + runOnUiThread { displayAuthorized() } + } + + @WorkerThread + private fun handleCodeExchangeResponse( + tokenResponse: TokenResponse?, + authException: AuthorizationException? + ) { + stateManager.updateAfterTokenResponse(tokenResponse, authException) + if (!stateManager.current.isAuthorized) { + val message = ("Authorization Code exchange failed" + + if (authException != null) authException.error else "") + runOnUiThread { displayNotAuthorized(message) } + } else { + runOnUiThread { displayAuthorized() } + } + } + + /** + * Demonstrates the use of [AuthState.performActionWithFreshTokens] to retrieve + * user info from the IDP's user info endpoint. This callback will negotiate a new access + * token / id token for use in a follow-up action, or provide an error if this fails. + */ + @MainThread + private fun fetchUserInfo() { + displayLoading("Fetching user info") + stateManager.current.performActionWithFreshTokens(authService) { accessToken: String?, idToken: String?, + ex: AuthorizationException? -> + this.fetchUserInfo(accessToken, idToken, ex) + } + } + + @MainThread + private fun fetchUserInfo(accessToken: String?, idToken: String?, ex: AuthorizationException?) { + if (ex != null) { + Log.e(TAG, "Token refresh failed when fetching user info") + userInfoJson.set(null) + runOnUiThread { displayAuthorized() } + return + } + val discovery = stateManager.current.authorizationServiceConfiguration!!.discoveryDoc + val userInfoEndpoint: URL = try { + if (configuration.userInfoEndpointUri != null) { + URL(configuration.userInfoEndpointUri.toString()) + } else { + URL(discovery!!.userinfoEndpoint.toString()) + } + } catch (urlEx: MalformedURLException) { + Log.e(TAG, "Failed to construct user info endpoint URL", urlEx) + userInfoJson.set(null) + runOnUiThread { displayAuthorized() } + return + } + executor.submit { + try { + val conn = userInfoEndpoint.openConnection() as HttpURLConnection + conn.setRequestProperty("Authorization", "Bearer $accessToken") + conn.instanceFollowRedirects = false + val response = Okio.buffer(Okio.source(conn.inputStream)) + .readString(Charset.forName("UTF-8")) + userInfoJson.set(JSONObject(response)) + } catch (ioEx: IOException) { + Log.e(TAG, "Network error when querying userinfo endpoint", ioEx) + Snackbar.make(binding.coordinator, "Fetching user info failed", Snackbar.LENGTH_SHORT).show() + } catch (jsonEx: JSONException) { + Log.e(TAG, "Failed to parse userinfo response") + Snackbar.make(binding.coordinator, "Failed to parse userinfo response", Snackbar.LENGTH_SHORT).show() + } + runOnUiThread { displayAuthorized() } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == END_SESSION_REQUEST_CODE && resultCode == RESULT_OK) { + signOut() + finish() + } else { + Snackbar.make(binding.coordinator, "Sign out canceled", Snackbar.LENGTH_SHORT) + .show() + } + } + + @MainThread + private fun endSession() { + val endSessionEnter = authService.getEndSessionRequestIntent( + EndSessionRequest.Builder( + stateManager.current.authorizationServiceConfiguration!!, + stateManager.current.idToken!!, + configuration.endSessionUri!! + ).build() + ) + startActivityForResult(endSessionEnter, END_SESSION_REQUEST_CODE) + } + + @MainThread + private fun signOut() { + // discard the authorization and token state, but retain the configuration and + // dynamic client registration (if applicable), to save from retrieving them again. + val currentState = stateManager.current + val clearedState = AuthState(currentState.authorizationServiceConfiguration!!) + if (currentState.lastRegistrationResponse != null) { + clearedState.update(currentState.lastRegistrationResponse) + } + stateManager.replace(clearedState) + val mainIntent = Intent(this, LoginActivity::class.java) + mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(mainIntent) + finish() + } + + companion object { + private const val TAG = "TokenActivity" + private const val KEY_USER_INFO = "userInfo" + private const val END_SESSION_REQUEST_CODE = 911 + } +} \ No newline at end of file diff --git a/appkotlin/res/drawable/appauth_96dp.xml b/appkotlin/res/drawable/appauth_96dp.xml new file mode 100644 index 00000000..f193811a --- /dev/null +++ b/appkotlin/res/drawable/appauth_96dp.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/appkotlin/res/drawable/unknown_user_48dp.xml b/appkotlin/res/drawable/unknown_user_48dp.xml new file mode 100644 index 00000000..8f0ba063 --- /dev/null +++ b/appkotlin/res/drawable/unknown_user_48dp.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/appkotlin/res/layout/activity_login.xml b/appkotlin/res/layout/activity_login.xml new file mode 100644 index 00000000..ffa20908 --- /dev/null +++ b/appkotlin/res/layout/activity_login.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + +