Files
Anand d04b769670 Fix storage metrics double-counting, sign-out, SMTP plaintext, and OIDC gaps
- Fleet Overview's Storage KPI and per-connection totals (internal/api/
  overview.go) were summing shared storage once per node that mounts it,
  since PVE's own Shared flag is unreliable for storage added per-node
  without ticking it. New dedupeSharedStorage collapses those by an exact
  (name, total, used) match within a connection; local-only plugin types
  are never touched. Mirrored on the Storage page, which also now
  disambiguates same-named local pools (e.g. every node's "local-lvm") by
  node so a bar chart's category axis never gets duplicate labels.
- Sign-out: React Query keeps a query's last-successful data through a
  failed refetch, so the cached signed-in user survived the post-logout
  401 and the app never noticed. signOut() now pins ["auth","me"] to null
  directly instead of invalidating-and-hoping a refetch lands in time.
- SMTP: net/smtp.SendMail opportunistically attempts STARTTLS whenever the
  server advertises it regardless of the admin's "Use STARTTLS" toggle, so
  an internal relay with a bad cert failed sends the admin explicitly asked
  to be plaintext. A dedicated sendPlain path never attempts TLS.
- OIDC: added an "Auto-create new accounts" toggle — when off, a
  first-time SSO login for an unrecognized identity is refused instead of
  silently provisioning one. Also added RP-Initiated Logout: signing out of
  an SSO session now also ends the session at the identity provider when it
  advertises support for it (end_session_endpoint), via a stored id_token
  on OIDC sessions (new sessions.oidc_id_token column).
- ResourceAreaChart tooltips now fall back to the same yTickFormatter given
  to the axis when a series has no formatter of its own — fixes raw
  unformatted numbers in Fleet Trend and every RRD chart built on it
  (Node/Guest detail) that only ever set the axis formatter.
2026-09-03 23:17:38 +05:30

360 lines
13 KiB
Go

package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
// fakeOIDCProvider is a minimal in-process stand-in for a real provider
// (Keycloak, etc.) — enough of the OIDC surface (discovery, JWKS, token
// endpoint) to prove OIDCClient's discovery, JWKS-based signature
// verification, and claim checks all actually work end to end, not just
// that the code compiles.
type fakeOIDCProvider struct {
server *httptest.Server
key *rsa.PrivateKey
kid string
nextID string // the id_token the token endpoint will hand back
// endSession, when true, advertises an end_session_endpoint in
// discovery — real providers make this optional, so tests exercise both.
endSession bool
}
func newFakeOIDCProvider(t *testing.T) *fakeOIDCProvider {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generating test key: %v", err)
}
p := &fakeOIDCProvider{key: key, kid: "test-key-1"}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
doc := map[string]string{
"issuer": base,
"authorization_endpoint": base + "/auth",
"token_endpoint": base + "/token",
"jwks_uri": base + "/jwks",
}
if p.endSession {
doc["end_session_endpoint"] = base + "/logout"
}
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{{
"kty": "RSA",
"kid": p.kid,
"n": base64.RawURLEncoding.EncodeToString(p.key.PublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big64(p.key.PublicKey.E)),
}},
})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": p.nextID, "access_token": "unused"})
})
p.server = httptest.NewServer(mux)
t.Cleanup(p.server.Close)
return p
}
func big64(i int) []byte {
b := []byte{byte(i >> 16), byte(i >> 8), byte(i)}
// Trim a leading zero byte the way real JWKS "e" values do (e=65537 -> 3 bytes, no leading zero).
for len(b) > 1 && b[0] == 0 {
b = b[1:]
}
return b
}
// mint builds and signs a real RS256 ID token for this provider.
func (p *fakeOIDCProvider) mint(claims map[string]any) string {
header := map[string]string{"alg": "RS256", "kid": p.kid}
headerB, _ := json.Marshal(header)
payloadB, _ := json.Marshal(claims)
signingInput := base64.RawURLEncoding.EncodeToString(headerB) + "." + base64.RawURLEncoding.EncodeToString(payloadB)
hashed := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, p.key, crypto.SHA256, hashed[:])
if err != nil {
panic(err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func (p *fakeOIDCProvider) issuer() string { return p.server.URL }
func TestOIDCExchangeVerifiesRealSignedToken(t *testing.T) {
provider := newFakeOIDCProvider(t)
client := NewOIDCClient(OIDCConfig{
IssuerURL: provider.issuer(),
ClientID: "ferrum",
RedirectURL: "http://localhost/api/v1/auth/oidc/callback",
})
provider.nextID = provider.mint(map[string]any{
"iss": provider.issuer(),
"aud": "ferrum",
"sub": "user-123",
"email": "alice@example.com",
"preferred_username": "alice",
"nonce": "expected-nonce",
"exp": time.Now().Add(time.Hour).Unix(),
})
claims, err := client.Exchange("some-code", "expected-nonce")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if claims.Subject != "user-123" || claims.Email != "alice@example.com" || claims.PreferredUsername != "alice" {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestOIDCExchangeRejectsTamperedSignature(t *testing.T) {
provider := newFakeOIDCProvider(t)
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://x/callback"})
real := provider.mint(map[string]any{
"iss": provider.issuer(), "aud": "ferrum", "sub": "user-123", "nonce": "n", "exp": time.Now().Add(time.Hour).Unix(),
})
// Flip a character in the payload segment without re-signing — simulates
// a forged/altered token an attacker controls but didn't get the IdP to sign.
tampered := real[:len(real)-40] + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
provider.nextID = tampered
if _, err := client.Exchange("code", "n"); err == nil {
t.Fatal("expected signature verification to reject a tampered token")
}
}
func TestOIDCExchangeRejectsWrongAudience(t *testing.T) {
provider := newFakeOIDCProvider(t)
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://x/callback"})
provider.nextID = provider.mint(map[string]any{
"iss": provider.issuer(), "aud": "some-other-app", "sub": "user-123", "nonce": "n", "exp": time.Now().Add(time.Hour).Unix(),
})
if _, err := client.Exchange("code", "n"); err == nil {
t.Fatal("expected a token minted for a different client to be rejected")
}
}
func TestOIDCExchangeRejectsNonceMismatch(t *testing.T) {
provider := newFakeOIDCProvider(t)
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://x/callback"})
provider.nextID = provider.mint(map[string]any{
"iss": provider.issuer(), "aud": "ferrum", "sub": "user-123", "nonce": "actual-nonce", "exp": time.Now().Add(time.Hour).Unix(),
})
if _, err := client.Exchange("code", "different-nonce"); err == nil {
t.Fatal("expected nonce mismatch (possible replay) to be rejected")
}
}
func TestOIDCExchangeRejectsExpiredToken(t *testing.T) {
provider := newFakeOIDCProvider(t)
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://x/callback"})
provider.nextID = provider.mint(map[string]any{
"iss": provider.issuer(), "aud": "ferrum", "sub": "user-123", "nonce": "n", "exp": time.Now().Add(-time.Hour).Unix(),
})
if _, err := client.Exchange("code", "n"); err == nil {
t.Fatal("expected an expired token to be rejected")
}
}
func TestFindOrCreateOIDCUserLinksExistingEmailThenReusesSubject(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
// Pre-existing local (password) account with the same email.
local, err := svc.Bootstrap(ctx, "alice", "alice@example.com", "supersecret1")
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
linked, err := svc.FindOrCreateOIDCUser(ctx, "sub-1", "alice@example.com", "alice", true, true)
if err != nil {
t.Fatalf("FindOrCreateOIDCUser (link): %v", err)
}
if linked.ID != local.ID {
t.Fatalf("expected SSO login to link to the existing local account, got a different user")
}
again, err := svc.FindOrCreateOIDCUser(ctx, "sub-1", "alice@example.com", "alice", true, true)
if err != nil {
t.Fatalf("FindOrCreateOIDCUser (reuse): %v", err)
}
if again.ID != local.ID {
t.Fatalf("expected repeat SSO login to resolve to the same linked account")
}
// A brand-new subject/email should provision a fresh, non-admin account.
fresh, err := svc.FindOrCreateOIDCUser(ctx, "sub-2", "bob@example.com", "bob", true, true)
if err != nil {
t.Fatalf("FindOrCreateOIDCUser (new): %v", err)
}
if fresh.ID == local.ID || fresh.IsAdmin {
t.Fatalf("expected a distinct, non-admin account for a new SSO identity, got %+v", fresh)
}
}
func TestFindOrCreateOIDCUserNeverLinksWithUnverifiedEmail(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
// A local account already owns the email the SSO identity claims.
if _, err := svc.Bootstrap(ctx, "alice", "alice@example.com", "supersecret1"); err != nil {
t.Fatalf("Bootstrap: %v", err)
}
// Unverified claim on a colliding email must NOT link into alice's
// account (account takeover via IdP email spoofing) and must NOT create
// a second user holding the same address — it refuses instead.
if _, err := svc.FindOrCreateOIDCUser(ctx, "sub-evil", "alice@example.com", "alice", false, true); err == nil {
t.Fatal("unverified email colliding with a local account should be refused")
}
var oidcSubject string
err := svc.db.QueryRowContext(ctx, `SELECT oidc_subject FROM users WHERE username = 'alice'`).Scan(&oidcSubject)
if err == nil && oidcSubject == "sub-evil" {
t.Fatal("local account was linked from an unverified SSO identity")
}
// A fresh unverified identity still gets an account, but with a
// placeholder email that can't collide with anyone's real address.
created, err := svc.FindOrCreateOIDCUser(ctx, "sub-new", "mallory@example.com", "mallory", false, true)
if err != nil {
t.Fatalf("unverified fresh identity should be provisioned: %v", err)
}
if created.IsAdmin {
t.Fatal("provisioned SSO users must never be admins")
}
var email string
if err := svc.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, created.ID).Scan(&email); err != nil {
t.Fatalf("loading created user: %v", err)
}
if email == "mallory@example.com" {
t.Fatal("unverified email was persisted verbatim — it must use the placeholder")
}
// The same identity logging in again resolves to the same account.
again, err := svc.FindOrCreateOIDCUser(ctx, "sub-new", "mallory@example.com", "mallory", false, true)
if err != nil || again.ID != created.ID {
t.Fatalf("second login should reuse the subject's account: %v %+v", err, again)
}
}
func TestFindOrCreateOIDCUserLinksVerifiedEmail(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
if _, err := svc.Bootstrap(ctx, "alice", "alice@example.com", "supersecret1"); err != nil {
t.Fatalf("Bootstrap: %v", err)
}
// A provider-verified email authorizes the account link.
linked, err := svc.FindOrCreateOIDCUser(ctx, "sub-1", "alice@example.com", "alice", true, true)
if err != nil {
t.Fatalf("link with verified email: %v", err)
}
var username string
if err := svc.db.QueryRowContext(ctx, `SELECT username FROM users WHERE oidc_subject = ?`, "sub-1").Scan(&username); err != nil {
t.Fatalf("linked account not persisted: %v", err)
}
if username != "alice" || linked.Username != "alice" {
t.Fatalf("linked to %q, want alice", linked.Username)
}
}
func TestFindOrCreateOIDCUserRefusesAutoProvisionWhenDisabled(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
// No local account exists for this identity, and auto-provisioning is
// off: the login must be refused, not silently create an account.
_, err := svc.FindOrCreateOIDCUser(ctx, "sub-new", "carol@example.com", "carol", true, false)
if !errors.Is(err, ErrOIDCUserNotProvisioned) {
t.Fatalf("expected ErrOIDCUserNotProvisioned, got %v", err)
}
var count int
if err := svc.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`, "carol@example.com").Scan(&count); err != nil {
t.Fatalf("counting users: %v", err)
}
if count != 0 {
t.Fatal("no account should have been created")
}
// An existing account (linked by subject) still resolves normally even
// with auto-provisioning off — the gate only blocks *new* accounts.
local, err := svc.Bootstrap(ctx, "dave", "dave@example.com", "supersecret1")
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
if _, err := svc.db.ExecContext(ctx, `UPDATE users SET oidc_subject = ? WHERE id = ?`, "sub-dave", local.ID); err != nil {
t.Fatalf("linking subject: %v", err)
}
found, err := svc.FindOrCreateOIDCUser(ctx, "sub-dave", "dave@example.com", "dave", true, false)
if err != nil {
t.Fatalf("existing account should still resolve: %v", err)
}
if found.ID != local.ID {
t.Fatalf("expected to resolve to dave's existing account, got %+v", found)
}
}
func TestEndSessionURLNotAdvertised(t *testing.T) {
provider := newFakeOIDCProvider(t) // endSession left false
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://localhost/api/v1/auth/oidc/callback"})
if _, ok := client.EndSessionURL("some-id-token"); ok {
t.Fatal("expected ok=false when the provider doesn't advertise end_session_endpoint")
}
}
func TestEndSessionURLBuildsRPInitiatedLogout(t *testing.T) {
provider := newFakeOIDCProvider(t)
provider.endSession = true
client := NewOIDCClient(OIDCConfig{IssuerURL: provider.issuer(), ClientID: "ferrum", RedirectURL: "http://localhost/api/v1/auth/oidc/callback"})
got, ok := client.EndSessionURL("the-id-token")
if !ok {
t.Fatal("expected ok=true when the provider advertises end_session_endpoint")
}
u, err := url.Parse(got)
if err != nil {
t.Fatalf("EndSessionURL returned an unparseable URL: %v", err)
}
if u.Path != "/logout" {
t.Fatalf("path = %q, want /logout", u.Path)
}
q := u.Query()
if q.Get("client_id") != "ferrum" {
t.Errorf("client_id = %q, want ferrum", q.Get("client_id"))
}
if q.Get("id_token_hint") != "the-id-token" {
t.Errorf("id_token_hint = %q, want the-id-token", q.Get("id_token_hint"))
}
// Derived from RedirectURL's origin, not something an attacker-controlled
// request could influence.
if want := "http://localhost/login"; q.Get("post_logout_redirect_uri") != want {
t.Errorf("post_logout_redirect_uri = %q, want %q", q.Get("post_logout_redirect_uri"), want)
}
}