-
-
Save niger-prequel/07e997cdb75d559d9945653a314eb717 to your computer and use it in GitHub Desktop.
AWS to GCP IAM Federation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"bytes" | |
"context" | |
"encoding/json" | |
"fmt" | |
"io" | |
"log" | |
"net/http" | |
"net/url" | |
"strings" | |
"time" | |
"github.com/aws/aws-sdk-go-v2/aws" | |
"github.com/aws/aws-sdk-go-v2/aws/signer/v4" | |
"github.com/aws/aws-sdk-go-v2/config" | |
// "github.com/aws/aws-sdk-go-v2/credentials" | |
"github.com/aws/aws-sdk-go-v2/service/iam/types" | |
"github.com/aws/aws-sdk-go-v2/service/sts" | |
"golang.org/x/oauth2" | |
gsts "google.golang.org/api/sts/v1" | |
) | |
const AWS_TEMP_KEY_TTL_SECONDS = 3600 | |
type CredentialSource struct { | |
EnvironmentID string `json:"environment_id"` | |
RegionURL string `json:"region_url"` | |
URL string `json:"url"` | |
RegionalCredVerificationURL string `json:"regional_cred_verification_url"` | |
} | |
type GcpExternalAccountMetadata struct { | |
Type string `json:"type"` | |
Audience string `json:"audience"` | |
SubjectTokenType string `json:"subject_token_type"` | |
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` | |
TokenURL string `json:"token_url"` | |
CredentialSource CredentialSource `json:"credential_source"` | |
} | |
func main() { | |
awsRoleArn := "arn:aws:iam::123456789012:role/ChangnesiaVictim" | |
awsRegion := "us-space-1" | |
gcpServiceAccountEmail := "dean-a-ling@GreendaleCommunityCollege.iam.gserviceaccount.com" | |
gcpExternalAccountMetadata := &GcpExternalAccountMetadata{} | |
// replace scopes with whatever GCP scopes you'd like the aws role to be able to call when it assumes the GCP sevice account | |
scopes := []string{"https://www.googleapis.com/auth/devstorage.read_only"} | |
// Read the Client Library Config file downloaded from the GCP Work Identity Pool Console | |
jsonFile, err := os.Open("client_library_config.json") | |
if err != nil { | |
log.Fatalf("Error opening JSON file: %v", err) | |
} | |
defer jsonFile.Close() | |
// Read the file's content into a byte array | |
byteValue, err := ioutil.ReadAll(jsonFile) | |
if err != nil { | |
log.Fatalf("Error reading JSON file: %v", err) | |
} | |
// Unmarshal the JSON data into the struct | |
err = json.Unmarshal(byteValue, &gcpExternalAccountMetadata) | |
if err != nil { | |
log.Fatalf("Error unmarshaling JSON: %v", err) | |
} | |
// Generate a Google Oauth token source | |
ts, err := GenerateDelegatedGcpCredentials(awsRoleArn, awsRegion, gcpServiceAccountEmail, *gcpExternalAccountMetadata, scopes) | |
if err != nil { | |
log.Fatalf("Error generating federated token source: %v", err) | |
} | |
// you can initialize most GCP Golang clients with a token source | |
// by providing an option `option.WithTokenSource` to the client initializer | |
// most of the time you will need need to manually call `Token()` as below | |
tok, err := ts.Token() | |
if err != nil { | |
log.Fatalf("Error acquiring access token: %v", err) | |
} | |
log.Printf("Access Token: %v\n", tok) | |
} | |
func GenerateDelegatedGcpCredentials(awsRoleArn string, awsRegion string, gcpServiceAccountEmail string, gcpExternalAccountMetadata *GcpExternalAccountMetadata, scopes []string) (oauth2.TokenSource, error) { | |
optFns := []func(*config.LoadOptions) error{ | |
config.WithRegion(awsRegion), | |
// use below if authenticating to AWS with static credentials | |
// config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyId, secretKey, sessionToken)) | |
} | |
cfg, err := config.LoadDefaultConfig(context.TODO(), optFns...) | |
if err != nil { | |
return nil, fmt.Errorf("Error loading AWS config: %w", err) | |
} | |
stsClient := sts.NewFromConfig(cfg) | |
tempCredentials, err := stsClient.AssumeRole(context.TODO(), &sts.AssumeRoleInput{ | |
RoleArn: &awsRoleArn, | |
RoleSessionName: aws.String("my-session-name"), | |
DurationSeconds: aws.Int32(AWS_TEMP_KEY_TTL_SECONDS), | |
}) | |
var ts oauth2.TokenSource = awsGCPBridgeTokenSource{ | |
awsCredentials: tempCredentials, | |
scopes: scopes, | |
region: awsRegion, | |
externalMetadata: gcpExternalAccountMetadata, | |
} | |
return ts, nil | |
} | |
func (ts awsGCPBridgeTokenSource) Token() (*oauth2.Token, error) { | |
targetResource := ts.externalMetadata.Audience | |
subjectToken, err := getSerializedCallerIdentityToken(*ts.awsCredentials.Credentials.AccessKeyId, *ts.awsCredentials.Credentials.SecretAccessKey, *ts.awsCredentials.Credentials.SessionToken, ts.region, targetResource) | |
if err != nil { | |
return nil, fmt.Errorf("Error getting serialized caller identity token: %w", err) | |
} | |
requestBody := gsts.GoogleIdentityStsV1ExchangeTokenRequest{ | |
// Name: name, | |
Audience: ts.externalMetadata.Audience, | |
GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", | |
SubjectToken: subjectToken, | |
SubjectTokenType: ts.externalMetadata.SubjectTokenType, | |
RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", | |
Scope: strings.Join(ts.scopes, " "), | |
} | |
reqBytes, err := json.Marshal(requestBody) | |
if err != nil { | |
return nil, err | |
} | |
resp, err := http.Post(ts.externalMetadata.TokenURL, "application/json", bytes.NewBuffer(reqBytes)) | |
if err != nil { | |
return nil, err | |
} | |
defer resp.Body.Close() | |
body, err := io.ReadAll(resp.Body) | |
if err != nil { | |
return nil, fmt.Errorf("Error Shepherd response: %w", err) | |
} | |
if resp.StatusCode != http.StatusOK { | |
return nil, fmt.Errorf("failed to exchange token, status: %s with message: %s", resp.Status, string(body)) | |
} | |
var tokenResponse gsts.GoogleIdentityStsV1ExchangeTokenResponse | |
if err := json.Unmarshal(body, &tokenResponse); err != nil { | |
return nil, err | |
} | |
now := time.Now() | |
return &oauth2.Token{ | |
AccessToken: tokenResponse.AccessToken, | |
TokenType: "Bearer", | |
Expiry: now.Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), | |
}, nil | |
} | |
func getSerializedCallerIdentityToken(accessKey string, secretKey string, sessionToken string, region string, targetResource string) (string, error) { | |
// builds payload according to subjectToken instructions https://cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token?apix_params=%7B%22resource%22%3A%7B%22audience%22%3A%22%2F%2Fiam.googleapis.com%2Fprojects%2F161374094453%2Flocations%2Fglobal%2FworkloadIdentityPools%2Ftest-aws%2Fproviders%2Fprequel%22%2C%22name%22%3A%22projects%2F-%2Flocations%2Fglobal%2FworkloadIdentityPools%2Ftest-aws%2Fproviders%2Fprequel%22%2C%22grantType%22%3A%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange%22%2C%22requestedTokenType%22%3A%22urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token%22%2C%22subjectTokenType%22%3A%22urn%3Aietf%3Aparams%3Aaws%3Atoken-type%3Aaws4_request%22%2C%22subjectToken%22%3A%22POST%20%2F%20HTTP%2F1.1%5Cnhost%3Asts.us-west-1.amazonaws.com%5Cn%5CnAction%3DGetCallerIdentity%26Version%3D2011-06-15%26X-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3DASIATPTNQCCEPKL2AFFV%252F20230828%252Fus-west-1%252Fsts%252Faws4_request%26X-Amz-Date%3D20230828T215022Z%26X-Amz-Security-Token%3DIQoJb3JpZ2luX2VjEM7%252F%252F%252F%252F%252F%252F%252F%252F%252F%252FwEaCXVzLXdlc3QtMSJHMEUCIAlUdYLWuG%252FP7%252FBpHJkisoqgW3aYGDAyL7iINBrNeNE9AiEAm0o%252BPmoWkP9RGuZ2ic7pKusNMkYHSTLrr%252BjiPTcM3BIqnwIIl%252F%252F%252F%252F%252F%252F%252F%252F%252F%252F%252FARABGgwyMzk2NzQwNjkxMjgiDEsibEWhk7pf%252BZCHHCrzAexlCcl0AuOemzl7UD0zO3ogno%252FxQNvF12H48E%252FKn64tWaMl78ZZAi2Obvjp4q6Vv9%252BAXvtNKv5eHf7Ac06v9MmUNeDzDLhLXnafV0XgDzW8ZONZbHPO3f343WH1vKAs4TNXdDMJjxOmWQdhmYFE6CSQqIicy9tPVF1puMLzO7MxhzQfbPHLQ5Ijqr2AVsZCIks4ljE3Q3lulZNUIVjwaovGp1bCcNaPSMnIUzsOijBXOpa%252BtLjqITF1XWqWlWtLkKKCrDbuSOxys2W0APUYB77KP8EHrzrPfRTN3Gv0z5ljqgir%252F3Aj8wQ75otntFpGMo6sIjCerbSnBjqdAa0oee8vAjfkCKhS0maVvDu1F7cvTlSS7PevfirVtGEkE%252FddZpUYHX4Yqv4%252BU8tJpookFZDzriINfi94MvHV%252FG%252B7lCaF9cWFMv03Eg51042chBsRMTpa2PnGYYTbSJwHAQ87HPV23LML%252B8gsDseLTSPPTDDJ8dElAajNtjdvnsB2ycHrcaEwmVlvIyNvXWVI2v7U4GzLIdvyiC8c1Os%253D%26X-Amz-SignedHeaders%3Dhost%26X-Amz-Signature%3D3c76cd607ce1b75289843811a5dacbd6774d83d81cecc65c1140f2569e82b2fc%22%2C%22scope%22%3A%22https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.read_only%22%7D%7D | |
endpoint, headerMap, err := toSignedAwsStsPostRequest(accessKey, secretKey, sessionToken, region, targetResource) | |
if err != nil { | |
return "", err | |
} | |
headers := []Header{} | |
for key, value := range headerMap { | |
headers = append(headers, Header{Key: key, Value: value}) | |
} | |
token := GetCallerIdentityToken{ | |
Headers: headers, | |
Method: "POST", | |
URL: endpoint, | |
} | |
jsonOutput, err := json.Marshal(token) | |
if err != nil { | |
return "", fmt.Errorf("Error marshalling token to JSON: %w", err) | |
} | |
// token exchange endpoint requires URL encoded JSON for subjectToken field | |
return url.QueryEscape(string(jsonOutput)), nil | |
} | |
func toSignedAwsStsPostRequest(accessKeyID, secretAccessKey, sessionToken, region, targetResource string) (string, map[string]string, error) { | |
service := "sts" | |
host := fmt.Sprintf("sts.%s.amazonaws.com", region) | |
target := fmt.Sprintf("https://sts.%s.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15", region) | |
now := time.Now() | |
body := bytes.NewBuffer([]byte{}) | |
req, err := http.NewRequest("POST", target, body) | |
if err != nil { | |
return "", nil, fmt.Errorf("failed to create request, %w", err) | |
} | |
// Set headers | |
req.Header.Set("Host", host) | |
req.Header.Set("x-amz-security-token", sessionToken) | |
req.Header.Set("x-goog-cloud-target-resource", targetResource) | |
req.Header.Set("x-amz-date", now.UTC().Format("20060102T150405Z")) | |
// Create credentials | |
credentials := aws.Credentials{ | |
AccessKeyID: accessKeyID, | |
SecretAccessKey: secretAccessKey, | |
SessionToken: sessionToken, | |
} | |
// Create and sign the request | |
signer := v4.NewSigner() | |
err = signer.SignHTTP(context.TODO(), credentials, req, AWS_EMPTY_PAYLOAD_HASH, service, region, now) | |
if err != nil { | |
return "", nil, fmt.Errorf("failed to sign request, %w", err) | |
} | |
headerMap := map[string]string{} | |
for key, values := range req.Header { | |
// GCP token exchange endpoint requires Authorization to be capitalized | |
if key != "Authorization" { | |
key = strings.ToLower(key) | |
} | |
headerMap[key] = values[0] | |
} | |
return req.URL.String(), headerMap, nil | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment