3 Commits

Author SHA1 Message Date
190368290b 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>
2026-03-30 17:19:24 -07:00
7761a5c5a4 Fix Dockerfile for rootless podman
Remove USER and VOLUME directives (cause layer unpacking failures in
rootless podman). Add ARG VERSION for build-time injection. Follow the
standard mcdoc/mcq Dockerfile pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:26:30 -07:00
2228b27c7c Add Makefile docker/push targets for MCR
Add MCR and VERSION variables, docker target to build the container
image with MCR tagging, and push target to push to MCR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:13 -07:00
9 changed files with 238 additions and 26 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,18 +1,24 @@
FROM golang:1.25-alpine AS builder
ARG VERSION=dev
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" -o mcat ./cmd/mcat
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mcat ./cmd/mcat
FROM alpine:3.21
RUN apk --no-cache add ca-certificates && \
adduser -D -h /srv/mcat mcat
USER mcat
WORKDIR /srv/mcat
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /build/mcat /usr/local/bin/mcat
VOLUME /srv/mcat
WORKDIR /srv/mcat
EXPOSE 8443
ENTRYPOINT ["mcat"]
CMD ["server", "--config", "/srv/mcat/mcat.toml"]

View File

@@ -1,6 +1,8 @@
.PHONY: build test vet lint clean all devserver
.PHONY: build test vet lint clean docker push all devserver
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
MCR := mcr.svc.mcp.metacircular.net:8443
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
mcat:
go build $(LDFLAGS) -o mcat ./cmd/mcat
@@ -20,6 +22,12 @@ lint:
clean:
rm -f mcat
docker:
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mcat:$(VERSION) -f deploy/Dockerfile .
push: docker
docker push $(MCR)/mcat:$(VERSION)
all: vet lint test mcat
devserver: mcat

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
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/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.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
git.wntrmute.dev/mc/mcdsl v1.5.0 h1:JUlSYuvETRCycf+cZ56Gxp/1XZn0T7fOfWezM3m89qE=
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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
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/csrf"
"git.wntrmute.dev/mc/mcdsl/httpserver"
"git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
mcatweb "git.wntrmute.dev/mc/mcat/web"
@@ -33,16 +34,17 @@ type Config struct {
// Server is the mcat web UI server.
type Server struct {
wsCfg Config
auth *auth.Authenticator
logger *slog.Logger
csrf *csrf.Protect
staticFS fs.FS
handler http.Handler
wsCfg Config
auth *auth.Authenticator
logger *slog.Logger
csrf *csrf.Protect
staticFS fs.FS
handler http.Handler
ssoClient *sso.Client
}
// 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")
if err != nil {
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{
wsCfg: wsCfg,
auth: authenticator,
logger: logger,
csrf: csrf.New(secret, csrfCookieName, csrfFieldName),
staticFS: staticFS,
wsCfg: wsCfg,
auth: authenticator,
logger: logger,
csrf: csrf.New(secret, csrfCookieName, csrfFieldName),
staticFS: staticFS,
ssoClient: ssoClient,
}
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.Get("/", s.handleRoot)
r.Get("/login", s.handleLogin)
r.Post("/login", s.handleLogin)
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.handleLogin)
r.Post("/login", s.handleLogin)
}
r.Post("/logout", s.requireAuth(s.handleLogout))
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)
}
// 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) {
token := web.GetSessionToken(r, sessionCookieName)
if token != "" {

View File

@@ -8,6 +8,12 @@
<div class="card">
<div class="card-title">Sign In</div>
{{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">
{{csrfField}}
<div class="form-group">
@@ -26,5 +32,6 @@
<button type="submit">Login</button>
</div>
</form>
{{end}}
</div>
{{end}}