Skip to content

Instantly share code, notes, and snippets.

@niger-prequel
Last active October 16, 2023 15:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save niger-prequel/07e997cdb75d559d9945653a314eb717 to your computer and use it in GitHub Desktop.
Save niger-prequel/07e997cdb75d559d9945653a314eb717 to your computer and use it in GitHub Desktop.
AWS to GCP IAM Federation
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