Files
mcq/internal/webserver/server.go
Kyle Isom b48fcc8465 sso: public MCIAS authorize URL + docs
Add [sso].public_url so the browser SSO authorize redirect uses the
public MCIAS hostname while the code exchange stays on the internal
address (mcdsl v1.9.0). Document the SSO URL split and the rootless-podman
/ unikernel-eligibility rules in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:50 -07:00

252 lines
7.2 KiB
Go

package webserver
import (
"crypto/rand"
"fmt"
"html/template"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
mcqweb "git.wntrmute.dev/mc/mcq/web"
"git.wntrmute.dev/mc/mcq/internal/db"
"git.wntrmute.dev/mc/mcq/internal/render"
)
const cookieName = "mcq_session"
// Config holds webserver-specific configuration.
type Config struct {
ServiceName string
Tags []string
// SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead
// of the direct username/password login form.
MciasURL string // internal MCIAS URL for the server-to-server code exchange
PublicURL string // browser-facing MCIAS URL for the authorize redirect (optional)
CACert string
RedirectURI string
}
// Server is the MCQ web UI server.
type Server struct {
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
ssoClient *mcdsso.Client
render *render.Renderer
logger *slog.Logger
config Config
}
// New creates a web UI server.
func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) {
csrfSecret := make([]byte, 32)
if _, err := rand.Read(csrfSecret); err != nil {
return nil, fmt.Errorf("generate CSRF secret: %w", err)
}
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
s := &Server{
db: database,
auth: authenticator,
csrf: csrfProtect,
render: render.New(),
logger: logger,
config: cfg,
}
// Create SSO client if the service has an SSO redirect_uri configured.
if cfg.RedirectURI != "" {
ssoClient, err := mcdsso.New(mcdsso.Config{
MciasURL: cfg.MciasURL,
PublicURL: cfg.PublicURL,
ClientID: "mcq",
RedirectURI: cfg.RedirectURI,
CACert: cfg.CACert,
})
if err != nil {
return nil, fmt.Errorf("create SSO client: %w", err)
}
s.ssoClient = ssoClient
authorizeURL := cfg.PublicURL
if authorizeURL == "" {
authorizeURL = cfg.MciasURL
}
logger.Info("SSO enabled: redirecting to MCIAS for login",
"authorize_url", authorizeURL, "exchange_url", cfg.MciasURL)
}
return s, nil
}
// RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) {
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.handleLoginPage)
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
}
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
// Authenticated routes.
r.Group(func(r chi.Router) {
r.Use(web.RequireAuth(s.auth, cookieName, "/login"))
r.Use(s.csrf.Middleware)
r.Get("/", s.handleList)
r.Get("/d/{slug}", s.handleRead)
r.Post("/d/{slug}/read", s.handleMarkRead)
r.Post("/d/{slug}/unread", s.handleMarkUnread)
r.Post("/d/{slug}/delete", s.handleDelete)
r.Post("/logout", s.handleLogout)
})
}
type pageData struct {
Username string
Error string
Title string
Content any
SSO bool
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{}, s.csrf.TemplateFunc(w))
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
totpCode := r.FormValue("totp_code")
token, _, err := s.auth.Login(username, password, totpCode)
if err != nil {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{Error: "Invalid credentials"}, s.csrf.TemplateFunc(w))
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, "/", http.StatusFound)
}
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w))
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); 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 := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq")
if err != nil {
s.logger.Error("sso: callback", "error", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName)
if token != "" {
_ = s.auth.Logout(token)
}
web.ClearSessionCookie(w, cookieName)
http.Redirect(w, r, "/login", http.StatusFound)
}
type listData struct {
Username string
Documents []db.Document
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
docs, err := s.db.ListDocuments()
if err != nil {
s.logger.Error("failed to list documents", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if docs == nil {
docs = []db.Document{}
}
web.RenderTemplate(w, mcqweb.FS, "list.html", listData{
Username: info.Username,
Documents: docs,
}, s.csrf.TemplateFunc(w))
}
type readData struct {
Username string
Doc db.Document
HTML template.HTML
}
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
slug := chi.URLParam(r, "slug")
doc, err := s.db.GetDocument(slug)
if err != nil {
http.NotFound(w, r)
return
}
html, err := s.render.Render([]byte(doc.Body))
if err != nil {
s.logger.Error("failed to render markdown", "slug", slug, "error", err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}
web.RenderTemplate(w, mcqweb.FS, "read.html", readData{
Username: info.Username,
Doc: *doc,
HTML: template.HTML(html), //nolint:gosec // markdown rendered by goldmark, not user-controlled injection
}, s.csrf.TemplateFunc(w))
}
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if _, err := s.db.MarkRead(slug); err != nil {
s.logger.Error("failed to mark read", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if _, err := s.db.MarkUnread(slug); err != nil {
s.logger.Error("failed to mark unread", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if err := s.db.DeleteDocument(slug); err != nil {
s.logger.Error("failed to delete document", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}