Files
Aether/backend/internal/oidc/handler.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

197 lines
5.6 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"
"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
}
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc provider: %w", 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()
}