Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 015d8d51d0
commit 995bdd5ae6
92 changed files with 8140 additions and 208 deletions

View File

@@ -0,0 +1,8 @@
package dev.ulfrx.recipe.auth
import org.koin.dsl.module
val iosAuthModule =
module {
single { createIosLokksmith() }
}

View File

@@ -0,0 +1,98 @@
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")
}

View File

@@ -0,0 +1,55 @@
@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)
}

View File

@@ -0,0 +1,32 @@
@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"
}
}

View File

@@ -1,8 +1,11 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.iosAuthModule
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
initKoin {
modules(iosAuthModule)
}
}