Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ written for Android. You can read more on the [Descope Website](https://descope.
Add the following to your `build.gradle` dependencies:

```groovy
implementation 'com.descope:descope-kotlin:0.17.0'
implementation 'com.descope:descope-kotlin:0.17.1'
```

## Quickstart
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.descope.internal.http
package com.descope.android

import android.content.Context
import android.os.Build

internal interface SystemInfo {
interface SystemInfo {
val appName: String?
val appVersion: String?
val platformVersion: String
val device: String?
}

internal class DescopeSystemInfo private constructor(context: Context) : SystemInfo {
class DescopeSystemInfo private constructor(context: Context) : SystemInfo {

override val appName: String? = try {
context.applicationInfo.loadLabel(context.packageManager).toString()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.descope.internal.http

import com.descope.android.SystemInfo
import com.descope.sdk.DescopeConfig
import com.descope.sdk.DescopeSdk
import com.descope.types.DeliveryMethod
Expand Down
19 changes: 19 additions & 0 deletions descopesdk/src/main/java/com/descope/internal/http/Responses.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.descope.internal.http
import com.descope.internal.others.optionalMap
import com.descope.internal.others.secToMs
import com.descope.internal.others.stringOrEmptyAsNull
import com.descope.internal.others.toBooleanMap
import com.descope.internal.others.toObjectList
import com.descope.internal.others.toStringList
import org.json.JSONObject
Expand Down Expand Up @@ -54,6 +55,15 @@ internal data class UserResponse(
val givenName: String?,
val middleName: String?,
val familyName: String?,
val password: Boolean,
val status: String,
val roleNames: List<String>,
val ssoAppIds: List<String>,
val oauthProviders: Map<String, Boolean>,
val webauthn: Boolean,
val totp: Boolean,
val saml: Boolean,
val scim: Boolean,
) {
companion object {
@Suppress("UNUSED_PARAMETER")
Expand All @@ -74,6 +84,15 @@ internal data class UserResponse(
givenName = stringOrEmptyAsNull("givenName"),
middleName = stringOrEmptyAsNull("middleName"),
familyName = stringOrEmptyAsNull("familyName"),
password = optBoolean("password"),
status = getString("status"),
roleNames = getJSONArray("roleNames").toStringList(),
ssoAppIds = getJSONArray("ssoAppIds").toStringList(),
oauthProviders = getJSONObject("OAuth").toBooleanMap(),
webauthn = optBoolean("webauthn"),
totp = optBoolean("TOTP"),
saml = optBoolean("SAML"),
scim = optBoolean("SCIM"),
)
}
}
Expand Down
63 changes: 55 additions & 8 deletions descopesdk/src/main/java/com/descope/internal/others/Utils.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.descope.internal.others

import android.util.Base64
import com.descope.types.DescopeException
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
Expand All @@ -15,14 +16,20 @@ internal fun ByteArray.toBase64(): String {
return Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)
}

// JSON
// JSON -> Map/List

internal fun JSONObject.stringOrEmptyAsNull(key: String): String? = try {
getString(key).ifEmpty { null }
} catch (_: JSONException) {
null
}

internal fun JSONObject.booleanOrNull(key: String): Boolean? = try {
getBoolean(key)
} catch (_: JSONException) {
null
}

internal fun JSONObject.toMap(): Map<String, Any> {
val map = mutableMapOf<String, Any>()
keys().forEach { key ->
Expand Down Expand Up @@ -54,27 +61,66 @@ internal fun JSONObject.optionalMap(key: String): Map<String, Any> = try {
emptyMap()
}

internal fun JSONArray.toStringList(): List<String> {
val list = mutableListOf<String>()
internal fun JSONObject.toBooleanMap(): Map<String, Boolean> = toTypedMap<Boolean>()

internal fun JSONArray.toStringList(): List<String> = toTypedList<String>()

internal fun JSONArray.toStringSet(): Set<String> = toTypedSet<String>()

internal fun JSONArray.toObjectList(): List<JSONObject> = toTypedList<JSONObject>()

internal inline fun <reified T> JSONObject.toTypedMap(): Map<String, T> {
val map = mutableMapOf<String, T>()
keys().forEach { key ->
map[key] = when(val obj = get(key)) {
is T -> obj
else -> throw DescopeException.decodeError.with(desc = "JSON object expected only '${T::class.java.name}' but contains an unexpected type: '${obj.javaClass.name}'")
}
}
return map.toMap()
}

internal inline fun <reified T> JSONArray.toTypedList(): List<T> {
val list = mutableListOf<T>()
for (i in 0 until length()) {
list.add(getString(i))
list.add(when(val obj = get(i)) {
is T -> obj
else -> throw DescopeException.decodeError.with(desc = "JSON array expected only '${T::class.java.name}' but contains an unexpected type: '${obj?.javaClass?.name}'")
})
}
return list
}

internal fun JSONArray.toObjectList(): List<JSONObject> {
val list = mutableListOf<JSONObject>()
internal inline fun <reified T> JSONArray.toTypedSet(): Set<T> {
val set = mutableSetOf<T>()
for (i in 0 until length()) {
list.add(getJSONObject(i))
set.add(when(val obj = get(i)) {
is T -> obj
else -> throw DescopeException.decodeError.with(desc = "JSON array expected only '${T::class.java.name}' but contains an unexpected type: '${obj?.javaClass?.name}'")
})
}
return list
return set
}

// Map/List -> JSON

internal fun List<*>.toJsonArray(): JSONArray = JSONArray().apply {
this@toJsonArray.forEach {
when {
it is Map<*, *> -> put(it.toJsonObject())
it is List<*> -> put(it.toJsonArray())
it is Set<*> -> put(it.toJsonArray())
it != null -> put(it)
}
}
}

internal fun Set<*>.toJsonArray(): JSONArray = JSONArray().apply {
this@toJsonArray.forEach {
when {
it is Map<*, *> -> put(it.toJsonObject())
it is List<*> -> put(it.toJsonArray())
it is Set<*> -> put(it.toJsonArray())
it != null -> put(it)
}
}
Expand All @@ -87,6 +133,7 @@ internal fun Map<*, *>.toJsonObject(): JSONObject = JSONObject().apply {
when {
value is Map<*, *> -> put(key, value.toJsonObject())
value is List<*> -> put(key, value.toJsonArray())
value is Set<*> -> put(key, value.toJsonArray())
value != null -> put(key, value)
}
}
Expand Down
12 changes: 8 additions & 4 deletions descopesdk/src/main/java/com/descope/internal/routes/OAuth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ internal suspend fun nativeAuthorization(context: Context, responseJson: JSONObj
}

internal suspend fun nativeAuthorization(context: Context, startResponse: OAuthNativeStartServerResponse): NativeAuthorizationResponse {
if (!startResponse.implicit) {
val identityToken = performNativeAuthorization(context, startResponse.clientId, startResponse.nonce, startResponse.implicit)
return NativeAuthorizationResponse(startResponse.stateId, identityToken)
}

suspend fun performNativeAuthorization(context: Context, clientId: String, nonce: String?, implicit: Boolean): String {
if (!implicit) {
throw DescopeException.oauthNativeFailed.with(message = "OAuth provider configuration must allow 'implicit' grant type")
}

val authorization = performAuthorization(context, startResponse.clientId, startResponse.nonce)
val identityToken = parseCredential(authorization.credential)
return NativeAuthorizationResponse(startResponse.stateId, identityToken)
val authorization = performAuthorization(context, clientId, nonce)
return parseCredential(authorization.credential)
}

private suspend fun performAuthorization(context: Context, clientId: String, nonce: String?): GetCredentialResponse {
Expand Down
18 changes: 12 additions & 6 deletions descopesdk/src/main/java/com/descope/internal/routes/Passkey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,15 @@ private fun convertOptions(options: String): String {
return publicKey
}

@RequiresApi(Build.VERSION_CODES.P)
internal fun getPackageOrigin(context: Context): String {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
val signers = packageInfo.signingInfo?.apkContentsSigners // nullable according to source code
fun getPackageOrigin(context: Context): String {
@Suppress("DEPRECATION")
val signers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
packageInfo?.signingInfo?.apkContentsSigners // nullable according to source code
} else {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
packageInfo.signatures
}
Comment thread
itaihanski marked this conversation as resolved.

if (signers.isNullOrEmpty()) {
throw DescopeException.passkeyFailed.with(message = "Failed to find signing certificates")
Expand All @@ -109,7 +114,7 @@ internal fun getPackageOrigin(context: Context): String {
}

@SuppressLint("PublicKeyCredential")
internal suspend fun performRegister(context: Context, options: String): String {
suspend fun performRegister(context: Context, options: String): String {
val publicKey = convertOptions(options)
val request = CreatePublicKeyCredentialRequest(publicKey)

Expand All @@ -134,7 +139,7 @@ internal suspend fun performRegister(context: Context, options: String): String
}
}

internal suspend fun performAssertion(context: Context, options: String): String {
suspend fun performAssertion(context: Context, options: String): String {
val publicKey = convertOptions(options)
val option = GetPublicKeyCredentialOption(publicKey)
val request = GetCredentialRequest(listOf(option))
Expand All @@ -160,3 +165,4 @@ internal suspend fun performAssertion(context: Context, options: String): String
throw DescopeException.passkeyFailed.with(message = "Unexpected failure", cause = e)
}
}

14 changes: 14 additions & 0 deletions descopesdk/src/main/java/com/descope/internal/routes/Shared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ internal fun UserResponse.convert(): DescopeUser = DescopeUser(
givenName = givenName,
middleName = middleName,
familyName = familyName,
status = DescopeUser.Status.deserialize(status),
authentication = DescopeUser.Authentication(
passkey = webauthn,
password = password,
totp = totp,
oauth = oauthProviders.keys.toSet(),
sso = saml,
scim = scim,
),
authorization = DescopeUser.Authorization(
roles = roleNames.toSet(),
ssoAppIds = ssoAppIds.toSet(),
),
isUpdateRequired = false
)

internal fun JwtServerResponse.convert(): AuthenticationResponse {
Expand Down
4 changes: 2 additions & 2 deletions descopesdk/src/main/java/com/descope/sdk/Sdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ package com.descope.sdk
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Looper
import com.descope.android.DescopeSystemInfo
import com.descope.internal.http.DescopeClient
import com.descope.internal.http.DescopeSystemInfo
import com.descope.internal.others.ConsoleLogger
import com.descope.internal.routes.Auth
import com.descope.internal.routes.EnchantedLink
Expand Down Expand Up @@ -78,6 +78,6 @@ class DescopeSdk(context: Context, projectId: String, configure: DescopeConfig.(
const val NAME = "DescopeAndroid"

/** The Descope SDK version */
const val VERSION = "0.17.0"
const val VERSION = "0.17.1"
}
}
47 changes: 44 additions & 3 deletions descopesdk/src/main/java/com/descope/session/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.descope.internal.others.booleanOrNull
import com.descope.internal.others.debug
import com.descope.internal.others.error
import com.descope.internal.others.optionalMap
import com.descope.internal.others.stringOrEmptyAsNull
import com.descope.internal.others.toJsonArray
import com.descope.internal.others.toJsonObject
import com.descope.internal.others.toStringList
import com.descope.internal.others.toStringSet
import com.descope.internal.others.tryOrNull
import com.descope.sdk.DescopeLogger
import com.descope.types.DescopeUser
Expand Down Expand Up @@ -128,7 +130,7 @@ class SessionStorage(context: Context, private val projectId: String, logger: De
get() = JSONObject().apply {
put("sessionJwt", sessionJwt)
put("refreshJwt", refreshJwt)
put("user", user.toJson())
put("user", user.serialize())
}.toString()

companion object {
Expand Down Expand Up @@ -171,7 +173,25 @@ class EncryptedSharedPrefs(name: String, context: Context) : SessionStorage.Stor

// Serialization

private fun deserializeDescopeUser(json: JSONObject): DescopeUser = json.run {
internal fun deserializeDescopeUser(json: JSONObject): DescopeUser = json.run {
val authentication = optJSONObject("authentication").let { json ->
DescopeUser.Authentication(
password = json?.optBoolean("password") ?: false,
passkey = json?.optBoolean("passkey") ?: false,
totp = json?.optBoolean("totp") ?: false,
oauth = json?.optJSONArray("oauth")?.toStringSet() ?: emptySet(),
sso = json?.optBoolean("sso") ?: false,
scim = json?.optBoolean("scim") ?: false,
)
}

val authorization = optJSONObject("authorization").let { json ->
DescopeUser.Authorization(
roles = json?.optJSONArray("roles")?.toStringSet() ?: emptySet(),
ssoAppIds = json?.optJSONArray("ssoAppIds")?.toStringSet() ?: emptySet(),
)
}

DescopeUser(
userId = getString("userId"),
loginIds = getJSONArray("loginIds").toStringList(),
Expand All @@ -186,10 +206,14 @@ private fun deserializeDescopeUser(json: JSONObject): DescopeUser = json.run {
givenName = stringOrEmptyAsNull("givenName"),
middleName = stringOrEmptyAsNull("middleName"),
familyName = stringOrEmptyAsNull("familyName"),
status = DescopeUser.Status.deserialize(stringOrEmptyAsNull("status") ?: "enabled"),
authentication = authentication,
authorization = authorization,
isUpdateRequired = booleanOrNull("isUpdateRequired") ?: true, // if the flag doesn't exist we've got old data without the new fields
)
}

private fun DescopeUser.toJson() = JSONObject().apply {
internal fun DescopeUser.serialize() = JSONObject().apply {
put("userId", userId)
put("loginIds", loginIds.toJsonArray())
put("createdAt", createdAt)
Expand All @@ -203,8 +227,25 @@ private fun DescopeUser.toJson() = JSONObject().apply {
put("givenName", givenName)
put("middleName", middleName)
put("familyName", familyName)
put("authentication", authentication.toJsonObject())
put("authorization", authorization.toJsonObject())
put("status", status.serialize())
put("isUpdateRequired", isUpdateRequired)
}

private fun DescopeUser.Authentication.toJsonObject() = JSONObject().apply {
put("password", password)
put("passkey", passkey)
put("totp", totp)
put("oauth", oauth.toJsonArray())
put("sso", sso)
put("scim", scim)
}

private fun DescopeUser.Authorization.toJsonObject() = JSONObject().apply {
put("roles", roles.toJsonArray())
put("ssoAppIds", ssoAppIds.toJsonArray())
}
private fun createEncryptedStore(context: Context, projectId: String, logger: DescopeLogger?): SessionStorage.Store {
try {
val storage = EncryptedSharedPrefs(projectId, context)
Expand Down
Loading
Loading