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

@ -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)

View file

@ -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

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