Skip to content

Commit f91208d

Browse files
authored
feat: fetch user info (#201)
1 parent 484db8b commit f91208d

File tree

22 files changed

+378
-71
lines changed

22 files changed

+378
-71
lines changed

android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import io.logto.sdk.core.Core
1818
import io.logto.sdk.core.constant.ReservedScope
1919
import io.logto.sdk.core.type.IdTokenClaims
2020
import io.logto.sdk.core.type.OidcConfigResponse
21+
import io.logto.sdk.core.type.UserInfoResponse
2122
import io.logto.sdk.core.util.TokenUtils
2223
import org.jetbrains.annotations.TestOnly
2324
import org.jose4j.jwk.JsonWebKeySet
@@ -308,6 +309,38 @@ open class LogtoClient(
308309
}
309310
}
310311

312+
/**
313+
* Fetch user info
314+
* @param[completion] the completion which handles the retrieved result
315+
*/
316+
fun fetchUserInfo(completion: Completion<LogtoException, UserInfoResponse>) {
317+
getOidcConfig { getOidcConfigException, oidcConfig ->
318+
getOidcConfigException?.let {
319+
completion.onComplete(it, null)
320+
return@getOidcConfig
321+
}
322+
getAccessToken { getAccessTokenException, accessToken ->
323+
getAccessTokenException?.let {
324+
completion.onComplete(it, null)
325+
return@getAccessToken
326+
}
327+
Core.fetchUserInfo(
328+
userInfoEndpoint = requireNotNull(oidcConfig).userinfoEndpoint,
329+
accessToken = requireNotNull(accessToken).token,
330+
) fetchUserInfoInCore@{ fetchUserInfoException, userInfoResponse ->
331+
fetchUserInfoException?.let {
332+
completion.onComplete(
333+
LogtoException(LogtoException.Type.UNABLE_TO_FETCH_USER_INFO, it),
334+
null,
335+
)
336+
return@fetchUserInfoInCore
337+
}
338+
completion.onComplete(null, userInfoResponse)
339+
}
340+
}
341+
}
342+
}
343+
311344
private fun verifyAndSaveTokenResponse(
312345
issuer: String,
313346
responseIdToken: String?,

android-sdk/android/src/main/kotlin/io/logto/sdk/android/type/LogtoConfig.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ class LogtoConfig(
1111
val usingPersistStorage: Boolean = true,
1212
val prompt: String = PromptValue.CONSENT,
1313
) {
14-
val scopes = ScopeUtils.withReservedScopes(scopes)
14+
val scopes = ScopeUtils.withDefaultScopes(scopes)
1515
}

android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.logto.sdk.core.http.HttpEmptyCompletion
1414
import io.logto.sdk.core.type.IdTokenClaims
1515
import io.logto.sdk.core.type.OidcConfigResponse
1616
import io.logto.sdk.core.type.RefreshTokenTokenResponse
17+
import io.logto.sdk.core.type.UserInfoResponse
1718
import io.logto.sdk.core.util.TokenUtils
1819
import io.mockk.Runs
1920
import io.mockk.clearAllMocks
@@ -50,6 +51,7 @@ class LogtoClientTest {
5051
private const val TEST_APP_ID = "app_id"
5152
private const val TEST_REFRESH_TOKEN = "refreshToken"
5253
private const val TEST_TOKEN_ENDPOINT = "tokenEndpoint"
54+
private const val TEST_USERINFO_ENDPOINT = "userinfoEndpoint"
5355
private const val TEST_REVOCATION_ENDPOINT = "endSessionEndpoint"
5456
private const val TEST_ISSUER = "issuer"
5557
private const val TEST_ACCESS_TOKEN = "accessToken"
@@ -439,6 +441,109 @@ class LogtoClientTest {
439441
}
440442
}
441443

444+
@Test
445+
fun `fetchUserInfo should complete with user info`() {
446+
logtoClient = LogtoClient(logtoConfigMock, mockk())
447+
448+
every { oidcConfigResponseMock.userinfoEndpoint } returns TEST_USERINFO_ENDPOINT
449+
450+
mockkObject(logtoClient)
451+
every { logtoClient.getOidcConfig(any()) } answers {
452+
firstArg<Completion<LogtoException, OidcConfigResponse>>().onComplete(null, oidcConfigResponseMock)
453+
}
454+
val accessTokenMock: AccessToken = mockk()
455+
every { accessTokenMock.token } returns TEST_ACCESS_TOKEN
456+
every { logtoClient.getAccessToken(any(), any()) } answers {
457+
lastArg<Completion<LogtoException, AccessToken>>().onComplete(null, accessTokenMock)
458+
}
459+
460+
val userInfoResponseMock: UserInfoResponse = mockk()
461+
462+
mockkObject(Core)
463+
every { Core.fetchUserInfo(any(), any(), any()) } answers {
464+
lastArg<HttpCompletion<UserInfoResponse>>().onComplete(null, userInfoResponseMock)
465+
}
466+
467+
logtoClient.fetchUserInfo { logtoException, result ->
468+
assertThat(logtoException).isNull()
469+
assertThat(result).isEqualTo(userInfoResponseMock)
470+
}
471+
}
472+
473+
@Test
474+
fun `fetchUserInfo should complete with exception if cannot get oidc config`() {
475+
logtoClient = LogtoClient(logtoConfigMock, mockk())
476+
477+
mockkObject(logtoClient)
478+
every { logtoClient.getOidcConfig(any()) } answers {
479+
firstArg<Completion<LogtoException, OidcConfigResponse>>().onComplete(
480+
LogtoException(LogtoException.Type.UNABLE_TO_FETCH_OIDC_CONFIG),
481+
null,
482+
)
483+
}
484+
485+
logtoClient.fetchUserInfo { logtoException, result ->
486+
assertThat(logtoException)
487+
.hasMessageThat()
488+
.isEqualTo(LogtoException.Type.UNABLE_TO_FETCH_OIDC_CONFIG.name)
489+
assertThat(result).isNull()
490+
}
491+
}
492+
493+
@Test
494+
fun `fetchUserInfo should complete with exception if cannot get access token`() {
495+
logtoClient = LogtoClient(logtoConfigMock, mockk())
496+
497+
every { oidcConfigResponseMock.userinfoEndpoint } returns TEST_USERINFO_ENDPOINT
498+
499+
mockkObject(logtoClient)
500+
every { logtoClient.getOidcConfig(any()) } answers {
501+
firstArg<Completion<LogtoException, OidcConfigResponse>>().onComplete(null, oidcConfigResponseMock)
502+
}
503+
504+
val mockGetAccessTokenException: LogtoException = mockk()
505+
every { logtoClient.getAccessToken(any(), any()) } answers {
506+
lastArg<Completion<LogtoException, AccessToken>>().onComplete(mockGetAccessTokenException, null)
507+
}
508+
509+
logtoClient.fetchUserInfo { logtoException, result ->
510+
assertThat(logtoException).isEqualTo(mockGetAccessTokenException)
511+
assertThat(result).isNull()
512+
}
513+
}
514+
515+
@Test
516+
fun `fetchUserInfo should complete with exception if fetchUserInfo failed`() {
517+
logtoClient = LogtoClient(logtoConfigMock, mockk())
518+
519+
every { oidcConfigResponseMock.userinfoEndpoint } returns TEST_USERINFO_ENDPOINT
520+
521+
mockkObject(logtoClient)
522+
every { logtoClient.getOidcConfig(any()) } answers {
523+
firstArg<Completion<LogtoException, OidcConfigResponse>>().onComplete(null, oidcConfigResponseMock)
524+
}
525+
val accessTokenMock: AccessToken = mockk()
526+
every { accessTokenMock.token } returns TEST_ACCESS_TOKEN
527+
every { logtoClient.getAccessToken(any(), any()) } answers {
528+
lastArg<Completion<LogtoException, AccessToken>>().onComplete(null, accessTokenMock)
529+
}
530+
531+
mockkObject(Core)
532+
every { Core.fetchUserInfo(any(), any(), any()) } answers {
533+
lastArg<HttpCompletion<UserInfoResponse>>().onComplete(
534+
LogtoException(LogtoException.Type.UNABLE_TO_FETCH_USER_INFO),
535+
null,
536+
)
537+
}
538+
539+
logtoClient.fetchUserInfo { logtoException, result ->
540+
assertThat(logtoException)
541+
.hasMessageThat()
542+
.isEqualTo(LogtoException.Type.UNABLE_TO_FETCH_USER_INFO.name)
543+
assertThat(result).isNull()
544+
}
545+
}
546+
442547
@Test
443548
fun `getJwks should complete with jwks`() {
444549
every { oidcConfigResponseMock.jwksUri } returns "https://logto.dev/oidc/jwks"

android-sdk/android/src/test/kotlin/io/logto/sdk/android/auth/logto/LogtoAuthSessionTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class LogtoAuthSessionTest {
3131
authorizationEndpoint = "authorizationEndpoint",
3232
tokenEndpoint = "tokenEndpoint",
3333
endSessionEndpoint = "endSessionEndpoint",
34+
userinfoEndpoint = "userinfoEndpoint",
3435
jwksUri = "jwksUri",
3536
issuer = "issuer",
3637
revocationEndpoint = "revocationEndpoint",

android-sdk/android/src/test/kotlin/io/logto/sdk/android/type/LogtoConfigTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.logto.sdk.android.type
22

33
import com.google.common.truth.Truth.assertThat
44
import io.logto.sdk.core.constant.ReservedScope
5+
import io.logto.sdk.core.constant.UserScope
56
import org.junit.Test
67

78
class LogtoConfigTest {
@@ -15,7 +16,7 @@ class LogtoConfigTest {
1516
assertThat(logtoConfigWithoutScope.scopes).apply {
1617
contains(ReservedScope.OPENID)
1718
contains(ReservedScope.OFFLINE_ACCESS)
18-
contains(ReservedScope.PROFILE)
19+
contains(UserScope.PROFILE)
1920
}
2021

2122
val logtoConfigWithOtherScope = LogtoConfig(
@@ -27,7 +28,7 @@ class LogtoConfigTest {
2728
assertThat(logtoConfigWithOtherScope.scopes).apply {
2829
contains(ReservedScope.OPENID)
2930
contains(ReservedScope.OFFLINE_ACCESS)
30-
contains(ReservedScope.PROFILE)
31+
contains(UserScope.PROFILE)
3132
contains("other_scope")
3233
}
3334
}

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/Core.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.logto.sdk.core.http.httpPost
1313
import io.logto.sdk.core.type.CodeTokenResponse
1414
import io.logto.sdk.core.type.OidcConfigResponse
1515
import io.logto.sdk.core.type.RefreshTokenTokenResponse
16+
import io.logto.sdk.core.type.UserInfoResponse
1617
import io.logto.sdk.core.util.ScopeUtils
1718
import okhttp3.FormBody
1819
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -38,7 +39,7 @@ object Core {
3839
addQueryParameter(QueryKey.STATE, state)
3940
addQueryParameter(QueryKey.REDIRECT_URI, redirectUri)
4041
addQueryParameter(QueryKey.RESPONSE_TYPE, ResponseType.CODE)
41-
addQueryParameter(QueryKey.SCOPE, ScopeUtils.withReservedScopes(scopes).joinToString(" "))
42+
addQueryParameter(QueryKey.SCOPE, ScopeUtils.withDefaultScopes(scopes).joinToString(" "))
4243
resources?.let { for (value in it) { addQueryParameter(QueryKey.RESOURCE, value) } }
4344
addQueryParameter(QueryKey.PROMPT, prompt ?: PromptValue.CONSENT)
4445
}.build().toString()
@@ -104,6 +105,16 @@ object Core {
104105
httpPost(tokenEndpoint, formBody, completion)
105106
}
106107

108+
fun fetchUserInfo(
109+
userInfoEndpoint: String,
110+
accessToken: String,
111+
completion: HttpCompletion<UserInfoResponse>,
112+
) = httpGet(
113+
userInfoEndpoint,
114+
headers = mapOf("Authorization" to "Bearer $accessToken"),
115+
completion,
116+
)
117+
107118
fun revoke(
108119
revocationEndpoint: String,
109120
clientId: String,

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/constant/ClaimName.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ object ClaimName {
44
const val AT_HASH = "at_hash"
55
const val NAME = "name"
66
const val USERNAME = "username"
7-
const val AVATAR = "avatar"
7+
const val PICTURE = "picture"
88
const val ROLE_NAMES = "roleNames"
9+
const val EMAIL = "email"
10+
const val EMAIL_VERIFIED = "email_verified"
11+
const val PHONE_NUMBER = "phone_number"
12+
const val PHONE_NUMBER_VERIFIED = "phone_number_verified"
13+
const val CUSTOM_DATA = "custom_data"
14+
const val IDENTITIES = "identities"
915
}

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/constant/ReservedScope.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@ package io.logto.sdk.core.constant
33
object ReservedScope {
44
const val OPENID = "openid"
55
const val OFFLINE_ACCESS = "offline_access"
6-
const val PROFILE = "profile"
76
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.logto.sdk.core.constant
2+
3+
object UserScope {
4+
const val PROFILE = "profile"
5+
const val EMAIL = "email"
6+
const val PHONE = "phone"
7+
const val CUSTOM_DATA = "custom_data"
8+
const val IDENTITIES = "identities"
9+
}

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/extension/JwtClaimsExt.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ fun JwtClaims.toIdTokenClaims(): IdTokenClaims = IdTokenClaims(
1313
atHash = this.getClaimValueAsString(ClaimName.AT_HASH),
1414
name = this.getClaimValueAsString(ClaimName.NAME),
1515
username = this.getClaimValueAsString(ClaimName.USERNAME),
16-
avatar = this.getClaimValueAsString(ClaimName.AVATAR),
16+
picture = this.getClaimValueAsString(ClaimName.PICTURE),
1717
roleNames = this.getStringListClaimValue(ClaimName.ROLE_NAMES),
18+
email = this.getClaimValueAsString(ClaimName.EMAIL),
19+
emailVerified = this.getClaimValue(ClaimName.EMAIL_VERIFIED) as Boolean?,
20+
phoneNumber = this.getClaimValueAsString(ClaimName.PHONE_NUMBER),
21+
phoneNumberVerified = this.getClaimValue(ClaimName.PHONE_NUMBER_VERIFIED) as Boolean?,
1822
)

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/type/IdTokenClaims.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ data class IdTokenClaims(
1111
// Scope `profile`
1212
val name: String?,
1313
val username: String?,
14-
val avatar: String?,
14+
val picture: String?,
1515
val roleNames: List<String>?,
16+
17+
// Scope `email`
18+
val email: String?,
19+
val emailVerified: Boolean?,
20+
21+
// Scope `phone`
22+
val phoneNumber: String?,
23+
val phoneNumberVerified: Boolean?,
1624
)

kotlin-sdk/kotlin/src/main/kotlin/io/logto/sdk/core/type/OidcConfigResponse.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ data class OidcConfigResponse(
44
val authorizationEndpoint: String,
55
val tokenEndpoint: String,
66
val endSessionEndpoint: String,
7+
val userinfoEndpoint: String,
78
val jwksUri: String,
89
val issuer: String,
910
val revocationEndpoint: String,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.logto.sdk.core.type
2+
3+
import com.google.gson.JsonObject
4+
5+
data class UserInfoResponse(
6+
val sub: String,
7+
8+
// Scope `profile`
9+
val name: String?,
10+
val username: String?,
11+
val picture: String?,
12+
val roleNames: List<String>?,
13+
14+
// Scope `email`
15+
val email: String?,
16+
val emailVerified: Boolean?,
17+
18+
// Scope `phone`
19+
val phoneNumber: String?,
20+
val phoneNumberVerified: Boolean?,
21+
22+
// Scope `custom_data`
23+
val customData: JsonObject?,
24+
25+
// Scope `identities`
26+
val identities: JsonObject?,
27+
)
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
package io.logto.sdk.core.util
22

33
import io.logto.sdk.core.constant.ReservedScope
4+
import io.logto.sdk.core.constant.UserScope
45

56
object ScopeUtils {
67
/**
7-
* Ensure the scope list contains `open_id` and `offline_access`
8+
* Ensure the scope list contains `open_id`, `offline_access` and `profile`
89
* @param[scopes] The origin scope list
9-
* @return The scope list which contains `open_id` and `offline_access`
10+
* @return The scope list which contains `open_id`, `offline_access` and `profile`
1011
*/
11-
fun withReservedScopes(scopes: List<String>?): List<String> = (
12+
fun withDefaultScopes(scopes: List<String>?): List<String> = (
1213
(scopes ?: listOf()) + listOf(
1314
ReservedScope.OPENID,
1415
ReservedScope.OFFLINE_ACCESS,
15-
ReservedScope.PROFILE,
16+
UserScope.PROFILE,
1617
)
1718
).distinct()
1819
}

0 commit comments

Comments
 (0)