5 Commits

Author SHA1 Message Date
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
10 changed files with 124 additions and 19 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:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcrsrv ./cmd/mcrsrv
@@ -36,7 +38,12 @@ clean:
rm -f mcrsrv mcr-web mcrctl
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
@mkdir -p srv

View File

@@ -10,7 +10,7 @@
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
version = "0.1.0";
version = "1.2.0";
in
{
packages.${system} = {
@@ -27,6 +27,14 @@
"-w"
"-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" +
"\x06status\x18\x01 \x01(\tR\x06status2G\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 (
file_mcr_v1_admin_proto_rawDescOnce sync.Once

View File

@@ -287,7 +287,7 @@ const file_mcr_v1_audit_proto_rawDesc = "" +
"\x17ListAuditEventsResponse\x12*\n" +
"\x06events\x18\x01 \x03(\v2\x12.mcr.v1.AuditEventR\x06events2b\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 (
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" +
"\x11PaginationRequest\x12\x14\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 (
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" +
"\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" +
"\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 (
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" +
"\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" +
"\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 (
file_mcr_v1_registry_proto_rawDescOnce sync.Once

View File

@@ -14,7 +14,7 @@ func NewRouter(validator TokenValidator, loginClient LoginClient, serviceName st
// Token endpoint is NOT behind RequireAuth — clients use Basic auth
// 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.
r.Route("/v2", func(v2 chi.Router) {

View File

@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"net/http"
"strings"
"time"
)
@@ -21,14 +22,40 @@ type tokenResponse struct {
// TokenHandler returns an http.HandlerFunc that exchanges Basic
// 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) {
username, password, ok := r.BasicAuth()
if !ok || username == "" {
if !ok || (username == "" && password == "") {
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "basic authentication required")
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)
if err != nil {
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
}
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) {
t.Helper()
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.SetBasicAuth("alice", "secret")
@@ -49,10 +58,64 @@ func TestTokenHandlerSuccess(t *testing.T) {
}
}
func TestTokenHandlerInvalidCreds(t *testing.T) {
t.Helper()
func TestTokenHandlerJWTAsPassword(t *testing.T) {
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.SetBasicAuth("alice", "wrong")
@@ -74,9 +137,9 @@ func TestTokenHandlerInvalidCreds(t *testing.T) {
}
func TestTokenHandlerMissingAuth(t *testing.T) {
t.Helper()
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)
// No Authorization header.