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>
209 lines
6.3 KiB
Go
209 lines
6.3 KiB
Go
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()
|
||
}
|