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>
This commit is contained in:
2026-03-28 15:13:27 -07:00
parent f51e5edca0
commit 8c654a5537
3 changed files with 100 additions and 10 deletions

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.