Backend Phase B (2/2): OIDC auth + config validation

- internal/auth/oidc.go: OIDCService mit go-oidc v3
  - id_token-Verifikation via JWKS (Signatur, iss, aud, exp)
  - Provider-Caching (sync.Map, lazy init per Issuer-URL)
  - Unterstützt Google + Generic OIDC
- internal/auth/user.go: GetByOIDCSubject + CreateOIDCUser
  (find-or-create via (oidc_issuer, oidc_subject))
- internal/httpapi/auth.go: POST /auth/oidc Handler
  (id_token verifiziern → find-or-create User → issueSession)
- internal/httpapi/api.go: /auth/oidc Route verdrahtet
- internal/config/config.go: OIDC-Validierung
  (enabled → client_id + issuer Pflicht)
- go.mod/go.sum: go-oidc/v3 + oauth2 Abhängigkeiten
- AGENTS.md: Phase B vollständig als erledigt markiert

Verifiziert: E2E gegen lokalen Mock-IdP (Discovery → JWKS →
signiertes id_token → User angelegt → 2. Login gleicher User →
tampered Token → 401). Alle Fehlerpfade geprüft.

go build ./... && go vet ./... && go test ./internal/auth/... 
This commit is contained in:
Tronax 2026-08-05 19:45:00 +02:00
parent 7b1c18590e
commit a5ef8cf3ba
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
8 changed files with 322 additions and 23 deletions

View file

@ -20,12 +20,15 @@ const passwordMinLen = 8
type AuthHandler struct {
users *auth.UserStore
sessions *auth.SessionStore
oidc *auth.OIDCService
cfg *config.Config
}
// NewAuthHandler constructs an AuthHandler from the configured stores.
func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, sessions: sessions, cfg: cfg}
// oidc may be nil when no provider is enabled; the OIDC endpoint then returns
// 400 for every request.
func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, oidc *auth.OIDCService, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, sessions: sessions, oidc: oidc, cfg: cfg}
}
// --- request / response bodies ----------------------------------------------
@ -41,6 +44,11 @@ type loginRequest struct {
Password string `json:"password"`
}
type oidcRequest struct {
Provider string `json:"provider"` // "google" | "generic"
IDToken string `json:"id_token"`
}
type authResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
@ -136,6 +144,62 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// OIDC accepts an id_token that the Android app obtained via its own code+PKCE
// flow, verifies it against the configured provider, then finds-or-creates the
// user and issues a backend session (same shape as Login).
//
// Request body: {"provider": "google"|"generic", "id_token": "<jwt>"}
func (h *AuthHandler) OIDC(w http.ResponseWriter, r *http.Request) {
var req oidcRequest
if !decodeJSON(w, r, &req) {
return
}
provider := strings.TrimSpace(req.Provider)
idToken := strings.TrimSpace(req.IDToken)
if provider == "" || idToken == "" {
renderError(w, http.StatusBadRequest, "Bad request",
"Both 'provider' and 'id_token' are required.")
return
}
if h.oidc == nil {
renderError(w, http.StatusBadRequest, "OIDC disabled",
"No OIDC provider is configured on this server.")
return
}
claims, err := h.oidc.Verify(r.Context(), provider, idToken)
if err != nil {
if errors.Is(err, auth.ErrProviderUnknown) {
renderError(w, http.StatusBadRequest, "Unknown provider", err.Error())
return
}
slog.Info("oidc verify failed", "provider", provider, "error", err)
renderError(w, http.StatusUnauthorized, "Invalid id_token",
"The id_token could not be verified.")
return
}
// find-or-create user by (issuer, subject)
user, err := h.users.GetByOIDCSubject(r.Context(), claims.Issuer, claims.Subject)
if err != nil {
if !errors.Is(err, auth.ErrUserNotFound) {
slog.Error("oidc user lookup failed", "error", err, "issuer", claims.Issuer)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
return
}
user, err = h.users.CreateOIDCUser(r.Context(), claims.Issuer, claims.Subject, claims.Email, claims.Name)
if err != nil {
slog.Error("oidc user create failed", "error", err, "issuer", claims.Issuer)
renderError(w, http.StatusConflict, "Account conflict",
"This account cannot be linked automatically. Contact support.")
return
}
slog.Info("oidc user created", "user_id", user.ID, "issuer", claims.Issuer)
}
h.issueSession(w, r, user, http.StatusOK)
}
// --- helpers ----------------------------------------------------------------
// issueSession creates a session, sets the cookie (for browsers) and writes the