From dda9bff8ec70e6d104901b4105d13dcaa4e2404c Mon Sep 17 00:00:00 2001 From: Cody Oss <6331106+codyoss@users.noreply.github.com> Date: Mon, 11 Sep 2023 10:44:55 -0500 Subject: [PATCH] feat(auth): add downscope package (#8532) This is roughly the equivalent of the old oauth2/google/downscope package. Some integration tests are being added here but disabling them until a future commit where all test infra will be updated to support these tests. Ran them locally for now and all seems well. --- auth/downscope/doc.go | 43 +++++ auth/downscope/downscope.go | 191 ++++++++++++++++++++++ auth/downscope/downscope_test.go | 130 +++++++++++++++ auth/downscope/example_test.go | 64 ++++++++ auth/downscope/integration_test.go | 115 +++++++++++++ auth/internal/testutil/testdns/dns.go | 65 ++++++++ auth/internal/testutil/testgcs/storage.go | 119 ++++++++++++++ auth/internal/testutil/testutil.go | 93 +++++++++++ 8 files changed, 820 insertions(+) create mode 100644 auth/downscope/doc.go create mode 100644 auth/downscope/downscope.go create mode 100644 auth/downscope/downscope_test.go create mode 100644 auth/downscope/example_test.go create mode 100644 auth/downscope/integration_test.go create mode 100644 auth/internal/testutil/testdns/dns.go create mode 100644 auth/internal/testutil/testgcs/storage.go create mode 100644 auth/internal/testutil/testutil.go diff --git a/auth/downscope/doc.go b/auth/downscope/doc.go new file mode 100644 index 000000000000..6c268bb3db45 --- /dev/null +++ b/auth/downscope/doc.go @@ -0,0 +1,43 @@ +// 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 downscope implements the ability to downscope, or restrict, the +// Identity and Access Management permissions that a short-lived Token +// can use. Please note that only Google Cloud Storage supports this feature. +// For complete documentation, see https://cloud.google.com/iam/docs/downscoping-short-lived-credentials +// +// To downscope permissions of a source credential, you need to define +// a Credential Access Boundary. Said Boundary specifies which resources +// the newly created credential can access, an upper bound on the permissions +// it has over those resources, and optionally attribute-based conditional +// access to the aforementioned resources. For more information on IAM +// Conditions, see https://cloud.google.com/iam/docs/conditions-overview. +// +// This functionality can be used to provide a third party with +// limited access to and permissions on resources held by the owner of the root +// credential or internally in conjunction with the principle of least privilege +// to ensure that internal services only hold the minimum necessary privileges +// for their function. +// +// For example, a token broker can be set up on a server in a private network. +// Various workloads (token consumers) in the same network will send +// authenticated requests to that broker for downscoped tokens to access or +// modify specific google cloud storage buckets. See the NewTokenProvider example +// for an example of how a token broker would use this package. +// +// The broker will use the functionality in this package to generate a +// downscoped token with the requested configuration, and then pass it back to +// the token consumer. These downscoped access tokens can then be used to access +// Google Cloud resources. +package downscope diff --git a/auth/downscope/downscope.go b/auth/downscope/downscope.go new file mode 100644 index 000000000000..26f59256f98e --- /dev/null +++ b/auth/downscope/downscope.go @@ -0,0 +1,191 @@ +// 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 downscope + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" +) + +var identityBindingEndpoint = "https://sts.googleapis.com/v1/token" + +// Options for configuring [NewTokenProvider]. +type Options struct { + // BaseProvider is the [cloud.google.com/go/auth.TokenProvider] used to + // create the downscoped provider. The downscoped provider therefore has + // some subset of the accesses of the original BaseProvider. Required. + BaseProvider auth.TokenProvider + // Rules defines the accesses held by the new downscoped provider. One or + // more AccessBoundaryRules are required to define permissions for the new + // downscoped provider. Each one defines an access (or set of accesses) that + // the new provider has to a given resource. There can be a maximum of 10 + // AccessBoundaryRules. Required. + Rules []AccessBoundaryRule + // Client configures the underlying client used to make network requests + // when fetching tokens. Optional. + Client *http.Client +} + +func (c Options) client() *http.Client { + if c.Client != nil { + return c.Client + } + return internal.CloneDefaultClient() +} + +// An AccessBoundaryRule Sets the permissions (and optionally conditions) that +// the new token has on given resource. +type AccessBoundaryRule struct { + // AvailableResource is the full resource name of the Cloud Storage bucket + // that the rule applies to. Use the format + // //storage.googleapis.com/projects/_/buckets/bucket-name. + AvailableResource string `json:"availableResource"` + // AvailablePermissions is a list that defines the upper bound on the available permissions + // for the resource. Each value is the identifier for an IAM predefined role or custom role, + // with the prefix inRole:. For example: inRole:roles/storage.objectViewer. + // Only the permissions in these roles will be available. + AvailablePermissions []string `json:"availablePermissions"` + // An Condition restricts the availability of permissions + // to specific Cloud Storage objects. Optional. + // + // A Condition can be used to make permissions available for specific objects, + // rather than all objects in a Cloud Storage bucket. + Condition *AvailabilityCondition `json:"availabilityCondition,omitempty"` +} + +// An AvailabilityCondition restricts access to a given Resource. +type AvailabilityCondition struct { + // An Expression specifies the Cloud Storage objects where + // permissions are available. For further documentation, see + // https://cloud.google.com/iam/docs/conditions-overview. Required. + Expression string `json:"expression"` + // Title is short string that identifies the purpose of the condition. Optional. + Title string `json:"title,omitempty"` + // Description details about the purpose of the condition. Optional. + Description string `json:"description,omitempty"` +} + +// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] that is +// more restrictive than [Options.BaseProvider] provided. +func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { + if opts == nil { + return nil, fmt.Errorf("downscope: providing opts is required") + } + if opts.BaseProvider == nil { + return nil, fmt.Errorf("downscope: BaseProvider cannot be nil") + } + if len(opts.Rules) == 0 { + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") + } + if len(opts.Rules) > 10 { + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") + } + for _, val := range opts.Rules { + if val.AvailableResource == "" { + return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource") + } + if len(val.AvailablePermissions) == 0 { + return nil, fmt.Errorf("downscope: all rules must provide at least one permission") + } + } + return &downscopedTokenProvider{Options: opts, Client: opts.client()}, nil +} + +// downscopedTokenProvider is used to retrieve a downscoped tokens. +type downscopedTokenProvider struct { + Options *Options + Client *http.Client +} + +type downscopedOptions struct { + Boundary accessBoundary `json:"accessBoundary"` +} + +type accessBoundary struct { + AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` +} + +type downscopedTokenResponse struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func (dts *downscopedTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + downscopedOptions := downscopedOptions{ + Boundary: accessBoundary{ + AccessBoundaryRules: dts.Options.Rules, + }, + } + + tok, err := dts.Options.BaseProvider.Token(ctx) + if err != nil { + return nil, fmt.Errorf("downscope: unable to obtain root token: %w", err) + } + b, err := json.Marshal(downscopedOptions) + if err != nil { + return nil, err + } + + form := url.Values{} + form.Add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + form.Add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + form.Add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + form.Add("subject_token", tok.Value) + form.Add("options", string(b)) + + resp, err := dts.Client.PostForm(identityBindingEndpoint, form) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := internal.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downscope: unable to exchange token, %v: %s", resp.StatusCode, respBody) + } + + var tresp downscopedTokenResponse + err = json.Unmarshal(respBody, &tresp) + if err != nil { + return nil, err + } + + // An exchanged token that is derived from a service account (2LO) has an + // expired_in value a token derived from a users token (3LO) does not. + // The following code uses the time remaining on rootToken for a user as the + // value for the derived token's lifetime. + var expiryTime time.Time + if tresp.ExpiresIn > 0 { + expiryTime = time.Now().Add(time.Duration(tresp.ExpiresIn) * time.Second) + } else { + expiryTime = tok.Expiry + } + return &auth.Token{ + Value: tresp.AccessToken, + Type: tresp.TokenType, + Expiry: expiryTime, + }, nil +} diff --git a/auth/downscope/downscope_test.go b/auth/downscope/downscope_test.go new file mode 100644 index 000000000000..73eebfc65824 --- /dev/null +++ b/auth/downscope/downscope_test.go @@ -0,0 +1,130 @@ +// 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 downscope + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "cloud.google.com/go/auth" +) + +var ( + standardReqBody = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B%22availableResource%22%3A%22test1%22%2C%22availablePermissions%22%3A%5B%22Perm1%22%2C%22Perm2%22%5D%7D%5D%7D%7D&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&subject_token=token_base&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token" + standardRespBody = `{"access_token":"fake_token","expires_in":42,"token_type":"Bearer"}` +) + +type staticTokenProvider string + +func (s staticTokenProvider) Token(context.Context) (*auth.Token, error) { + return &auth.Token{Value: string(s)}, nil +} + +func TestNewTokenProvider(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Unexpected request method, %v is found", r.Method) + } + if r.URL.String() != "/" { + t.Errorf("Unexpected request URL, %v is found", r.URL) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed to read request body: %v", err) + } + if got, want := string(body), standardReqBody; got != want { + t.Errorf("Unexpected exchange payload: got %v but want %v,", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(standardRespBody)) + + })) + defer ts.Close() + identityBindingEndpoint = ts.URL + tp, err := NewTokenProvider(&Options{ + BaseProvider: staticTokenProvider("token_base"), + Rules: []AccessBoundaryRule{ + { + AvailableResource: "test1", + AvailablePermissions: []string{"Perm1", "Perm2"}, + }, + }, + }) + if err != nil { + t.Fatalf("NewTokenProvider() = %v", err) + } + tok, err := tp.Token(context.Background()) + if err != nil { + t.Fatalf("NewDownscopedTokenSource failed with error: %v", err) + } + if want := "fake_token"; tok.Value != want { + t.Fatalf("got %v, want %v", tok.Value, want) + } +} + +func TestTestNewTokenProvider_Validations(t *testing.T) { + tests := []struct { + name string + opts *Options + }{ + { + name: "no opts", + opts: nil, + }, + { + name: "no provider", + opts: &Options{}, + }, + { + name: "no rules", + opts: &Options{ + BaseProvider: staticTokenProvider("token_base"), + }, + }, + { + name: "too many rules", + opts: &Options{ + BaseProvider: staticTokenProvider("token_base"), + Rules: []AccessBoundaryRule{{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, + }, + }, + { + name: "no resource", + opts: &Options{ + BaseProvider: staticTokenProvider("token_base"), + Rules: []AccessBoundaryRule{{}}, + }, + }, + { + name: "no perm", + opts: &Options{ + BaseProvider: staticTokenProvider("token_base"), + Rules: []AccessBoundaryRule{{ + AvailableResource: "resource", + }}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := NewTokenProvider(test.opts); err == nil { + t.Fatal("want non-nil err") + } + }) + } +} diff --git a/auth/downscope/example_test.go b/auth/downscope/example_test.go new file mode 100644 index 000000000000..bc12b7e96504 --- /dev/null +++ b/auth/downscope/example_test.go @@ -0,0 +1,64 @@ +// 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 downscope_test + +import ( + "context" + "fmt" + + "cloud.google.com/go/auth/detect" + "cloud.google.com/go/auth/downscope" +) + +func ExampleNewTokenProvider() { + // This shows how to generate a downscoped token. This code would be run on + // the token broker, which holds the root token used to generate the + // downscoped token. + ctx := context.Background() + + // Initializes an accessBoundary with one Rule which restricts the + // downscoped token to only be able to access the bucket "foo" and only + // grants it the permission "storage.objectViewer". + accessBoundary := []downscope.AccessBoundaryRule{ + { + AvailableResource: "//storage.googleapis.com/projects/_/buckets/foo", + AvailablePermissions: []string{"inRole:roles/storage.objectViewer"}, + }, + } + + // This Source can be initialized in multiple ways; the following example uses + // Application Default Credentials. + baseProvider, err := detect.DefaultCredentials(&detect.Options{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + }) + tp, err := downscope.NewTokenProvider(&downscope.Options{BaseProvider: baseProvider, Rules: accessBoundary}) + if err != nil { + fmt.Printf("failed to generate downscoped token provider: %v", err) + return + } + + tok, err := tp.Token(ctx) + if err != nil { + fmt.Printf("failed to generate token: %v", err) + return + } + _ = tok + // You can now pass tok to a token consumer however you wish, such as exposing + // a REST API and sending it over HTTP. + + // You can instead use the token held in tp to make + // Google Cloud Storage calls, as follows: + // storageClient, err := storage.NewClient(ctx, option.WithTokenProvider(tp)) +} diff --git a/auth/downscope/integration_test.go b/auth/downscope/integration_test.go new file mode 100644 index 000000000000..0535a70e0040 --- /dev/null +++ b/auth/downscope/integration_test.go @@ -0,0 +1,115 @@ +// 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 downscope_test + +import ( + "context" + "fmt" + "io" + "os" + "testing" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/detect" + "cloud.google.com/go/auth/downscope" + "cloud.google.com/go/auth/internal/testutil" + "cloud.google.com/go/auth/internal/testutil/testgcs" +) + +const ( + rootTokenScope = "https://www.googleapis.com/auth/cloud-platform" + envServiceAccountFile = "GOOGLE_APPLICATION_CREDENTIALS" + object1 = "cab-first-c45wknuy.txt" + object2 = "cab-second-c45wknuy.txt" + bucket = "dulcet-port-762" +) + +func TestDownscopedToken(t *testing.T) { + testutil.IntegrationTestCheck(t) + creds, err := detect.DefaultCredentials(&detect.Options{ + CredentialsFile: os.Getenv(envServiceAccountFile), + Scopes: []string{rootTokenScope}, + }) + if err != nil { + t.Fatalf("DefaultCredentials() = %v", err) + } + + var downscopeTests = []struct { + name string + rule downscope.AccessBoundaryRule + objectName string + expectError bool + }{ + { + name: "successfulDownscopedRead", + rule: downscope.AccessBoundaryRule{ + AvailableResource: "//storage.googleapis.com/projects/_/buckets/" + bucket, + AvailablePermissions: []string{"inRole:roles/storage.objectViewer"}, + Condition: &downscope.AvailabilityCondition{ + Expression: "resource.name.startsWith('projects/_/buckets/" + bucket + "/objects/" + object1 + "')", + }, + }, + objectName: object1, + expectError: false, + }, + { + name: "readWithoutPermission", + rule: downscope.AccessBoundaryRule{ + AvailableResource: "//storage.googleapis.com/projects/_/buckets/" + bucket, + AvailablePermissions: []string{"inRole:roles/storage.objectViewer"}, + Condition: &downscope.AvailabilityCondition{ + Expression: "resource.name.startsWith('projects/_/buckets/" + bucket + "/objects/" + object1 + "')", + }, + }, + objectName: object2, + expectError: true, + }, + } + + for _, tt := range downscopeTests { + t.Run(tt.name, func(t *testing.T) { + err := testDownscopedToken(t, tt.rule, tt.objectName, creds) + if !tt.expectError && err != nil { + t.Errorf("test case %v should have succeeded, but instead returned %v", tt.name, err) + } else if tt.expectError && err == nil { + t.Errorf(" test case %v should have returned an error, but instead returned nil", tt.name) + } + }) + } +} + +func testDownscopedToken(t *testing.T, rule downscope.AccessBoundaryRule, objectName string, tp auth.TokenProvider) error { + t.Helper() + ctx := context.Background() + tp, err := downscope.NewTokenProvider(&downscope.Options{BaseProvider: tp, Rules: []downscope.AccessBoundaryRule{rule}}) + if err != nil { + return fmt.Errorf("downscope.NewTokenProvider() = %v", err) + } + + ctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + client := testgcs.NewClient(tp) + resp, err := client.DownloadObject(ctx, bucket, objectName) + if err != nil { + return err + } + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + if err != nil { + return err + } + return nil +} diff --git a/auth/internal/testutil/testdns/dns.go b/auth/internal/testutil/testdns/dns.go new file mode 100644 index 000000000000..fbc11ace4828 --- /dev/null +++ b/auth/internal/testutil/testdns/dns.go @@ -0,0 +1,65 @@ +// 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 testdns is a light DNS client used for testings to avoid pulling in +// dependencies. +package testdns + +import ( + "context" + "fmt" + "io" + "net/http" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" + "cloud.google.com/go/auth/internal/testutil" +) + +// Client is a lightweight DNS client for testing. +type Client struct { + client *http.Client +} + +// NewClient creates a [Client] using the provided +// [cloud.google.com/go/auth.TokenProvider] for authentication. +func NewClient(tp auth.TokenProvider) *Client { + client := internal.CloneDefaultClient() + testutil.AddAuthorizationMiddleware(client, tp) + return &Client{ + client: client, + } +} + +// GetProject calls the GET project endpoint. +func (c *Client) GetProject(ctx context.Context, projectID string) error { + req, err := http.NewRequest("GET", fmt.Sprintf("https://dns.googleapis.com/dns/v1/projects/%s", projectID), nil) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + errBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("%s", errBody) + } + return nil +} diff --git a/auth/internal/testutil/testgcs/storage.go b/auth/internal/testutil/testgcs/storage.go new file mode 100644 index 000000000000..7bad231878a3 --- /dev/null +++ b/auth/internal/testutil/testgcs/storage.go @@ -0,0 +1,119 @@ +// 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 testgcs is a light GCS client used for testings to avoid pulling in +// dependencies. +package testgcs + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" + "cloud.google.com/go/auth/internal/testutil" +) + +// Client is a lightweight GCS client for testing. +type Client struct { + client *http.Client +} + +// NewClient creates a [Client] using the provided +// [cloud.google.com/go/auth.TokenProvider] for authentication. +func NewClient(tp auth.TokenProvider) *Client { + client := internal.CloneDefaultClient() + testutil.AddAuthorizationMiddleware(client, tp) + return &Client{ + client: client, + } +} + +// CreateBucket creates the specified bucket. +func (c *Client) CreateBucket(ctx context.Context, projectID, bucket string) error { + var bucketRequest struct { + Name string `json:"name,omitempty"` + } + bucketRequest.Name = bucket + b, err := json.Marshal(bucketRequest) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("https://storage.googleapis.com/storage/v1/b?project=%s", projectID), bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + errBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("%s", errBody) + } + return nil +} + +// DeleteBucket deletes the specified bucket. +func (c *Client) DeleteBucket(ctx context.Context, bucket string) error { + req, err := http.NewRequest("DELETE", fmt.Sprintf("https://storage.googleapis.com/storage/v1/b/%s", bucket), nil) + if err != nil { + return err + } + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + errBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("%s", errBody) + } + return nil +} + +// DownloadObject returns an [http.Response] who's body can be consumed to +// read the contents of an object. +func (c *Client) DownloadObject(ctx context.Context, bucket, object string) (*http.Response, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("https://storage.googleapis.com/storage/v1/b/%s/o/%s", bucket, object), nil) + if err != nil { + return nil, err + } + req.Header.Add("alt", "media") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + errBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("%s", errBody) + } + return resp, nil +} diff --git a/auth/internal/testutil/testutil.go b/auth/internal/testutil/testutil.go new file mode 100644 index 000000000000..4dfbfce8083e --- /dev/null +++ b/auth/internal/testutil/testutil.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 testutil + +import ( + "fmt" + "net/http" + "testing" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" +) + +// IntegrationTestCheck is a helper to check if an integration test should be +// run +func IntegrationTestCheck(t *testing.T) { + t.Helper() + t.Skip("TODO(codyoss): remove this once we add all secrets") + if testing.Short() { + t.Skip("skipping integration test") + } +} + +// TODO(codyoss): remove all code below when httptransport package is added. + +// AddAuthorizationMiddleware adds a middleware to the provided client's +// transport that sets the Authorization header with the value produced by the +// provided [cloud.google.com/go/auth.TokenProvider]. An error is returned only +// if client or tp is nil. +func AddAuthorizationMiddleware(client *http.Client, tp auth.TokenProvider) error { + if client == nil || tp == nil { + return fmt.Errorf("httptransport: client and tp must not be nil") + } + base := client.Transport + if base == nil { + base = http.DefaultTransport.(*http.Transport).Clone() + } + client.Transport = &authTransport{ + provider: auth.NewCachedTokenProvider(tp, nil), + base: base, + } + return nil +} + +type authTransport struct { + provider auth.TokenProvider + base http.RoundTripper +} + +// RoundTrip authorizes and authenticates the request with an +// access token from Transport's Source. Per the RoundTripper contract we must +// not modify the initial request, so we clone it, and we must close the body +// on any errors that happens during our token logic. +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + if req.Body != nil { + defer func() { + if !reqBodyClosed { + req.Body.Close() + } + }() + } + token, err := t.provider.Token(req.Context()) + if err != nil { + return nil, err + } + req2 := req.Clone(req.Context()) + SetAuthHeader(token, req2) + reqBodyClosed = true + return t.base.RoundTrip(req2) +} + +// SetAuthHeader uses the provided token to set the Authorization header on a +// request. If the token.Type is empty, the type is assumed to be Bearer. +func SetAuthHeader(token *auth.Token, req *http.Request) { + typ := token.Type + if typ == "" { + typ = internal.TokenTypeBearer + } + req.Header.Set("Authorization", typ+" "+token.Value) +}