diff --git a/auth/impersonate/doc.go b/auth/impersonate/doc.go new file mode 100644 index 000000000000..b8a185b120f9 --- /dev/null +++ b/auth/impersonate/doc.go @@ -0,0 +1,42 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package impersonate is used to impersonate Google Credentials. +// +// # Required IAM roles +// +// In order to impersonate a service account the base service account must have +// the Service Account Token Creator role, roles/iam.serviceAccountTokenCreator, +// on the service account being impersonated. See +// https://cloud.google.com/iam/docs/understanding-service-accounts. +// +// Optionally, delegates can be used during impersonation if the base service +// account lacks the token creator role on the target. When using delegates, +// each service account must be granted roles/iam.serviceAccountTokenCreator +// on the next service account in the delgation chain. +// +// For example, if a base service account of SA1 is trying to impersonate target +// service account SA2 while using delegate service accounts DSA1 and DSA2, +// the following must be true: +// +// 1. Base service account SA1 has roles/iam.serviceAccountTokenCreator on +// DSA1. +// 2. DSA1 has roles/iam.serviceAccountTokenCreator on DSA2. +// 3. DSA2 has roles/iam.serviceAccountTokenCreator on target SA2. +// +// If the base credential is an authorized user and not a service account, or if +// the option WithQuotaProject is set, the target service account must have a +// role that grants the serviceusage.services.use permission such as +// roles/serviceusage.serviceUsageConsumer. +package impersonate diff --git a/auth/impersonate/example_test.go b/auth/impersonate/example_test.go new file mode 100644 index 000000000000..a18b109b16dc --- /dev/null +++ b/auth/impersonate/example_test.go @@ -0,0 +1,83 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate_test + +import ( + "log" + + "cloud.google.com/go/auth/httptransport" + "cloud.google.com/go/auth/impersonate" +) + +func ExampleNewCredentialTokenProvider_serviceAccount() { + // Base credentials sourced from ADC or provided client options + tp, err := impersonate.NewCredentialTokenProvider(&impersonate.CredentialOptions{ + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + // Optionally supply delegates + Delegates: []string{"bar@project-id.iam.gserviceaccount.com"}, + }) + if err != nil { + log.Fatal(err) + } + + // Use this TokenProvider with a client library + _ = tp +} + +func ExampleNewCredentialTokenProvider_adminUser() { + // Base credentials sourced from ADC or provided client options + tp, err := impersonate.NewCredentialTokenProvider(&impersonate.CredentialOptions{ + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + // Optionally supply delegates + Delegates: []string{"bar@project-id.iam.gserviceaccount.com"}, + // Specify user to impersonate + Subject: "admin@example.com", + }) + if err != nil { + log.Fatal(err) + } + + // Use this TokenProvider with a client library like + // "google.golang.org/api/admin/directory/v1" + _ = tp +} + +func ExampleNewIDTokenProvider() { + // Base credentials sourced from ADC or provided client options. + tp, err := impersonate.NewIDTokenProvider(&impersonate.IDTokenOptions{ + Audience: "http://example.com/", + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + IncludeEmail: true, + // Optionally supply delegates. + Delegates: []string{"bar@project-id.iam.gserviceaccount.com"}, + }) + if err != nil { + log.Fatal(err) + } + + // Create an authenticated client + client, err := httptransport.NewClient(&httptransport.Options{ + TokenProvider: tp, + }) + if err != nil { + log.Fatal(err) + } + + // Use your client that is authenticated with impersonated credentials to + // make requests. + client.Get("http://example.com/") +} diff --git a/auth/impersonate/idtoken.go b/auth/impersonate/idtoken.go new file mode 100644 index 000000000000..5af8bfc23381 --- /dev/null +++ b/auth/impersonate/idtoken.go @@ -0,0 +1,172 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/detect" + "cloud.google.com/go/auth/httptransport" + "cloud.google.com/go/auth/internal" +) + +// IDTokenOptions for generating an impersonated ID token. +type IDTokenOptions struct { + // Audience is the `aud` field for the token, such as an API endpoint the + // token will grant access to. Required. + Audience string + // TargetPrincipal is the email address of the service account to + // impersonate. Required. + TargetPrincipal string + // IncludeEmail includes the service account's email in the token. The + // resulting token will include both an `email` and `email_verified` + // claim. Optional. + IncludeEmail bool + // Delegates are the service account email addresses in a delegation chain. + // Each service account must be granted roles/iam.serviceAccountTokenCreator + // on the next service account in the chain. Optional. + Delegates []string + + // TokenProvider is the provider of the credentials used to fetch the ID + // token. If not provided, and a Client is also not provided, credentials + // will try to be detected from the environment. Optional. + TokenProvider auth.TokenProvider + // Client configures the underlying client used to make network requests + // when fetching tokens. If provided the client should provide it's own + // credentials at call time. Optional. + Client *http.Client +} + +var ( + defaultAud = "https://iamcredentials.googleapis.com/" + defaultScope = "https://www.googleapis.com/auth/cloud-platform" +) + +// NewIDTokenProvider creates an impersonated +// [cloud.google.com/go/auth/TokenProvider] that returns ID tokens configured +// with the provided config and using credentials loaded from Application +// Default Credentials as the base credentials if not provided with the opts. +// The tokens produced are valid for one hour and are automatically refreshed. +func NewIDTokenProvider(opts *IDTokenOptions) (auth.TokenProvider, error) { + if opts == nil { + return nil, fmt.Errorf("impersonate: opts must be provided") + } + if opts.Audience == "" { + return nil, fmt.Errorf("impersonate: an audience must be provided") + } + if opts.TargetPrincipal == "" { + return nil, fmt.Errorf("impersonate: a target service account must be provided") + } + + var client *http.Client + if opts.Client == nil && opts.TokenProvider == nil { + var err error + client, err = httptransport.NewClient(&httptransport.Options{ + DetectOpts: &detect.Options{ + Audience: defaultAud, + Scopes: []string{defaultScope}, + }, + }) + if err != nil { + return nil, err + } + } else if opts.Client == nil { + client = internal.CloneDefaultClient() + if err := httptransport.AddAuthorizationMiddleware(client, opts.TokenProvider); err != nil { + return nil, err + } + } else { + client = opts.Client + } + + itp := impersonatedIDTokenProvider{ + client: client, + targetPrincipal: opts.TargetPrincipal, + audience: opts.Audience, + includeEmail: opts.IncludeEmail, + } + for _, v := range opts.Delegates { + itp.delegates = append(itp.delegates, formatIAMServiceAccountName(v)) + } + return auth.NewCachedTokenProvider(itp, nil), nil +} + +type generateIDTokenRequest struct { + Audience string `json:"audience"` + IncludeEmail bool `json:"includeEmail"` + Delegates []string `json:"delegates,omitempty"` +} + +type generateIDTokenResponse struct { + Token string `json:"token"` +} + +type impersonatedIDTokenProvider struct { + client *http.Client + + targetPrincipal string + audience string + includeEmail bool + delegates []string +} + +func (i impersonatedIDTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + now := time.Now() + genIDTokenReq := generateIDTokenRequest{ + Audience: i.audience, + IncludeEmail: i.includeEmail, + Delegates: i.delegates, + } + bodyBytes, err := json.Marshal(genIDTokenReq) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err) + } + + // TODO FIX ME + url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal)) + req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := i.client.Do(req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to generate ID token: %v", err) + } + defer resp.Body.Close() + body, err := internal.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var generateIDTokenResp generateIDTokenResponse + if err := json.Unmarshal(body, &generateIDTokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + return &auth.Token{ + Value: generateIDTokenResp.Token, + // Generated ID tokens are good for one hour. + Expiry: now.Add(1 * time.Hour), + }, nil +} diff --git a/auth/impersonate/idtoken_test.go b/auth/impersonate/idtoken_test.go new file mode 100644 index 000000000000..7e6a5984a4d3 --- /dev/null +++ b/auth/impersonate/idtoken_test.go @@ -0,0 +1,93 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" +) + +func TestIDTokenSource(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + aud string + targetPrincipal string + wantErr bool + }{ + { + name: "missing aud", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: true, + }, + { + name: "missing targetPrincipal", + aud: "http://example.com/", + wantErr: true, + }, + { + name: "works", + aud: "http://example.com/", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: false, + }, + } + + for _, tt := range tests { + name := tt.name + t.Run(name, func(t *testing.T) { + idTok := "id-token" + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + resp := generateIDTokenResponse{ + Token: idTok, + } + b, err := json.Marshal(&resp) + if err != nil { + t.Fatalf("unable to marshal response: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(b)), + Header: make(http.Header), + } + }), + } + tp, err := NewIDTokenProvider(&IDTokenOptions{ + Audience: tt.aud, + TargetPrincipal: tt.targetPrincipal, + Client: client, + }, + ) + if tt.wantErr && err != nil { + return + } + if err != nil { + t.Fatal(err) + } + tok, err := tp.Token(ctx) + if err != nil { + t.Fatal(err) + } + if tok.Value != idTok { + t.Fatalf("got %q, want %q", tok.Value, idTok) + } + }) + } +} diff --git a/auth/impersonate/impersonate.go b/auth/impersonate/impersonate.go new file mode 100644 index 000000000000..e0f7705d3b96 --- /dev/null +++ b/auth/impersonate/impersonate.go @@ -0,0 +1,208 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/detect" + "cloud.google.com/go/auth/httptransport" + "cloud.google.com/go/auth/internal" +) + +var ( + iamCredentialsEndpoint = "https://iamcredentials.googleapis.com" + oauth2Endpoint = "https://oauth2.googleapis.com" +) + +// NewCredentialTokenProvider returns an impersonated +// [cloud.google.com/go/auth/TokenProvider] configured with the provided options +// and using credentials loaded from Application Default Credentials as the base +// credentials if not provided with the opts. +func NewCredentialTokenProvider(opts *CredentialOptions) (auth.TokenProvider, error) { + if opts == nil { + return nil, fmt.Errorf("impersonate: opts must be provided") + } + if opts.TargetPrincipal == "" { + return nil, fmt.Errorf("impersonate: a target service account must be provided") + } + if len(opts.Scopes) == 0 { + return nil, fmt.Errorf("impersonate: scopes must be provided") + } + if opts.Lifetime.Hours() > 12 { + return nil, fmt.Errorf("impersonate: max lifetime is 12 hours") + } + + var isStaticToken bool + // Default to the longest acceptable value of one hour as the token will + // be refreshed automatically if not set. + lifetime := 3600 * time.Second + if opts.Lifetime != 0 { + lifetime = opts.Lifetime + // Don't auto-refresh token if a lifetime is configured. + isStaticToken = true + } + + var client *http.Client + if opts.Client == nil && opts.TokenProvider == nil { + var err error + client, err = httptransport.NewClient(&httptransport.Options{ + DetectOpts: &detect.Options{ + Audience: defaultAud, + Scopes: []string{defaultScope}, + }, + }) + if err != nil { + return nil, err + } + } else if opts.TokenProvider != nil { + client = internal.CloneDefaultClient() + if err := httptransport.AddAuthorizationMiddleware(client, opts.TokenProvider); err != nil { + return nil, err + } + } else { + client = opts.Client + } + + // If a subject is specified a different auth-flow is initiated to + // impersonate as the provided subject (user). + if opts.Subject != "" { + return user(opts, client, lifetime, isStaticToken) + } + + its := impersonatedTokenProvider{ + client: client, + targetPrincipal: opts.TargetPrincipal, + lifetime: fmt.Sprintf("%.fs", lifetime.Seconds()), + } + for _, v := range opts.Delegates { + its.delegates = append(its.delegates, formatIAMServiceAccountName(v)) + } + its.scopes = make([]string, len(opts.Scopes)) + copy(its.scopes, opts.Scopes) + + if isStaticToken { + return auth.NewCachedTokenProvider(its, nil), nil + } + return auth.NewCachedTokenProvider(its, nil), nil +} + +// CredentialOptions for generating an impersonated credential token. +type CredentialOptions struct { + // TargetPrincipal is the email address of the service account to + // impersonate. Required. + TargetPrincipal string + // Scopes that the impersonated credential should have. Required. + Scopes []string + // Delegates are the service account email addresses in a delegation chain. + // Each service account must be granted roles/iam.serviceAccountTokenCreator + // on the next service account in the chain. Optional. + Delegates []string + // Lifetime is the amount of time until the impersonated token expires. If + // unset the token's lifetime will be one hour and be automatically + // refreshed. If set the token may have a max lifetime of one hour and will + // not be refreshed. Service accounts that have been added to an org policy + // with constraints/iam.allowServiceAccountCredentialLifetimeExtension may + // request a token lifetime of up to 12 hours. Optional. + Lifetime time.Duration + // Subject is the sub field of a JWT. This field should only be set if you + // wish to impersonate as a user. This feature is useful when using domain + // wide delegation. Optional. + Subject string + + // TokenProvider is the provider of the credentials used to fetch the ID + // token. If not provided, and a Client is also not provided, credentials + // will try to be detected from the environment. Optional. + TokenProvider auth.TokenProvider + // Client configures the underlying client used to make network requests + // when fetching tokens. If provided the client should provide it's own + // credentials at call time. Optional. + Client *http.Client +} + +func formatIAMServiceAccountName(name string) string { + return fmt.Sprintf("projects/-/serviceAccounts/%s", name) +} + +type generateAccessTokenReq struct { + Delegates []string `json:"delegates,omitempty"` + Lifetime string `json:"lifetime,omitempty"` + Scope []string `json:"scope,omitempty"` +} + +type generateAccessTokenResp struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` +} + +type impersonatedTokenProvider struct { + client *http.Client + + targetPrincipal string + lifetime string + scopes []string + delegates []string +} + +// Token returns an impersonated Token. +func (i impersonatedTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + reqBody := generateAccessTokenReq{ + Delegates: i.delegates, + Lifetime: i.lifetime, + Scope: i.scopes, + } + b, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err) + } + url := fmt.Sprintf("%s/v1/%s:generateAccessToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal)) + req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := i.client.Do(req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err) + } + defer resp.Body.Close() + body, err := internal.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var accessTokenResp generateAccessTokenResp + if err := json.Unmarshal(body, &accessTokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err) + } + return &auth.Token{ + Value: accessTokenResp.AccessToken, + Expiry: expiry, + }, nil +} diff --git a/auth/impersonate/impersonate_test.go b/auth/impersonate/impersonate_test.go new file mode 100644 index 000000000000..2191499fee14 --- /dev/null +++ b/auth/impersonate/impersonate_test.go @@ -0,0 +1,110 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestTokenSource_serviceAccount(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + targetPrincipal string + scopes []string + lifetime time.Duration + wantErr bool + }{ + { + name: "missing targetPrincipal", + wantErr: true, + }, + { + name: "missing scopes", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: true, + }, + { + name: "lifetime over max", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + lifetime: 13 * time.Hour, + wantErr: true, + }, + { + name: "works", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + wantErr: false, + }, + } + + for _, tt := range tests { + name := tt.name + t.Run(name, func(t *testing.T) { + saTok := "sa-token" + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + if strings.Contains(req.URL.Path, "generateAccessToken") { + resp := generateAccessTokenResp{ + AccessToken: saTok, + ExpireTime: time.Now().Format(time.RFC3339), + } + b, err := json.Marshal(&resp) + if err != nil { + t.Fatalf("unable to marshal response: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(b)), + Header: http.Header{}, + } + } + return nil + }), + } + ts, err := NewCredentialTokenProvider(&CredentialOptions{ + TargetPrincipal: tt.targetPrincipal, + Scopes: tt.scopes, + Lifetime: tt.lifetime, + Client: client, + }) + if tt.wantErr && err != nil { + return + } + if err != nil { + t.Fatal(err) + } + tok, err := ts.Token(ctx) + if err != nil { + t.Fatal(err) + } + if tok.Value != saTok { + t.Fatalf("got %q, want %q", tok.Value, saTok) + } + }) + } +} + +type RoundTripFn func(req *http.Request) *http.Response + +func (f RoundTripFn) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } diff --git a/auth/impersonate/integration_test.go b/auth/impersonate/integration_test.go new file mode 100644 index 000000000000..af995946a0db --- /dev/null +++ b/auth/impersonate/integration_test.go @@ -0,0 +1,179 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate_test + +import ( + "context" + "flag" + "fmt" + "log" + "math/rand" + "os" + "testing" + "time" + + "cloud.google.com/go/auth/detect" + "cloud.google.com/go/auth/impersonate" + "cloud.google.com/go/auth/internal/testutil" + "cloud.google.com/go/auth/internal/testutil/testgcs" +) + +const ( + envAppCreds = "GOOGLE_APPLICATION_CREDENTIALS" + envProjectID = "GOOGLE_CLOUD_PROJECT" + envReaderCreds = "GCLOUD_TESTS_IMPERSONATE_READER_KEY" + envReaderEmail = "GCLOUD_TESTS_IMPERSONATE_READER_EMAIL" + envWriterEmail = "GCLOUD_TESTS_IMPERSONATE_WRITER_EMAIL" +) + +var ( + baseKeyFile string + readerKeyFile string + readerEmail string + writerEmail string + projectID string + random *rand.Rand +) + +func TestMain(m *testing.M) { + flag.Parse() + random = rand.New(rand.NewSource(time.Now().UnixNano())) + baseKeyFile = os.Getenv(envAppCreds) + projectID = os.Getenv(envProjectID) + readerKeyFile = os.Getenv(envReaderCreds) + readerEmail = os.Getenv(envReaderEmail) + writerEmail = os.Getenv(envWriterEmail) + + if !testing.Short() && (baseKeyFile == "" || + readerKeyFile == "" || + readerEmail == "" || + writerEmail == "" || + projectID == "") { + log.Println("required environment variable not set, skipping") + os.Exit(0) + } + + os.Exit(m.Run()) +} + +func TestCredentialsTokenSourceIntegration(t *testing.T) { + testutil.IntegrationTestCheck(t) + tests := []struct { + name string + baseKeyFile string + delegates []string + }{ + { + name: "SA -> SA", + baseKeyFile: readerKeyFile, + }, + { + name: "SA -> Delegate -> SA", + baseKeyFile: baseKeyFile, + delegates: []string{readerEmail}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + creds, err := detect.DefaultCredentials(&detect.Options{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + CredentialsFile: tt.baseKeyFile, + }) + if err != nil { + t.Fatalf("detect.DefaultCredentials() = %v", err) + } + tp, err := impersonate.NewCredentialTokenProvider(&impersonate.CredentialOptions{ + TargetPrincipal: writerEmail, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + Delegates: tt.delegates, + TokenProvider: creds, + }) + if err != nil { + t.Fatalf("failed to create ts: %v", err) + } + client := testgcs.NewClient(tp) + bucketName := fmt.Sprintf("%s-impersonate-test-%d", projectID, random.Int63()) + if err := client.CreateBucket(ctx, projectID, bucketName); err != nil { + t.Fatalf("error creating bucket: %v", err) + } + if err := client.DeleteBucket(ctx, bucketName); err != nil { + t.Fatalf("unable to cleanup bucket %q: %v", bucketName, err) + } + }) + } +} + +// TODO(codyoss): uncomment after adding idtoken package + +// func TestIDTokenSourceIntegration(t *testing.T) { +// testutil.IntegrationTestCheck(t) + +// ctx := context.Background() +// tests := []struct { +// name string +// baseKeyFile string +// delegates []string +// }{ +// { +// name: "SA -> SA", +// baseKeyFile: readerKeyFile, +// }, +// { +// name: "SA -> Delegate -> SA", +// baseKeyFile: baseKeyFile, +// delegates: []string{readerEmail}, +// }, +// } + +// for _, tt := range tests { +// name := tt.name +// t.Run(name, func(t *testing.T) { +// creds, err := detect.DefaultCredentials(&detect.Options{ +// Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, +// CredentialsFile: tt.baseKeyFile, +// }) +// if err != nil { +// t.Fatalf("detect.DefaultCredentials() = %v", err) +// } +// aud := "http://example.com/" +// tp, err := impersonate.NewIDTokenProvider(&impersonate.IDTokenOptions{ +// TargetPrincipal: writerEmail, +// Audience: aud, +// Delegates: tt.delegates, +// IncludeEmail: true, +// TokenProvider: creds, +// }) +// if err != nil { +// t.Fatalf("failed to create ts: %v", err) +// } +// tok, err := tp.Token(ctx) +// if err != nil { +// t.Fatalf("unable to retrieve Token: %v", err) +// } +// validTok, err := idtoken.Validate(ctx, tok.Value, aud) +// if err != nil { +// t.Fatalf("token validation failed: %v", err) +// } +// if validTok.Audience != aud { +// t.Fatalf("got %q, want %q", validTok.Audience, aud) +// } +// if validTok.Claims["email"] != writerEmail { +// t.Fatalf("got %q, want %q", validTok.Claims["email"], writerEmail) +// } +// }) +// } +// } diff --git a/auth/impersonate/user.go b/auth/impersonate/user.go new file mode 100644 index 000000000000..15b9b0f4e948 --- /dev/null +++ b/auth/impersonate/user.go @@ -0,0 +1,180 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" +) + +func user(opts *CredentialOptions, client *http.Client, lifetime time.Duration, isStaticToken bool) (auth.TokenProvider, error) { + u := userTokenProvider{ + client: client, + targetPrincipal: opts.TargetPrincipal, + subject: opts.Subject, + lifetime: lifetime, + } + u.delegates = make([]string, len(opts.Delegates)) + for i, v := range opts.Delegates { + u.delegates[i] = formatIAMServiceAccountName(v) + } + u.scopes = make([]string, len(opts.Scopes)) + copy(u.scopes, opts.Scopes) + if isStaticToken { + return auth.NewCachedTokenProvider(u, &auth.CachedTokenProviderOptions{ + DisableAutoRefresh: true, + }), nil + } + return auth.NewCachedTokenProvider(u, nil), nil +} + +type claimSet struct { + Iss string `json:"iss"` + Scope string `json:"scope,omitempty"` + Sub string `json:"sub,omitempty"` + Aud string `json:"aud"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` +} + +type signJWTRequest struct { + Payload string `json:"payload"` + Delegates []string `json:"delegates,omitempty"` +} + +type signJWTResponse struct { + // KeyID is the key used to sign the JWT. + KeyID string `json:"keyId"` + // SignedJwt contains the automatically generated header; the + // client-supplied payload; and the signature, which is generated using + // the key referenced by the `kid` field in the header. + SignedJWT string `json:"signedJwt"` +} + +type exchangeTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +type userTokenProvider struct { + client *http.Client + + targetPrincipal string + subject string + scopes []string + lifetime time.Duration + delegates []string +} + +func (u userTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + signedJWT, err := u.signJWT() + if err != nil { + return nil, err + } + return u.exchangeToken(ctx, signedJWT) +} + +func (u userTokenProvider) signJWT() (string, error) { + now := time.Now() + exp := now.Add(u.lifetime) + claims := claimSet{ + Iss: u.targetPrincipal, + Scope: strings.Join(u.scopes, " "), + Sub: u.subject, + Aud: fmt.Sprintf("%s/token", oauth2Endpoint), + Iat: now.Unix(), + Exp: exp.Unix(), + } + payloadBytes, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("impersonate: unable to marshal claims: %v", err) + } + signJWTReq := signJWTRequest{ + Payload: string(payloadBytes), + Delegates: u.delegates, + } + + bodyBytes, err := json.Marshal(signJWTReq) + if err != nil { + return "", fmt.Errorf("impersonate: unable to marshal request: %v", err) + } + reqURL := fmt.Sprintf("%s/v1/%s:signJwt", iamCredentialsEndpoint, formatIAMServiceAccountName(u.targetPrincipal)) + req, err := http.NewRequest("POST", reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("impersonate: unable to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + rawResp, err := u.client.Do(req) + if err != nil { + return "", fmt.Errorf("impersonate: unable to sign JWT: %v", err) + } + body, err := internal.ReadAll(rawResp.Body) + if err != nil { + return "", fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := rawResp.StatusCode; c < 200 || c > 299 { + return "", fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var signJWTResp signJWTResponse + if err := json.Unmarshal(body, &signJWTResp); err != nil { + return "", fmt.Errorf("impersonate: unable to parse response: %v", err) + } + return signJWTResp.SignedJWT, nil +} + +func (u userTokenProvider) exchangeToken(ctx context.Context, signedJWT string) (*auth.Token, error) { + now := time.Now() + v := url.Values{} + v.Set("grant_type", "assertion") + v.Set("assertion_type", "http://oauth.net/grant_type/jwt/1.0/bearer") + v.Set("assertion", signedJWT) + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/token", oauth2Endpoint), strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + rawResp, err := u.client.Do(req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to exchange token: %v", err) + } + body, err := internal.ReadAll(rawResp.Body) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to read body: %v", err) + } + if c := rawResp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var tokenResp exchangeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %v", err) + } + + return &auth.Token{ + Value: tokenResp.AccessToken, + Type: tokenResp.TokenType, + Expiry: now.Add(time.Second * time.Duration(tokenResp.ExpiresIn)), + }, nil +} diff --git a/auth/impersonate/user_test.go b/auth/impersonate/user_test.go new file mode 100644 index 000000000000..859509ec5130 --- /dev/null +++ b/auth/impersonate/user_test.go @@ -0,0 +1,128 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impersonate + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "cloud.google.com/go/auth/internal" + "cloud.google.com/go/auth/internal/jwt" +) + +func TestTokenSource_user(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + targetPrincipal string + scopes []string + lifetime time.Duration + subject string + wantErr bool + }{ + { + name: "missing targetPrincipal", + wantErr: true, + }, + { + name: "missing scopes", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + wantErr: true, + }, + { + name: "lifetime over max", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + lifetime: 13 * time.Hour, + wantErr: true, + }, + { + name: "works", + targetPrincipal: "foo@project-id.iam.gserviceaccount.com", + scopes: []string{"scope"}, + subject: "admin@example.com", + wantErr: false, + }, + } + + for _, tt := range tests { + userTok := "user-token" + name := tt.name + t.Run(name, func(t *testing.T) { + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + if strings.Contains(req.URL.Path, "signJwt") { + resp := signJWTResponse{ + KeyID: "123", + SignedJWT: jwt.HeaderType, + } + b, err := json.Marshal(&resp) + if err != nil { + t.Fatalf("unable to marshal response: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(b)), + Header: make(http.Header), + } + } + if strings.Contains(req.URL.Path, "/token") { + resp := exchangeTokenResponse{ + AccessToken: userTok, + TokenType: internal.TokenTypeBearer, + ExpiresIn: int64(time.Hour.Seconds()), + } + b, err := json.Marshal(&resp) + if err != nil { + t.Fatalf("unable to marshal response: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(b)), + Header: make(http.Header), + } + } + return nil + }), + } + ts, err := NewCredentialTokenProvider(&CredentialOptions{ + TargetPrincipal: tt.targetPrincipal, + Scopes: tt.scopes, + Lifetime: tt.lifetime, + Subject: tt.subject, + Client: client, + }) + if tt.wantErr && err != nil { + return + } + if err != nil { + t.Fatal(err) + } + tok, err := ts.Token(ctx) + if err != nil { + t.Fatal(err) + } + if tok.Value != userTok { + t.Fatalf("got %q, want %q", tok.Value, userTok) + } + }) + } +}