Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 190368290b | |||
| 7761a5c5a4 | |||
| 2228b27c7c | |||
| c65ad856a7 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
srv/
|
||||
mcat
|
||||
/mcat
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -6,9 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
mcat is a lightweight web application for testing MCIAS login policies. It presents a login form, forwards credentials (with configurable `service_name` and `tags`) to MCIAS, and shows whether the login was accepted or denied by policy. Single binary, no database, no gRPC.
|
||||
|
||||
Module path: `git.wntrmute.dev/kyle/mcat`
|
||||
Module path: `git.wntrmute.dev/mc/mcat`
|
||||
|
||||
MCIAS client library: `git.wntrmute.dev/kyle/mcias/clients/go` (imported as `mcias`), local replace directive in go.mod.
|
||||
MCIAS client library: `git.wntrmute.dev/mc/mcias/clients/go` (imported as `mcias`), local replace directive in go.mod.
|
||||
|
||||
## Build Commands
|
||||
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -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"]
|
||||
|
||||
12
Makefile
12
Makefile
@@ -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
35
cmd/mcat/main.go
Normal 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
116
cmd/mcat/server.go
Normal 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)
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -1,9 +1,9 @@
|
||||
module git.wntrmute.dev/kyle/mcat
|
||||
module git.wntrmute.dev/mc/mcat
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/kyle/mcdsl v1.0.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
4
go.sum
@@ -1,5 +1,5 @@
|
||||
git.wntrmute.dev/kyle/mcdsl v1.0.0 h1:YB7dx4gdNYKKcVySpL6UkwHqdCJ9Nl1yS0+eHk0hNtk=
|
||||
git.wntrmute.dev/kyle/mcdsl v1.0.0/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg=
|
||||
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=
|
||||
|
||||
@@ -10,12 +10,13 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcdsl/auth"
|
||||
"git.wntrmute.dev/kyle/mcdsl/csrf"
|
||||
"git.wntrmute.dev/kyle/mcdsl/httpserver"
|
||||
"git.wntrmute.dev/kyle/mcdsl/web"
|
||||
"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/kyle/mcat/web"
|
||||
mcatweb "git.wntrmute.dev/mc/mcat/web"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,10 +40,11 @@ type Server struct {
|
||||
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)
|
||||
@@ -59,6 +61,7 @@ func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (
|
||||
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)
|
||||
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 != "" {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user