From 93648c9d2f09c9febdd990bc7957ed88ae376eb8 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Thu, 21 Nov 2024 16:19:17 +0200 Subject: [PATCH] Add get tenants API (#156) --- .../descope/internal/http/DescopeClient.kt | 10 ++ .../com/descope/internal/http/Responses.kt | 25 ++++ .../java/com/descope/internal/others/Utils.kt | 8 ++ .../java/com/descope/internal/routes/Auth.kt | 19 +++ .../src/main/java/com/descope/sdk/Routes.kt | 17 +++ .../src/main/java/com/descope/types/Tenant.kt | 21 ++++ .../com/descope/internal/routes/AuthTest.kt | 117 ++++++++++++++++++ .../com/descope/internal/routes/TestUtils.kt | 8 ++ 8 files changed, 225 insertions(+) create mode 100644 descopesdk/src/main/java/com/descope/types/Tenant.kt create mode 100644 descopesdk/src/test/java/com/descope/internal/routes/AuthTest.kt 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 0d6e6fc0..24f04ce2 100644 --- a/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt +++ b/descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt @@ -466,6 +466,16 @@ internal open class DescopeClient(internal val config: DescopeConfig) : HttpClie headers = authorization(refreshJwt), ) + suspend fun tenants(dct: Boolean, tenantIds: List, refreshJwt: String): TenantsResponse = post( + route = "auth/me/tenants", + decoder = TenantsResponse::fromJson, + headers = authorization(refreshJwt), + body = mapOf( + "dct" to dct, + "ids" to tenantIds, + ) + ) + suspend fun refresh(refreshJwt: String): JwtServerResponse = post( route = "auth/refresh", decoder = JwtServerResponse::fromJson, diff --git a/descopesdk/src/main/java/com/descope/internal/http/Responses.kt b/descopesdk/src/main/java/com/descope/internal/http/Responses.kt index b506276d..95a3a296 100644 --- a/descopesdk/src/main/java/com/descope/internal/http/Responses.kt +++ b/descopesdk/src/main/java/com/descope/internal/http/Responses.kt @@ -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.toObjectList import com.descope.internal.others.toStringList import org.json.JSONObject import java.net.HttpCookie @@ -78,6 +79,30 @@ internal data class UserResponse( } } +internal data class TenantsResponse( + val tenants: List, +) { + + data class Tenant( + val tenantId: String, + val name: String, + val customAttributes: Map, + ) + + companion object { + @Suppress("UNUSED_PARAMETER") + fun fromJson(json: String, cookies: List) = JSONObject(json).run { + TenantsResponse(tenants = getJSONArray("tenants").toObjectList().map { + Tenant( + tenantId = it.getString("id"), + name = it.getString("name"), + customAttributes = it.optionalMap("customAttributes"), + ) + }) + } + } +} + internal data class MaskedAddressServerResponse( val maskedEmail: String? = null, val maskedPhone: String? = null, diff --git a/descopesdk/src/main/java/com/descope/internal/others/Utils.kt b/descopesdk/src/main/java/com/descope/internal/others/Utils.kt index f7e94105..3882163c 100644 --- a/descopesdk/src/main/java/com/descope/internal/others/Utils.kt +++ b/descopesdk/src/main/java/com/descope/internal/others/Utils.kt @@ -62,6 +62,14 @@ internal fun JSONArray.toStringList(): List { return list } +internal fun JSONArray.toObjectList(): List { + val list = mutableListOf() + for (i in 0 until length()) { + list.add(getJSONObject(i)) + } + return list +} + internal fun List<*>.toJsonArray(): JSONArray = JSONArray().apply { this@toJsonArray.forEach { when { diff --git a/descopesdk/src/main/java/com/descope/internal/routes/Auth.kt b/descopesdk/src/main/java/com/descope/internal/routes/Auth.kt index b7126349..963f6132 100644 --- a/descopesdk/src/main/java/com/descope/internal/routes/Auth.kt +++ b/descopesdk/src/main/java/com/descope/internal/routes/Auth.kt @@ -1,7 +1,9 @@ package com.descope.internal.routes import com.descope.internal.http.DescopeClient +import com.descope.internal.http.TenantsResponse import com.descope.sdk.DescopeAuth +import com.descope.types.DescopeTenant import com.descope.types.DescopeUser import com.descope.types.RevokeType import com.descope.types.RefreshResponse @@ -16,6 +18,13 @@ internal class Auth(private val client: DescopeClient) : DescopeAuth { me(refreshJwt) } + override suspend fun tenants(dct: Boolean, tenantIds: List, refreshJwt: String): List = + client.tenants(dct, tenantIds, refreshJwt).convert() + + override fun tenants(dct: Boolean, tenantIds: List, refreshJwt: String, callback: (Result>) -> Unit) = wrapCoroutine(callback) { + tenants(dct, tenantIds, refreshJwt) + } + override suspend fun refreshSession(refreshJwt: String): RefreshResponse = client.refresh(refreshJwt).toRefreshResponse() @@ -42,3 +51,13 @@ internal class Auth(private val client: DescopeClient) : DescopeAuth { revokeSessions(RevokeType.CurrentSession, refreshJwt, callback) } + +internal fun TenantsResponse.convert(): List { + return tenants.map { + DescopeTenant( + tenantId = it.tenantId, + name = it.name, + customAttributes = it.customAttributes, + ) + } +} diff --git a/descopesdk/src/main/java/com/descope/sdk/Routes.kt b/descopesdk/src/main/java/com/descope/sdk/Routes.kt index ef0a9c37..ebeca268 100644 --- a/descopesdk/src/main/java/com/descope/sdk/Routes.kt +++ b/descopesdk/src/main/java/com/descope/sdk/Routes.kt @@ -8,6 +8,7 @@ import androidx.browser.customtabs.CustomTabsIntent import com.descope.session.DescopeSession import com.descope.types.AuthenticationResponse import com.descope.types.DeliveryMethod +import com.descope.types.DescopeTenant import com.descope.types.DescopeUser import com.descope.types.EnchantedLinkResponse import com.descope.types.RevokeType @@ -39,6 +40,22 @@ interface DescopeAuth { /** @see me */ fun me(refreshJwt: String, callback: (Result) -> Unit) + /** + * Returns the current session user tenants. + * + * @param dct Set this to `true` and leave [tenantIds] empty to request the current + * tenant for the user as set in the `dct` claim. This will fail if a tenant + * hasn't already been selected. + * @param tenantIds Provide a non-empty array of tenant IDs and set `dct` to `false` + * to request a specific list of tenants for the user. + * @param refreshJwt The refreshJwt from an active [DescopeSession]. + * @return A list of one or more [DescopeTenant] values. + */ + suspend fun tenants(dct: Boolean, tenantIds: List, refreshJwt: String): List + + /** @see tenants */ + fun tenants(dct: Boolean, tenantIds: List, refreshJwt: String, callback: (Result>) -> Unit) + /** * Refreshes a [DescopeSession]. * diff --git a/descopesdk/src/main/java/com/descope/types/Tenant.kt b/descopesdk/src/main/java/com/descope/types/Tenant.kt new file mode 100644 index 00000000..026c99ac --- /dev/null +++ b/descopesdk/src/main/java/com/descope/types/Tenant.kt @@ -0,0 +1,21 @@ +package com.descope.types + +import com.descope.sdk.DescopeAuth + +/** + * The [DescopeTenant] class represents a tenant in Descope. + * + * You can retrieve the tenants for a user after authentication by calling [DescopeAuth.tenants]. + * + * @property tenantId The unique identifier for the user in the project. + * This is either an automatically generated value or a custom value that was set + * when the tenant was created. + * @property name The name of the tenant. + * @property customAttributes A mapping of any custom attributes associated with this tenant. The custom attributes + * are managed via the Descope console. + */ +data class DescopeTenant( + val tenantId: String, + val name: String, + val customAttributes: Map, +) diff --git a/descopesdk/src/test/java/com/descope/internal/routes/AuthTest.kt b/descopesdk/src/test/java/com/descope/internal/routes/AuthTest.kt new file mode 100644 index 00000000..c4936fb4 --- /dev/null +++ b/descopesdk/src/test/java/com/descope/internal/routes/AuthTest.kt @@ -0,0 +1,117 @@ +package com.descope.internal.routes + +import com.descope.internal.http.TenantsResponse +import com.descope.internal.http.UserResponse +import com.descope.types.RevokeType +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthTest { + @Test + fun me() = runTest { + val client = MockClient() + val auth = Auth(client) + client.assert = { route: String, _: Map, headers: Map, _: Map -> + assertEquals("auth/me", route) + val authorizationHeader = headers["Authorization"] + assertNotNull(authorizationHeader) + assertTrue(authorizationHeader!!.contains("refreshJwt")) + } + client.response = UserResponse( + userId = "userId", + loginIds = listOf("loginId"), + name = "name", + picture = null, + email = null, + verifiedEmail = false, + phone = null, + verifiedPhone = false, + createdTime = 0L, + customAttributes = emptyMap(), + givenName = null, + middleName = null, + familyName = null, + ) + val response = auth.me("refreshJwt") + assertEquals("name", response.name) + assertEquals(1, client.calls) + } + + @Test + fun tenants() = runTest { + val client = MockClient() + val auth = Auth(client) + client.assert = { route: String, body: Map, headers: Map, _: Map -> + assertEquals("auth/me/tenants", route) + val authorizationHeader = headers["Authorization"] + assertNotNull(authorizationHeader) + assertTrue(authorizationHeader!!.contains("refreshJwt")) + assertEquals(true, body["dct"]) + } + client.response = TenantsResponse( + tenants = listOf( + TenantsResponse.Tenant("id1", "t1", emptyMap()), + TenantsResponse.Tenant("id2", "t2", mapOf("a" to "b", "c" to "d")) + ), + ) + val response = auth.tenants(true, emptyList(), "refreshJwt") + assertEquals(2, response.size) + assertEquals("id1", response[0].tenantId) + assertEquals("t1", response[0].name) + assertEquals("id2", response[1].tenantId) + assertEquals("t2", response[1].name) + assertEquals(2, response[1].customAttributes.size) + assertEquals(1, client.calls) + } + + @Test + fun refreshSession() = runTest { + val client = MockClient() + val auth = Auth(client) + client.assert = { route: String, _: Map, headers: Map, _: Map -> + assertEquals("auth/refresh", route) + val authorizationHeader = headers["Authorization"] + assertNotNull(authorizationHeader) + assertTrue(authorizationHeader!!.contains("refreshJwt")) + } + client.response = mockJwtResponse + val response = auth.refreshSession("refreshJwt") + assertEquals(jwt, response.sessionToken.jwt) + assertEquals(jwt, response.refreshToken!!.jwt) + assertEquals(1, client.calls) + } + + @Test + fun revokeSession_current() = runTest { + val client = MockClient() + val auth = Auth(client) + client.assert = { route: String, _: Map, headers: Map, _: Map -> + assertEquals("auth/logout", route) + val authorizationHeader = headers["Authorization"] + assertNotNull(authorizationHeader) + assertTrue(authorizationHeader!!.contains("refreshJwt")) + } + client.response = Unit + auth.revokeSessions(RevokeType.CurrentSession, "refreshJwt") + assertEquals(1, client.calls) + } + + @Test + fun revokeSession_all() = runTest { + val client = MockClient() + val auth = Auth(client) + client.assert = { route: String, _: Map, headers: Map, _: Map -> + assertEquals("auth/logoutall", route) + val authorizationHeader = headers["Authorization"] + assertNotNull(authorizationHeader) + assertTrue(authorizationHeader!!.contains("refreshJwt")) + } + client.response = Unit + auth.revokeSessions(RevokeType.AllSessions, "refreshJwt") + assertEquals(1, client.calls) + } + +} diff --git a/descopesdk/src/test/java/com/descope/internal/routes/TestUtils.kt b/descopesdk/src/test/java/com/descope/internal/routes/TestUtils.kt index d43576ae..3151cfa3 100644 --- a/descopesdk/src/test/java/com/descope/internal/routes/TestUtils.kt +++ b/descopesdk/src/test/java/com/descope/internal/routes/TestUtils.kt @@ -26,6 +26,14 @@ internal class MockClient : DescopeClient(DescopeConfig("p1")) { response?.run { return this as T } throw Exception("Test did not configure response") } + + override suspend fun get(route: String, decoder: (String, List) -> T, headers: Map, params: Map): T { + calls += 1 + assert?.invoke(route, emptyMap(), headers, params) + error?.run { throw this } + response?.run { return this as T } + throw Exception("Test did not configure response") + } } internal fun SignUpDetails.validate(body: Map) {