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.
This commit is contained in:
parent
b44bc8c3af
commit
3f187f1ede
10 changed files with 577 additions and 244 deletions
|
|
@ -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<ServerConfigDto>
|
||||
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body body: RegisterRequestDto): Response<AuthResponseDto>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,6 +133,39 @@ fun AuthScreen(
|
|||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isLoggedIn) {
|
||||
LoggedInView(userEmail, viewModel)
|
||||
} else {
|
||||
val cfg = uiState.serverConfig
|
||||
if (cfg == null) {
|
||||
// Step 1: connect to the server first.
|
||||
ConnectView(uiState, viewModel, focusManager)
|
||||
} else {
|
||||
// Step 2: login with the methods the server offers.
|
||||
LoginView(uiState, viewModel, cfg, focusManager)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onLoginSuccess) {
|
||||
Text(
|
||||
text = "Zurück zu meinen Listen",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoggedInView(
|
||||
userEmail: String?,
|
||||
viewModel: AuthViewModel,
|
||||
) {
|
||||
Text(
|
||||
text = "Angemeldet als",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
|
@ -156,10 +190,94 @@ fun AuthScreen(
|
|||
) {
|
||||
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 -> "Authentik / OIDC Login"
|
||||
uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} anmelden"
|
||||
uiState.isRegisteringMode -> "Konto erstellen"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
|
|
@ -177,26 +295,13 @@ fun AuthScreen(
|
|||
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
|
||||
)
|
||||
}
|
||||
ErrorBox(msg)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isOidcMode) {
|
||||
// OIDC / Authentik Token Field
|
||||
// OIDC token input (App does PKCE itself; for now manual token paste).
|
||||
OutlinedTextField(
|
||||
value = uiState.oidcTokenInput,
|
||||
onValueChange = viewModel::onOidcTokenInputChanged,
|
||||
|
|
@ -288,7 +393,7 @@ fun AuthScreen(
|
|||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
uiState.isOidcMode -> "Mit Authentik / OIDC verbinden"
|
||||
uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} verbinden"
|
||||
uiState.isRegisteringMode -> "Registrieren"
|
||||
else -> "Anmelden"
|
||||
},
|
||||
|
|
@ -300,11 +405,13 @@ fun AuthScreen(
|
|||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Toggle Buttons (E-Mail vs OIDC / Authentik)
|
||||
// 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 = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
|
||||
|
|
@ -312,6 +419,9 @@ fun AuthScreen(
|
|||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(0.dp))
|
||||
}
|
||||
|
||||
TextButton(onClick = viewModel::toggleOidcMode) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -322,74 +432,37 @@ fun AuthScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (uiState.isOidcMode) "Passwort Login" else "Authentik / OIDC",
|
||||
text = if (uiState.isOidcMode) "Passwort Login" else cfg.oidcDisplayName,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onLoginSuccess) {
|
||||
} else if (cfg.passwordEnabled) {
|
||||
// Only password auth → just show register/login toggle.
|
||||
TextButton(onClick = viewModel::toggleMode) {
|
||||
Text(
|
||||
text = "Zurück zu meinen Listen",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Server URL Dialog
|
||||
if (showServerUrlDialog) {
|
||||
var tempUrl by remember { mutableStateOf(currentServerUrl) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showServerUrlDialog = false },
|
||||
title = { Text("Self-Hosted Server URL") },
|
||||
text = {
|
||||
Column {
|
||||
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,
|
||||
@Composable
|
||||
private fun ErrorBox(msg: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (tempUrl.isNotBlank()) {
|
||||
viewModel.saveServerUrl(tempUrl)
|
||||
showServerUrlDialog = false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text("Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showServerUrlDialog = false }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuthUiState> = _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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -55,6 +59,9 @@ type GenericOIDCConfig struct {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
backend/internal/httpapi/config.go
Normal file
62
backend/internal/httpapi/config.go
Normal file
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue