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

+79
View File
@@ -0,0 +1,79 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler)
alias(masterLibs.plugins.versionPlugin)
}
group = "net.keinekekse.app"
version = libs.versions.composeAppVersion.get()
description = "Compose Multiplatform Test-Client"
extra["displayname"] = "Books App"
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
kotlin {
jvm()
// @OptIn(ExperimentalWasmDsl::class)
// wasmJs {
// browser()
// binaries.executable()
// }
sourceSets {
commonMain.dependencies {
implementation(masterLibs.utilLibrary)
implementation(libs.bundles.kotlin.compose)
implementation(libs.ktor.client)
implementation(libs.ktor.clientCio)
implementation(libs.ktor.clientAuth)
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.clientContentNegotiation)
implementation(libs.ktor.serializationJson)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverCio)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.annotations)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// implementation(libs.koin.compose.viewmodel.navigation)
implementation(projects.shared)
}
// commonTest.dependencies {
// implementation(libs.kotlin.test)
// }
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.ktor.clientJava) // ??
// implementation(libs.ktor.serverCio)
// implementation(libs.ktor.serverCore)
// implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverAuthJwt) // nicht f. js, wasmJs
}
// wasmJsMain.dependencies {
// implementation(libs.ktor.clientJs)
// }
}
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
}
compose.desktop {
application {
mainClass = "net.keinekekse.app.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = project.name
packageVersion = project.version.toString()
}
}
}
+3
View File
@@ -0,0 +1,3 @@
#auto generated
#Tue Feb 03 00:28:14 CET 2026
build.number=174
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,86 @@
package net.keinekekse.app
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.keinekekse.app.books.generated.resources.Res
import net.keinekekse.app.books.generated.resources.compose_multiplatform
import net.keinekekse.app.books.ui.theme.AppTheme
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject
import org.koin.core.annotation.Single
@Composable
@Preview
fun App() {
/*MaterialTheme*/
/*
eigenes Theme -> https://material-foundation.github.io/material-theme-builder/
den Download in den source code übernehmen (in Theme.kt sind Anpassungen nötig)
per default hat die generierte Klasse den Namen AppTheme.
*/
AppTheme {
val test = koinInject<TestAuth>()
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
Button(onClick = {
CoroutineScope(Dispatchers.Default).launch {
println("requesting '/test'")
test.request("/test")
}
}) {
Text("Test Token Refresh")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
}
}
}
}
@Single
class TestAuth(val httpClient: HttpClient) {
private val srv = "https://kas.keinekekse.net/"
suspend fun request(url: String) {
println("connecting")
val response = httpClient.get(srv + url)
println("call to $url")
println(response.status)
println(response.bodyAsText())
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books
import net.keinekekse.version.Version
object VERSION: Version {
override val version = "1.0.0"
override val fullVersion = "1.0.0+build.174"
override val major = 1
override val minor = 0
override val patch = 0
override val preRelease = ""
override val buildMetadata = "build.174"
override val buildDate = "2026-02-02T23:28:14.786916Z"
override val displayName = "Books App"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books"
override val description = "Compose Multiplatform Test-Client"
}
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.di
import org.koin.core.annotation.KoinApplication
@KoinApplication
object KoinApp
@@ -0,0 +1,13 @@
package net.keinekekse.app.books.di
import org.koin.core.annotation.*
@Module
@Configuration
@ComponentScan("net.keinekekse.app")
class KoinModules {
@Named("AppString")
@Factory
fun provideSomeString() = "Injected App string"
}
@@ -0,0 +1,219 @@
package net.keinekekse.app.books.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF1C6585)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFC3E8FF)
val onPrimaryContainerLight = Color(0xFF004C69)
val secondaryLight = Color(0xFF4E616D)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFD1E5F4)
val onSecondaryContainerLight = Color(0xFF364955)
val tertiaryLight = Color(0xFF605A7D)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFE6DEFF)
val onTertiaryContainerLight = Color(0xFF484264)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF6FAFE)
val onBackgroundLight = Color(0xFF181C1F)
val surfaceLight = Color(0xFFF6FAFE)
val onSurfaceLight = Color(0xFF181C1F)
val surfaceVariantLight = Color(0xFFDCE3E9)
val onSurfaceVariantLight = Color(0xFF41484D)
val outlineLight = Color(0xFF71787D)
val outlineVariantLight = Color(0xFFC0C7CD)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2C3134)
val inverseOnSurfaceLight = Color(0xFFEDF1F5)
val inversePrimaryLight = Color(0xFF8FCEF3)
val surfaceDimLight = Color(0xFFD6DADF)
val surfaceBrightLight = Color(0xFFF6FAFE)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF0F4F8)
val surfaceContainerLight = Color(0xFFEAEEF2)
val surfaceContainerHighLight = Color(0xFFE5E9ED)
val surfaceContainerHighestLight = Color(0xFFDFE3E7)
val primaryLightMediumContrast = Color(0xFF003B51)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF307495)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF263844)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF5D707C)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF373252)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF6F688C)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF740006)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF6FAFE)
val onBackgroundLightMediumContrast = Color(0xFF181C1F)
val surfaceLightMediumContrast = Color(0xFFF6FAFE)
val onSurfaceLightMediumContrast = Color(0xFF0D1215)
val surfaceVariantLightMediumContrast = Color(0xFFDCE3E9)
val onSurfaceVariantLightMediumContrast = Color(0xFF30373C)
val outlineLightMediumContrast = Color(0xFF4C5359)
val outlineVariantLightMediumContrast = Color(0xFF676E73)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2C3134)
val inverseOnSurfaceLightMediumContrast = Color(0xFFEDF1F5)
val inversePrimaryLightMediumContrast = Color(0xFF8FCEF3)
val surfaceDimLightMediumContrast = Color(0xFFC3C7CB)
val surfaceBrightLightMediumContrast = Color(0xFFF6FAFE)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF0F4F8)
val surfaceContainerLightMediumContrast = Color(0xFFE5E9ED)
val surfaceContainerHighLightMediumContrast = Color(0xFFD9DDE1)
val surfaceContainerHighestLightMediumContrast = Color(0xFFCED2D6)
val primaryLightHighContrast = Color(0xFF003043)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF004F6C)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF1C2E39)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF394C57)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF2D2847)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF4B4566)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF600004)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF98000A)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF6FAFE)
val onBackgroundLightHighContrast = Color(0xFF181C1F)
val surfaceLightHighContrast = Color(0xFFF6FAFE)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFDCE3E9)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF262D32)
val outlineVariantLightHighContrast = Color(0xFF434A4F)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2C3134)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFF8FCEF3)
val surfaceDimLightHighContrast = Color(0xFFB5B9BD)
val surfaceBrightLightHighContrast = Color(0xFFF6FAFE)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFEDF1F5)
val surfaceContainerLightHighContrast = Color(0xFFDFE3E7)
val surfaceContainerHighLightHighContrast = Color(0xFFD1D5D9)
val surfaceContainerHighestLightHighContrast = Color(0xFFC3C7CB)
val primaryDark = Color(0xFF8FCEF3)
val onPrimaryDark = Color(0xFF003549)
val primaryContainerDark = Color(0xFF004C69)
val onPrimaryContainerDark = Color(0xFFC3E8FF)
val secondaryDark = Color(0xFFB5C9D7)
val onSecondaryDark = Color(0xFF20333E)
val secondaryContainerDark = Color(0xFF364955)
val onSecondaryContainerDark = Color(0xFFD1E5F4)
val tertiaryDark = Color(0xFFCAC1EA)
val onTertiaryDark = Color(0xFF322C4C)
val tertiaryContainerDark = Color(0xFF484264)
val onTertiaryContainerDark = Color(0xFFE6DEFF)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF0F1417)
val onBackgroundDark = Color(0xFFDFE3E7)
val surfaceDark = Color(0xFF0F1417)
val onSurfaceDark = Color(0xFFDFE3E7)
val surfaceVariantDark = Color(0xFF41484D)
val onSurfaceVariantDark = Color(0xFFC0C7CD)
val outlineDark = Color(0xFF8A9297)
val outlineVariantDark = Color(0xFF41484D)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFDFE3E7)
val inverseOnSurfaceDark = Color(0xFF2C3134)
val inversePrimaryDark = Color(0xFF1C6585)
val surfaceDimDark = Color(0xFF0F1417)
val surfaceBrightDark = Color(0xFF353A3D)
val surfaceContainerLowestDark = Color(0xFF0A0F12)
val surfaceContainerLowDark = Color(0xFF181C1F)
val surfaceContainerDark = Color(0xFF1C2023)
val surfaceContainerHighDark = Color(0xFF262B2E)
val surfaceContainerHighestDark = Color(0xFF313539)
val primaryDarkMediumContrast = Color(0xFFB5E2FF)
val onPrimaryDarkMediumContrast = Color(0xFF00293A)
val primaryContainerDarkMediumContrast = Color(0xFF5898BB)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFCBDFED)
val onSecondaryDarkMediumContrast = Color(0xFF152832)
val secondaryContainerDarkMediumContrast = Color(0xFF8093A0)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFE0D7FF)
val onTertiaryDarkMediumContrast = Color(0xFF272140)
val tertiaryContainerDarkMediumContrast = Color(0xFF938CB2)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFD2CC)
val onErrorDarkMediumContrast = Color(0xFF540003)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF0F1417)
val onBackgroundDarkMediumContrast = Color(0xFFDFE3E7)
val surfaceDarkMediumContrast = Color(0xFF0F1417)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF41484D)
val onSurfaceVariantDarkMediumContrast = Color(0xFFD6DDE3)
val outlineDarkMediumContrast = Color(0xFFACB3B9)
val outlineVariantDarkMediumContrast = Color(0xFF8A9197)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFDFE3E7)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF262B2E)
val inversePrimaryDarkMediumContrast = Color(0xFF004E6A)
val surfaceDimDarkMediumContrast = Color(0xFF0F1417)
val surfaceBrightDarkMediumContrast = Color(0xFF404549)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF04080B)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1E21)
val surfaceContainerDarkMediumContrast = Color(0xFF24282C)
val surfaceContainerHighDarkMediumContrast = Color(0xFF2E3337)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3A3E42)
val primaryDarkHighContrast = Color(0xFFE1F2FF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFF8BCAEF)
val onPrimaryContainerDarkHighContrast = Color(0xFF000D15)
val secondaryDarkHighContrast = Color(0xFFE1F2FF)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFB1C5D3)
val onSecondaryContainerDarkHighContrast = Color(0xFF000D15)
val tertiaryDarkHighContrast = Color(0xFFF3EDFF)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFC6BDE5)
val onTertiaryContainerDarkHighContrast = Color(0xFF0C0625)
val errorDarkHighContrast = Color(0xFFFFECE9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF0F1417)
val onBackgroundDarkHighContrast = Color(0xFFDFE3E7)
val surfaceDarkHighContrast = Color(0xFF0F1417)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF41484D)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFEAF1F7)
val outlineVariantDarkHighContrast = Color(0xFFBCC3C9)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFDFE3E7)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF004E6A)
val surfaceDimDarkHighContrast = Color(0xFF0F1417)
val surfaceBrightDarkHighContrast = Color(0xFF4C5154)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF1C2023)
val surfaceContainerDarkHighContrast = Color(0xFF2C3134)
val surfaceContainerHighDarkHighContrast = Color(0xFF373C3F)
val surfaceContainerHighestDarkHighContrast = Color(0xFF43474B)
@@ -0,0 +1,278 @@
package net.keinekekse.app.books.ui.theme
//import android.os.Build
//import androidx.compose.material3.dynamicDarkColorScheme
//import androidx.compose.material3.dynamicLightColorScheme
//import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color,
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit,
) {
val colorScheme = when {
// *hb* das gibt Probleme außerhalb von Android
// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
// val context = LocalContext.current
// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// }
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
@@ -0,0 +1,5 @@
package net.keinekekse.app.books.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
@@ -0,0 +1,507 @@
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.network.tls.extensions.HashAlgorithm
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.util.Digest
import io.ktor.util.sha1
import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.Closeable
import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.awt.Desktop
import java.net.Socket
import kotlin.io.encoding.Base64
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
import kotlin.use
class Authenticator2(
keycloakServerHostname: String,
keycloakRealm: String,
keycloakClient: String,
) : Closeable {
private val httpClient = HttpClient {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
private val keyc =
KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
override fun close() = httpClient.close()
suspend fun authenticate(): AuthResult {
val config = AuthRun(keyc)
if (!testServerConnection(config)) {
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
}
// var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
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 withTimeout(5.minutes) {
val resolvedTokens = config.resultDeferred.await()
resolvedTokens
}
} 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): AuthResult {
val config = AuthRun(keyc)
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 ?: "")
}
)
// {
// headers { append("Authorization", "Bearer ${tokens.accessToken}") }
// }
return validateRequestTokenResponse(response)
}
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
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 serverPort = engine.resolvedConnectors().first().port
val code = call.request.queryParameters["code"]
var authResult =
validateAuthorizationRequest(call.request, config.csrfProtectionState)
when (authResult) {
is AuthResult.Success -> {
if (code == null) {
authResult = AuthResult.Fatal(
"ERR003",
"Schwerer Fehler",
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
} else {
authResult = requestTokens(config, code)
}
}
else -> {}
}
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
}
}
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 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(BearerTokens("", "")) // hier ist nur Success wichtig
}
/* 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 {
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)
}
)
return validateRequestTokenResponse(response)
}
suspend fun validateRequestTokenResponse(response: HttpResponse): AuthResult {
if (response.status != HttpStatusCode.OK) {
try {
val errorResponse = response.body<TokenErrorResponse>()
return AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
)
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
val errorResponse = response.bodyAsText()
return AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
errorResponse
)
}
}
val tokenResponse: TokenResponse = try {
response.body<TokenResponse>()
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
return AuthResult.Fatal(
"ERR007",
"Schwerer Fehler",
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
}
return 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) }
refreshTokens {
val refreshTokenInfo: TokenInfo = client.submitForm(
url = "https://accounts.google.com/o/oauth2/token",
formParameters = parameters {
append("grant_type", "refresh_token")
append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
append("refresh_token", oldTokens?.refreshToken ?: "")
}
) { markAsRefreshTokenRequest() }.body()
bearerTokenStorage.add(BearerTokens(refreshTokenInfo.accessToken, oldTokens?.refreshToken!!))
bearerTokenStorage.last()
}
*/
}
sealed interface 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
}
internal 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"
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(kotlin.text.Charsets.US_ASCII)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(diges
}
}
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,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}")
}
}
@@ -0,0 +1,11 @@
package net.keinekekse.app
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
App()
}
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>app</title>
<link href="styles.css" rel="stylesheet" type="text/css">
</head>
<body style="text-align: center; align-content: center">
<svg height="40" role="presentation" viewBox="0 0 50 50" width="40"
xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" fill="none" r="20" stroke="#ccc" stroke-width="4"/>
<circle cx="25" cy="25" fill="none" r="20" stroke="#333" stroke-dasharray="90 125" stroke-linecap="round"
stroke-width="4">
<animateTransform attributeName="transform" dur="1s" from="0 25 25" repeatCount="indefinite" to="360 25 25"
type="rotate"/>
</circle>
</svg>
<script src="composeApp.js" type="application/javascript"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
+12
View File
@@ -0,0 +1,12 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.koin.compiler) apply false
}
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
rsync -rti --delete --stats server/build/install/server/* /Users/hb/mnt/nas/Server/vol/kas/app
+8
View File
@@ -0,0 +1,8 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
org.jetbrains.compose.experimental.macos.enabled=true
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
+98
View File
@@ -0,0 +1,98 @@
[versions]
serverVersion = "0.1.0-beta.1"
# z.Zt. ist nur major > 0.minor.patch möglich für Apple-Targets
# -> https://youtrack.jetbrains.com/issue/CMP-2360
# nach compose multiplatform 1.11 nochmal anschauen
composeAppVersion = "1.0.0"
androidx-lifecycle = "2.9.6"
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.0"
junit = "4.13.2"
kotlin = "2.3.0"
kotlin-wrappers-css = "2025.6.4"
kotlinx-coroutines = "1.10.2"
kotlinx-html = "0.12.0"
koin-bom = "4.2.0-RC1"
koin-plugin = "0.3.0"
ktor = "3.4.0"
logback = "1.5.26"
material3 = "1.10.0-alpha05"
uiDesktop = "1.7.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
# -- compose bundle
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-materialIcons = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "uiDesktop" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
# --
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" }
kotlin-wrappersCss = { module = "org.jetbrains.kotlin-wrappers:kotlin-css-jvm", version.ref = "kotlin-wrappers-css" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
ktor-serverCore = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-serverNetty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-serverCio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-serverAuth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-serverAuthJwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" }
ktor-serverAuthApiKey = { module = "io.ktor:ktor-server-auth-api-key", version.ref = "ktor" }
ktor-serializationJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-callLogging = { module = "io.ktor:ktor-call-logging", version.ref = "ktor" }
ktor-serverHtmlBuilder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
ktor-serverHtmx = { module = "io.ktor:ktor-server-htmx", version.ref = "ktor" }
ktor-htmxHtml = { module = "io.ktor:ktor-htmx-html", version.ref = "ktor" }
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-clientAuth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-clientLogging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-clientContentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-clientJava = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-clientOkHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-clientWinHttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" }
ktor-clientCurl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
ktor-clientJs = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
androidx-ui-desktop = { group = "androidx.compose.ui", name = "ui-desktop", version.ref = "uiDesktop" }
# -- Koin
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-annotations = { module = "io.insert-koin:koin-annotations" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation" }
# z.Zt. alpha
#koin-compose-viewmodel-navigation3 = { module = "io.insert-koin:koin-compose-viewmodel-navigation3" }
koin-ktor = { module = "io.insert-koin:koin-ktor" }
koin-test = { module = "io.insert-koin:koin-test" }
# --
[bundles]
kotlin-compose = [
"compose-ui", "compose-runtime", "compose-foundation",
"compose-components-resources", "compose-uiToolingPreview", "compose-materialIcons",
"compose-material3",
"androidx-lifecycle-runtimeCompose", "androidx-lifecycle-viewmodelCompose"
]
[plugins]
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+8
View File
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.ktor)
application
alias(libs.plugins.kotlinSerialization)
alias(masterLibs.plugins.versionPlugin)
}
group = "net.keinekekse.app"
version = libs.versions.serverVersion.get()
description = "ktor Application-Server"
extra["displayname"] = "kAS"
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
application {
mainClass.set("net.keinekekse.app.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
dependencies {
implementation(masterLibs.utilLibrary)
// implementation(projects.shared) // err wg. doppelter jar ??
implementation(libs.logback)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverAuth)
implementation(libs.ktor.serverAuthApiKey)
implementation(libs.ktor.serverAuthJwt)
implementation(libs.ktor.serverContentNegotiation)
implementation(libs.ktor.serializationJson)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}
kotlin {
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
}
+3
View File
@@ -0,0 +1,3 @@
#auto generated
#Tue Feb 03 00:47:38 CET 2026
build.number=19
@@ -0,0 +1,19 @@
package net.keinekekse.app
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import net.keinekekse.app.server.VERSION
import net.keinekekse.app.server.config.configureSecurity
fun main() {
// embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
println("**\n** Starting ${VERSION.displayName} - ${VERSION.description} ${VERSION.fullVersion}\n**")
embeddedServer(Netty, port = 7770, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureSecurity()
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.server
import net.keinekekse.version.Version
object VERSION: Version {
override val version = "0.1.0-beta.1+build.19"
override val fullVersion = "0.1.0-beta.1+build.19"
override val major = 0
override val minor = 1
override val patch = 0
override val preRelease = "beta.1"
override val buildMetadata = "build.19"
override val buildDate = "2026-02-02T23:47:38.629893Z"
override val displayName = "kAS"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "server"
override val description = "ktor Application-Server"
}
@@ -0,0 +1,22 @@
package net.keinekekse.app.server.config
import io.ktor.server.auth.jwt.*
import java.security.Principal
class AppPrincipal(credential: JWTCredential) : Principal {
val userId: String = credential.getClaim("preferred_username", String::class) ?: ""
val vorname: String = credential.getClaim("given_name", String::class) ?: ""
val nachname: String = credential.getClaim("family_name", String::class) ?: ""
val email: String = credential.getClaim("email", String::class) ?: ""
val roles: List<String>
init {
val realmAccess: Map<*, *>? = credential.getClaim("realm_access", Map::class)
val list = realmAccess?.get("roles")
roles = if (list is List<*>) list.map { it as String } else emptyList()
}
override fun getName(): String {
return "$userId ($vorname $nachname) <$email>"
}
}
@@ -0,0 +1,55 @@
package net.keinekekse.app.server.config
import com.auth0.jwk.UrlJwkProvider
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.keinekekse.app.server.VERSION
import java.net.URI
fun Application.configureSecurity() {
// TODO die Werte müssen aus der Config kommen
// Kommunikation funktioniert nur im internen Containernetzwerk,
// d.h. Container- bzw. Pod-Name + interner Port
val keycloakInternal = "http://kec:8080/realms"
val keycloak = "https://kec.keinekekse.net/realms"
val realm = "app-server-realm"
val clientId = "pkce-book-app"
val certUrl = "$keycloakInternal/$realm/protocol/openid-connect/certs"
val issuer = "$keycloak/$realm"
authentication {
jwt("auth-KC") {
verifier(
UrlJwkProvider(URI(certUrl).toURL()), issuer
) {
withAudience("account")
withClaim("azp", clientId)
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
validate { credential ->
AppPrincipal(credential)
}
}
}
routing {
authenticate("auth-KC") {
get("/") {
val principal = call.principal<AppPrincipal>()
call.respondText("Ktor ${VERSION.displayName} ${VERSION.fullVersion} - principal=${principal?.name}")
}
get("/test") {
val principal = call.principal<AppPrincipal>()
call.respondText("user: ${principal?.userId}")
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
+43
View File
@@ -0,0 +1,43 @@
rootProject.name = "app"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
maven("https://nexus.keinekekse.net/repository/maven-public/")
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://nexus.keinekekse.net/repository/maven-public/")
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
}
versionCatalogs {
create("masterLibs") {
from("net.keinekekse:version-catalog:128")
}
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":books")
include(":server")
include(":shared")
+38
View File
@@ -0,0 +1,38 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler) // sonst fkt. Composable-Import nicht
alias(libs.plugins.koin.compiler)
}
kotlin {
jvm()
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
// nodejs()
browser()
}
macosArm64()
// In einem Compose-Projekt gibt das Ärger, weil Compose nicht mit
// linux + mingw kompatibel ist. Sonst müsste alle UI über die
// restlichen Plattformen verstreut werden.
// linuxX64()
// mingwX64()
sourceSets {
commonMain.dependencies {
implementation(libs.bundles.kotlin.compose)
}
jvmMain.dependencies {
}
wasmJsMain.dependencies {
}
macosArm64Main.dependencies {
}
}
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
}
@@ -0,0 +1,3 @@
package net.keinekekse.app
const val SERVER_PORT = 7770
@@ -0,0 +1,9 @@
package net.keinekekse.app
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
@@ -0,0 +1,7 @@
package net.keinekekse.app
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
@@ -0,0 +1,31 @@
package net.keinekekse.app.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
@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,7 @@
package net.keinekekse.app
class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
actual fun getPlatform(): Platform = JVMPlatform()
@@ -0,0 +1,7 @@
package net.keinekekse.app
class MacOsPlatform : Platform {
override val name: String = "macOS"
}
actual fun getPlatform(): Platform = MacOsPlatform()
@@ -0,0 +1,7 @@
package net.keinekekse.app
class WasmPlatform : Platform {
override val name: String = "Web with Kotlin/Wasm"
}
actual fun getPlatform(): Platform = WasmPlatform()