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

@@ -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{