Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: "<idToken>", configureEndSessionUrl: { urlBuilder in
let factory = CodeAuthFlowFactory_(ephemeralBrowserSession: false)
let flow = factory.createAuthFlow(client: client)
try await flow.endSession(idToken: "<idToken>", configureEndSessionUrl: { urlBuilder in
})
```

Expand Down
19 changes: 19 additions & 0 deletions docs/setup-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app id>```.
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:

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions oidc-appsupport/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ kotlin {
dependencies {
api(projects.oidcCore)
api(projects.oidcTokenstore)

implementation(projects.oidcPreferences)
}
}

Expand All @@ -45,6 +47,7 @@ kotlin {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ 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.org.publicvalue.multiplatform.oidc.preferences.PreferencesDataStore

/**
* Factory to create an Auth Flow on Android.
Expand Down Expand Up @@ -41,11 +43,12 @@ 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<String> = listOf()
private val customTabProviderPriority: List<String> = listOf(),
): CodeAuthFlowFactory {

private lateinit var activityResultLauncher: ActivityResultLauncherSuspend<Intent, ActivityResult>
private lateinit var context: Context
private lateinit var preferences: Preferences

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

Expand Down Expand Up @@ -80,6 +83,7 @@ class AndroidCodeAuthFlowFactory(
}
)
this.context = activity.applicationContext
this.preferences = PreferencesDataStore(context.dataStore)
}

override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow {
Expand All @@ -106,7 +110,8 @@ class AndroidCodeAuthFlowFactory(
}
return PlatformCodeAuthFlow(
client = client,
webFlow = webFlow
webFlow = webFlow,
preferences = preferences
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ 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 kotlinx.coroutines.runBlocking
import org.publicvalue.multiplatform.oidc.ExperimentalOpenIdConnect
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"
Expand Down Expand Up @@ -48,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() {
Expand All @@ -58,8 +63,10 @@ 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)
val preferences = PreferencesDataStore(context.dataStore)
runBlocking {
preferences.setResponseUri(Url(requestedUrl.toString()))
}
finish()
true
} else {
Expand Down Expand Up @@ -115,6 +122,10 @@ 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
setResult(RESULT_OK, Intent().setData(intent?.data))
finish()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,25 @@
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
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 {

// 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<AuthCodeResult>(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())
}
throwAuthenticationIfCancelled(result)
}

actual override suspend fun endSession(request: EndSessionRequest): EndSessionResponse {
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<Unit>(result.responseUri)) {
null -> {
return EndSessionResponse.success(Unit)
}
else -> {
return error
}
}
} else {
// browser closed, no redirect
EndSessionResponse.failure(OpenIdConnectException.AuthenticationCancelled("Logout cancelled"))
}
throwEndsessionIfCancelled(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,19 @@ 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
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 getAuthorizationCode(request: AuthCodeRequest): AuthCodeResponse
override suspend fun endSession(request: EndSessionRequest): EndSessionResponse
override suspend fun startLoginFlow(request: AuthCodeRequest)
override suspend fun startLogoutFlow(request: EndSessionRequest)
override val client: OpenIdConnectClient
override val preferences: Preferences
}

@OptIn(ExperimentalContracts::class)
Expand All @@ -37,4 +36,16 @@ internal fun <T> getErrorResult(responseUri: Url?): Result<T>? {
return Result.failure(OpenIdConnectException.AuthenticationFailure(message = "No Uri in callback from browser (was ${responseUri})."))
}
return null
}

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")
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
package org.publicvalue.multiplatform.oidc.appsupport

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
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
private val ephemeralBrowserSession: Boolean = false,
/** 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) }

// constructor for swift-only library
constructor(ephemeralBrowserSession: Boolean) : this(ephemeralBrowserSession, preferencesFactory = PreferencesFactory())

override fun createAuthFlow(client: OpenIdConnectClient): PlatformCodeAuthFlow {
return PlatformCodeAuthFlow(
client = client,
webFlow = WebSessionFlow(
ephemeralBrowserSession = ephemeralBrowserSession
)
ephemeralBrowserSession = ephemeralBrowserSession,
preferences = preferences
),
preferences = preferences
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ 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
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
Expand All @@ -32,43 +31,17 @@ 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 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<AuthCodeResult>(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())
}
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<Unit>(result.responseUri)) {
null -> {
return EndSessionResponse.success(Unit)
}
else -> {
return error
}
}
} else {
// browser closed, no redirect
EndSessionResponse.failure(OpenIdConnectException.AuthenticationCancelled("Logout cancelled"))
}
throwEndsessionIfCancelled(result)
}
}

Expand Down
Loading