From 8c654a5537c791e1329a04d62b67745a770bf397 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 28 Mar 2026 15:13:27 -0700 Subject: [PATCH] Accept MCIAS JWT tokens as passwords at token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/server/routes.go | 2 +- internal/server/token.go | 31 +++++++++++++- internal/server/token_test.go | 77 +++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/internal/server/routes.go b/internal/server/routes.go index 8ba0b98..36bcfc0 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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) { diff --git a/internal/server/token.go b/internal/server/token.go index f5525bb..3ef020c 100644 --- a/internal/server/token.go +++ b/internal/server/token.go @@ -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") diff --git a/internal/server/token_test.go b/internal/server/token_test.go index f183cc2..dd4bcec 100644 --- a/internal/server/token_test.go +++ b/internal/server/token_test.go @@ -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.