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:
2026-03-30 15:30:30 -07:00
parent 8c654a5537
commit 18756f62b7
9 changed files with 392 additions and 6 deletions

View File

@@ -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).