From d04028783cf159bebfcd4fa66741a2193e2138a9 Mon Sep 17 00:00:00 2001 From: Shin Fan Date: Tue, 15 Jun 2021 18:57:26 +0000 Subject: [PATCH 1/5] google: support scopes for JWT access token Change-Id: I11acd87a56cd003fdb68a5a687e37df450c400d1 GitHub-Last-Rev: efb2e8a08a8db0dc654298b90b814b3b7cb4d83d GitHub-Pull-Request: golang/oauth2#504 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/327929 Trust: Shin Fan Trust: Cody Oss Run-TryBot: Shin Fan TryBot-Result: Go Bot Reviewed-by: Cody Oss --- google/jwt.go | 37 ++++++++++++++--- google/jwt_test.go | 101 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/google/jwt.go b/google/jwt.go index b0fdb3a..67d97b9 100644 --- a/google/jwt.go +++ b/google/jwt.go @@ -7,6 +7,7 @@ package google import ( "crypto/rsa" "fmt" + "strings" "time" "golang.org/x/oauth2" @@ -24,6 +25,28 @@ import ( // optimization supported by a few Google services. // Unless you know otherwise, you should use JWTConfigFromJSON instead. func JWTAccessTokenSourceFromJSON(jsonKey []byte, audience string) (oauth2.TokenSource, error) { + return newJWTSource(jsonKey, audience, nil) +} + +// JWTAccessTokenSourceWithScope uses a Google Developers service account JSON +// key file to read the credentials that authorize and authenticate the +// requests, and returns a TokenSource that does not use any OAuth2 flow but +// instead creates a JWT and sends that as the access token. +// The scope is typically a list of URLs that specifies the scope of the +// credentials. +// +// Note that this is not a standard OAuth flow, but rather an +// optimization supported by a few Google services. +// Unless you know otherwise, you should use JWTConfigFromJSON instead. +func JWTAccessTokenSourceWithScope(jsonKey []byte, scope ...string) (oauth2.TokenSource, error) { + return newJWTSource(jsonKey, "", scope) +} + +func newJWTSource(jsonKey []byte, audience string, scopes []string) (oauth2.TokenSource, error) { + if len(scopes) == 0 && audience == "" { + return nil, fmt.Errorf("google: missing scope/audience for JWT access token") + } + cfg, err := JWTConfigFromJSON(jsonKey) if err != nil { return nil, fmt.Errorf("google: could not parse JSON key: %v", err) @@ -35,6 +58,7 @@ func JWTAccessTokenSourceFromJSON(jsonKey []byte, audience string) (oauth2.Token ts := &jwtAccessTokenSource{ email: cfg.Email, audience: audience, + scopes: scopes, pk: pk, pkID: cfg.PrivateKeyID, } @@ -47,6 +71,7 @@ func JWTAccessTokenSourceFromJSON(jsonKey []byte, audience string) (oauth2.Token type jwtAccessTokenSource struct { email, audience string + scopes []string pk *rsa.PrivateKey pkID string } @@ -54,12 +79,14 @@ type jwtAccessTokenSource struct { func (ts *jwtAccessTokenSource) Token() (*oauth2.Token, error) { iat := time.Now() exp := iat.Add(time.Hour) + scope := strings.Join(ts.scopes, " ") cs := &jws.ClaimSet{ - Iss: ts.email, - Sub: ts.email, - Aud: ts.audience, - Iat: iat.Unix(), - Exp: exp.Unix(), + Iss: ts.email, + Sub: ts.email, + Aud: ts.audience, + Scope: scope, + Iat: iat.Unix(), + Exp: exp.Unix(), } hdr := &jws.Header{ Algorithm: "RS256", diff --git a/google/jwt_test.go b/google/jwt_test.go index f844436..043f445 100644 --- a/google/jwt_test.go +++ b/google/jwt_test.go @@ -13,29 +13,21 @@ import ( "encoding/json" "encoding/pem" "strings" + "sync" "testing" "time" "golang.org/x/oauth2/jws" ) -func TestJWTAccessTokenSourceFromJSON(t *testing.T) { - // Generate a key we can use in the test data. - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } +var ( + privateKey *rsa.PrivateKey + jsonKey []byte + once sync.Once +) - // Encode the key and substitute into our example JSON. - enc := pem.EncodeToMemory(&pem.Block{ - Type: "PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - }) - enc, err = json.Marshal(string(enc)) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - jsonKey := bytes.Replace(jwtJSONKey, []byte(`"super secret key"`), enc, 1) +func TestJWTAccessTokenSourceFromJSON(t *testing.T) { + setupDummyKey(t) ts, err := JWTAccessTokenSourceFromJSON(jsonKey, "audience") if err != nil { @@ -89,3 +81,80 @@ func TestJWTAccessTokenSourceFromJSON(t *testing.T) { t.Errorf("Header KeyID = %q, want %q", got, want) } } + +func TestJWTAccessTokenSourceWithScope(t *testing.T) { + setupDummyKey(t) + + ts, err := JWTAccessTokenSourceWithScope(jsonKey, "scope1", "scope2") + if err != nil { + t.Fatalf("JWTAccessTokenSourceWithScope: %v\nJSON: %s", err, string(jsonKey)) + } + + tok, err := ts.Token() + if err != nil { + t.Fatalf("Token: %v", err) + } + + if got, want := tok.TokenType, "Bearer"; got != want { + t.Errorf("TokenType = %q, want %q", got, want) + } + if got := tok.Expiry; tok.Expiry.Before(time.Now()) { + t.Errorf("Expiry = %v, should not be expired", got) + } + + err = jws.Verify(tok.AccessToken, &privateKey.PublicKey) + if err != nil { + t.Errorf("jws.Verify on AccessToken: %v", err) + } + + claim, err := jws.Decode(tok.AccessToken) + if err != nil { + t.Fatalf("jws.Decode on AccessToken: %v", err) + } + + if got, want := claim.Iss, "gopher@developer.gserviceaccount.com"; got != want { + t.Errorf("Iss = %q, want %q", got, want) + } + if got, want := claim.Sub, "gopher@developer.gserviceaccount.com"; got != want { + t.Errorf("Sub = %q, want %q", got, want) + } + if got, want := claim.Scope, "scope1 scope2"; got != want { + t.Errorf("Aud = %q, want %q", got, want) + } + + // Finally, check the header private key. + parts := strings.Split(tok.AccessToken, ".") + hdrJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("base64 DecodeString: %v\nString: %q", err, parts[0]) + } + var hdr jws.Header + if err := json.Unmarshal([]byte(hdrJSON), &hdr); err != nil { + t.Fatalf("json.Unmarshal: %v (%q)", err, hdrJSON) + } + + if got, want := hdr.KeyID, "268f54e43a1af97cfc71731688434f45aca15c8b"; got != want { + t.Errorf("Header KeyID = %q, want %q", got, want) + } +} + +func setupDummyKey(t *testing.T) { + once.Do(func() { + // Generate a key we can use in the test data. + pk, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privateKey = pk + // Encode the key and substitute into our example JSON. + enc := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + enc, err = json.Marshal(string(enc)) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + jsonKey = bytes.Replace(jwtJSONKey, []byte(`"super secret key"`), enc, 1) + }) +} From 14747e66f690f610ffbe442ac6d8188b9e9e15e2 Mon Sep 17 00:00:00 2001 From: gIthuriel Date: Tue, 22 Jun 2021 16:39:14 +0000 Subject: [PATCH 2/5] google: check additional AWS variable AWS_DEFAULT_REGION should have been checked as a backup to AWS_REGION but wasn't. Also removed a redundant print statement in a test case. Change-Id: Ia6e13eb20f509110a81e3071228283c43a1e9283 GitHub-Last-Rev: 1a10bcc0791f862983c3e3ae36f0cb73e29db267 GitHub-Pull-Request: golang/oauth2#486 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/302789 Reviewed-by: Cody Oss Trust: Cody Oss Trust: Tyler Bui-Palsulich --- google/internal/externalaccount/aws.go | 2 + google/internal/externalaccount/aws_test.go | 75 +++++++++++++++++++ .../externalaccount/urlcredsource_test.go | 2 - 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/google/internal/externalaccount/aws.go b/google/internal/externalaccount/aws.go index fbcefb4..cb41c62 100644 --- a/google/internal/externalaccount/aws.go +++ b/google/internal/externalaccount/aws.go @@ -342,6 +342,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { func (cs *awsCredentialSource) getRegion() (string, error) { if envAwsRegion := getenv("AWS_REGION"); envAwsRegion != "" { return envAwsRegion, nil + } if envAwsRegion := getenv("AWS_DEFAULT_REGION"); envAwsRegion != "" { + return envAwsRegion, nil } if cs.RegionURL == "" { diff --git a/google/internal/externalaccount/aws_test.go b/google/internal/externalaccount/aws_test.go index 95ff9ce..669ba1e 100644 --- a/google/internal/externalaccount/aws_test.go +++ b/google/internal/externalaccount/aws_test.go @@ -638,6 +638,81 @@ func TestAwsCredential_BasicRequestWithEnv(t *testing.T) { } } +func TestAwsCredential_BasicRequestWithDefaultEnv(t *testing.T) { + server := createDefaultAwsTestServer() + ts := httptest.NewServer(server) + + tfc := testFileConfig + tfc.CredentialSource = server.getCredentialSource(ts.URL) + + oldGetenv := getenv + defer func() { getenv = oldGetenv }() + getenv = setEnvironment(map[string]string{ + "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "AWS_DEFAULT_REGION": "us-west-1", + }) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + expected := getExpectedSubjectToken( + "https://sts.us-west-1.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-west-1", + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "", + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = %q, want %q", got, want) + } +} + +func TestAwsCredential_BasicRequestWithTwoRegions(t *testing.T) { + server := createDefaultAwsTestServer() + ts := httptest.NewServer(server) + + tfc := testFileConfig + tfc.CredentialSource = server.getCredentialSource(ts.URL) + + oldGetenv := getenv + defer func() { getenv = oldGetenv }() + getenv = setEnvironment(map[string]string{ + "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "AWS_REGION": "us-west-1", + "AWS_DEFAULT_REGION": "us-east-1", + }) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + expected := getExpectedSubjectToken( + "https://sts.us-west-1.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-west-1", + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "", + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = %q, want %q", got, want) + } +} + func TestAwsCredential_RequestWithBadVersion(t *testing.T) { server := createDefaultAwsTestServer() ts := httptest.NewServer(server) diff --git a/google/internal/externalaccount/urlcredsource_test.go b/google/internal/externalaccount/urlcredsource_test.go index 8ade2a2..6a36d0d 100644 --- a/google/internal/externalaccount/urlcredsource_test.go +++ b/google/internal/externalaccount/urlcredsource_test.go @@ -7,7 +7,6 @@ package externalaccount import ( "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" @@ -20,7 +19,6 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) { if r.Method != "GET" { t.Errorf("Unexpected request method, %v is found", r.Method) } - fmt.Println(r.Header) if r.Header.Get("Metadata") != "True" { t.Errorf("Metadata header not properly included.") } From bce0382f6c220dc0f85edc1e6c02bd25170a155f Mon Sep 17 00:00:00 2001 From: Eno Compton Date: Tue, 22 Jun 2021 12:44:14 -0600 Subject: [PATCH 3/5] google: fix syntax error Change-Id: I18dd98234a87dca59a199d90a5d0b9cedd80e5af Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/330189 Reviewed-by: Cody Oss Trust: Cody Oss Trust: Tyler Bui-Palsulich Run-TryBot: Cody Oss TryBot-Result: Go Bot --- google/internal/externalaccount/aws.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/google/internal/externalaccount/aws.go b/google/internal/externalaccount/aws.go index cb41c62..a5a5423 100644 --- a/google/internal/externalaccount/aws.go +++ b/google/internal/externalaccount/aws.go @@ -13,7 +13,6 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/oauth2" "io" "io/ioutil" "net/http" @@ -23,6 +22,8 @@ import ( "sort" "strings" "time" + + "golang.org/x/oauth2" ) type awsSecurityCredentials struct { @@ -342,7 +343,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { func (cs *awsCredentialSource) getRegion() (string, error) { if envAwsRegion := getenv("AWS_REGION"); envAwsRegion != "" { return envAwsRegion, nil - } if envAwsRegion := getenv("AWS_DEFAULT_REGION"); envAwsRegion != "" { + } + if envAwsRegion := getenv("AWS_DEFAULT_REGION"); envAwsRegion != "" { return envAwsRegion, nil } From a8dc77f794b698f200a57b3bbd77da000f86124e Mon Sep 17 00:00:00 2001 From: gIthuriel Date: Tue, 22 Jun 2021 21:08:18 +0000 Subject: [PATCH 4/5] google: add external account documentation Adds some documentation to existing public structures for third-party authentication. Change-Id: I756f5cd5619fbd752c028e99176991139fd45c60 GitHub-Last-Rev: c846ea6748d2cc15bf496bbfc41f671c264d2220 GitHub-Pull-Request: golang/oauth2#485 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/301610 Trust: Cody Oss Trust: Tyler Bui-Palsulich Reviewed-by: Cody Oss --- google/doc.go | 6 ++--- .../externalaccount/basecredentials.go | 23 ++++++++++++++++++- google/internal/externalaccount/clientauth.go | 3 +++ .../internal/externalaccount/impersonate.go | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/google/doc.go b/google/doc.go index b241c72..8e6a57c 100644 --- a/google/doc.go +++ b/google/doc.go @@ -4,9 +4,9 @@ // Package google provides support for making OAuth2 authorized and authenticated // HTTP requests to Google APIs. It supports the Web server flow, client-side -// credentials, service accounts, Google Compute Engine service accounts, Google -// App Engine service accounts and workload identity federation from non-Google -// cloud platforms. +// credentials, service accounts, Google Compute Engine service accounts, +// Google App Engine service accounts and workload identity federation +// from non-Google cloud platforms. // // A brief overview of the package follows. For more information, please read // https://developers.google.com/accounts/docs/OAuth2 diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index 1a6e93c..a4d45d9 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -20,15 +20,34 @@ var now = func() time.Time { // Config stores the configuration for fetching tokens with external credentials. type Config struct { + // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload + // identity pool or the workforce pool and the provider identifier in that pool. Audience string + // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec + // e.g. `urn:ietf:params:oauth:token-type:jwt`. SubjectTokenType string + // TokenURL is the STS token exchange endpoint. TokenURL string + // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( + // user attributes like account identifier, eg. email, username, uid, etc). This is + // needed for gCloud session account identification. TokenInfoURL string + // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only + // required for workload identity pools when APIs to be accessed have not integrated with UberMint. ServiceAccountImpersonationURL string + // ClientSecret is currently only required if token_info endpoint also + // needs to be called with the generated GCP access token. When provided, STS will be + // called with additional basic authentication using client_id as username and client_secret as password. ClientSecret string + // ClientID is only required in conjunction with ClientSecret, as described above. ClientID string + // CredentialSource contains the necessary information to retrieve the token itself, as well + // as some environmental information. CredentialSource CredentialSource + // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries + // will set the x-goog-user-project which overrides the project associated with the credentials. QuotaProjectID string + // Scopes contains the desired scopes for the returned access token. Scopes []string } @@ -66,6 +85,8 @@ type format struct { } // CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. +// Either the File or the URL field should be filled, depending on the kind of credential in question. +// The EnvironmentID should start with AWS if being used for an AWS credential. type CredentialSource struct { File string `json:"file"` @@ -107,7 +128,7 @@ type baseCredentialSource interface { subjectToken() (string, error) } -// tokenSource is the source that handles external credentials. +// tokenSource is the source that handles external credentials. It is used to retrieve Tokens. type tokenSource struct { ctx context.Context conf *Config diff --git a/google/internal/externalaccount/clientauth.go b/google/internal/externalaccount/clientauth.go index feccf8b..62c2e36 100644 --- a/google/internal/externalaccount/clientauth.go +++ b/google/internal/externalaccount/clientauth.go @@ -19,6 +19,9 @@ type clientAuthentication struct { ClientSecret string } +// InjectAuthentication is used to add authentication to a Secure Token Service exchange +// request. It modifies either the passed url.Values or http.Header depending on the desired +// authentication format. func (c *clientAuthentication) InjectAuthentication(values url.Values, headers http.Header) { if c.ClientID == "" || c.ClientSecret == "" || values == nil || headers == nil { return diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/externalaccount/impersonate.go index 1d29c46..1f6009b 100644 --- a/google/internal/externalaccount/impersonate.go +++ b/google/internal/externalaccount/impersonate.go @@ -36,7 +36,7 @@ type impersonateTokenSource struct { scopes []string } -// Token performs the exchange to get a temporary service account +// Token performs the exchange to get a temporary service account token to allow access to GCP. func (its impersonateTokenSource) Token() (*oauth2.Token, error) { reqBody := generateAccessTokenReq{ Lifetime: "3600s", From a41e5a7819143202e8d21fb9ac9c659fbd501fb3 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Thu, 24 Jun 2021 23:26:16 +0000 Subject: [PATCH 5/5] downscope: implement support for token downscoping Implements support for token downscoping to allow for the creation of tokens with restricted permissions Change-Id: I52459bdb0dfdd5e8d86e6043ba0362f4bf4b823c GitHub-Last-Rev: 941cf10a8ebe14d2b03bf7253731134629fc7f80 GitHub-Pull-Request: golang/oauth2#502 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/326529 Reviewed-by: Chris Broadfoot Run-TryBot: Chris Broadfoot TryBot-Result: Go Bot Trust: Tyler Bui-Palsulich Trust: Cody Oss --- google/downscope/downscoping.go | 190 +++++++++++++++++++++++++++ google/downscope/downscoping_test.go | 55 ++++++++ google/downscope/example_test.go | 38 ++++++ 3 files changed, 283 insertions(+) create mode 100644 google/downscope/downscoping.go create mode 100644 google/downscope/downscoping_test.go create mode 100644 google/downscope/example_test.go diff --git a/google/downscope/downscoping.go b/google/downscope/downscoping.go new file mode 100644 index 0000000..2d74c37 --- /dev/null +++ b/google/downscope/downscoping.go @@ -0,0 +1,190 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package downscope implements the ability to downscope, or restrict, the +Identity and AccessManagement 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 +*/ +package downscope + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "golang.org/x/oauth2" +) + +var ( + identityBindingEndpoint = "https://sts.googleapis.com/v1/token" +) + +type accessBoundary struct { + AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` +} + +// 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 + 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"` +} + +// 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"` +} + +type downscopedTokenResponse struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// DownscopingConfig specifies the information necessary to request a downscoped token. +type DownscopingConfig struct { + // RootSource is the TokenSource used to create the downscoped token. + // The downscoped token therefore has some subset of the accesses of + // the original RootSource. + RootSource oauth2.TokenSource + // Rules defines the accesses held by the new + // downscoped Token. One or more AccessBoundaryRules are required to + // define permissions for the new downscoped token. Each one defines an + // access (or set of accesses) that the new token has to a given resource. + // There can be a maximum of 10 AccessBoundaryRules. + Rules []AccessBoundaryRule +} + +// A downscopingTokenSource is used to retrieve a downscoped token with restricted +// permissions compared to the root Token that is used to generate it. +type downscopingTokenSource struct { + // ctx is the context used to query the API to retrieve a downscoped Token. + ctx context.Context + // config holds the information necessary to generate a downscoped Token. + config DownscopingConfig +} + +// NewTokenSource returns an empty downscopingTokenSource. +func NewTokenSource(ctx context.Context, conf DownscopingConfig) (oauth2.TokenSource, error) { + if conf.RootSource == nil { + return nil, fmt.Errorf("downscope: rootSource cannot be nil") + } + if len(conf.Rules) == 0 { + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") + } + if len(conf.Rules) > 10 { + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") + } + for _, val := range conf.Rules { + if val.AvailableResource == "" { + return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource: %+v", val) + } + if len(val.AvailablePermissions) == 0 { + return nil, fmt.Errorf("downscope: all rules must provide at least one permission: %+v", val) + } + } + return downscopingTokenSource{ctx: ctx, config: conf}, nil +} + +// Token() uses a downscopingTokenSource to generate an oauth2 Token. +// Do note that the returned TokenSource is an oauth2.StaticTokenSource. If you wish +// to refresh this token automatically, then initialize a locally defined +// TokenSource struct with the Token held by the StaticTokenSource and wrap +// that TokenSource in an oauth2.ReuseTokenSource. +func (dts downscopingTokenSource) Token() (*oauth2.Token, error) { + + downscopedOptions := struct { + Boundary accessBoundary `json:"accessBoundary"` + }{ + Boundary: accessBoundary{ + AccessBoundaryRules: dts.config.Rules, + }, + } + + tok, err := dts.config.RootSource.Token() + if err != nil { + return nil, fmt.Errorf("downscope: unable to obtain root token: %v", err) + } + + b, err := json.Marshal(downscopedOptions) + if err != nil { + return nil, fmt.Errorf("downscope: unable to marshal AccessBoundary payload %v", 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.AccessToken) + form.Add("options", string(b)) + + myClient := oauth2.NewClient(dts.ctx, nil) + resp, err := myClient.PostForm(identityBindingEndpoint, form) + if err != nil { + return nil, fmt.Errorf("unable to generate POST Request %v", err) + } + defer resp.Body.Close() + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("downscope: unable to read reaponse body: %v", err) + } + if resp.StatusCode != http.StatusOK { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("downscope: unable to exchange token; %v. Failed to read response body: %v", resp.StatusCode, err) + } + return nil, fmt.Errorf("downscope: unable to exchange token; %v. Server responsed: %v", resp.StatusCode, string(b)) + } + + var tresp downscopedTokenResponse + + err = json.Unmarshal(respBody, &tresp) + if err != nil { + return nil, fmt.Errorf("downscope: unable to unmarshal response body: %v", 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 + } + + newToken := &oauth2.Token{ + AccessToken: tresp.AccessToken, + TokenType: tresp.TokenType, + Expiry: expiryTime, + } + return newToken, nil +} diff --git a/google/downscope/downscoping_test.go b/google/downscope/downscoping_test.go new file mode 100644 index 0000000..d5adda1 --- /dev/null +++ b/google/downscope/downscoping_test.go @@ -0,0 +1,55 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package downscope + +import ( + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" +) + +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=Mellon&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token" + standardRespBody = `{"access_token":"Open Sesame","expires_in":432,"issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer"}` +) + +func Test_DownscopedTokenSource(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 := ioutil.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)) + + })) + new := []AccessBoundaryRule{ + { + AvailableResource: "test1", + AvailablePermissions: []string{"Perm1", "Perm2"}, + }, + } + myTok := oauth2.Token{AccessToken: "Mellon"} + tmpSrc := oauth2.StaticTokenSource(&myTok) + dts := downscopingTokenSource{context.Background(), DownscopingConfig{tmpSrc, new}} + identityBindingEndpoint = ts.URL + _, err := dts.Token() + if err != nil { + t.Fatalf("NewDownscopedTokenSource failed with error: %v", err) + } +} diff --git a/google/downscope/example_test.go b/google/downscope/example_test.go new file mode 100644 index 0000000..061cf57 --- /dev/null +++ b/google/downscope/example_test.go @@ -0,0 +1,38 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package downscope_test + +import ( + "context" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google/downscope" +) + +func ExampleNewTokenSource() { + ctx := context.Background() + // Initializes an accessBoundary with one Rule. + accessBoundary := []downscope.AccessBoundaryRule{ + { + AvailableResource: "//storage.googleapis.com/projects/_/buckets/foo", + AvailablePermissions: []string{"inRole:roles/storage.objectViewer"}, + }, + } + + var rootSource oauth2.TokenSource + // This Source can be initialized in multiple ways; the following example uses + // Application Default Credentials. + + // rootSource, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform") + + dts, err := downscope.NewTokenSource(ctx, downscope.DownscopingConfig{RootSource: rootSource, Rules: accessBoundary}) + if err != nil { + _ = dts + } + // You can now use the token held in myTokenSource to make + // Google Cloud Storage calls, as follows: + + // storageClient, err := storage.NewClient(ctx, option.WithTokenSource(myTokenSource)) +}