From 7b5d099ccbb3ea06b9bcf3b7f9bcc28079d4cb26 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Wed, 12 Nov 2025 21:26:45 +0100 Subject: [PATCH 01/10] refactor: split up getAccessToken() --- .../oidc/appsupport/HandleRedirectActivity.kt | 6 +- .../PlatformCodeAuthFlow.android.kt | 23 +--- .../oidc/appsupport/PlatformCodeAuthFlow.kt | 9 +- .../appsupport/PlatformCodeAuthFlow.ios.kt | 20 +-- .../oidc/appsupport/WebSessionFlow.kt | 2 + .../appsupport/PlatformCodeAuthFlow.jvm.kt | 25 +--- .../oidc/appsupport/WebServerFlow.kt | 2 + .../appsupport/PlatformCodeAuthFlow.wasmJs.kt | 22 +--- .../oidc/appsupport/WebPopupFlow.kt | 2 + .../multiplatform/oidc/OpenIdConnectClient.kt | 2 +- .../multiplatform/oidc/flows/CodeAuthFlow.kt | 123 ++++++++++-------- .../multiplatform/oidc/flows/Preferences.kt | 9 ++ 12 files changed, 104 insertions(+), 141 deletions(-) create mode 100644 oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt index 5458dcc8..5a0bd777 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt @@ -17,7 +17,9 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams +import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect +import org.publicvalue.multiplatform.oidc.flows.Preferences internal const val EXTRA_KEY_USEWEBVIEW = "usewebview" internal const val EXTRA_KEY_EPHEMERAL_SESSION = "ephemeral_session" @@ -58,8 +60,7 @@ class HandleRedirectActivity : ComponentActivity() { ): Boolean { val requestedUrl = request?.url return if (requestedUrl != null && redirectUrl != null && requestedUrl.toString().startsWith(redirectUrl)) { - intent.data = request.url - setResult(RESULT_OK, intent) + Preferences.resultUri = Url(requestedUrl.toString()) finish() true } else { @@ -116,6 +117,7 @@ class HandleRedirectActivity : ComponentActivity() { if (intent?.data != null) { // we're called by custom tab // create new intent for result to mitigate intent redirection vulnerability + Preferences.resultUri = Url(intent?.data.toString()) setResult(RESULT_OK, Intent().setData(intent?.data)) finish() } else if (useWebView == true && url == null) { diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt index df337be6..e0b7714b 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt @@ -2,8 +2,6 @@ package org.publicvalue.multiplatform.oidc.appsupport import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResult import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse @@ -15,28 +13,13 @@ actual class PlatformCodeAuthFlow internal constructor( actual override val client: OpenIdConnectClient, ) : CodeAuthFlow, EndSessionFlow { - // TODO extract common code - actual override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse { + actual override suspend fun startLoginFlow(request: AuthCodeRequest) { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - val state = result.responseUri.parameters.get("state") - val code = result.responseUri.parameters.get("code") - Result.success(AuthCodeResult(code, state)) - } - else -> { - return error - } - } - } else { - // browser closed, no redirect - Result.failure(OpenIdConnectException.AuthenticationCancelled()) - } + throwIfCancelled(result) } actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { + // TODO persist endsession request and handle redirect intent accordingly val result = webFlow.startWebFlow(request.url, request.url.parameters.get("post_logout_redirect_uri").orEmpty()) return if (result is WebAuthenticationFlowResult.Success) { diff --git a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt index 05bb1dc0..6e832013 100644 --- a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt +++ b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt @@ -3,7 +3,6 @@ package org.publicvalue.multiplatform.oidc.appsupport import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse @@ -14,7 +13,7 @@ import kotlin.contracts.contract expect class PlatformCodeAuthFlow: CodeAuthFlow, EndSessionFlow { // in kotlin 2.0, we need to implement methods in expect classes - override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse + override suspend fun startLoginFlow(request: AuthCodeRequest) override suspend fun endSession(request: EndSessionRequest): EndSessionResponse override val client: OpenIdConnectClient } @@ -37,4 +36,10 @@ internal fun getErrorResult(responseUri: Url?): Result? { return Result.failure(OpenIdConnectException.AuthenticationFailure(message = "No Uri in callback from browser (was ${responseUri}).")) } return null +} + +internal fun throwIfCancelled(result: WebAuthenticationFlowResult) { + if (result is WebAuthenticationFlowResult.Cancelled) { + throw OpenIdConnectException.AuthenticationCancelled() + } } \ No newline at end of file diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt index 28c5412e..992f2f61 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt @@ -3,8 +3,6 @@ package org.publicvalue.multiplatform.oidc.appsupport import kotlinx.coroutines.CancellableContinuation import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResult import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse @@ -34,23 +32,9 @@ actual class PlatformCodeAuthFlow internal constructor( private val webFlow: WebAuthenticationFlow, ): CodeAuthFlow, EndSessionFlow { - actual override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse = wrapExceptions { + actual override suspend fun startLoginFlow(request: AuthCodeRequest) = wrapExceptions { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - val state = result.responseUri?.parameters?.get("state") - val code = result.responseUri?.parameters?.get("code") - Result.success(AuthCodeResult(code, state)) - } - else -> { - return error - } - } - } else { - // browser closed, no redirect - Result.failure(OpenIdConnectException.AuthenticationCancelled()) - } + throwIfCancelled(result) } actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse = wrapExceptions { diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt index 1616b0ae..222677ea 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.publicvalue.multiplatform.oidc.OpenIdConnectException +import org.publicvalue.multiplatform.oidc.flows.Preferences import platform.AuthenticationServices.ASWebAuthenticationSession import platform.AuthenticationServices.ASWebAuthenticationSessionCompletionHandler import platform.Foundation.NSError @@ -27,6 +28,7 @@ internal class WebSessionFlow( override fun invoke(p1: NSURL?, p2: NSError?) { if (p1 != null) { val url = Url(p1.toString()) // use sane url instead of NS garbage + Preferences.resultUri = url continuation.resumeIfActive(WebAuthenticationFlowResult.Success(url)) } else { // browser closed, no redirect. diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt index d54b9648..fa0f45b8 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt @@ -4,10 +4,6 @@ import io.ktor.http.* import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.appsupport.webserver.SimpleKtorWebserver -import org.publicvalue.multiplatform.oidc.appsupport.webserver.Webserver -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResult import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse @@ -26,26 +22,11 @@ actual class PlatformCodeAuthFlow internal constructor( private val webFlow: WebAuthenticationFlow ) : CodeAuthFlow, EndSessionFlow { - actual override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse { + actual override suspend fun startLoginFlow(request: AuthCodeRequest) { val redirectUrl = request.url.parameters.get("redirect_uri").orEmpty() - val result = webFlow.startWebFlow(request.url, redirectUrl) checkRedirectPort(Url(redirectUrl)) - - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - val state = result.responseUri.parameters.get("state") - val code = result.responseUri.parameters.get("code") - Result.success(AuthCodeResult(code, state)) - } - else -> { - return error - } - } - } else { - // doesn't return at all if unsuccessful, so this will not happen - Result.failure(OpenIdConnectException.AuthenticationCancelled()) - } + val result = webFlow.startWebFlow(request.url, redirectUrl) + throwIfCancelled(result) } actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt index 99f19938..a1501597 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.withContext import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.appsupport.webserver.Webserver +import org.publicvalue.multiplatform.oidc.flows.Preferences @ExperimentalOpenIdConnect internal class WebServerFlow( @@ -18,6 +19,7 @@ internal class WebServerFlow( async { openUrl(requestUrl) val response = webserver.startAndWaitForRedirect(redirectPath = Url(redirectUrl).encodedPath) + Preferences.resultUri = response webserver.stop() response }.await() diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt index d3c56c31..1aacd1fb 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt @@ -2,9 +2,6 @@ package org.publicvalue.multiplatform.oidc.appsupport import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectClient -import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResponse -import org.publicvalue.multiplatform.oidc.flows.AuthCodeResult import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse @@ -22,24 +19,9 @@ actual class PlatformCodeAuthFlow( private val webFlow = WebPopupFlow(windowTarget, windowFeatures, redirectOrigin) @ExperimentalOpenIdConnect - actual override suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse { + actual override suspend fun startLoginFlow(request: AuthCodeRequest) { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - val state = result.responseUri.parameters.get("state") - val code = result.responseUri.parameters.get("code") - Result.success(AuthCodeResult(code, state)) - } - else -> { - return error - } - } - } else { - // browser closed, no redirect - Result.failure(OpenIdConnectException.AuthenticationCancelled()) - } + throwIfCancelled(result) } actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt index 81a2ba6e..fa9cdc05 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt @@ -5,6 +5,7 @@ import kotlinx.browser.window import kotlinx.serialization.json.Json import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectException.TechnicalFailure +import org.publicvalue.multiplatform.oidc.flows.Preferences import org.w3c.dom.MessageEvent import org.w3c.dom.Window import org.w3c.dom.events.Event @@ -37,6 +38,7 @@ internal class WebPopupFlow( val urlString: String = Json.decodeFromString(getEventData(event)) val url = Url(urlString) window.removeEventListener("message", messageHandler) + Preferences.resultUri = url continuation.resume(WebAuthenticationFlowResult.Success(url)) } else { // Log an advisory but stay registered for the true callback diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClient.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClient.kt index a91508f7..4a9ddb38 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClient.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClient.kt @@ -82,7 +82,7 @@ interface OpenIdConnectClient { /** * RP-initiated logout. - * Just performs the POST request for logout, we skip the redirect part for convenience. + * Performs the POST request for logout. * * See: [OpenID Spec](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) * diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index 48ca4ac3..a5b7a2a9 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -2,6 +2,7 @@ package org.publicvalue.multiplatform.oidc.flows import io.ktor.client.request.HttpRequestBuilder import io.ktor.http.URLBuilder +import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest @@ -18,68 +19,48 @@ import kotlin.native.ObjCName * Implements the OAuth 2.0 Code Authorization Flow. * See: [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) * - * Implementations have to provide their own method [getAuthorizationCode] + * Implementations have to provide their own method [startLoginFlow] * as this requires user interaction (e.g. via browser). */ + @OptIn(ExperimentalObjCName::class) @ObjCName(swiftName = "AbstractCodeAuthFlow", name = "AbstractCodeAuthFlow", exact = true) interface CodeAuthFlow { val client: OpenIdConnectClient - /** - * For some reason the default parameter is not available in Platform implementations, - * so this provides an empty parameter method instead. - */ - @Suppress("unused") - @Throws(CancellationException::class, OpenIdConnectException::class) - suspend fun getAccessToken(): AccessTokenResponse = getAccessToken(null, null) - /** * Start the authorization flow to request an access token. * - * @param configure configuration closure to configure the http request builder with (will _not_ + * @param configureAuthUrl configuration closure to configure the auth url passed to browser + * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) */ @Suppress("unused") @Throws(CancellationException::class, OpenIdConnectException::class) - @Deprecated( - message = "Use getAccessToken(configureAuthUrl, configureTokenExchange) instead", - replaceWith = ReplaceWith("getAccessToken(configureAuthUrl = null, configureTokenExchange = configure)") - ) - suspend fun getAccessToken(configure: (HttpRequestBuilder.() -> Unit)? = null): AccessTokenResponse = getAccessToken(null, configure) + suspend fun getAccessToken( + configureAuthUrl: (URLBuilder.() -> Unit)? = null, + configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null + ): AccessTokenResponse { + startLogin() + return client.continueLogin( null) + } /** - * Start the authorization flow to request an access token. + * Start the authorization flow. * * @param configureAuthUrl configuration closure to configure the auth url passed to browser - * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ - * be used for discovery if necessary) */ @Suppress("unused") @Throws(CancellationException::class, OpenIdConnectException::class) - suspend fun getAccessToken( + suspend fun startLogin( configureAuthUrl: (URLBuilder.() -> Unit)? = null, - configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null - ): AccessTokenResponse = wrapExceptions { + ) = wrapExceptions { if (!client.config.discoveryUri.isNullOrEmpty()) { client.discover() } val request = client.createAuthorizationCodeRequest(configureAuthUrl) - return getAccessToken(request, configureTokenExchange) - } - - private suspend fun getAccessToken(request: AuthCodeRequest, configure: (HttpRequestBuilder.() -> Unit)?): AccessTokenResponse { - val codeResponse = getAuthorizationCode(request) - return codeResponse.fold( - onSuccess = { - exchangeToken( - client = client, request = request, result = it, configure = configure - ) - }, - onFailure = { - throw it - } - ) + Preferences.lastRequest = request + startLoginFlow(request) } /** @@ -88,26 +69,56 @@ interface CodeAuthFlow { * @return the Authorization Code. */ @Throws(CancellationException::class, OpenIdConnectException::class) - abstract suspend fun getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse + suspend fun startLoginFlow(request: AuthCodeRequest) +} - private suspend fun exchangeToken( - client: OpenIdConnectClient, - request: AuthCodeRequest, - result: AuthCodeResult, - configure: (HttpRequestBuilder.() -> Unit)? - ): AccessTokenResponse { - if (result.code != null) { - if (!request.validateState(result.state ?: "")) { - throw OpenIdConnectException.AuthenticationFailure("Invalid state") - } - val response = client.exchangeToken(request, result.code, configure) - val nonce = response.id_token?.parseJwt()?.payload?.nonce - if (!request.validateNonce(nonce ?: "")) { - throw OpenIdConnectException.AuthenticationFailure("Invalid nonce") - } - return response - } else { - throw OpenIdConnectException.AuthenticationFailure("No auth code", cause = null) - } +/** + * Continue login flow. + * + * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ + * be used for discovery if necessary) + */ +suspend fun OpenIdConnectClient.continueLogin(configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null): AccessTokenResponse { + val parsed = Preferences.resultUri!! + getError(parsed)?.let { throw it } + + val state = parsed.parameters["state"] + val code = parsed.parameters["code"] + + val request: AuthCodeRequest = Preferences.lastRequest!! + return exchangeToken( + request = request, + result = AuthCodeResult(code, state), + configure = configureTokenExchange + ).also { + Preferences.lastRequest = null + Preferences.resultUri = null + } +} + +private fun getError(responseUri: Url?): OpenIdConnectException.AuthenticationFailure? { + return if (responseUri?.parameters?.contains("error") == true) { + OpenIdConnectException.AuthenticationFailure( + message = responseUri.parameters.get("error") ?: "") + } else null +} + +private suspend fun OpenIdConnectClient.exchangeToken( + request: AuthCodeRequest, + result: AuthCodeResult, + configure: (HttpRequestBuilder.() -> Unit)? +): AccessTokenResponse { + if (result.code != null) { + if (!request.validateState(result.state ?: "")) { + throw OpenIdConnectException.AuthenticationFailure("Invalid state") + } + val response = exchangeToken(request, result.code, configure) + val nonce = response.id_token?.parseJwt()?.payload?.nonce + if (!request.validateNonce(nonce ?: "")) { + throw OpenIdConnectException.AuthenticationFailure("Invalid nonce") + } + return response + } else { + throw OpenIdConnectException.AuthenticationFailure("No auth code", cause = null) } } \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt new file mode 100644 index 00000000..45def9c4 --- /dev/null +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt @@ -0,0 +1,9 @@ +package org.publicvalue.multiplatform.oidc.flows + +import io.ktor.http.Url +import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest + +object Preferences { + var lastRequest: AuthCodeRequest? = null + var resultUri: Url? = null +} \ No newline at end of file From 67f39180154766809a955be8cadf442558565f29 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Thu, 13 Nov 2025 17:50:40 +0100 Subject: [PATCH 02/10] feat: persist login state when application killed --- gradle/libs.versions.toml | 1 + oidc-appsupport/build.gradle.kts | 3 + .../appsupport/AndroidCodeAuthFlowFactory.kt | 12 +- .../oidc/appsupport/DataStore.kt | 9 ++ .../oidc/appsupport/HandleRedirectActivity.kt | 17 ++- .../PlatformCodeAuthFlow.android.kt | 2 + .../oidc/appsupport/PlatformCodeAuthFlow.kt | 3 +- .../oidc/appsupport/IosCodeAuthFlowFactory.kt | 14 ++- .../appsupport/PlatformCodeAuthFlow.ios.kt | 2 + .../oidc/appsupport/WebSessionFlow.kt | 9 +- .../oidc/appsupport/JvmCodeAuthFlowFactory.kt | 9 +- .../appsupport/PlatformCodeAuthFlow.jvm.kt | 4 +- .../oidc/appsupport/WebServerFlow.kt | 6 +- .../appsupport/PlatformCodeAuthFlow.wasmJs.kt | 4 +- .../appsupport/WasmCodeAuthFlowFactory.kt | 9 +- .../oidc/appsupport/WebPopupFlow.kt | 12 +- oidc-core/build.gradle.kts | 9 +- .../oidc/OpenIdConnectClientConfig.kt | 11 +- .../multiplatform/oidc/flows/CodeAuthFlow.kt | 114 +++++++++++++----- .../multiplatform/oidc/flows/Pkce.kt | 12 +- .../multiplatform/oidc/flows/Preferences.kt | 9 -- .../oidc/preferences/Preferences.kt | 60 +++++++++ .../oidc/types/AuthCodeRequest.kt | 2 + oidc-preferences/build.gradle.kts | 49 ++++++++ .../preferences/PreferencesFactory.android.kt | 26 ++++ .../oidc/preferences/Preferences.kt | 8 ++ .../oidc/preferences/PreferencesFactory.kt | 3 + .../preferences/PreferencesFactory.ios.kt | 28 +++++ .../preferences/PreferencesFactory.jvm.kt | 16 +++ .../oidc/preferences/PreferencesDataStore.kt | 53 ++++++++ .../preferences/PreferencesFactory.wasmJs.kt | 7 ++ .../oidc/preferences/PreferencesSession.kt | 24 ++++ .../oidc/sample/home/HomePresenter.kt | 15 +++ settings.gradle.kts | 1 + 34 files changed, 493 insertions(+), 70 deletions(-) create mode 100644 oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/DataStore.kt delete mode 100644 oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt create mode 100644 oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt create mode 100644 oidc-preferences/build.gradle.kts create mode 100644 oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt create mode 100644 oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt create mode 100644 oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt create mode 100644 oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt create mode 100644 oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt create mode 100644 oidc-preferences/src/nonWasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesDataStore.kt create mode 100644 oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt create mode 100644 oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesSession.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3eb156a2..6b2744c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ androidx-datastore = { module = "androidx.datastore:datastore-preferences", vers androidx-browser = { module = "androidx.browser:browser", version = "1.9.0" } androidx-security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoKtx" } +androidx-datastore-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidxDataStore" } material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } diff --git a/oidc-appsupport/build.gradle.kts b/oidc-appsupport/build.gradle.kts index 3764f5a2..990fd36b 100644 --- a/oidc-appsupport/build.gradle.kts +++ b/oidc-appsupport/build.gradle.kts @@ -32,6 +32,8 @@ kotlin { dependencies { api(projects.oidcCore) api(projects.oidcTokenstore) + + implementation(projects.oidcPreferences) } } @@ -45,6 +47,7 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.browser) + implementation(libs.androidx.datastore) } } diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt index 42b2fd67..b9a41a41 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt @@ -14,6 +14,9 @@ import org.publicvalue.multiplatform.oidc.appsupport.customtab.CustomTabFlow import org.publicvalue.multiplatform.oidc.appsupport.customtab.getCustomTabProviders import org.publicvalue.multiplatform.oidc.appsupport.webview.WebViewFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory +import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore /** * Factory to create an Auth Flow on Android. @@ -41,11 +44,14 @@ class AndroidCodeAuthFlowFactory( */ private val ephemeralSession: Boolean = false, /** preferred custom tab providers, list of package names in order of priority. Check [Browser][org.publicvalue.multiplatform.oidc.appsupport.customtab.Browser] for example values. **/ - private val customTabProviderPriority: List = listOf() + private val customTabProviderPriority: List = listOf(), + /** factory used to create preferences to save session information during login process. **/ + private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { private lateinit var activityResultLauncher: ActivityResultLauncherSuspend private lateinit var context: Context + private lateinit var preferences: Preferences private val resultFlow: MutableStateFlow = MutableStateFlow(null) @@ -80,6 +86,7 @@ class AndroidCodeAuthFlowFactory( } ) this.context = activity.applicationContext + this.preferences = PreferencesDataStore(context.dataStore) } override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { @@ -106,7 +113,8 @@ class AndroidCodeAuthFlowFactory( } return PlatformCodeAuthFlow( client = client, - webFlow = webFlow + webFlow = webFlow, + preferences = preferences ) } diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/DataStore.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/DataStore.kt new file mode 100644 index 00000000..54e0197f --- /dev/null +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/DataStore.kt @@ -0,0 +1,9 @@ +package org.publicvalue.multiplatform.oidc.appsupport + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore +import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME + +internal val Context.dataStore by preferencesDataStore( + name = PREFERENCES_FILENAME +) \ No newline at end of file diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt index 5a0bd777..7ee54a52 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/HandleRedirectActivity.kt @@ -18,8 +18,10 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import io.ktor.http.Url +import kotlinx.coroutines.runBlocking import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect -import org.publicvalue.multiplatform.oidc.flows.Preferences +import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore +import org.publicvalue.multiplatform.oidc.preferences.setResponseUri internal const val EXTRA_KEY_USEWEBVIEW = "usewebview" internal const val EXTRA_KEY_EPHEMERAL_SESSION = "ephemeral_session" @@ -50,7 +52,8 @@ class HandleRedirectActivity : ComponentActivity() { @ExperimentalOpenIdConnect var createWebView: ComponentActivity.(redirectUrl: String?) -> WebView = { redirectUrl -> - WebView(this).apply { + val context = this + WebView(context).apply { configureWebView(this) webChromeClient = WebChromeClient() webViewClient = object : WebViewClient() { @@ -60,7 +63,10 @@ class HandleRedirectActivity : ComponentActivity() { ): Boolean { val requestedUrl = request?.url return if (requestedUrl != null && redirectUrl != null && requestedUrl.toString().startsWith(redirectUrl)) { - Preferences.resultUri = Url(requestedUrl.toString()) + val preferences = PreferencesDataStore(context.dataStore) + runBlocking { + preferences.setResponseUri(Url(requestedUrl.toString())) + } finish() true } else { @@ -116,8 +122,11 @@ class HandleRedirectActivity : ComponentActivity() { if (intent?.data != null) { // we're called by custom tab + runBlocking { + val preferences = PreferencesDataStore(this@HandleRedirectActivity.dataStore) + preferences.setResponseUri(Url(intent?.data.toString())) + } // create new intent for result to mitigate intent redirection vulnerability - Preferences.resultUri = Url(intent?.data.toString()) setResult(RESULT_OK, Intent().setData(intent?.data)) finish() } else if (useWebView == true && url == null) { diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt index e0b7714b..7f51c9ac 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt @@ -5,12 +5,14 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse +import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest actual class PlatformCodeAuthFlow internal constructor( private val webFlow: WebAuthenticationFlow, actual override val client: OpenIdConnectClient, + actual override val preferences: Preferences ) : CodeAuthFlow, EndSessionFlow { actual override suspend fun startLoginFlow(request: AuthCodeRequest) { diff --git a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt index 6e832013..f006b576 100644 --- a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt +++ b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt @@ -6,16 +6,17 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse +import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract expect class PlatformCodeAuthFlow: CodeAuthFlow, EndSessionFlow { - // in kotlin 2.0, we need to implement methods in expect classes override suspend fun startLoginFlow(request: AuthCodeRequest) override suspend fun endSession(request: EndSessionRequest): EndSessionResponse override val client: OpenIdConnectClient + override val preferences: Preferences } @OptIn(ExperimentalContracts::class) diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt index 957fd88b..6e5902f5 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt @@ -1,21 +1,29 @@ package org.publicvalue.multiplatform.oidc.appsupport +import androidx.datastore.preferences.core.Preferences import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow +import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME +import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory import kotlin.experimental.ExperimentalObjCRefinement @OptIn(ExperimentalObjCRefinement::class) @HiddenFromObjC @Suppress("unused") class IosCodeAuthFlowFactory( - private val ephemeralBrowserSession: Boolean = false + private val ephemeralBrowserSession: Boolean = false, + /** factory used to create preferences to save session information during login process. **/ + private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { + val preferences = preferencesFactory.create(PREFERENCES_FILENAME) return PlatformCodeAuthFlow( client = client, webFlow = WebSessionFlow( - ephemeralBrowserSession = ephemeralBrowserSession - ) + ephemeralBrowserSession = ephemeralBrowserSession, + preferences = preferences + ), + preferences = preferences ) } diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt index 992f2f61..1f8a687b 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt @@ -6,6 +6,7 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse +import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest import org.publicvalue.multiplatform.oidc.wrapExceptions @@ -30,6 +31,7 @@ actual class PlatformCodeAuthFlow internal constructor( actual override val client: OpenIdConnectClient, ephemeralBrowserSession: Boolean = false, private val webFlow: WebAuthenticationFlow, + actual override val preferences: Preferences, ): CodeAuthFlow, EndSessionFlow { actual override suspend fun startLoginFlow(request: AuthCodeRequest) = wrapExceptions { diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt index 222677ea..7a09b4ab 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebSessionFlow.kt @@ -3,9 +3,11 @@ package org.publicvalue.multiplatform.oidc.appsupport import io.ktor.http.Url import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import org.publicvalue.multiplatform.oidc.OpenIdConnectException -import org.publicvalue.multiplatform.oidc.flows.Preferences +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.setResponseUri import platform.AuthenticationServices.ASWebAuthenticationSession import platform.AuthenticationServices.ASWebAuthenticationSessionCompletionHandler import platform.Foundation.NSError @@ -13,6 +15,7 @@ import platform.Foundation.NSURL internal class WebSessionFlow( private val ephemeralBrowserSession: Boolean, + private val preferences: Preferences, ): WebAuthenticationFlow { /** * @return null if user cancelled the flow (closed the web view) @@ -28,7 +31,9 @@ internal class WebSessionFlow( override fun invoke(p1: NSURL?, p2: NSError?) { if (p1 != null) { val url = Url(p1.toString()) // use sane url instead of NS garbage - Preferences.resultUri = url + runBlocking { + preferences.setResponseUri(url) + } continuation.resumeIfActive(WebAuthenticationFlowResult.Success(url)) } else { // browser closed, no redirect. diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt index 45e7194a..1a44bf62 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt @@ -6,20 +6,27 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.appsupport.webserver.SimpleKtorWebserver import org.publicvalue.multiplatform.oidc.appsupport.webserver.Webserver import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow +import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME +import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory @Suppress("unused") @ExperimentalOpenIdConnect class JvmCodeAuthFlowFactory( private val webserverProvider: () -> Webserver = { SimpleKtorWebserver() }, private val openUrl: (Url) -> Unit = { it.openInBrowser() }, + /** factory used to create preferences to save session information during login process. **/ + private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { + val preferences = preferencesFactory.create(PREFERENCES_FILENAME) return PlatformCodeAuthFlow( client = client, webFlow = WebServerFlow( webserverProvider = webserverProvider, openUrl = openUrl, - ) + preferences = preferences + ), + preferences = preferences ) } diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt index fa0f45b8..23d531cf 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt @@ -7,6 +7,7 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse +import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest import java.awt.Desktop @@ -19,7 +20,8 @@ import kotlin.contracts.contract @ExperimentalOpenIdConnect actual class PlatformCodeAuthFlow internal constructor( actual override val client: OpenIdConnectClient, - private val webFlow: WebAuthenticationFlow + private val webFlow: WebAuthenticationFlow, + actual override val preferences: Preferences ) : CodeAuthFlow, EndSessionFlow { actual override suspend fun startLoginFlow(request: AuthCodeRequest) { diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt index a1501597..16fce186 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebServerFlow.kt @@ -6,12 +6,14 @@ import kotlinx.coroutines.async import kotlinx.coroutines.withContext import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.appsupport.webserver.Webserver -import org.publicvalue.multiplatform.oidc.flows.Preferences +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.setResponseUri @ExperimentalOpenIdConnect internal class WebServerFlow( private val webserverProvider: () -> Webserver, private val openUrl: (Url) -> Unit, + private val preferences: Preferences, ): WebAuthenticationFlow { override suspend fun startWebFlow(requestUrl: Url, redirectUrl: String): WebAuthenticationFlowResult { val webserver = webserverProvider() @@ -19,7 +21,7 @@ internal class WebServerFlow( async { openUrl(requestUrl) val response = webserver.startAndWaitForRedirect(redirectPath = Url(redirectUrl).encodedPath) - Preferences.resultUri = response + preferences.setResponseUri(response) webserver.stop() response }.await() diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt index 1aacd1fb..10156738 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt @@ -5,6 +5,7 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse +import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest @@ -14,9 +15,10 @@ actual class PlatformCodeAuthFlow( windowFeatures: String = "width=1000,height=800,resizable=yes,scrollbars=yes", redirectOrigin: String, actual override val client: OpenIdConnectClient, + actual override val preferences: Preferences, ) : CodeAuthFlow, EndSessionFlow { - private val webFlow = WebPopupFlow(windowTarget, windowFeatures, redirectOrigin) + private val webFlow = WebPopupFlow(windowTarget, windowFeatures, redirectOrigin, preferences) @ExperimentalOpenIdConnect actual override suspend fun startLoginFlow(request: AuthCodeRequest) { diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WasmCodeAuthFlowFactory.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WasmCodeAuthFlowFactory.kt index 2534f6a4..3713cfec 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WasmCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WasmCodeAuthFlowFactory.kt @@ -4,15 +4,20 @@ import kotlinx.browser.window import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow +import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME +import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory @ExperimentalOpenIdConnect class WasmCodeAuthFlowFactory( private val windowTarget: String = "", private val windowFeatures: String = "width=1000,height=800,resizable=yes,scrollbars=yes", - private val redirectOrigin: String = window.location.origin + private val redirectOrigin: String = window.location.origin, + /** factory used to create preferences to save session information during login process. **/ + private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { - return PlatformCodeAuthFlow(windowTarget, windowFeatures, redirectOrigin, client) + val preferences = preferencesFactory.create() + return PlatformCodeAuthFlow(windowTarget, windowFeatures, redirectOrigin, client, preferences) } override fun createEndSessionFlow(client: OpenIdConnectClient): EndSessionFlow { diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt index fa9cdc05..ad98be24 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt @@ -5,7 +5,8 @@ import kotlinx.browser.window import kotlinx.serialization.json.Json import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectException.TechnicalFailure -import org.publicvalue.multiplatform.oidc.flows.Preferences +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.setResponseUri import org.w3c.dom.MessageEvent import org.w3c.dom.Window import org.w3c.dom.events.Event @@ -17,12 +18,13 @@ internal class WebPopupFlow( private val windowTarget: String = "", private val windowFeatures: String = "width=1000,height=800,resizable=yes,scrollbars=yes", private val redirectOrigin: String, + private val preferences: Preferences, ): WebAuthenticationFlow { private class WindowHolder(var window: Window?) override suspend fun startWebFlow(requestUrl: Url, redirectUrl: String): WebAuthenticationFlowResult { - return suspendCoroutine { continuation -> + val result = suspendCoroutine { continuation -> val popupHolder = WindowHolder(null) lateinit var messageHandler: (Event) -> Unit @@ -38,7 +40,6 @@ internal class WebPopupFlow( val urlString: String = Json.decodeFromString(getEventData(event)) val url = Url(urlString) window.removeEventListener("message", messageHandler) - Preferences.resultUri = url continuation.resume(WebAuthenticationFlowResult.Success(url)) } else { // Log an advisory but stay registered for the true callback @@ -52,6 +53,11 @@ internal class WebPopupFlow( popupHolder.window = window.open(requestUrl.toString(), windowTarget, windowFeatures) ?: throw TechnicalFailure("Could not open popup", null) } + if(result is WebAuthenticationFlowResult.Success) { + // TODO refactor wasm code to just set preferences in event handler + preferences.setResponseUri(result.responseUri) + } + return result } internal companion object { diff --git a/oidc-core/build.gradle.kts b/oidc-core/build.gradle.kts index 91de772e..a017da2e 100644 --- a/oidc-core/build.gradle.kts +++ b/oidc-core/build.gradle.kts @@ -16,7 +16,7 @@ kotlin { configureIosTargets() configureWasmTarget() sourceSets { - val commonMain by getting { + commonMain { dependencies { implementation(libs.kotlinx.coroutines.core) @@ -26,22 +26,23 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(projects.oidcCrypto) + implementation(projects.oidcPreferences) } } - val jvmMain by getting { + jvmMain { dependencies { implementation(libs.ktor.client.okhttp) } } - val iosMain by getting { + iosMain { dependencies { implementation(libs.ktor.client.darwin) } } - val commonTest by getting { + commonTest { dependencies { implementation(kotlin("test")) implementation(libs.assertk) diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClientConfig.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClientConfig.kt index b88c8a1d..621055dd 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClientConfig.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectClientConfig.kt @@ -1,5 +1,6 @@ package org.publicvalue.multiplatform.oidc +import kotlinx.serialization.Serializable import org.publicvalue.multiplatform.oidc.types.CodeChallengeMethod import org.publicvalue.multiplatform.oidc.types.remote.OpenIdConnectConfiguration import kotlin.experimental.ExperimentalObjCName @@ -16,6 +17,9 @@ fun OpenIdConnectClientConfig(block: OpenIdConnectClientConfig.() -> Unit): Open return config } +@DslMarker +private annotation class EndpointMarker + /** * Configuration for an [OpenIdConnectClient]. * A configuration can also be built using [OpenIdConnectClient] builder function with block @@ -24,7 +28,8 @@ fun OpenIdConnectClientConfig(block: OpenIdConnectClientConfig.() -> Unit): Open @OptIn(ExperimentalObjCName::class) @EndpointMarker @ObjCName(swiftName = "OpenIdConnectClientConfig", name = "OpenIdConnectClientConfig", exact = true) -class OpenIdConnectClientConfig( +@Serializable +data class OpenIdConnectClientConfig( /** * If set, no further endpoints have to be configured. * You can override discovered endpoints in [endpoints] @@ -100,15 +105,13 @@ class OpenIdConnectClientConfig( } } -@DslMarker -private annotation class EndpointMarker - /** * Endpoint configuration */ @OptIn(ExperimentalObjCName::class) @EndpointMarker @ObjCName(swiftName = "Endpoints", name = "Endpoints", exact = true) +@Serializable data class Endpoints( var tokenEndpoint: String? = null, var authorizationEndpoint: String? = null, diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index a5b7a2a9..5ba8aa2b 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -5,6 +5,11 @@ import io.ktor.http.URLBuilder import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.clearOidcPreferences +import org.publicvalue.multiplatform.oidc.preferences.getAuthRequest +import org.publicvalue.multiplatform.oidc.preferences.getResponseUri +import org.publicvalue.multiplatform.oidc.preferences.setAuthRequest import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.parseJwt import org.publicvalue.multiplatform.oidc.types.remote.AccessTokenResponse @@ -27,10 +32,16 @@ import kotlin.native.ObjCName @ObjCName(swiftName = "AbstractCodeAuthFlow", name = "AbstractCodeAuthFlow", exact = true) interface CodeAuthFlow { val client: OpenIdConnectClient + val preferences: Preferences /** * Start the authorization flow to request an access token. * + * This may not return in some cases on Android if the application is terminated while the login website is shown. + * In this cases, call continueLogin() manually after your application restarts. + * + * @return the AccessTokenResponse. + * * @param configureAuthUrl configuration closure to configure the auth url passed to browser * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) @@ -42,77 +53,117 @@ interface CodeAuthFlow { configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null ): AccessTokenResponse { startLogin() - return client.continueLogin( null) + return continueLogin(configureTokenExchange) } /** * Start the authorization flow. * + * @return the Authorization Code Request + * * @param configureAuthUrl configuration closure to configure the auth url passed to browser */ @Suppress("unused") @Throws(CancellationException::class, OpenIdConnectException::class) suspend fun startLogin( configureAuthUrl: (URLBuilder.() -> Unit)? = null, - ) = wrapExceptions { + ): AuthCodeRequest = wrapExceptions { if (!client.config.discoveryUri.isNullOrEmpty()) { client.discover() } val request = client.createAuthorizationCodeRequest(configureAuthUrl) - Preferences.lastRequest = request + preferences.setAuthRequest(request) startLoginFlow(request) + return request } /** * Uses the request URL to open a browser and perform authorization. * @param request The request containing the url and relevant state information - * @return the Authorization Code. */ @Throws(CancellationException::class, OpenIdConnectException::class) suspend fun startLoginFlow(request: AuthCodeRequest) + + /** + * Check whether continueLogin can safely be called. + * + * @return true if startLogin() was called before and continueLogin() was not yet called. + */ + suspend fun canContinueLogin(): Boolean { + return preferences.getAuthRequest() != null && preferences.getResponseUri() != null + } + + /** + * Continue login flow. + * + * @throws OpenIdConnectException if canContinueLogin() returns false or if token exchange fails. + * + * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ + * be used for discovery if necessary) + */ + @Throws(OpenIdConnectException::class) + suspend fun continueLogin(configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null): AccessTokenResponse { + val authRequest = preferences.getAuthRequest() + val responseUri = preferences.getResponseUri() + if (authRequest == null) { + throw OpenIdConnectException.AuthenticationFailure("No authRequest present") + } + if (responseUri == null) { + throw OpenIdConnectException.AuthenticationFailure("No responseUri present") + } + preferences.clearOidcPreferences() + + val tokenResponse = client.continueLogin(authRequest, responseUri, configureTokenExchange) + return tokenResponse + } } + /** * Continue login flow. * * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) */ -suspend fun OpenIdConnectClient.continueLogin(configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null): AccessTokenResponse { - val parsed = Preferences.resultUri!! - getError(parsed)?.let { throw it } - - val state = parsed.parameters["state"] - val code = parsed.parameters["code"] +@Throws(OpenIdConnectException::class) +private suspend fun OpenIdConnectClient.continueLogin( + authCodeRequest: AuthCodeRequest, + responseUri: Url, + configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null +): AccessTokenResponse { + getError(responseUri)?.let { throw it } - val request: AuthCodeRequest = Preferences.lastRequest!! - return exchangeToken( - request = request, - result = AuthCodeResult(code, state), - configure = configureTokenExchange - ).also { - Preferences.lastRequest = null - Preferences.resultUri = null - } -} + val state = responseUri.parameters["state"] + val code = responseUri.parameters["code"] -private fun getError(responseUri: Url?): OpenIdConnectException.AuthenticationFailure? { - return if (responseUri?.parameters?.contains("error") == true) { - OpenIdConnectException.AuthenticationFailure( - message = responseUri.parameters.get("error") ?: "") - } else null + return continueLogin( + request = authCodeRequest, + result = AuthCodeResult(code = code, state = state), + configureTokenExchange = configureTokenExchange + ) } -private suspend fun OpenIdConnectClient.exchangeToken( +/** + * Continue login flow. + * + * @param request The original token request + * @param result [AuthCodeResult] containing the authorization code and state returned by the IDP + * @param configureTokenExchange Configuration closure to configure the http request builder with (will _not_ + * be used for discovery if necessary) + * + * @return The AccessTokenResponse + */ +@Throws(OpenIdConnectException::class) +suspend fun OpenIdConnectClient.continueLogin( request: AuthCodeRequest, result: AuthCodeResult, - configure: (HttpRequestBuilder.() -> Unit)? + configureTokenExchange: (HttpRequestBuilder.() -> Unit)? ): AccessTokenResponse { if (result.code != null) { if (!request.validateState(result.state ?: "")) { throw OpenIdConnectException.AuthenticationFailure("Invalid state") } - val response = exchangeToken(request, result.code, configure) + val response = exchangeToken(request, result.code, configureTokenExchange) val nonce = response.id_token?.parseJwt()?.payload?.nonce if (!request.validateNonce(nonce ?: "")) { throw OpenIdConnectException.AuthenticationFailure("Invalid nonce") @@ -121,4 +172,11 @@ private suspend fun OpenIdConnectClient.exchangeToken( } else { throw OpenIdConnectException.AuthenticationFailure("No auth code", cause = null) } +} + +private fun getError(responseUri: Url?): OpenIdConnectException.AuthenticationFailure? { + return if (responseUri?.parameters?.contains("error") == true) { + OpenIdConnectException.AuthenticationFailure( + message = responseUri.parameters.get("error") ?: "") + } else null } \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Pkce.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Pkce.kt index bc4e7222..072b2549 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Pkce.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Pkce.kt @@ -1,5 +1,6 @@ package org.publicvalue.multiplatform.oidc.flows +import kotlinx.serialization.Serializable import org.publicvalue.multiplatform.oidc.encodeForPKCE import org.publicvalue.multiplatform.oidc.s256 import org.publicvalue.multiplatform.oidc.secureRandomBytes @@ -12,13 +13,18 @@ import kotlin.native.ObjCName */ @OptIn(ExperimentalObjCName::class) @ObjCName(swiftName = "PKCE", name = "PKCE", exact = true) -class Pkce( - codeChallengeMethod: CodeChallengeMethod, +@Serializable +data class Pkce( /** For token request **/ val codeVerifier: String = verifier(), /** For authorization **/ - val codeChallenge: String = challenge(codeVerifier, codeChallengeMethod), + val codeChallenge: String ) { + constructor(codeChallengeMethod: CodeChallengeMethod, codeVerifier: String = verifier()) : this( + codeChallenge = challenge(codeVerifier = codeVerifier, method = codeChallengeMethod), + codeVerifier = codeVerifier + ) + private companion object { fun verifier(): String { val bytes = secureRandomBytes() diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt deleted file mode 100644 index 45def9c4..00000000 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/Preferences.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.publicvalue.multiplatform.oidc.flows - -import io.ktor.http.Url -import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest - -object Preferences { - var lastRequest: AuthCodeRequest? = null - var resultUri: Url? = null -} \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt new file mode 100644 index 00000000..666bf137 --- /dev/null +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt @@ -0,0 +1,60 @@ +package org.publicvalue.multiplatform.oidc.preferences + +import io.ktor.http.Url +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest + +object PreferencesStatic { + var authRequest: AuthCodeRequest? = null + var responseUri: Url? = null + + +} + +val PREFERENCES_FILENAME = "oidcsession.preferences_pb" + +private enum class PreferenceKeys(val key: String) { + LAST_REQUEST("lastRequest"), + RESPONSE_URI("responseUri") +} + +suspend fun Preferences.setAuthRequest(request: AuthCodeRequest) { +// PreferencesStatic.authRequest = request + put(PreferenceKeys.LAST_REQUEST.key, Json.encodeToString(request)) +} + +suspend fun Preferences.getAuthRequest(): AuthCodeRequest? { +// return PreferencesStatic.authRequest + return get(PreferenceKeys.LAST_REQUEST.key)?.let { Json.decodeFromStringOrNull(it) } +} + +suspend fun Preferences.setResponseUri(response: Url) { +// PreferencesStatic.responseUri = response + put(PreferenceKeys.RESPONSE_URI.key, Json.encodeToString(response)) +} + +suspend fun Preferences.getResponseUri(): Url? { +// return PreferencesStatic.responseUri + return get(PreferenceKeys.RESPONSE_URI.key)?.let { Json.decodeFromStringOrNull(it) } +} + +suspend fun Preferences.clearOidcPreferences() { + remove(PreferenceKeys.RESPONSE_URI.key) + remove(PreferenceKeys.LAST_REQUEST.key) +} + +inline fun Json.decodeFromStringOrNull(string: String): T? { + try { + return Json.decodeFromString(string) + } catch (e: Exception) { + when (e) { + is SerializationException, is IllegalArgumentException -> { + return null + } + else -> throw e + } + + } +} + diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/AuthCodeRequest.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/AuthCodeRequest.kt index b9fdbde2..47fe8593 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/AuthCodeRequest.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/AuthCodeRequest.kt @@ -1,6 +1,7 @@ package org.publicvalue.multiplatform.oidc.types import io.ktor.http.Url +import kotlinx.serialization.Serializable import org.publicvalue.multiplatform.oidc.OpenIdConnectClientConfig import org.publicvalue.multiplatform.oidc.flows.Pkce import kotlin.experimental.ExperimentalObjCName @@ -8,6 +9,7 @@ import kotlin.native.ObjCName @OptIn(ExperimentalObjCName::class) @ObjCName(swiftName = "AuthCodeRequest", name = "AuthCodeRequest", exact = true) +@Serializable data class AuthCodeRequest( val url: Url, val config: OpenIdConnectClientConfig, diff --git a/oidc-preferences/build.gradle.kts b/oidc-preferences/build.gradle.kts new file mode 100644 index 00000000..d081f3b1 --- /dev/null +++ b/oidc-preferences/build.gradle.kts @@ -0,0 +1,49 @@ +import org.publicvalue.convention.config.configureIosTargets +import org.publicvalue.convention.config.configureWasmTarget +import org.publicvalue.convention.config.exportKdoc + +plugins { + id("org.publicvalue.convention.android.library") + id("org.publicvalue.convention.kotlin.multiplatform") + id("org.publicvalue.convention.kotlin.multiplatform.mobile") +// alias(libs.plugins.kotlin.serialization) + id("org.publicvalue.convention.centralPublish") +} + +description = "Kotlin Multiplatform OIDC preferences library" + +kotlin { + jvm() + configureIosTargets() + configureWasmTarget() + sourceSets { + commonMain { + dependencies { +// implementation(libs.kotlinx.coroutines.core) +// + implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.serialization.kotlinx.json) + } + } + + wasmJsMain { + dependencies { + implementation(libs.kotlinx.browser) + } + } + + val nonWasmJsMain by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.androidx.datastore.core) + } + } + + jvmMain.get().dependsOn(nonWasmJsMain) + iosMain.get().dependsOn(nonWasmJsMain) + androidMain.get().dependsOn(nonWasmJsMain) + + } + + exportKdoc() +} \ No newline at end of file diff --git a/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt new file mode 100644 index 00000000..6d45acd7 --- /dev/null +++ b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt @@ -0,0 +1,26 @@ +package org.publicvalue.multiplatform.oidc.preferences + +import android.content.Context +import okio.Path.Companion.toPath +import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore + +actual class PreferencesFactory actual constructor() { + +// private val store: DataStore? = null +// /** +// * Filename must end with ".preferences_pb". It is not possible to create multiple instances like this. +// */ +// fun getOrCreate(context: Context, filename: String): PreferencesDataStore { +// println("YY getting preferences for application = ${context.applicationContext}") +// // get singleton preferencesDataStore +// val preferencesDataStore = preferencesDataStore(filename).getValue(context.applicationContext, ::store) +// return PreferencesDataStore(preferencesDataStore) +// } +// + /** + * Filename must end with ".preferences_pb". + */ + fun create(context: Context, filename: String): PreferencesDataStore { + return PreferencesDataStore(context.filesDir.resolve(filename).absolutePath.toPath()) + } +} \ No newline at end of file diff --git a/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt new file mode 100644 index 00000000..f050a6ce --- /dev/null +++ b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt @@ -0,0 +1,8 @@ +package org.publicvalue.multiplatform.oidc.preferences + +interface Preferences { + suspend fun get(key: String): String? + suspend fun put(key: String, value: String) + suspend fun remove(key: String) + suspend fun clear() +} diff --git a/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt new file mode 100644 index 00000000..a156319d --- /dev/null +++ b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt @@ -0,0 +1,3 @@ +package org.publicvalue.multiplatform.oidc.preferences + +expect class PreferencesFactory() \ No newline at end of file diff --git a/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt b/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt new file mode 100644 index 00000000..38bec86f --- /dev/null +++ b/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt @@ -0,0 +1,28 @@ +package org.publicvalue.multiplatform.oidc.preferences + +import kotlinx.cinterop.ExperimentalForeignApi +import okio.Path.Companion.toPath +import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +actual class PreferencesFactory actual constructor() { + /** + * Filename must end with ".preferences_pb" + */ + @OptIn(ExperimentalForeignApi::class) + fun create(filename: String): PreferencesDataStore { + val documentDirectory = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null + ) + + val path = requireNotNull(value = documentDirectory).path + "/$filename" + + return PreferencesDataStore(path.toPath()) + } +} \ No newline at end of file diff --git a/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt b/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt new file mode 100644 index 00000000..353452a6 --- /dev/null +++ b/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt @@ -0,0 +1,16 @@ +package org.publicvalue.multiplatform.oidc.preferences + +import okio.Path.Companion.toPath +import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore +import java.io.File + +actual class PreferencesFactory actual constructor() { + /** + * Filename must end with ".preferences_pb" + */ + fun create(filename: String): PreferencesDataStore { + val home = System.getProperty("user.home") + val path = File(home, "beihilfeapp/$filename").absolutePath.toPath() + return PreferencesDataStore(path) + } +} \ No newline at end of file diff --git a/oidc-preferences/src/nonWasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesDataStore.kt b/oidc-preferences/src/nonWasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesDataStore.kt new file mode 100644 index 00000000..c1f8258b --- /dev/null +++ b/oidc-preferences/src/nonWasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesDataStore.kt @@ -0,0 +1,53 @@ +package org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import okio.Path + +class PreferencesDataStore(private val dataStore: DataStore): org.publicvalue.multiplatform.oidc.preferences.Preferences { + + constructor(preferencesPath: Path) : this( + PreferenceDataStoreFactory.createWithPath( + migrations = emptyList(), + corruptionHandler = null, + scope = CoroutineScope(context = Dispatchers.IO + SupervisorJob()), + produceFile = { preferencesPath } + ) + ) + + override suspend fun get(key: String): String? { + val prefKey = stringPreferencesKey(key) + return dataStore.data + .map { prefs -> prefs[prefKey] } + .first() + } + + override suspend fun put(key: String, value: String) { + val prefKey = stringPreferencesKey(key) + dataStore.edit { prefs -> + prefs[prefKey] = value + } + } + + override suspend fun remove(key: String) { + val prefKey = stringPreferencesKey(key) + dataStore.edit { prefs -> + prefs.remove(prefKey) + } + } + + override suspend fun clear() { + dataStore.edit { prefs -> + prefs.clear() + } + } +} \ No newline at end of file diff --git a/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt new file mode 100644 index 00000000..35d641cc --- /dev/null +++ b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt @@ -0,0 +1,7 @@ +package org.publicvalue.multiplatform.oidc.preferences + +actual class PreferencesFactory actual constructor() { + fun create(): Preferences { + return PreferencesSession() + } +} \ No newline at end of file diff --git a/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesSession.kt b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesSession.kt new file mode 100644 index 00000000..5f80d57a --- /dev/null +++ b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesSession.kt @@ -0,0 +1,24 @@ +package org.publicvalue.multiplatform.oidc.preferences + +import kotlinx.browser.sessionStorage + +class PreferencesSession: Preferences { + override suspend fun get(key: String): String? { + return sessionStorage.getItem(key) + } + + override suspend fun put(key: String, value: String) { + sessionStorage.setItem(key, value) + } + + override suspend fun remove(key: String) { + sessionStorage.removeItem(key) + } + + /** + * On WASM, this will clear the whole local storage, which might not be intended. + */ + override suspend fun clear() { + sessionStorage.clear() + } +} \ No newline at end of file diff --git a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt index 312ff031..97bf5599 100644 --- a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt +++ b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt @@ -124,6 +124,7 @@ class HomePresenter( } if (result.isSuccess) HttpStatusCode.OK else null } else { + // maybe send bearer? client.endSession(idToken = it.id_token ?: "") } } @@ -162,6 +163,20 @@ class HomePresenter( } } + DisposableEffect(authFlowFactory) { + val client = createClient() + val authFlowFactory = client?.let { this@HomePresenter.authFlowFactory.createAuthFlow(it) } + scope.launch { + if (authFlowFactory != null && authFlowFactory.canContinueLogin()) { + catchErrorMessage { + val tokens = authFlowFactory.continueLogin(configureTokenExchange = null) + updateTokenResponse(tokens) + } + } + } + onDispose { } + } + return HomeUiState( loginEnabled = idpSettings?.isValid() == true && clientSettings?.isValid() == true, refreshEnabled = tokenData?.refreshToken != null, diff --git a/settings.gradle.kts b/settings.gradle.kts index 183a2ca8..5bd3f45a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -54,6 +54,7 @@ rootProject.name="kotlin-multiplatform-oidc" include(":oidc-crypto") include(":oidc-core") +include(":oidc-preferences") include(":oidc-appsupport") include(":oidc-tokenstore") include(":oidc-okhttp4") From dc9342dc1c29736c985a4b04ac61ee8e513aed6c Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Thu, 13 Nov 2025 18:02:56 +0100 Subject: [PATCH 03/10] make continueLogin with uri public --- .../multiplatform/oidc/flows/CodeAuthFlow.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index 5ba8aa2b..e9fd4b8a 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -122,12 +122,14 @@ interface CodeAuthFlow { /** * Continue login flow. * + * @param request The original auth code request + * @param responseUri URI returned by the IDP in response to the authorization, containing code and state. * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) */ @Throws(OpenIdConnectException::class) -private suspend fun OpenIdConnectClient.continueLogin( - authCodeRequest: AuthCodeRequest, +suspend fun OpenIdConnectClient.continueLogin( + request: AuthCodeRequest, responseUri: Url, configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null ): AccessTokenResponse { @@ -137,7 +139,7 @@ private suspend fun OpenIdConnectClient.continueLogin( val code = responseUri.parameters["code"] return continueLogin( - request = authCodeRequest, + request = request, result = AuthCodeResult(code = code, state = state), configureTokenExchange = configureTokenExchange ) @@ -146,7 +148,7 @@ private suspend fun OpenIdConnectClient.continueLogin( /** * Continue login flow. * - * @param request The original token request + * @param request The original auth code request * @param result [AuthCodeResult] containing the authorization code and state returned by the IDP * @param configureTokenExchange Configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) @@ -154,7 +156,7 @@ private suspend fun OpenIdConnectClient.continueLogin( * @return The AccessTokenResponse */ @Throws(OpenIdConnectException::class) -suspend fun OpenIdConnectClient.continueLogin( +private suspend fun OpenIdConnectClient.continueLogin( request: AuthCodeRequest, result: AuthCodeResult, configureTokenExchange: (HttpRequestBuilder.() -> Unit)? From 4fc6ee54122bea867490c7fd3cd21c307d2a794c Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Thu, 13 Nov 2025 18:07:52 +0100 Subject: [PATCH 04/10] refactor: remove comment --- .../oidc/preferences/PreferencesFactory.android.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt index 6d45acd7..7218cf3d 100644 --- a/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt +++ b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt @@ -6,17 +6,6 @@ import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatf actual class PreferencesFactory actual constructor() { -// private val store: DataStore? = null -// /** -// * Filename must end with ".preferences_pb". It is not possible to create multiple instances like this. -// */ -// fun getOrCreate(context: Context, filename: String): PreferencesDataStore { -// println("YY getting preferences for application = ${context.applicationContext}") -// // get singleton preferencesDataStore -// val preferencesDataStore = preferencesDataStore(filename).getValue(context.applicationContext, ::store) -// return PreferencesDataStore(preferencesDataStore) -// } -// /** * Filename must end with ".preferences_pb". */ From 35e90c36504205893b2c018ad1693100fd8109b9 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Fri, 14 Nov 2025 17:22:48 +0100 Subject: [PATCH 05/10] fix jvm --- .../appsupport/AndroidCodeAuthFlowFactory.kt | 3 -- .../oidc/appsupport/IosCodeAuthFlowFactory.kt | 4 +-- .../oidc/appsupport/JvmCodeAuthFlowFactory.kt | 3 +- .../multiplatform/oidc/flows/CodeAuthFlow.kt | 6 ++-- .../preferences/PreferencesFactory.android.kt | 8 ++++-- .../oidc/preferences/PreferencesFactory.kt | 28 ++++++++++++++++++- .../preferences/PreferencesFactory.ios.kt | 4 +-- .../preferences/PreferencesFactory.jvm.kt | 5 ++-- .../preferences/PreferencesFactory.wasmJs.kt | 2 +- .../multiplatform/oidc/sample/home/Home.kt | 13 +++++---- .../shared/src/jvmMain/kotlin/main.desktop.kt | 5 +++- 11 files changed, 57 insertions(+), 24 deletions(-) diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt index b9a41a41..fbbb53df 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/AndroidCodeAuthFlowFactory.kt @@ -15,7 +15,6 @@ import org.publicvalue.multiplatform.oidc.appsupport.customtab.getCustomTabProvi import org.publicvalue.multiplatform.oidc.appsupport.webview.WebViewFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.preferences.Preferences -import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore /** @@ -45,8 +44,6 @@ class AndroidCodeAuthFlowFactory( private val ephemeralSession: Boolean = false, /** preferred custom tab providers, list of package names in order of priority. Check [Browser][org.publicvalue.multiplatform.oidc.appsupport.customtab.Browser] for example values. **/ private val customTabProviderPriority: List = listOf(), - /** factory used to create preferences to save session information during login process. **/ - private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { private lateinit var activityResultLauncher: ActivityResultLauncherSuspend diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt index 6e5902f5..80e58fc6 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt @@ -1,6 +1,6 @@ package org.publicvalue.multiplatform.oidc.appsupport -import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.runBlocking import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME @@ -15,8 +15,8 @@ class IosCodeAuthFlowFactory( /** factory used to create preferences to save session information during login process. **/ private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { + private val preferences = runBlocking { preferencesFactory.getOrCreate(PREFERENCES_FILENAME) } override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { - val preferences = preferencesFactory.create(PREFERENCES_FILENAME) return PlatformCodeAuthFlow( client = client, webFlow = WebSessionFlow( diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt index 1a44bf62..5a61f496 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/JvmCodeAuthFlowFactory.kt @@ -1,6 +1,7 @@ package org.publicvalue.multiplatform.oidc.appsupport import io.ktor.http.* +import kotlinx.coroutines.runBlocking import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.appsupport.webserver.SimpleKtorWebserver @@ -18,7 +19,7 @@ class JvmCodeAuthFlowFactory( private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { - val preferences = preferencesFactory.create(PREFERENCES_FILENAME) + val preferences = runBlocking { preferencesFactory.getOrCreate(PREFERENCES_FILENAME) } return PlatformCodeAuthFlow( client = client, webFlow = WebServerFlow( diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index e9fd4b8a..4d4004a0 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -101,7 +101,7 @@ interface CodeAuthFlow { * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) */ - @Throws(OpenIdConnectException::class) + @Throws(OpenIdConnectException::class, CancellationException::class) suspend fun continueLogin(configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null): AccessTokenResponse { val authRequest = preferences.getAuthRequest() val responseUri = preferences.getResponseUri() @@ -127,7 +127,7 @@ interface CodeAuthFlow { * @param configureTokenExchange configuration closure to configure the http request builder with (will _not_ * be used for discovery if necessary) */ -@Throws(OpenIdConnectException::class) +@Throws(OpenIdConnectException::class, CancellationException::class) suspend fun OpenIdConnectClient.continueLogin( request: AuthCodeRequest, responseUri: Url, @@ -155,7 +155,7 @@ suspend fun OpenIdConnectClient.continueLogin( * * @return The AccessTokenResponse */ -@Throws(OpenIdConnectException::class) +@Throws(OpenIdConnectException::class, CancellationException::class) private suspend fun OpenIdConnectClient.continueLogin( request: AuthCodeRequest, result: AuthCodeResult, diff --git a/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt index 7218cf3d..28eb2771 100644 --- a/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt +++ b/oidc-preferences/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.android.kt @@ -4,12 +4,14 @@ import android.content.Context import okio.Path.Companion.toPath import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore -actual class PreferencesFactory actual constructor() { +actual class PreferencesFactory(context: Context) : PreferencesSingletonFactory() { + + private val filesDir = context.filesDir /** * Filename must end with ".preferences_pb". */ - fun create(context: Context, filename: String): PreferencesDataStore { - return PreferencesDataStore(context.filesDir.resolve(filename).absolutePath.toPath()) + override fun create(filename: String): Preferences { + return PreferencesDataStore(filesDir.resolve(filename).absolutePath.toPath()) } } \ No newline at end of file diff --git a/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt index a156319d..f272d4cb 100644 --- a/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt +++ b/oidc-preferences/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.kt @@ -1,3 +1,29 @@ package org.publicvalue.multiplatform.oidc.preferences -expect class PreferencesFactory() \ No newline at end of file +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.jvm.JvmStatic + +abstract class PreferencesSingletonFactory { + companion object { + @JvmStatic + protected val mutex = Mutex() + @JvmStatic + @kotlin.concurrent.Volatile + protected var INSTANCE: Preferences? = null + } + + suspend fun getOrCreate(filename: String): Preferences { + return INSTANCE ?: mutex.withLock { + if (INSTANCE == null) { + create(filename).also { + INSTANCE = it + } + } else INSTANCE!! + } + } + + protected abstract fun create(filename: String): Preferences +} + +expect class PreferencesFactory \ No newline at end of file diff --git a/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt b/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt index 38bec86f..5b8fbfb5 100644 --- a/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt +++ b/oidc-preferences/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.ios.kt @@ -7,12 +7,12 @@ import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSUserDomainMask -actual class PreferencesFactory actual constructor() { +actual class PreferencesFactory: PreferencesSingletonFactory() { /** * Filename must end with ".preferences_pb" */ @OptIn(ExperimentalForeignApi::class) - fun create(filename: String): PreferencesDataStore { + override fun create(filename: String): Preferences { val documentDirectory = NSFileManager.defaultManager.URLForDirectory( directory = NSDocumentDirectory, inDomain = NSUserDomainMask, diff --git a/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt b/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt index 353452a6..e9353134 100644 --- a/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt +++ b/oidc-preferences/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.jvm.kt @@ -4,11 +4,12 @@ import okio.Path.Companion.toPath import org.publicvalue.multiplatform.oidc.preferences.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore import java.io.File -actual class PreferencesFactory actual constructor() { +actual class PreferencesFactory: PreferencesSingletonFactory() { + /** * Filename must end with ".preferences_pb" */ - fun create(filename: String): PreferencesDataStore { + override fun create(filename: String): Preferences { val home = System.getProperty("user.home") val path = File(home, "beihilfeapp/$filename").absolutePath.toPath() return PreferencesDataStore(path) diff --git a/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt index 35d641cc..be0569f0 100644 --- a/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt +++ b/oidc-preferences/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/PreferencesFactory.wasmJs.kt @@ -1,6 +1,6 @@ package org.publicvalue.multiplatform.oidc.preferences -actual class PreferencesFactory actual constructor() { +actual class PreferencesFactory { fun create(): Preferences { return PreferencesSession() } diff --git a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/Home.kt b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/Home.kt index d0aab537..f7611dc3 100644 --- a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/Home.kt +++ b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/Home.kt @@ -132,14 +132,17 @@ fun Home( Button(onClick = { onLogoutClick(useWebFlow) }, - enabled = logoutEnabled) { + enabled = logoutEnabled + ) { Text("Logout") } - Button(onClick = { - onRefreshClick() - }, - enabled = refreshEnabled) { + Button( + onClick = { + onRefreshClick() + }, + enabled = refreshEnabled + ) { Text("Refresh") } } diff --git a/sample-app/shared/src/jvmMain/kotlin/main.desktop.kt b/sample-app/shared/src/jvmMain/kotlin/main.desktop.kt index 15144490..eafef61c 100644 --- a/sample-app/shared/src/jvmMain/kotlin/main.desktop.kt +++ b/sample-app/shared/src/jvmMain/kotlin/main.desktop.kt @@ -1,10 +1,13 @@ import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.rememberCircuitNavigator +import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect import org.publicvalue.multiplatform.oidc.appsupport.JvmCodeAuthFlowFactory import org.publicvalue.multiplatform.oidc.sample.screens.HomeScreen import org.publicvalue.multiplatform.oidc.settings.JvmSettingsStore +@OptIn(ExperimentalOpenIdConnect::class) @Composable fun MainView() { @@ -18,6 +21,6 @@ fun MainView() { backstack = backstack, navigator = navigator, settingsStore = settingsStore, - authFlowFactory = JvmCodeAuthFlowFactory() + authFlowFactory = remember { JvmCodeAuthFlowFactory() } ) } From 7aa3dbf65b807bcdd7174d8007d9b3e93900e9e5 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Fri, 14 Nov 2025 17:56:39 +0100 Subject: [PATCH 06/10] comment-out swift readme code as it's deprecated --- sample-app/ios-app/iosApp/Readme.swift | 106 ++++++++++++------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/sample-app/ios-app/iosApp/Readme.swift b/sample-app/ios-app/iosApp/Readme.swift index 4c82003f..09668a24 100644 --- a/sample-app/ios-app/iosApp/Readme.swift +++ b/sample-app/ios-app/iosApp/Readme.swift @@ -60,64 +60,64 @@ struct Readme { } // Request access token using code auth flow: - func _2() async { - let flow = CodeAuthFlow(client: client) - do { - let tokens = try await flow.getAccessToken() - } catch { - print(error) - } - } +// func _2() async { +// let flow = CodeAuthFlow(client: client) +// do { +// let tokens = try await flow.getAccessToken() +// } catch { +// print(error) +// } +// } - // Perform refresh or endSession: - func _3() async throws { - try await client.refreshToken(refreshToken: tokens.refresh_token!) - try await client.endSession(idToken: tokens.id_token!) - } +// // Perform refresh or endSession: +// func _3() async throws { +// try await client.refreshToken(refreshToken: tokens.refresh_token!) +// try await client.endSession(idToken: tokens.id_token!) +// } // customize endSession request: - func _3a() async throws { - try await client.endSession(idToken: "") { requestBuilder in - requestBuilder.headers.append(name: "X-CUSTOM-HEADER", value: "value") - requestBuilder.url.parameters.append(name: "custom_parameter", value: "value") - } - // endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect) - let flow = CodeAuthFlow(client: client) - try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in - }) - - } +// func _3a() async throws { +// try await client.endSession(idToken: "") { requestBuilder in +// requestBuilder.headers.append(name: "X-CUSTOM-HEADER", value: "value") +// requestBuilder.url.parameters.append(name: "custom_parameter", value: "value") +// } +// // endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect) +// let flow = CodeAuthFlow(client: client) +// try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in +// }) +// +// } // customize getAccessToken request: - func _3b() async throws { - let flow = CodeAuthFlow(client: client) - try await flow.getAccessToken( - configureAuthUrl: { urlBuilder in - urlBuilder.parameters.append(name: "prompt", value: "login") - }, - configureTokenExchange: { requestBuilder in - requestBuilder.headers.append(name: "additionalHeaderField", value: "value") - } - ) - } +// func _3b() async throws { +// let flow = CodeAuthFlow(client: client) +// try await flow.getAccessToken( +// configureAuthUrl: { urlBuilder in +// urlBuilder.parameters.append(name: "prompt", value: "login") +// }, +// configureTokenExchange: { requestBuilder in +// requestBuilder.headers.append(name: "additionalHeaderField", value: "value") +// } +// ) +// } // We provide simple JWT parsing: - func _4() { - let jwt = tokens.id_token.map { try! JwtParser.shared.parse(from: $0) } - print(jwt?.payload.aud) // print audience - print(jwt?.payload.iss) // print issuer - print(jwt?.payload.additionalClaims["email"]) // get claim - } - - // TokenStore - func _5() async throws { - let tokenstore = KeychainTokenStore() - try await tokenstore.saveTokens(tokens: tokens) - } - - // RefreshHandler - func _6() async throws { - let refreshHandler = TokenRefreshHandler(tokenStore: tokenstore) - try await refreshHandler.refreshAndSaveToken(client: client, oldAccessToken: oldAccessToken) // thread-safe refresh and save new tokens to store - } +// func _4() { +// let jwt = tokens.id_token.map { try! JwtParser.shared.parse(from: $0) } +// print(jwt?.payload.aud) // print audience +// print(jwt?.payload.iss) // print issuer +// print(jwt?.payload.additionalClaims["email"]) // get claim +// } +// +// // TokenStore +// func _5() async throws { +// let tokenstore = KeychainTokenStore() +// try await tokenstore.saveTokens(tokens: tokens) +// } +// +// // RefreshHandler +// func _6() async throws { +// let refreshHandler = TokenRefreshHandler(tokenStore: tokenstore) +// try await refreshHandler.refreshAndSaveToken(client: client, oldAccessToken: oldAccessToken) // thread-safe refresh and save new tokens to store +// } } From b8d395023a3dd842c8c6a8710e949b73f599e542 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Mon, 17 Nov 2025 10:16:01 +0100 Subject: [PATCH 07/10] fix wasm --- .../publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt index ad98be24..5bbcf267 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/WebPopupFlow.kt @@ -55,7 +55,9 @@ internal class WebPopupFlow( } if(result is WebAuthenticationFlowResult.Success) { // TODO refactor wasm code to just set preferences in event handler - preferences.setResponseUri(result.responseUri) + result.responseUri?.let { + preferences.setResponseUri(it) + } } return result } From 4a042cac3db8568db37e80c7ae31a37c56d3e537 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Mon, 17 Nov 2025 11:36:56 +0100 Subject: [PATCH 08/10] fix ios swift package --- docs/ios/README.md | 8 +- .../oidc/appsupport/CodeAuthFlowFactory.kt | 1 - .../oidc/appsupport/IosCodeAuthFlowFactory.kt | 9 +- .../CodeAuthFlowConvenienceExcentions.kt | 13 ++ .../multiplatform/oidc/flows/CodeAuthFlow.kt | 2 +- sample-app/ios-app/iosApp/Readme.swift | 113 +++++++++--------- 6 files changed, 84 insertions(+), 62 deletions(-) create mode 100644 oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/helper/CodeAuthFlowConvenienceExcentions.kt diff --git a/docs/ios/README.md b/docs/ios/README.md index a3e033a3..ab42e4bb 100644 --- a/docs/ios/README.md +++ b/docs/ios/README.md @@ -69,7 +69,8 @@ let client = OpenIdConnectClient( Request access token using code auth flow: ```swift -let flow = CodeAuthFlow(client: client) +let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false) +let flow = factory.createAuthFlow(client: client) do { let tokens = try await flow.getAccessToken() } catch { @@ -93,8 +94,9 @@ try await client.endSession(idToken: idToken) { requestBuilder in requestBuilder.url.parameters.append(name: "custom_parameter", value: "value") } // endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect) -let flow = CodeAuthFlow(client: client) - try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in +let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false) +let flow = factory.createAuthFlow(client: client) +try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in }) ``` diff --git a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/CodeAuthFlowFactory.kt b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/CodeAuthFlowFactory.kt index 2d9db892..42ea1052 100644 --- a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/CodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/CodeAuthFlowFactory.kt @@ -7,7 +7,6 @@ import kotlin.experimental.ExperimentalObjCRefinement import kotlin.native.HiddenFromObjC @OptIn(ExperimentalObjCRefinement::class) -@HiddenFromObjC interface CodeAuthFlowFactory { fun createAuthFlow(client: OpenIdConnectClient): CodeAuthFlow fun createEndSessionFlow(client: OpenIdConnectClient): EndSessionFlow diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt index 80e58fc6..1df56f70 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/IosCodeAuthFlowFactory.kt @@ -5,10 +5,11 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow import org.publicvalue.multiplatform.oidc.preferences.PREFERENCES_FILENAME import org.publicvalue.multiplatform.oidc.preferences.PreferencesFactory +import kotlin.experimental.ExperimentalObjCName import kotlin.experimental.ExperimentalObjCRefinement -@OptIn(ExperimentalObjCRefinement::class) -@HiddenFromObjC +@OptIn(ExperimentalObjCRefinement::class, ExperimentalObjCName::class) +@ObjCName("CodeAuthFlowFactory") @Suppress("unused") class IosCodeAuthFlowFactory( private val ephemeralBrowserSession: Boolean = false, @@ -16,6 +17,10 @@ class IosCodeAuthFlowFactory( private val preferencesFactory: PreferencesFactory = PreferencesFactory() ): CodeAuthFlowFactory { private val preferences = runBlocking { preferencesFactory.getOrCreate(PREFERENCES_FILENAME) } + + // constructor for swift-only library + constructor(ephemeralBrowserSession: Boolean) : this(ephemeralBrowserSession, preferencesFactory = PreferencesFactory()) + override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow { return PlatformCodeAuthFlow( client = client, diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/helper/CodeAuthFlowConvenienceExcentions.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/helper/CodeAuthFlowConvenienceExcentions.kt new file mode 100644 index 00000000..b634737b --- /dev/null +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/helper/CodeAuthFlowConvenienceExcentions.kt @@ -0,0 +1,13 @@ +package org.publicvalue.multiplatform.oidc.appsupport.helper + +import org.publicvalue.multiplatform.oidc.OpenIdConnectException +import org.publicvalue.multiplatform.oidc.appsupport.PlatformCodeAuthFlow +import org.publicvalue.multiplatform.oidc.types.remote.AccessTokenResponse +import kotlin.coroutines.cancellation.CancellationException + +@Throws(OpenIdConnectException::class, CancellationException::class) +suspend fun PlatformCodeAuthFlow.getAccessToken( +): AccessTokenResponse { + startLogin() + return continueLogin() +} \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index 4d4004a0..25d25264 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -52,7 +52,7 @@ interface CodeAuthFlow { configureAuthUrl: (URLBuilder.() -> Unit)? = null, configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null ): AccessTokenResponse { - startLogin() + startLogin(configureAuthUrl) return continueLogin(configureTokenExchange) } diff --git a/sample-app/ios-app/iosApp/Readme.swift b/sample-app/ios-app/iosApp/Readme.swift index 09668a24..ab94996a 100644 --- a/sample-app/ios-app/iosApp/Readme.swift +++ b/sample-app/ios-app/iosApp/Readme.swift @@ -60,64 +60,67 @@ struct Readme { } // Request access token using code auth flow: -// func _2() async { -// let flow = CodeAuthFlow(client: client) -// do { -// let tokens = try await flow.getAccessToken() -// } catch { -// print(error) -// } -// } + func _2() async { + let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false) + let flow = factory.createAuthFlow(client: client) + do { + let tokens = try await flow.getAccessToken() + } catch { + print(error) + } + } -// // Perform refresh or endSession: -// func _3() async throws { -// try await client.refreshToken(refreshToken: tokens.refresh_token!) -// try await client.endSession(idToken: tokens.id_token!) -// } + // Perform refresh or endSession: + func _3() async throws { + try await client.refreshToken(refreshToken: tokens.refresh_token!) + try await client.endSession(idToken: tokens.id_token!) + } - // customize endSession request: -// func _3a() async throws { -// try await client.endSession(idToken: "") { requestBuilder in -// requestBuilder.headers.append(name: "X-CUSTOM-HEADER", value: "value") -// requestBuilder.url.parameters.append(name: "custom_parameter", value: "value") -// } -// // endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect) -// let flow = CodeAuthFlow(client: client) -// try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in -// }) -// -// } +// customize endSession request: + func _3a() async throws { + try await client.endSession(idToken: "") { requestBuilder in + requestBuilder.headers.append(name: "X-CUSTOM-HEADER", value: "value") + requestBuilder.url.parameters.append(name: "custom_parameter", value: "value") + } + // endSession with Web flow (opens browser and handles post_logout_redirect_uri redirect) + let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false) + let flow = factory.createAuthFlow(client: client) + try await flow.endSession(idToken: "", configureEndSessionUrl: { urlBuilder in + }) + + } - // customize getAccessToken request: -// func _3b() async throws { -// let flow = CodeAuthFlow(client: client) -// try await flow.getAccessToken( -// configureAuthUrl: { urlBuilder in -// urlBuilder.parameters.append(name: "prompt", value: "login") -// }, -// configureTokenExchange: { requestBuilder in -// requestBuilder.headers.append(name: "additionalHeaderField", value: "value") -// } -// ) -// } +// customize getAccessToken request: + func _3b() async throws { + let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false) + let flow = factory.createAuthFlow(client: client) + try await flow.getAccessToken( + configureAuthUrl: { urlBuilder in + urlBuilder.parameters.append(name: "prompt", value: "login") + }, + configureTokenExchange: { requestBuilder in + requestBuilder.headers.append(name: "additionalHeaderField", value: "value") + } + ) + } // We provide simple JWT parsing: -// func _4() { -// let jwt = tokens.id_token.map { try! JwtParser.shared.parse(from: $0) } -// print(jwt?.payload.aud) // print audience -// print(jwt?.payload.iss) // print issuer -// print(jwt?.payload.additionalClaims["email"]) // get claim -// } -// -// // TokenStore -// func _5() async throws { -// let tokenstore = KeychainTokenStore() -// try await tokenstore.saveTokens(tokens: tokens) -// } -// -// // RefreshHandler -// func _6() async throws { -// let refreshHandler = TokenRefreshHandler(tokenStore: tokenstore) -// try await refreshHandler.refreshAndSaveToken(client: client, oldAccessToken: oldAccessToken) // thread-safe refresh and save new tokens to store -// } + func _4() { + let jwt = tokens.id_token.map { try! JwtParser.shared.parse(from: $0) } + print(jwt?.payload.aud) // print audience + print(jwt?.payload.iss) // print issuer + print(jwt?.payload.additionalClaims["email"]) // get claim + } + + // TokenStore + func _5() async throws { + let tokenstore = KeychainTokenStore() + try await tokenstore.saveTokens(tokens: tokens) + } + + // RefreshHandler + func _6() async throws { + let refreshHandler = TokenRefreshHandler(tokenStore: tokenstore) + try await refreshHandler.refreshAndSaveToken(client: client, oldAccessToken: oldAccessToken) // thread-safe refresh and save new tokens to store + } } From 7134803673c3022f9f301459b830e289564a0dfd Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Mon, 17 Nov 2025 12:21:49 +0100 Subject: [PATCH 09/10] refactor endsession flow to use persistent state --- .../PlatformCodeAuthFlow.android.kt | 22 +----- .../oidc/appsupport/PlatformCodeAuthFlow.kt | 11 ++- .../appsupport/PlatformCodeAuthFlow.ios.kt | 19 +---- .../appsupport/PlatformCodeAuthFlow.jvm.kt | 10 +-- .../appsupport/PlatformCodeAuthFlow.wasmJs.kt | 8 +- .../oidc/OpenIdConnectException.kt | 8 ++ .../multiplatform/oidc/flows/CodeAuthFlow.kt | 12 +-- .../oidc/flows/EndSessionFlow.kt | 73 ++++++++++++++++++- .../oidc/preferences/Preferences.kt | 29 ++++---- .../oidc/types/EndSessionRequest.kt | 2 + .../oidc/sample/home/HomePresenter.kt | 42 ++++++----- 11 files changed, 145 insertions(+), 91 deletions(-) diff --git a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt index 7f51c9ac..b834f230 100644 --- a/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt +++ b/oidc-appsupport/src/androidMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.android.kt @@ -1,10 +1,8 @@ package org.publicvalue.multiplatform.oidc.appsupport import org.publicvalue.multiplatform.oidc.OpenIdConnectClient -import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow -import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest @@ -17,25 +15,11 @@ actual class PlatformCodeAuthFlow internal constructor( actual override suspend fun startLoginFlow(request: AuthCodeRequest) { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - throwIfCancelled(result) + throwAuthenticationIfCancelled(result) } - actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { - // TODO persist endsession request and handle redirect intent accordingly + actual override suspend fun startLogoutFlow(request: EndSessionRequest) { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("post_logout_redirect_uri").orEmpty()) - - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - return EndSessionResponse.success(Unit) - } - else -> { - return error - } - } - } else { - // browser closed, no redirect - EndSessionResponse.failure(OpenIdConnectException.AuthenticationCancelled("Logout cancelled")) - } + throwEndsessionIfCancelled(result) } } diff --git a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt index f006b576..182c305f 100644 --- a/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt +++ b/oidc-appsupport/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.kt @@ -5,7 +5,6 @@ import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException import org.publicvalue.multiplatform.oidc.flows.CodeAuthFlow import org.publicvalue.multiplatform.oidc.flows.EndSessionFlow -import org.publicvalue.multiplatform.oidc.flows.EndSessionResponse import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest @@ -14,7 +13,7 @@ import kotlin.contracts.contract expect class PlatformCodeAuthFlow: CodeAuthFlow, EndSessionFlow { override suspend fun startLoginFlow(request: AuthCodeRequest) - override suspend fun endSession(request: EndSessionRequest): EndSessionResponse + override suspend fun startLogoutFlow(request: EndSessionRequest) override val client: OpenIdConnectClient override val preferences: Preferences } @@ -39,8 +38,14 @@ internal fun getErrorResult(responseUri: Url?): Result? { return null } -internal fun throwIfCancelled(result: WebAuthenticationFlowResult) { +internal fun throwAuthenticationIfCancelled(result: WebAuthenticationFlowResult) { if (result is WebAuthenticationFlowResult.Cancelled) { throw OpenIdConnectException.AuthenticationCancelled() } +} + +internal fun throwEndsessionIfCancelled(result: WebAuthenticationFlowResult) { + if (result is WebAuthenticationFlowResult.Cancelled) { + throw OpenIdConnectException.AuthenticationCancelled("Logout Cancelled") + } } \ No newline at end of file diff --git a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt index 1f8a687b..eaa1a7bc 100644 --- a/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt +++ b/oidc-appsupport/src/iosMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.ios.kt @@ -36,25 +36,12 @@ actual class PlatformCodeAuthFlow internal constructor( actual override suspend fun startLoginFlow(request: AuthCodeRequest) = wrapExceptions { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - throwIfCancelled(result) + throwAuthenticationIfCancelled(result) } - actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse = wrapExceptions { + actual override suspend fun startLogoutFlow(request: EndSessionRequest) = wrapExceptions { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("post_logout_redirect_uri").orEmpty()) - - return if (result is WebAuthenticationFlowResult.Success) { - when (val error = getErrorResult(result.responseUri)) { - null -> { - return EndSessionResponse.success(Unit) - } - else -> { - return error - } - } - } else { - // browser closed, no redirect - EndSessionResponse.failure(OpenIdConnectException.AuthenticationCancelled("Logout cancelled")) - } + throwEndsessionIfCancelled(result) } } diff --git a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt index 23d531cf..b7dedfff 100644 --- a/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt +++ b/oidc-appsupport/src/jvmMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.jvm.kt @@ -28,16 +28,14 @@ actual class PlatformCodeAuthFlow internal constructor( val redirectUrl = request.url.parameters.get("redirect_uri").orEmpty() checkRedirectPort(Url(redirectUrl)) val result = webFlow.startWebFlow(request.url, redirectUrl) - throwIfCancelled(result) + throwAuthenticationIfCancelled(result) } - actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { + actual override suspend fun startLogoutFlow(request: EndSessionRequest) { val redirectUrl = request.url.parameters.get("post_logout_redirect_uri").orEmpty() checkRedirectPort(Url(redirectUrl)) - - webFlow.startWebFlow(request.url, redirectUrl) - // doesn't return at all if unsuccessful - return EndSessionResponse.success(Unit) + val result = webFlow.startWebFlow(request.url, redirectUrl) + throwEndsessionIfCancelled(result) } @OptIn(ExperimentalContracts::class) diff --git a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt index 10156738..cba7acb8 100644 --- a/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt +++ b/oidc-appsupport/src/wasmJsMain/kotlin/org/publicvalue/multiplatform/oidc/appsupport/PlatformCodeAuthFlow.wasmJs.kt @@ -23,13 +23,13 @@ actual class PlatformCodeAuthFlow( @ExperimentalOpenIdConnect actual override suspend fun startLoginFlow(request: AuthCodeRequest) { val result = webFlow.startWebFlow(request.url, request.url.parameters.get("redirect_uri").orEmpty()) - throwIfCancelled(result) + throwAuthenticationIfCancelled(result) } - actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse { + actual override suspend fun startLogoutFlow(request: EndSessionRequest) { val redirectUrl = request.url.parameters.get("post_logout_redirect_uri").orEmpty() - webFlow.startWebFlow(request.url, redirectUrl) - return Result.success(Unit) + val result = webFlow.startWebFlow(request.url, redirectUrl) + throwEndsessionIfCancelled(result) } companion object { diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectException.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectException.kt index 47132d44..6fc1e5b8 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectException.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/OpenIdConnectException.kt @@ -1,6 +1,7 @@ package org.publicvalue.multiplatform.oidc import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.types.remote.ErrorResponse import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName @@ -29,3 +30,10 @@ sealed class OpenIdConnectException( data class InvalidConfiguration(override val message: String): OpenIdConnectException(message) } + +internal fun Url?.getError(): OpenIdConnectException.AuthenticationFailure? { + return if (this?.parameters?.contains("error") == true) { + OpenIdConnectException.AuthenticationFailure( + message = this.parameters.get("error") ?: "") + } else null +} \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt index 25d25264..ac2640a6 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/CodeAuthFlow.kt @@ -5,6 +5,7 @@ import io.ktor.http.URLBuilder import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException +import org.publicvalue.multiplatform.oidc.getError import org.publicvalue.multiplatform.oidc.preferences.Preferences import org.publicvalue.multiplatform.oidc.preferences.clearOidcPreferences import org.publicvalue.multiplatform.oidc.preferences.getAuthRequest @@ -79,6 +80,8 @@ interface CodeAuthFlow { /** * Uses the request URL to open a browser and perform authorization. + * Call [continueLogin] after returning to your app to receive tokens. + * * @param request The request containing the url and relevant state information */ @Throws(CancellationException::class, OpenIdConnectException::class) @@ -133,7 +136,7 @@ suspend fun OpenIdConnectClient.continueLogin( responseUri: Url, configureTokenExchange: (HttpRequestBuilder.() -> Unit)? = null ): AccessTokenResponse { - getError(responseUri)?.let { throw it } + responseUri.getError()?.let { throw it } val state = responseUri.parameters["state"] val code = responseUri.parameters["code"] @@ -174,11 +177,4 @@ private suspend fun OpenIdConnectClient.continueLogin( } else { throw OpenIdConnectException.AuthenticationFailure("No auth code", cause = null) } -} - -private fun getError(responseUri: Url?): OpenIdConnectException.AuthenticationFailure? { - return if (responseUri?.parameters?.contains("error") == true) { - OpenIdConnectException.AuthenticationFailure( - message = responseUri.parameters.get("error") ?: "") - } else null } \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/EndSessionFlow.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/EndSessionFlow.kt index fab83568..9870b4c9 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/EndSessionFlow.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/flows/EndSessionFlow.kt @@ -1,8 +1,15 @@ package org.publicvalue.multiplatform.oidc.flows import io.ktor.http.URLBuilder +import io.ktor.http.Url import org.publicvalue.multiplatform.oidc.OpenIdConnectClient import org.publicvalue.multiplatform.oidc.OpenIdConnectException +import org.publicvalue.multiplatform.oidc.getError +import org.publicvalue.multiplatform.oidc.preferences.Preferences +import org.publicvalue.multiplatform.oidc.preferences.clearOidcPreferences +import org.publicvalue.multiplatform.oidc.preferences.getEndsessionRequest +import org.publicvalue.multiplatform.oidc.preferences.getResponseUri +import org.publicvalue.multiplatform.oidc.preferences.setEndSessionRequest import org.publicvalue.multiplatform.oidc.types.EndSessionRequest import org.publicvalue.multiplatform.oidc.wrapExceptions import kotlin.coroutines.cancellation.CancellationException @@ -14,6 +21,7 @@ import kotlin.coroutines.cancellation.CancellationException */ interface EndSessionFlow { val client: OpenIdConnectClient + val preferences: Preferences /** * End session using a GET-Request in a WebView. @@ -26,14 +34,75 @@ interface EndSessionFlow { suspend fun endSession( idToken: String?, configureEndSessionUrl: (URLBuilder.() -> Unit)? = null, + ) = wrapExceptions { + startLogout(idToken = idToken, configureEndSessionUrl = configureEndSessionUrl) + continueLogout() + } + + /** + * Start end session flow using a GET-Request in a WebView. + * This supports redirecting to the app after logout if post_logout_redirect_uri is set. + * + * Call [continueLogout] after returning to your app to check for errors during logout. + * + * @param idToken used for id_token_hint, recommended by openid spec, optional + * @param configureEndSessionUrl configuration closure to configure the http request builder with + */ + suspend fun startLogout( + idToken: String?, + configureEndSessionUrl: (URLBuilder.() -> Unit)? = null, ) = wrapExceptions { if (!client.config.discoveryUri.isNullOrEmpty()) { client.discover() } val request = client.createEndSessionRequest(idToken, configureEndSessionUrl) - endSession(request) + preferences.setEndSessionRequest(request) + startLogoutFlow(request) } + /** + * Start end session flow using a GET-Request in a WebView. + * This supports redirecting to the app after logout if post_logout_redirect_uri is set. + * + * Call [continueLogout] after returning to your app to check for errors during logout. + * + * @param request The request containing the url with relevant parameters + */ @Throws(CancellationException::class, OpenIdConnectException::class) - suspend fun endSession(request: EndSessionRequest): EndSessionResponse + suspend fun startLogoutFlow(request: EndSessionRequest) + + /** + * Check whether continueLogout can safely be called. + * + * @return true if startLogout() was called before and continueLogout() was not yet called. + */ + suspend fun canContinueLogout(): Boolean { + return preferences.getEndsessionRequest() != null && preferences.getResponseUri() != null + } + + /** + * Continue logout flow. + * + * @throws OpenIdConnectException if canContinueLogout() returns false or if there was an error during logout. + * + */ + suspend fun continueLogout() { + val endSessionRequest = preferences.getEndsessionRequest() + val responseUri = preferences.getResponseUri() + if (endSessionRequest == null) { + throw OpenIdConnectException.AuthenticationFailure("No endSessionRequest present") + } + if (responseUri == null) { + throw OpenIdConnectException.AuthenticationFailure("No responseUri present") + } + preferences.clearOidcPreferences() + client.continueLogout(responseUri) + } +} + +@Throws(OpenIdConnectException::class, CancellationException::class) +suspend fun OpenIdConnectClient.continueLogout( + responseUri: Url, +) { + responseUri.getError()?.let { throw it } } \ No newline at end of file diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt index 666bf137..23d4843c 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/preferences/Preferences.kt @@ -4,44 +4,43 @@ import io.ktor.http.Url import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import org.publicvalue.multiplatform.oidc.types.AuthCodeRequest - -object PreferencesStatic { - var authRequest: AuthCodeRequest? = null - var responseUri: Url? = null - - -} +import org.publicvalue.multiplatform.oidc.types.EndSessionRequest val PREFERENCES_FILENAME = "oidcsession.preferences_pb" private enum class PreferenceKeys(val key: String) { - LAST_REQUEST("lastRequest"), + LAST_AUTH_REQUEST("lastAuthRequest"), + LAST_ENDSESSION_REQUEST("lastEndsessionRequest"), RESPONSE_URI("responseUri") } suspend fun Preferences.setAuthRequest(request: AuthCodeRequest) { -// PreferencesStatic.authRequest = request - put(PreferenceKeys.LAST_REQUEST.key, Json.encodeToString(request)) + put(PreferenceKeys.LAST_AUTH_REQUEST.key, Json.encodeToString(request)) } suspend fun Preferences.getAuthRequest(): AuthCodeRequest? { -// return PreferencesStatic.authRequest - return get(PreferenceKeys.LAST_REQUEST.key)?.let { Json.decodeFromStringOrNull(it) } + return get(PreferenceKeys.LAST_AUTH_REQUEST.key)?.let { Json.decodeFromStringOrNull(it) } +} + +suspend fun Preferences.setEndSessionRequest(request: EndSessionRequest) { + put(PreferenceKeys.LAST_ENDSESSION_REQUEST.key, Json.encodeToString(request)) +} + +suspend fun Preferences.getEndsessionRequest(): EndSessionRequest? { + return get(PreferenceKeys.LAST_ENDSESSION_REQUEST.key)?.let { Json.decodeFromStringOrNull(it) } } suspend fun Preferences.setResponseUri(response: Url) { -// PreferencesStatic.responseUri = response put(PreferenceKeys.RESPONSE_URI.key, Json.encodeToString(response)) } suspend fun Preferences.getResponseUri(): Url? { -// return PreferencesStatic.responseUri return get(PreferenceKeys.RESPONSE_URI.key)?.let { Json.decodeFromStringOrNull(it) } } suspend fun Preferences.clearOidcPreferences() { remove(PreferenceKeys.RESPONSE_URI.key) - remove(PreferenceKeys.LAST_REQUEST.key) + remove(PreferenceKeys.LAST_AUTH_REQUEST.key) } inline fun Json.decodeFromStringOrNull(string: String): T? { diff --git a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/EndSessionRequest.kt b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/EndSessionRequest.kt index a96ad44f..4a61497b 100644 --- a/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/EndSessionRequest.kt +++ b/oidc-core/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/types/EndSessionRequest.kt @@ -1,11 +1,13 @@ package org.publicvalue.multiplatform.oidc.types import io.ktor.http.Url +import kotlinx.serialization.Serializable import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName @OptIn(ExperimentalObjCName::class) @ObjCName(swiftName = "EndSessionRequest", name = "EndSessionRequest", exact = true) +@Serializable data class EndSessionRequest( val url: Url ) \ No newline at end of file diff --git a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt index 97bf5599..7fa4d7c2 100644 --- a/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt +++ b/sample-app/shared/src/commonMain/kotlin/org/publicvalue/multiplatform/oidc/sample/home/HomePresenter.kt @@ -107,34 +107,31 @@ class HomePresenter( val isGoogle = client.config.discoveryUri.toString().contains("accounts.google.com") if (!client.config.endpoints?.endSessionEndpoint.isNullOrEmpty() || isGoogle) { catchErrorMessage { - val result = if(isGoogle) { + if(isGoogle) { val endpoint = "https://accounts.google.com/o/oauth2/revoke" val url = URLBuilder(endpoint) val response = DefaultHttpClient.submitForm { url(url.build()) parameter("token", it.access_token) } - response.status + if (response.status.isSuccess().not()) { + throw Exception("Logout received ${response.status}") + } } else { if (event.useWebFlow) { val flow = authFlowFactory.createEndSessionFlow(client) - val result = flow.endSession(it.id_token ?: "") - if (result.isFailure) { - setErrorMessage(result.exceptionOrNull()?.message ?: "Unknown error") - } - if (result.isSuccess) HttpStatusCode.OK else null + flow.endSession(it.id_token ?: "") } else { // maybe send bearer? - client.endSession(idToken = it.id_token ?: "") + val status =client.endSession(idToken = it.id_token ?: "") + if (status.isSuccess().not()) { + throw Exception("Logout received ${status}") + } } } - if (result?.isSuccess() == true || result == HttpStatusCode.Found) { - tokenResponse = null - subject = null - settingsStore.clearTokenData() - } else { - setErrorMessage("Logout received $result") - } + tokenResponse = null + subject = null + settingsStore.clearTokenData() } } else { setErrorMessage("No endSessionEndpoint set") @@ -165,14 +162,23 @@ class HomePresenter( DisposableEffect(authFlowFactory) { val client = createClient() - val authFlowFactory = client?.let { this@HomePresenter.authFlowFactory.createAuthFlow(it) } + val authFlow = client?.let { this@HomePresenter.authFlowFactory.createAuthFlow(it) } + val endSessionFlow = client?.let { this@HomePresenter.authFlowFactory.createEndSessionFlow(it) } scope.launch { - if (authFlowFactory != null && authFlowFactory.canContinueLogin()) { + if (authFlow != null && authFlow.canContinueLogin()) { catchErrorMessage { - val tokens = authFlowFactory.continueLogin(configureTokenExchange = null) + val tokens = authFlow.continueLogin(configureTokenExchange = null) updateTokenResponse(tokens) } } + if (endSessionFlow != null && endSessionFlow.canContinueLogout()) { + catchErrorMessage { + endSessionFlow.continueLogout() + tokenResponse = null + subject = null + settingsStore.clearTokenData() + } + } } onDispose { } } From 4282262ab3f750fbd6bf84587b69d94b8d913a7f Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Mon, 17 Nov 2025 12:35:27 +0100 Subject: [PATCH 10/10] update android readme --- docs/setup-android.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/setup-android.md b/docs/setup-android.md index f2fb1b48..f736e7b3 100644 --- a/docs/setup-android.md +++ b/docs/setup-android.md @@ -38,6 +38,25 @@ class MainActivity : ComponentActivity() { > will attach to the ComponentActivity's lifecycle. > If you don't use ComponentActivity, you'll need to implement your own Factory. +## Login/Logout continuation +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. +This behaviour can be forced by using ```adb shell am kill ```. +To continue the login flow on application restart, call ```authFlow.continueLogin()``` on startup: +``` +if (authFlow.canContinueLogin()) { + val tokens = authFlow.continueLogin(configureTokenExchange = null) + // save tokens +} +``` + +To continue a logout flow on application restart: +``` +if (endSessionFlow.canContinueLogout()) { + endSessionFlow.continueLogout() + // clear tokens +} +``` + ## Verified App-Links as Redirect Url 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: