From 98eb3833bdd917b2e90ed512ea9ff684b878960d Mon Sep 17 00:00:00 2001 From: Bezerra Date: Mon, 11 May 2026 21:58:59 -0300 Subject: [PATCH 1/2] feat(auth): add OIDC login for the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional. Set MP_UI_OIDC_ISSUER and MP_UI_OIDC_CLIENT_ID to turn it on. When unset, behaviour is identical to today. The UI redirects to the IdP via Authorization Code + PKCE (handled in the SPA by oidc-client-ts). Tokens live in sessionStorage and are silently renewed via the refresh token. Each request carries the ID token as a Bearer JWT; the server verifies it against the IdP's JWKS (fetched once on boot and cached for 24h). Basic Auth still works in parallel so API integrations don't break. The oidc-client-ts bundle is only loaded when OIDC is enabled — nothing extra is shipped to users on default deployments. --- cmd/root.go | 8 + config/config.go | 18 ++ esbuild.config.mjs | 2 +- go.mod | 3 + go.sum | 6 + internal/auth/oidc.go | 157 +++++++++ internal/auth/oidc_test.go | 304 ++++++++++++++++++ package-lock.json | 22 ++ package.json | 1 + server/server.go | 87 +++-- server/server_test.go | 290 +++++++++++++++++ server/ui-src/App.vue | 3 + server/ui-src/components/AppLogout.vue | 30 ++ server/ui-src/components/AppNotifications.vue | 23 +- server/ui-src/mixins/CommonMixins.js | 26 ++ server/ui-src/oidc-entry.js | 8 + server/ui-src/router/index.js | 19 ++ server/ui-src/services/oidcAuth.js | 79 +++++ server/ui-src/views/AuthCallbackView.vue | 24 ++ 19 files changed, 1080 insertions(+), 30 deletions(-) create mode 100644 internal/auth/oidc.go create mode 100644 internal/auth/oidc_test.go create mode 100644 server/ui-src/components/AppLogout.vue create mode 100644 server/ui-src/oidc-entry.js create mode 100644 server/ui-src/services/oidcAuth.js create mode 100644 server/ui-src/views/AuthCallbackView.vue diff --git a/cmd/root.go b/cmd/root.go index ddb9b8984..bf4cd366c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -102,6 +102,8 @@ func init() { rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface & port for UI") rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API") rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication") + rootCmd.Flags().StringVar(&config.UIOIDCIssuer, "ui-oidc-issuer", config.UIOIDCIssuer, "OIDC issuer URL (discovery endpoint) for web UI authentication") + rootCmd.Flags().StringVar(&config.UIOIDCClientID, "ui-oidc-client-id", config.UIOIDCClientID, "OIDC client ID for web UI authentication") rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key") rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert") rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)") @@ -246,6 +248,12 @@ func initConfigFromEnv() { if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil { logger.Log().Error(err.Error()) } + if v := os.Getenv("MP_UI_OIDC_ISSUER"); v != "" { + config.UIOIDCIssuer = v + } + if v := os.Getenv("MP_UI_OIDC_CLIENT_ID"); v != "" { + config.UIOIDCClientID = v + } config.UITLSCert = os.Getenv("MP_UI_TLS_CERT") config.UITLSKey = os.Getenv("MP_UI_TLS_KEY") if len(os.Getenv("MP_API_CORS")) > 0 { diff --git a/config/config.go b/config/config.go index bc1009763..8e51821bd 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( + "context" "errors" "fmt" "net" @@ -84,6 +85,13 @@ var ( // UIAuthFile for UI & API authentication UIAuthFile string + // UIOIDCIssuer is the OIDC issuer URL (discovery endpoint) for web UI authentication. + // When empty, OIDC is disabled and the existing Basic Auth behaviour is unchanged. + UIOIDCIssuer string + + // UIOIDCClientID is the OIDC client ID registered with the IdP for this Mailpit instance. + UIOIDCClientID string + // Webroot to define the base path for the UI and API Webroot = "/" @@ -343,6 +351,16 @@ func VerifyConfig() error { } } + // OIDC for the web UI. Disabled when issuer is empty. + if UIOIDCIssuer != "" { + if UIOIDCClientID == "" { + return errors.New("[ui] OIDC client ID is required when OIDC issuer is set") + } + if err := auth.InitOIDC(context.Background(), UIOIDCIssuer, UIOIDCClientID); err != nil { + return fmt.Errorf("[ui] OIDC: %w", err) + } + } + if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" { return errors.New("[ui] you must provide both a UI TLS certificate and a key") } diff --git a/esbuild.config.mjs b/esbuild.config.mjs index ad13c63ea..444ffdc18 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -6,7 +6,7 @@ const doWatch = process.env.WATCH === "true"; const doMinify = process.env.MINIFY === "true"; const ctx = await esbuild.context({ - entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"], + entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js", "server/ui-src/oidc-entry.js"], bundle: true, minify: doMinify, sourcemap: false, diff --git a/go.mod b/go.mod index 14f74e844..f14ebc32c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/axllent/ghru/v2 v2.2.3 github.com/axllent/semver v1.0.0 + github.com/coreos/go-oidc/v3 v3.18.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/goccy/go-yaml v1.19.2 github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f github.com/google/uuid v1.6.0 @@ -59,6 +61,7 @@ require ( github.com/vanng822/css v1.0.1 // indirect golang.org/x/image v0.39.0 // indirect golang.org/x/mod v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f0dbb9e7f..b8a950da2 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -26,6 +28,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= @@ -151,6 +155,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 000000000..a7a4e326a --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,157 @@ +// OIDC verification for the web UI. +// +// When configured (issuer + client ID), Mailpit verifies incoming +// `Authorization: Bearer ` headers against the IdP's published +// signing keys. The JWKS is fetched once at startup and cached in +// memory for jwksTTL (24h by default) or until restart — never +// re-fetched per request. + +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + jose "github.com/go-jose/go-jose/v4" +) + +// jwksTTL is overridable in tests via init via SetJWKSTTLForTests. +var jwksTTL = 24 * time.Hour + +// OIDCVerifier is nil when OIDC is disabled. +var OIDCVerifier *oidc.IDTokenVerifier + +var oidcSupportedAlgs = []jose.SignatureAlgorithm{ + jose.RS256, jose.RS384, jose.RS512, + jose.ES256, jose.ES384, jose.ES512, + jose.PS256, jose.PS384, jose.PS512, +} + +// cachedKeySet implements oidc.KeySet. JWKS is held in memory for jwksTTL. +type cachedKeySet struct { + jwksURL string + + mu sync.RWMutex + keys *jose.JSONWebKeySet + fetched time.Time +} + +func (c *cachedKeySet) VerifySignature(ctx context.Context, raw string) ([]byte, error) { + if err := c.ensureFresh(ctx); err != nil { + return nil, err + } + jws, err := jose.ParseSigned(raw, oidcSupportedAlgs) + if err != nil { + return nil, fmt.Errorf("oidc: parse jwt: %w", err) + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.keys == nil { + return nil, errors.New("oidc: jwks not loaded") + } + for _, k := range c.keys.Keys { + if payload, err := jws.Verify(k); err == nil { + return payload, nil + } + } + return nil, errors.New("oidc: no matching signing key") +} + +func (c *cachedKeySet) ensureFresh(ctx context.Context) error { + c.mu.RLock() + fresh := c.keys != nil && time.Since(c.fetched) < jwksTTL + c.mu.RUnlock() + if fresh { + return nil + } + return c.fetch(ctx) +} + +func (c *cachedKeySet) fetch(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURL, nil) + if err != nil { + return fmt.Errorf("oidc: jwks request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("oidc: jwks fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("oidc: jwks fetch: status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("oidc: jwks read: %w", err) + } + var ks jose.JSONWebKeySet + if err := json.Unmarshal(body, &ks); err != nil { + return fmt.Errorf("oidc: jwks parse: %w", err) + } + c.mu.Lock() + c.keys = &ks + c.fetched = time.Now() + c.mu.Unlock() + return nil +} + +// InitOIDC configures the OIDC verifier from the issuer URL and client ID. +// When issuer is empty, OIDC is disabled and the verifier remains nil. +// JWKS is fetched once here so an unreachable IdP makes Mailpit fail to start. +func InitOIDC(ctx context.Context, issuer, clientID string) error { + if issuer == "" { + OIDCVerifier = nil + return nil + } + if clientID == "" { + return errors.New("OIDC client ID is required when issuer is set") + } + p, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return fmt.Errorf("oidc discovery: %w", err) + } + var claims struct { + JWKSURL string `json:"jwks_uri"` + } + if err := p.Claims(&claims); err != nil { + return fmt.Errorf("oidc claims: %w", err) + } + if claims.JWKSURL == "" { + return errors.New("oidc: provider discovery returned no jwks_uri") + } + keys := &cachedKeySet{jwksURL: claims.JWKSURL} + if err := keys.fetch(ctx); err != nil { + return err + } + OIDCVerifier = oidc.NewVerifier(issuer, keys, &oidc.Config{ClientID: clientID}) + return nil +} + +// VerifyBearer accepts a raw JWT (with or without a "Bearer " prefix) and +// returns (subject, true) when the token verifies against the configured IdP. +func VerifyBearer(ctx context.Context, raw string) (string, bool) { + if OIDCVerifier == nil || raw == "" { + return "", false + } + raw = strings.TrimPrefix(raw, "Bearer ") + tok, err := OIDCVerifier.Verify(ctx, raw) + if err != nil { + return "", false + } + return tok.Subject, true +} + +// SetJWKSTTLForTests overrides the cache TTL. Test-only. +func SetJWKSTTLForTests(d time.Duration) func() { + prev := jwksTTL + jwksTTL = d + return func() { jwksTTL = prev } +} diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go new file mode 100644 index 000000000..bf1b35b8f --- /dev/null +++ b/internal/auth/oidc_test.go @@ -0,0 +1,304 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// fakeIdP is a minimal OIDC provider for tests. It serves +// /.well-known/openid-configuration and /jwks, signs JWTs on demand, +// and counts how many times the JWKS endpoint is hit. +type fakeIdP struct { + t *testing.T + server *httptest.Server + signer jose.Signer + priv *rsa.PrivateKey + kid string + jwksHits int64 +} + +func newFakeIdP(t *testing.T) *fakeIdP { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + kid := "test-key-1" + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: priv}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + if err != nil { + t.Fatalf("signer: %v", err) + } + + f := &fakeIdP{t: t, priv: priv, kid: kid, signer: signer} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": f.IssuerURL(), + "jwks_uri": f.IssuerURL() + "/jwks", + "authorization_endpoint": f.IssuerURL() + "/authorize", + "token_endpoint": f.IssuerURL() + "/token", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt64(&f.jwksHits, 1) + jwk := jose.JSONWebKey{Key: &priv.PublicKey, KeyID: kid, Algorithm: "RS256", Use: "sig"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}) + }) + f.server = httptest.NewServer(mux) + return f +} + +func (f *fakeIdP) Close() { f.server.Close() } +func (f *fakeIdP) IssuerURL() string { return f.server.URL } +func (f *fakeIdP) JWKSHits() int64 { return atomic.LoadInt64(&f.jwksHits) } +func (f *fakeIdP) resetHits() { atomic.StoreInt64(&f.jwksHits, 0) } + +// issue signs a JWT with default claims (iss=this issuer, aud=clientID, +// exp=+1h) and applies any caller-supplied overrides. +func (f *fakeIdP) issue(clientID, sub string, override map[string]any) string { + f.t.Helper() + claims := map[string]any{ + "iss": f.IssuerURL(), + "aud": clientID, + "sub": sub, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + maps.Copy(claims, override) + tok, err := jwt.Signed(f.signer).Claims(claims).Serialize() + if err != nil { + f.t.Fatalf("sign jwt: %v", err) + } + return tok +} + +// resetOIDCState clears the package globals so tests don't bleed into each other. +func resetOIDCState() func() { + prev := OIDCVerifier + return func() { OIDCVerifier = prev } +} + +func TestInitOIDC_Disabled(t *testing.T) { + defer resetOIDCState()() + OIDCVerifier = nil + if err := InitOIDC(context.Background(), "", ""); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if OIDCVerifier != nil { + t.Fatalf("expected verifier to remain nil when issuer is empty") + } +} + +func TestInitOIDC_MissingClientID(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + err := InitOIDC(context.Background(), idp.IssuerURL(), "") + if err == nil { + t.Fatalf("expected error when client ID is empty") + } + if !strings.Contains(err.Error(), "client ID") { + t.Fatalf("expected error to mention client ID, got %v", err) + } +} + +func TestInitOIDC_UnreachableIssuer(t *testing.T) { + defer resetOIDCState()() + // 127.0.0.1:1 — guaranteed to refuse connections. + if err := InitOIDC(context.Background(), "http://127.0.0.1:1/realms/x", "mailpit"); err == nil { + t.Fatalf("expected error for unreachable issuer") + } +} + +func TestInitOIDC_PreloadsJWKS(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + if got := idp.JWKSHits(); got != 1 { + t.Fatalf("expected exactly 1 JWKS hit on boot, got %d", got) + } +} + +func TestVerifyBearer_HappyPath(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + tok := idp.issue("mailpit", "alice", nil) + sub, ok := VerifyBearer(context.Background(), tok) + if !ok { + t.Fatalf("expected verify to succeed") + } + if sub != "alice" { + t.Fatalf("expected sub=alice, got %q", sub) + } +} + +func TestVerifyBearer_StripsBearerPrefix(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + tok := idp.issue("mailpit", "bob", nil) + if _, ok := VerifyBearer(context.Background(), "Bearer "+tok); !ok { + t.Fatalf("expected verify to succeed with Bearer prefix") + } + if _, ok := VerifyBearer(context.Background(), tok); !ok { + t.Fatalf("expected verify to succeed without prefix") + } +} + +func TestVerifyBearer_WrongAudience(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + tok := idp.issue("someone-else", "alice", nil) + if _, ok := VerifyBearer(context.Background(), tok); ok { + t.Fatalf("expected verify to fail for wrong audience") + } +} + +func TestVerifyBearer_WrongIssuer(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + tok := idp.issue("mailpit", "alice", map[string]any{"iss": "https://attacker.example/"}) + if _, ok := VerifyBearer(context.Background(), tok); ok { + t.Fatalf("expected verify to fail for wrong issuer") + } +} + +func TestVerifyBearer_Expired(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + tok := idp.issue("mailpit", "alice", map[string]any{"exp": time.Now().Add(-time.Hour).Unix()}) + if _, ok := VerifyBearer(context.Background(), tok); ok { + t.Fatalf("expected verify to fail for expired token") + } +} + +func TestVerifyBearer_BadSignature(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + // Token signed by a different (unrelated) signer. + other, _ := rsa.GenerateKey(rand.Reader, 2048) + otherSigner, _ := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: other}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "rogue"), + ) + tok, _ := jwt.Signed(otherSigner).Claims(map[string]any{ + "iss": idp.IssuerURL(), + "aud": "mailpit", + "sub": "alice", + "exp": time.Now().Add(time.Hour).Unix(), + }).Serialize() + if _, ok := VerifyBearer(context.Background(), tok); ok { + t.Fatalf("expected verify to fail for unrelated signing key") + } +} + +func TestVerifyBearer_EmptyAndDisabled(t *testing.T) { + defer resetOIDCState()() + OIDCVerifier = nil + if _, ok := VerifyBearer(context.Background(), "anything"); ok { + t.Fatalf("expected false when verifier is nil") + } + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + if _, ok := VerifyBearer(context.Background(), ""); ok { + t.Fatalf("expected false for empty token") + } +} + +func TestJWKSCachedAcrossVerifies(t *testing.T) { + defer resetOIDCState()() + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + idp.resetHits() // discount the boot fetch + for i := range 50 { + tok := idp.issue("mailpit", "alice", nil) + if _, ok := VerifyBearer(context.Background(), tok); !ok { + t.Fatalf("iter %d: expected verify to succeed", i) + } + } + if got := idp.JWKSHits(); got != 0 { + t.Fatalf("expected 0 JWKS hits across cached verifies, got %d", got) + } +} + +func TestJWKSRefreshAfterTTL(t *testing.T) { + defer resetOIDCState()() + restore := SetJWKSTTLForTests(50 * time.Millisecond) + defer restore() + + idp := newFakeIdP(t) + defer idp.Close() + if err := InitOIDC(context.Background(), idp.IssuerURL(), "mailpit"); err != nil { + t.Fatalf("init: %v", err) + } + idp.resetHits() + // First verify uses cached keys (within TTL). + tok := idp.issue("mailpit", "alice", nil) + if _, ok := VerifyBearer(context.Background(), tok); !ok { + t.Fatalf("expected verify to succeed (cache)") + } + if got := idp.JWKSHits(); got != 0 { + t.Fatalf("expected 0 JWKS hits before TTL, got %d", got) + } + time.Sleep(80 * time.Millisecond) + // Now TTL elapsed — next verify should trigger a refresh. + tok2 := idp.issue("mailpit", "alice", nil) + if _, ok := VerifyBearer(context.Background(), tok2); !ok { + t.Fatalf("expected verify to succeed (refresh)") + } + if got := idp.JWKSHits(); got != 1 { + t.Fatalf("expected 1 JWKS hit after TTL refresh, got %d", got) + } +} diff --git a/package-lock.json b/package-lock.json index 5188a34b6..3f253711e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "ical.js": "^2.0.1", "mitt": "^3.0.1", "modern-screenshot": "^4.4.30", + "oidc-client-ts": "3.5.0", "rapidoc": "^9.3.4", "timezones-list": "^3.0.3", "vue": "^3.2.13", @@ -3271,6 +3272,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3622,6 +3632,18 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/openapi-path-templating": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", diff --git a/package.json b/package.json index 0ad299464..71c518464 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "ical.js": "^2.0.1", "mitt": "^3.0.1", "modern-screenshot": "^4.4.30", + "oidc-client-ts": "3.5.0", "rapidoc": "^9.3.4", "timezones-list": "^3.0.3", "vue": "^3.2.13", diff --git a/server/server.go b/server/server.go index 3e931141f..6e14e4c6f 100644 --- a/server/server.go +++ b/server/server.go @@ -220,8 +220,44 @@ func apiRoutes() *http.ServeMux { } // BasicAuthResponse returns an basic auth response to the browser -func basicAuthResponse(w http.ResponseWriter) { - w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) +// checkUIAuth returns true when the request carries valid auth via either +// the OIDC verifier (Bearer JWT in Authorization header or ?access_token= +// query param) or Basic Auth against the htpasswd store. When neither +// auth method is configured the request is allowed. +func checkUIAuth(r *http.Request) bool { + if auth.UICredentials == nil && auth.OIDCVerifier == nil { + return true + } + if auth.OIDCVerifier != nil { + authz := r.Header.Get("Authorization") + raw := strings.TrimPrefix(authz, "Bearer ") + if raw == authz { + // Authorization header was not a Bearer token; fall back to query param. + raw = r.URL.Query().Get("access_token") + } + if _, ok := auth.VerifyBearer(r.Context(), raw); ok { + return true + } + } + if auth.UICredentials != nil { + if user, pass, ok := r.BasicAuth(); ok && auth.UICredentials.Match(user, pass) { + return true + } + } + return false +} + +// unauthorizedResponse writes a 401. When OIDC is enabled it adds the +// X-Mp-Auth-Required header so the SPA's axios interceptor can trigger +// signinRedirect. The Basic challenge is preserved when htpasswd is +// configured so curl-based integrations keep working. +func unauthorizedResponse(w http.ResponseWriter) { + if auth.OIDCVerifier != nil { + w.Header().Set("X-Mp-Auth-Required", "oidc") + } + if auth.UICredentials != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) + } w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("Unauthorized.\n")) } @@ -244,12 +280,12 @@ func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc { user, pass, ok := r.BasicAuth() if !ok { - basicAuthResponse(w) + unauthorizedResponse(w) return } if !auth.SendAPICredentials.Match(user, pass) { - basicAuthResponse(w) + unauthorizedResponse(w) return } @@ -308,22 +344,16 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { w.Header().Set("Access-Control-Allow-Headers", "*") } - // Check basic authentication headers if configured. - // OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight checks. - // skipUIAuthKey in the request context allows sendAPIAuthMiddleware to bypass UI auth - // for a specific request without touching the global auth.UICredentials pointer. + // Check UI authentication if configured. OPTIONS requests are skipped if CORS is + // enabled, since browsers omit credentials for preflight checks. skipUIAuthKey in + // the request context allows sendAPIAuthMiddleware to bypass UI auth for a specific + // request without touching the global auth state. checkUIAuth accepts either a + // valid OIDC Bearer token (when OIDC is configured) or matching Basic credentials. skipUIAuth, _ := r.Context().Value(skipUIAuthKey).(bool) isCORSOptionsRequest := AccessControlAllowOrigin != "" && r.Method == http.MethodOptions - if !skipUIAuth && !isCORSOptionsRequest && auth.UICredentials != nil { - user, pass, ok := r.BasicAuth() - - if !ok { - basicAuthResponse(w) - return - } - - if !auth.UICredentials.Match(user, pass) { - basicAuthResponse(w) + if !skipUIAuth && !isCORSOptionsRequest { + if !checkUIAuth(r) { + unauthorizedResponse(w) return } } @@ -407,12 +437,13 @@ func index(w http.ResponseWriter, r *http.Request) { -
+
+ {{ if .OIDCEnabled }}{{ end }} @@ -424,13 +455,19 @@ func index(w http.ResponseWriter, r *http.Request) { } data := struct { - Webroot string - Version string - Nonce string + Webroot string + Version string + Nonce string + OIDCEnabled bool + OIDCIssuer string + OIDCClientID string }{ - Webroot: config.Webroot, - Version: config.Version, - Nonce: r.Header.Get("mp-nonce"), + Webroot: config.Webroot, + Version: config.Version, + Nonce: r.Header.Get("mp-nonce"), + OIDCEnabled: config.UIOIDCIssuer != "", + OIDCIssuer: config.UIOIDCIssuer, + OIDCClientID: config.UIOIDCClientID, } buff := new(bytes.Buffer) diff --git a/server/server_test.go b/server/server_test.go index 5d3b4e0c8..6fbb0dd0c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2,6 +2,9 @@ package server import ( "bytes" + "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "fmt" "io" @@ -11,12 +14,15 @@ import ( "os" "strings" "testing" + "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/apiv1" + jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" "github.com/jhillyerd/enmime/v2" "golang.org/x/crypto/bcrypt" ) @@ -761,3 +767,287 @@ func assertEqual(t *testing.T, a any, b any, message string) { message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b) t.Fatal(message) } + +// clientGetWithBearer issues an authenticated GET using a Bearer JWT. +func clientGetWithBearer(url, token string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + return http.DefaultClient.Do(req) +} + +// clientGetRaw returns the raw *http.Response so tests can assert on +// status code and headers. The caller must close the body. +func clientGetRaw(url string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + return http.DefaultClient.Do(req) +} + +// testIdP is a minimal OIDC provider used for integration tests of +// the server's auth middleware. It mirrors the helper in +// internal/auth/oidc_test.go (kept separate to avoid build-tag plumbing). +type testIdP struct { + t *testing.T + server *httptest.Server + signer jose.Signer + priv *rsa.PrivateKey +} + +func newTestIdP(t *testing.T) *testIdP { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + kid := "test-key-1" + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: priv}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + if err != nil { + t.Fatalf("signer: %v", err) + } + idp := &testIdP{t: t, signer: signer, priv: priv} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": idp.URL(), + "jwks_uri": idp.URL() + "/jwks", + "authorization_endpoint": idp.URL() + "/authorize", + "token_endpoint": idp.URL() + "/token", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + jwk := jose.JSONWebKey{Key: &priv.PublicKey, KeyID: kid, Algorithm: "RS256", Use: "sig"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}) + }) + idp.server = httptest.NewServer(mux) + return idp +} + +func (i *testIdP) URL() string { return i.server.URL } +func (i *testIdP) Close() { i.server.Close() } + +func (i *testIdP) issue(clientID, sub string, exp time.Time) string { + i.t.Helper() + claims := map[string]any{ + "iss": i.URL(), + "aud": clientID, + "sub": sub, + "exp": exp.Unix(), + "iat": time.Now().Unix(), + } + tok, err := jwt.Signed(i.signer).Claims(claims).Serialize() + if err != nil { + i.t.Fatalf("sign jwt: %v", err) + } + return tok +} + +func TestUIAuthOIDC(t *testing.T) { + setup() + defer storage.Close() + + idp := newTestIdP(t) + defer idp.Close() + + origUI := auth.UICredentials + origVerifier := auth.OIDCVerifier + defer func() { + auth.UICredentials = origUI + auth.OIDCVerifier = origVerifier + }() + + auth.UICredentials = nil // OIDC-only mode for this test + if err := auth.InitOIDC(context.Background(), idp.URL(), "mailpit"); err != nil { + t.Fatalf("init oidc: %v", err) + } + + ts := httptest.NewServer(apiRoutes()) + defer ts.Close() + + t.Run("NoAuth_Returns401_WithOIDCHeader", func(t *testing.T) { + resp, err := clientGetRaw(ts.URL + "/api/v1/messages") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401") + assertEqual(t, "oidc", resp.Header.Get("X-Mp-Auth-Required"), "expected X-Mp-Auth-Required header") + // Basic challenge must NOT be present (no htpasswd configured). + assertEqual(t, "", resp.Header.Get("WWW-Authenticate"), "Basic challenge unexpected") + }) + + t.Run("ValidBearer_Returns200", func(t *testing.T) { + tok := idp.issue("mailpit", "alice", time.Now().Add(time.Hour)) + resp, err := clientGetWithBearer(ts.URL+"/api/v1/messages", tok) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusOK, resp.StatusCode, "expected 200 with valid Bearer") + }) + + t.Run("BearerInQueryParam_Returns200", func(t *testing.T) { + tok := idp.issue("mailpit", "alice", time.Now().Add(time.Hour)) + resp, err := clientGetRaw(ts.URL + "/api/v1/messages?access_token=" + tok) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusOK, resp.StatusCode, "expected 200 with ?access_token=") + }) + + t.Run("ExpiredBearer_Returns401", func(t *testing.T) { + tok := idp.issue("mailpit", "alice", time.Now().Add(-time.Hour)) + resp, err := clientGetWithBearer(ts.URL+"/api/v1/messages", tok) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401 for expired token") + }) + + t.Run("TamperedBearer_Returns401", func(t *testing.T) { + tok := idp.issue("mailpit", "alice", time.Now().Add(time.Hour)) + // Flip the last char of the signature. + tampered := tok[:len(tok)-1] + "A" + if tampered == tok { + tampered = tok[:len(tok)-1] + "B" + } + resp, err := clientGetWithBearer(ts.URL+"/api/v1/messages", tampered) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401 for tampered token") + }) +} + +func TestUIAuthOIDCAndBasicCoexist(t *testing.T) { + setup() + defer storage.Close() + + idp := newTestIdP(t) + defer idp.Close() + + origUI := auth.UICredentials + origVerifier := auth.OIDCVerifier + defer func() { + auth.UICredentials = origUI + auth.OIDCVerifier = origVerifier + }() + + // Configure BOTH OIDC and Basic Auth. + testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost) + if err := auth.SetUIAuth("testuser:" + string(testHash)); err != nil { + t.Fatalf("set ui auth: %v", err) + } + if err := auth.InitOIDC(context.Background(), idp.URL(), "mailpit"); err != nil { + t.Fatalf("init oidc: %v", err) + } + + ts := httptest.NewServer(apiRoutes()) + defer ts.Close() + + t.Run("ValidBearer_Returns200", func(t *testing.T) { + tok := idp.issue("mailpit", "alice", time.Now().Add(time.Hour)) + resp, err := clientGetWithBearer(ts.URL+"/api/v1/messages", tok) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusOK, resp.StatusCode, "expected 200 with Bearer") + }) + + t.Run("ValidBasic_Returns200", func(t *testing.T) { + if _, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "testuser", "testpass"); err != nil { + t.Fatalf("expected 200 with Basic, got %v", err) + } + }) + + t.Run("NoAuth_401_WithBothChallenges", func(t *testing.T) { + resp, err := clientGetRaw(ts.URL + "/api/v1/messages") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401") + assertEqual(t, "oidc", resp.Header.Get("X-Mp-Auth-Required"), "expected OIDC hint") + if resp.Header.Get("WWW-Authenticate") == "" { + t.Fatalf("expected WWW-Authenticate Basic challenge") + } + }) +} + +func TestUIAuthOIDCDisabled_BasicStillWorks(t *testing.T) { + setup() + defer storage.Close() + + origUI := auth.UICredentials + origVerifier := auth.OIDCVerifier + defer func() { + auth.UICredentials = origUI + auth.OIDCVerifier = origVerifier + }() + + auth.OIDCVerifier = nil + testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost) + if err := auth.SetUIAuth("testuser:" + string(testHash)); err != nil { + t.Fatalf("set ui auth: %v", err) + } + + ts := httptest.NewServer(apiRoutes()) + defer ts.Close() + + resp, err := clientGetRaw(ts.URL + "/api/v1/messages") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401") + // No OIDC, so no OIDC hint header. + assertEqual(t, "", resp.Header.Get("X-Mp-Auth-Required"), "X-Mp-Auth-Required must not be set when OIDC is disabled") + if resp.Header.Get("WWW-Authenticate") == "" { + t.Fatalf("expected WWW-Authenticate Basic challenge") + } + + if _, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "testuser", "testpass"); err != nil { + t.Fatalf("expected 200 with valid Basic creds, got %v", err) + } +} + +func TestUIAuthBothNil_AllowsAnonymous(t *testing.T) { + setup() + defer storage.Close() + + origUI := auth.UICredentials + origVerifier := auth.OIDCVerifier + defer func() { + auth.UICredentials = origUI + auth.OIDCVerifier = origVerifier + }() + + auth.UICredentials = nil + auth.OIDCVerifier = nil + + ts := httptest.NewServer(apiRoutes()) + defer ts.Close() + + resp, err := clientGetRaw(ts.URL + "/api/v1/messages") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + assertEqual(t, http.StatusOK, resp.StatusCode, "expected 200 with no auth configured") +} diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 77f26ad9a..131ed9c0d 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -2,6 +2,7 @@ import CommonMixins from "./mixins/CommonMixins"; import Favicon from "./components/AppFavicon.vue"; import AppBadge from "./components/AppBadge.vue"; +import AppLogout from "./components/AppLogout.vue"; import Notifications from "./components/AppNotifications.vue"; import EditTags from "./components/EditTags.vue"; import { mailbox } from "./stores/mailbox"; @@ -10,6 +11,7 @@ export default { components: { Favicon, AppBadge, + AppLogout, Notifications, EditTags, }, @@ -42,6 +44,7 @@ export default { + diff --git a/server/ui-src/components/AppLogout.vue b/server/ui-src/components/AppLogout.vue new file mode 100644 index 000000000..4864455c4 --- /dev/null +++ b/server/ui-src/components/AppLogout.vue @@ -0,0 +1,30 @@ + + + diff --git a/server/ui-src/components/AppNotifications.vue b/server/ui-src/components/AppNotifications.vue index a411ca3c2..3674e76a0 100644 --- a/server/ui-src/components/AppNotifications.vue +++ b/server/ui-src/components/AppNotifications.vue @@ -3,6 +3,7 @@ import CommonMixins from "../mixins/CommonMixins"; import { Toast } from "bootstrap"; import { mailbox } from "../stores/mailbox"; import { pagination } from "../stores/pagination"; +import { getToken, oidcEnabled } from "../services/oidcAuth"; export default { mixins: [CommonMixins], @@ -31,9 +32,6 @@ export default { this.version = d.dataset.version; } - const proto = location.protocol === "https:" ? "wss" : "ws"; - this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`); - this.socketBreakReset(); this.connect(); @@ -45,8 +43,25 @@ export default { }, methods: { + // Build the WebSocket URL. When OIDC is enabled, append the current + // ID token as ?access_token= since browsers can't set headers on + // WebSocket upgrades. Recomputed on every connect so refresh-token + // rotation is picked up on reconnections. + async buildSocketURI() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + let uri = proto + "://" + document.location.host + this.resolve("/api/events"); + if (oidcEnabled()) { + const t = await getToken(); + if (t) { + uri += "?access_token=" + encodeURIComponent(t); + } + } + return uri; + }, + // websocket connect - connect() { + async connect() { + this.socketURI = await this.buildSocketURI(); const ws = new WebSocket(this.socketURI); ws.onmessage = (e) => { let response; diff --git a/server/ui-src/mixins/CommonMixins.js b/server/ui-src/mixins/CommonMixins.js index 530383f1a..00bfb72d8 100644 --- a/server/ui-src/mixins/CommonMixins.js +++ b/server/ui-src/mixins/CommonMixins.js @@ -3,6 +3,32 @@ import dayjs from "dayjs"; import ColorHash from "color-hash"; import { Modal, Offcanvas } from "bootstrap"; import { limitOptions } from "../stores/pagination"; +import { getToken, login, oidcEnabled } from "../services/oidcAuth"; + +// Attach the OIDC Bearer token on every request when OIDC is enabled. +axios.interceptors.request.use(async (config) => { + if (oidcEnabled()) { + const t = await getToken(); + if (t) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${t}`; + } + } + return config; +}); + +// On 401 from the server with X-Mp-Auth-Required: oidc, restart the OIDC flow. +axios.interceptors.response.use( + (r) => r, + async (err) => { + const r = err && err.response; + if (r && r.status === 401 && r.headers && r.headers["x-mp-auth-required"] === "oidc") { + await login(window.location.pathname + window.location.search); + return new Promise(() => {}); // page is navigating; swallow the error + } + return Promise.reject(err); + }, +); // BootstrapElement is used to return a fake Bootstrap element // if the ID returns nothing to prevent errors. diff --git a/server/ui-src/oidc-entry.js b/server/ui-src/oidc-entry.js new file mode 100644 index 000000000..29cbf08a7 --- /dev/null +++ b/server/ui-src/oidc-entry.js @@ -0,0 +1,8 @@ +// Standalone entry that loads oidc-client-ts and exposes it on the +// global window.__mailpitOIDC__. The HTML template only includes this +// script when OIDC is configured server-side, so the library is never +// shipped to users when OIDC is disabled. +import { UserManager, WebStorageStateStore } from "oidc-client-ts"; + +window.__mailpitOIDC__ = { UserManager, WebStorageStateStore }; +window.dispatchEvent(new Event("mp-oidc-ready")); diff --git a/server/ui-src/router/index.js b/server/ui-src/router/index.js index 6a0e8a789..e44591718 100644 --- a/server/ui-src/router/index.js +++ b/server/ui-src/router/index.js @@ -1,8 +1,10 @@ import { createRouter, createWebHistory } from "vue-router"; +import AuthCallbackView from "../views/AuthCallbackView.vue"; import MailboxView from "../views/MailboxView.vue"; import MessageView from "../views/MessageView.vue"; import NotFoundView from "../views/NotFoundView.vue"; import SearchView from "../views/SearchView.vue"; +import { configureOIDC, getUser, login, oidcEnabled } from "../services/oidcAuth"; const d = document.getElementById("app"); let webroot = "/"; @@ -10,6 +12,9 @@ if (d) { webroot = d.dataset.webroot; } +// Resolves once oidc-client-ts is loaded (or immediately to null when OIDC is disabled). +const oidcReady = configureOIDC(); + // paths are relative to webroot const router = createRouter({ history: createWebHistory(webroot), @@ -26,6 +31,10 @@ const router = createRouter({ path: "/view/:id", component: MessageView, }, + { + path: "/auth/callback", + component: AuthCallbackView, + }, { path: "/:pathMatch(.*)*", name: "NotFound", @@ -34,4 +43,14 @@ const router = createRouter({ ], }); +router.beforeEach(async (to) => { + if (!oidcEnabled()) return true; + await oidcReady; + if (to.path === "/auth/callback") return true; + const u = await getUser(); + if (u && !u.expired) return true; + await login(to.fullPath); + return false; // navigation paused — browser is being redirected +}); + export default router; diff --git a/server/ui-src/services/oidcAuth.js b/server/ui-src/services/oidcAuth.js new file mode 100644 index 000000000..d7cdafc2b --- /dev/null +++ b/server/ui-src/services/oidcAuth.js @@ -0,0 +1,79 @@ +// Wraps oidc-client-ts UserManager. Reads the library off the global +// window.__mailpitOIDC__ that the conditionally-loaded oidc-entry.js +// bundle attaches — so the main app.js bundle never imports +// oidc-client-ts directly. + +let mgr = null; +let configured = false; + +export function oidcEnabled() { + const el = document.getElementById("app"); + return !!(el && el.dataset.oidcIssuer && el.dataset.oidcClientId); +} + +function loadLib() { + if (window.__mailpitOIDC__) return Promise.resolve(window.__mailpitOIDC__); + return new Promise((resolve) => { + window.addEventListener("mp-oidc-ready", () => resolve(window.__mailpitOIDC__), { once: true }); + }); +} + +export async function configureOIDC() { + if (configured) return mgr; + configured = true; + if (!oidcEnabled()) return null; + const { UserManager, WebStorageStateStore } = await loadLib(); + const el = document.getElementById("app"); + const issuer = el.dataset.oidcIssuer; + const clientId = el.dataset.oidcClientId; + const webroot = el.dataset.webroot || "/"; + const origin = window.location.origin; + mgr = new UserManager({ + authority: issuer, + client_id: clientId, + redirect_uri: origin + webroot + "auth/callback", + post_logout_redirect_uri: origin + webroot, + response_type: "code", + scope: "openid email profile offline_access", + userStore: new WebStorageStateStore({ store: window.sessionStorage }), + automaticSilentRenew: true, + includeIdTokenInSilentRenew: true, + }); + mgr.events.addSilentRenewError(() => { + // Refresh-token grant failed — forget the user so the next 401 + // triggers a full redirect via the axios response interceptor. + mgr.removeUser(); + }); + return mgr; +} + +export async function getUser() { + if (!mgr) return null; + return mgr.getUser(); +} + +export async function getToken() { + const u = await getUser(); + if (!u || u.expired) return null; + return u.id_token; +} + +export async function login(returnTo) { + if (!mgr) return; + return mgr.signinRedirect({ + state: returnTo || window.location.pathname + window.location.search, + }); +} + +export async function logout() { + if (!mgr) return; + return mgr.signoutRedirect(); +} + +export async function handleCallback() { + if (!mgr) return "/"; + const u = await mgr.signinRedirectCallback(); + const el = document.getElementById("app"); + const webroot = (el && el.dataset.webroot) || "/"; + return typeof u.state === "string" ? u.state : webroot; +} diff --git a/server/ui-src/views/AuthCallbackView.vue b/server/ui-src/views/AuthCallbackView.vue new file mode 100644 index 000000000..c4840f75b --- /dev/null +++ b/server/ui-src/views/AuthCallbackView.vue @@ -0,0 +1,24 @@ + + + From f2aa36f1ceebb693ebe19b49f3025a0c7cb980ca Mon Sep 17 00:00:00 2001 From: Bezerra Date: Mon, 11 May 2026 23:27:45 -0300 Subject: [PATCH 2/2] fix(auth): polish OIDC integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Let the SPA shell (HTML/JS/CSS, /search, /view/, /auth/callback) load without an auth challenge when OIDC is enabled, so the SPA can run the redirect itself. API and direct .html/.txt previews stay gated. - Suppress WWW-Authenticate: Basic on 401s when OIDC is on, so the browser's native Basic dialog doesn't pop up on a SPA-side 401. Basic Auth still works for clients that send the header proactively. - Add the IdP origin to connect-src in the CSP so the SPA can reach the discovery / JWKS / token endpoints. - Drop the duplicate Basic Auth check in websockets.ServeWs — auth is the middleware's job, and the duplicate broke browser WS upgrades (browsers can't send Authorization on WS handshakes). - Move the SPA-side OIDC config to a cached Promise so callers race- free await the same init. AuthCallbackView does a hard navigation after exchange so the router guard sees the freshly stored user. - Store tokens in localStorage so a new tab inherits the session. - Move the logout button next to "About Mailpit" and show the signed- in user's name beside it. --- config/config.go | 14 ++- server/server.go | 56 +++++++++++- server/server_test.go | 89 +++++++++++++++++-- server/ui-src/App.vue | 3 - server/ui-src/components/AppAbout.vue | 3 + server/ui-src/components/AppLogout.vue | 33 ++++--- server/ui-src/components/AppNotifications.vue | 7 +- server/ui-src/services/oidcAuth.js | 62 +++++++------ server/ui-src/views/AuthCallbackView.vue | 6 +- server/websockets/client.go | 26 ++---- 10 files changed, 221 insertions(+), 78 deletions(-) diff --git a/config/config.go b/config/config.go index 8e51821bd..f7012cb2c 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "net/url" "os" "path" "path/filepath" @@ -301,9 +302,18 @@ func VerifyConfig() error { // The default Content Security Policy is updates on every application page load to replace script-src 'self' // with a random nonce ID to prevent XSS. This applies to the Mailpit app & API. // See server.middleWareFunc() + connectSrc := "'self' ws: wss:" + // When OIDC is enabled the SPA must be allowed to fetch the IdP's + // discovery doc / JWKS / token endpoint. Add the issuer's origin + // to connect-src. + if UIOIDCIssuer != "" { + if u, err := url.Parse(UIOIDCIssuer); err == nil && u.Scheme != "" && u.Host != "" { + connectSrc += " " + u.Scheme + "://" + u.Host + } + } ContentSecurityPolicy = fmt.Sprintf( - "default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';", - cssFontRestriction, cssFontRestriction, + "default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src %s; object-src 'none'; base-uri 'self';", + cssFontRestriction, cssFontRestriction, connectSrc, ) if Database != "" && isDir(Database) { diff --git a/server/server.go b/server/server.go index 6e14e4c6f..260fc2e75 100644 --- a/server/server.go +++ b/server/server.go @@ -92,6 +92,8 @@ func Listen() { r.HandleFunc("GET "+config.Webroot+"view/", middleWareFunc(viewHandler)) r.Handle("GET "+config.Webroot+"search", middleWareFunc(index)) + // OIDC callback — served by the SPA (Vue Router handles the code+state). + r.Handle("GET "+config.Webroot+"auth/callback", middleWareFunc(index)) // Exact-match the webroot; stdlib "/" is always a subtree so we guard inside. r.HandleFunc("GET "+config.Webroot, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != config.Webroot { @@ -228,6 +230,15 @@ func checkUIAuth(r *http.Request) bool { if auth.UICredentials == nil && auth.OIDCVerifier == nil { return true } + // When OIDC is configured, the SPA shell (HTML + JS/CSS + SPA-served + // routes) must boot without an auth challenge so it can run the OIDC + // redirect itself. Otherwise the browser's native Basic Auth dialog + // would fire on first navigation and the SPA would never get a chance + // to redirect to the IdP. The API and direct message-preview routes + // remain gated. + if auth.OIDCVerifier != nil && isSPAShellRequest(r) { + return true + } if auth.OIDCVerifier != nil { authz := r.Header.Get("Authorization") raw := strings.TrimPrefix(authz, "Bearer ") @@ -247,15 +258,56 @@ func checkUIAuth(r *http.Request) bool { return false } +// isSPAShellRequest reports whether the request targets the SPA shell — +// the HTML page, its bundled JS/CSS, favicons, and the client-side +// routes that all serve the same index template. It deliberately +// excludes /api/*, direct HTML/TXT message previews, and /view/latest +// (which leaks the latest message ID via redirect). +func isSPAShellRequest(r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + p := r.URL.Path + wr := config.Webroot + wrTrim := strings.TrimRight(wr, "/") + if p == wr || p == wrTrim { + return true + } + if strings.HasPrefix(p, wr+"dist/") { + return true + } + switch p { + case wr + "favicon.ico", + wr + "favicon.svg", + wr + "mailpit.svg", + wr + "notification.png", + wr + "search", + wr + "auth/callback": + return true + } + if strings.HasPrefix(p, wr+"view/") && p != wr+"view/latest" { + if strings.HasSuffix(p, ".html") || strings.HasSuffix(p, ".txt") { + return false + } + return true + } + return false +} + // unauthorizedResponse writes a 401. When OIDC is enabled it adds the // X-Mp-Auth-Required header so the SPA's axios interceptor can trigger // signinRedirect. The Basic challenge is preserved when htpasswd is // configured so curl-based integrations keep working. func unauthorizedResponse(w http.ResponseWriter) { + // When OIDC is enabled, do NOT advertise a Basic challenge. The + // browser would otherwise pop its native Basic Auth dialog on every + // SPA-side 401 (e.g. a new tab with empty sessionStorage racing the + // OIDC redirect) — even when Basic Auth is also configured for API + // integrations. Basic Auth still works for clients that proactively + // send `Authorization: Basic …` (curl -u, automation scripts). if auth.OIDCVerifier != nil { w.Header().Set("X-Mp-Auth-Required", "oidc") - } - if auth.UICredentials != nil { + } else if auth.UICredentials != nil { w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) } w.WriteHeader(http.StatusUnauthorized) diff --git a/server/server_test.go b/server/server_test.go index 6fbb0dd0c..c17c04210 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -920,11 +920,17 @@ func TestUIAuthOIDC(t *testing.T) { t.Run("TamperedBearer_Returns401", func(t *testing.T) { tok := idp.issue("mailpit", "alice", time.Now().Add(time.Hour)) - // Flip the last char of the signature. - tampered := tok[:len(tok)-1] + "A" - if tampered == tok { - tampered = tok[:len(tok)-1] + "B" + // Flip a char in the middle of the signature segment so we are + // always changing real signature bytes (the last base64url char + // may encode unused padding bits and produce identical bytes). + dot := strings.LastIndex(tok, ".") + sigStart := dot + 1 + mid := sigStart + (len(tok)-sigStart)/2 + swap := byte('A') + if tok[mid] == 'A' { + swap = 'B' } + tampered := tok[:mid] + string(swap) + tok[mid+1:] resp, err := clientGetWithBearer(ts.URL+"/api/v1/messages", tampered) if err != nil { t.Fatalf("get: %v", err) @@ -932,6 +938,71 @@ func TestUIAuthOIDC(t *testing.T) { defer func() { _ = resp.Body.Close() }() assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401 for tampered token") }) + + t.Run("SPAShell_ServesWithoutAuth_WhenOIDCEnabled", func(t *testing.T) { + // The SPA shell must load without an auth challenge so the SPA + // can run the OIDC redirect itself. Otherwise the browser pops + // up its native Basic Auth dialog and the SPA never boots. + for _, path := range []string{"/", "/search", "/view/abc", "/dist/app.js", "/favicon.svg"} { + resp, err := clientGetRaw(ts.URL + path) + if err != nil { + t.Fatalf("get %s: %v", path, err) + } + _ = resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + t.Errorf("%s: expected SPA shell to load without auth, got 401 (WWW-Authenticate=%q)", + path, resp.Header.Get("Www-Authenticate")) + } + if got := resp.Header.Get("Www-Authenticate"); got != "" { + t.Errorf("%s: SPA shell must not return WWW-Authenticate, got %q", path, got) + } + } + }) + +} + +func TestIsSPAShellRequest(t *testing.T) { + // Webroot is "/" in tests by default. + cases := []struct { + method string + path string + want bool + }{ + {"GET", "/", true}, + {"GET", "/search", true}, + {"GET", "/auth/callback", true}, + {"GET", "/view/abc", true}, + {"GET", "/view/abc123XYZ", true}, + {"GET", "/dist/app.js", true}, + {"GET", "/dist/app.css", true}, + {"GET", "/favicon.ico", true}, + {"GET", "/favicon.svg", true}, + {"GET", "/mailpit.svg", true}, + {"GET", "/notification.png", true}, + // Must remain gated: + {"GET", "/view/abc.html", false}, + {"GET", "/view/abc.txt", false}, + {"GET", "/view/latest", false}, + {"GET", "/api/v1/messages", false}, + {"GET", "/api/v1/webui", false}, + {"GET", "/api/events", false}, + {"GET", "/proxy", false}, + // HEAD also qualifies (browsers + curl -I). + {"HEAD", "/", true}, + {"HEAD", "/dist/app.js", true}, + // Non-GET/HEAD methods never qualify. + {"POST", "/", false}, + {"PUT", "/dist/app.js", false}, + } + for _, tc := range cases { + r, err := http.NewRequest(tc.method, tc.path, nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if got := isSPAShellRequest(r); got != tc.want { + t.Errorf("isSPAShellRequest(%s %s) = %v, want %v", tc.method, tc.path, got, tc.want) + } + } } func TestUIAuthOIDCAndBasicCoexist(t *testing.T) { @@ -976,7 +1047,11 @@ func TestUIAuthOIDCAndBasicCoexist(t *testing.T) { } }) - t.Run("NoAuth_401_WithBothChallenges", func(t *testing.T) { + t.Run("NoAuth_401_OIDCHintOnly_NoBasicChallenge", func(t *testing.T) { + // When OIDC is enabled the server must NOT advertise a Basic + // challenge, even if htpasswd is also configured — otherwise + // the browser pops its native dialog on any SPA-side 401. + // Basic Auth still works for clients that proactively send it. resp, err := clientGetRaw(ts.URL + "/api/v1/messages") if err != nil { t.Fatalf("get: %v", err) @@ -984,9 +1059,7 @@ func TestUIAuthOIDCAndBasicCoexist(t *testing.T) { defer func() { _ = resp.Body.Close() }() assertEqual(t, http.StatusUnauthorized, resp.StatusCode, "expected 401") assertEqual(t, "oidc", resp.Header.Get("X-Mp-Auth-Required"), "expected OIDC hint") - if resp.Header.Get("WWW-Authenticate") == "" { - t.Fatalf("expected WWW-Authenticate Basic challenge") - } + assertEqual(t, "", resp.Header.Get("WWW-Authenticate"), "Basic challenge must be suppressed when OIDC is enabled") }) } diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 131ed9c0d..77f26ad9a 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -2,7 +2,6 @@ import CommonMixins from "./mixins/CommonMixins"; import Favicon from "./components/AppFavicon.vue"; import AppBadge from "./components/AppBadge.vue"; -import AppLogout from "./components/AppLogout.vue"; import Notifications from "./components/AppNotifications.vue"; import EditTags from "./components/EditTags.vue"; import { mailbox } from "./stores/mailbox"; @@ -11,7 +10,6 @@ export default { components: { Favicon, AppBadge, - AppLogout, Notifications, EditTags, }, @@ -44,7 +42,6 @@ export default { - diff --git a/server/ui-src/components/AppAbout.vue b/server/ui-src/components/AppAbout.vue index 8bd4314d4..b3a8580fb 100644 --- a/server/ui-src/components/AppAbout.vue +++ b/server/ui-src/components/AppAbout.vue @@ -1,5 +1,6 @@ diff --git a/server/ui-src/components/AppNotifications.vue b/server/ui-src/components/AppNotifications.vue index 3674e76a0..1c967438b 100644 --- a/server/ui-src/components/AppNotifications.vue +++ b/server/ui-src/components/AppNotifications.vue @@ -3,7 +3,7 @@ import CommonMixins from "../mixins/CommonMixins"; import { Toast } from "bootstrap"; import { mailbox } from "../stores/mailbox"; import { pagination } from "../stores/pagination"; -import { getToken, oidcEnabled } from "../services/oidcAuth"; +import { configureOIDC, getToken, oidcEnabled } from "../services/oidcAuth"; export default { mixins: [CommonMixins], @@ -51,6 +51,11 @@ export default { const proto = location.protocol === "https:" ? "wss" : "ws"; let uri = proto + "://" + document.location.host + this.resolve("/api/events"); if (oidcEnabled()) { + // configureOIDC is idempotent: the first call sets up the + // UserManager, subsequent calls await the same promise. + // Awaiting here closes the race between component mount + // and the async OIDC init in router/index.js. + await configureOIDC(); const t = await getToken(); if (t) { uri += "?access_token=" + encodeURIComponent(t); diff --git a/server/ui-src/services/oidcAuth.js b/server/ui-src/services/oidcAuth.js index d7cdafc2b..d1a3bd462 100644 --- a/server/ui-src/services/oidcAuth.js +++ b/server/ui-src/services/oidcAuth.js @@ -4,7 +4,7 @@ // oidc-client-ts directly. let mgr = null; -let configured = false; +let configurePromise = null; export function oidcEnabled() { const el = document.getElementById("app"); @@ -18,36 +18,39 @@ function loadLib() { }); } -export async function configureOIDC() { - if (configured) return mgr; - configured = true; - if (!oidcEnabled()) return null; - const { UserManager, WebStorageStateStore } = await loadLib(); - const el = document.getElementById("app"); - const issuer = el.dataset.oidcIssuer; - const clientId = el.dataset.oidcClientId; - const webroot = el.dataset.webroot || "/"; - const origin = window.location.origin; - mgr = new UserManager({ - authority: issuer, - client_id: clientId, - redirect_uri: origin + webroot + "auth/callback", - post_logout_redirect_uri: origin + webroot, - response_type: "code", - scope: "openid email profile offline_access", - userStore: new WebStorageStateStore({ store: window.sessionStorage }), - automaticSilentRenew: true, - includeIdTokenInSilentRenew: true, - }); - mgr.events.addSilentRenewError(() => { - // Refresh-token grant failed — forget the user so the next 401 - // triggers a full redirect via the axios response interceptor. - mgr.removeUser(); - }); - return mgr; +export function configureOIDC() { + if (configurePromise) return configurePromise; + configurePromise = (async () => { + if (!oidcEnabled()) return null; + const { UserManager, WebStorageStateStore } = await loadLib(); + const el = document.getElementById("app"); + const issuer = el.dataset.oidcIssuer; + const clientId = el.dataset.oidcClientId; + const webroot = el.dataset.webroot || "/"; + const origin = window.location.origin; + mgr = new UserManager({ + authority: issuer, + client_id: clientId, + redirect_uri: origin + webroot + "auth/callback", + post_logout_redirect_uri: origin + webroot, + response_type: "code", + scope: "openid email profile offline_access", + userStore: new WebStorageStateStore({ store: window.localStorage }), + automaticSilentRenew: true, + includeIdTokenInSilentRenew: true, + }); + mgr.events.addSilentRenewError(() => { + // Refresh-token grant failed — forget the user so the next 401 + // triggers a full redirect via the axios response interceptor. + mgr.removeUser(); + }); + return mgr; + })(); + return configurePromise; } export async function getUser() { + await configureOIDC(); if (!mgr) return null; return mgr.getUser(); } @@ -59,6 +62,7 @@ export async function getToken() { } export async function login(returnTo) { + await configureOIDC(); if (!mgr) return; return mgr.signinRedirect({ state: returnTo || window.location.pathname + window.location.search, @@ -66,11 +70,13 @@ export async function login(returnTo) { } export async function logout() { + await configureOIDC(); if (!mgr) return; return mgr.signoutRedirect(); } export async function handleCallback() { + await configureOIDC(); if (!mgr) return "/"; const u = await mgr.signinRedirectCallback(); const el = document.getElementById("app"); diff --git a/server/ui-src/views/AuthCallbackView.vue b/server/ui-src/views/AuthCallbackView.vue index c4840f75b..50c1c34b9 100644 --- a/server/ui-src/views/AuthCallbackView.vue +++ b/server/ui-src/views/AuthCallbackView.vue @@ -8,7 +8,11 @@ export default { async mounted() { try { const returnTo = await handleCallback(); - this.$router.replace(returnTo || "/"); + // Hard navigation: re-boot the SPA so the router guard sees + // the freshly stored user. router.replace can be canceled + // during the initial route resolution and leave us stuck + // here. + window.location.replace(returnTo || "/"); } catch (err) { this.error = err && err.message ? err.message : String(err); } diff --git a/server/websockets/client.go b/server/websockets/client.go index 1fca11fba..61fb86a6d 100644 --- a/server/websockets/client.go +++ b/server/websockets/client.go @@ -8,7 +8,6 @@ import ( "net/http" "time" - "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/gorilla/websocket" ) @@ -103,21 +102,12 @@ func (c *Client) writePump() { } // ServeWs handles websocket requests from the peer. +// ServeWs upgrades the connection to a WebSocket. Authentication is +// the responsibility of the caller (the server middleware), which has +// already validated the request — re-checking Basic Auth here would +// reject browser WebSocket upgrades when the user authenticated via +// OIDC (browsers cannot send `Authorization` headers on WS upgrades). func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { - if auth.UICredentials != nil { - user, pass, ok := r.BasicAuth() - - if !ok { - basicAuthResponse(w) - return - } - - if !auth.UICredentials.Match(user, pass) { - basicAuthResponse(w) - return - } - } - conn, err := upgrader.Upgrade(w, r, nil) if err != nil { logger.Log().Errorf("[websocket] %s", err.Error()) @@ -132,9 +122,3 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { go client.writePump() } -// BasicAuthResponse returns an basic auth response to the browser -func basicAuthResponse(w http.ResponseWriter) { - w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("Unauthorized.\n")) -}