Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd5142a48a | |||
| 2c3db6ea25 | |||
| 063bdccf1b |
@@ -77,6 +77,9 @@ func runServer(configPath string) error {
|
|||||||
wsCfg := webserver.Config{
|
wsCfg := webserver.Config{
|
||||||
ServiceName: cfg.MCIAS.ServiceName,
|
ServiceName: cfg.MCIAS.ServiceName,
|
||||||
Tags: cfg.MCIAS.Tags,
|
Tags: cfg.MCIAS.Tags,
|
||||||
|
MciasURL: cfg.MCIAS.ServerURL,
|
||||||
|
CACert: cfg.MCIAS.CACert,
|
||||||
|
RedirectURI: cfg.SSO.RedirectURI,
|
||||||
}
|
}
|
||||||
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcq
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.wntrmute.dev/mc/mcdsl v1.2.0
|
git.wntrmute.dev/mc/mcdsl v1.7.0
|
||||||
github.com/alecthomas/chroma/v2 v2.18.0
|
github.com/alecthomas/chroma/v2 v2.18.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/mark3labs/mcp-go v0.46.0
|
github.com/mark3labs/mcp-go v0.46.0
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
|
||||||
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
|
git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ import (
|
|||||||
|
|
||||||
// Config is the MCQ configuration.
|
// Config is the MCQ configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
MCIAS mcdslauth.Config `toml:"mcias"`
|
MCIAS mcdslauth.Config `toml:"mcias"`
|
||||||
Log LogConfig `toml:"log"`
|
SSO SSOConfig `toml:"sso"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSOConfig holds SSO redirect settings for the web UI.
|
||||||
|
type SSOConfig struct {
|
||||||
|
// RedirectURI is the callback URL that MCIAS redirects to after login.
|
||||||
|
// Must exactly match the redirect_uri registered in MCIAS config.
|
||||||
|
RedirectURI string `toml:"redirect_uri"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
|
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
"git.wntrmute.dev/mc/mcdsl/csrf"
|
"git.wntrmute.dev/mc/mcdsl/csrf"
|
||||||
|
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||||
"git.wntrmute.dev/mc/mcdsl/web"
|
"git.wntrmute.dev/mc/mcdsl/web"
|
||||||
|
|
||||||
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
||||||
@@ -25,16 +26,22 @@ const cookieName = "mcq_session"
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
Tags []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
|
||||||
|
CACert string
|
||||||
|
RedirectURI string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the MCQ web UI server.
|
// Server is the MCQ web UI server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
csrf *csrf.Protect
|
csrf *csrf.Protect
|
||||||
render *render.Renderer
|
ssoClient *mcdsso.Client
|
||||||
logger *slog.Logger
|
render *render.Renderer
|
||||||
config Config
|
logger *slog.Logger
|
||||||
|
config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a web UI server.
|
// New creates a web UI server.
|
||||||
@@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger
|
|||||||
}
|
}
|
||||||
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
|
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
|
||||||
|
|
||||||
return &Server{
|
s := &Server{
|
||||||
db: database,
|
db: database,
|
||||||
auth: authenticator,
|
auth: authenticator,
|
||||||
csrf: csrfProtect,
|
csrf: csrfProtect,
|
||||||
render: render.New(),
|
render: render.New(),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Create SSO client if the service has an SSO redirect_uri configured.
|
||||||
|
if cfg.RedirectURI != "" {
|
||||||
|
ssoClient, err := mcdsso.New(mcdsso.Config{
|
||||||
|
MciasURL: cfg.MciasURL,
|
||||||
|
ClientID: "mcq",
|
||||||
|
RedirectURI: cfg.RedirectURI,
|
||||||
|
CACert: cfg.CACert,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create SSO client: %w", err)
|
||||||
|
}
|
||||||
|
s.ssoClient = ssoClient
|
||||||
|
logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes adds web UI routes to the given router.
|
// RegisterRoutes adds web UI routes to the given router.
|
||||||
func (s *Server) RegisterRoutes(r chi.Router) {
|
func (s *Server) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/login", s.handleLoginPage)
|
if s.ssoClient != nil {
|
||||||
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
|
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)
|
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
|
||||||
|
|
||||||
// Authenticated routes.
|
// Authenticated routes.
|
||||||
@@ -80,6 +110,7 @@ type pageData struct {
|
|||||||
Error string
|
Error string
|
||||||
Title string
|
Title string
|
||||||
Content any
|
Content any
|
||||||
|
SSO bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -101,6 +132,32 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusFound)
|
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) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
token := web.GetSessionToken(r, cookieName)
|
token := web.GetSessionToken(r, cookieName)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<div class="brand">mcq</div>
|
<div class="brand">mcq</div>
|
||||||
<div class="tagline">Reading Queue</div>
|
<div class="tagline">Metacircular Reading Queue</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||||
|
{{if .SSO}}
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/sso/redirect" style="display:block;text-align:center;text-decoration:none;"><button type="button" style="width:100%" class="btn">Sign in with MCIAS</button></a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
{{csrfField}}
|
{{csrfField}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -25,5 +30,6 @@
|
|||||||
<button type="submit" class="btn">Login</button>
|
<button type="submit" class="btn">Login</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user