From 7f566cf781c944fde2bf1f9499dc92d852be6ed1 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Thu, 16 Nov 2023 16:45:16 +0200 Subject: [PATCH] Add DescopeLogger and DescopeException --- .../src/main/java/com/descope/Descope.kt | 10 -- .../com/descope/internal/http/ClientErrors.kt | 30 ++++ .../descope/internal/http/DescopeClient.kt | 17 ++- .../com/descope/internal/http/HttpClient.kt | 30 +++- .../com/descope/internal/others/Errors.kt | 10 ++ .../descope/internal/routes/EnchantedLink.kt | 21 ++- .../java/com/descope/internal/routes/Flow.kt | 16 +- .../com/descope/internal/routes/Shared.kt | 20 ++- .../src/main/java/com/descope/sdk/Config.kt | 49 +++++++ .../src/main/java/com/descope/sdk/Sdk.kt | 12 +- .../main/java/com/descope/session/Token.kt | 79 +++++----- .../src/main/java/com/descope/types/Error.kt | 138 ++++++++++++++++++ .../descope/session/DescopeExceptionTest.kt | 27 ++++ 13 files changed, 383 insertions(+), 76 deletions(-) create mode 100644 descopesdk/src/main/java/com/descope/internal/http/ClientErrors.kt create mode 100644 descopesdk/src/main/java/com/descope/internal/others/Errors.kt create mode 100644 descopesdk/src/main/java/com/descope/types/Error.kt create mode 100644 descopesdk/src/test/java/com/descope/session/DescopeExceptionTest.kt diff --git a/descopesdk/src/main/java/com/descope/Descope.kt b/descopesdk/src/main/java/com/descope/Descope.kt index 36fb240b..59761f76 100644 --- a/descopesdk/src/main/java/com/descope/Descope.kt +++ b/descopesdk/src/main/java/com/descope/Descope.kt @@ -123,13 +123,3 @@ object Descope { DescopeSdk(config = config) } } - -// SDK information - -/** The Descope SDK name */ -val Descope.name: String - get() = "DescopeAndroid" - -/** The Descope SDK version */ -val Descope.version: String - get() = "0.9.3" diff --git a/descopesdk/src/main/java/com/descope/internal/http/ClientErrors.kt b/descopesdk/src/main/java/com/descope/internal/http/ClientErrors.kt new file mode 100644 index 00000000..e0e0fb28 --- /dev/null +++ b/descopesdk/src/main/java/com/descope/internal/http/ClientErrors.kt @@ -0,0 +1,30 @@ +package com.descope.internal.http + +import com.descope.internal.others.toMap +import com.descope.internal.others.with +import com.descope.types.DescopeException +import org.json.JSONObject + +internal fun parseServerError(response: String): DescopeException? = try { + val map = JSONObject(response).toMap() + val code = map["errorCode"] as? String ?: throw Exception("errorCode is required") + val desc = map["errorDescription"] as? String ?: "Descope server error" + val message = map["errorMessage"] as? String + DescopeException(code = code, desc = desc, message = message) +} catch (_: Exception) { + null +} + +internal fun exceptionFromResponseCode(code: Int): DescopeException? { + val desc = when (code) { + in 200..299 -> null + 400 -> "The request was invalid" + 401 -> "The request was unauthorized" + 403 -> "The request was forbidden" + 404 -> "The resource was not found" + 500, 503 -> "The server failed with status code $code" + in 500..<600 -> "The server was unreachable" + else -> "The server returned status code $code" + } + return desc?.run { DescopeException.httpError.with(desc = this) } +} diff --git a/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt b/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt index 8e88796c..96c502f8 100644 --- a/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt +++ b/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt @@ -1,13 +1,12 @@ package com.descope.internal.http -import com.descope.Descope import com.descope.sdk.DescopeConfig +import com.descope.sdk.DescopeSdk import com.descope.types.DeliveryMethod import com.descope.types.OAuthProvider import com.descope.types.SignUpDetails -import com.descope.version -internal class DescopeClient(private val config: DescopeConfig) : HttpClient(config.baseUrl) { +internal class DescopeClient(internal val config: DescopeConfig) : HttpClient(config.baseUrl, config.logger) { // OTP @@ -261,7 +260,7 @@ internal class DescopeClient(private val config: DescopeConfig) : HttpClient(con // OAuth suspend fun oauthStart(provider: OAuthProvider, redirectUrl: String?): OAuthServerResponse = post( - route="auth/oauth/authorize", + route = "auth/oauth/authorize", decoder = OAuthServerResponse::fromJson, params = mapOf( "provider" to provider.name, @@ -270,7 +269,7 @@ internal class DescopeClient(private val config: DescopeConfig) : HttpClient(con ) suspend fun oauthExchange(code: String): JwtServerResponse = post( - route="auth/oauth/exchange", + route = "auth/oauth/exchange", decoder = JwtServerResponse::fromJson, body = mapOf( "code" to code, @@ -280,7 +279,7 @@ internal class DescopeClient(private val config: DescopeConfig) : HttpClient(con // SSO suspend fun ssoStart(emailOrTenantId: String, redirectUrl: String?): SsoServerResponse = post( - route="auth/saml/authorize", + route = "auth/saml/authorize", decoder = SsoServerResponse::fromJson, params = mapOf( "tenant" to emailOrTenantId, @@ -289,7 +288,7 @@ internal class DescopeClient(private val config: DescopeConfig) : HttpClient(con ) suspend fun ssoExchange(code: String): JwtServerResponse = post( - route="auth/saml/exchange", + route = "auth/saml/exchange", decoder = JwtServerResponse::fromJson, body = mapOf( "code" to code, @@ -334,9 +333,11 @@ internal class DescopeClient(private val config: DescopeConfig) : HttpClient(con override val defaultHeaders: Map = mapOf( "Authorization" to "Bearer ${config.projectId}", "x-descope-sdk-name" to "android", - "x-descope-sdk-version" to Descope.version, + "x-descope-sdk-version" to DescopeSdk.version, ) + override fun exceptionFromResponse(response: String): Exception? = parseServerError(response) + // Internal private fun authorization(value: String) = mapOf( diff --git a/descopesdk/src/main/java/com/descope/internal/http/HttpClient.kt b/descopesdk/src/main/java/com/descope/internal/http/HttpClient.kt index 5ed21263..4721a4a2 100644 --- a/descopesdk/src/main/java/com/descope/internal/http/HttpClient.kt +++ b/descopesdk/src/main/java/com/descope/internal/http/HttpClient.kt @@ -1,6 +1,12 @@ package com.descope.internal.http import android.net.Uri +import com.descope.internal.others.with +import com.descope.sdk.DescopeLogger +import com.descope.sdk.DescopeLogger.Level.Debug +import com.descope.sdk.DescopeLogger.Level.Error +import com.descope.sdk.DescopeLogger.Level.Info +import com.descope.types.DescopeException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -9,6 +15,7 @@ import javax.net.ssl.HttpsURLConnection internal open class HttpClient( private val baseUrl: String, + private val logger: DescopeLogger?, ) { // Convenience functions @@ -33,6 +40,8 @@ internal open class HttpClient( open val basePath = "/" open val defaultHeaders: Map = emptyMap() + + open fun exceptionFromResponse(response: String): Exception? = null // Internal @@ -45,6 +54,7 @@ internal open class HttpClient( params: Map, ): T = withContext(Dispatchers.IO) { val url = makeUrl(route, params) + logger?.log(Info, "Starting network call", url) val connection = url.openConnection() as HttpsURLConnection try { @@ -55,12 +65,13 @@ internal open class HttpClient( // Send body if needed body?.run { + logger?.log(Debug, "Sending request body", this) connection.setRequestProperty("Content-Type", "application/json") connection.doOutput = true // Send the request connection.outputStream.bufferedWriter().use { it.write(JSONObject().apply { - filterValues { value -> value != null } + filterValues { value -> value != null } .forEach { entry -> put(entry.key, entry.value) } }.toString()) it.flush() @@ -71,12 +82,25 @@ internal open class HttpClient( val responseCode = connection.responseCode if (responseCode == HttpsURLConnection.HTTP_OK) { val response = connection.inputStream.bufferedReader().use { it.readText() } + logger?.log(Debug, "Received response body", response) decoder(response) } else { - // TODO: handle error responses and network errors - throw Exception("Network error") + val response = connection.errorStream.bufferedReader().use { it.readText() } + exceptionFromResponse(response)?.run { + logger?.log(Info, "Network call failed with server error", url, responseCode, this) + throw this + } + logger?.log(Info, "Network call failed with server error", url, responseCode) + throw exceptionFromResponseCode(responseCode) ?: Exception("Network error") + } + } catch (e: Exception) { + if (e !is DescopeException) { + logger?.log(Error, "Network call failed with network error", url, e) + throw DescopeException.networkError.with(cause = e) } + throw e } finally { + logger?.log(Info, "Network call finished", url) connection.disconnect() } } diff --git a/descopesdk/src/main/java/com/descope/internal/others/Errors.kt b/descopesdk/src/main/java/com/descope/internal/others/Errors.kt new file mode 100644 index 00000000..b301638e --- /dev/null +++ b/descopesdk/src/main/java/com/descope/internal/others/Errors.kt @@ -0,0 +1,10 @@ +package com.descope.internal.others + +import com.descope.types.DescopeException + +internal fun DescopeException.with(desc: String? = null, message: String? = null, cause: Throwable? = null) = DescopeException( + code = code, + desc = desc ?: this.desc, + message = message ?: this.message, + cause = cause ?: this.cause, +) diff --git a/descopesdk/src/main/java/com/descope/internal/routes/EnchantedLink.kt b/descopesdk/src/main/java/com/descope/internal/routes/EnchantedLink.kt index 3857f5cd..3f64813e 100644 --- a/descopesdk/src/main/java/com/descope/internal/routes/EnchantedLink.kt +++ b/descopesdk/src/main/java/com/descope/internal/routes/EnchantedLink.kt @@ -3,15 +3,19 @@ package com.descope.internal.routes import com.descope.internal.http.DescopeClient import com.descope.internal.http.EnchantedLinkServerResponse import com.descope.sdk.DescopeEnchantedLink +import com.descope.sdk.DescopeLogger +import com.descope.sdk.DescopeLogger.Level.Error +import com.descope.sdk.DescopeLogger.Level.Info import com.descope.types.AuthenticationResponse +import com.descope.types.DescopeException import com.descope.types.EnchantedLinkResponse import com.descope.types.Result import com.descope.types.SignUpDetails import kotlinx.coroutines.delay -private const val defaultPollDuration: Long = 2 /* mins */ * 60 /* secs */ * 1000 /* ms */ +private const val DEFAULT_POLL_DURATION: Long = 2 /* mins */ * 60 /* secs */ * 1000 /* ms */ -internal class EnchantedLink(private val client: DescopeClient) : DescopeEnchantedLink { +internal class EnchantedLink(override val client: DescopeClient) : Route, DescopeEnchantedLink { override suspend fun signUp(loginId: String, details: SignUpDetails?, uri: String?): EnchantedLinkResponse = client.enchantedLinkSignUp(loginId, details, uri).convert() @@ -49,20 +53,27 @@ internal class EnchantedLink(private val client: DescopeClient) : DescopeEnchant } override suspend fun pollForSession(pendingRef: String, timeoutMilliseconds: Long?): AuthenticationResponse { - val pollingEndsAt = System.currentTimeMillis() + (timeoutMilliseconds ?: defaultPollDuration) + val pollingEndsAt = System.currentTimeMillis() + (timeoutMilliseconds ?: DEFAULT_POLL_DURATION) + log(Info, "Polling for enchanted link", timeoutMilliseconds ?: DEFAULT_POLL_DURATION) // use repeat to ensure we always check at least once while (true) { // check for the session once, any errors not specifically handled // below are intentionally let through to the calling code try { - return checkForSession(pendingRef) + val response = checkForSession(pendingRef) + log(Info, "Enchanted link authentication succeeded") + return response } catch (e: Exception) { // sleep for a second before checking again + log(Info, "Waiting for enchanted link") delay(1000L) // if the timer's expired then we throw as specific // client side error that can be handled appropriately // by the calling code - if (pollingEndsAt - System.currentTimeMillis() <= 0L) throw Exception("Enchanted link polling expired") // TODO: Make the enchantedLinkExpired exception + if (pollingEndsAt - System.currentTimeMillis() <= 0L) { + log(Error, "Timed out while polling for enchanted link") + throw DescopeException.enchantedLinkExpired + } } } } diff --git a/descopesdk/src/main/java/com/descope/internal/routes/Flow.kt b/descopesdk/src/main/java/com/descope/internal/routes/Flow.kt index 4db9cd80..b0fe5b46 100644 --- a/descopesdk/src/main/java/com/descope/internal/routes/Flow.kt +++ b/descopesdk/src/main/java/com/descope/internal/routes/Flow.kt @@ -4,8 +4,12 @@ import android.content.Context import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import com.descope.internal.http.DescopeClient +import com.descope.internal.others.with import com.descope.sdk.DescopeFlow +import com.descope.sdk.DescopeLogger +import com.descope.sdk.DescopeLogger.Level.Info import com.descope.types.AuthenticationResponse +import com.descope.types.DescopeException import com.descope.types.Result import java.security.MessageDigest import kotlin.io.encoding.Base64 @@ -14,8 +18,8 @@ import kotlin.random.Random @OptIn(ExperimentalEncodingApi::class) internal class Flow( - private val client: DescopeClient -) : DescopeFlow { + override val client: DescopeClient +) : Route, DescopeFlow { override var currentRunner: DescopeFlow.Runner? = null private set @@ -34,6 +38,7 @@ internal class Flow( private lateinit var codeVerifier: String override fun start(context: Context) { + log(Info, "Starting flow authentication", flowUrl) // create some random bytes val randomBytes = ByteArray(32) Random.nextBytes(randomBytes) @@ -72,10 +77,11 @@ internal class Flow( override suspend fun exchange(incomingUri: Uri): AuthenticationResponse { // make sure start has been called - if (!this::codeVerifier.isInitialized) throw Exception("`start(context)` must be called before exchange") + if (!this::codeVerifier.isInitialized) throw DescopeException.flowFailed.with(desc = "`start(context)` must be called before exchange") // get the `code` url param from the incoming uri and exchange it - val authorizationCode = incomingUri.getQueryParameter("code") ?: throw Exception("No code parameter on incoming URI") + val authorizationCode = incomingUri.getQueryParameter("code") ?: throw DescopeException.flowFailed.with(desc = "No code parameter on incoming URI") + log(Info, "Exchanging flow authorization code for session", authorizationCode) if (currentRunner === this) currentRunner = null return client.flowExchange(authorizationCode, codeVerifier).convert() } @@ -85,7 +91,7 @@ internal class Flow( } } - + } private fun launchUri(context: Context, uri: Uri) { diff --git a/descopesdk/src/main/java/com/descope/internal/routes/Shared.kt b/descopesdk/src/main/java/com/descope/internal/routes/Shared.kt index ed2e1617..a887373b 100644 --- a/descopesdk/src/main/java/com/descope/internal/routes/Shared.kt +++ b/descopesdk/src/main/java/com/descope/internal/routes/Shared.kt @@ -1,12 +1,16 @@ package com.descope.internal.routes import android.net.Uri +import com.descope.internal.http.DescopeClient import com.descope.internal.http.JwtServerResponse import com.descope.internal.http.MaskedAddressServerResponse import com.descope.internal.http.UserResponse +import com.descope.internal.others.with +import com.descope.sdk.DescopeLogger import com.descope.session.Token import com.descope.types.AuthenticationResponse import com.descope.types.DeliveryMethod +import com.descope.types.DescopeException import com.descope.types.DescopeUser import com.descope.types.RefreshResponse import com.descope.types.Result @@ -14,6 +18,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +internal interface Route { + val client: DescopeClient + + fun log(level: DescopeLogger.Level, message: String, vararg values: Any) { + client.config.logger?.log(level, message, *values) + } +} + internal fun UserResponse.convert(): DescopeUser = DescopeUser( userId = userId, loginIds = loginIds, @@ -28,8 +40,8 @@ internal fun UserResponse.convert(): DescopeUser = DescopeUser( ) internal fun JwtServerResponse.convert(): AuthenticationResponse { - val refreshJwt = refreshJwt ?: throw Exception("Missing refresh JWT") // TODO replace with DescopeError - val user = user ?: throw Exception("Missing user details") // TODO replace with DescopeError + val refreshJwt = refreshJwt ?: throw DescopeException.decodeError.with(message = "Missing refresh JWT") + val user = user ?: throw DescopeException.decodeError.with(message = "Missing user details") return AuthenticationResponse( refreshToken = Token(refreshJwt), sessionToken = Token(sessionJwt), @@ -44,8 +56,8 @@ internal fun JwtServerResponse.toRefreshResponse(): RefreshResponse = RefreshRes ) internal fun MaskedAddressServerResponse.convert(method: DeliveryMethod) = when (method) { - DeliveryMethod.Email -> maskedEmail ?: throw Exception("masked email not received") - DeliveryMethod.Sms, DeliveryMethod.Whatsapp -> maskedPhone ?: throw Exception("masked phone not received") + DeliveryMethod.Email -> maskedEmail ?: throw DescopeException.decodeError.with(message = "masked email not received") + DeliveryMethod.Sms, DeliveryMethod.Whatsapp -> maskedPhone ?: throw DescopeException.decodeError.with(message = "masked phone not received") } @Suppress("OPT_IN_USAGE") diff --git a/descopesdk/src/main/java/com/descope/sdk/Config.kt b/descopesdk/src/main/java/com/descope/sdk/Config.kt index 0d2eedbd..86e51019 100644 --- a/descopesdk/src/main/java/com/descope/sdk/Config.kt +++ b/descopesdk/src/main/java/com/descope/sdk/Config.kt @@ -8,10 +8,14 @@ const val DEFAULT_BASE_URL = "https://api.descope.com" * * @property projectId the id of the Descope project. * @property baseUrl the base URL of the Descope server. + * @property logger an option logger to use to log messages in the Descope SDK. + * _**IMPORTANT**: Logging is intended for `DEBUG` only. Do not enable logs when building + * the `RELEASE` versions of your application._ */ data class DescopeConfig( val projectId: String, val baseUrl: String = DEFAULT_BASE_URL, + val logger: DescopeLogger? = null, ) { companion object { @@ -19,3 +23,48 @@ data class DescopeConfig( } } + +/** + * _**IMPORTANT**: Logging is intended for `DEBUG` only. Do not enable logs when building + * the `RELEASE` versions of your application._ + * + * The [DescopeLogger] class can be used to customize logging functionality in the Descope SDK. + * + * The default behavior is for log messages to be written to the standard output using + * the `println()` function. + * + * You can also customize how logging functions in the Descope SDK by creating a subclass + * of [DescopeLogger] and overriding the [DescopeLogger.output] method. See the + * documentation for that method for more details. + */ +open class DescopeLogger(private val level: Level = Level.Debug) { + + /** The severity of a log message. */ + enum class Level { + Error, Info, Debug + } + + /** + * Formats the log message and prints it. + * + * Override this method to customize how to handle log messages from the Descope SDK. + * + * @param level the log level printed + * @param message the message to print + * @param values any associated values. _**IMPORTANT** - sensitive information may be printed here. Enable logs only when debugging._ + */ + open fun output(level: Level, message: String, vararg values: Any) { + var text = "[${DescopeSdk.name}] $message" + if (values.isNotEmpty()) { + text += """ (${values.joinToString(", ") { v -> v.toString() }})""" + } + println(text); + } + + // Called by other code in the Descope SDK to output log messages. + fun log(level: Level, message: String, vararg values: Any) { + if (level <= this.level) { + output(level, message, *values) + } + } +} diff --git a/descopesdk/src/main/java/com/descope/sdk/Sdk.kt b/descopesdk/src/main/java/com/descope/sdk/Sdk.kt index dd43b22e..ccf265e2 100644 --- a/descopesdk/src/main/java/com/descope/sdk/Sdk.kt +++ b/descopesdk/src/main/java/com/descope/sdk/Sdk.kt @@ -1,9 +1,9 @@ package com.descope.sdk -import com.descope.internal.routes.Flow import com.descope.internal.http.DescopeClient import com.descope.internal.routes.Auth import com.descope.internal.routes.EnchantedLink +import com.descope.internal.routes.Flow import com.descope.internal.routes.MagicLink import com.descope.internal.routes.OAuth import com.descope.internal.routes.Otp @@ -60,4 +60,14 @@ class DescopeSdk(val config: DescopeConfig) { this.manager = manager return manager } + + // SDK information + + companion object { + /** The Descope SDK name */ + const val name = "DescopeAndroid" + + /** The Descope SDK version */ + const val version = "0.9.3" + } } diff --git a/descopesdk/src/main/java/com/descope/session/Token.kt b/descopesdk/src/main/java/com/descope/session/Token.kt index 7fc30d5c..5b7604fa 100644 --- a/descopesdk/src/main/java/com/descope/session/Token.kt +++ b/descopesdk/src/main/java/com/descope/session/Token.kt @@ -2,8 +2,10 @@ package com.descope.session import androidx.annotation.VisibleForTesting import com.descope.internal.others.secToMs +import com.descope.internal.others.toMap import com.descope.internal.others.tryOrNull -import org.json.JSONArray +import com.descope.internal.others.with +import com.descope.types.DescopeException import org.json.JSONObject import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -86,7 +88,7 @@ internal class Token( claims = map.filter { Claim.isCustom(it.key) } allClaims = map } catch (e: Exception) { - throw IllegalArgumentException("Error decoding token", e) // TODO: replace with DescopeError + throw DescopeException.tokenError.with(cause = e) } } @@ -114,19 +116,41 @@ internal class Token( } private inline fun getValueForTenant(tenant: String, key: String): T { - val foundTenant = getTenants()[tenant] - ?: throw IllegalArgumentException("Missing tenant") // TODO: replace with DescopeError + val foundTenant = getTenants()[tenant] ?: throw TokenException.MissingTenant(tenant) if (foundTenant is Map<*, *>) { foundTenant[key]?.run { if (this is T) return this } } - throw IllegalArgumentException("Invalid tenant") // TODO: replace with DescopeError + throw TokenException.InvalidTenant(tenant) } private fun getTenants(): Map = getClaim(Claim.Tenants, allClaims) } +// Error + +private sealed class TokenException : Exception() { + class InvalidFormat : TokenException() + class InvalidEncoding : TokenException() + class InvalidData : TokenException() + class MissingClaim(val claim: String) : TokenException() + class InvalidClaim(val claim: String) : TokenException() + class MissingTenant(val tenant: String) : TokenException() + class InvalidTenant(val tenant: String) : TokenException() + + override val message: String? + get() = when (this) { + is InvalidFormat -> "Invalid token format" + is InvalidEncoding -> "Invalid token encoding" + is InvalidData -> "Invalid token data" + is MissingClaim -> "Missing $claim claim in token" + is InvalidClaim -> "Invalid $claim claim in token" + is MissingTenant -> "Tenant $tenant not found in token" + is InvalidTenant -> "Invalid data for tenant $tenant in token" + } +} + // Claims private enum class Claim(val key: String) { @@ -148,8 +172,8 @@ private inline fun getClaim(claim: Claim, map: Map): T getClaim(claim.key, map) private inline fun getClaim(claim: String, map: Map): T { - val obj = map[claim] ?: throw IllegalArgumentException("Missing claim") // TODO: replace with DescopeError - return obj as? T ?: throw IllegalArgumentException("Invalid claim") // TODO: replace with DescopeError + val obj = map[claim] ?: throw TokenException.MissingClaim(claim) + return obj as? T ?: throw TokenException.InvalidClaim(claim) } // JWT Decoding @@ -157,46 +181,21 @@ private inline fun getClaim(claim: String, map: Map): T @OptIn(ExperimentalEncodingApi::class) private fun decodeFragment(string: String): Map { val data = Base64.UrlSafe.decode(string) - val json = JSONObject(String(data)) - return json.toMap() + try { + val json = JSONObject(String(data)) + return json.toMap() + } catch (_: Exception) { + throw TokenException.InvalidData() + } } @VisibleForTesting fun decodeJwt(jwt: String): Map { val fragments = jwt.split(".") - if (fragments.size != 3) throw IllegalArgumentException("Invalid format") // TODO: replace with DescopeError + if (fragments.size != 3) throw TokenException.InvalidEncoding() return decodeFragment(fragments[1]) } private fun decodeIssuer(issuer: String): String = issuer.split("/").lastOrNull() - ?: throw IllegalArgumentException("Invalid format") // TODO: replace with DescopeError - -// JSON Transformations - -private fun JSONObject.toMap(): Map { - val map = mutableMapOf() - val iterator = keys() - while (iterator.hasNext()) { - val key = iterator.next() - map[key] = when (val value = get(key)) { - is JSONArray -> value.toList() - is JSONObject -> value.toMap() - else -> value - } - } - return map -} - -private fun JSONArray.toList(): List { - val list = mutableListOf() - for (i in 0 until this.length()) { - val value = when (val value = this[i]) { - is JSONArray -> value.toList() - is JSONObject -> value.toMap() - else -> value - } - list.add(value) - } - return list -} + ?: throw TokenException.InvalidFormat() diff --git a/descopesdk/src/main/java/com/descope/types/Error.kt b/descopesdk/src/main/java/com/descope/types/Error.kt new file mode 100644 index 00000000..8897bed1 --- /dev/null +++ b/descopesdk/src/main/java/com/descope/types/Error.kt @@ -0,0 +1,138 @@ +package com.descope.types + +import com.descope.session.DescopeSession + +private const val API_DESC = "Descope API error" + +/** + * The concrete type of [Exception] thrown by all operations in the Descope SDK. + * + * There are several ways to catch and handle a [DescopeException] thrown by a Descope SDK + * operation, and you can use whichever one is more appropriate in each specific use case. + * + * try { + * val authResponse = Descope.otp.verify(DeliveryMethod.Email, "andy@example.com", "123456") + * showLoginSuccess(with: authResponse) + * } catch (e: DescopeException) { + * when(e) { + * // handle one or more kinds of errors where we don't + * // need to use the actual error object + * DescopeException.wrongOtpCode, + * DescopeException.invalidRequest -> showBadCodeAlert() + * + * // handle a specific kind of error and do something + * // with the [DescopeException] object + * DescopeException.networkError -> { + * logError("A network error has occurred", e.desc, e.cause) + * showNetworkErrorRetry() + * } + * + * // handle any other scenario + * else -> { + * logError("Unexpected authentication failure: $e") + * showUnexpectedErrorAlert(with: error) + * } + * } + * } + * + * See the [DescopeException] companion object for specific error values. Note that not all API errors + * are listed in the SDK yet. Please let us know via a Github issue or pull request if you + * need us to add any entries to make your code simpler. + * + * @property code A string of 7 characters that represents a specific Descope error. + * For example, the value of [code] is `"E011003"` when an API request fails validation. + * @property desc A short description of the error message. + * For example, the value of [desc] is `"Request is invalid"` when an API request fails validation. + * @property message An optional message with more details about the error. + * For example, the value of [message] might be `"The email field is required"` when + * attempting to authenticate via enchanted link with an empty email address. + * @property cause An optional underlying [Throwable] that caused this error. + * For example, when a [DescopeException.networkError] is caught the [cause] property + * will usually have the [Exception] object thrown by the internal `HttpsURLConnection` call. + */ +class DescopeException( + val code: String, + val desc: String, + override val message: String? = null, + override val cause: Throwable? = null, +) : Exception(), Comparable { + + override fun compareTo(other: DescopeException): Int { + return code.compareTo(other.code) + } + + override fun equals(other: Any?): Boolean { + val exception = other as? DescopeException ?: return false + return code == exception.code + } + + override fun hashCode(): Int { + var result = code.hashCode() + result = 31 * result + desc.hashCode() + return result + } + + override fun toString(): String { + var str = """DescopeError(code: "$code", description: "$desc"""" + message?.run { + str += """, message: "$this"""" + } + cause?.run { + str += ", cause: {$this}" + } + str += ")" + return str + } + + companion object { + /** + * Thrown when a call to the Descope API fails due to a network error. + * + * You can catch this kind of error to handle error cases such as the user being + * offline or the network request timing out. + */ + val networkError = DescopeException(code = "K010001", desc = "Network error") + + val badRequest = DescopeException(code = "E011001", desc = API_DESC) + val missingArguments = DescopeException(code = "E011002", desc = API_DESC) + val invalidRequest = DescopeException(code = "E011003", desc = API_DESC) + val invalidArguments = DescopeException(code = "E011004", desc = API_DESC) + + val wrongOtpCode = DescopeException(code = "E061102", desc = API_DESC) + val tooManyOtpAttempts = DescopeException(code = "E061103", desc = API_DESC) + + val enchantedLinkPending = DescopeException(code = "E062503", desc = API_DESC) + val enchantedLinkExpired = DescopeException(code = "K060001", desc = "Enchanted link expired") + + val flowFailed = DescopeException(code = "K100001", desc = "Flow failed to run") + + // Internal + + // These errors are not expected to happen in common usage and there shouldn't be + // a need to catch them specifically. + + /** + * Thrown if a call to the Descope API fails in an unexpected manner. + * + * This should only be thrown when there's no error response body to parse or the body + * isn't in the expected format. The value of [desc] is overwritten with a more specific + * value when possible. + */ + internal val httpError = DescopeException("K010002", "Server request failed") + + /** Thrown if a response from the Descope API can't be parsed for an unexpected reason. */ + internal val decodeError = DescopeException("K010003", "Failed to decode response") + + /** Thrown if a request to the Descope API fails to encode for an unexpected reason. */ + internal val encodeError = DescopeException("K010004", "Failed to encode request") + + /** + * Thrown if a JWT string fails to decode. + * + * This might be thrown if the [DescopeSession] initializer is called with an invalid + * `sessionJwt` or `refreshJwt` value. + */ + internal val tokenError = DescopeException("K010005", "Failed to parse token") + } +} + diff --git a/descopesdk/src/test/java/com/descope/session/DescopeExceptionTest.kt b/descopesdk/src/test/java/com/descope/session/DescopeExceptionTest.kt new file mode 100644 index 00000000..74900664 --- /dev/null +++ b/descopesdk/src/test/java/com/descope/session/DescopeExceptionTest.kt @@ -0,0 +1,27 @@ +package com.descope.session + +import com.descope.internal.http.parseServerError +import com.descope.types.DescopeException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class DescopeExceptionTest { + + + @Test + fun error_comparison() { + val mockResponse = JSONObject().apply { + put("errorCode", "E061102") + put("errorDescription", "server description") + put("errorMessage", "some reason") + }.toString() + val serverException = parseServerError(mockResponse) + assertEquals(DescopeException.wrongOtpCode, serverException) + when (serverException) { + DescopeException.wrongOtpCode -> assertEquals("server description", serverException.desc) + else -> fail("wrong when clause") + } + } +}