Simplify Lokksmith integration
This commit is contained in:
@@ -1,8 +1,30 @@
|
||||
@file:OptIn(
|
||||
com.russhwolf.settings.ExperimentalSettingsApi::class,
|
||||
com.russhwolf.settings.ExperimentalSettingsImplementation::class,
|
||||
kotlinx.cinterop.ExperimentalForeignApi::class,
|
||||
)
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import com.russhwolf.settings.KeychainSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.lokksmith.Lokksmith
|
||||
import dev.lokksmith.SingletonLokksmithProvider
|
||||
import dev.lokksmith.createLokksmith
|
||||
import org.koin.dsl.module
|
||||
import platform.Security.kSecAttrAccessible
|
||||
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
val iosAuthModule =
|
||||
module {
|
||||
single { createIosLokksmith() }
|
||||
single<Lokksmith> {
|
||||
createLokksmith().also { lokksmith ->
|
||||
SingletonLokksmithProvider.set(lokksmith)
|
||||
}
|
||||
}
|
||||
single<Settings> {
|
||||
KeychainSettings(
|
||||
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.lokksmith.Lokksmith
|
||||
import dev.lokksmith.client.Client
|
||||
import dev.lokksmith.client.InternalClient
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
|
||||
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
||||
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
||||
import dev.lokksmith.client.request.parameter.Scope
|
||||
import dev.lokksmith.discoveryUrl
|
||||
import dev.lokksmith.id
|
||||
import dev.ulfrx.recipe.shared.Constants
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
||||
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
||||
|
||||
internal suspend fun Lokksmith.recipeClient(): Client =
|
||||
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
||||
id = Constants.OIDC_CLIENT_ID
|
||||
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
||||
}
|
||||
|
||||
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
|
||||
authorizationCodeFlow(
|
||||
AuthorizationCodeFlow.Request(
|
||||
redirectUri = Constants.OIDC_REDIRECT_URI,
|
||||
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
|
||||
),
|
||||
)
|
||||
|
||||
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
|
||||
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
||||
|
||||
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
|
||||
AuthFlowResultProvider
|
||||
.forClient(this)
|
||||
.first { result ->
|
||||
result is AuthFlowResultProvider.Result.Success ||
|
||||
result is AuthFlowResultProvider.Result.Cancelled ||
|
||||
result is AuthFlowResultProvider.Result.Error
|
||||
}
|
||||
|
||||
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
|
||||
coroutineScope {
|
||||
val terminal = async { client.awaitTerminalAuthFlowResult() }
|
||||
val responseUri =
|
||||
async {
|
||||
(client as InternalClient)
|
||||
.snapshots
|
||||
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
|
||||
.distinctUntilChanged()
|
||||
.first { responseUri -> responseUri != null }
|
||||
}
|
||||
|
||||
select<AuthFlowResultProvider.Result> {
|
||||
terminal.onAwait { result ->
|
||||
responseUri.cancel()
|
||||
result
|
||||
}
|
||||
responseUri.onAwait { uri ->
|
||||
terminal.cancel()
|
||||
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
|
||||
client.awaitTerminalAuthFlowResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
||||
var freshTokens: Client.Tokens? = null
|
||||
runWithTokens { tokens -> freshTokens = tokens }
|
||||
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
||||
return OidcResult.Success(
|
||||
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
|
||||
accessToken = tokens.accessToken.token,
|
||||
idToken = tokens.idToken.raw,
|
||||
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
|
||||
when (this) {
|
||||
is AuthFlowResultProvider.Result.Success -> null
|
||||
|
||||
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
|
||||
|
||||
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
|
||||
|
||||
AuthFlowResultProvider.Result.Undefined,
|
||||
is AuthFlowResultProvider.Result.Processing,
|
||||
-> OidcResult.AuthError("OIDC flow did not complete")
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.lokksmith.Lokksmith
|
||||
import dev.lokksmith.SingletonLokksmithProvider
|
||||
import dev.lokksmith.createLokksmith
|
||||
import dev.lokksmith.ios.launchAuthFlow
|
||||
import org.koin.mp.KoinPlatform
|
||||
|
||||
actual class OidcClient {
|
||||
private val lokksmith: Lokksmith
|
||||
get() = KoinPlatform.getKoin().get()
|
||||
|
||||
actual suspend fun login(): OidcResult {
|
||||
val client = lokksmith.recipeClient()
|
||||
val flow = client.recipeAuthorizationCodeFlow()
|
||||
val initiation = flow.prepare()
|
||||
|
||||
lokksmith.launchAuthFlow(initiation)
|
||||
|
||||
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
|
||||
null -> runCatching { client.toOidcSuccess() }.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
|
||||
else -> failure
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
||||
return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
|
||||
}
|
||||
val client = lokksmith.recipeClient()
|
||||
return runCatching { client.toOidcSuccess() }
|
||||
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
|
||||
}
|
||||
|
||||
actual suspend fun logout(authStateJson: String) {
|
||||
val client = lokksmith.recipeClient()
|
||||
val flow = client.recipeEndSessionFlow()
|
||||
|
||||
if (flow != null) {
|
||||
runCatching {
|
||||
lokksmith.launchAuthFlow(flow.prepare())
|
||||
lokksmith.completeAuthFlow(client)
|
||||
}
|
||||
}
|
||||
|
||||
client.resetTokens()
|
||||
}
|
||||
}
|
||||
|
||||
fun createIosLokksmith(): Lokksmith =
|
||||
createLokksmith().also { lokksmith ->
|
||||
SingletonLokksmithProvider.set(lokksmith)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import com.russhwolf.settings.ExperimentalSettingsApi
|
||||
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
||||
import com.russhwolf.settings.KeychainSettings
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Security.kSecAttrAccessible
|
||||
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class)
|
||||
actual class SecureAuthStateStore {
|
||||
private val settings =
|
||||
KeychainSettings(
|
||||
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
)
|
||||
|
||||
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
|
||||
|
||||
actual fun write(authStateJson: String) {
|
||||
settings.putString(AUTH_STATE_KEY, authStateJson)
|
||||
}
|
||||
|
||||
actual fun clear() {
|
||||
settings.remove(AUTH_STATE_KEY)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user