Skip to content

Commit eb435f1

Browse files
support GCP Workload Identity Federation for CI
Add support for authenticating with Google Cloud using Workload Identity Federation in addition to traditional service account keys. This enables GitHub Actions and other federated identity providers to authenticate without requiring service account key files.
1 parent 2269b5c commit eb435f1

File tree

2 files changed

+239
-21
lines changed

2 files changed

+239
-21
lines changed

src/pkg/clouds/gcp/account.go

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9-
"log"
9+
"net/http"
10+
"regexp"
1011
"strings"
1112

1213
"golang.org/x/oauth2/google"
@@ -19,45 +20,102 @@ func (gcp Gcp) GetCurrentAccountEmail(ctx context.Context) (string, error) {
1920
if err != nil {
2021
return "", err
2122
}
22-
content := struct {
23-
ClientEmail string `json:"client_email"`
24-
}{}
2523

26-
json.Unmarshal(creds.JSON, &content)
27-
if content.ClientEmail != "" {
28-
return content.ClientEmail, nil
24+
// Unmarshal creds.JSON into a struct that includes both possible fields
25+
var key struct {
26+
ClientEmail string `json:"client_email"`
27+
Type string `json:"type"`
28+
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
29+
}
30+
err = json.Unmarshal(creds.JSON, &key)
31+
if err == nil {
32+
if key.Type == "external_account" {
33+
return parseServiceAccountFromURL(key.ServiceAccountImpersonationURL)
34+
}
35+
if key.Type == "impersonated_service_account" {
36+
return parseServiceAccountFromURL(key.ServiceAccountImpersonationURL)
37+
}
38+
if key.ClientEmail != "" {
39+
return key.ClientEmail, nil
40+
}
2941
}
3042

43+
// Fallback: get token and try to extract email
3144
token, err := creds.TokenSource.Token()
3245
if err != nil {
3346
return "", fmt.Errorf("failed to retrieve token: %w", err)
3447
}
35-
idToken, ok := token.Extra("id_token").(string)
36-
if !ok {
37-
return "", errors.New("failed to retrieve ID token")
48+
49+
// Try to extract email from id_token if present
50+
if idToken, ok := token.Extra("id_token").(string); ok && idToken != "" {
51+
if email, err := extractEmailFromIDToken(idToken); err == nil && email != "" {
52+
return email, nil
53+
}
3854
}
3955

40-
// Split the ID token into its 3 parts: header, payload, and signature
56+
// Last resort: query tokeninfo endpoint
57+
return getEmailFromToken(ctx, token.AccessToken)
58+
}
59+
60+
func extractEmailFromIDToken(idToken string) (string, error) {
61+
// JWT format: header.payload.signature
4162
parts := strings.Split(idToken, ".")
42-
if len(parts) != 3 {
43-
log.Fatalf("invalid ID token format")
63+
if len(parts) < 2 {
64+
return "", errors.New("invalid id_token format")
4465
}
4566

46-
// Decode and Parse the payload
67+
// Decode the payload (second part)
4768
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
4869
if err != nil {
49-
log.Fatalf("failed to decode payload: %v", err)
70+
return "", fmt.Errorf("failed to decode id_token payload: %w", err)
5071
}
51-
claims := struct {
72+
73+
var claims struct {
5274
Email string `json:"email"`
53-
}{}
75+
}
5476
if err := json.Unmarshal(payload, &claims); err != nil {
55-
log.Fatalf("failed to unmarshal payload: %v", err)
77+
return "", fmt.Errorf("failed to unmarshal id_token claims: %w", err)
78+
}
79+
80+
return claims.Email, nil
81+
}
82+
83+
func parseServiceAccountFromURL(url string) (string, error) {
84+
// URL format: https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/EMAIL:generateAccessToken
85+
re := regexp.MustCompile(`serviceAccounts/([^:]+):`)
86+
matches := re.FindStringSubmatch(url)
87+
if len(matches) > 1 {
88+
return matches[1], nil
89+
}
90+
return "", fmt.Errorf("unable to parse service account from URL: %s", url)
91+
}
92+
93+
func getEmailFromToken(ctx context.Context, accessToken string) (string, error) {
94+
url := "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + accessToken
95+
96+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
97+
if err != nil {
98+
return "", err
99+
}
100+
101+
resp, err := http.DefaultClient.Do(req)
102+
if err != nil {
103+
return "", err
104+
}
105+
defer resp.Body.Close()
106+
107+
var tokenInfo struct {
108+
Email string `json:"email"`
109+
VerifiedEmail bool `json:"verified_email"`
110+
}
111+
112+
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
113+
return "", err
56114
}
57115

58-
if claims.Email != "" {
59-
return claims.Email, nil
116+
if tokenInfo.Email == "" {
117+
return "", errors.New("no email found in token info")
60118
}
61119

62-
return "", nil // Should this be an error?
120+
return tokenInfo.Email, nil
63121
}

src/pkg/clouds/gcp/account_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package gcp
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"errors"
7+
"fmt"
58
"testing"
69

710
"golang.org/x/oauth2"
@@ -55,4 +58,161 @@ func TestGetCurrentAccountEmail(t *testing.T) {
5558
t.Errorf("expected email to be %s, got %s", expected, email)
5659
}
5760
})
61+
62+
t.Run("External account with service account impersonation", func(t *testing.T) {
63+
FindGoogleDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
64+
return &google.Credentials{
65+
JSON: []byte(`{
66+
"type": "external_account",
67+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/1234567890/serviceAccounts/[email protected]:generateAccessToken"
68+
}`),
69+
}, nil
70+
}
71+
var gcp Gcp
72+
email, err := gcp.GetCurrentAccountEmail(context.Background())
73+
if err != nil {
74+
t.Errorf("unexpected error: %v", err)
75+
}
76+
expected := "[email protected]"
77+
if email != expected {
78+
t.Errorf("expected email to be %s, got %s", expected, email)
79+
}
80+
})
81+
82+
t.Run("Error getting credentials", func(t *testing.T) {
83+
FindGoogleDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
84+
return nil, errors.New("no credentials found")
85+
}
86+
var gcp Gcp
87+
_, err := gcp.GetCurrentAccountEmail(context.Background())
88+
if err == nil {
89+
t.Error("expected error but got none")
90+
}
91+
})
92+
93+
t.Run("Token error", func(t *testing.T) {
94+
FindGoogleDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
95+
return &google.Credentials{
96+
JSON: []byte(`{}`),
97+
TokenSource: &MockTokenSourceWithError{},
98+
}, nil
99+
}
100+
var gcp Gcp
101+
_, err := gcp.GetCurrentAccountEmail(context.Background())
102+
if err == nil {
103+
t.Error("expected error but got none")
104+
}
105+
})
106+
107+
t.Run("Invalid JSON in credentials", func(t *testing.T) {
108+
token := &oauth2.Token{AccessToken: "test-token"}
109+
FindGoogleDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
110+
return &google.Credentials{
111+
JSON: []byte(`invalid json`),
112+
TokenSource: &MockTokenSource{token: token},
113+
}, nil
114+
}
115+
var gcp Gcp
116+
_, err := gcp.GetCurrentAccountEmail(context.Background())
117+
if err == nil {
118+
t.Error("expected error but got none")
119+
}
120+
})
121+
}
122+
123+
type MockTokenSourceWithError struct{}
124+
125+
func (m *MockTokenSourceWithError) Token() (*oauth2.Token, error) {
126+
return nil, errors.New("token error")
127+
}
128+
129+
func TestExtractEmailFromIDToken(t *testing.T) {
130+
t.Run("Valid ID token", func(t *testing.T) {
131+
header := `{"typ":"JWT","alg":"HS256"}`
132+
payload := `{"email":"[email protected]"}`
133+
idToken := encodeJWT(header, payload)
134+
email, err := extractEmailFromIDToken(idToken)
135+
if err != nil {
136+
t.Errorf("unexpected error: %v", err)
137+
}
138+
expected := "[email protected]"
139+
if email != expected {
140+
t.Errorf("expected email to be %s, got %s", expected, email)
141+
}
142+
})
143+
144+
t.Run("Invalid token format", func(t *testing.T) {
145+
idToken := "invalid.token"
146+
_, err := extractEmailFromIDToken(idToken)
147+
if err == nil {
148+
t.Error("expected error but got none")
149+
}
150+
})
151+
152+
t.Run("Invalid base64 payload", func(t *testing.T) {
153+
idToken := "header.invalid_base64.signature"
154+
_, err := extractEmailFromIDToken(idToken)
155+
if err == nil {
156+
t.Error("expected error but got none")
157+
}
158+
})
159+
160+
t.Run("Invalid JSON payload", func(t *testing.T) {
161+
idToken := encodeJWT("header", "invalid json")
162+
_, err := extractEmailFromIDToken(idToken)
163+
if err == nil {
164+
t.Error("expected error but got none")
165+
}
166+
})
167+
168+
t.Run("Empty email in token", func(t *testing.T) {
169+
// JWT with payload: {"email":""}
170+
header := `{"typ":"JWT","alg":"HS256"}`
171+
payload := `{"email":""}`
172+
idToken := encodeJWT(header, payload)
173+
email, err := extractEmailFromIDToken(idToken)
174+
if err != nil {
175+
t.Errorf("unexpected error: %v", err)
176+
}
177+
if email != "" {
178+
t.Errorf("expected empty email, got %s", email)
179+
}
180+
})
181+
}
182+
183+
func TestParseServiceAccountFromURL(t *testing.T) {
184+
t.Run("Valid service account URL", func(t *testing.T) {
185+
url := "https://iamcredentials.googleapis.com/v1/projects/123456789/serviceAccounts/[email protected]:generateAccessToken"
186+
email, err := parseServiceAccountFromURL(url)
187+
if err != nil {
188+
t.Errorf("unexpected error: %v", err)
189+
}
190+
expected := "[email protected]"
191+
if email != expected {
192+
t.Errorf("expected email to be %s, got %s", expected, email)
193+
}
194+
})
195+
196+
t.Run("Invalid URL format", func(t *testing.T) {
197+
url := "https://invalidpath"
198+
_, err := parseServiceAccountFromURL(url)
199+
if err == nil {
200+
t.Error("expected error but got none")
201+
}
202+
})
203+
204+
t.Run("URL without service account", func(t *testing.T) {
205+
url := "https://iamcredentials.googleapis.com/v1/projects/123456789/notaserviceaccount"
206+
_, err := parseServiceAccountFromURL(url)
207+
if err == nil {
208+
t.Error("expected error but got none")
209+
}
210+
})
211+
}
212+
213+
func encodeJWT(header, payload string) string {
214+
encode := func(s string) string {
215+
return base64.RawURLEncoding.EncodeToString([]byte(s))
216+
}
217+
return fmt.Sprintf("%s.%s.signature", encode(header), encode(payload))
58218
}

0 commit comments

Comments
 (0)