Add SSO login support

- Add [sso] config section with redirect_uri
- Create mcdsl/sso client when SSO is configured
- Add /login (landing page), /sso/redirect, /sso/callback routes
- Add /logout route
- Update login template with SSO landing page variant
- Bump mcdsl to v1.6.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 20:23:25 -07:00
parent ae4cc8b420
commit 647fd26e60
2619 changed files with 6833933 additions and 9 deletions

View File

@@ -10,13 +10,14 @@ import (
// Config is the top-level configuration for Metacrypt.
type Config struct {
Server ServerConfig `toml:"server"`
Web WebConfig `toml:"web"`
MCIAS MCIASConfig `toml:"mcias"`
Server ServerConfig `toml:"server"`
Web WebConfig `toml:"web"`
MCIAS MCIASConfig `toml:"mcias"`
SSO SSOConfig `toml:"sso"`
Database mcdslconfig.DatabaseConfig `toml:"database"`
Log mcdslconfig.LogConfig `toml:"log"`
Seal SealConfig `toml:"seal"`
Audit AuditConfig `toml:"audit"`
Seal SealConfig `toml:"seal"`
Audit AuditConfig `toml:"audit"`
}
// ServerConfig holds HTTP/gRPC server settings. It embeds the standard
@@ -33,6 +34,13 @@ type MCIASConfig struct {
ServiceToken string `toml:"service_token"`
}
// SSOConfig holds SSO redirect settings for the web UI.
type SSOConfig struct {
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config.
RedirectURI string `toml:"redirect_uri"`
}
// WebConfig holds settings for the standalone web UI server (metacrypt-web).
type WebConfig struct {
// ListenAddr is the address the web server listens on (default: 127.0.0.1:8080).

View File

@@ -17,6 +17,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
)
@@ -37,7 +38,14 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Get("/", ws.handleRoot)
r.HandleFunc("/init", ws.handleInit)
r.HandleFunc("/unseal", ws.handleUnseal)
r.HandleFunc("/login", ws.handleLogin)
if ws.ssoClient != nil {
r.Get("/login", ws.handleSSOLogin)
r.Get("/sso/redirect", ws.handleSSORedirect)
r.Get("/sso/callback", ws.handleSSOCallback)
} else {
r.HandleFunc("/login", ws.handleLogin)
}
r.Get("/logout", ws.handleLogout)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
@@ -236,6 +244,43 @@ func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
}
}
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
func (ws *WebServer) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
state, _ := ws.vault.Status(r.Context())
if state != "unsealed" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
ws.renderTemplate(w, "login.html", map[string]interface{}{"SSO": true})
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (ws *WebServer) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
if err := mcdsso.RedirectToLogin(w, r, ws.ssoClient, "metacrypt"); err != nil {
ws.logger.Error("sso: redirect to login", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
func (ws *WebServer) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
token, returnTo, err := mcdsso.HandleCallback(w, r, ws.ssoClient, "metacrypt")
if err != nil {
ws.logger.Error("sso: callback", "error", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
web.SetSessionCookie(w, "metacrypt_token", token)
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
// handleLogout clears the session and redirects to login.
func (ws *WebServer) handleLogout(w http.ResponseWriter, r *http.Request) {
web.ClearSessionCookie(w, "metacrypt_token")
http.Redirect(w, r, "/login", http.StatusFound)
}
func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)

View File

@@ -17,6 +17,7 @@ import (
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
"git.wntrmute.dev/mc/metacrypt/internal/config"
webui "git.wntrmute.dev/mc/metacrypt/web"
@@ -115,10 +116,11 @@ type cachedUsername struct {
type WebServer struct {
cfg *config.Config
vault vaultBackend
logger *slog.Logger
logger *slog.Logger
httpSrv *http.Server
staticFS fs.FS
csrf *csrf.Protect
ssoClient *mcdsso.Client
tgzCache sync.Map // key: UUID string → *tgzEntry
userCache sync.Map // key: UUID string → *cachedUsername
}
@@ -169,6 +171,21 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
csrf: csrf.New(secret, "metacrypt_csrf", "csrf_token"),
}
// Create SSO client if the service has an SSO redirect_uri configured.
if cfg.SSO.RedirectURI != "" {
ssoClient, ssoErr := mcdsso.New(mcdsso.Config{
MciasURL: cfg.MCIAS.ServerURL,
ClientID: "metacrypt",
RedirectURI: cfg.SSO.RedirectURI,
CACert: cfg.MCIAS.CACert,
})
if ssoErr != nil {
return nil, fmt.Errorf("webserver: create SSO client: %w", ssoErr)
}
ws.ssoClient = ssoClient
logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MCIAS.ServerURL)
}
if tok := cfg.MCIAS.ServiceToken; tok != "" {
a, err := mcdslauth.New(mcdslauth.Config{
ServerURL: cfg.MCIAS.ServerURL,