initial commit - source

This commit is contained in:
Hartmut Bauer committed 2026-02-03 22:36:35 +01:00
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
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}")
}
}