Files
mcr/vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.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

305 lines
8.7 KiB
Go

// Package sso provides an SSO redirect client for Metacircular web services.
//
// Services redirect unauthenticated users to MCIAS for login. After
// authentication, MCIAS redirects back with an authorization code that
// the service exchanges for a JWT token. This package handles the
// redirect, state management, and code exchange.
//
// Security design:
// - State cookies use SameSite=Lax (not Strict) because the redirect from
// MCIAS back to the service is a cross-site navigation.
// - State is a 256-bit random value stored in an HttpOnly cookie.
// - Return-to URLs are stored in a separate cookie so MCIAS never sees them.
// - The code exchange is a server-to-server HTTPS call (TLS 1.3 minimum).
package sso
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
stateBytes = 32 // 256 bits
stateCookieAge = 5 * 60 // 5 minutes in seconds
)
// Config holds the SSO client configuration. The values must match the
// SSO client registration in MCIAS config.
type Config struct {
// MciasURL is the base URL of the MCIAS server.
MciasURL string
// ClientID is the registered SSO client identifier.
ClientID string
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config.
RedirectURI string
// CACert is an optional path to a PEM-encoded CA certificate for
// verifying the MCIAS server's TLS certificate.
CACert string
}
// Client handles the SSO redirect flow with MCIAS.
type Client struct {
cfg Config
httpClient *http.Client
}
// New creates an SSO client. TLS 1.3 is required for all HTTPS
// connections to MCIAS.
func New(cfg Config) (*Client, error) {
if cfg.MciasURL == "" {
return nil, fmt.Errorf("sso: mcias_url is required")
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("sso: client_id is required")
}
if cfg.RedirectURI == "" {
return nil, fmt.Errorf("sso: redirect_uri is required")
}
transport := &http.Transport{}
if !strings.HasPrefix(cfg.MciasURL, "http://") {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.CACert != "" {
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
if err != nil {
return nil, fmt.Errorf("sso: read CA cert %s: %w", cfg.CACert, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("sso: no valid certificates in %s", cfg.CACert)
}
tlsCfg.RootCAs = pool
}
transport.TLSClientConfig = tlsCfg
}
return &Client{
cfg: cfg,
httpClient: &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
},
}, nil
}
// AuthorizeURL returns the MCIAS authorize URL with the given state parameter.
func (c *Client) AuthorizeURL(state string) string {
base := strings.TrimRight(c.cfg.MciasURL, "/")
return base + "/sso/authorize?" + url.Values{
"client_id": {c.cfg.ClientID},
"redirect_uri": {c.cfg.RedirectURI},
"state": {state},
}.Encode()
}
// ExchangeCode exchanges an authorization code for a JWT token by calling
// MCIAS POST /v1/sso/token.
func (c *Client) ExchangeCode(ctx context.Context, code string) (token string, expiresAt time.Time, err error) {
reqBody, _ := json.Marshal(map[string]string{
"code": code,
"client_id": c.cfg.ClientID,
"redirect_uri": c.cfg.RedirectURI,
})
base := strings.TrimRight(c.cfg.MciasURL, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
base+"/v1/sso/token", bytes.NewReader(reqBody))
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: build exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: MCIAS exchange: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: read exchange response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("sso: exchange failed (HTTP %d): %s", resp.StatusCode, body)
}
var result struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", time.Time{}, fmt.Errorf("sso: decode exchange response: %w", err)
}
exp, parseErr := time.Parse(time.RFC3339, result.ExpiresAt)
if parseErr != nil {
exp = time.Now().Add(1 * time.Hour)
}
return result.Token, exp, nil
}
// GenerateState returns a cryptographically random hex-encoded state string.
func GenerateState() (string, error) {
raw := make([]byte, stateBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("sso: generate state: %w", err)
}
return hex.EncodeToString(raw), nil
}
// StateCookieName returns the cookie name used for SSO state for a given
// service cookie prefix (e.g., "mcr" → "mcr_sso_state").
func StateCookieName(prefix string) string {
return prefix + "_sso_state"
}
// ReturnToCookieName returns the cookie name used for SSO return-to URL
// (e.g., "mcr" → "mcr_sso_return").
func ReturnToCookieName(prefix string) string {
return prefix + "_sso_return"
}
// SetStateCookie stores the SSO state in a short-lived cookie.
//
// Security: SameSite=Lax is required because the redirect from MCIAS back to
// the service is a cross-site top-level navigation. SameSite=Strict cookies
// would not be sent on that redirect.
func SetStateCookie(w http.ResponseWriter, prefix, state string) {
http.SetCookie(w, &http.Cookie{
Name: StateCookieName(prefix),
Value: state,
Path: "/",
MaxAge: stateCookieAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ValidateStateCookie compares the state query parameter against the state
// cookie. If they match, the cookie is cleared and nil is returned.
func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, queryState string) error {
c, err := r.Cookie(StateCookieName(prefix))
if err != nil || c.Value == "" {
return fmt.Errorf("sso: missing state cookie")
}
if c.Value != queryState {
return fmt.Errorf("sso: state mismatch")
}
// Clear the state cookie (single-use).
http.SetCookie(w, &http.Cookie{
Name: StateCookieName(prefix),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
return nil
}
// SetReturnToCookie stores the current request path so the service can
// redirect back to it after SSO login completes.
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
path := r.URL.Path
if path == "" || path == "/login" || path == "/sso/callback" {
path = "/"
}
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: path,
Path: "/",
MaxAge: stateCookieAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ConsumeReturnToCookie reads and clears the return-to cookie, returning
// the path. Returns "/" if the cookie is missing or empty.
func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) string {
c, err := r.Cookie(ReturnToCookieName(prefix))
path := "/"
if err == nil && c.Value != "" {
path = c.Value
}
// Clear the cookie.
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
return path
}
// RedirectToLogin generates a state, sets the state and return-to cookies,
// and redirects the user to the MCIAS authorize URL.
func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error {
state, err := GenerateState()
if err != nil {
return err
}
SetStateCookie(w, cookiePrefix, state)
SetReturnToCookie(w, r, cookiePrefix)
http.Redirect(w, r, client.AuthorizeURL(state), http.StatusFound)
return nil
}
// HandleCallback validates the state, exchanges the authorization code for
// a JWT, and returns the token and the return-to path. The caller should
// set the session cookie with the returned token.
func HandleCallback(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) (token, returnTo string, err error) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
return "", "", fmt.Errorf("sso: missing code or state parameter")
}
if err := ValidateStateCookie(w, r, cookiePrefix, state); err != nil {
return "", "", err
}
token, _, err = client.ExchangeCode(r.Context(), code)
if err != nil {
return "", "", err
}
returnTo = ConsumeReturnToCookie(w, r, cookiePrefix)
return token, returnTo, nil
}