6 Commits

Author SHA1 Message Date
1f3bcd6b69 Fix gRPC auth: inject bearer token via PerRPCCredentials
The gRPC client was not sending the authorization header, causing
"missing authorization header" errors even when a token was configured.
Also fix config test to isolate from real ~/.config/mcrctl.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:39:00 -07:00
0fe52afbb2 Add TOML config file support to mcrctl
Loads defaults from ~/.config/mcrctl.toml (or XDG_CONFIG_HOME).
Resolution order: flag > env (MCR_TOKEN) > config file.
Adds --config flag to specify an explicit path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:02:23 -07:00
bf206ae67c Bump mcdsl to v1.6.0 (SSO redirect fix)
Fixes SSO login redirect loop where the return-to cookie stored
/sso/redirect, bouncing users back to MCIAS after successful login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:55:46 -07:00
8eeab91cbd Show SSO landing page instead of immediate redirect
The login page now shows the service name and a "Sign in with MCIAS"
button instead of immediately redirecting to MCIAS. This lets the user
know what service they are logging into before the redirect.

- GET /login renders the landing page with SSO button
- GET /sso/redirect initiates the actual SSO redirect
- Non-SSO login form still works when SSO is not configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:40:25 -07:00
908aaed168 Use mcdsl v1.5.0 release (remove replace directive)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:34:04 -07:00
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
15 changed files with 618 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1"
"git.wntrmute.dev/mc/mcr/internal/auth" "git.wntrmute.dev/mc/mcr/internal/auth"
"git.wntrmute.dev/mc/mcr/internal/config" "git.wntrmute.dev/mc/mcr/internal/config"
@@ -113,8 +114,23 @@ func runServer(configPath string) error {
return fmt.Errorf("generate CSRF key: %w", err) 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. // 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 { if err != nil {
return fmt.Errorf("create web server: %w", err) return fmt.Errorf("create web server: %w", err)
} }

View File

@@ -62,10 +62,14 @@ func newClient(serverURL, grpcAddr, token, caCertFile string) (*apiClient, error
if grpcAddr != "" { if grpcAddr != "" {
creds := credentials.NewTLS(tlsCfg) creds := credentials.NewTLS(tlsCfg)
cc, err := grpc.NewClient(grpcAddr, dialOpts := []grpc.DialOption{
grpc.WithTransportCredentials(creds), grpc.WithTransportCredentials(creds),
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})), grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
) }
if token != "" {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(bearerToken(token)))
}
cc, err := grpc.NewClient(grpcAddr, dialOpts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("grpc dial: %w", err) return nil, fmt.Errorf("grpc dial: %w", err)
} }
@@ -91,6 +95,16 @@ func (c *apiClient) useGRPC() bool {
return c.grpcConn != nil return c.grpcConn != nil
} }
// bearerToken implements grpc.PerRPCCredentials, injecting the
// Authorization header into every gRPC call.
type bearerToken string
func (t bearerToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + string(t)}, nil
}
func (t bearerToken) RequireTransportSecurity() bool { return true }
// apiError is the JSON error envelope returned by the REST API. // apiError is the JSON error envelope returned by the REST API.
type apiError struct { type apiError struct {
Error string `json:"error"` Error string `json:"error"`

56
cmd/mcrctl/config.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"fmt"
"os"
"path/filepath"
toml "github.com/pelletier/go-toml/v2"
)
// config holds CLI defaults loaded from a TOML file.
type config struct {
Server string `toml:"server"`
GRPC string `toml:"grpc"`
Token string `toml:"token"`
CACert string `toml:"ca_cert"`
}
// loadConfig reads a TOML config file from the given path. If path is
// empty it searches the default location (~/.config/mcrctl.toml). A
// missing file at the default location is not an error — an explicit
// --config path that doesn't exist is.
func loadConfig(path string) (config, error) {
explicit := path != ""
if !explicit {
path = defaultConfigPath()
}
data, err := os.ReadFile(path) //nolint:gosec // operator-supplied config path
if err != nil {
if !explicit && os.IsNotExist(err) {
return config{}, nil
}
return config{}, fmt.Errorf("reading config: %w", err)
}
var cfg config
if err := toml.Unmarshal(data, &cfg); err != nil {
return config{}, fmt.Errorf("parsing config %s: %w", path, err)
}
return cfg, nil
}
// defaultConfigPath returns ~/.config/mcrctl.toml, respecting
// XDG_CONFIG_HOME if set.
func defaultConfigPath() string {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "mcrctl.toml"
}
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "mcrctl.toml")
}

93
cmd/mcrctl/config_test.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigExplicitPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcrctl.toml")
data := []byte(`server = "https://reg.example.com"
grpc = "reg.example.com:9443"
token = "file-token"
ca_cert = "/path/to/ca.pem"
`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
if cfg.Server != "https://reg.example.com" {
t.Errorf("Server = %q, want %q", cfg.Server, "https://reg.example.com")
}
if cfg.GRPC != "reg.example.com:9443" {
t.Errorf("GRPC = %q, want %q", cfg.GRPC, "reg.example.com:9443")
}
if cfg.Token != "file-token" {
t.Errorf("Token = %q, want %q", cfg.Token, "file-token")
}
if cfg.CACert != "/path/to/ca.pem" {
t.Errorf("CACert = %q, want %q", cfg.CACert, "/path/to/ca.pem")
}
}
func TestLoadConfigMissingDefaultIsOK(t *testing.T) {
// Point XDG_CONFIG_HOME at an empty dir so we don't find the real config.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
if cfg.Server != "" || cfg.GRPC != "" || cfg.Token != "" {
t.Errorf("expected zero config from missing default, got %+v", cfg)
}
}
func TestLoadConfigMissingExplicitIsError(t *testing.T) {
_, err := loadConfig("/nonexistent/mcrctl.toml")
if err == nil {
t.Fatal("expected error for missing explicit config path")
}
}
func TestLoadConfigInvalidTOML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.toml")
if err := os.WriteFile(path, []byte("not valid [[[ toml"), 0o600); err != nil {
t.Fatal(err)
}
_, err := loadConfig(path)
if err == nil {
t.Fatal("expected error for invalid TOML")
}
}
func TestLoadConfigPartial(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcrctl.toml")
data := []byte(`server = "https://reg.example.com"
`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
if cfg.Server != "https://reg.example.com" {
t.Errorf("Server = %q, want %q", cfg.Server, "https://reg.example.com")
}
if cfg.GRPC != "" {
t.Errorf("GRPC should be empty, got %q", cfg.GRPC)
}
}

View File

@@ -18,6 +18,7 @@ var version = "dev"
// Global flags, resolved in PersistentPreRunE. // Global flags, resolved in PersistentPreRunE.
var ( var (
flagConfig string
flagServer string flagServer string
flagGRPC string flagGRPC string
flagToken string flagToken string
@@ -32,15 +33,27 @@ func main() {
Use: "mcrctl", Use: "mcrctl",
Short: "Metacircular Container Registry admin CLI", Short: "Metacircular Container Registry admin CLI",
Version: version, Version: version,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error { PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// Resolve token: flag overrides env. cfg, err := loadConfig(flagConfig)
token := flagToken if err != nil {
if token == "" { return err
token = os.Getenv("MCR_TOKEN")
} }
var err error // Resolution order: flag > env > config file.
client, err = newClient(flagServer, flagGRPC, token, flagCACert) server := applyDefault(cmd, "server", flagServer, cfg.Server)
grpcAddr := applyDefault(cmd, "grpc", flagGRPC, cfg.GRPC)
caCert := applyDefault(cmd, "ca-cert", flagCACert, cfg.CACert)
token := flagToken
if !cmd.Flags().Changed("token") {
if envToken := os.Getenv("MCR_TOKEN"); envToken != "" {
token = envToken
} else if cfg.Token != "" {
token = cfg.Token
}
}
client, err = newClient(server, grpcAddr, token, caCert)
if err != nil { if err != nil {
return err return err
} }
@@ -53,9 +66,10 @@ func main() {
}, },
} }
root.PersistentFlags().StringVar(&flagConfig, "config", "", "config file (default ~/.config/mcrctl.toml)")
root.PersistentFlags().StringVar(&flagServer, "server", "", "REST API base URL (e.g. https://registry.example.com)") root.PersistentFlags().StringVar(&flagServer, "server", "", "REST API base URL (e.g. https://registry.example.com)")
root.PersistentFlags().StringVar(&flagGRPC, "grpc", "", "gRPC server address (e.g. registry.example.com:9443)") root.PersistentFlags().StringVar(&flagGRPC, "grpc", "", "gRPC server address (e.g. registry.example.com:9443)")
root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env)") root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env, config file)")
root.PersistentFlags().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file") root.PersistentFlags().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table")
@@ -740,6 +754,18 @@ func runSnapshot(_ *cobra.Command, _ []string) error {
// ---------- helpers ---------- // ---------- helpers ----------
// applyDefault returns the flag value if the flag was explicitly set on
// the command line, otherwise falls back to the config file value.
func applyDefault(cmd *cobra.Command, name, flagVal, cfgVal string) string {
if cmd.Flags().Changed(name) {
return flagVal
}
if cfgVal != "" {
return cfgVal
}
return flagVal
}
// confirmPrompt displays msg and waits for y/n from stdin. // confirmPrompt displays msg and waits for y/n from stdin.
func confirmPrompt(msg string) bool { func confirmPrompt(msg string) bool {
fmt.Fprintf(os.Stderr, "%s [y/N] ", msg) fmt.Fprintf(os.Stderr, "%s [y/N] ", msg)

View File

@@ -0,0 +1,7 @@
# mcrctl — Metacircular Container Registry CLI configuration.
# Copy to ~/.config/mcrctl.toml and edit.
server = "https://registry.example.com:8443" # REST API base URL
grpc = "registry.example.com:9443" # gRPC server address (optional)
token = "" # bearer token (MCR_TOKEN env overrides)
ca_cert = "" # custom CA certificate PEM file

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcr
go 1.25.7 go 1.25.7
require ( require (
git.wntrmute.dev/mc/mcdsl v1.2.0 git.wntrmute.dev/mc/mcdsl v1.6.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2

6
go.sum
View File

@@ -1,5 +1,7 @@
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U= git.wntrmute.dev/mc/mcdsl v1.5.0 h1:JUlSYuvETRCycf+cZ56Gxp/1XZn0T7fOfWezM3m89qE=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= git.wntrmute.dev/mc/mcdsl v1.5.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
git.wntrmute.dev/mc/mcdsl v1.6.0 h1:Vn1uy6b1yZ4Y8fsl1+kLucVprrFKlQ4SN2cjUH/Eg2k=
git.wntrmute.dev/mc/mcdsl v1.6.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

View File

@@ -14,6 +14,14 @@ type Config struct {
mcdslconfig.Base mcdslconfig.Base
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"` 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. // StorageConfig holds blob/layer storage settings.

View File

@@ -10,6 +10,8 @@ import (
"net/http" "net/http"
"slices" "slices"
"strings" "strings"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
) )
// sessionKey is the context key for the session token. // sessionKey is the context key for the session token.
@@ -131,6 +133,59 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// handleSSOLogin renders a landing page with a "Sign in" button that
// initiates the SSO redirect to MCIAS.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
s.templates.render(w, "login", map[string]any{
"SSO": true,
"Session": false,
})
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (s *Server) handleSSORedirect(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. // handleLogout clears the session and redirects to login.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{

View File

@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1"
"git.wntrmute.dev/mc/mcr/web" "git.wntrmute.dev/mc/mcr/web"
) )
@@ -35,6 +36,7 @@ type Server struct {
loginFn LoginFunc loginFn LoginFunc
validateFn ValidateFunc validateFn ValidateFunc
csrfKey []byte // 32-byte key for HMAC signing 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. // New creates a new web UI server with the given gRPC clients and login function.
@@ -46,6 +48,7 @@ func New(
loginFn LoginFunc, loginFn LoginFunc,
validateFn ValidateFunc, validateFn ValidateFunc,
csrfKey []byte, csrfKey []byte,
ssoClient *mcdsso.Client,
) (*Server, error) { ) (*Server, error) {
tmpl, err := loadTemplates() tmpl, err := loadTemplates()
if err != nil { if err != nil {
@@ -61,6 +64,7 @@ func New(
loginFn: loginFn, loginFn: loginFn,
validateFn: validateFn, validateFn: validateFn,
csrfKey: csrfKey, csrfKey: csrfKey,
ssoClient: ssoClient,
} }
s.router = s.buildRouter() s.router = s.buildRouter()
@@ -89,8 +93,14 @@ func (s *Server) buildRouter() chi.Router {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Public routes (no session required). // Public routes (no session required).
if s.ssoClient != nil {
r.Get("/login", s.handleSSOLogin)
r.Get("/sso/redirect", s.handleSSORedirect)
r.Get("/sso/callback", s.handleSSOCallback)
} else {
r.Get("/login", s.handleLoginPage) r.Get("/login", s.handleLoginPage)
r.Post("/login", s.handleLoginSubmit) r.Post("/login", s.handleLoginSubmit)
}
r.Get("/logout", s.handleLogout) r.Get("/logout", s.handleLogout)
// Protected routes (session required). // Protected routes (session required).

View File

@@ -213,6 +213,7 @@ func setupTestEnv(t *testing.T) *testEnv {
loginFn, loginFn,
validateFn, validateFn,
csrfKey, csrfKey,
nil, // SSO client (nil = use direct login form for tests)
) )
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
@@ -244,8 +245,8 @@ func TestLoginPageRenders(t *testing.T) {
} }
body := rec.Body.String() body := rec.Body.String()
if !strings.Contains(body, "MCR Login") { if !strings.Contains(body, "Metacircular Container Registry") {
t.Error("login page does not contain 'MCR Login'") t.Error("login page does not contain 'Metacircular Container Registry'")
} }
if !strings.Contains(body, "_csrf") { if !strings.Contains(body, "_csrf") {
t.Error("login page does not contain CSRF token field") t.Error("login page does not contain CSRF token field")

View File

@@ -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" || strings.HasPrefix(path, "/sso/") {
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
}

3
vendor/modules.txt vendored
View File

@@ -1,9 +1,10 @@
# git.wntrmute.dev/mc/mcdsl v1.2.0 # git.wntrmute.dev/mc/mcdsl v1.6.0
## explicit; go 1.25.7 ## explicit; go 1.25.7
git.wntrmute.dev/mc/mcdsl/auth git.wntrmute.dev/mc/mcdsl/auth
git.wntrmute.dev/mc/mcdsl/config git.wntrmute.dev/mc/mcdsl/config
git.wntrmute.dev/mc/mcdsl/db git.wntrmute.dev/mc/mcdsl/db
git.wntrmute.dev/mc/mcdsl/grpcserver git.wntrmute.dev/mc/mcdsl/grpcserver
git.wntrmute.dev/mc/mcdsl/sso
# github.com/dustin/go-humanize v1.0.1 # github.com/dustin/go-humanize v1.0.1
## explicit; go 1.16 ## explicit; go 1.16
github.com/dustin/go-humanize github.com/dustin/go-humanize

View File

@@ -2,10 +2,14 @@
{{define "content"}} {{define "content"}}
<div class="login-container"> <div class="login-container">
<h1>MCR Login</h1> <h1>Metacircular Container Registry</h1>
{{if .Error}} {{if .Error}}
<div class="error">{{.Error}}</div> <div class="error">{{.Error}}</div>
{{end}} {{end}}
{{if .SSO}}
<p>Sign in to manage container images, policies, and audit logs.</p>
<a href="/sso/redirect" class="btn">Sign in with MCIAS</a>
{{else}}
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}"> <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div class="form-group"> <div class="form-group">
@@ -18,5 +22,6 @@
</div> </div>
<button type="submit">Sign In</button> <button type="submit">Sign In</button>
</form> </form>
{{end}}
</div> </div>
{{end}} {{end}}