Add SSO login support

MCAT can now redirect users to MCIAS for SSO login (including passkey
support) instead of showing its own login form. SSO is opt-in via the
[sso] config section.

- Add SSO landing page with "Sign in with MCIAS" button
- Add /sso/redirect and /sso/callback routes
- Update mcdsl to v1.5.0 (sso package)
- Fix .gitignore: /mcat ignores only the root binary, not cmd/mcat/
- Track cmd/mcat/ source files (previously gitignored by accident)

Security:
- State cookie uses SameSite=Lax for cross-site redirect compatibility
- Session cookie remains SameSite=Strict after login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 17:19:24 -07:00
parent 7761a5c5a4
commit 190368290b
7 changed files with 216 additions and 18 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
srv/ srv/
mcat /mcat
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm

35
cmd/mcat/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "mcat",
Short: "MCIAS login policy tester",
}
root.AddCommand(serverCmd())
root.AddCommand(versionCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print mcat version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version)
},
}
}

116
cmd/mcat/server.go Normal file
View File

@@ -0,0 +1,116 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcat/internal/webserver"
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/config"
"git.wntrmute.dev/mc/mcdsl/httpserver"
"git.wntrmute.dev/mc/mcdsl/sso"
)
// mcatConfig is the mcat-specific configuration. It embeds config.Base
// for the standard sections (server, mcias, log). mcat has no database
// or additional sections.
type mcatConfig struct {
config.Base
SSO ssoConfig `toml:"sso"`
}
type ssoConfig struct {
RedirectURI string `toml:"redirect_uri"`
}
func serverCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the mcat web server",
RunE: func(_ *cobra.Command, _ []string) error {
return runServer(configPath)
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "mcat.toml", "path to config file")
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load[mcatConfig](configPath, "MCAT")
if err != nil {
return err
}
var logLevel slog.Level
switch cfg.Log.Level {
case "debug":
logLevel = slog.LevelDebug
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
authenticator, err := auth.New(cfg.MCIAS, logger)
if err != nil {
return err
}
// Create SSO client if configured.
var ssoClient *sso.Client
if cfg.SSO.RedirectURI != "" {
ssoClient, err = sso.New(sso.Config{
MciasURL: cfg.MCIAS.ServerURL,
ClientID: cfg.MCIAS.ServiceName,
RedirectURI: cfg.SSO.RedirectURI,
CACert: cfg.MCIAS.CACert,
})
if err != nil {
return err
}
logger.Info("SSO enabled", "mcias", cfg.MCIAS.ServerURL)
}
wsCfg := webserver.Config{
ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags,
}
srv, err := webserver.New(wsCfg, authenticator, logger, ssoClient)
if err != nil {
return err
}
httpSrv := httpserver.New(cfg.Server, logger)
httpSrv.Router.Mount("/", srv.Handler())
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("starting mcat", "addr", cfg.Server.ListenAddr, "version", version)
if err := httpSrv.ListenAndServeTLS(); err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout.Duration)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcat
go 1.25.7 go 1.25.7
require ( require (
git.wntrmute.dev/mc/mcdsl v1.2.0 git.wntrmute.dev/mc/mcdsl v1.5.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )

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.5.0 h1:JUlSYuvETRCycf+cZ56Gxp/1XZn0T7fOfWezM3m89qE=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= git.wntrmute.dev/mc/mcdsl v1.5.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=

View File

@@ -13,6 +13,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"
"git.wntrmute.dev/mc/mcdsl/httpserver" "git.wntrmute.dev/mc/mcdsl/httpserver"
"git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web" "git.wntrmute.dev/mc/mcdsl/web"
mcatweb "git.wntrmute.dev/mc/mcat/web" mcatweb "git.wntrmute.dev/mc/mcat/web"
@@ -33,16 +34,17 @@ type Config struct {
// Server is the mcat web UI server. // Server is the mcat web UI server.
type Server struct { type Server struct {
wsCfg Config wsCfg Config
auth *auth.Authenticator auth *auth.Authenticator
logger *slog.Logger logger *slog.Logger
csrf *csrf.Protect csrf *csrf.Protect
staticFS fs.FS staticFS fs.FS
handler http.Handler handler http.Handler
ssoClient *sso.Client
} }
// New creates a new web UI server. // New creates a new web UI server.
func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) { func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger, ssoClient *sso.Client) (*Server, error) {
staticFS, err := fs.Sub(mcatweb.FS, "static") staticFS, err := fs.Sub(mcatweb.FS, "static")
if err != nil { if err != nil {
return nil, fmt.Errorf("webserver: static fs: %w", err) return nil, fmt.Errorf("webserver: static fs: %w", err)
@@ -54,11 +56,12 @@ func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (
} }
s := &Server{ s := &Server{
wsCfg: wsCfg, wsCfg: wsCfg,
auth: authenticator, auth: authenticator,
logger: logger, logger: logger,
csrf: csrf.New(secret, csrfCookieName, csrfFieldName), csrf: csrf.New(secret, csrfCookieName, csrfFieldName),
staticFS: staticFS, staticFS: staticFS,
ssoClient: ssoClient,
} }
r := chi.NewRouter() r := chi.NewRouter()
@@ -79,8 +82,14 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS)))) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS))))
r.Get("/", s.handleRoot) r.Get("/", s.handleRoot)
r.Get("/login", s.handleLogin) if s.ssoClient != nil {
r.Post("/login", s.handleLogin) r.Get("/login", s.handleSSOLogin)
r.Get("/sso/redirect", s.handleSSORedirect)
r.Get("/sso/callback", s.handleSSOCallback)
} else {
r.Get("/login", s.handleLogin)
r.Post("/login", s.handleLogin)
}
r.Post("/logout", s.requireAuth(s.handleLogout)) r.Post("/logout", s.requireAuth(s.handleLogout))
r.Get("/dashboard", s.requireAuth(s.handleDashboard)) r.Get("/dashboard", s.requireAuth(s.handleDashboard))
} }
@@ -131,6 +140,37 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusFound) http.Redirect(w, r, "/dashboard", http.StatusFound)
} }
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, "login.html", map[string]interface{}{
"SSO": true,
})
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
if err := sso.RedirectToLogin(w, r, s.ssoClient, "mcat"); 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 := sso.HandleCallback(w, r, s.ssoClient, "mcat")
if err != nil {
s.logger.Error("sso: callback", "error", err)
s.renderTemplate(w, "login.html", map[string]interface{}{
"SSO": s.ssoClient != nil,
"Error": "Login failed. Please try again.",
})
return
}
web.SetSessionCookie(w, sessionCookieName, token)
http.Redirect(w, r, returnTo, http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, sessionCookieName) token := web.GetSessionToken(r, sessionCookieName)
if token != "" { if token != "" {

View File

@@ -8,6 +8,12 @@
<div class="card"> <div class="card">
<div class="card-title">Sign In</div> <div class="card-title">Sign In</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}} {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .SSO}}
<p>Sign in to test MCIAS login policies.</p>
<div class="form-actions">
<a href="/sso/redirect" class="btn">Sign in with MCIAS</a>
</div>
{{else}}
<form method="POST" action="/login"> <form method="POST" action="/login">
{{csrfField}} {{csrfField}}
<div class="form-group"> <div class="form-group">
@@ -26,5 +32,6 @@
<button type="submit">Login</button> <button type="submit">Login</button>
</div> </div>
</form> </form>
{{end}}
</div> </div>
{{end}} {{end}}