Files
Aether/backend/internal/oidc/handler.go
T
HoneyandClaude Opus 5 617c665a2e fix(oidc): klare Meldung bei einem OIDC_ISSUER ohne Schema
OIDC_ISSUER=PocketID (der Name statt der Adresse) endete in
unsupported protocol scheme "" – tief aus der Bibliothek, ohne Hinweis
auf die Ursache. Und weil ein Fehlschlag hier den Start abbricht, stand
die ganze Cloud wegen einer Zeile in der .env.

Jetzt wird vorher geprueft und die Zeile beim Namen genannt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:01:21 +02:00

209 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"aether/internal/auth"
"aether/pkg/middleware"
"aether/pkg/response"
)
const stateCookie = "oidc_state"
// Config holds OIDC provider settings (all required if OIDC is enabled).
type Config struct {
Issuer string
ClientID string
ClientSecret string
RedirectURL string
// LinkByUsername: bestehendes Konto gleichen Namens übernehmen statt ein
// zweites anzulegen. Siehe auth.Store.GetOrCreateByOIDC.
LinkByUsername bool
}
// Handler implements /auth/oidc/login and /auth/oidc/callback.
type Handler struct {
cfg Config
provider *gooidc.Provider
oauth2 *oauth2.Config
verifier *gooidc.IDTokenVerifier
authStore *auth.Store
sessions *auth.SessionStore
}
// New creates an OIDC handler. Returns nil if cfg.Issuer is empty (OIDC disabled).
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore) (*Handler, error) {
if cfg.Issuer == "" {
return nil, nil
}
// Erst prüfen, ob überhaupt eine Adresse dasteht. Sonst hängt der Fehler
// tief in der Bibliothek: `unsupported protocol scheme ""` sagt nicht,
// dass jemand den *Namen* des Anbieters eingetragen hat statt seiner URL.
// Und weil ein Fehlschlag hier den Start abbricht, steht die ganze Cloud
// wegen einer Zeile in der .env – dann soll die Zeile wenigstens genannt
// werden. (Gemessen mit OIDC_ISSUER=PocketID.)
if u, uerr := url.Parse(cfg.Issuer); uerr != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf(
"OIDC_ISSUER muss die vollstaendige Adresse des Anbieters sein, "+
"z. B. https://id.example.com – gefunden: %q", cfg.Issuer)
}
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc provider %q nicht erreichbar: %w", cfg.Issuer, err)
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{gooidc.ScopeOpenID, "profile"},
}
return &Handler{
cfg: cfg,
provider: provider,
oauth2: oauthCfg,
verifier: provider.Verifier(&gooidc.Config{ClientID: cfg.ClientID}),
authStore: authStore,
sessions: sessions,
}, nil
}
// GET /api/v1/auth/oidc/login – redirect to OIDC provider.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
state, err := randomState()
if err != nil {
response.Error(w, http.StatusInternalServerError, "state generation failed")
return
}
// Secure wie bei Sitzungs- und CSRF-Cookie: Hinter dem Reverse Proxy sieht
// die App selbst kein TLS, deshalb über IsHTTPS (das X-Forwarded-Proto
// auswertet – aber nur von vertrauenswürdigen Proxys). Ohne das ginge der
// Zustandswert auch über eine unverschlüsselte Verbindung mit.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: "/",
MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: middleware.IsHTTPS(r),
})
http.Redirect(w, r, h.oauth2.AuthCodeURL(state), http.StatusFound)
}
// GET /api/v1/auth/oidc/callback – exchange code, create session, redirect to SPA.
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
// Validate state.
stateCk, err := r.Cookie(stateCookie)
if err != nil || r.URL.Query().Get("state") != stateCk.Value {
http.Redirect(w, r, "/?error=oidc_state", http.StatusFound)
return
}
// Clear state cookie.
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(w, r, "/?error=oidc_no_code", http.StatusFound)
return
}
// Exchange code for tokens.
token, err := h.oauth2.Exchange(r.Context(), code)
if err != nil {
http.Redirect(w, r, "/?error=oidc_exchange", http.StatusFound)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Redirect(w, r, "/?error=oidc_no_id_token", http.StatusFound)
return
}
idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Redirect(w, r, "/?error=oidc_verify", http.StatusFound)
return
}
// Extract claims.
var claims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
return
}
// Derive username: prefer preferred_username, fall back to sub.
username := sanitizeUsername(claims.PreferredUsername)
if username == "" {
username = sanitizeUsername(claims.Sub)
}
if username == "" {
http.Redirect(w, r, "/?error=oidc_no_username", http.StatusFound)
return
}
// Find or create user.
user, err := h.authStore.GetOrCreateByOIDC(r.Context(), claims.Sub, username, claims.Name, h.cfg.LinkByUsername)
if err != nil {
http.Redirect(w, r, "/?error=oidc_user", http.StatusFound)
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
return
}
// Set CSRF cookie so the SPA can function immediately after redirect.
_, _ = auth.SetCSRFCookie(w, r)
http.Redirect(w, r, "/", http.StatusFound)
}
// PublicConfig returns whether OIDC is enabled (called without auth).
func (h *Handler) IsEnabled() bool { return h != nil }
// ConfigJSON writes the OIDC config state (for login page).
func ConfigJSON(enabled bool, cloudName string) []byte {
b, _ := json.Marshal(map[string]any{
"oidc_enabled": enabled,
"cloud_name": cloudName,
})
return b
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func sanitizeUsername(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var out strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
out.WriteRune(r)
}
}
return out.String()
}