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:
parent
7b1c18590e
commit
a5ef8cf3ba
8 changed files with 322 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue