From a8e26b9ca42882fab141b70a957c66c33ade0eee Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Wed, 9 Jun 2021 10:46:53 -0700 Subject: [PATCH] oauth2/google: implement support for token downscoping to allow for restricted permissions --- google/downscoped/downscoping.go | 116 ++++++++++++++++++++++++++ google/downscoped/downscoping_test.go | 55 ++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 google/downscoped/downscoping.go create mode 100644 google/downscoped/downscoping_test.go diff --git a/google/downscoped/downscoping.go b/google/downscoped/downscoping.go new file mode 100644 index 0000000..5d2ce6e --- /dev/null +++ b/google/downscoped/downscoping.go @@ -0,0 +1,116 @@ +package downscoped + +import ( + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "io/ioutil" + "net/http" + "net/url" + "time" +) + +const ( + IDENTITY_BINDING_ENDPOINT = "https://sts.googleapis.com/v1beta/token" +) + +// Defines an upper bound of permissions available for a GCP credential for one or more resources +type AccessBoundary struct { + AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` +} + +func NewAccessBoundary() AccessBoundary { + return AccessBoundary{make([]AccessBoundaryRule, 0)} +} + +type AvailabilityCondition struct { + Title string `json:"title,omitempty"` + Expression string `json:"expression"` + Description string `json:"description,omitempty"` +} + +type AccessBoundaryRule struct { + AvailableResource string `json:"availableResource"` + AvailablePermissions []string `json:"availablePermissions"` + 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"` +} + +type DownscopingConfig struct { + RootSource oauth2.TokenSource + CredentialAccessBoundary AccessBoundary +} + +func DownscopedTokenWithEndpoint(config DownscopingConfig, endpoint string) (oauth2.TokenSource, error) { + if config.RootSource == nil { + return nil, fmt.Errorf("oauth2/google: rootTokenSource cannot be nil") + } + if len(config.CredentialAccessBoundary.AccessBoundaryRules) == 0 { + return nil, fmt.Errorf("oauth2/google: length of AccessBoundaryRules must be at least 1") + } + + downscopedOptions := struct { + Boundary AccessBoundary `json:"accessBoundary"` + }{ + Boundary: config.CredentialAccessBoundary, + } + + tok, err := config.RootSource.Token() + if err != nil { + return nil, fmt.Errorf("oauth2/google: unable to refresh root token %v", err) + } + + b, err := json.Marshal(downscopedOptions) // TODO: make sure that this marshals properly! + if err != nil { + return nil, fmt.Errorf("oauth2/google: Unable to marshall 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", url.QueryEscape(string(b))) + + resp, err := http.PostForm(endpoint, form) + defer resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("unable to generate POST Request %v", err) + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("unable to exchange token %v", string(bodyBytes)) + } + + tresp := DownScopedTokenResponse{} + json.NewDecoder(resp.Body).Decode(&tresp) + + // 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 expiry_time time.Time + if tresp.ExpiresIn > 0 { + expiry_time = time.Now().Add(time.Duration(time.Duration(tresp.ExpiresIn) * time.Second)) + } else { + expiry_time = tok.Expiry + } + + newToken := &oauth2.Token{ + AccessToken: tresp.AccessToken, + TokenType: tresp.TokenType, + Expiry: expiry_time, + } + return oauth2.StaticTokenSource(newToken), nil +} + +func NewDownscopedTokenSource(config DownscopingConfig) (oauth2.TokenSource, error) { + return DownscopedTokenWithEndpoint(config, IDENTITY_BINDING_ENDPOINT) +} diff --git a/google/downscoped/downscoping_test.go b/google/downscoped/downscoping_test.go new file mode 100644 index 0000000..fca1601 --- /dev/null +++ b/google/downscoped/downscoping_test.go @@ -0,0 +1,55 @@ +package downscoped + +import ( + "golang.org/x/oauth2" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +var ( + standardReqBody = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%257B%2522accessBoundary%2522%253A%257B%2522accessBoundaryRules%2522%253A%255B%257B%2522availableResource%2522%253A%2522test1%2522%252C%2522availablePermissions%2522%253A%255B%2522Perm1%252C%2Bperm2%2522%255D%257D%255D%257D%257D&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_NewAccessBoundary(t *testing.T) { + got := NewAccessBoundary() + want := AccessBoundary{nil} + if got.AccessBoundaryRules == nil || len(got.AccessBoundaryRules) != 0 { + t.Errorf("NewAccessBoundary() = %v; want %v", got, want) + } +} + +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() != "/" { //TODO: Will this work, or do I need to redirect this to this test server instead? + 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 := NewAccessBoundary() + new.AccessBoundaryRules = append(new.AccessBoundaryRules, AccessBoundaryRule{"test1", []string{"Perm1, perm2"}, nil}) + myTok := oauth2.Token{AccessToken: "Mellon"} + tmpSrc := oauth2.StaticTokenSource(&myTok) + out, err := DownscopedTokenWithEndpoint(DownscopingConfig{tmpSrc, new}, ts.URL) + if err != nil { + t.Fatalf("NewDownscopedTokenSource failed with error: %v", err) + } + _, err = out.Token() + if err != nil { + t.Fatalf("Token() call failed with error %v", err) + } +}