From 6be1a09d00f32505078358972b4bc60cfc51501e Mon Sep 17 00:00:00 2001 From: fat Date: Fri, 10 Jul 2026 14:13:11 -0700 Subject: [PATCH] pass jwts around --- packages/code-storage-go/client.go | 25 ++++++-- packages/code-storage-go/client_test.go | 58 +++++++++++++++++-- packages/code-storage-go/types.go | 3 +- .../pierre_storage/client.py | 48 +++++++++++---- .../pierre_storage/types.py | 3 +- .../code-storage-python/tests/test_client.py | 57 ++++++++++++++++-- 6 files changed, 168 insertions(+), 26 deletions(-) diff --git a/packages/code-storage-go/client.go b/packages/code-storage-go/client.go index 3d5aa7e..2e62e0d 100644 --- a/packages/code-storage-go/client.go +++ b/packages/code-storage-go/client.go @@ -23,8 +23,11 @@ const ( // NewClient creates a Git storage client. func NewClient(options Options) (*Client, error) { - if strings.TrimSpace(options.Name) == "" || strings.TrimSpace(options.Key) == "" { - return nil, errors.New("git storage requires a name and key") + if strings.TrimSpace(options.Name) == "" { + return nil, errors.New("git storage requires a name") + } + if strings.TrimSpace(options.Key) == "" && strings.TrimSpace(options.Token) == "" { + return nil, errors.New("git storage requires either a key or a token") } apiBaseURL := options.APIBaseURL @@ -40,15 +43,20 @@ func NewClient(options Options) (*Client, error) { version = DefaultAPIVersion } - privateKey, err := parseECPrivateKey([]byte(options.Key)) - if err != nil { - return nil, err + var privateKey *ecdsa.PrivateKey + if strings.TrimSpace(options.Key) != "" { + var err error + privateKey, err = parseECPrivateKey([]byte(options.Key)) + if err != nil { + return nil, err + } } client := &Client{ options: Options{ Name: options.Name, Key: options.Key, + Token: options.Token, APIBaseURL: apiBaseURL, StorageBaseURL: storageBaseURL, APIVersion: version, @@ -447,6 +455,13 @@ func (c *Client) DeleteGitCredential(ctx context.Context, options DeleteGitCrede } func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, error) { + if strings.TrimSpace(c.options.Token) != "" { + return c.options.Token, nil + } + if c.privateKey == nil { + return "", errors.New("git storage requires a key to generate a JWT") + } + permissions := options.Permissions if len(permissions) == 0 { permissions = []Permission{PermissionGitWrite, PermissionGitRead} diff --git a/packages/code-storage-go/client_test.go b/packages/code-storage-go/client_test.go index 1c5ae5e..7d77e4c 100644 --- a/packages/code-storage-go/client_test.go +++ b/packages/code-storage-go/client_test.go @@ -11,16 +11,66 @@ import ( func TestNewClientValidation(t *testing.T) { _, err := NewClient(Options{}) - if err == nil || !strings.Contains(err.Error(), "requires a name and key") { - t.Fatalf("expected validation error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "requires a name") { + t.Fatalf("expected validation error for missing name, got %v", err) } _, err = NewClient(Options{Name: "", Key: "test"}) if err == nil { t.Fatalf("expected error for empty name") } _, err = NewClient(Options{Name: "test", Key: ""}) - if err == nil { - t.Fatalf("expected error for empty key") + if err == nil || !strings.Contains(err.Error(), "requires either a key or a token") { + t.Fatalf("expected error for missing key and token, got %v", err) + } + _, err = NewClient(Options{Name: "test"}) + if err == nil || !strings.Contains(err.Error(), "requires either a key or a token") { + t.Fatalf("expected error when neither key nor token provided, got %v", err) + } +} + +func TestNewClientWithToken(t *testing.T) { + client, err := NewClient(Options{Name: "acme", Token: "my-pre-minted-jwt"}) + if err != nil { + t.Fatalf("expected no error for token-only client, got %v", err) + } + if client == nil { + t.Fatalf("expected non-nil client") + } +} + +func TestNewClientWithTokenEmptyKey(t *testing.T) { + client, err := NewClient(Options{Name: "acme", Key: "", Token: "my-pre-minted-jwt"}) + if err != nil { + t.Fatalf("expected no error for token with empty key, got %v", err) + } + if client == nil { + t.Fatalf("expected non-nil client") + } +} + +func TestTokenSentVerbatim(t *testing.T) { + expectedToken := "my-pre-minted-jwt-token-value" + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"repo_id":"repo","url":"https://repo.git"}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Token: expectedToken, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + + _, err = client.CreateRepo(nil, CreateRepoOptions{}) + if err != nil { + t.Fatalf("create repo error: %v", err) + } + + expected := "Bearer " + expectedToken + if receivedAuth != expected { + t.Fatalf("expected Authorization header %q, got %q", expected, receivedAuth) } } diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index a3d2b3f..9b0b588 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -22,7 +22,8 @@ const ( // Options configure the Git storage client. type Options struct { Name string - Key string + Key string // required unless Token is set + Token string // pre-minted JWT, used verbatim if set APIBaseURL string StorageBaseURL string APIVersion int diff --git a/packages/code-storage-python/pierre_storage/client.py b/packages/code-storage-python/pierre_storage/client.py index b7d24fa..5d2d824 100644 --- a/packages/code-storage-python/pierre_storage/client.py +++ b/packages/code-storage-python/pierre_storage/client.py @@ -43,24 +43,39 @@ def __init__(self, options: GitStorageOptions) -> None: ValueError: If required options are missing or invalid """ # Validate required fields - if not options or "name" not in options or "key" not in options: + if not options or "name" not in options: raise ValueError( - "GitStorage requires a name and key. Please check your configuration and try again." + "GitStorage requires a name. Please check your configuration and try again." ) name = options["name"] - key = options["key"] - if name is None or key is None: + if name is None: raise ValueError( - "GitStorage requires a name and key. Please check your configuration and try again." + "GitStorage requires a name. Please check your configuration and try again." ) if not isinstance(name, str) or not name.strip(): raise ValueError("GitStorage name must be a non-empty string.") - if not isinstance(key, str) or not key.strip(): - raise ValueError("GitStorage key must be a non-empty string.") + has_key = "key" in options and options.get("key") is not None + has_token = "token" in options and options.get("token") is not None + + if not has_key and not has_token: + raise ValueError( + "GitStorage requires either a key or a token. " + "Please check your configuration and try again." + ) + + if has_key: + key = options["key"] + if not isinstance(key, str) or not key.strip(): + raise ValueError("GitStorage key must be a non-empty string.") + + if has_token: + token = options["token"] + if not isinstance(token, str) or not token.strip(): + raise ValueError("GitStorage token must be a non-empty string.") # Resolve configuration api_base_url = options.get("api_base_url") or self.get_default_api_base_url(name) @@ -72,12 +87,17 @@ def __init__(self, options: GitStorageOptions) -> None: self.options: GitStorageOptions = { "name": name, - "key": key, "api_base_url": api_base_url, "storage_base_url": storage_base_url, "api_version": api_version, } + if has_key: + self.options["key"] = options["key"] + + if has_token: + self.options["token"] = options["token"] + if default_ttl: self.options["default_ttl"] = default_ttl @@ -613,8 +633,16 @@ def _generate_jwt( options: JWT generation options (internal use) Returns: - Signed JWT token + Signed JWT token or pre-minted token if set """ + token = self.options.get("token") + if token: + return token + + key = self.options.get("key") + if not key: + raise ValueError("GitStorage requires a key to generate a JWT.") + permissions = ["git:write", "git:read"] ttl: int = 31536000 # 1 year default ops: Optional[List[str]] = None @@ -637,7 +665,7 @@ def _generate_jwt( ttl = default_ttl return generate_jwt( - self.options["key"], + key, self.options["name"], repo_id, permissions, diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index e2d0512..101d68c 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -34,7 +34,8 @@ class GitStorageOptions(TypedDict, total=False): """Options for GitStorage client.""" name: str # required - key: str # required + key: str # required unless token is set + token: str # pre-minted JWT, used verbatim if set api_base_url: Optional[str] storage_base_url: Optional[str] api_version: Optional[int] diff --git a/packages/code-storage-python/tests/test_client.py b/packages/code-storage-python/tests/test_client.py index 69fabaf..aefbb7b 100644 --- a/packages/code-storage-python/tests/test_client.py +++ b/packages/code-storage-python/tests/test_client.py @@ -27,16 +27,16 @@ def test_store_key(self, git_storage_options: dict, test_key: str) -> None: def test_missing_options(self) -> None: """Test error when options are missing.""" - with pytest.raises(ValueError, match="GitStorage requires a name and key"): + with pytest.raises(ValueError, match="GitStorage requires a name"): GitStorage({}) # type: ignore def test_null_key(self, test_key: str) -> None: - """Test error when key is null.""" - with pytest.raises(ValueError, match="GitStorage requires a name and key"): + """Test error when key is null and no token.""" + with pytest.raises(ValueError, match="GitStorage requires either a key or a token"): GitStorage({"name": "test", "key": None}) # type: ignore def test_empty_key(self) -> None: - """Test error when key is empty.""" + """Test error when key is empty and no token.""" with pytest.raises(ValueError, match="GitStorage key must be a non-empty string"): GitStorage({"name": "test", "key": ""}) @@ -46,7 +46,7 @@ def test_empty_name(self, test_key: str) -> None: GitStorage({"name": "", "key": test_key}) def test_whitespace_key(self) -> None: - """Test error when key is whitespace.""" + """Test error when key is whitespace and no token.""" with pytest.raises(ValueError, match="GitStorage key must be a non-empty string"): GitStorage({"name": "test", "key": " "}) @@ -65,6 +65,53 @@ def test_non_string_name(self, test_key: str) -> None: with pytest.raises(ValueError, match="GitStorage name must be a non-empty string"): GitStorage({"name": 123, "key": test_key}) # type: ignore + def test_create_with_token(self) -> None: + """Test creating GitStorage with token only.""" + storage = GitStorage({"name": "test", "token": "my-pre-minted-jwt"}) + assert storage is not None + assert isinstance(storage, GitStorage) + + def test_empty_token(self) -> None: + """Test error when token is empty.""" + with pytest.raises(ValueError, match="GitStorage token must be a non-empty string"): + GitStorage({"name": "test", "token": ""}) + + def test_whitespace_token(self) -> None: + """Test error when token is whitespace.""" + with pytest.raises(ValueError, match="GitStorage token must be a non-empty string"): + GitStorage({"name": "test", "token": " "}) + + def test_missing_key_and_token(self) -> None: + """Test error when neither key nor token is provided.""" + with pytest.raises(ValueError, match="GitStorage requires either a key or a token"): + GitStorage({"name": "test"}) + + @pytest.mark.asyncio + async def test_token_sent_verbatim(self) -> None: + """Test that a pre-minted token is sent verbatim in the Authorization header.""" + expected_token = "my-pre-minted-jwt-token-value" + storage = GitStorage({ + "name": "test-customer", + "token": expected_token, + "api_base_url": "https://api.test.code.storage", + "storage_base_url": "test.code.storage", + }) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.json.return_value = {"repo_id": "test-repo", "url": "https://test.git"} + + with patch("httpx.AsyncClient") as mock_client: + mock_post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.post = mock_post + + await storage.create_repo(id="test-repo") + + call_kwargs = mock_post.call_args[1] + headers = call_kwargs["headers"] + assert headers["Authorization"] == f"Bearer {expected_token}" + @pytest.mark.asyncio async def test_create_repo(self, git_storage_options: dict) -> None: """Test creating a repository."""