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:
1 parent
2b6b6fc72c
commit
80fd60d006
4 files changed
+119
-4
No files matched your search
+98
-4
@@ -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)))
|
||||
|
||||
@@ -25,6 +25,7 @@ import { pendingVersion, useUpdateStatus } from "./update";
|
||||
import { storeThumb } from "./thumbs";
|
||||
import { fetchLibraryItems, libraryUrlFromHash, parseServerLibrary } from "./library";
|
||||
import { fitScene } from "./fit";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
type SaveState = "saved" | "dirty" | "saving" | "error";
|
||||
@@ -533,6 +534,7 @@ export default function App() {
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
<UserMenu beforeLogout={save} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ export interface PublicScene {
|
||||
scene: { elements?: unknown[]; appState?: Record<string, unknown>; files?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** Session courante (GET /api/me) ; logout présent en mode OIDC. */
|
||||
export interface Me {
|
||||
mode: "none" | "local" | "oidc";
|
||||
user?: { sub: string; name: string; email?: string };
|
||||
logout?: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
@@ -83,6 +90,9 @@ export class ApiError extends Error {
|
||||
async function request(url: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
// Session OIDC expirée : reconnexion, puis retour sur la page courante.
|
||||
const login = res.status === 401 ? res.headers.get("X-Auth-Login") : null;
|
||||
if (login) location.assign(`${login}?next=${encodeURIComponent(location.pathname + location.search + location.hash)}`);
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
msg = (await res.json()).error ?? msg;
|
||||
@@ -104,6 +114,8 @@ const json = (body: unknown): RequestInit => ({
|
||||
export const api = {
|
||||
update: async (): Promise<UpdateStatus> => (await request("/api/update")).json(),
|
||||
|
||||
me: async (): Promise<Me> => (await request("/api/me")).json(),
|
||||
|
||||
list: async (dir: string): Promise<Entry[]> =>
|
||||
(await request(`/api/list?path=${q(dir)}`)).json(),
|
||||
|
||||
|
||||
@@ -199,3 +199,10 @@ export const EyeIcon = icon(
|
||||
<path d="M22 12c-2.67 4.67-6 7-10 7s-7.33-2.33-10-7c2.67-4.67 6-7 10-7s7.33 2.33 10 7" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const LogoutIcon = icon(
|
||||
<>
|
||||
<path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2" />
|
||||
<path d="M9 12h12l-3-3M18 15l3-3" />
|
||||
</>,
|
||||
);
|
||||
Reference in new issue
Block a user