Add SSO login support to MCR web UI
MCR can now redirect users to MCIAS for login instead of showing its own login form. This enables passkey/FIDO2 authentication since WebAuthn credentials are bound to MCIAS's domain. - Add optional [sso] config section with redirect_uri - Add handleSSOLogin (redirects to MCIAS) and handleSSOCallback (exchanges code for JWT, validates roles, sets session cookie) - SSO is opt-in: when redirect_uri is empty, the existing login form is used (backward compatible) - Guest role check preserved in SSO callback path - Return-to URL preserved across the SSO redirect - Uses mcdsl/sso package (local replace for now) Security: - State cookie uses SameSite=Lax for cross-site redirect compatibility - Session cookie remains SameSite=Strict (same-site only after login) - Code exchange is server-to-server over TLS 1.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,14 @@ type Config struct {
|
||||
mcdslconfig.Base
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
SSO SSOConfig `toml:"sso"`
|
||||
}
|
||||
|
||||
// SSOConfig holds optional SSO redirect settings. When redirect_uri is
|
||||
// non-empty, the web UI redirects to MCIAS for login instead of showing
|
||||
// its own login form.
|
||||
type SSOConfig struct {
|
||||
RedirectURI string `toml:"redirect_uri"`
|
||||
}
|
||||
|
||||
// StorageConfig holds blob/layer storage settings.
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
)
|
||||
|
||||
// sessionKey is the context key for the session token.
|
||||
@@ -131,6 +133,50 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleSSOLogin redirects the user to MCIAS for SSO login.
|
||||
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcr"); err != nil {
|
||||
log.Printf("sso: redirect to login: %v", 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, "mcr")
|
||||
if err != nil {
|
||||
log.Printf("sso: callback: %v", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token to check roles. Guest accounts are not
|
||||
// permitted to use the web interface.
|
||||
roles, err := s.validateFn(token)
|
||||
if err != nil {
|
||||
log.Printf("sso: token validation failed: %v", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(roles, "guest") {
|
||||
log.Printf("sso: login denied for guest user")
|
||||
http.Error(w, "Guest accounts are not permitted to access the web interface.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "mcr_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogout clears the session and redirects to login.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1"
|
||||
"git.wntrmute.dev/mc/mcr/web"
|
||||
)
|
||||
@@ -35,6 +36,7 @@ type Server struct {
|
||||
loginFn LoginFunc
|
||||
validateFn ValidateFunc
|
||||
csrfKey []byte // 32-byte key for HMAC signing
|
||||
ssoClient *mcdsso.Client
|
||||
}
|
||||
|
||||
// New creates a new web UI server with the given gRPC clients and login function.
|
||||
@@ -46,6 +48,7 @@ func New(
|
||||
loginFn LoginFunc,
|
||||
validateFn ValidateFunc,
|
||||
csrfKey []byte,
|
||||
ssoClient *mcdsso.Client,
|
||||
) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
@@ -61,6 +64,7 @@ func New(
|
||||
loginFn: loginFn,
|
||||
validateFn: validateFn,
|
||||
csrfKey: csrfKey,
|
||||
ssoClient: ssoClient,
|
||||
}
|
||||
|
||||
s.router = s.buildRouter()
|
||||
@@ -89,8 +93,13 @@ func (s *Server) buildRouter() chi.Router {
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes (no session required).
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
if s.ssoClient != nil {
|
||||
r.Get("/login", s.handleSSOLogin)
|
||||
r.Get("/sso/callback", s.handleSSOCallback)
|
||||
} else {
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
}
|
||||
r.Get("/logout", s.handleLogout)
|
||||
|
||||
// Protected routes (session required).
|
||||
|
||||
@@ -213,6 +213,7 @@ func setupTestEnv(t *testing.T) *testEnv {
|
||||
loginFn,
|
||||
validateFn,
|
||||
csrfKey,
|
||||
nil, // SSO client (nil = use direct login form for tests)
|
||||
)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
Reference in New Issue
Block a user