Authentification OIDC : AUTH_MODE=local|oidc

- AUTH_MODE=local (défaut) : comportement inchangé, Basic si AUTH_USER et
  AUTH_PASSWORD sont définis, sinon aucune authentification intégrée.
- AUTH_MODE=oidc : flux Authorization Code + PKCE (S256) avec state et nonce,
  découverte .well-known, vérification de l'ID Token (RS*/PS*/ES* via JWKS
  avec rotation des clés, HS* avec le secret client), émetteur, audience,
  expiration ; restriction optionnelle par groupes (OIDC_ALLOWED_GROUPS,
  OIDC_GROUPS_CLAIM, chemin pointé accepté).
- Session : cookie HttpOnly SameSite=Lax signé HMAC (SESSION_SECRET, sinon
  DATA_DIR/.session-secret), prolongée à l'usage (SESSION_TTL d'inactivité).
- Routes /auth/login, /auth/callback, /auth/logout (déconnexion propagée au
  fournisseur si OIDC_LOGOUT), /auth/logged-out ; GET /api/me.
- /s/, /api/public/, /healthz et fichiers statiques restent publics.
- Sans session : l'API répond 401 + X-Auth-Login, la navigation renvoie vers
  la connexion en conservant le schéma ouvert (#/chemin).
- Frontend : reconnexion automatique sur 401, bouton de déconnexion.
- Bibliothèque standard uniquement (internal/auth), tests avec un
  fournisseur simulé.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-08 08:20:54 +02:00
1 parent 2b6b6fc72c
commit 80fd60d006
4 files changed
+119 -4

No files matched your search

+98 -4
View File
@@ -4,7 +4,9 @@ package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
@@ -18,6 +20,7 @@ import (
"time"
"github.com/vblogio/excalidraw-go/internal/api"
"github.com/vblogio/excalidraw-go/internal/auth"
"github.com/vblogio/excalidraw-go/internal/storage"
"github.com/vblogio/excalidraw-go/internal/update"
"github.com/vblogio/excalidraw-go/web"
@@ -93,10 +96,10 @@ func main() {
})
mux.Handle("/", spa())
var h http.Handler = mux
if user, pass := os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASSWORD"); user != "" && pass != "" {
h = basicAuth(h, user, pass, public)
slog.Info("authentification Basic activée", "user", user)
h, err := authenticate(ctx, mux, dataDir)
if err != nil {
slog.Error("authentification", "err", err)
os.Exit(1)
}
h = logRequests(securityHeaders(h))
@@ -122,6 +125,97 @@ func main() {
slog.Info("arrêt")
}
// authenticate protège mux selon AUTH_MODE :
// - local (défaut) : authentification Basic si AUTH_USER et AUTH_PASSWORD
// sont définis, sinon aucune (reverse proxy avec SSO, usage local) ;
// - oidc : connexion auprès d'un fournisseur OpenID Connect (variables OIDC_*).
//
// Dans les deux cas, /healthz, /s/<jeton> et /api/public/ restent publics.
// GET /api/me décrit la session au frontend.
func authenticate(ctx context.Context, mux *http.ServeMux, dataDir string) (http.Handler, error) {
me := auth.Me{Mode: "none"}
var h http.Handler = mux
switch mode := strings.ToLower(env("AUTH_MODE", "local")); mode {
case "local":
if user, pass := os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASSWORD"); user != "" && pass != "" {
h = basicAuth(h, user, pass, public)
me = auth.Me{Mode: "local", User: &auth.User{Sub: user, Name: user}}
slog.Info("authentification locale (Basic) activée", "user", user)
} else {
slog.Warn("aucune authentification intégrée (AUTH_MODE=local sans AUTH_USER/AUTH_PASSWORD)")
}
case "oidc":
cfg, err := oidcConfig(dataDir)
if err != nil {
return nil, err
}
o, err := auth.NewOIDC(ctx, cfg)
if err != nil {
return nil, err
}
o.Register(mux)
h = o.Middleware(mux, public)
me.Mode = "oidc"
slog.Info("authentification OIDC activée", "issuer", cfg.Issuer, "client_id", cfg.ClientID,
"groups", cfg.AllowedGroups, "session_ttl", cfg.SessionTTL.String())
default:
return nil, fmt.Errorf("AUTH_MODE %q inconnu (local ou oidc)", mode)
}
mux.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) {
m := me
if u := auth.UserFrom(r.Context()); u != nil {
m.User, m.Logout = u, "/auth/logout"
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(m)
})
return h, nil
}
// oidcConfig lit les variables OIDC_* et SESSION_*.
func oidcConfig(dataDir string) (auth.Config, error) {
cfg := auth.Config{
Issuer: os.Getenv("OIDC_ISSUER"),
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Scopes: fields(env("OIDC_SCOPES", "openid profile email")),
GroupsClaim: env("OIDC_GROUPS_CLAIM", "groups"),
}
// Secret client lu dans un fichier (secrets Docker) si OIDC_CLIENT_SECRET_FILE est défini.
if f := os.Getenv("OIDC_CLIENT_SECRET_FILE"); f != "" && cfg.ClientSecret == "" {
b, err := os.ReadFile(f)
if err != nil {
return cfg, fmt.Errorf("OIDC_CLIENT_SECRET_FILE : %w", err)
}
cfg.ClientSecret = strings.TrimSpace(string(b))
}
if g := strings.TrimSpace(os.Getenv("OIDC_ALLOWED_GROUPS")); g != "" {
for _, s := range strings.Split(g, ",") {
if s = strings.TrimSpace(s); s != "" {
cfg.AllowedGroups = append(cfg.AllowedGroups, s)
}
}
}
var err error
if cfg.EndSession, err = strconv.ParseBool(env("OIDC_LOGOUT", "true")); err != nil {
return cfg, errors.New("OIDC_LOGOUT invalide (true ou false)")
}
if cfg.SessionTTL, err = time.ParseDuration(env("SESSION_TTL", "24h")); err != nil || cfg.SessionTTL < 5*time.Minute {
return cfg, errors.New("SESSION_TTL invalide (ex. 24h, minimum 5m)")
}
if cfg.Secret, err = auth.LoadSecret(os.Getenv("SESSION_SECRET"), dataDir); err != nil {
return cfg, err
}
return cfg, nil
}
// fields découpe une liste séparée par des espaces ou des virgules.
func fields(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool { return r == ' ' || r == ',' })
}
func historyPolicy() (storage.HistoryPolicy, error) {
p := storage.DefaultHistory
keep, err := strconv.Atoi(env("HISTORY_KEEP", strconv.Itoa(p.Keep)))