7 Commits

Author SHA1 Message Date
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
19 changed files with 516 additions and 26 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

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

4
go.sum
View File

@@ -1,5 +1,5 @@
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=
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,50 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// handleSSOLogin redirects the user to MCIAS for SSO login.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcr"); err != nil {
log.Printf("sso: redirect to login: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcr")
if err != nil {
log.Printf("sso: callback: %v", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
// Validate the token to check roles. Guest accounts are not
// permitted to use the web interface.
roles, err := s.validateFn(token)
if err != nil {
log.Printf("sso: token validation failed: %v", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
if slices.Contains(roles, "guest") {
log.Printf("sso: login denied for guest user")
http.Error(w, "Guest accounts are not permitted to access the web interface.", http.StatusForbidden)
return
}
http.SetCookie(w, &http.Cookie{
Name: "mcr_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
// handleLogout clears the session and redirects to login. // 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,13 @@ 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/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()

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" || path == "/sso/callback" {
path = "/"
}
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: path,
Path: "/",
MaxAge: stateCookieAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ConsumeReturnToCookie reads and clears the return-to cookie, returning
// the path. Returns "/" if the cookie is missing or empty.
func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) string {
c, err := r.Cookie(ReturnToCookieName(prefix))
path := "/"
if err == nil && c.Value != "" {
path = c.Value
}
// Clear the cookie.
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
return path
}
// RedirectToLogin generates a state, sets the state and return-to cookies,
// and redirects the user to the MCIAS authorize URL.
func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error {
state, err := GenerateState()
if err != nil {
return err
}
SetStateCookie(w, cookiePrefix, state)
SetReturnToCookie(w, r, cookiePrefix)
http.Redirect(w, r, client.AuthorizeURL(state), http.StatusFound)
return nil
}
// HandleCallback validates the state, exchanges the authorization code for
// a JWT, and returns the token and the return-to path. The caller should
// set the session cookie with the returned token.
func HandleCallback(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) (token, returnTo string, err error) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
return "", "", fmt.Errorf("sso: missing code or state parameter")
}
if err := ValidateStateCookie(w, r, cookiePrefix, state); err != nil {
return "", "", err
}
token, _, err = client.ExchangeCode(r.Context(), code)
if err != nil {
return "", "", err
}
returnTo = ConsumeReturnToCookie(w, r, cookiePrefix)
return token, returnTo, nil
}

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