Skip to content

Commit 307e097

Browse files
committed
feat: add token exchange
1 parent f902c01 commit 307e097

File tree

9 files changed

+182
-71
lines changed

9 files changed

+182
-71
lines changed

app/build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ android {
3232
}
3333
}
3434

35+
flavorDimensions += "version"
36+
37+
productFlavors {
38+
create("default") {
39+
dimension = "version"
40+
buildConfigField("boolean", "TOKEN_EXCHANGE", "false")
41+
}
42+
43+
create("tokenExchange") {
44+
dimension = "version"
45+
buildConfigField("boolean", "TOKEN_EXCHANGE", "true")
46+
}
47+
}
48+
3549
compileOptions {
3650
sourceCompatibility = JavaVersion.VERSION_17
3751
targetCompatibility = JavaVersion.VERSION_17

app/src/main/java/cloud/pace/sdk/app/CloudSDKApplication.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cloud.pace.sdk.app
33
import android.app.Application
44
import cloud.pace.sdk.PACECloudSDK
55
import cloud.pace.sdk.idkit.model.CustomOIDConfiguration
6+
import cloud.pace.sdk.idkit.model.TokenExchangeConfiguration
67
import cloud.pace.sdk.utils.Configuration
78
import cloud.pace.sdk.utils.Environment
89

@@ -11,6 +12,23 @@ class CloudSDKApplication : Application() {
1112
override fun onCreate() {
1213
super.onCreate()
1314

15+
val oidConfiguration = if (BuildConfig.TOKEN_EXCHANGE) {
16+
CustomOIDConfiguration(
17+
authorizationEndpoint = "https://id.dev.pace.cloud/auth/realms/MultiRealm/protocol/openid-connect/auth",
18+
tokenEndpoint = "https://id.dev.pace.cloud/auth/realms/MultiRealm/protocol/openid-connect/token",
19+
endSessionEndpoint = "https://id.dev.pace.cloud/auth/realms/MultiRealm/protocol/openid-connect/logout",
20+
redirectUri = "cloud-sdk-example://callback",
21+
clientSecret = "YIUXbpLZeN6OD1afjXwD4lFZigQAIHp7",
22+
tokenExchangeConfig = TokenExchangeConfiguration(
23+
issuerId = "multi-oidc",
24+
clientId = "cloud-sdk-example-app-token-exchange",
25+
clientSecret = "IMqeEWNd91lOf9tCEnIFZyOwcnDNV6Jw"
26+
)
27+
)
28+
} else {
29+
CustomOIDConfiguration(redirectUri = "cloud-sdk-example://callback")
30+
}
31+
1432
PACECloudSDK.setup(
1533
this,
1634
Configuration(
@@ -20,7 +38,7 @@ class CloudSDKApplication : Application() {
2038
clientAppBuild = BuildConfig.VERSION_CODE.toString(),
2139
apiKey = "YOUR_API_KEY",
2240
environment = Environment.DEVELOPMENT,
23-
oidConfiguration = CustomOIDConfiguration(redirectUri = "cloud-sdk-example://callback")
41+
oidConfiguration = oidConfiguration
2442
)
2543
)
2644
}

app/src/main/java/cloud/pace/sdk/app/ui/components/loginscreen/LoginScreen.kt

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
package cloud.pace.sdk.app.ui.components.loginscreen
22

3-
import android.widget.Toast
43
import androidx.compose.foundation.BorderStroke
5-
import androidx.compose.foundation.Canvas
64
import androidx.compose.foundation.background
7-
import androidx.compose.foundation.clickable
85
import androidx.compose.foundation.layout.fillMaxSize
96
import androidx.compose.foundation.layout.fillMaxWidth
107
import androidx.compose.foundation.layout.padding
@@ -21,10 +18,8 @@ import androidx.compose.material.primarySurface
2118
import androidx.compose.runtime.Composable
2219
import androidx.compose.ui.Modifier
2320
import androidx.compose.ui.graphics.Color
24-
import androidx.compose.ui.platform.LocalContext
2521
import androidx.compose.ui.res.painterResource
2622
import androidx.compose.ui.res.stringResource
27-
import androidx.compose.ui.semantics.Role
2823
import androidx.compose.ui.unit.dp
2924
import androidx.compose.ui.unit.sp
3025
import androidx.constraintlayout.compose.ConstraintLayout
@@ -57,8 +52,7 @@ fun ShowLoginScreen(showDialog: Boolean, onDialogDismiss: () -> Unit, openLogin:
5752
.fillMaxSize()
5853
.padding(it)
5954
) {
60-
val (infoText, loginButton, termsText, termsAndPrivacyDivider, privacyText) = createRefs()
61-
val context = LocalContext.current
55+
val (infoText, loginButton) = createRefs()
6256
Text(
6357
text = stringResource(id = R.string.short_login_screen_information),
6458
modifier = Modifier
@@ -71,62 +65,13 @@ fun ShowLoginScreen(showDialog: Boolean, onDialogDismiss: () -> Unit, openLogin:
7165
LoginButton(
7266
modifier = Modifier.constrainAs(loginButton) {
7367
top.linkTo(infoText.bottom)
74-
bottom.linkTo(termsAndPrivacyDivider.top)
68+
bottom.linkTo(parent.bottom)
7569
start.linkTo(parent.start)
7670
end.linkTo(parent.end)
7771
},
7872
openLogin = openLogin
7973
)
8074

81-
Text(
82-
text = "Terms",
83-
color = Color(0, 102, 204),
84-
modifier = Modifier
85-
.padding(75.dp, 25.dp)
86-
.constrainAs(termsText) {
87-
bottom.linkTo(parent.bottom)
88-
start.linkTo(parent.start)
89-
}
90-
.clickable(
91-
enabled = true,
92-
role = Role.Button
93-
) {
94-
Toast
95-
.makeText(context, "Clicked on terms", Toast.LENGTH_SHORT)
96-
.show()
97-
}
98-
)
99-
100-
Canvas(
101-
modifier = Modifier
102-
.padding(0.dp, 32.dp)
103-
.constrainAs(termsAndPrivacyDivider) {
104-
start.linkTo(termsText.end)
105-
end.linkTo(privacyText.start)
106-
bottom.linkTo(parent.bottom)
107-
}
108-
) {
109-
drawCircle(
110-
color = Color.Black,
111-
radius = 6f
112-
)
113-
}
114-
115-
Text(
116-
text = "Privacy",
117-
color = Color(0, 102, 204),
118-
modifier = Modifier
119-
.padding(75.dp, 25.dp)
120-
.constrainAs(privacyText) {
121-
bottom.linkTo(parent.bottom)
122-
end.linkTo(parent.end)
123-
}
124-
.clickable(
125-
enabled = true,
126-
role = Role.Button
127-
) {}
128-
)
129-
13075
if (showDialog) {
13176
NoSupportedBrowserDialog(onDismiss = onDialogDismiss)
13277
}

app/src/main/res/values-de/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
33
<string name="app_name">PACE Example App</string>
4-
<string name="short_login_screen_information">Wilkommen in der PACE Example App.</string>
4+
<string name="short_login_screen_information">Willkommen in der PACE Example App.</string>
55
<string name="common_use_unknown">unbekannt</string>
66
<string name="common_use_unit_meter">m</string>
77
<string name="common_use_unit_kilometer">km</string>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package cloud.pace.sdk.api.user
2+
3+
import cloud.pace.sdk.api.request.BaseRequest
4+
import cloud.pace.sdk.utils.URL
5+
import com.squareup.moshi.Json
6+
import retrofit2.Call
7+
import retrofit2.http.Field
8+
import retrofit2.http.FormUrlEncoded
9+
import retrofit2.http.HeaderMap
10+
import retrofit2.http.POST
11+
12+
object PACETokenExchangeAPI {
13+
14+
data class TokenExchangeResponse(@Json(name = "access_token") val accessToken: String)
15+
16+
interface PACETokenExchangeService {
17+
@FormUrlEncoded
18+
@POST("/auth/realms/pace/protocol/openid-connect/token")
19+
fun tokenExchange(
20+
@HeaderMap headers: Map<String, String>,
21+
@Field("client_id") clientId: String,
22+
@Field("client_secret") clientSecret: String,
23+
@Field("grant_type") grantType: String,
24+
@Field("subject_issuer") subjectIssuer: String,
25+
@Field("subject_token") subjectToken: String,
26+
@Field("subject_token_type") subjectTokenType: String
27+
): Call<TokenExchangeResponse>
28+
}
29+
30+
open class Request : BaseRequest() {
31+
fun tokenExchange(
32+
clientId: String,
33+
clientSecret: String,
34+
grantType: String,
35+
subjectIssuer: String,
36+
subjectToken: String,
37+
subjectTokenType: String
38+
): Call<TokenExchangeResponse> {
39+
val headers = headers(false, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded")
40+
41+
return retrofit(URL.paceID)
42+
.create(PACETokenExchangeService::class.java)
43+
.tokenExchange(
44+
headers = headers,
45+
clientId = clientId,
46+
clientSecret = clientSecret,
47+
grantType = grantType,
48+
subjectIssuer = subjectIssuer,
49+
subjectToken = subjectToken,
50+
subjectTokenType = subjectTokenType
51+
)
52+
}
53+
}
54+
55+
fun tokenExchange(
56+
clientId: String,
57+
clientSecret: String,
58+
grantType: String = "urn:ietf:params:oauth:grant-type:token-exchange",
59+
subjectIssuer: String,
60+
subjectToken: String,
61+
subjectTokenType: String = "urn:ietf:params:oauth:token-type:access_token"
62+
) = Request().tokenExchange(
63+
clientId = clientId,
64+
clientSecret = clientSecret,
65+
grantType = grantType,
66+
subjectIssuer = subjectIssuer,
67+
subjectToken = subjectToken,
68+
subjectTokenType = subjectTokenType
69+
)
70+
}

library/src/main/java/cloud/pace/sdk/idkit/authorization/AuthorizationManager.kt

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import androidx.lifecycle.ProcessLifecycleOwner
1919
import cloud.pace.sdk.PACECloudSDK
2020
import cloud.pace.sdk.R
2121
import cloud.pace.sdk.api.API
22+
import cloud.pace.sdk.api.user.PACETokenExchangeAPI.tokenExchange
2223
import cloud.pace.sdk.appkit.AppKit
2324
import cloud.pace.sdk.idkit.authorization.integrated.AuthorizationWebViewActivity
2425
import cloud.pace.sdk.idkit.model.FailedRetrievingConfigurationWhileDiscovering
26+
import cloud.pace.sdk.idkit.model.FailedRetrievingExchangedToken
2527
import cloud.pace.sdk.idkit.model.FailedRetrievingSessionWhileAuthorizing
2628
import cloud.pace.sdk.idkit.model.FailedRetrievingSessionWhileEnding
2729
import cloud.pace.sdk.idkit.model.InternalError
@@ -30,6 +32,7 @@ import cloud.pace.sdk.idkit.model.NoSupportedBrowser
3032
import cloud.pace.sdk.idkit.model.OIDConfiguration
3133
import cloud.pace.sdk.idkit.model.OperationCanceled
3234
import cloud.pace.sdk.idkit.model.ServiceConfiguration
35+
import cloud.pace.sdk.idkit.model.TokenExchangeConfiguration
3336
import cloud.pace.sdk.idkit.model.toAuthorizationServiceConfiguration
3437
import cloud.pace.sdk.idkit.userinfo.UserInfoApiClient
3538
import cloud.pace.sdk.idkit.userinfo.UserInfoResponse
@@ -42,6 +45,7 @@ import cloud.pace.sdk.utils.Ok
4245
import cloud.pace.sdk.utils.SetupLogger
4346
import cloud.pace.sdk.utils.Success
4447
import cloud.pace.sdk.utils.Theme
48+
import cloud.pace.sdk.utils.enqueue
4549
import cloud.pace.sdk.utils.getResultFor
4650
import cloud.pace.sdk.utils.resumeIfActive
4751
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -68,6 +72,7 @@ internal class AuthorizationManager(
6872
private lateinit var clientId: String
6973
private lateinit var configuration: OIDConfiguration
7074
private lateinit var authorizationRequest: AuthorizationRequest
75+
private var exchangedAccessToken: String? = null
7176

7277
internal fun setup(clientId: String, configuration: OIDConfiguration) {
7378
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
@@ -315,8 +320,7 @@ internal class AuthorizationManager(
315320
completion(Failure(exception))
316321
}
317322
response != null -> {
318-
API.addAuthorizationHeader(null)
319-
sessionHolder.clearSessionAndPreferences()
323+
clearSession()
320324
completion(Success(Unit))
321325
}
322326
else -> {
@@ -335,7 +339,13 @@ internal class AuthorizationManager(
335339

336340
internal fun containsException(intent: Intent) = intent.hasExtra(AuthorizationException.EXTRA_EXCEPTION)
337341

338-
internal fun cachedToken() = sessionHolder.cachedToken()
342+
internal fun cachedToken(): String? {
343+
return if (configuration.tokenExchangeConfig == null) {
344+
sessionHolder.cachedToken()
345+
} else {
346+
exchangedAccessToken
347+
}
348+
}
339349

340350
internal fun userInfo(additionalHeaders: Map<String, String>? = null, additionalParameters: Map<String, String>? = null, completion: (Completion<UserInfoResponse>) -> Unit) {
341351
userInfoApi.getUserInfo(additionalHeaders, additionalParameters, completion)
@@ -398,10 +408,16 @@ internal class AuthorizationManager(
398408
completion(Failure(exception))
399409
}
400410
tokenResponse != null -> {
401-
val accessToken = sessionHolder.cachedToken()
402-
accessToken?.let { API.addAuthorizationHeader(it) }
403-
completion(Success(accessToken))
404-
Timber.i("Token refresh successful")
411+
val tokenExchangeConfig = configuration.tokenExchangeConfig
412+
if (tokenExchangeConfig == null) {
413+
val accessToken = sessionHolder.cachedToken()
414+
accessToken?.let { API.addAuthorizationHeader(it) }
415+
completion(Success(accessToken))
416+
Timber.i("Token refresh successful")
417+
} else {
418+
Timber.i("Try to exchange token...")
419+
exchangeToken(sessionHolder.cachedToken(), tokenExchangeConfig, completion)
420+
}
405421
}
406422
else -> {
407423
val throwable = FailedRetrievingSessionWhileAuthorizing
@@ -411,6 +427,40 @@ internal class AuthorizationManager(
411427
}
412428
}
413429

430+
private fun exchangeToken(externalToken: String?, tokenExchangeConfiguration: TokenExchangeConfiguration, completion: (Completion<String?>) -> Unit) {
431+
if (externalToken == null) {
432+
val throwable = FailedRetrievingSessionWhileAuthorizing
433+
Timber.w(throwable, "Failed to handle token response")
434+
completion(Failure(throwable))
435+
return
436+
}
437+
438+
tokenExchange(
439+
clientId = tokenExchangeConfiguration.clientId,
440+
clientSecret = tokenExchangeConfiguration.clientSecret,
441+
subjectIssuer = tokenExchangeConfiguration.issuerId,
442+
subjectToken = externalToken
443+
).enqueue {
444+
onResponse = {
445+
val accessToken = it.body()?.accessToken
446+
if (accessToken != null) {
447+
API.addAuthorizationHeader(accessToken)
448+
exchangedAccessToken = accessToken
449+
completion(Success(accessToken))
450+
Timber.i("Token exchange successful")
451+
} else {
452+
clearSession()
453+
completion(Failure(FailedRetrievingExchangedToken))
454+
}
455+
}
456+
457+
onFailure = {
458+
clearSession()
459+
completion(Failure(FailedRetrievingExchangedToken))
460+
}
461+
}
462+
}
463+
414464
private fun showNoSupportedBrowserToast() {
415465
Toast.makeText(context, R.string.no_supported_browser_toast, Toast.LENGTH_LONG).show()
416466
}
@@ -434,6 +484,12 @@ internal class AuthorizationManager(
434484
return authorizationService.createCustomTabsIntentBuilder().setColorScheme(colorScheme).build()
435485
}
436486

487+
private fun clearSession() {
488+
API.addAuthorizationHeader(null)
489+
sessionHolder.clearSessionAndPreferences()
490+
exchangedAccessToken = null
491+
}
492+
437493
override fun onDestroy(owner: LifecycleOwner) {
438494
// This must be called to avoid memory leaks.
439495
authorizationService.dispose()

0 commit comments

Comments
 (0)