diff --git a/cmd/mcr-web/main.go b/cmd/mcr-web/main.go index 755d536..53e3721 100644 --- a/cmd/mcr-web/main.go +++ b/cmd/mcr-web/main.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" + mcdsso "git.wntrmute.dev/mc/mcdsl/sso" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" "git.wntrmute.dev/mc/mcr/internal/auth" "git.wntrmute.dev/mc/mcr/internal/config" @@ -113,8 +114,23 @@ func runServer(configPath string) error { return fmt.Errorf("generate CSRF key: %w", err) } + // Create SSO client if the service has an SSO redirect_uri configured. + var ssoClient *mcdsso.Client + if cfg.SSO.RedirectURI != "" { + ssoClient, err = mcdsso.New(mcdsso.Config{ + MciasURL: cfg.MCIAS.ServerURL, + ClientID: cfg.MCIAS.ServiceName, + RedirectURI: cfg.SSO.RedirectURI, + CACert: cfg.MCIAS.CACert, + }) + if err != nil { + return fmt.Errorf("create SSO client: %w", err) + } + log.Printf("SSO enabled: redirecting to %s for login", cfg.MCIAS.ServerURL) + } + // Create web server. - srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey) + srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey, ssoClient) if err != nil { return fmt.Errorf("create web server: %w", err) } diff --git a/go.mod b/go.mod index 5aa8057..a8c3eaf 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module git.wntrmute.dev/mc/mcr go 1.25.7 +replace git.wntrmute.dev/mc/mcdsl => ../mcdsl + require ( git.wntrmute.dev/mc/mcdsl v1.2.0 github.com/go-chi/chi/v5 v5.2.5 diff --git a/go.sum b/go.sum index 03b3e72..dcd41b6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U= -git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/config/config.go b/internal/config/config.go index a849706..6eb6e47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/webserver/auth.go b/internal/webserver/auth.go index a5acf47..115b655 100644 --- a/internal/webserver/auth.go +++ b/internal/webserver/auth.go @@ -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{ diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 2c9a876..5a25571 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -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). diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go index 6de9e1e..1642665 100644 --- a/internal/webserver/server_test.go +++ b/internal/webserver/server_test.go @@ -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() diff --git a/vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go b/vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go new file mode 100644 index 0000000..d2e17dd --- /dev/null +++ b/vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go @@ -0,0 +1,304 @@ +// 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 +} diff --git a/vendor/modules.txt b/vendor/modules.txt index abd9b69..1c0ac77 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,9 +1,10 @@ -# git.wntrmute.dev/mc/mcdsl v1.2.0 +# git.wntrmute.dev/mc/mcdsl v1.2.0 => ../mcdsl ## explicit; go 1.25.7 git.wntrmute.dev/mc/mcdsl/auth git.wntrmute.dev/mc/mcdsl/config git.wntrmute.dev/mc/mcdsl/db git.wntrmute.dev/mc/mcdsl/grpcserver +git.wntrmute.dev/mc/mcdsl/sso # github.com/dustin/go-humanize v1.0.1 ## explicit; go 1.16 github.com/dustin/go-humanize @@ -197,3 +198,4 @@ modernc.org/memory modernc.org/sqlite modernc.org/sqlite/lib modernc.org/sqlite/vtab +# git.wntrmute.dev/mc/mcdsl => ../mcdsl