11 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
8c654a5537 Accept MCIAS JWT tokens as passwords at token endpoint
The /v2/token endpoint now detects when the password looks like a JWT
(contains two dots) and validates it directly against MCIAS before
falling back to the standard username+password login flow. This enables
non-interactive registry auth for service accounts — podman login with
a pre-issued MCIAS token as the password.

Follows the personal-access-token pattern used by GHCR, GitLab, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:13:27 -07:00
f51e5edca0 flake: install shell completions for mcrctl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:41:30 -07:00
a69ed648f9 Standardize Makefile docker/push targets for MCR
Add MCR and VERSION variables. Tag images with full MCR registry URL
and version. Add push target. Fix docker target to build both
Dockerfile.api and Dockerfile.web (was only building one).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:05 -07:00
1405424ded Regenerate proto files for mc/ module path
Raw descriptor bytes in .pb.go files were corrupted by the sed-based
module path rename (string length changed, breaking protobuf binary
encoding). Regenerated with protoc to fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:54:31 -07:00
62eecc5240 Bump flake.nix version to match latest tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:16:41 -07:00
25 changed files with 742 additions and 39 deletions

View File

@@ -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

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

View File

@@ -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
'';
}; };
}; };
}; };

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
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

@@ -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) {

View File

@@ -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")

View File

@@ -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.

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).
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).

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}}