Add authentication
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import android.app.Application
|
||||
import dev.ulfrx.recipe.auth.androidAuthModule
|
||||
import dev.ulfrx.recipe.di.initKoin
|
||||
import dev.ulfrx.recipe.logging.configureLogging
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
@@ -11,6 +12,7 @@ class MainApplication : Application() {
|
||||
configureLogging()
|
||||
initKoin {
|
||||
androidContext(this@MainApplication)
|
||||
modules(androidAuthModule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val androidAuthModule =
|
||||
module {
|
||||
single { createAndroidLokksmith(androidContext().applicationContext) }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dev.lokksmith.Lokksmith
|
||||
import dev.lokksmith.SingletonLokksmithProvider
|
||||
import dev.lokksmith.android.LokksmithAuthFlowActivity
|
||||
import dev.lokksmith.createLokksmith
|
||||
import org.koin.core.context.GlobalContext
|
||||
|
||||
actual class OidcClient {
|
||||
private val context: Context
|
||||
get() = GlobalContext.get().get<Context>().applicationContext
|
||||
|
||||
private val lokksmith: Lokksmith
|
||||
get() = GlobalContext.get().get()
|
||||
|
||||
actual suspend fun login(): OidcResult {
|
||||
val client = lokksmith.recipeClient()
|
||||
val flow = client.recipeAuthorizationCodeFlow()
|
||||
val initiation = flow.prepare()
|
||||
|
||||
context.startActivity(
|
||||
LokksmithAuthFlowActivity
|
||||
.createCustomTabsIntent(context = context, initiation = initiation)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
|
||||
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
|
||||
null -> {
|
||||
runCatching { client.toOidcSuccess() }.getOrElse { error ->
|
||||
OidcResult.AuthError(error.message ?: "OIDC login failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
||||
return OidcResult.AuthError("Stored Android 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 {
|
||||
val initiation = flow.prepare()
|
||||
context.startActivity(
|
||||
LokksmithAuthFlowActivity
|
||||
.createCustomTabsIntent(context = context, initiation = initiation)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
lokksmith.completeAuthFlow(client)
|
||||
}
|
||||
}
|
||||
|
||||
client.resetTokens()
|
||||
}
|
||||
}
|
||||
|
||||
fun createAndroidLokksmith(context: Context): Lokksmith =
|
||||
createLokksmith(context).also { lokksmith ->
|
||||
SingletonLokksmithProvider.set(lokksmith)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.koin.core.context.GlobalContext
|
||||
|
||||
actual class SecureAuthStateStore {
|
||||
private val preferences by lazy {
|
||||
val appContext = GlobalContext.get().get<Context>().applicationContext
|
||||
val masterKey =
|
||||
MasterKey
|
||||
.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
|
||||
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
|
||||
EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
|
||||
|
||||
actual fun write(authStateJson: String) {
|
||||
preferences
|
||||
.edit()
|
||||
.putString(KEY_AUTH_STATE_JSON, authStateJson)
|
||||
.apply()
|
||||
}
|
||||
|
||||
actual fun clear() {
|
||||
preferences
|
||||
.edit()
|
||||
.remove(KEY_AUTH_STATE_JSON)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "recipe_auth_state"
|
||||
const val KEY_AUTH_STATE_JSON = "auth_state_json"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user