You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tasks/app/src/main/java/org/tasks/auth/Configuration.kt

223 lines
8.6 KiB
Kotlin

/*
* 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 org.tasks.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.TextUtils
import net.openid.appauth.connectivity.ConnectionBuilder
import net.openid.appauth.connectivity.DefaultConnectionBuilder
import okio.Buffer
import okio.buffer
import okio.source
import org.json.JSONException
import org.json.JSONObject
import org.tasks.BuildConfig
import org.tasks.R
import java.io.IOException
import java.nio.charset.StandardCharsets
/**
* Reads and validates the app configuration from `authConfig`. 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 context: Context,
private val authConfig: Int,
debugConnectionBuilder: DebugConnectionBuilder
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
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
private set
private var mScope: String? = null
private var mRedirectUri: Uri? = null
var discoveryUri: Uri? = null
private set
var authEndpointUri: Uri? = null
private set
var tokenEndpointUri: Uri? = null
private set
var registrationEndpointUri: Uri? = null
private set
var userInfoEndpointUri: Uri? = null
private set
private var isHttpsRequired = false
/**
* Indicates whether the configuration has changed from the last known valid state.
*/
fun hasConfigurationChanged(): Boolean = configHash != lastKnownConfigHash
/**
* 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() {
prefs.edit().putString(KEY_LAST_HASH, configHash).apply()
}
val scope: String
get() = mScope!!
val redirectUri: Uri
get() = mRedirectUri!!
val connectionBuilder: ConnectionBuilder = when {
BuildConfig.DEBUG -> debugConnectionBuilder
else -> DefaultConnectionBuilder.INSTANCE
}
private val lastKnownConfigHash: String?
get() = prefs.getString(KEY_LAST_HASH, null)
@Throws(InvalidConfigurationException::class)
private fun readConfiguration() {
val configSource = context.resources.openRawResource(authConfig).source().buffer()
val configData = Buffer()
configJson = try {
configSource.readAll(configData)
JSONObject(configData.readString(StandardCharsets.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")
mScope = getRequiredConfigString("authorization_scope")
mRedirectUri = getRequiredConfigUri("redirect_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")
if (clientId == null) {
registrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri")
}
} else {
discoveryUri = getRequiredConfigWebUri("discovery_uri")
if (BuildConfig.DEBUG) {
discoveryUri = Uri.parse(discoveryUri.toString().replace(
"""^https://caldav.tasks.org""".toRegex(),
context.getString(R.string.tasks_caldav_url)
))
}
}
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)
private fun getRequiredConfigString(propName: String): String = 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 = 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().apply {
setPackage(context.packageName)
action = Intent.ACTION_VIEW
addCategory(Intent.CATEGORY_BROWSABLE)
data = mRedirectUri
}
return context.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 PREFS_NAME = "config"
private const val KEY_LAST_HASH = "lastHash"
val GOOGLE_CONFIG = R.raw.google_config
val GITHUB_CONFIG = R.raw.github_config
}
init {
try {
readConfiguration()
} catch (ex: InvalidConfigurationException) {
configurationError = ex.message
}
}
}