Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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)")
Expand Down Expand Up @@ -250,6 +252,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 {
Expand Down
32 changes: 30 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
package config

import (
"context"
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
Expand Down Expand Up @@ -84,6 +86,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 = "/"

Expand Down Expand Up @@ -298,9 +307,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) {
Expand Down Expand Up @@ -352,6 +370,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")
}
Expand Down
2 changes: 1 addition & 1 deletion esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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-20260417124207-7d523f7318df
github.com/google/uuid v1.6.0
Expand Down Expand Up @@ -59,6 +61,7 @@ require (
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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=
Expand Down
157 changes: 157 additions & 0 deletions internal/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// OIDC verification for the web UI.
//
// When configured (issuer + client ID), Mailpit verifies incoming
// `Authorization: Bearer <jwt>` 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 }
}
Loading