diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index 5cb82d4..b95aee9 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -133,6 +133,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Cargo", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewCargoRegistryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "cargo_registry", + "url": "https://us-central1-cargo.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for cargo registry: https://us-central1-cargo.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-cargo.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Composer // @@ -228,6 +249,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Composer", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewComposerHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "composer_repository", + "registry": "https://us-central1-composer.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for composer repository: https://us-central1-composer.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-composer.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Docker @@ -323,6 +365,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Docker", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewDockerRegistryHandler(creds, &http.Transport{}, nil) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "docker_registry", + "registry": "https://us-central1-docker.pkg.dev", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for docker registry: https://us-central1-docker.pkg.dev", + }, + urlsToAuthenticate: []string{ + "https://us-central1-docker.pkg.dev/some-package", + }, + }, // // Go proxy // @@ -417,6 +480,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Go proxy", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewGoProxyServerHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "goproxy_server", + "url": "https://us-central1-go.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for goproxy server: https://us-central1-go.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-go.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Helm // @@ -511,6 +595,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Helm registry", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewHelmRegistryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "helm_registry", + "registry": "https://us-central1-helm.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for helm registry: https://us-central1-helm.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-helm.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Hex // @@ -605,6 +710,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Hex", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewHexRepositoryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "hex_repository", + "url": "https://us-central1-hex.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for hex repository: https://us-central1-hex.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-hex.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Maven // @@ -699,6 +825,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Maven", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewMavenRepositoryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "maven_repository", + "url": "https://us-central1-maven.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for maven repository: https://us-central1-maven.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-maven.pkg.dev/my-project/my-repo/some-package", + }, + }, // // NPM // @@ -793,6 +940,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "NPM", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewNPMRegistryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "npm_registry", + "url": "https://us-central1-npm.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for npm registry: https://us-central1-npm.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-npm.pkg.dev/my-project/my-repo/some-package", + }, + }, // // NuGet // @@ -919,6 +1087,35 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/v3/packages/some.package/index.json", // package url }, }, + { + name: "NuGet", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewNugetFeedHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{ + { + verb: "GET", + url: "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", + response: `{"version":"3.0.0","resources":[{"@id":"https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + }, + }, + expectedLogLines: []string{ + "registered gcp OIDC credentials for nuget feed: https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", + "registered gcp OIDC credentials for nuget resource: https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages", + }, + urlsToAuthenticate: []string{ + "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", // base url + "https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages/some.package/index.json", // package url + }, + }, // // Pub // @@ -1013,6 +1210,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Pub", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewPubRepositoryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "pub_repository", + "url": "https://us-central1-pub.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for pub repository: https://us-central1-pub.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-pub.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Python // @@ -1107,6 +1325,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Python", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewPythonIndexHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "python_index", + "index-url": "https://us-central1-python.pkg.dev/my-project/my-repo/simple", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for python index: https://us-central1-python.pkg.dev/my-project/my-repo/", + }, + urlsToAuthenticate: []string{ + "https://us-central1-python.pkg.dev/my-project/my-repo/simple/some-package", + }, + }, // // RubyGems // @@ -1203,6 +1442,28 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "RubyGems", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewRubyGemsServerHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "rubygems_server", + "url": "https://us-central1-ruby.pkg.dev/my-project/my-repo", + "host": "https://us-central1-ruby.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for rubygems server: https://us-central1-ruby.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-ruby.pkg.dev/my-project/my-repo/some-package", + }, + }, // // Terraform // @@ -1297,6 +1558,27 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "https://cloudsmith.example.com/some-package", }, }, + { + name: "Terraform", + provider: "gcp", + handlerFactory: func(creds config.Credentials) oidcHandler { + return NewTerraformRegistryHandler(creds) + }, + credentials: config.Credentials{ + config.Credential{ + "type": "terraform_registry", + "url": "https://us-central1-terraform.pkg.dev/my-project/my-repo", + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + urlMocks: []mockHttpRequest{}, + expectedLogLines: []string{ + "registered gcp OIDC credentials for terraform registry: https://us-central1-terraform.pkg.dev/my-project/my-repo", + }, + urlsToAuthenticate: []string{ + "https://us-central1-terraform.pkg.dev/my-project/my-repo/some-package", + }, + }, } for _, tc := range testCases { t.Run(fmt.Sprintf("%s - %s", tc.name, tc.provider), func(t *testing.T) { @@ -1357,6 +1639,13 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { httpmock.NewStringResponder(200, `{ "token": "__test_token__" }`)) + case "gcp": + httpmock.RegisterResponder("POST", "https://sts.googleapis.com/v1/token", + httpmock.NewStringResponder(200, `{ + "access_token": "__test_token__", + "expires_in": 3600, + "token_type": "urn:ietf:params:oauth:token-type:access_token" + }`)) default: t.Fatal("unsupported provider in test case: " + tc.provider) } @@ -1380,10 +1669,20 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { for _, urlToAuth := range tc.urlsToAuthenticate { req := httptest.NewRequest("GET", urlToAuth, nil) req = handleRequestAndClose(handler, req, nil) - if tc.provider == "cloudsmith" { + switch tc.provider { + case "cloudsmith": assert.Equal(t, "__test_token__", req.Header.Get("X-Api-Key"), "package url: "+urlToAuth+" should include Cloudsmith API key") assert.Equal(t, "", req.Header.Get("Authorization"), "package url: "+urlToAuth+" should not include Authorization header for Cloudsmith") - } else { + case "gcp": + if strings.Contains(urlToAuth, "-docker.pkg.dev") { + user, pass, ok := req.BasicAuth() + assert.True(t, ok, "package url: "+urlToAuth+" should use Basic auth for GCP docker") + assert.Equal(t, "oauth2accesstoken", user, "package url: "+urlToAuth+" should use oauth2accesstoken as username") + assert.Equal(t, "__test_token__", pass, "package url: "+urlToAuth+" should include GCP token as password") + } else { + assertHasTokenAuth(t, req, "Bearer", "__test_token__", "package url: "+urlToAuth) + } + default: assertHasTokenAuth(t, req, "Bearer", "__test_token__", "package url: "+urlToAuth) } } diff --git a/internal/oidc/actions_oidc.go b/internal/oidc/actions_oidc.go index 35fe8d2..4f52de5 100644 --- a/internal/oidc/actions_oidc.go +++ b/internal/oidc/actions_oidc.go @@ -187,6 +187,32 @@ type cloudsmithTokenResponse struct { Token string `json:"token"` } +// GCP STS token exchange request body (camelCase per Google's gRPC-transcoded JSON convention) +type gcpSTSTokenRequest struct { + Audience string `json:"audience"` + GrantType string `json:"grantType"` + RequestedTokenType string `json:"requestedTokenType"` + SubjectTokenType string `json:"subjectTokenType"` + SubjectToken string `json:"subjectToken"` + Scope string `json:"scope"` +} + +type gcpSTSTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + IssuedTokenType string `json:"issued_token_type"` +} + +type gcpIAMGenerateAccessTokenRequest struct { + Scope []string `json:"scope"` +} + +type gcpIAMGenerateAccessTokenResponse struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` // RFC3339 (may include fractional seconds) +} + // OIDCAccessToken represents an access token with its expiry information type OIDCAccessToken struct { Token string @@ -653,6 +679,160 @@ func GetCloudsmithAccessTokenForDevOps(ctx context.Context, params CloudsmithOID return cloudsmithToken, nil } +func GetGCPAccessToken(ctx context.Context, params GCPOIDCParameters, githubToken string) (*OIDCAccessToken, error) { + if params.WorkloadIdentityProvider == "" { + return nil, fmt.Errorf("workload-identity-provider is required") + } + if params.Audience == "" { + return nil, fmt.Errorf("audience is required") + } + if githubToken == "" { + return nil, fmt.Errorf("GitHub token is required") + } + + // Step A: STS token exchange (always) + stsReqBody := gcpSTSTokenRequest{ + Audience: params.Audience, + GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", + RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + SubjectToken: githubToken, + Scope: "https://www.googleapis.com/auth/cloud-platform", + } + + stsBodyJSON, err := json.Marshal(stsReqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal GCP STS request: %w", err) + } + + stsReq, err := http.NewRequestWithContext(ctx, "POST", "https://sts.googleapis.com/v1/token", bytes.NewReader(stsBodyJSON)) + if err != nil { + return nil, fmt.Errorf("failed to create GCP STS request: %w", err) + } + + stsReq.Header.Set("Content-Type", "application/json") + stsReq.Header.Set("Accept", "application/json") + stsReq.Header.Set("User-Agent", "dependabot-proxy/1.0") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + stsResp, err := client.Do(stsReq) + if err != nil { + return nil, fmt.Errorf("failed to execute GCP STS request: %w", err) + } + defer stsResp.Body.Close() + + stsBody, err := io.ReadAll(stsResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read GCP STS response body: %w", err) + } + + if stsResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GCP STS returned status %d (audience: %s): %s", stsResp.StatusCode, params.Audience, string(stsBody)) + } + + var stsTokenResp gcpSTSTokenResponse + if err := json.Unmarshal(stsBody, &stsTokenResp); err != nil { + return nil, fmt.Errorf("failed to parse GCP STS response: %w", err) + } + + if stsTokenResp.AccessToken == "" { + return nil, fmt.Errorf("GCP STS response does not contain an access token") + } + + // If no service account, return the federated token directly (direct WIF) + if params.ServiceAccount == "" { + stsExpiry := time.Duration(stsTokenResp.ExpiresIn) * time.Second + if stsExpiry <= 5*time.Minute { + return nil, fmt.Errorf("GCP STS token expires too soon (%v remaining)", stsExpiry) + } + return &OIDCAccessToken{ + Token: stsTokenResp.AccessToken, + ExpiresIn: stsExpiry, + }, nil + } + + // Step B: Service-account impersonation + iamReqBody := gcpIAMGenerateAccessTokenRequest{ + Scope: []string{"https://www.googleapis.com/auth/cloud-platform"}, + } + + iamBodyJSON, err := json.Marshal(iamReqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal GCP IAM request: %w", err) + } + + iamURL := fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", params.ServiceAccount) + iamReq, err := http.NewRequestWithContext(ctx, "POST", iamURL, bytes.NewReader(iamBodyJSON)) + if err != nil { + return nil, fmt.Errorf("failed to create GCP IAM request: %w", err) + } + + iamReq.Header.Set("Content-Type", "application/json") + iamReq.Header.Set("Accept", "application/json") + iamReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", stsTokenResp.AccessToken)) + iamReq.Header.Set("User-Agent", "dependabot-proxy/1.0") + + iamResp, err := client.Do(iamReq) + if err != nil { + return nil, fmt.Errorf("failed to execute GCP IAM request: %w", err) + } + defer iamResp.Body.Close() + + iamBody, err := io.ReadAll(iamResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read GCP IAM response body: %w", err) + } + + if iamResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GCP IAM returned status %d (service-account: %s): %s", iamResp.StatusCode, params.ServiceAccount, string(iamBody)) + } + + var iamTokenResp gcpIAMGenerateAccessTokenResponse + if err := json.Unmarshal(iamBody, &iamTokenResp); err != nil { + return nil, fmt.Errorf("failed to parse GCP IAM response: %w", err) + } + + if iamTokenResp.AccessToken == "" { + return nil, fmt.Errorf("GCP IAM response does not contain an access token") + } + + expireTime, err := time.Parse(time.RFC3339Nano, iamTokenResp.ExpireTime) + if err != nil { + return nil, fmt.Errorf("failed to parse GCP IAM expireTime %q: %w", iamTokenResp.ExpireTime, err) + } + + remaining := time.Until(expireTime) + if remaining <= 5*time.Minute { + return nil, fmt.Errorf("GCP IAM token expires too soon (%v remaining, service-account: %s)", remaining, params.ServiceAccount) + } + + return &OIDCAccessToken{ + Token: iamTokenResp.AccessToken, + ExpiresIn: remaining, + }, nil +} + +func GetGCPAccessTokenForDevOps(ctx context.Context, params GCPOIDCParameters) (*OIDCAccessToken, error) { + if !IsOIDCConfigured() { + return nil, fmt.Errorf("GitHub Actions OIDC is not configured") + } + + // Get GitHub OIDC token + githubToken, err := GetToken(ctx, params.Audience) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub OIDC token: %w", err) + } + + gcpToken, err := GetGCPAccessToken(ctx, params, githubToken) + if err != nil { + return nil, fmt.Errorf("failed to exchange GitHub token for GCP token: %w", err) + } + + return gcpToken, nil +} + func calculateContentSha256Header(payload []byte) string { payloadHash := sha256.Sum256(payload) return hex.EncodeToString(payloadHash[:]) diff --git a/internal/oidc/actions_oidc_test.go b/internal/oidc/actions_oidc_test.go index 6a08df3..92db843 100644 --- a/internal/oidc/actions_oidc_test.go +++ b/internal/oidc/actions_oidc_test.go @@ -1013,3 +1013,321 @@ func TestGetCloudsmithAccessToken(t *testing.T) { }) } } + +func TestGetGCPAccessToken(t *testing.T) { + tests := []struct { + name string + params GCPOIDCParameters + githubToken string + stsHandler http.HandlerFunc + iamHandler http.HandlerFunc + expectError bool + expectedToken string + }{ + { + name: "successful direct WIF (no service account)", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "", r.Header.Get("Authorization")) + + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + + var request gcpSTSTokenRequest + err = json.Unmarshal(bodyBytes, &request) + require.NoError(t, err) + + assert.Equal(t, "urn:ietf:params:oauth:grant-type:token-exchange", request.GrantType) + assert.Equal(t, "urn:ietf:params:oauth:token-type:access_token", request.RequestedTokenType) + assert.Equal(t, "urn:ietf:params:oauth:token-type:jwt", request.SubjectTokenType) + assert.Equal(t, "test-github-jwt-token", request.SubjectToken) + assert.Equal(t, "https://www.googleapis.com/auth/cloud-platform", request.Scope) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + TokenType: "urn:ietf:params:oauth:token-type:access_token", + }) + }, + expectError: false, + expectedToken: "federated-access-token", + }, + { + name: "successful service account impersonation", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + }) + }, + iamHandler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "Bearer federated-access-token", r.Header.Get("Authorization")) + + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + + var request gcpIAMGenerateAccessTokenRequest + err = json.Unmarshal(bodyBytes, &request) + require.NoError(t, err) + assert.Contains(t, request.Scope, "https://www.googleapis.com/auth/cloud-platform") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpIAMGenerateAccessTokenResponse{ + AccessToken: "impersonated-access-token", + ExpireTime: "2099-12-31T23:59:59Z", + }) + }, + expectError: false, + expectedToken: "impersonated-access-token", + }, + { + name: "successful impersonation with fractional-second expireTime", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + }) + }, + iamHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpIAMGenerateAccessTokenResponse{ + AccessToken: "impersonated-nano-token", + ExpireTime: "2099-12-31T23:59:59.999999999Z", + }) + }, + expectError: false, + expectedToken: "impersonated-nano-token", + }, + { + name: "missing workload-identity-provider", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "", + }, + githubToken: "test-github-jwt-token", + expectError: true, + }, + { + name: "missing audience", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "", + }, + githubToken: "test-github-jwt-token", + expectError: true, + }, + { + name: "missing GitHub token", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "", + expectError: true, + }, + { + name: "STS returns 401", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "invalid-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid_grant"}`)) + }, + expectError: true, + }, + { + name: "STS returns invalid JSON", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{invalid json`)) + }, + expectError: true, + }, + { + name: "STS returns empty token", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "", + ExpiresIn: 3600, + }) + }, + expectError: true, + }, + { + name: "STS token expires too soon (direct WIF)", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 0, + }) + }, + expectError: true, + }, + { + name: "STS token at exactly 5 minutes (direct WIF)", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 300, + }) + }, + expectError: true, + }, + { + name: "IAM returns 403", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + }) + }, + iamHandler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":{"code":403,"message":"Permission denied"}}`)) + }, + expectError: true, + }, + { + name: "IAM returns empty token", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + }) + }, + iamHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpIAMGenerateAccessTokenResponse{ + AccessToken: "", + ExpireTime: "2099-12-31T23:59:59Z", + }) + }, + expectError: true, + }, + { + name: "IAM token expires too soon", + params: GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + githubToken: "test-github-jwt-token", + stsHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpSTSTokenResponse{ + AccessToken: "federated-access-token", + ExpiresIn: 3600, + }) + }, + iamHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gcpIAMGenerateAccessTokenResponse{ + AccessToken: "impersonated-access-token", + ExpireTime: "2000-01-01T00:00:00Z", + }) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + if tt.stsHandler != nil { + httpmock.RegisterResponder("POST", "https://sts.googleapis.com/v1/token", + httpmock.Responder(func(req *http.Request) (*http.Response, error) { + rr := httptest.NewRecorder() + tt.stsHandler(rr, req) + return rr.Result(), nil + })) + } + + if tt.iamHandler != nil { + iamURL := fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", tt.params.ServiceAccount) + httpmock.RegisterResponder("POST", iamURL, + httpmock.Responder(func(req *http.Request) (*http.Response, error) { + rr := httptest.NewRecorder() + tt.iamHandler(rr, req) + return rr.Result(), nil + })) + } + + gcpToken, err := GetGCPAccessToken(ctx, tt.params, tt.githubToken) + + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, gcpToken) + assert.Equal(t, tt.expectedToken, gcpToken.Token) + } + }) + } +} diff --git a/internal/oidc/oidc_credential.go b/internal/oidc/oidc_credential.go index a752715..d3d591a 100644 --- a/internal/oidc/oidc_credential.go +++ b/internal/oidc/oidc_credential.go @@ -58,6 +58,16 @@ func (c *CloudsmithOIDCParameters) Name() string { return "cloudsmith" } +type GCPOIDCParameters struct { + WorkloadIdentityProvider string + ServiceAccount string // "" => direct WIF (no impersonation) + Audience string +} + +func (g *GCPOIDCParameters) Name() string { + return "gcp" +} + type OIDCCredential struct { parameters OIDCParameters cachedToken string @@ -97,6 +107,10 @@ func CreateOIDCCredential(cred config.Credential) (*OIDCCredential, error) { serviceSlug := cred.GetString("service-slug") cloudsmithAudience := cred.GetString("audience") + // gcp values + workloadIdentityProvider := cred.GetString("workload-identity-provider") + serviceAccount := cred.GetString("service-account") + switch { case tenantID != "" && clientID != "": parameters = &AzureOIDCParameters{ @@ -141,6 +155,16 @@ func CreateOIDCCredential(cred config.Credential) (*OIDCCredential, error) { ApiHost: apiHost, Audience: cloudsmithAudience, } + case workloadIdentityProvider != "": + audience := cred.GetString("audience") + if audience == "" { + audience = "//iam.googleapis.com/" + workloadIdentityProvider + } + parameters = &GCPOIDCParameters{ + WorkloadIdentityProvider: workloadIdentityProvider, + ServiceAccount: serviceAccount, + Audience: audience, + } } if parameters == nil { @@ -184,6 +208,8 @@ func GetOrRefreshOIDCToken(cred *OIDCCredential, ctx context.Context) (string, e oidcAccessToken, err = GetAWSAccessTokenForDevOps(ctx, *params) case *CloudsmithOIDCParameters: oidcAccessToken, err = GetCloudsmithAccessTokenForDevOps(ctx, *params) + case *GCPOIDCParameters: + oidcAccessToken, err = GetGCPAccessTokenForDevOps(ctx, *params) default: return "", fmt.Errorf("unsupported OIDC provider: %s", cred.Provider()) } diff --git a/internal/oidc/oidc_credential_test.go b/internal/oidc/oidc_credential_test.go index bca5fba..9adf7f8 100644 --- a/internal/oidc/oidc_credential_test.go +++ b/internal/oidc/oidc_credential_test.go @@ -320,6 +320,41 @@ func TestTryCreateOIDCCredential(t *testing.T) { }, nil, }, + { + "gcp with direct WIF (no service account)", + config.Credential{ + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + &GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + { + "gcp with service account impersonation", + config.Credential{ + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + "service-account": "my-sa@my-project.iam.gserviceaccount.com", + }, + &GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "my-sa@my-project.iam.gserviceaccount.com", + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + }, + }, + { + "gcp with explicit audience", + config.Credential{ + "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + "audience": "custom-audience", + }, + &GCPOIDCParameters{ + WorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + ServiceAccount: "", + Audience: "custom-audience", + }, + }, } for _, tc := range tests { @@ -388,6 +423,14 @@ func TestTryCreateOIDCCredential(t *testing.T) { assert.Equal(t, expectedParams.ServiceSlug, p.ServiceSlug) assert.Equal(t, expectedParams.ApiHost, p.ApiHost) assert.Equal(t, expectedParams.Audience, p.Audience) + case *GCPOIDCParameters: + expectedParams, ok := tc.expectedParameters.(*GCPOIDCParameters) + if !ok { + t.Fatalf("expected parameters of type GCPOIDCParameters, but got %T", tc.expectedParameters) + } + assert.Equal(t, expectedParams.WorkloadIdentityProvider, p.WorkloadIdentityProvider) + assert.Equal(t, expectedParams.ServiceAccount, p.ServiceAccount) + assert.Equal(t, expectedParams.Audience, p.Audience) default: t.Fatalf("unexpected parameters type %T", actual.parameters) } diff --git a/internal/oidc/oidc_registry.go b/internal/oidc/oidc_registry.go index 18019e9..ce8f702 100644 --- a/internal/oidc/oidc_registry.go +++ b/internal/oidc/oidc_registry.go @@ -144,6 +144,14 @@ func (r *OIDCRegistry) TryAuth(req *http.Request, ctx *goproxy.ProxyCtx) bool { case *CloudsmithOIDCParameters: logging.RequestLogf(ctx, "* authenticating request with OIDC API key (host: %s)", host) req.Header.Set("X-Api-Key", token) + case *GCPOIDCParameters: + if strings.HasSuffix(host, "-docker.pkg.dev") { + logging.RequestLogf(ctx, "* authenticating request with OIDC oauth2accesstoken (host: %s)", host) + req.SetBasicAuth("oauth2accesstoken", token) + } else { + logging.RequestLogf(ctx, "* authenticating request with OIDC token (host: %s)", host) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + } default: logging.RequestLogf(ctx, "* authenticating request with OIDC token (host: %s)", host) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) diff --git a/internal/oidc/oidc_registry_test.go b/internal/oidc/oidc_registry_test.go index 95aa253..c189868 100644 --- a/internal/oidc/oidc_registry_test.go +++ b/internal/oidc/oidc_registry_test.go @@ -409,6 +409,64 @@ func TestOIDCRegistry_TryAuth_Cloudsmith_UsesAPIKey(t *testing.T) { assert.Empty(t, req.Header.Get("Authorization"), "cloudsmith should not set Authorization") } +func mockGCPOIDC(t *testing.T, token string) { + t.Helper() + httpmock.RegisterResponder("GET", "https://token.actions.example.com", + httpmock.NewStringResponder(200, `{"count": 1, "value": "sometoken"}`)) + httpmock.RegisterResponder("POST", "https://sts.googleapis.com/v1/token", + httpmock.NewStringResponder(200, `{"access_token": "`+token+`", "expires_in": 3600, "token_type": "urn:ietf:params:oauth:token-type:access_token"}`)) +} + +func gcpCred(wip, url string) config.Credential { + return config.Credential{ + "type": "test_registry", + "workload-identity-provider": wip, + "url": url, + } +} + +func TestOIDCRegistry_TryAuth_GCP_UsesBearer(t *testing.T) { + setupOIDCEnv(t) + httpmock.Activate() + defer httpmock.DeactivateAndReset() + mockGCPOIDC(t, "__gcp_token__") + + r := NewOIDCRegistry() + + cred := gcpCred("projects/123/locations/global/workloadIdentityPools/pool/providers/prov", "https://us-central1-python.pkg.dev/my-project/my-repo/simple") + r.Register(cred, []string{"url"}, "test registry") + + req := httptest.NewRequest("GET", "https://us-central1-python.pkg.dev/my-project/my-repo/simple/some-package", nil) + ok := r.TryAuth(req, nil) + + assert.True(t, ok, "GCP OIDC should authenticate") + assert.Equal(t, "Bearer __gcp_token__", req.Header.Get("Authorization"), "GCP non-docker should use Bearer") + assert.Empty(t, req.Header.Get("X-Api-Key"), "GCP should not set X-Api-Key") +} + +func TestOIDCRegistry_TryAuth_GCP_DockerUsesBasicAuth(t *testing.T) { + setupOIDCEnv(t) + httpmock.Activate() + defer httpmock.DeactivateAndReset() + mockGCPOIDC(t, "__gcp_token__") + + r := NewOIDCRegistry() + + cred := gcpCred("projects/123/locations/global/workloadIdentityPools/pool/providers/prov", "https://us-central1-docker.pkg.dev/my-project/my-repo") + r.Register(cred, []string{"url"}, "docker registry") + + req := httptest.NewRequest("GET", "https://us-central1-docker.pkg.dev/my-project/my-repo/v2/some-image/manifests/latest", nil) + ok := r.TryAuth(req, nil) + + assert.True(t, ok, "GCP OIDC should authenticate docker") + // Basic auth: oauth2accesstoken: + user, pass, hasBasic := req.BasicAuth() + assert.True(t, hasBasic, "GCP docker should use Basic auth") + assert.Equal(t, "oauth2accesstoken", user, "GCP docker should use oauth2accesstoken as username") + assert.Equal(t, "__gcp_token__", pass, "GCP docker should use token as password") + assert.Empty(t, req.Header.Get("X-Api-Key"), "GCP should not set X-Api-Key") +} + func TestOIDCRegistry_Register_IndexURLField(t *testing.T) { setupOIDCEnv(t) r := NewOIDCRegistry()