diff --git a/cmd/mcq/server.go b/cmd/mcq/server.go index 50463cd..87d9296 100644 --- a/cmd/mcq/server.go +++ b/cmd/mcq/server.go @@ -77,6 +77,9 @@ func runServer(configPath string) error { wsCfg := webserver.Config{ ServiceName: cfg.MCIAS.ServiceName, Tags: cfg.MCIAS.Tags, + MciasURL: cfg.MCIAS.ServerURL, + CACert: cfg.MCIAS.CACert, + RedirectURI: cfg.SSO.RedirectURI, } webSrv, err := webserver.New(wsCfg, database, authClient, logger) if err != nil { diff --git a/go.mod b/go.mod index 20167ec..45b5b24 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcq go 1.25.7 require ( - git.wntrmute.dev/mc/mcdsl v1.2.0 + git.wntrmute.dev/mc/mcdsl v1.7.0 github.com/alecthomas/chroma/v2 v2.18.0 github.com/go-chi/chi/v5 v5.2.5 github.com/mark3labs/mcp-go v0.46.0 diff --git a/go.sum b/go.sum index 84507ed..c842129 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U= -git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= +git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc= +git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= diff --git a/internal/config/config.go b/internal/config/config.go index 634efdb..3c4c924 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,10 +14,18 @@ import ( // Config is the MCQ configuration. type Config struct { - Server ServerConfig `toml:"server"` - Database DatabaseConfig `toml:"database"` + Server ServerConfig `toml:"server"` + Database DatabaseConfig `toml:"database"` MCIAS mcdslauth.Config `toml:"mcias"` - Log LogConfig `toml:"log"` + SSO SSOConfig `toml:"sso"` + Log LogConfig `toml:"log"` +} + +// 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"` } // ServerConfig holds HTTP/gRPC server settings. TLS fields are optional; diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 58c487d..b91bc47 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -11,6 +11,7 @@ import ( "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" mcqweb "git.wntrmute.dev/mc/mcq/web" @@ -25,16 +26,22 @@ const cookieName = "mcq_session" type Config struct { ServiceName string Tags []string + // SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead + // of the direct username/password login form. + MciasURL string + CACert string + RedirectURI string } // Server is the MCQ web UI server. type Server struct { - db *db.DB - auth *auth.Authenticator - csrf *csrf.Protect - render *render.Renderer - logger *slog.Logger - config Config + db *db.DB + auth *auth.Authenticator + csrf *csrf.Protect + ssoClient *mcdsso.Client + render *render.Renderer + logger *slog.Logger + config Config } // New creates a web UI server. @@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger } csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token") - return &Server{ + s := &Server{ db: database, auth: authenticator, csrf: csrfProtect, render: render.New(), logger: logger, config: cfg, - }, nil + } + + // Create SSO client if the service has an SSO redirect_uri configured. + if cfg.RedirectURI != "" { + ssoClient, err := mcdsso.New(mcdsso.Config{ + MciasURL: cfg.MciasURL, + ClientID: "mcq", + RedirectURI: cfg.RedirectURI, + CACert: cfg.CACert, + }) + if err != nil { + return nil, fmt.Errorf("create SSO client: %w", err) + } + s.ssoClient = ssoClient + logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL) + } + + return s, nil } // RegisterRoutes adds web UI routes to the given router. func (s *Server) RegisterRoutes(r chi.Router) { - r.Get("/login", s.handleLoginPage) - r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP) + if s.ssoClient != nil { + r.Get("/login", s.handleSSOLogin) + r.Get("/sso/redirect", s.handleSSORedirect) + r.Get("/sso/callback", s.handleSSOCallback) + } else { + r.Get("/login", s.handleLoginPage) + r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP) + } r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP) // Authenticated routes. @@ -80,6 +110,7 @@ type pageData struct { Error string Title string Content any + SSO bool } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { @@ -101,6 +132,32 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } +// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button. +func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) { + web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}) +} + +// handleSSORedirect initiates the SSO redirect to MCIAS. +func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) { + if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil { + s.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 (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) { + token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq") + if err != nil { + s.logger.Error("sso: callback", "error", err) + http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized) + return + } + + web.SetSessionCookie(w, cookieName, token) + http.Redirect(w, r, returnTo, http.StatusSeeOther) +} + func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { token := web.GetSessionToken(r, cookieName) if token != "" { diff --git a/web/templates/login.html b/web/templates/login.html index b488dda..1b07757 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -7,6 +7,11 @@
{{if .Error}}
{{.Error}}
{{end}} + {{if .SSO}} +
+ +
+ {{else}}
{{csrfField}}
@@ -25,5 +30,6 @@
+ {{end}}
{{end}}