Skip to content

Commit 96ea8ac

Browse files
authored
Persist login flow (#148)
* refactor: split up getAccessToken() * feat: persist login state when application killed * make continueLogin with uri public
1 parent 03c5b1a commit 96ea8ac

File tree

44 files changed

+749
-242
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+749
-242
lines changed

docs/ios/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ let client = OpenIdConnectClient(
6969

7070
Request access token using code auth flow:
7171
```swift
72-
let flow = CodeAuthFlow(client: client)
72+
let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false)
73+
let flow = factory.createAuthFlow(client: client)
7374
do {
7475
let tokens = try await flow.getAccessToken()
7576
} catch {
@@ -93,8 +94,9 @@ try await client.endSession(idToken: idToken) { requestBuilder in
9394
requestBuilder.url.parameters.append(name: "custom_parameter", value: "value")
9495
}
9596
// endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect)
96-
let flow = CodeAuthFlow(client: client)
97-
try await flow.endSession(idToken: "<idToken>", configureEndSessionUrl: { urlBuilder in
97+
let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false)
98+
let flow = factory.createAuthFlow(client: client)
99+
try await flow.endSession(idToken: "<idToken>", configureEndSessionUrl: { urlBuilder in
98100
})
99101
```
100102

docs/setup-android.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ class MainActivity : ComponentActivity() {
3838
> will attach to the ComponentActivity's lifecycle.
3939
> If you don't use ComponentActivity, you'll need to implement your own Factory.
4040
41+
## Login/Logout continuation
42+
As the actual authentication is performed in a Web Browser, it is possible, especially on low-end devices, that your application is terminated while in background.
43+
This behaviour can be forced by using ```adb shell am kill <app id>```.
44+
To continue the login flow on application restart, call ```authFlow.continueLogin()``` on startup:
45+
```
46+
if (authFlow.canContinueLogin()) {
47+
val tokens = authFlow.continueLogin(configureTokenExchange = null)
48+
// save tokens
49+
}
50+
```
51+
52+
To continue a logout flow on application restart:
53+
```
54+
if (endSessionFlow.canContinueLogout()) {
55+
endSessionFlow.continueLogout()
56+
// clear tokens
57+
}
58+
```
59+
4160
## Verified App-Links as Redirect Url
4261
If you want to use [https redirect links instead of custom schemes](https://github.com/kalinjul/kotlin-multiplatform-oidc/issues/46), you can do so by replacing the original intent filter in your AndroidManifest.xml:
4362

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ androidx-datastore = { module = "androidx.datastore:datastore-preferences", vers
6363
androidx-browser = { module = "androidx.browser:browser", version = "1.9.0" }
6464
androidx-security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" }
6565
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoKtx" }
66+
androidx-datastore-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidxDataStore" }
6667
material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" }
6768

6869
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }

oidc-appsupport/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ kotlin {
3232
dependencies {
3333
api(projects.oidcCore)
3434
api(projects.oidcTokenstore)
35+
36+
implementation(projects.oidcPreferences)
3537
}
3638
}
3739

@@ -45,6 +47,7 @@ kotlin {
4547
implementation(libs.androidx.activity.compose)
4648
implementation(libs.androidx.core.ktx)
4749
implementation(libs.androidx.browser)
50+
implementation(libs.androidx.datastore)
4851
}
4952
}
5053

oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import org.publicvalue.multiplatform.oidc.appsupport.customtab.CustomTabFlow
1414
import org.publicvalue.multiplatform.oidc.appsupport.customtab.getCustomTabProviders
1515
import org.publicvalue.multiplatform.oidc.appsupport.webview.WebViewFlow
1616
import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow
17+
import org.publicvalue.multiplatform.oidc.preferences.Preferences
18+
import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore
1719

1820
/**
1921
* Factory to create an Auth Flow on Android.
@@ -41,11 +43,12 @@ class AndroidCodeAuthFlowFactory(
4143
*/
4244
private val ephemeralSession: Boolean = false,
4345
/** preferred custom tab providers, list of package names in order of priority. Check [Browser][org.publicvalue.multiplatform.oidc.appsupport.customtab.Browser] for example values. **/
44-
private val customTabProviderPriority: List<String> = listOf()
46+
private val customTabProviderPriority: List<String> = listOf(),
4547
): CodeAuthFlowFactory {
4648

4749
private lateinit var activityResultLauncher: ActivityResultLauncherSuspend<Intent, ActivityResult>
4850
private lateinit var context: Context
51+
private lateinit var preferences: Preferences
4952

5053
private val resultFlow: MutableStateFlow<ActivityResult?> = MutableStateFlow(null)
5154

@@ -80,6 +83,7 @@ class AndroidCodeAuthFlowFactory(
8083
}
8184
)
8285
this.context = activity.applicationContext
86+
this.preferences = PreferencesDataStore(context.dataStore)
8387
}
8488

8589
override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow {
@@ -106,7 +110,8 @@ class AndroidCodeAuthFlowFactory(
106110
}
107111
return PlatformCodeAuthFlow(
108112
client = client,
109-
webFlow = webFlow
113+
webFlow = webFlow,
114+
preferences = preferences
110115
)
111116
}
112117

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.publicvalue.multiplatform.oidc.appsupport
2+
3+
import android.content.Context
4+
import androidx.datastore.preferences.preferencesDataStore
5+
import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME
6+
7+
internal val Context.dataStore by preferencesDataStore(
8+
name = PREFERENCES_FILENAME
9+
)

oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import androidx.core.net.toUri
1717
import androidx.core.view.ViewCompat
1818
import androidx.core.view.WindowInsetsCompat
1919
import androidx.core.view.updateLayoutParams
20+
import io.ktor.http.Url
21+
import kotlinx.coroutines.runBlocking
2022
import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect
23+
import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore
24+
import org.publicvalue.multiplatform.oidc.preferences.setResponseUri
2125

2226
internal const val EXTRA_KEY_USEWEBVIEW = "usewebview"
2327
internal const val EXTRA_KEY_EPHEMERAL_SESSION = "ephemeral_session"
@@ -48,7 +52,8 @@ class HandleRedirectActivity : ComponentActivity() {
4852

4953
@ExperimentalOpenIdConnect
5054
var createWebView: ComponentActivity.(redirectUrl: String?) -> WebView = { redirectUrl ->
51-
WebView(this).apply {
55+
val context = this
56+
WebView(context).apply {
5257
configureWebView(this)
5358
webChromeClient = WebChromeClient()
5459
webViewClient = object : WebViewClient() {
@@ -58,8 +63,10 @@ class HandleRedirectActivity : ComponentActivity() {
5863
): Boolean {
5964
val requestedUrl = request?.url
6065
return if (requestedUrl != null && redirectUrl != null && requestedUrl.toString().startsWith(redirectUrl)) {
61-
intent.data = request.url
62-
setResult(RESULT_OK, intent)
66+
val preferences = PreferencesDataStore(context.dataStore)
67+
runBlocking {
68+
preferences.setResponseUri(Url(requestedUrl.toString()))
69+
}
6370
finish()
6471
true
6572
} else {
@@ -115,6 +122,10 @@ class HandleRedirectActivity : ComponentActivity() {
115122

116123
if (intent?.data != null) {
117124
// we're called by custom tab
125+
runBlocking {
126+
val preferences = PreferencesDataStore(this@HandleRedirectActivity.dataStore)
127+
preferences.setResponseUri(Url(intent?.data.toString()))
128+
}
118129
// create new intent for result to mitigate intent redirection vulnerability
119130
setResult(RESULT_OK, Intent().setData(intent?.data))
120131
finish()
Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,25 @@
11
package org.publicvalue.multiplatform.oidc.appsupport
22

33
import org.publicvalue.multiplatform.oidc.OpenIdConnectClient
4-
import org.publicvalue.multiplatform.oidc.OpenIdConnectException
5-
import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse
6-
import org.publicvalue.multiplatform.oidc.flows.AuthCodeResult
74
import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow
85
import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow
9-
import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse
6+
import org.publicvalue.multiplatform.oidc.preferences.Preferences
107
import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest
118
import org.publicvalue.multiplatform.oidc.types.EndSessionRequest
129

1310
actual class PlatformCodeAuthFlow internal constructor(
1411
private val webFlow: WebAuthenticationFlow,
1512
actual override val client: OpenIdConnectClient,
13+
actual override val preferences: Preferences
1614
) : CodeAuthFlow, EndSessionFlow {
1715

18-
// TODO extract common code
19-
actual override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse {
16+
actual override suspend fun startLoginFlow(request: AuthCodeRequest) {
2017
val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty())
21-
22-
return if (result is WebAuthenticationFlowResult.Success) {
23-
when (val error = getErrorResult<AuthCodeResult>(result.responseUri)) {
24-
null -> {
25-
val state = result.responseUri.parameters.get("state")
26-
val code = result.responseUri.parameters.get("code")
27-
Result.success(AuthCodeResult(code, state))
28-
}
29-
else -> {
30-
return error
31-
}
32-
}
33-
} else {
34-
// browser closed, no redirect
35-
Result.failure(OpenIdConnectException.AuthenticationCancelled())
36-
}
18+
throwAuthenticationIfCancelled(result)
3719
}
3820

39-
actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse {
21+
actual override suspend fun startLogoutFlow(request: EndSessionRequest) {
4022
val result = webFlow.startWebFlow(request.url, request.url.parameters.get("post_logout_redirect_uri").orEmpty())
41-
42-
return if (result is WebAuthenticationFlowResult.Success) {
43-
when (val error = getErrorResult<Unit>(result.responseUri)) {
44-
null -> {
45-
return EndSessionResponse.success(Unit)
46-
}
47-
else -> {
48-
return error
49-
}
50-
}
51-
} else {
52-
// browser closed, no redirect
53-
EndSessionResponse.failure(OpenIdConnectException.AuthenticationCancelled("Logout cancelled"))
54-
}
23+
throwEndsessionIfCancelled(result)
5524
}
5625
}

oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/CodeAuthFlowFactory.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import kotlin.experimental.ExperimentalObjCRefinement
77
import kotlin.native.HiddenFromObjC
88

99
@OptIn(ExperimentalObjCRefinement::class)
10-
@HiddenFromObjC
1110
interface CodeAuthFlowFactory {
1211
fun createAuthFlow(client: OpenIdConnectClient): CodeAuthFlow
1312
fun createEndSessionFlow(client: OpenIdConnectClient): EndSessionFlow

oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,19 @@ package org.publicvalue.multiplatform.oidc.appsupport
33
import io.ktor.http.Url
44
import org.publicvalue.multiplatform.oidc.OpenIdConnectClient
55
import org.publicvalue.multiplatform.oidc.OpenIdConnectException
6-
import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse
76
import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow
87
import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow
9-
import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse
8+
import org.publicvalue.multiplatform.oidc.preferences.Preferences
109
import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest
1110
import org.publicvalue.multiplatform.oidc.types.EndSessionRequest
1211
import kotlin.contracts.ExperimentalContracts
1312
import kotlin.contracts.contract
1413

1514
expect class PlatformCodeAuthFlow: CodeAuthFlow, EndSessionFlow {
16-
// in kotlin 2.0, we need to implement methods in expect classes
17-
override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse
18-
override suspend fun endSession(request: EndSessionRequest): EndSessionResponse
15+
override suspend fun startLoginFlow(request: AuthCodeRequest)
16+
override suspend fun startLogoutFlow(request: EndSessionRequest)
1917
override val client: OpenIdConnectClient
18+
override val preferences: Preferences
2019
}
2120

2221
@OptIn(ExperimentalContracts::class)
@@ -37,4 +36,16 @@ internal fun <T> getErrorResult(responseUri: Url?): Result<T>? {
3736
return Result.failure(OpenIdConnectException.AuthenticationFailure(message = "No Uri in callback from browser (was ${responseUri})."))
3837
}
3938
return null
39+
}
40+
41+
internal fun throwAuthenticationIfCancelled(result: WebAuthenticationFlowResult) {
42+
if (result is WebAuthenticationFlowResult.Cancelled) {
43+
throw OpenIdConnectException.AuthenticationCancelled()
44+
}
45+
}
46+
47+
internal fun throwEndsessionIfCancelled(result: WebAuthenticationFlowResult) {
48+
if (result is WebAuthenticationFlowResult.Cancelled) {
49+
throw OpenIdConnectException.AuthenticationCancelled("Logout Cancelled")
50+
}
4051
}

0 commit comments

Comments
 (0)