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/internal/storage" "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 blobs storage.Storage } // 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, blobs storage.Storage) (*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, blobs: blobs, }, 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"` Picture string `json:"picture"` } 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 } // Profilbild übernehmen. Steht es nicht im Token, beim UserInfo-Endpunkt // nachfragen – manche Anbieter führen `picture` nur dort. picture := claims.Picture if picture == "" { picture = h.pictureFromUserInfo(r.Context(), token) } h.syncAvatar(r.Context(), user, picture, token) 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) } // pictureFromUserInfo fragt den UserInfo-Endpunkt nach der Bildadresse. // Fehlschläge sind stumm: ohne Bild geht es weiter. func (h *Handler) pictureFromUserInfo(ctx context.Context, token *oauth2.Token) string { info, err := h.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) if err != nil { return "" } var c struct { Picture string `json:"picture"` } if err := info.Claims(&c); err != nil { return "" } return c.Picture } // 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() }