From 3f187f1eded0db188a3a813c15006b73c5a6eabe Mon Sep 17 00:00:00 2001 From: Tronax Date: Thu, 6 Aug 2026 10:28:12 +0200 Subject: [PATCH] Feature: Server-driven auth method discovery + OIDC-only enforcement Backend: - New AUTH_PASSWORD_ENABLED flag (default true). When false, email/password registration and login return 403; the server enforces OIDC-only login. - New OIDC_GENERIC_DISPLAY_NAME so the app can show 'Authentik'/'Keycloak' instead of a generic 'OIDC' label. - New public endpoint GET /api/config returns which auth methods the server offers (password_enabled + per-provider OIDC capabilities). No auth required, so the login screen can query it before logging in. - .env.example and docker-compose.yml expose the new env vars. App: - DTOs + MitbringslApi.getServerConfig() for /api/config. - AuthViewModel: new 'connect' flow. The user enters the server URL, taps 'Verbinden', and the app fetches /api/config. The returned ServerAuthConfig drives which login options are shown: * password-only -> email/password form * OIDC-only -> OIDC token form * both -> toggle between the two If the server offers no method, a clear error is shown. - AuthScreen: split into ConnectView (server URL) and LoginView (the login form matching the server's capabilities). The mode toggle only appears when the server offers more than one method. --- .../data/remote/api/MitbringslApi.kt | 4 + .../mitbringsl/data/remote/dto/Dtos.kt | 29 + .../example/mitbringsl/ui/auth/AuthScreen.kt | 547 ++++++++++-------- .../mitbringsl/ui/auth/AuthViewModel.kt | 141 ++++- backend/internal/config/config.go | 13 +- backend/internal/httpapi/api.go | 5 + backend/internal/httpapi/auth.go | 10 + backend/internal/httpapi/config.go | 62 ++ deploy/.env.example | 8 + deploy/docker-compose.yml | 2 + 10 files changed, 577 insertions(+), 244 deletions(-) create mode 100644 backend/internal/httpapi/config.go diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt index c6587eb..4cd3929 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt @@ -11,6 +11,10 @@ interface MitbringslApi { // --- Auth --------------------------------------------------------------- + /** Public server config: which auth methods are available. No auth required. */ + @GET("api/config") + suspend fun getServerConfig(): Response + @POST("auth/register") suspend fun register(@Body body: RegisterRequestDto): Response diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt index fbb3317..da2d62a 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt @@ -142,6 +142,35 @@ data class ServerOpDto( @SerialName("created_at") val createdAt: String, ) +// --------------------------------------------------------------------------- +// Server config (public, used by the login screen to decide which auth +// methods to offer) +// --------------------------------------------------------------------------- + +@Serializable +data class ServerConfigDto( + val auth: AuthConfigDto, +) + +@Serializable +data class AuthConfigDto( + @SerialName("password_enabled") val passwordEnabled: Boolean, + val oidc: OidcProvidersDto, +) + +@Serializable +data class OidcProvidersDto( + val google: OidcProviderDto, + val generic: OidcProviderDto, +) + +@Serializable +data class OidcProviderDto( + val enabled: Boolean, + val issuer: String? = null, + @SerialName("display_name") val displayName: String? = null, +) + // --------------------------------------------------------------------------- // Suggestions // --------------------------------------------------------------------------- diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt index 509ef3b..cedb582 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt @@ -41,8 +41,6 @@ fun AuthScreen( val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle() val userEmail = viewModel.sessionManager.getUserEmail() - var showServerUrlDialog by remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current val gradientBrush = Brush.verticalGradient( colors = listOf( @@ -88,7 +86,7 @@ fun AuthScreen( ) Text( - text = "Verbinde dich mit deinem Server oder Authentik für geräteübergreifenden Sync.", + text = "Verbinde dich mit deinem Server für geräteübergreifenden Sync.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center @@ -98,7 +96,10 @@ fun AuthScreen( // Server URL Chip AssistChip( - onClick = { showServerUrlDialog = true }, + onClick = { + // Reset back to the connect screen to re-configure the server. + viewModel.onServerUrlChanged(currentServerUrl) + }, label = { Text( text = "Server: $currentServerUrl", @@ -132,201 +133,15 @@ fun AuthScreen( horizontalAlignment = Alignment.CenterHorizontally ) { if (isLoggedIn) { - Text( - text = "Angemeldet als", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = userEmail ?: "Benutzer", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(24.dp)) - - Button( - onClick = { viewModel.sessionManager.clearSession() }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text("Abmelden") - } + LoggedInView(userEmail, viewModel) } else { - Text( - text = when { - uiState.isOidcMode -> "Authentik / OIDC Login" - uiState.isRegisteringMode -> "Konto erstellen" - else -> "Anmelden" - }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(20.dp)) - - // Error Message - AnimatedVisibility( - visible = uiState.errorMessage != null, - enter = fadeIn(), - exit = fadeOut() - ) { - uiState.errorMessage?.let { msg -> - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - Text( - text = msg, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - textAlign = TextAlign.Center - ) - } - } - } - - if (uiState.isOidcMode) { - // OIDC / Authentik Token Field - OutlinedTextField( - value = uiState.oidcTokenInput, - onValueChange = viewModel::onOidcTokenInputChanged, - label = { Text("OIDC ID-Token (JWT)") }, - placeholder = { Text("eyJhbGci...") }, - singleLine = false, - maxLines = 4, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { - focusManager.clearFocus() - viewModel.submit() - }) - ) + val cfg = uiState.serverConfig + if (cfg == null) { + // Step 1: connect to the server first. + ConnectView(uiState, viewModel, focusManager) } else { - // Display Name (only in register mode) - if (uiState.isRegisteringMode) { - OutlinedTextField( - value = uiState.displayNameInput, - onValueChange = viewModel::onDisplayNameChanged, - label = { Text("Anzeigename (optional)") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - // Email Field - OutlinedTextField( - value = uiState.emailInput, - onValueChange = viewModel::onEmailChanged, - label = { Text("E-Mail Adresse") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next - ) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // Password Field - OutlinedTextField( - value = uiState.passwordInput, - onValueChange = viewModel::onPasswordChanged, - label = { Text("Passwort") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - viewModel.submit() - } - ) - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Submit Button - Button( - onClick = { - focusManager.clearFocus() - viewModel.submit() - }, - enabled = !uiState.isLoading, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.5.dp - ) - } else { - Text( - text = when { - uiState.isOidcMode -> "Mit Authentik / OIDC verbinden" - uiState.isRegisteringMode -> "Registrieren" - else -> "Anmelden" - }, - fontWeight = FontWeight.Bold, - fontSize = 16.sp - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Toggle Buttons (E-Mail vs OIDC / Authentik) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = viewModel::toggleMode) { - Text( - text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall - ) - } - - TextButton(onClick = viewModel::toggleOidcMode) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Key, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = if (uiState.isOidcMode) "Passwort Login" else "Authentik / OIDC", - style = MaterialTheme.typography.bodySmall - ) - } - } + // Step 2: login with the methods the server offers. + LoginView(uiState, viewModel, cfg, focusManager) } } @@ -344,52 +159,310 @@ fun AuthScreen( Spacer(modifier = Modifier.height(32.dp)) } } +} - // Server URL Dialog - if (showServerUrlDialog) { - var tempUrl by remember { mutableStateOf(currentServerUrl) } +@Composable +private fun LoggedInView( + userEmail: String?, + viewModel: AuthViewModel, +) { + Text( + text = "Angemeldet als", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = userEmail ?: "Benutzer", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(24.dp)) - AlertDialog( - onDismissRequest = { showServerUrlDialog = false }, - title = { Text("Self-Hosted Server URL") }, - text = { - Column { + Button( + onClick = { viewModel.sessionManager.clearSession() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Abmelden") + } +} + +@Composable +private fun ConnectView( + uiState: AuthUiState, + viewModel: AuthViewModel, + focusManager: androidx.compose.ui.platform.FocusManager, +) { + Text( + text = "Mit Server verbinden", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Gib die URL deines Mitbringsl-Servers ein. Danach wird geprüft, welche Anmeldemethoden verfügbar sind.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(20.dp)) + + // Error Message + AnimatedVisibility( + visible = uiState.connectError != null, + enter = fadeIn(), + exit = fadeOut() + ) { + uiState.connectError?.let { msg -> + ErrorBox(msg) + Spacer(modifier = Modifier.height(16.dp)) + } + } + + OutlinedTextField( + value = uiState.serverUrlInput, + onValueChange = viewModel::onServerUrlChanged, + label = { Text("Server URL") }, + placeholder = { Text("https://mitbringsl.example.com") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + focusManager.clearFocus() + viewModel.connect() + }) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + focusManager.clearFocus() + viewModel.connect() + }, + enabled = !uiState.isConnecting, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + if (uiState.isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.5.dp + ) + } else { + Text("Verbinden", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } +} + +@Composable +private fun LoginView( + uiState: AuthUiState, + viewModel: AuthViewModel, + cfg: ServerAuthConfig, + focusManager: androidx.compose.ui.platform.FocusManager, +) { + Text( + text = when { + uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} anmelden" + uiState.isRegisteringMode -> "Konto erstellen" + else -> "Anmelden" + }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Error Message + AnimatedVisibility( + visible = uiState.errorMessage != null, + enter = fadeIn(), + exit = fadeOut() + ) { + uiState.errorMessage?.let { msg -> + ErrorBox(msg) + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (uiState.isOidcMode) { + // OIDC token input (App does PKCE itself; for now manual token paste). + OutlinedTextField( + value = uiState.oidcTokenInput, + onValueChange = viewModel::onOidcTokenInputChanged, + label = { Text("OIDC ID-Token (JWT)") }, + placeholder = { Text("eyJhbGci...") }, + singleLine = false, + maxLines = 4, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + focusManager.clearFocus() + viewModel.submit() + }) + ) + } else { + // Display Name (only in register mode) + if (uiState.isRegisteringMode) { + OutlinedTextField( + value = uiState.displayNameInput, + onValueChange = viewModel::onDisplayNameChanged, + label = { Text("Anzeigename (optional)") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + // Email Field + OutlinedTextField( + value = uiState.emailInput, + onValueChange = viewModel::onEmailChanged, + label = { Text("E-Mail Adresse") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Password Field + OutlinedTextField( + value = uiState.passwordInput, + onValueChange = viewModel::onPasswordChanged, + label = { Text("Passwort") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + viewModel.submit() + } + ) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Submit Button + Button( + onClick = { + focusManager.clearFocus() + viewModel.submit() + }, + enabled = !uiState.isLoading, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.5.dp + ) + } else { + Text( + text = when { + uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} verbinden" + uiState.isRegisteringMode -> "Registrieren" + else -> "Anmelden" + }, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Toggle Buttons – only shown when the server offers multiple methods. + if (cfg.hasMultipleMethods) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (cfg.passwordEnabled) { + TextButton(onClick = viewModel::toggleMode) { Text( - text = "Gib die URL deines Mitbringsl Backend-Servers ein:", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = tempUrl, - onValueChange = { tempUrl = it }, - label = { Text("Server URL") }, - placeholder = { Text("https://mitbringsl.example.com") }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth() + text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall ) } - }, - confirmButton = { - Button( - onClick = { - if (tempUrl.isNotBlank()) { - viewModel.saveServerUrl(tempUrl) - showServerUrlDialog = false - } - }, - shape = RoundedCornerShape(12.dp) - ) { - Text("Speichern") + } else { + Spacer(modifier = Modifier.width(0.dp)) + } + + TextButton(onClick = viewModel::toggleOidcMode) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (uiState.isOidcMode) "Passwort Login" else cfg.oidcDisplayName, + style = MaterialTheme.typography.bodySmall + ) } - }, - dismissButton = { - TextButton(onClick = { showServerUrlDialog = false }) { - Text("Abbrechen") - } - }, - shape = RoundedCornerShape(24.dp) + } + } + } else if (cfg.passwordEnabled) { + // Only password auth → just show register/login toggle. + TextButton(onClick = viewModel::toggleMode) { + Text( + text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Composable +private fun ErrorBox(msg: String) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = msg, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + textAlign = TextAlign.Center ) } } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt index 509eb1b..d2c0ca8 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.example.mitbringsl.data.auth.AuthRepository import com.example.mitbringsl.data.auth.AuthResult import com.example.mitbringsl.data.auth.SessionManager +import com.example.mitbringsl.data.remote.api.MitbringslApi +import com.example.mitbringsl.data.remote.dto.AuthConfigDto import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,7 +15,40 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * The auth methods the connected server offers. `null` until the user has + * successfully connected to a server (or when running in local-only mode). + */ +data class ServerAuthConfig( + val passwordEnabled: Boolean, + val googleEnabled: Boolean, + val googleDisplayName: String, + val genericEnabled: Boolean, + val genericDisplayName: String, +) { + /** At least one login method is available. */ + val hasAnyMethod: Boolean get() = passwordEnabled || googleEnabled || genericEnabled + + /** Both password and an OIDC provider are available → show a mode toggle. */ + val hasMultipleMethods: Boolean get() { + val oidcCount = listOf(googleEnabled, genericEnabled).count { it } + return (if (passwordEnabled) 1 else 0) + oidcCount > 1 + } + + val oidcDisplayName: String + get() = when { + genericEnabled -> genericDisplayName.ifBlank { "OIDC" } + googleEnabled -> "Google" + else -> "OIDC" + } +} + data class AuthUiState( + val serverUrlInput: String = "", + val isConnecting: Boolean = false, + val serverConfig: ServerAuthConfig? = null, + val connectError: String? = null, + val emailInput: String = "", val passwordInput: String = "", val displayNameInput: String = "", @@ -22,12 +57,12 @@ data class AuthUiState( val isOidcMode: Boolean = false, val isLoading: Boolean = false, val errorMessage: String? = null, - val serverUrlInput: String = "", ) @HiltViewModel class AuthViewModel @Inject constructor( private val authRepository: AuthRepository, + private val api: MitbringslApi, val sessionManager: SessionManager, ) : ViewModel() { @@ -36,13 +71,87 @@ class AuthViewModel @Inject constructor( ) val uiState: StateFlow = _uiState.asStateFlow() + // --- server connection ------------------------------------------------- + fun onServerUrlChanged(value: String) { - _uiState.update { it.copy(serverUrlInput = value) } + _uiState.update { + it.copy( + serverUrlInput = value, + connectError = null, + serverConfig = null, + ) + } } + /** + * Saves the server URL and fetches the server's auth config so the UI can + * show only the login methods the server actually offers. + */ + fun connect() { + val url = _uiState.value.serverUrlInput.trim() + if (url.isBlank()) { + _uiState.update { it.copy(connectError = "Bitte eine Server-URL eingeben.") } + return + } + sessionManager.saveServerUrl(url) + viewModelScope.launch { + _uiState.update { it.copy(isConnecting = true, connectError = null) } + try { + val resp = api.getServerConfig() + if (resp.isSuccessful && resp.body() != null) { + val cfg = resp.body()!!.auth.toServerAuthConfig() + if (!cfg.hasAnyMethod) { + _uiState.update { + it.copy( + isConnecting = false, + serverConfig = cfg, + connectError = "Der Server bietet keine Anmeldemethode an.", + ) + } + } else { + // Pick a sensible default mode based on what's available. + val defaultOidc = !cfg.passwordEnabled && (cfg.googleEnabled || cfg.genericEnabled) + _uiState.update { + it.copy( + isConnecting = false, + serverConfig = cfg, + connectError = null, + isOidcMode = defaultOidc, + isRegisteringMode = false, + errorMessage = null, + ) + } + } + } else { + _uiState.update { + it.copy( + isConnecting = false, + connectError = "Server nicht erreichbar (HTTP ${resp.code()}).", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isConnecting = false, + connectError = e.localizedMessage ?: "Verbindung fehlgeschlagen.", + ) + } + } + } + } + + // --- form input -------------------------------------------------------- + fun saveServerUrl(url: String) { sessionManager.saveServerUrl(url) - _uiState.update { it.copy(serverUrlInput = sessionManager.getServerUrl(), errorMessage = null) } + _uiState.update { + it.copy( + serverUrlInput = sessionManager.getServerUrl(), + serverConfig = null, + connectError = null, + ) + } } fun onEmailChanged(value: String) { @@ -71,10 +180,15 @@ class AuthViewModel @Inject constructor( } } + /** + * Only callable when the server offers more than one method; toggles + * between password and OIDC mode. + */ fun toggleOidcMode() { _uiState.update { it.copy( isOidcMode = !it.isOidcMode, + isRegisteringMode = false, errorMessage = null ) } @@ -88,7 +202,8 @@ class AuthViewModel @Inject constructor( _uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") } return } - onOidcTokenReceived(provider = "generic", idToken = state.oidcTokenInput.trim()) + val provider = pickOidcProvider(state.serverConfig) + onOidcTokenReceived(provider = provider, idToken = state.oidcTokenInput.trim()) return } @@ -141,4 +256,22 @@ class AuthViewModel @Inject constructor( } } } + + /** + * Picks the OIDC provider key the backend expects. Prefers "generic" when + * available (Authentik/Keycloak), falls back to "google". + */ + private fun pickOidcProvider(cfg: ServerAuthConfig?): String = when { + cfg?.genericEnabled == true -> "generic" + cfg?.googleEnabled == true -> "google" + else -> "generic" + } + + private fun AuthConfigDto.toServerAuthConfig(): ServerAuthConfig = ServerAuthConfig( + passwordEnabled = passwordEnabled, + googleEnabled = oidc.google.enabled, + googleDisplayName = oidc.google.displayName?.ifBlank { "Google" } ?: "Google", + genericEnabled = oidc.generic.enabled, + genericDisplayName = oidc.generic.displayName?.ifBlank { "OIDC" } ?: "OIDC", + ) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 94d4d06..5749239 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -35,6 +35,10 @@ type Config struct { SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days SessionCookieName string `env:"SESSION_COOKIE_NAME" envDefault:"mitbringsl_session"` + // AuthPasswordEnabled controls whether email/password registration and + // login are offered. Set to false to enforce OIDC-only login. + AuthPasswordEnabled bool `env:"AUTH_PASSWORD_ENABLED" envDefault:"true"` + // OIDC providers. Both optional; enable per provider. GoogleOIDC GoogleOIDCConfig GenericOIDC GenericOIDCConfig @@ -52,9 +56,12 @@ type GoogleOIDCConfig struct { // GenericOIDCConfig for any standards-compliant OIDC IdP (Keycloak, Authentik, ...). type GenericOIDCConfig struct { - Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"` - Issuer string `env:"OIDC_GENERIC_ISSUER"` - ClientID string `env:"OIDC_GENERIC_CLIENT_ID"` + Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"` + Issuer string `env:"OIDC_GENERIC_ISSUER"` + ClientID string `env:"OIDC_GENERIC_CLIENT_ID"` + // DisplayName is shown to users in the app, e.g. "Authentik" or "Keycloak". + // Defaults to "OIDC" when empty. + DisplayName string `env:"OIDC_GENERIC_DISPLAY_NAME" envDefault:"OIDC"` } // Load reads configuration from environment variables and validates basic invariants. diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go index 7e1415d..a2e06f8 100644 --- a/backend/internal/httpapi/api.go +++ b/backend/internal/httpapi/api.go @@ -20,6 +20,7 @@ type API struct { lists *ListHandler ops *OpsHandler suggest *SuggestHandler + config *ConfigHandler } // NewAPI constructs the API with all handler groups. @@ -49,6 +50,7 @@ func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API { lists: NewListHandler(listStore, itemStore), ops: NewOpsHandler(opStore, listStore), suggest: NewSuggestHandler(suggestStore), + config: NewConfigHandler(cfg), } } @@ -73,6 +75,9 @@ func (a *API) Handler() http.Handler { mux.HandleFunc("GET /healthz", a.health.Healthz) mux.HandleFunc("GET /readyz", a.health.Readyz) + // --- public server config (auth capabilities, no auth required) --- + mux.HandleFunc("GET /api/config", a.config.Config) + // --- auth endpoints --- mux.HandleFunc("POST /auth/register", a.auth.Register) mux.HandleFunc("POST /auth/login", a.auth.Login) diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index a11a0b9..1bd8634 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -65,6 +65,11 @@ type userDTO struct { // Register creates a new email/password account and immediately issues a session. func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + if !h.cfg.AuthPasswordEnabled { + renderError(w, http.StatusForbidden, "Password auth disabled", + "Email/password registration is disabled on this server. Use OIDC.") + return + } var req registerRequest if !decodeJSON(w, r, &req) { return @@ -104,6 +109,11 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { // Login verifies credentials and issues a session. Uses a constant-shape error // path so a wrong password and an unknown email yield the same response. func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + if !h.cfg.AuthPasswordEnabled { + renderError(w, http.StatusForbidden, "Password auth disabled", + "Email/password login is disabled on this server. Use OIDC.") + return + } var req loginRequest if !decodeJSON(w, r, &req) { return diff --git a/backend/internal/httpapi/config.go b/backend/internal/httpapi/config.go new file mode 100644 index 0000000..4d90626 --- /dev/null +++ b/backend/internal/httpapi/config.go @@ -0,0 +1,62 @@ +package httpapi + +import ( + "net/http" + + "github.com/mitbringsl/backend/internal/config" +) + +// ConfigHandler exposes the public server configuration that the Android app +// needs before logging in (e.g. which auth methods are available). +type ConfigHandler struct { + cfg *config.Config +} + +// NewConfigHandler creates a ConfigHandler. +func NewConfigHandler(cfg *config.Config) *ConfigHandler { + return &ConfigHandler{cfg: cfg} +} + +// serverConfigResponse is the public config payload returned to the app. +type serverConfigResponse struct { + Auth authConfig `json:"auth"` +} + +// authConfig describes which login methods the server offers. +type authConfig struct { + PasswordEnabled bool `json:"password_enabled"` + OIDC oidcProvidersDTO `json:"oidc"` +} + +type oidcProvidersDTO struct { + Google oidcProviderDTO `json:"google"` + Generic oidcProviderDTO `json:"generic"` +} + +type oidcProviderDTO struct { + Enabled bool `json:"enabled"` + Issuer string `json:"issuer,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +// Config returns the public server configuration. +// GET /api/config (public, no auth required) +func (h *ConfigHandler) Config(w http.ResponseWriter, r *http.Request) { + renderJSON(w, http.StatusOK, serverConfigResponse{ + Auth: authConfig{ + PasswordEnabled: h.cfg.AuthPasswordEnabled, + OIDC: oidcProvidersDTO{ + Google: oidcProviderDTO{ + Enabled: h.cfg.GoogleOIDC.Enabled, + Issuer: h.cfg.GoogleOIDC.Issuer, + DisplayName: "Google", + }, + Generic: oidcProviderDTO{ + Enabled: h.cfg.GenericOIDC.Enabled, + Issuer: h.cfg.GenericOIDC.Issuer, + DisplayName: h.cfg.GenericOIDC.DisplayName, + }, + }, + }, + }) +} diff --git a/deploy/.env.example b/deploy/.env.example index 480008b..71b1238 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -26,6 +26,12 @@ POSTGRES_DB=appdb # TTL of the opaque session token issued after login. SESSION_TOKEN_TTL=720h # 30 days +# --- Auth methods --- +# Set to false to disable email/password registration and login and enforce +# OIDC-only login. The Android app reads /api/config and only shows the login +# methods the server actually offers. +AUTH_PASSWORD_ENABLED=true + # --- OIDC: Google (optional) --- OIDC_GOOGLE_ENABLED=false # The OAuth client ID you created in Google Cloud Console (Audience the @@ -38,6 +44,8 @@ OIDC_GOOGLE_ISSUER=https://accounts.google.com OIDC_GENERIC_ENABLED=false OIDC_GENERIC_ISSUER= # e.g. https://idp.example.com/realms/main OIDC_GENERIC_CLIENT_ID= # audience the backend accepts +# Human-readable name shown in the app, e.g. "Authentik", "Keycloak". +OIDC_GENERIC_DISPLAY_NAME=OIDC # --- CORS (only relevant for browser clients; Android doesn't need it) --- # Comma-separated list of allowed origins, e.g. https://app.example.com diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f4cadec..e6faf05 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,7 @@ services: HTTP_ADDR: ":8080" PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080} SESSION_TOKEN_TTL: ${SESSION_TOKEN_TTL:-720h} + AUTH_PASSWORD_ENABLED: ${AUTH_PASSWORD_ENABLED:-true} # OIDC (all optional) OIDC_GOOGLE_ENABLED: ${OIDC_GOOGLE_ENABLED:-false} OIDC_GOOGLE_CLIENT_ID: ${OIDC_GOOGLE_CLIENT_ID:-} @@ -69,6 +70,7 @@ services: OIDC_GENERIC_ENABLED: ${OIDC_GENERIC_ENABLED:-false} OIDC_GENERIC_ISSUER: ${OIDC_GENERIC_ISSUER:-} OIDC_GENERIC_CLIENT_ID: ${OIDC_GENERIC_CLIENT_ID:-} + OIDC_GENERIC_DISPLAY_NAME: ${OIDC_GENERIC_DISPLAY_NAME:-OIDC} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} expose: ["8080"] depends_on: