Files
mcr/internal/webserver/auth.go
Kyle Isom 18756f62b7 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>
2026-03-30 15:30:30 -07:00

257 lines
6.9 KiB
Go

package webserver
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"slices"
"strings"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
)
// sessionKey is the context key for the session token.
type sessionKey struct{}
// tokenFromContext retrieves the bearer token from context.
func tokenFromContext(ctx context.Context) string {
s, _ := ctx.Value(sessionKey{}).(string)
return s
}
// contextWithToken stores a bearer token in the context.
func contextWithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, sessionKey{}, token)
}
// sessionMiddleware checks for a valid mcr_session cookie and adds the
// token to the request context. If no session is present, it redirects
// to the login page.
func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("mcr_session")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := contextWithToken(r.Context(), cookie.Value)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// handleLoginPage renders the login form.
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"CSRFToken": csrf,
"Session": false,
})
}
// handleLoginSubmit processes the login form.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.validateCSRFToken(r) {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Invalid or expired form submission. Please try again.",
"CSRFToken": csrf,
"Session": false,
})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Username and password are required.",
"CSRFToken": csrf,
"Session": false,
})
return
}
token, _, err := s.loginFn(username, password)
if err != nil {
log.Printf("login failed for user %q: %v", username, err)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Invalid username or password.",
"CSRFToken": csrf,
"Session": false,
})
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("login token validation failed for user %q: %v", username, err)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Login failed. Please try again.",
"CSRFToken": csrf,
"Session": false,
})
return
}
if slices.Contains(roles, "guest") {
log.Printf("login denied for guest user %q", username)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Guest accounts are not permitted to access the web interface.",
"CSRFToken": csrf,
"Session": false,
})
return
}
http.SetCookie(w, &http.Cookie{
Name: "mcr_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
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{
Name: "mcr_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// generateCSRFToken creates a random token, signs it with HMAC, stores
// the signed value in a cookie, and returns the token for form embedding.
func (s *Server) generateCSRFToken(w http.ResponseWriter) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Crypto RNG failure is fatal; this should never happen.
log.Printf("csrf: failed to generate random bytes: %v", err)
return ""
}
token := hex.EncodeToString(b)
sig := s.signCSRF(token)
cookieVal := token + "." + sig
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: cookieVal,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return token
}
// validateCSRFToken verifies the form _csrf field matches the cookie and
// the HMAC signature is valid.
func (s *Server) validateCSRFToken(r *http.Request) bool {
formToken := r.FormValue("_csrf")
if formToken == "" {
return false
}
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value == "" {
return false
}
parts := strings.SplitN(cookie.Value, ".", 2)
if len(parts) != 2 {
return false
}
cookieToken := parts[0]
cookieSig := parts[1]
// Verify the form token matches the cookie token.
if !hmac.Equal([]byte(formToken), []byte(cookieToken)) {
return false
}
// Verify the HMAC signature.
expectedSig := s.signCSRF(cookieToken)
return hmac.Equal([]byte(cookieSig), []byte(expectedSig))
}
// signCSRF computes an HMAC-SHA256 signature for a CSRF token.
func (s *Server) signCSRF(token string) string {
mac := hmac.New(sha256.New, s.csrfKey)
mac.Write([]byte(token))
return hex.EncodeToString(mac.Sum(nil))
}