2 Commits

Author SHA1 Message Date
dd5142a48a Fix template error: pass CSRF func on SSO login page
Go templates require all referenced functions to be defined at parse
time, even in branches that won't execute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:35:21 -07:00
2c3db6ea25 Add SSO login support via mcdsl/sso
When [sso].redirect_uri is configured, the web UI shows a "Sign in
with MCIAS" button instead of the username/password form. Upgrades
mcdsl to v1.7.0 which includes the Firefox cookie fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:15:06 -07:00
6 changed files with 90 additions and 16 deletions

View File

@@ -77,6 +77,9 @@ func runServer(configPath string) error {
wsCfg := webserver.Config{ wsCfg := webserver.Config{
ServiceName: cfg.MCIAS.ServiceName, ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags, Tags: cfg.MCIAS.Tags,
MciasURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
RedirectURI: cfg.SSO.RedirectURI,
} }
webSrv, err := webserver.New(wsCfg, database, authClient, logger) webSrv, err := webserver.New(wsCfg, database, authClient, logger)
if err != nil { if err != nil {

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcq
go 1.25.7 go 1.25.7
require ( 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/alecthomas/chroma/v2 v2.18.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/mark3labs/mcp-go v0.46.0 github.com/mark3labs/mcp-go v0.46.0

4
go.sum
View File

@@ -1,5 +1,5 @@
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U= git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=

View File

@@ -17,9 +17,17 @@ type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"` Database DatabaseConfig `toml:"database"`
MCIAS mcdslauth.Config `toml:"mcias"` MCIAS mcdslauth.Config `toml:"mcias"`
SSO SSOConfig `toml:"sso"`
Log LogConfig `toml:"log"` 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; // ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
// when empty, MCQ serves plain HTTP (for use behind mc-proxy L7). // when empty, MCQ serves plain HTTP (for use behind mc-proxy L7).
type ServerConfig struct { type ServerConfig struct {

View File

@@ -11,6 +11,7 @@ import (
"git.wntrmute.dev/mc/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf" "git.wntrmute.dev/mc/mcdsl/csrf"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web" "git.wntrmute.dev/mc/mcdsl/web"
mcqweb "git.wntrmute.dev/mc/mcq/web" mcqweb "git.wntrmute.dev/mc/mcq/web"
@@ -25,6 +26,11 @@ const cookieName = "mcq_session"
type Config struct { type Config struct {
ServiceName string ServiceName string
Tags []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. // Server is the MCQ web UI server.
@@ -32,6 +38,7 @@ type Server struct {
db *db.DB db *db.DB
auth *auth.Authenticator auth *auth.Authenticator
csrf *csrf.Protect csrf *csrf.Protect
ssoClient *mcdsso.Client
render *render.Renderer render *render.Renderer
logger *slog.Logger logger *slog.Logger
config Config config Config
@@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger
} }
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token") csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
return &Server{ s := &Server{
db: database, db: database,
auth: authenticator, auth: authenticator,
csrf: csrfProtect, csrf: csrfProtect,
render: render.New(), render: render.New(),
logger: logger, logger: logger,
config: cfg, 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. // RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) { func (s *Server) RegisterRoutes(r chi.Router) {
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.Get("/login", s.handleLoginPage)
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP) r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
}
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP) r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
// Authenticated routes. // Authenticated routes.
@@ -80,6 +110,7 @@ type pageData struct {
Error string Error string
Title string Title string
Content any Content any
SSO bool
} }
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { 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) 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}, s.csrf.TemplateFunc(w))
}
// 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) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName) token := web.GetSessionToken(r, cookieName)
if token != "" { if token != "" {

View File

@@ -7,6 +7,11 @@
</div> </div>
<div class="card"> <div class="card">
{{if .Error}}<div class="error">{{.Error}}</div>{{end}} {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .SSO}}
<div class="form-actions">
<a href="/sso/redirect" style="display:block;text-align:center;text-decoration:none;"><button type="button" style="width:100%" class="btn">Sign in with MCIAS</button></a>
</div>
{{else}}
<form method="POST" action="/login"> <form method="POST" action="/login">
{{csrfField}} {{csrfField}}
<div class="form-group"> <div class="form-group">
@@ -25,5 +30,6 @@
<button type="submit" class="btn">Login</button> <button type="submit" class="btn">Login</button>
</div> </div>
</form> </form>
{{end}}
</div> </div>
{{end}} {{end}}