MCR can now redirect users to MCIAS for login instead of showing its own login form. This enables passkey/FIDO2 authentication since WebAuthn credentials are bound to MCIAS's domain. - Add optional [sso] config section with redirect_uri - Add handleSSOLogin (redirects to MCIAS) and handleSSOCallback (exchanges code for JWT, validates roles, sets session cookie) - SSO is opt-in: when redirect_uri is empty, the existing login form is used (backward compatible) - Guest role check preserved in SSO callback path - Return-to URL preserved across the SSO redirect - Uses mcdsl/sso package (local replace for now) Security: - State cookie uses SameSite=Lax for cross-site redirect compatibility - Session cookie remains SameSite=Strict (same-site only after login) - Code exchange is server-to-server over TLS 1.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
113 lines
3.1 KiB
Go
113 lines
3.1 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
mcdslconfig "git.wntrmute.dev/mc/mcdsl/config"
|
|
)
|
|
|
|
// Config is the top-level MCR configuration. It embeds config.Base for
|
|
// the standard Metacircular sections and adds MCR-specific sections.
|
|
type Config struct {
|
|
mcdslconfig.Base
|
|
Storage StorageConfig `toml:"storage"`
|
|
Web WebConfig `toml:"web"`
|
|
SSO SSOConfig `toml:"sso"`
|
|
}
|
|
|
|
// SSOConfig holds optional SSO redirect settings. When redirect_uri is
|
|
// non-empty, the web UI redirects to MCIAS for login instead of showing
|
|
// its own login form.
|
|
type SSOConfig struct {
|
|
RedirectURI string `toml:"redirect_uri"`
|
|
}
|
|
|
|
// StorageConfig holds blob/layer storage settings.
|
|
type StorageConfig struct {
|
|
LayersPath string `toml:"layers_path"`
|
|
UploadsPath string `toml:"uploads_path"`
|
|
}
|
|
|
|
// WebConfig holds the web UI server settings.
|
|
type WebConfig struct {
|
|
ListenAddr string `toml:"listen_addr"`
|
|
GRPCAddr string `toml:"grpc_addr"`
|
|
CACert string `toml:"ca_cert"`
|
|
TLSServerName string `toml:"tls_server_name"`
|
|
}
|
|
|
|
// Load reads a TOML config file, applies environment variable overrides
|
|
// (MCR_ prefix), sets defaults, and validates required fields.
|
|
func Load(path string) (*Config, error) {
|
|
cfg, err := mcdslconfig.Load[Config](path, "MCR")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// Validate implements the mcdsl config.Validator interface. It checks
|
|
// MCR-specific required fields and constraints beyond what config.Base
|
|
// validates.
|
|
func (c *Config) Validate() error {
|
|
if c.Database.Path == "" {
|
|
return fmt.Errorf("database.path is required")
|
|
}
|
|
if c.Storage.LayersPath == "" {
|
|
return fmt.Errorf("storage.layers_path is required")
|
|
}
|
|
if c.MCIAS.ServerURL == "" {
|
|
return fmt.Errorf("mcias.server_url is required")
|
|
}
|
|
|
|
// Default uploads path to sibling of layers path.
|
|
if c.Storage.UploadsPath == "" && c.Storage.LayersPath != "" {
|
|
c.Storage.UploadsPath = filepath.Join(filepath.Dir(c.Storage.LayersPath), "uploads")
|
|
}
|
|
|
|
return validateSameFilesystem(c.Storage.LayersPath, c.Storage.UploadsPath)
|
|
}
|
|
|
|
// validateSameFilesystem checks that two paths reside on the same filesystem
|
|
// by comparing device IDs. If either path does not exist yet, it checks the
|
|
// nearest existing parent directory.
|
|
func validateSameFilesystem(layersPath, uploadsPath string) error {
|
|
layersDev, err := deviceID(layersPath)
|
|
if err != nil {
|
|
return fmt.Errorf("stat layers_path: %w", err)
|
|
}
|
|
|
|
uploadsDev, err := deviceID(uploadsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("stat uploads_path: %w", err)
|
|
}
|
|
|
|
if layersDev != uploadsDev {
|
|
return fmt.Errorf("storage.layers_path and storage.uploads_path must be on the same filesystem")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deviceID returns the device ID for the given path. If the path does not
|
|
// exist, it walks up to the nearest existing parent.
|
|
func deviceID(path string) (uint64, error) {
|
|
p := filepath.Clean(path)
|
|
for {
|
|
info, err := os.Stat(p)
|
|
if err == nil {
|
|
return extractDeviceID(info)
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return 0, err
|
|
}
|
|
parent := filepath.Dir(p)
|
|
if parent == p {
|
|
return 0, fmt.Errorf("no existing parent for %s", path)
|
|
}
|
|
p = parent
|
|
}
|
|
}
|