Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user