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:
Tronax 2026-08-06 10:28:12 +02:00
parent b44bc8c3af
commit 3f187f1ede
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
10 changed files with 577 additions and 244 deletions

View file

@ -11,6 +11,10 @@ interface MitbringslApi {
// --- Auth --------------------------------------------------------------- // --- Auth ---------------------------------------------------------------
/** Public server config: which auth methods are available. No auth required. */
@GET("api/config")
suspend fun getServerConfig(): Response<ServerConfigDto>
@POST("auth/register") @POST("auth/register")
suspend fun register(@Body body: RegisterRequestDto): Response<AuthResponseDto> suspend fun register(@Body body: RegisterRequestDto): Response<AuthResponseDto>

View file

@ -142,6 +142,35 @@ data class ServerOpDto(
@SerialName("created_at") val createdAt: String, @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 // Suggestions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -41,8 +41,6 @@ fun AuthScreen(
val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle() val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail() val userEmail = viewModel.sessionManager.getUserEmail()
var showServerUrlDialog by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val gradientBrush = Brush.verticalGradient( val gradientBrush = Brush.verticalGradient(
colors = listOf( colors = listOf(
@ -88,7 +86,7 @@ fun AuthScreen(
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@ -98,7 +96,10 @@ fun AuthScreen(
// Server URL Chip // Server URL Chip
AssistChip( AssistChip(
onClick = { showServerUrlDialog = true }, onClick = {
// Reset back to the connect screen to re-configure the server.
viewModel.onServerUrlChanged(currentServerUrl)
},
label = { label = {
Text( Text(
text = "Server: $currentServerUrl", text = "Server: $currentServerUrl",
@ -132,6 +133,39 @@ fun AuthScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isLoggedIn) { 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(
text = "Angemeldet als", text = "Angemeldet als",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@ -156,10 +190,94 @@ fun AuthScreen(
) { ) {
Text("Abmelden") 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 { } 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(
text = when { text = when {
uiState.isOidcMode -> "Authentik / OIDC Login" uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} anmelden"
uiState.isRegisteringMode -> "Konto erstellen" uiState.isRegisteringMode -> "Konto erstellen"
else -> "Anmelden" else -> "Anmelden"
}, },
@ -177,26 +295,13 @@ fun AuthScreen(
exit = fadeOut() exit = fadeOut()
) { ) {
uiState.errorMessage?.let { msg -> uiState.errorMessage?.let { msg ->
Surface( ErrorBox(msg)
color = MaterialTheme.colorScheme.errorContainer, Spacer(modifier = Modifier.height(16.dp))
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) { if (uiState.isOidcMode) {
// OIDC / Authentik Token Field // OIDC token input (App does PKCE itself; for now manual token paste).
OutlinedTextField( OutlinedTextField(
value = uiState.oidcTokenInput, value = uiState.oidcTokenInput,
onValueChange = viewModel::onOidcTokenInputChanged, onValueChange = viewModel::onOidcTokenInputChanged,
@ -288,7 +393,7 @@ fun AuthScreen(
} else { } else {
Text( Text(
text = when { text = when {
uiState.isOidcMode -> "Mit Authentik / OIDC verbinden" uiState.isOidcMode -> "Mit ${cfg.oidcDisplayName} verbinden"
uiState.isRegisteringMode -> "Registrieren" uiState.isRegisteringMode -> "Registrieren"
else -> "Anmelden" else -> "Anmelden"
}, },
@ -300,11 +405,13 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(12.dp)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
if (cfg.passwordEnabled) {
TextButton(onClick = viewModel::toggleMode) { TextButton(onClick = viewModel::toggleMode) {
Text( Text(
text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen", text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
@ -312,6 +419,9 @@ fun AuthScreen(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
} else {
Spacer(modifier = Modifier.width(0.dp))
}
TextButton(onClick = viewModel::toggleOidcMode) { TextButton(onClick = viewModel::toggleOidcMode) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -322,74 +432,37 @@ fun AuthScreen(
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = if (uiState.isOidcMode) "Passwort Login" else "Authentik / OIDC", text = if (uiState.isOidcMode) "Passwort Login" else cfg.oidcDisplayName,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
} }
} }
} } else if (cfg.passwordEnabled) {
// Only password auth → just show register/login toggle.
Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = viewModel::toggleMode) {
TextButton(onClick = onLoginSuccess) {
Text( Text(
text = "Zurück zu meinen Listen", text = if (uiState.isRegisteringMode) "Zurück zu Login" else "Konto erstellen",
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
) )
} }
} }
} }
Spacer(modifier = Modifier.height(32.dp)) @Composable
} private fun ErrorBox(msg: String) {
} Surface(
color = MaterialTheme.colorScheme.errorContainer,
// 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,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
if (tempUrl.isNotBlank()) {
viewModel.saveServerUrl(tempUrl)
showServerUrlDialog = false
}
},
shape = RoundedCornerShape(12.dp)
) { ) {
Text("Speichern") Text(
} text = msg,
}, color = MaterialTheme.colorScheme.onErrorContainer,
dismissButton = { style = MaterialTheme.typography.bodySmall,
TextButton(onClick = { showServerUrlDialog = false }) { modifier = Modifier.padding(12.dp),
Text("Abbrechen") textAlign = TextAlign.Center
}
},
shape = RoundedCornerShape(24.dp)
) )
} }
} }

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.example.mitbringsl.data.auth.AuthRepository import com.example.mitbringsl.data.auth.AuthRepository
import com.example.mitbringsl.data.auth.AuthResult import com.example.mitbringsl.data.auth.AuthResult
import com.example.mitbringsl.data.auth.SessionManager 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -13,7 +15,40 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject 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( data class AuthUiState(
val serverUrlInput: String = "",
val isConnecting: Boolean = false,
val serverConfig: ServerAuthConfig? = null,
val connectError: String? = null,
val emailInput: String = "", val emailInput: String = "",
val passwordInput: String = "", val passwordInput: String = "",
val displayNameInput: String = "", val displayNameInput: String = "",
@ -22,12 +57,12 @@ data class AuthUiState(
val isOidcMode: Boolean = false, val isOidcMode: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val serverUrlInput: String = "",
) )
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val api: MitbringslApi,
val sessionManager: SessionManager, val sessionManager: SessionManager,
) : ViewModel() { ) : ViewModel() {
@ -36,13 +71,87 @@ class AuthViewModel @Inject constructor(
) )
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
// --- server connection -------------------------------------------------
fun onServerUrlChanged(value: String) { 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) { fun saveServerUrl(url: String) {
sessionManager.saveServerUrl(url) 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) { 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() { fun toggleOidcMode() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isOidcMode = !it.isOidcMode, isOidcMode = !it.isOidcMode,
isRegisteringMode = false,
errorMessage = null errorMessage = null
) )
} }
@ -88,7 +202,8 @@ class AuthViewModel @Inject constructor(
_uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") } _uiState.update { it.copy(errorMessage = "Bitte ID-Token eingeben.") }
return return
} }
onOidcTokenReceived(provider = "generic", idToken = state.oidcTokenInput.trim()) val provider = pickOidcProvider(state.serverConfig)
onOidcTokenReceived(provider = provider, idToken = state.oidcTokenInput.trim())
return 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",
)
} }

View file

@ -35,6 +35,10 @@ type Config struct {
SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days
SessionCookieName string `env:"SESSION_COOKIE_NAME" envDefault:"mitbringsl_session"` 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. // OIDC providers. Both optional; enable per provider.
GoogleOIDC GoogleOIDCConfig GoogleOIDC GoogleOIDCConfig
GenericOIDC GenericOIDCConfig GenericOIDC GenericOIDCConfig
@ -55,6 +59,9 @@ type GenericOIDCConfig struct {
Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"` Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"`
Issuer string `env:"OIDC_GENERIC_ISSUER"` Issuer string `env:"OIDC_GENERIC_ISSUER"`
ClientID string `env:"OIDC_GENERIC_CLIENT_ID"` 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. // Load reads configuration from environment variables and validates basic invariants.

View file

@ -20,6 +20,7 @@ type API struct {
lists *ListHandler lists *ListHandler
ops *OpsHandler ops *OpsHandler
suggest *SuggestHandler suggest *SuggestHandler
config *ConfigHandler
} }
// NewAPI constructs the API with all handler groups. // 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), lists: NewListHandler(listStore, itemStore),
ops: NewOpsHandler(opStore, listStore), ops: NewOpsHandler(opStore, listStore),
suggest: NewSuggestHandler(suggestStore), 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 /healthz", a.health.Healthz)
mux.HandleFunc("GET /readyz", a.health.Readyz) 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 --- // --- auth endpoints ---
mux.HandleFunc("POST /auth/register", a.auth.Register) mux.HandleFunc("POST /auth/register", a.auth.Register)
mux.HandleFunc("POST /auth/login", a.auth.Login) mux.HandleFunc("POST /auth/login", a.auth.Login)

View file

@ -65,6 +65,11 @@ type userDTO struct {
// Register creates a new email/password account and immediately issues a session. // Register creates a new email/password account and immediately issues a session.
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { 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 var req registerRequest
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {
return 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 // 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. // path so a wrong password and an unknown email yield the same response.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { 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 var req loginRequest
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {
return return

View 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,
},
},
},
})
}

View file

@ -26,6 +26,12 @@ POSTGRES_DB=appdb
# TTL of the opaque session token issued after login. # TTL of the opaque session token issued after login.
SESSION_TOKEN_TTL=720h # 30 days 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 (optional) ---
OIDC_GOOGLE_ENABLED=false OIDC_GOOGLE_ENABLED=false
# The OAuth client ID you created in Google Cloud Console (Audience the # 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_ENABLED=false
OIDC_GENERIC_ISSUER= # e.g. https://idp.example.com/realms/main OIDC_GENERIC_ISSUER= # e.g. https://idp.example.com/realms/main
OIDC_GENERIC_CLIENT_ID= # audience the backend accepts 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) --- # --- CORS (only relevant for browser clients; Android doesn't need it) ---
# Comma-separated list of allowed origins, e.g. https://app.example.com # Comma-separated list of allowed origins, e.g. https://app.example.com

View file

@ -62,6 +62,7 @@ services:
HTTP_ADDR: ":8080" HTTP_ADDR: ":8080"
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080}
SESSION_TOKEN_TTL: ${SESSION_TOKEN_TTL:-720h} SESSION_TOKEN_TTL: ${SESSION_TOKEN_TTL:-720h}
AUTH_PASSWORD_ENABLED: ${AUTH_PASSWORD_ENABLED:-true}
# OIDC (all optional) # OIDC (all optional)
OIDC_GOOGLE_ENABLED: ${OIDC_GOOGLE_ENABLED:-false} OIDC_GOOGLE_ENABLED: ${OIDC_GOOGLE_ENABLED:-false}
OIDC_GOOGLE_CLIENT_ID: ${OIDC_GOOGLE_CLIENT_ID:-} OIDC_GOOGLE_CLIENT_ID: ${OIDC_GOOGLE_CLIENT_ID:-}
@ -69,6 +70,7 @@ services:
OIDC_GENERIC_ENABLED: ${OIDC_GENERIC_ENABLED:-false} OIDC_GENERIC_ENABLED: ${OIDC_GENERIC_ENABLED:-false}
OIDC_GENERIC_ISSUER: ${OIDC_GENERIC_ISSUER:-} OIDC_GENERIC_ISSUER: ${OIDC_GENERIC_ISSUER:-}
OIDC_GENERIC_CLIENT_ID: ${OIDC_GENERIC_CLIENT_ID:-} OIDC_GENERIC_CLIENT_ID: ${OIDC_GENERIC_CLIENT_ID:-}
OIDC_GENERIC_DISPLAY_NAME: ${OIDC_GENERIC_DISPLAY_NAME:-OIDC}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
expose: ["8080"] expose: ["8080"]
depends_on: depends_on: