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

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)
}