Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fe52afbb2 | |||
| bf206ae67c | |||
| 8eeab91cbd | |||
| 908aaed168 | |||
| 18756f62b7 | |||
| 8c654a5537 | |||
| f51e5edca0 | |||
| a69ed648f9 | |||
| 1405424ded | |||
| 62eecc5240 |
13
Makefile
13
Makefile
@@ -1,6 +1,8 @@
|
|||||||
.PHONY: build test vet lint proto proto-lint clean docker all devserver
|
.PHONY: build test vet lint proto proto-lint clean docker push all devserver
|
||||||
|
|
||||||
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
|
MCR := mcr.svc.mcp.metacircular.net:8443
|
||||||
|
VERSION := $(shell git describe --tags --always --dirty)
|
||||||
|
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
|
||||||
|
|
||||||
mcrsrv:
|
mcrsrv:
|
||||||
CGO_ENABLED=0 go build $(LDFLAGS) -o mcrsrv ./cmd/mcrsrv
|
CGO_ENABLED=0 go build $(LDFLAGS) -o mcrsrv ./cmd/mcrsrv
|
||||||
@@ -36,7 +38,12 @@ clean:
|
|||||||
rm -f mcrsrv mcr-web mcrctl
|
rm -f mcrsrv mcr-web mcrctl
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) -t mcr -f Dockerfile .
|
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mcr:$(VERSION) -f Dockerfile.api .
|
||||||
|
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mcr-web:$(VERSION) -f Dockerfile.web .
|
||||||
|
|
||||||
|
push: docker
|
||||||
|
docker push $(MCR)/mcr:$(VERSION)
|
||||||
|
docker push $(MCR)/mcr-web:$(VERSION)
|
||||||
|
|
||||||
devserver: mcrsrv
|
devserver: mcrsrv
|
||||||
@mkdir -p srv
|
@mkdir -p srv
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
56
cmd/mcrctl/config.go
Normal file
56
cmd/mcrctl/config.go
Normal 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")
|
||||||
|
}
|
||||||
91
cmd/mcrctl/config_test.go
Normal file
91
cmd/mcrctl/config_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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) {
|
||||||
|
// Empty path triggers default search; missing file is not an error.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
7
deploy/examples/mcrctl.toml
Normal file
7
deploy/examples/mcrctl.toml
Normal 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
|
||||||
10
flake.nix
10
flake.nix
@@ -10,7 +10,7 @@
|
|||||||
let
|
let
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
version = "0.1.0";
|
version = "1.2.0";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages.${system} = {
|
packages.${system} = {
|
||||||
@@ -27,6 +27,14 @@
|
|||||||
"-w"
|
"-w"
|
||||||
"-X main.version=${version}"
|
"-X main.version=${version}"
|
||||||
];
|
];
|
||||||
|
postInstall = ''
|
||||||
|
mkdir -p $out/share/zsh/site-functions
|
||||||
|
mkdir -p $out/share/bash-completion/completions
|
||||||
|
mkdir -p $out/share/fish/vendor_completions.d
|
||||||
|
$out/bin/mcrctl completion zsh > $out/share/zsh/site-functions/_mcrctl
|
||||||
|
$out/bin/mcrctl completion bash > $out/share/bash-completion/completions/mcrctl
|
||||||
|
$out/bin/mcrctl completion fish > $out/share/fish/vendor_completions.d/mcrctl.fish
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const file_mcr_v1_admin_proto_rawDesc = "" +
|
|||||||
"\x0eHealthResponse\x12\x16\n" +
|
"\x0eHealthResponse\x12\x16\n" +
|
||||||
"\x06status\x18\x01 \x01(\tR\x06status2G\n" +
|
"\x06status\x18\x01 \x01(\tR\x06status2G\n" +
|
||||||
"\fAdminService\x127\n" +
|
"\fAdminService\x127\n" +
|
||||||
"\x06Health\x12\x15.mcr.v1.HealthRequest\x1a\x16.mcr.v1.HealthResponseB,Z*git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
"\x06Health\x12\x15.mcr.v1.HealthRequest\x1a\x16.mcr.v1.HealthResponseB*Z(git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mcr_v1_admin_proto_rawDescOnce sync.Once
|
file_mcr_v1_admin_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ const file_mcr_v1_audit_proto_rawDesc = "" +
|
|||||||
"\x17ListAuditEventsResponse\x12*\n" +
|
"\x17ListAuditEventsResponse\x12*\n" +
|
||||||
"\x06events\x18\x01 \x03(\v2\x12.mcr.v1.AuditEventR\x06events2b\n" +
|
"\x06events\x18\x01 \x03(\v2\x12.mcr.v1.AuditEventR\x06events2b\n" +
|
||||||
"\fAuditService\x12R\n" +
|
"\fAuditService\x12R\n" +
|
||||||
"\x0fListAuditEvents\x12\x1e.mcr.v1.ListAuditEventsRequest\x1a\x1f.mcr.v1.ListAuditEventsResponseB,Z*git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
"\x0fListAuditEvents\x12\x1e.mcr.v1.ListAuditEventsRequest\x1a\x1f.mcr.v1.ListAuditEventsResponseB*Z(git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mcr_v1_audit_proto_rawDescOnce sync.Once
|
file_mcr_v1_audit_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const file_mcr_v1_common_proto_rawDesc = "" +
|
|||||||
"\x13mcr/v1/common.proto\x12\x06mcr.v1\"A\n" +
|
"\x13mcr/v1/common.proto\x12\x06mcr.v1\"A\n" +
|
||||||
"\x11PaginationRequest\x12\x14\n" +
|
"\x11PaginationRequest\x12\x14\n" +
|
||||||
"\x05limit\x18\x01 \x01(\x05R\x05limit\x12\x16\n" +
|
"\x05limit\x18\x01 \x01(\x05R\x05limit\x12\x16\n" +
|
||||||
"\x06offset\x18\x02 \x01(\x05R\x06offsetB,Z*git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
"\x06offset\x18\x02 \x01(\x05R\x06offsetB*Z(git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mcr_v1_common_proto_rawDescOnce sync.Once
|
file_mcr_v1_common_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ const file_mcr_v1_policy_proto_rawDesc = "" +
|
|||||||
"\x10CreatePolicyRule\x12\x1f.mcr.v1.CreatePolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12A\n" +
|
"\x10CreatePolicyRule\x12\x1f.mcr.v1.CreatePolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12A\n" +
|
||||||
"\rGetPolicyRule\x12\x1c.mcr.v1.GetPolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12G\n" +
|
"\rGetPolicyRule\x12\x1c.mcr.v1.GetPolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12G\n" +
|
||||||
"\x10UpdatePolicyRule\x12\x1f.mcr.v1.UpdatePolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12U\n" +
|
"\x10UpdatePolicyRule\x12\x1f.mcr.v1.UpdatePolicyRuleRequest\x1a\x12.mcr.v1.PolicyRule\x12U\n" +
|
||||||
"\x10DeletePolicyRule\x12\x1f.mcr.v1.DeletePolicyRuleRequest\x1a .mcr.v1.DeletePolicyRuleResponseB,Z*git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
"\x10DeletePolicyRule\x12\x1f.mcr.v1.DeletePolicyRuleRequest\x1a .mcr.v1.DeletePolicyRuleResponseB*Z(git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mcr_v1_policy_proto_rawDescOnce sync.Once
|
file_mcr_v1_policy_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -812,7 +812,7 @@ const file_mcr_v1_registry_proto_rawDesc = "" +
|
|||||||
"\rGetRepository\x12\x1c.mcr.v1.GetRepositoryRequest\x1a\x1d.mcr.v1.GetRepositoryResponse\x12U\n" +
|
"\rGetRepository\x12\x1c.mcr.v1.GetRepositoryRequest\x1a\x1d.mcr.v1.GetRepositoryResponse\x12U\n" +
|
||||||
"\x10DeleteRepository\x12\x1f.mcr.v1.DeleteRepositoryRequest\x1a .mcr.v1.DeleteRepositoryResponse\x12O\n" +
|
"\x10DeleteRepository\x12\x1f.mcr.v1.DeleteRepositoryRequest\x1a .mcr.v1.DeleteRepositoryResponse\x12O\n" +
|
||||||
"\x0eGarbageCollect\x12\x1d.mcr.v1.GarbageCollectRequest\x1a\x1e.mcr.v1.GarbageCollectResponse\x12F\n" +
|
"\x0eGarbageCollect\x12\x1d.mcr.v1.GarbageCollectRequest\x1a\x1e.mcr.v1.GarbageCollectResponse\x12F\n" +
|
||||||
"\vGetGCStatus\x12\x1a.mcr.v1.GetGCStatusRequest\x1a\x1b.mcr.v1.GetGCStatusResponseB,Z*git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
"\vGetGCStatus\x12\x1a.mcr.v1.GetGCStatusRequest\x1a\x1b.mcr.v1.GetGCStatusResponseB*Z(git.wntrmute.dev/mc/mcr/gen/mcr/v1;mcrv1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mcr_v1_registry_proto_rawDescOnce sync.Once
|
file_mcr_v1_registry_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func NewRouter(validator TokenValidator, loginClient LoginClient, serviceName st
|
|||||||
|
|
||||||
// Token endpoint is NOT behind RequireAuth — clients use Basic auth
|
// Token endpoint is NOT behind RequireAuth — clients use Basic auth
|
||||||
// here to obtain a bearer token.
|
// here to obtain a bearer token.
|
||||||
r.Get("/v2/token", TokenHandler(loginClient))
|
r.Get("/v2/token", TokenHandler(loginClient, validator))
|
||||||
|
|
||||||
// All other /v2 endpoints require a valid bearer token.
|
// All other /v2 endpoints require a valid bearer token.
|
||||||
r.Route("/v2", func(v2 chi.Router) {
|
r.Route("/v2", func(v2 chi.Router) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,14 +22,40 @@ type tokenResponse struct {
|
|||||||
|
|
||||||
// TokenHandler returns an http.HandlerFunc that exchanges Basic
|
// TokenHandler returns an http.HandlerFunc that exchanges Basic
|
||||||
// credentials for a bearer token via the given LoginClient.
|
// credentials for a bearer token via the given LoginClient.
|
||||||
func TokenHandler(loginClient LoginClient) http.HandlerFunc {
|
//
|
||||||
|
// If the password looks like a JWT (contains two dots), the handler
|
||||||
|
// first tries to validate it directly via the TokenValidator. This
|
||||||
|
// allows service accounts to authenticate with a pre-issued MCIAS
|
||||||
|
// token as the password, following the personal-access-token pattern
|
||||||
|
// used by GitHub Container Registry, GitLab, etc. If JWT validation
|
||||||
|
// fails, the handler falls through to the standard username+password
|
||||||
|
// login flow.
|
||||||
|
func TokenHandler(loginClient LoginClient, validator TokenValidator) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
username, password, ok := r.BasicAuth()
|
username, password, ok := r.BasicAuth()
|
||||||
if !ok || username == "" {
|
if !ok || (username == "" && password == "") {
|
||||||
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "basic authentication required")
|
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "basic authentication required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the password looks like a JWT, try validating it directly.
|
||||||
|
// This enables non-interactive auth for service accounts.
|
||||||
|
if strings.Count(password, ".") == 2 {
|
||||||
|
if _, err := validator.ValidateToken(password); err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(tokenResponse{
|
||||||
|
Token: password,
|
||||||
|
IssuedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
token, expiresIn, err := loginClient.Login(username, password)
|
token, expiresIn, err := loginClient.Login(username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication failed")
|
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication failed")
|
||||||
|
|||||||
@@ -19,10 +19,19 @@ func (f *fakeLoginClient) Login(_, _ string) (string, int, error) {
|
|||||||
return f.token, f.expiresIn, f.err
|
return f.token, f.expiresIn, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeTokenValidator struct {
|
||||||
|
claims *auth.Claims
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTokenValidator) ValidateToken(_ string) (*auth.Claims, error) {
|
||||||
|
return f.claims, f.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestTokenHandlerSuccess(t *testing.T) {
|
func TestTokenHandlerSuccess(t *testing.T) {
|
||||||
t.Helper()
|
|
||||||
lc := &fakeLoginClient{token: "tok-xyz", expiresIn: 7200}
|
lc := &fakeLoginClient{token: "tok-xyz", expiresIn: 7200}
|
||||||
handler := TokenHandler(lc)
|
tv := &fakeTokenValidator{err: auth.ErrUnauthorized}
|
||||||
|
handler := TokenHandler(lc, tv)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
||||||
req.SetBasicAuth("alice", "secret")
|
req.SetBasicAuth("alice", "secret")
|
||||||
@@ -49,10 +58,64 @@ func TestTokenHandlerSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenHandlerInvalidCreds(t *testing.T) {
|
func TestTokenHandlerJWTAsPassword(t *testing.T) {
|
||||||
t.Helper()
|
|
||||||
lc := &fakeLoginClient{err: auth.ErrUnauthorized}
|
lc := &fakeLoginClient{err: auth.ErrUnauthorized}
|
||||||
handler := TokenHandler(lc)
|
tv := &fakeTokenValidator{claims: &auth.Claims{
|
||||||
|
Subject: "mcp-agent",
|
||||||
|
AccountType: "system",
|
||||||
|
Roles: nil,
|
||||||
|
}}
|
||||||
|
handler := TokenHandler(lc, tv)
|
||||||
|
|
||||||
|
jwt := "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0In0.c2lnbmF0dXJl"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
||||||
|
req.SetBasicAuth("x", jwt)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp tokenResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Token != jwt {
|
||||||
|
t.Fatalf("token: got %q, want JWT pass-through", resp.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandlerJWTFallsBackToLogin(t *testing.T) {
|
||||||
|
lc := &fakeLoginClient{token: "login-tok", expiresIn: 3600}
|
||||||
|
tv := &fakeTokenValidator{err: auth.ErrUnauthorized}
|
||||||
|
handler := TokenHandler(lc, tv)
|
||||||
|
|
||||||
|
// Password looks like a JWT but validator rejects it — should fall through to login.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
||||||
|
req.SetBasicAuth("alice", "not.a.jwt")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp tokenResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Token != "login-tok" {
|
||||||
|
t.Fatalf("token: got %q, want %q (login fallback)", resp.Token, "login-tok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandlerInvalidCreds(t *testing.T) {
|
||||||
|
lc := &fakeLoginClient{err: auth.ErrUnauthorized}
|
||||||
|
tv := &fakeTokenValidator{err: auth.ErrUnauthorized}
|
||||||
|
handler := TokenHandler(lc, tv)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
||||||
req.SetBasicAuth("alice", "wrong")
|
req.SetBasicAuth("alice", "wrong")
|
||||||
@@ -74,9 +137,9 @@ func TestTokenHandlerInvalidCreds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenHandlerMissingAuth(t *testing.T) {
|
func TestTokenHandlerMissingAuth(t *testing.T) {
|
||||||
t.Helper()
|
|
||||||
lc := &fakeLoginClient{token: "should-not-matter"}
|
lc := &fakeLoginClient{token: "should-not-matter"}
|
||||||
handler := TokenHandler(lc)
|
tv := &fakeTokenValidator{err: auth.ErrUnauthorized}
|
||||||
|
handler := TokenHandler(lc, tv)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
|
||||||
// No Authorization header.
|
// No Authorization header.
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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).
|
||||||
r.Get("/login", s.handleLoginPage)
|
if s.ssoClient != nil {
|
||||||
r.Post("/login", s.handleLoginSubmit)
|
r.Get("/login", s.handleSSOLogin)
|
||||||
|
r.Get("/sso/redirect", s.handleSSORedirect)
|
||||||
|
r.Get("/sso/callback", s.handleSSOCallback)
|
||||||
|
} else {
|
||||||
|
r.Get("/login", s.handleLoginPage)
|
||||||
|
r.Post("/login", s.handleLoginSubmit)
|
||||||
|
}
|
||||||
r.Get("/logout", s.handleLogout)
|
r.Get("/logout", s.handleLogout)
|
||||||
|
|
||||||
// Protected routes (session required).
|
// Protected routes (session required).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
304
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
Normal file
304
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
Normal 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
3
vendor/modules.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user