initial commit - source
This commit is contained in:
1 parent
003b5501f0
commit
e89bb29070
45 files changed
+5997
No files matched your search
@@ -0,0 +1,80 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.koin.core.annotation.Single
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
sealed interface AuthResult {
|
||||
class Success : AuthResult
|
||||
|
||||
// data class Success(val tokens: BearerTokens) : AuthResult
|
||||
data class Error(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
|
||||
data class Fatal(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
}
|
||||
|
||||
@Single
|
||||
fun createKeycloakConfig(): KeycloakConfig {
|
||||
|
||||
// TODO muss aus config geholt werden (<--> DI??)
|
||||
|
||||
val keycloakServerHostname = "kec.keinekekse.net"
|
||||
val keycloakRealm = "app-server-realm"
|
||||
val keycloakClient = "pkce-book-app"
|
||||
|
||||
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
}
|
||||
|
||||
class KeycloakConfig(
|
||||
val keycloakServerHostname: String,
|
||||
keycloakRealm: String,
|
||||
val keycloakClient: String,
|
||||
) {
|
||||
companion object {
|
||||
const val LOCALHOST = "127.0.0.1"
|
||||
const val CALLBACK_PATH = "/callback"
|
||||
const val SCOPE = "openid"
|
||||
const val PKCE_METHOD = "S256"
|
||||
|
||||
// TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden
|
||||
fun generateRandomString(): String {
|
||||
val bytes = ByteArray(64)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray(Charsets.US_ASCII)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
|
||||
}
|
||||
}
|
||||
|
||||
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
|
||||
|
||||
val authorizationEndpoint = "$keycloakBaseUrl/auth"
|
||||
val tokenEndpoint = "$keycloakBaseUrl/token"
|
||||
val certificatesEndpoint = "$keycloakBaseUrl/certs"
|
||||
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
|
||||
|
||||
}
|
||||
|
||||
internal class AuthRun(val kc: KeycloakConfig) {
|
||||
val codeVerifier = KeycloakConfig.generateRandomString()
|
||||
val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier)
|
||||
val csrfProtectionState = KeycloakConfig.generateRandomString()
|
||||
|
||||
val resultDeferred = CompletableDeferred<AuthResult>()
|
||||
private var redirectUri_: String? = null
|
||||
val redirectUri: String
|
||||
get() = redirectUri_
|
||||
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
|
||||
|
||||
fun buildRedirectUri(port: Int) {
|
||||
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.utils.io.core.Closeable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.keinekekse.app.service.TokenService
|
||||
import org.koin.core.annotation.Single
|
||||
import java.awt.Desktop
|
||||
import java.net.Socket
|
||||
import kotlin.use
|
||||
|
||||
@Single
|
||||
class Authenticator(
|
||||
// @Property("kc_server") keycloakServerHostname: String,
|
||||
// @Property("kc_realm") keycloakRealm: String,
|
||||
// @Property("kc_client") keycloakClient: String,
|
||||
val tokenService: TokenService,
|
||||
private val keycloakConfig: KeycloakConfig,
|
||||
) : Closeable {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) { json() }
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
}
|
||||
|
||||
// private val keycloakConfig =
|
||||
// KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
|
||||
override fun close() = httpClient.close()
|
||||
|
||||
suspend fun authenticate(): AuthResult {
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
|
||||
}
|
||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
try {
|
||||
server = createCallbackServer(config).start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
config.buildRedirectUri(port)
|
||||
if (!waitForServer(port)) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR008",
|
||||
"Schwerer Fehler",
|
||||
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
|
||||
)
|
||||
}
|
||||
|
||||
openAuthorizationInBrowser(config)
|
||||
|
||||
return config.resultDeferred.await()
|
||||
|
||||
} catch (e: Exception) {
|
||||
return AuthResult.Error(
|
||||
"ERR002",
|
||||
"Bei der Anmeldung ist ein Fehler aufgetreten",
|
||||
e.message ?: "Unbekannter Fehler"
|
||||
)
|
||||
} finally {
|
||||
server?.stop(1000, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
val response = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("refresh_token", tokens?.refreshToken ?: "")
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
return result.second
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(port: Int): Boolean =
|
||||
try {
|
||||
// TODO Socket ist nicht portabel (JVM only)
|
||||
Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
} catch (e: Exception) {
|
||||
// Port is closed or unreachable
|
||||
println("${e.javaClass.simpleName}: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
repeat(timeoutSeconds * 10) {
|
||||
if (isPortOpen(port)) return true
|
||||
delay(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||
val authorizationUrl = buildAuthorizationUrl(config)
|
||||
|
||||
// TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
}
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
}
|
||||
|
||||
desktop.browse(authorizationUrl.toURI())
|
||||
}
|
||||
|
||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.authorizationEndpoint
|
||||
parameters.append("response_type", "code")
|
||||
parameters.append("client_id", config.kc.keycloakClient)
|
||||
parameters.append("redirect_uri", config.redirectUri)
|
||||
parameters.append("scope", KeycloakConfig.SCOPE)
|
||||
parameters.append("state", config.csrfProtectionState)
|
||||
parameters.append("code_challenge", config.codeChallenge)
|
||||
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
|
||||
}
|
||||
|
||||
private fun createCallbackServer(config: AuthRun) =
|
||||
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
|
||||
routing {
|
||||
get(KeycloakConfig.CALLBACK_PATH) {
|
||||
val code = call.request.queryParameters["code"]
|
||||
|
||||
var authResult =
|
||||
validateAuthorizationRequest(call.request, config.csrfProtectionState)
|
||||
|
||||
if (authResult is AuthResult.Success) {
|
||||
authResult = requestTokens(config, code)
|
||||
}
|
||||
|
||||
val (text, status) = createGoodByeMessage(authResult)
|
||||
call.respondText(text, contentType = ContentType.Text.Html, status = status)
|
||||
|
||||
if (!config.resultDeferred.isCompleted)
|
||||
config.resultDeferred.complete(authResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun htmlEscape(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
||||
var text = ""
|
||||
var status = HttpStatusCode.BadRequest
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
text =
|
||||
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
|
||||
status = HttpStatusCode.OK
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
|
||||
htmlEscape(authResult.message)
|
||||
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
|
||||
status = HttpStatusCode.BadRequest
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
text =
|
||||
"<html><body><h1>Schwerer Fehler.</h1><h2>${htmlEscape(authResult.message)}</h2><h3>${
|
||||
htmlEscape(authResult.details ?: "")
|
||||
}</h3></body></html>"
|
||||
status = HttpStatusCode.InternalServerError
|
||||
}
|
||||
}
|
||||
return Pair(text, status)
|
||||
}
|
||||
|
||||
private fun validateAuthorizationRequest(
|
||||
request: RoutingRequest,
|
||||
csrfProtectionState: String,
|
||||
): AuthResult {
|
||||
/*
|
||||
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
|
||||
könnte auf Malware hindeuten, Abbruch
|
||||
throw Fatal error authenticating user -> exit app
|
||||
- wenn error =! null -> error + error_description anzeigen
|
||||
rc NULL == keine auth -> app muss entscheiden
|
||||
- code == null -> Fehler: Server liefert ungültige Daten,
|
||||
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
|
||||
rc NULL -> app
|
||||
*/
|
||||
if (request.queryParameters["error"] != null) {
|
||||
return AuthResult.Error(
|
||||
"ERR004",
|
||||
"Fehler bei der Anmeldung",
|
||||
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
|
||||
)
|
||||
}
|
||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR005",
|
||||
"Schwerer Fehler",
|
||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
return AuthResult.Success()
|
||||
}
|
||||
|
||||
/* Antwort von Keycloak:
|
||||
{
|
||||
"access_token": "eyJhbG ...
|
||||
"expires_in": 300,
|
||||
"refresh_expires_in": 0,
|
||||
"refresh_token": "eyJhb ...
|
||||
"token_type": "Bearer",
|
||||
"id_token": "eyJ ...
|
||||
"not-before-policy": 1769817270,
|
||||
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("token_type") val tokenType: String,
|
||||
@SerialName("id_token") val idToken: String?,
|
||||
@SerialName("not-before-policy") val notBeforePolicy: Int?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
val scope: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenErrorResponse(
|
||||
@SerialName("error") val error: String,
|
||||
@SerialName("error_description") val errorDescription: String?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
)
|
||||
|
||||
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
||||
if (authCode == null) {
|
||||
return AuthResult.Fatal(
|
||||
"ERR003",
|
||||
"Schwerer Fehler",
|
||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
val response: HttpResponse = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("code", authCode)
|
||||
append("redirect_uri", config.redirectUri)
|
||||
append("code_verifier", config.codeVerifier)
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
}
|
||||
}
|
||||
return result.first
|
||||
}
|
||||
|
||||
suspend fun validateRequestTokenResponse(response: HttpResponse): Pair<AuthResult, BearerTokens?> {
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
try {
|
||||
val errorResponse = response.body<TokenErrorResponse>()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
|
||||
), null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
val errorResponse = response.bodyAsText()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR006",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
errorResponse
|
||||
), null
|
||||
)
|
||||
}
|
||||
}
|
||||
val tokenResponse: TokenResponse = try {
|
||||
response.body<TokenResponse>()
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
return Pair(
|
||||
AuthResult.Fatal(
|
||||
"ERR007",
|
||||
"Schwerer Fehler",
|
||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
), null
|
||||
)
|
||||
}
|
||||
|
||||
return Pair(
|
||||
AuthResult.Success(),
|
||||
BearerTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// suspend fun requestUserInfo(
|
||||
// accessToken: String,
|
||||
// ) {
|
||||
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
//
|
||||
// val response = httpClient.get(
|
||||
// url = buildUrl {
|
||||
// protocol = URLProtocol.HTTPS
|
||||
// host = config.keycloakServerHostname
|
||||
// path(config.userinfoEndpoint)
|
||||
// },
|
||||
//// formParameters = parameters {
|
||||
//// append("grant_type", "refresh_token")
|
||||
//// append("client_id", config.keycloakClient)
|
||||
//// append("refresh_token", refreshToken)
|
||||
//// }
|
||||
// ) {
|
||||
// headers { append("Authorization", "Bearer $accessToken") }
|
||||
// }
|
||||
//
|
||||
// println("Status: ${response.status}")
|
||||
// println("Body: ${response.bodyAsText()}")
|
||||
//
|
||||
// }
|
||||
|
||||
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||
val response: HttpResponse = httpClient.request(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.certificatesEndpoint
|
||||
}
|
||||
)
|
||||
return response.status.value < 400
|
||||
}
|
||||
|
||||
/*
|
||||
access token:
|
||||
|
||||
{
|
||||
"exp": 1769839803,
|
||||
"iat": 1769839503,
|
||||
"auth_time": 1769839502,
|
||||
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
|
||||
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
|
||||
"aud": "account",
|
||||
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
|
||||
"typ": "Bearer",
|
||||
"azp": "pkce-book-app",
|
||||
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
|
||||
"acr": "1",
|
||||
"allowed-origins": [
|
||||
"http://127.0.0.1",
|
||||
"https://kas.keinekekse.net"
|
||||
],
|
||||
"realm_access": {
|
||||
"roles": [
|
||||
"offline_access",
|
||||
"default-roles-app-server-realm",
|
||||
"uma_authorization",
|
||||
"book-admin"
|
||||
]
|
||||
},
|
||||
"resource_access": {
|
||||
"account": {
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"manage-account-links",
|
||||
"view-profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scope": "openid profile email",
|
||||
"email_verified": true,
|
||||
"name": "Hartmut Bauer",
|
||||
"preferred_username": "hb42",
|
||||
"given_name": "Hartmut",
|
||||
"family_name": "Bauer",
|
||||
"email": "hb242@pm.me"
|
||||
}
|
||||
|
||||
-> realm_access:
|
||||
|
||||
implementation(libs.ktor.serverAuthJwt)
|
||||
|
||||
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
|
||||
// claims, die nur String enthalten: getClaim("claim-name")
|
||||
// für andere: "{" -> asMap<String, Any> => cast!
|
||||
// "[" -> asList(Class<T>)
|
||||
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
|
||||
roles.forEach { println(it) }
|
||||
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package net.keinekekse.app
|
||||
|
||||
//import org.koin.plugin.module.dsl.startKoin
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.keinekekse.app.auth.AuthResult
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import net.keinekekse.app.books.di.KoinApp
|
||||
import net.keinekekse.app.ui.ProgressIndicator
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
|
||||
//@PropertyValue("kc_server")
|
||||
//var keycloakServer: String = ""
|
||||
//
|
||||
//@PropertyValue("kc_realm")
|
||||
//var keycloakRealm: String = ""
|
||||
//
|
||||
//@PropertyValue("kc_client")
|
||||
//var keycloakClient: String = ""
|
||||
|
||||
fun main() = application {
|
||||
|
||||
startKoin<KoinApp>()
|
||||
|
||||
// keycloakServer = "kec.keinekekse.net"
|
||||
// keycloakRealm = "app-server-realm"
|
||||
// keycloakClient = "pkce-book-app"
|
||||
|
||||
// val inj = koinInject<String>(named("AppString"))
|
||||
|
||||
val authResultState = remember { mutableStateOf<AuthResult?>(null) }
|
||||
|
||||
val authenticator: Authenticator = koinInject()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
authResultState.value = withContext(Dispatchers.IO) {
|
||||
// TODO - (~) Verständnis für App-Aufbau und DI
|
||||
// - (x) das object muss global angelegt werden -> DI singleton
|
||||
// - (x) globale Speicherung der Tokens
|
||||
// - Tokens persistieren? Multiplatform! (evtl. library)
|
||||
// - (x) globaler httpClient mit Konfig für Tokens -> DI
|
||||
// - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
|
||||
// Authenticator(
|
||||
// "kec.keinekekse.net",
|
||||
// "app-server-realm",
|
||||
// "pkce-book-app",
|
||||
// ).use {
|
||||
|
||||
val result = authenticator.authenticate()
|
||||
|
||||
when (result) {
|
||||
is AuthResult.Success -> {
|
||||
// println("Access Token: ${result.tokens?.accessToken}")
|
||||
// println("Refresh Token: ${result.tokens?.refreshToken}")
|
||||
|
||||
// delay(3000)
|
||||
/*
|
||||
// klappt, gibt aber nicht mehr her, als ohnehin im Token steht
|
||||
it.requestUserInfo(result.tokens!!.accessToken)
|
||||
*/
|
||||
/*
|
||||
// funktioniert -> jetzt fehlt die DI-Mimik für die compose app
|
||||
when (val res = it.requestRefreshToken(result.tokens)) {
|
||||
is AuthResult.Success -> {
|
||||
println("Access Token: ${res.tokens?.accessToken}")
|
||||
println("Refresh Token: ${res.tokens?.refreshToken}")
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
|
||||
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
result
|
||||
// }
|
||||
}
|
||||
}
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "app",
|
||||
) {
|
||||
when (val result = authResultState.value) {
|
||||
null -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("Warten auf Anmeldung...")
|
||||
|
||||
// Icon(
|
||||
// imageVector = Icons.Default.HourglassTop,
|
||||
// contentDescription = "Home",
|
||||
// modifier = Modifier.padding(top = 16.dp).size(32.dp)
|
||||
// )
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
// TODO show an error UI (and maybe a retry button)
|
||||
Text("Fehler bei der Anmeldung: ${result.message} (${result.code})")
|
||||
Text("Fehlerbeschreibung: ${result.details ?: ""}")
|
||||
// App()
|
||||
}
|
||||
|
||||
is AuthResult.Fatal -> {
|
||||
Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})")
|
||||
Text("Fehlerbeschreibung: ${result.details ?: ""}")
|
||||
}
|
||||
|
||||
is AuthResult.Success -> {
|
||||
val test = koinInject<TestAuth>()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
println("requesting '/'")
|
||||
test.request("/")
|
||||
}
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
//@Composable
|
||||
//fun ProgressIndicator(animationSize: Int = 48, paddingTop: Int = 16, strokeWidth: Int = 5) {
|
||||
// val primaryColor = MaterialTheme.colorScheme.primary
|
||||
// val backgroundColor = MaterialTheme.colorScheme.background
|
||||
// val strokewidth = strokeWidth.dp
|
||||
// CircularProgressIndicator(
|
||||
// modifier =
|
||||
// Modifier.padding(top = paddingTop.dp).size(animationSize.dp).drawBehind {
|
||||
// drawCircle(
|
||||
// primaryColor,
|
||||
// radius = size.width / 2 - strokewidth.toPx() / 2,
|
||||
// style = Stroke(strokewidth.toPx()),
|
||||
//
|
||||
// )
|
||||
// },
|
||||
// color = backgroundColor,
|
||||
// strokeWidth = strokewidth
|
||||
// )
|
||||
//}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
fun createHttpClient(authenticator: Authenticator) = HttpClient {
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
authenticator.requestRefreshToken(oldTokens)
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class TokenService {
|
||||
private var tokens: BearerTokens? = null
|
||||
|
||||
// TODO persist tokens
|
||||
|
||||
fun getTokens(): BearerTokens? = tokens
|
||||
fun setTokens(tokens: BearerTokens? = null) {
|
||||
this.tokens = tokens
|
||||
println("set tokens")
|
||||
println("Access Token: ${tokens?.accessToken}")
|
||||
println("Refresh Token: ${tokens?.refreshToken}")
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user