Files
Anand 7295a28ec9 Opt-in SSO single logout, nice chart scales, storage usage donut, topology export/snap, disk SMART + temperature
- OIDC: RP-Initiated Logout is now opt-in (default off) via a new
  'single_logout' setting, with the exact post-logout redirect URL shown
  in Settings for the admin to register at their provider. Fixes the
  regression from last time: enabling it unconditionally broke sign-out
  for anyone whose IdP hadn't been told to trust the redirect yet
  (Keycloak's invalid_redirect_uri, browser stuck on a stale page).
- formatBytes shows up to 2 decimals (was an adaptive 0-or-1 rule); every
  bytes/rate chart now computes a rounded 'nice' axis scale (0/5/10/15/20
  GB, the standard Heckbert algorithm) and locks every tick + the tooltip
  to one consistent unit derived from the axis's own max.
- Storage page: the capacity donut is now sized by used bytes + a free
  remainder instead of by total capacity share, which was always 100%
  the moment there was only one pool — completely disconnected from the
  "224 GB used" text next to it.
- Topology: Export SVG (fits the full diagram regardless of current pan/
  zoom) and a Snap-to-grid toggle.
- New Disks tab on the node detail page: every physical disk with model/
  serial/size/type, PASSED/FAILED health, SSD/NVMe wearout %, and a
  per-drive temperature read from SMART, plus a full SMART attribute
  table per disk. Backed by new /nodes/{node}/disks and
  /nodes/{node}/disks/smart endpoints.
- CPU/GPU temperature is not exposed by Proxmox's own API (no built-in
  lm-sensors/nvidia-smi integration) and isn't something this can add
  without a node-side agent Proxmox doesn't ship — disk temperature via
  SMART is the thermal data actually available.
2026-09-04 00:22:53 +05:30

415 lines
13 KiB
Go

package auth
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// OIDCConfig is the subset of internal/config.OIDCConfig the client needs —
// kept local to auth so this package doesn't import internal/config.
type OIDCConfig struct {
DisplayName string
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
// AllowAutoProvision controls whether a first-time SSO login creates a
// new local account (the historical default) or is refused — see
// Service.FindOrCreateOIDCUser and ErrOIDCUserNotProvisioned. An admin
// who wants SSO restricted to pre-existing accounts turns this off.
AllowAutoProvision bool
// SingleLogout opts in to RP-Initiated Logout (also ending the session
// at the identity provider on sign-out) — see EndSessionURL. Defaults
// off: it requires the admin to have registered Ferrum's post-logout
// redirect URL with the provider first (e.g. Keycloak's "Valid post
// logout redirect URIs"), which an existing SSO deployment upgrading to
// this feature hasn't necessarily done — turning it on unconditionally
// would have broken sign-out for them (the provider rejects the
// redirect with invalid_redirect_uri, stranding the browser mid-logout)
// instead of leaving their working, Ferrum-only sign-out alone.
SingleLogout bool
}
// OIDCClient implements the OpenID Connect authorization-code flow against
// any standard-compliant provider (Keycloak, Authentik, Entra ID, Okta, ...)
// using only the standard library. It supports RS256-signed ID tokens,
// which covers every mainstream provider's default signing algorithm; a
// provider configured for a different alg (ES256, HS256, ...) will fail
// verification with a clear error rather than silently accepting a token
// it can't actually check.
type OIDCClient struct {
cfg OIDCConfig
httpClient *http.Client
mu sync.Mutex
discovery *oidcDiscovery
discoveredAt time.Time
jwks map[string]*rsa.PublicKey
jwksAt time.Time
}
type oidcDiscovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
// EndSessionEndpoint (RP-Initiated Logout 1.0) is optional per spec —
// not every provider advertises one (see EndSessionURL).
EndSessionEndpoint string `json:"end_session_endpoint"`
}
func NewOIDCClient(cfg OIDCConfig) *OIDCClient {
return &OIDCClient{cfg: cfg, httpClient: &http.Client{Timeout: 10 * time.Second}}
}
func (c *OIDCClient) DisplayName() string {
if c.cfg.DisplayName != "" {
return c.cfg.DisplayName
}
return "SSO"
}
// AllowAutoProvision reports whether a first-time SSO login may create a new
// local account (see OIDCConfig.AllowAutoProvision).
func (c *OIDCClient) AllowAutoProvision() bool {
return c.cfg.AllowAutoProvision
}
// SingleLogoutEnabled reports whether sign-out should also end the session
// at the identity provider (see OIDCConfig.SingleLogout).
func (c *OIDCClient) SingleLogoutEnabled() bool {
return c.cfg.SingleLogout
}
// discoveryTTL bounds how long a cached discovery document is trusted, so a
// provider rotating its endpoints is picked up instead of pinned forever.
const discoveryTTL = 10 * time.Minute
func (c *OIDCClient) discover() (*oidcDiscovery, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.discovery != nil && time.Since(c.discoveredAt) < discoveryTTL {
return c.discovery, nil
}
resp, err := c.httpClient.Get(strings.TrimRight(c.cfg.IssuerURL, "/") + "/.well-known/openid-configuration")
if err != nil {
return nil, fmt.Errorf("fetching OIDC discovery document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OIDC discovery returned %d", resp.StatusCode)
}
var d oidcDiscovery
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("parsing OIDC discovery document: %w", err)
}
if d.Issuer != c.cfg.IssuerURL && d.Issuer != strings.TrimRight(c.cfg.IssuerURL, "/") {
return nil, fmt.Errorf("OIDC discovery issuer %q does not match configured issuer %q", d.Issuer, c.cfg.IssuerURL)
}
c.discovery = &d
c.discoveredAt = time.Now()
return &d, nil
}
// AuthURL builds the redirect URL that sends the browser to the provider's
// login page, embedding the CSRF state and the replay-protection nonce.
func (c *OIDCClient) AuthURL(state, nonce string) (string, error) {
d, err := c.discover()
if err != nil {
return "", err
}
q := url.Values{
"response_type": {"code"},
"client_id": {c.cfg.ClientID},
"redirect_uri": {c.cfg.RedirectURL},
"scope": {"openid profile email"},
"state": {state},
"nonce": {nonce},
}
return d.AuthorizationEndpoint + "?" + q.Encode(), nil
}
// Claims is the subset of ID token claims Ferrum acts on.
type Claims struct {
Subject string
Email string
EmailVerified bool
PreferredUsername string
// RawIDToken is the verified ID token JWT itself, kept only so it can be
// handed back to the provider as id_token_hint on RP-Initiated Logout —
// see EndSessionURL. Ferrum's own session is a random bearer token; this
// is never used to re-authenticate a request.
RawIDToken string
}
// Exchange trades an authorization code for an ID token at the provider's
// token endpoint, then verifies it, returning the caller's identity claims.
func (c *OIDCClient) Exchange(code, nonce string) (*Claims, error) {
d, err := c.discover()
if err != nil {
return nil, err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {c.cfg.RedirectURL},
"client_id": {c.cfg.ClientID},
"client_secret": {c.cfg.ClientSecret},
}
req, err := http.NewRequest(http.MethodPost, d.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("calling OIDC token endpoint: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OIDC token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("parsing OIDC token response: %w", err)
}
if tokenResp.IDToken == "" {
return nil, errors.New("OIDC provider did not return an id_token")
}
claims, err := c.verifyIDToken(tokenResp.IDToken, d.Issuer, nonce)
if err != nil {
return nil, err
}
claims.RawIDToken = tokenResp.IDToken
return claims, nil
}
// EndSessionURL builds the RP-Initiated Logout redirect (OpenID Connect
// RP-Initiated Logout 1.0): the browser is sent here to end the session at
// the provider too, not just at Ferrum, then bounced back to Ferrum's login
// page (same origin as the configured callback URL — never taken from the
// request, which is attacker-controlled). ok is false when the provider
// doesn't advertise an end_session_endpoint (optional per spec — plenty of
// providers omit it), in which case there's nothing to redirect to and
// Ferrum's own logout (already done by the caller) is all that happens.
func (c *OIDCClient) EndSessionURL(idTokenHint string) (endSessionURL string, ok bool) {
d, err := c.discover()
if err != nil || d.EndSessionEndpoint == "" {
return "", false
}
q := url.Values{"client_id": {c.cfg.ClientID}}
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
if redirect := c.postLogoutRedirectURI(); redirect != "" {
q.Set("post_logout_redirect_uri", redirect)
}
return d.EndSessionEndpoint + "?" + q.Encode(), true
}
// postLogoutRedirectURI derives Ferrum's login page from the configured
// callback URL's origin (…/api/v1/auth/oidc/callback → …/login), so it's
// never a second thing the admin has to configure separately.
func (c *OIDCClient) postLogoutRedirectURI() string {
u, err := url.Parse(c.cfg.RedirectURL)
if err != nil {
return ""
}
u.Path = "/login"
u.RawQuery = ""
return u.String()
}
func (c *OIDCClient) verifyIDToken(idToken, expectIssuer, expectNonce string) (*Claims, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, errors.New("malformed ID token")
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
headerJSON, err := base64URLDecode(parts[0])
if err != nil {
return nil, fmt.Errorf("decoding ID token header: %w", err)
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, err
}
if header.Alg != "RS256" {
return nil, fmt.Errorf("unsupported ID token signing algorithm %q (only RS256 is supported)", header.Alg)
}
sig, err := base64URLDecode(parts[2])
if err != nil {
return nil, fmt.Errorf("decoding ID token signature: %w", err)
}
key, err := c.publicKey(header.Kid)
if err != nil {
return nil, err
}
hashed := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, hashed[:], sig); err != nil {
return nil, fmt.Errorf("ID token signature verification failed: %w", err)
}
payloadJSON, err := base64URLDecode(parts[1])
if err != nil {
return nil, fmt.Errorf("decoding ID token payload: %w", err)
}
var claims struct {
Iss string `json:"iss"`
Aud any `json:"aud"` // string or []string per the OIDC spec
Exp int64 `json:"exp"`
Nonce string `json:"nonce"`
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
PreferredUsername string `json:"preferred_username"`
}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, err
}
if claims.Iss != expectIssuer {
return nil, fmt.Errorf("ID token issuer %q does not match expected issuer %q", claims.Iss, expectIssuer)
}
if !audienceContains(claims.Aud, c.cfg.ClientID) {
return nil, errors.New("ID token audience does not include this client")
}
if time.Now().After(time.Unix(claims.Exp, 0)) {
return nil, errors.New("ID token has expired")
}
if claims.Nonce != expectNonce {
return nil, errors.New("ID token nonce does not match — possible replay")
}
if claims.Sub == "" {
return nil, errors.New("ID token has no subject claim")
}
return &Claims{Subject: claims.Sub, Email: claims.Email, EmailVerified: claims.EmailVerified, PreferredUsername: claims.PreferredUsername}, nil
}
func audienceContains(aud any, clientID string) bool {
switch v := aud.(type) {
case string:
return v == clientID
case []any:
for _, a := range v {
if s, ok := a.(string); ok && s == clientID {
return true
}
}
}
return false
}
// publicKey resolves a JWKS key id to an *rsa.PublicKey, fetching (and
// caching for 10 minutes) the provider's JWKS document as needed.
func (c *OIDCClient) publicKey(kid string) (*rsa.PublicKey, error) {
c.mu.Lock()
fresh := c.jwks != nil && time.Since(c.jwksAt) < 10*time.Minute
c.mu.Unlock()
if !fresh {
if err := c.refreshJWKS(); err != nil {
return nil, err
}
}
c.mu.Lock()
key, ok := c.jwks[kid]
c.mu.Unlock()
if !ok {
// The key may have rotated since our last fetch — try once more before failing.
if err := c.refreshJWKS(); err != nil {
return nil, err
}
c.mu.Lock()
key, ok = c.jwks[kid]
c.mu.Unlock()
if !ok {
return nil, fmt.Errorf("no JWKS key found for kid %q", kid)
}
}
return key, nil
}
func (c *OIDCClient) refreshJWKS() error {
d, err := c.discover()
if err != nil {
return err
}
resp, err := c.httpClient.Get(d.JWKSURI)
if err != nil {
return fmt.Errorf("fetching JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("JWKS endpoint returned %d", resp.StatusCode)
}
var doc struct {
Keys []struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return fmt.Errorf("parsing JWKS: %w", err)
}
keys := map[string]*rsa.PublicKey{}
for _, k := range doc.Keys {
if k.Kty != "RSA" {
continue
}
nBytes, err := base64URLDecode(k.N)
if err != nil {
continue
}
eBytes, err := base64URLDecode(k.E)
if err != nil {
continue
}
keys[k.Kid] = &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: int(new(big.Int).SetBytes(eBytes).Int64()),
}
}
c.mu.Lock()
c.jwks = keys
c.jwksAt = time.Now()
c.mu.Unlock()
return nil
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}