MCIAS now acts as an SSO provider for downstream services. Services redirect users to /sso/authorize, MCIAS handles login (password, TOTP, or passkey), then redirects back with an authorization code that the service exchanges for a JWT via POST /v1/sso/token. - Add SSO client registry to config (client_id, redirect_uri, service_name, tags) with validation - Add internal/sso package: authorization code and session stores using sync.Map with TTL, single-use LoadAndDelete, cleanup goroutines - Add GET /sso/authorize endpoint (validates client, creates session, redirects to /login?sso=<nonce>) - Add POST /v1/sso/token endpoint (exchanges code for JWT with policy evaluation using client's service_name/tags from config) - Thread SSO nonce through password→TOTP and WebAuthn login flows - Update login.html, totp_step.html, and webauthn.js for SSO nonce passthrough Security: - Authorization codes are 256-bit random, single-use, 60-second TTL - redirect_uri validated as exact match against registered config - Policy context comes from MCIAS config, not the calling service - SSO sessions are server-side only; nonce is the sole client-visible value - WebAuthn SSO returns redirect URL as JSON (not HTTP redirect) for JS compat Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
Go
322 lines
12 KiB
Go
// Package config handles loading and validating the MCIAS server configuration.
|
|
// Sensitive values (master key passphrase) are never stored in this struct
|
|
// after initial loading — they are read once and discarded.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
// Config is the top-level configuration structure parsed from the TOML file.
|
|
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
|
|
Server ServerConfig `toml:"server"`
|
|
MasterKey MasterKeyConfig `toml:"master_key"`
|
|
Database DatabaseConfig `toml:"database"`
|
|
Tokens TokensConfig `toml:"tokens"`
|
|
Argon2 Argon2Config `toml:"argon2"`
|
|
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
|
SSO SSOConfig `toml:"sso"`
|
|
}
|
|
|
|
// SSOConfig holds registered SSO clients that may use the authorization code
|
|
// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving
|
|
// clients empty disables SSO.
|
|
type SSOConfig struct {
|
|
Clients []SSOClient `toml:"clients"`
|
|
}
|
|
|
|
// SSOClient is a registered relying-party application that may redirect users
|
|
// to MCIAS for login. The redirect_uri is validated as an exact match (no
|
|
// wildcards) to prevent open-redirect attacks.
|
|
type SSOClient struct {
|
|
ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr")
|
|
RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required
|
|
ServiceName string `toml:"service_name"` // passed to policy engine on login
|
|
Tags []string `toml:"tags"` // passed to policy engine on login
|
|
}
|
|
|
|
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
|
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
|
|
// required and RPOrigin must use the HTTPS scheme.
|
|
type WebAuthnConfig struct {
|
|
RPID string `toml:"rp_id"`
|
|
RPOrigin string `toml:"rp_origin"`
|
|
DisplayName string `toml:"display_name"`
|
|
}
|
|
|
|
// ServerConfig holds HTTP listener and TLS settings.
|
|
type ServerConfig struct {
|
|
// ListenAddr is the HTTPS listen address (required).
|
|
ListenAddr string `toml:"listen_addr"`
|
|
// GRPCAddr is the gRPC listen address (optional; omit to disable gRPC).
|
|
// The gRPC listener uses the same TLS certificate and key as the REST listener.
|
|
GRPCAddr string `toml:"grpc_addr"`
|
|
TLSCert string `toml:"tls_cert"`
|
|
TLSKey string `toml:"tls_key"`
|
|
// TrustedProxy is the IP address (not a range) of a reverse proxy that
|
|
// sits in front of the server and sets X-Forwarded-For or X-Real-IP
|
|
// headers. When set, the rate limiter and audit log extract the real
|
|
// client IP from these headers instead of r.RemoteAddr.
|
|
//
|
|
// Security: only requests whose r.RemoteAddr matches TrustedProxy are
|
|
// trusted to carry a valid forwarded-IP header. All other requests use
|
|
// r.RemoteAddr directly, so this field cannot be exploited for IP
|
|
// spoofing by external clients. Omit or leave empty when running
|
|
// without a reverse proxy.
|
|
TrustedProxy string `toml:"trusted_proxy"`
|
|
}
|
|
|
|
// DatabaseConfig holds SQLite database settings.
|
|
type DatabaseConfig struct {
|
|
Path string `toml:"path"`
|
|
}
|
|
|
|
// TokensConfig holds JWT issuance settings.
|
|
type TokensConfig struct {
|
|
Issuer string `toml:"issuer"`
|
|
DefaultExpiry duration `toml:"default_expiry"`
|
|
AdminExpiry duration `toml:"admin_expiry"`
|
|
ServiceExpiry duration `toml:"service_expiry"`
|
|
}
|
|
|
|
// Argon2Config holds Argon2id password hashing parameters.
|
|
// Security: OWASP 2023 minimums are time=2, memory=65536 KiB.
|
|
// We enforce these minimums to prevent accidental weakening.
|
|
type Argon2Config struct {
|
|
Time uint32 `toml:"time"`
|
|
Memory uint32 `toml:"memory"` // KiB
|
|
Threads uint8 `toml:"threads"`
|
|
}
|
|
|
|
// MasterKeyConfig specifies how to obtain the AES-256-GCM master key used to
|
|
// encrypt stored secrets (TOTP, Postgres passwords, signing key).
|
|
// Exactly one of PassphraseEnv or KeyFile must be set.
|
|
type MasterKeyConfig struct {
|
|
PassphraseEnv string `toml:"passphrase_env"`
|
|
KeyFile string `toml:"keyfile"`
|
|
}
|
|
|
|
// duration is a wrapper around time.Duration that supports TOML string parsing
|
|
// (e.g. "168h", "8h").
|
|
type duration struct {
|
|
time.Duration
|
|
}
|
|
|
|
func (d *duration) UnmarshalText(text []byte) error {
|
|
var err error
|
|
d.Duration, err = time.ParseDuration(string(text))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration %q: %w", string(text), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewTestConfig returns a minimal valid Config for use in tests.
|
|
// It does not read a file; callers can override fields as needed.
|
|
func NewTestConfig(issuer string) *Config {
|
|
return &Config{
|
|
Server: ServerConfig{
|
|
ListenAddr: "127.0.0.1:0",
|
|
TLSCert: "/dev/null",
|
|
TLSKey: "/dev/null",
|
|
},
|
|
Database: DatabaseConfig{Path: ":memory:"},
|
|
Tokens: TokensConfig{
|
|
Issuer: issuer,
|
|
DefaultExpiry: duration{24 * time.Hour},
|
|
AdminExpiry: duration{8 * time.Hour},
|
|
ServiceExpiry: duration{8760 * time.Hour},
|
|
},
|
|
Argon2: Argon2Config{
|
|
Time: 3,
|
|
Memory: 65536,
|
|
Threads: 4,
|
|
},
|
|
MasterKey: MasterKeyConfig{
|
|
PassphraseEnv: "MCIAS_MASTER_PASSPHRASE", //nolint:gosec // G101: env var name, not a credential value
|
|
},
|
|
}
|
|
}
|
|
|
|
// Load reads and validates a TOML config file from path.
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304: path comes from the operator-supplied --config flag, not user input
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: read file: %w", err)
|
|
}
|
|
|
|
var cfg Config
|
|
if err := toml.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("config: parse TOML: %w", err)
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, fmt.Errorf("config: invalid: %w", err)
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// validate checks that all required fields are present and values are safe.
|
|
func (c *Config) validate() error {
|
|
var errs []error
|
|
|
|
// Server
|
|
if c.Server.ListenAddr == "" {
|
|
errs = append(errs, errors.New("server.listen_addr is required"))
|
|
}
|
|
if c.Server.TLSCert == "" {
|
|
errs = append(errs, errors.New("server.tls_cert is required"))
|
|
}
|
|
if c.Server.TLSKey == "" {
|
|
errs = append(errs, errors.New("server.tls_key is required"))
|
|
}
|
|
// Security (DEF-03): if trusted_proxy is set it must be a valid IP address
|
|
// (not a hostname or CIDR) so the middleware can compare it to the parsed
|
|
// host part of r.RemoteAddr using a reliable byte-level equality check.
|
|
if c.Server.TrustedProxy != "" {
|
|
if net.ParseIP(c.Server.TrustedProxy) == nil {
|
|
errs = append(errs, fmt.Errorf("server.trusted_proxy %q is not a valid IP address", c.Server.TrustedProxy))
|
|
}
|
|
}
|
|
|
|
// Database
|
|
if c.Database.Path == "" {
|
|
errs = append(errs, errors.New("database.path is required"))
|
|
}
|
|
|
|
// Tokens
|
|
if c.Tokens.Issuer == "" {
|
|
errs = append(errs, errors.New("tokens.issuer is required"))
|
|
}
|
|
// Security (DEF-05): enforce both lower and upper bounds on token expiry
|
|
// durations. An operator misconfiguration could otherwise produce tokens
|
|
// valid for centuries, which would be irrevocable (bar explicit JTI
|
|
// revocation) if a token were stolen. Upper bounds are intentionally
|
|
// generous to accommodate a range of legitimate deployments while
|
|
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
|
const (
|
|
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
|
maxAdminExpiry = 24 * time.Hour // 24 hours
|
|
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
|
)
|
|
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
|
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
|
|
} else if c.Tokens.DefaultExpiry.Duration > maxDefaultExpiry {
|
|
errs = append(errs, fmt.Errorf("tokens.default_expiry must be <= %s (got %s)", maxDefaultExpiry, c.Tokens.DefaultExpiry.Duration))
|
|
}
|
|
if c.Tokens.AdminExpiry.Duration <= 0 {
|
|
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
|
|
} else if c.Tokens.AdminExpiry.Duration > maxAdminExpiry {
|
|
errs = append(errs, fmt.Errorf("tokens.admin_expiry must be <= %s (got %s)", maxAdminExpiry, c.Tokens.AdminExpiry.Duration))
|
|
}
|
|
if c.Tokens.ServiceExpiry.Duration <= 0 {
|
|
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
|
|
} else if c.Tokens.ServiceExpiry.Duration > maxServiceExpiry {
|
|
errs = append(errs, fmt.Errorf("tokens.service_expiry must be <= %s (got %s)", maxServiceExpiry, c.Tokens.ServiceExpiry.Duration))
|
|
}
|
|
|
|
// Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB).
|
|
// Security: reducing these parameters weakens resistance to brute-force
|
|
// attacks. Rejection here prevents accidental misconfiguration.
|
|
const (
|
|
minArgon2Time = 2
|
|
minArgon2Memory = 65536 // 64 MiB in KiB
|
|
minArgon2Thread = 1
|
|
)
|
|
if c.Argon2.Time < minArgon2Time {
|
|
errs = append(errs, fmt.Errorf("argon2.time must be >= %d (OWASP minimum)", minArgon2Time))
|
|
}
|
|
if c.Argon2.Memory < minArgon2Memory {
|
|
errs = append(errs, fmt.Errorf("argon2.memory must be >= %d KiB (OWASP minimum)", minArgon2Memory))
|
|
}
|
|
if c.Argon2.Threads < minArgon2Thread {
|
|
errs = append(errs, errors.New("argon2.threads must be >= 1"))
|
|
}
|
|
|
|
// Master key — exactly one source must be configured.
|
|
hasPassEnv := c.MasterKey.PassphraseEnv != ""
|
|
hasKeyFile := c.MasterKey.KeyFile != ""
|
|
if !hasPassEnv && !hasKeyFile {
|
|
errs = append(errs, errors.New("master_key: one of passphrase_env or keyfile must be set"))
|
|
}
|
|
if hasPassEnv && hasKeyFile {
|
|
errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set"))
|
|
}
|
|
|
|
// WebAuthn — if any field is set, RPID and RPOrigin are required.
|
|
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
|
|
if hasWebAuthn {
|
|
if c.WebAuthn.RPID == "" {
|
|
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
|
|
}
|
|
if c.WebAuthn.RPOrigin == "" {
|
|
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
|
|
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
|
|
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
|
|
}
|
|
}
|
|
|
|
// SSO clients — if any are configured, each must have a unique client_id,
|
|
// a non-empty redirect_uri with the https:// scheme, and a non-empty
|
|
// service_name.
|
|
seen := make(map[string]bool, len(c.SSO.Clients))
|
|
for i, cl := range c.SSO.Clients {
|
|
prefix := fmt.Sprintf("sso.clients[%d]", i)
|
|
if cl.ClientID == "" {
|
|
errs = append(errs, fmt.Errorf("%s: client_id is required", prefix))
|
|
} else if seen[cl.ClientID] {
|
|
errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID))
|
|
} else {
|
|
seen[cl.ClientID] = true
|
|
}
|
|
if cl.RedirectURI == "" {
|
|
errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix))
|
|
} else if !strings.HasPrefix(cl.RedirectURI, "https://") {
|
|
errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI))
|
|
}
|
|
if cl.ServiceName == "" {
|
|
errs = append(errs, fmt.Errorf("%s: service_name is required", prefix))
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// SSOClient looks up a registered SSO client by client_id.
|
|
// Returns nil if no client with that ID is registered.
|
|
func (c *Config) SSOClient(clientID string) *SSOClient {
|
|
for i := range c.SSO.Clients {
|
|
if c.SSO.Clients[i].ClientID == clientID {
|
|
return &c.SSO.Clients[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SSOEnabled reports whether any SSO clients are registered.
|
|
func (c *Config) SSOEnabled() bool {
|
|
return len(c.SSO.Clients) > 0
|
|
}
|
|
|
|
// DefaultExpiry returns the configured default token expiry duration.
|
|
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }
|
|
|
|
// AdminExpiry returns the configured admin token expiry duration.
|
|
func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Duration }
|
|
|
|
// ServiceExpiry returns the configured service token expiry duration.
|
|
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
|
|
|
|
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
|
|
func (c *Config) WebAuthnEnabled() bool {
|
|
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
|
|
}
|