From a069e6a35ac6c22f47287f6a43fcf313d6fc79fb Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 08:23:02 +0200 Subject: [PATCH] Authentification OIDC : flux de connexion et protection des routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/auth/oidc.go : découverte, /auth/login (PKCE S256, state, nonce), /auth/callback (échange du code, vérification de l'ID Token, groupes), /auth/logout (RP-Initiated Logout), middleware de session. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo --- internal/auth/oidc.go | 549 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 internal/auth/oidc.go diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..efd6de3 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,549 @@ +package auth + +import ( + "context" + "crypto" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" +) + +const ( + sessionCookie = "excalicloud_session" + stateCookie = "excalicloud_oidc" + stateTTL = 10 * time.Minute + + // LoginHeader accompagne les réponses 401 de l'API : le frontend y lit + // l'adresse de connexion. + LoginHeader = "X-Auth-Login" +) + +// Config regroupe les réglages OIDC (variables OIDC_* et SESSION_*). +type Config struct { + Issuer string // OIDC_ISSUER + ClientID string // OIDC_CLIENT_ID + ClientSecret string // OIDC_CLIENT_SECRET (vide : client public, PKCE seul) + RedirectURL string // OIDC_REDIRECT_URL (vide : https:///auth/callback) + Scopes []string // OIDC_SCOPES + // AllowedGroups restreint l'accès aux membres d'au moins un de ces groupes + // (claim GroupsClaim) ; vide : tout utilisateur authentifié. + AllowedGroups []string + GroupsClaim string // OIDC_GROUPS_CLAIM, « groups » par défaut + EndSession bool // OIDC_LOGOUT : déconnexion aussi chez le fournisseur + SessionTTL time.Duration + Secret []byte // clé HMAC des cookies + Client *http.Client +} + +// discovery est la partie utile de /.well-known/openid-configuration. +type discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + EndSessionEndpoint string `json:"end_session_endpoint"` +} + +// OIDC authentifie les utilisateurs auprès d'un fournisseur OpenID Connect +// (Authentik, Keycloak, Entra ID, Google, Dex...). +type OIDC struct { + cfg Config + signer signer + + mu sync.Mutex + disc *discovery // nil : pas encore découvert + keys map[string]crypto.PublicKey + keysAt time.Time + discErr time.Time // dernier échec de découverte (limite les tentatives) +} + +// NewOIDC prépare l'authentification ; la découverte du fournisseur est +// tentée tout de suite puis, en cas d'échec, à la première connexion. +func NewOIDC(ctx context.Context, cfg Config) (*OIDC, error) { + if cfg.Issuer == "" || cfg.ClientID == "" { + return nil, errors.New("OIDC_ISSUER et OIDC_CLIENT_ID sont requis en mode oidc") + } + if len(cfg.Secret) < 16 { + return nil, errors.New("clé de session trop courte") + } + if cfg.RedirectURL != "" { + u, err := url.Parse(cfg.RedirectURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return nil, errors.New("OIDC_REDIRECT_URL invalide (URL absolue attendue)") + } + } + if len(cfg.Scopes) == 0 { + cfg.Scopes = []string{"openid", "profile", "email"} + } + if !slices.Contains(cfg.Scopes, "openid") { + cfg.Scopes = append([]string{"openid"}, cfg.Scopes...) + } + if cfg.GroupsClaim == "" { + cfg.GroupsClaim = "groups" + } + if cfg.SessionTTL <= 0 { + cfg.SessionTTL = 24 * time.Hour + } + if cfg.Client == nil { + cfg.Client = &http.Client{Timeout: 15 * time.Second} + } + a := &OIDC{cfg: cfg, signer: signer{cfg.Secret}} + if _, err := a.provider(ctx); err != nil { + slog.Warn("fournisseur OIDC injoignable, nouvel essai à la première connexion", "issuer", cfg.Issuer, "err", err) + } + return a, nil +} + +func (a *OIDC) getJSON(ctx context.Context, u string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + res, err := a.cfg.Client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%s : HTTP %d", u, res.StatusCode) + } + return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v) +} + +// provider renvoie la configuration découverte (mise en cache). +func (a *OIDC) provider(ctx context.Context) (*discovery, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.disc != nil { + return a.disc, nil + } + if time.Since(a.discErr) < 5*time.Second { + return nil, errors.New("fournisseur OIDC injoignable") + } + var d discovery + u := strings.TrimRight(a.cfg.Issuer, "/") + "/.well-known/openid-configuration" + if err := a.getJSON(ctx, u, &d); err != nil { + a.discErr = time.Now() + return nil, err + } + if strings.TrimRight(d.Issuer, "/") != strings.TrimRight(a.cfg.Issuer, "/") { + a.discErr = time.Now() + return nil, fmt.Errorf("l'émetteur annoncé (%s) ne correspond pas à OIDC_ISSUER", d.Issuer) + } + if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.JWKSURI == "" { + a.discErr = time.Now() + return nil, errors.New("configuration OIDC incomplète (authorization, token ou jwks absent)") + } + a.disc = &d + slog.Info("fournisseur OIDC découvert", "issuer", d.Issuer) + return a.disc, nil +} + +// key renvoie la clé publique kid, en rechargeant le JWKS si elle est +// inconnue (rotation de clés), au plus une fois par minute. +func (a *OIDC) key(ctx context.Context, d *discovery, kid, alg string) (crypto.PublicKey, error) { + a.mu.Lock() + defer a.mu.Unlock() + find := func() crypto.PublicKey { + if k, ok := a.keys[kid]; ok { + return k + } + if kid == "" && len(a.keys) == 1 { + for _, k := range a.keys { + return k + } + } + return nil + } + if k := find(); k != nil { + return k, nil + } + if time.Since(a.keysAt) < time.Minute && a.keys != nil { + return nil, fmt.Errorf("clé %q inconnue", kid) + } + var set struct { + Keys []jwk `json:"keys"` + } + if err := a.getJSON(ctx, d.JWKSURI, &set); err != nil { + return nil, fmt.Errorf("JWKS : %w", err) + } + a.keys = map[string]crypto.PublicKey{} + a.keysAt = time.Now() + for _, j := range set.Keys { + if j.Use != "" && j.Use != "sig" { + continue + } + if pk, err := j.publicKey(); err == nil { + a.keys[j.Kid] = pk + } + } + if k := find(); k != nil { + return k, nil + } + return nil, fmt.Errorf("clé %q absente du JWKS (alg %s)", kid, alg) +} + +// --- Routes /auth/* ----------------------------------------------------------- + +// Register ajoute /auth/login, /auth/callback, /auth/logout et /auth/logged-out. +func (a *OIDC) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /auth/login", a.login) + mux.HandleFunc("GET /auth/callback", a.callback) + mux.HandleFunc("GET /auth/logout", a.logout) + mux.HandleFunc("POST /auth/logout", a.logout) + mux.HandleFunc("GET /auth/logged-out", func(w http.ResponseWriter, r *http.Request) { + page(w, http.StatusOK, "Déconnecté", "Vous êtes déconnecté.", "/", "Se reconnecter") + }) +} + +func secure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// baseURL est l'origine publique du service : celle de OIDC_REDIRECT_URL si +// elle est fixée, sinon celle de la requête (en-têtes X-Forwarded-* compris). +func (a *OIDC) baseURL(r *http.Request) string { + if a.cfg.RedirectURL != "" { + u, _ := url.Parse(a.cfg.RedirectURL) + return u.Scheme + "://" + u.Host + } + scheme := "http" + if secure(r) { + scheme = "https" + } + host := r.Host + if h := r.Header.Get("X-Forwarded-Host"); h != "" { + host, _, _ = strings.Cut(h, ",") + host = strings.TrimSpace(host) + } + return scheme + "://" + host +} + +func (a *OIDC) redirectURL(r *http.Request) string { + if a.cfg.RedirectURL != "" { + return a.cfg.RedirectURL + } + return a.baseURL(r) + "/auth/callback" +} + +func (a *OIDC) setCookie(w http.ResponseWriter, r *http.Request, name, value, path string, ttl time.Duration) { + c := &http.Cookie{ + Name: name, + Value: value, + Path: path, + HttpOnly: true, + Secure: secure(r) || strings.HasPrefix(a.cfg.RedirectURL, "https://"), + SameSite: http.SameSiteLaxMode, // envoyé au retour du fournisseur (navigation GET) + } + if ttl < 0 { + c.MaxAge = -1 + } else { + c.Expires = now().Add(ttl) + c.MaxAge = int(ttl / time.Second) + } + http.SetCookie(w, c) +} + +// safeNext n'accepte que les chemins locaux (pas de redirection ouverte). +func safeNext(next string) string { + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") || + strings.HasPrefix(next, "/auth/") || strings.ContainsAny(next, "\r\n") { + return "/" + } + return next +} + +func (a *OIDC) login(w http.ResponseWriter, r *http.Request) { + d, err := a.provider(r.Context()) + if err != nil { + slog.Error("OIDC : découverte", "err", err) + page(w, http.StatusBadGateway, "Connexion impossible", + "Le fournisseur d'identité est injoignable pour l'instant.", r.URL.RequestURI(), "Réessayer") + return + } + st := loginState{ + State: randomString(24), + Nonce: randomString(24), + Verifier: randomString(48), // 64 caractères (RFC 7636 : 43 à 128) + Next: safeNext(r.URL.Query().Get("next")), + Exp: now().Add(stateTTL).Unix(), + } + v, err := a.signer.encode(st) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + a.setCookie(w, r, stateCookie, v, "/auth/", stateTTL) + + challenge := sha256.Sum256([]byte(st.Verifier)) + q := url.Values{ + "response_type": {"code"}, + "client_id": {a.cfg.ClientID}, + "redirect_uri": {a.redirectURL(r)}, + "scope": {strings.Join(a.cfg.Scopes, " ")}, + "state": {st.State}, + "nonce": {st.Nonce}, + "code_challenge": {b64.EncodeToString(challenge[:])}, + "code_challenge_method": {"S256"}, + } + sep := "?" + if strings.Contains(d.AuthorizationEndpoint, "?") { + sep = "&" + } + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, d.AuthorizationEndpoint+sep+q.Encode(), http.StatusFound) +} + +func (a *OIDC) callback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + q := r.URL.Query() + retry := "/auth/login" + var st loginState + c, err := r.Cookie(stateCookie) + if err == nil { + err = a.signer.decode(c.Value, &st) + } + if err == nil && st.Next != "/" { + retry += "?next=" + url.QueryEscape(st.Next) + } + a.setCookie(w, r, stateCookie, "", "/auth/", -1) // usage unique + if e := q.Get("error"); e != "" { + slog.Warn("OIDC : refus du fournisseur", "error", e, "description", q.Get("error_description")) + page(w, http.StatusForbidden, "Connexion refusée", "Le fournisseur d'identité a refusé la connexion ("+e+").", retry, "Réessayer") + return + } + if err != nil || st.Exp < now().Unix() || st.State == "" || q.Get("state") != st.State { + page(w, http.StatusBadRequest, "Connexion expirée", + "La demande de connexion est expirée ou invalide (cookies bloqués ?).", retry, "Recommencer") + return + } + user, err := a.exchange(r, q.Get("code"), st) + if errors.Is(err, errForbidden) { + slog.Warn("OIDC : accès refusé", "sub", user.Sub, "name", user.Name) + page(w, http.StatusForbidden, "Accès refusé", + "Votre compte ("+user.Name+") n'appartient à aucun groupe autorisé.", "/auth/logout", "Changer de compte") + return + } + if err != nil { + slog.Error("OIDC : échange du code", "err", err) + page(w, http.StatusBadGateway, "Connexion impossible", "La vérification de l'identité a échoué.", retry, "Réessayer") + return + } + if err := a.startSession(w, r, user); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + slog.Info("connexion OIDC", "sub", user.Sub, "name", user.Name) + http.Redirect(w, r, st.Next, http.StatusFound) +} + +var errForbidden = errors.New("utilisateur hors des groupes autorisés") + +// exchange échange le code contre les jetons, vérifie l'ID Token et en tire +// l'utilisateur. +func (a *OIDC) exchange(r *http.Request, code string, st loginState) (User, error) { + ctx := r.Context() + d, err := a.provider(ctx) + if err != nil { + return User{}, err + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {a.redirectURL(r)}, + "code_verifier": {st.Verifier}, + "client_id": {a.cfg.ClientID}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return User{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if a.cfg.ClientSecret != "" { // client_secret_basic (RFC 6749 2.3.1) + req.SetBasicAuth(url.QueryEscape(a.cfg.ClientID), url.QueryEscape(a.cfg.ClientSecret)) + } + res, err := a.cfg.Client.Do(req) + if err != nil { + return User{}, err + } + defer res.Body.Close() + var tok struct { + IDToken string `json:"id_token"` + Error string `json:"error"` + Desc string `json:"error_description"` + } + if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&tok); err != nil { + return User{}, fmt.Errorf("réponse du token endpoint (HTTP %d) : %w", res.StatusCode, err) + } + if res.StatusCode != http.StatusOK || tok.IDToken == "" { + return User{}, fmt.Errorf("token endpoint : HTTP %d %s %s", res.StatusCode, tok.Error, tok.Desc) + } + return a.verifyIDToken(ctx, d, tok.IDToken, st.Nonce) +} + +func (a *OIDC) verifyIDToken(ctx context.Context, d *discovery, raw, nonce string) (User, error) { + h, body, signed, sig, err := parseJWT(raw) + if err != nil { + return User{}, err + } + var key crypto.PublicKey + if !strings.HasPrefix(h.Alg, "HS") { + if key, err = a.key(ctx, d, h.Kid, h.Alg); err != nil { + return User{}, err + } + } + if err := verifySignature(h.Alg, key, []byte(a.cfg.ClientSecret), signed, sig); err != nil { + return User{}, err + } + var c idClaims + if err := json.Unmarshal(body, &c); err != nil { + return User{}, errors.New("claims illisibles") + } + if err := c.validate(d.Issuer, a.cfg.ClientID, nonce, now()); err != nil { + return User{}, err + } + u := User{Sub: c.Sub, Email: c.Email, Name: c.PreferredUsername} + for _, n := range []string{c.Name, c.Email, c.Sub} { + if u.Name == "" { + u.Name = n + } + } + if len(a.cfg.AllowedGroups) > 0 { + var all map[string]any + _ = json.Unmarshal(body, &all) + groups := stringList(claimPath(all, a.cfg.GroupsClaim)) + ok := slices.ContainsFunc(groups, func(g string) bool { + // Keycloak préfixe les groupes par « / » quand le chemin complet est inclus. + return slices.Contains(a.cfg.AllowedGroups, g) || slices.Contains(a.cfg.AllowedGroups, strings.TrimPrefix(g, "/")) + }) + if !ok { + return u, errForbidden + } + } + return u, nil +} + +func (a *OIDC) startSession(w http.ResponseWriter, r *http.Request, u User) error { + v, err := a.signer.encode(session{User: u, Exp: now().Add(a.cfg.SessionTTL).Unix()}) + if err != nil { + return err + } + a.setCookie(w, r, sessionCookie, v, "/", a.cfg.SessionTTL) + return nil +} + +func (a *OIDC) logout(w http.ResponseWriter, r *http.Request) { + a.setCookie(w, r, sessionCookie, "", "/", -1) + w.Header().Set("Cache-Control", "no-store") + back := a.baseURL(r) + "/auth/logged-out" + if a.cfg.EndSession { + if d, err := a.provider(r.Context()); err == nil && d.EndSessionEndpoint != "" { + // RP-Initiated Logout 1.0 : client_id identifie le client sans id_token_hint. + q := url.Values{"client_id": {a.cfg.ClientID}, "post_logout_redirect_uri": {back}} + sep := "?" + if strings.Contains(d.EndSessionEndpoint, "?") { + sep = "&" + } + http.Redirect(w, r, d.EndSessionEndpoint+sep+q.Encode(), http.StatusFound) + return + } + } + http.Redirect(w, r, back, http.StatusFound) +} + +// --- Protection des routes ------------------------------------------------------- + +type ctxKey struct{} + +// UserFrom renvoie l'utilisateur de la requête (nil hors session OIDC). +func UserFrom(ctx context.Context) *User { + u, _ := ctx.Value(ctxKey{}).(*User) + return u +} + +// Session renvoie l'utilisateur du cookie de session s'il est valide. +func (a *OIDC) session(r *http.Request) (session, bool) { + var s session + c, err := r.Cookie(sessionCookie) + if err != nil || a.signer.decode(c.Value, &s) != nil || s.Exp < now().Unix() || s.Sub == "" { + return s, false + } + return s, true +} + +// Middleware exige une session sauf pour /auth/* et les chemins publics. +// Navigation sans session : page qui renvoie vers /auth/login en conservant +// l'URL complète (fragment #/dossier/schéma compris). API : 401 + LoginHeader. +// La session est prolongée à chaque requête passée la moitié de sa durée. +func (a *OIDC) Middleware(next http.Handler, public func(string) bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if strings.HasPrefix(p, "/auth/") || public(p) { + next.ServeHTTP(w, r) + return + } + s, ok := a.session(r) + if !ok { + if strings.HasPrefix(p, "/api/") || r.Method != http.MethodGet { + w.Header().Set(LoginHeader, "/auth/login") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"session expirée : reconnexion nécessaire"}`)) + return + } + loginRedirect(w) + return + } + if time.Until(time.Unix(s.Exp, 0)) < a.cfg.SessionTTL/2 { + _ = a.startSession(w, r, s.User) + } + u := s.User + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, &u))) + }) +} + +// loginRedirect renvoie vers /auth/login par script pour conserver le +// fragment de l'URL, que le navigateur n'envoie pas au serveur. +func loginRedirect(w http.ResponseWriter) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `Connexion + +`) +} + +// page affiche un message simple (erreur de connexion, déconnexion). +func page(w http.ResponseWriter, status int, title, msg, link, label string) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + e := html.EscapeString + _, _ = fmt.Fprintf(w, ` +%s + +

%s

%s

%s
`, e(title), e(title), e(msg), e(link), e(label)) +} + +// Me décrit la session courante pour le frontend. +type Me struct { + Mode string `json:"mode"` // none, local ou oidc + User *User `json:"user,omitempty"` + Logout string `json:"logout,omitempty"` // URL de déconnexion, si disponible +}