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

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 != "" {