From 0b49973bad195855a6a62fc57db590cc1bfe0771 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Wed, 2 Dec 2020 23:43:55 +0000 Subject: [PATCH] google: add ExchangeToken() to run STS exchanges. Adds the ExchangeToken() function and support structs, but depends on https://github.com/golang/oauth2/pull/439 Change-Id: Id738a27b0c2ac083409156af1f60283b9140b159 GitHub-Last-Rev: 1aa066dc21e11a278f2698f982b7e7c857114d0a GitHub-Pull-Request: golang/oauth2#444 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/261918 Run-TryBot: Tyler Bui-Palsulich TryBot-Result: Go Bot Trust: Tyler Bui-Palsulich Trust: Cody Oss Reviewed-by: Tyler Bui-Palsulich --- .../internal/externalaccount/sts_exchange.go | 96 +++++++++ .../externalaccount/sts_exchange_test.go | 183 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 google/internal/externalaccount/sts_exchange.go create mode 100644 google/internal/externalaccount/sts_exchange_test.go diff --git a/google/internal/externalaccount/sts_exchange.go b/google/internal/externalaccount/sts_exchange.go new file mode 100644 index 0000000..d7f54e0 --- /dev/null +++ b/google/internal/externalaccount/sts_exchange.go @@ -0,0 +1,96 @@ +// Copyright 2020 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 externalaccount + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +// ExchangeToken performs an oauth2 token exchange with the provided endpoint. +// The first 4 fields are all mandatory. headers can be used to pass additional +// headers beyond the bare minimum required by the token exchange. options can +// be used to pass additional JSON-structured options to the remote server. +func ExchangeToken(ctx context.Context, endpoint string, request *STSTokenExchangeRequest, authentication ClientAuthentication, headers http.Header, options map[string]interface{}) (*STSTokenExchangeResponse, error) { + + client := oauth2.NewClient(ctx, nil) + + data := url.Values{} + data.Set("audience", request.Audience) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + data.Set("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + data.Set("subject_token_type", request.SubjectTokenType) + data.Set("subject_token", request.SubjectToken) + data.Set("scope", strings.Join(request.Scope, " ")) + opts, err := json.Marshal(options) + if err != nil { + return nil, fmt.Errorf("oauth2/google: failed to marshal additional options: %v", err) + } + data.Set("options", string(opts)) + + authentication.InjectAuthentication(data, headers) + encodedData := data.Encode() + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(encodedData)) + if err != nil { + return nil, fmt.Errorf("oauth2/google: failed to properly build http request: %v", err) + + } + for key, list := range headers { + for _, val := range list { + req.Header.Add(key, val) + } + } + req.Header.Add("Content-Length", strconv.Itoa(len(encodedData))) + + resp, err := client.Do(req) + + if err != nil { + return nil, fmt.Errorf("oauth2/google: invalid response from Secure Token Server: %v", err) + } + defer resp.Body.Close() + + bodyJson := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + var stsResp STSTokenExchangeResponse + err = bodyJson.Decode(&stsResp) + if err != nil { + return nil, fmt.Errorf("oauth2/google: failed to unmarshal response body from Secure Token Server: %v", err) + + } + + return &stsResp, nil +} + +// STSTokenExchangeRequest contains fields necessary to make an oauth2 token exchange. +type STSTokenExchangeRequest struct { + ActingParty struct { + ActorToken string + ActorTokenType string + } + GrantType string + Resource string + Audience string + Scope []string + RequestedTokenType string + SubjectToken string + SubjectTokenType string +} + +// STSTokenExchangeResponse is used to decode the remote server response during an oauth2 token exchange. +type STSTokenExchangeResponse struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` +} diff --git a/google/internal/externalaccount/sts_exchange_test.go b/google/internal/externalaccount/sts_exchange_test.go new file mode 100644 index 0000000..bd4034a --- /dev/null +++ b/google/internal/externalaccount/sts_exchange_test.go @@ -0,0 +1,183 @@ +// Copyright 2020 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 externalaccount + +import ( + "context" + "encoding/json" + "golang.org/x/oauth2" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +var auth = ClientAuthentication{ + AuthStyle: oauth2.AuthStyleInHeader, + ClientID: clientID, + ClientSecret: clientSecret, +} + +var tokenRequest = STSTokenExchangeRequest{ + ActingParty: struct { + ActorToken string + ActorTokenType string + }{}, + GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", + Resource: "", + Audience: "32555940559.apps.googleusercontent.com", //TODO: Make sure audience is correct in this test (might be mismatched) + Scope: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", + SubjectToken: "Sample.Subject.Token", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", +} + +var requestbody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=null&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=Sample.Subject.Token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" +var responseBody = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}` +var expectedToken = STSTokenExchangeResponse{ + AccessToken: "Sample.Access.Token", + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + TokenType: "Bearer", + ExpiresIn: 3600, + Scope: "https://www.googleapis.com/auth/cloud-platform", + RefreshToken: "", +} + +func TestExchangeToken(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) + } + if got, want := r.Header.Get("Authorization"), "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want { + t.Errorf("Unexpected authorization header, got %v, want %v", got, want) + } + if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want { + t.Errorf("Unexpected Content-Type header, got %v, want %v", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Errorf("Failed reading request body: %v.", err) + } + if got, want := string(body), requestbody; got != want { + t.Errorf("Unexpected exchange payload, got %v but want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(responseBody)) + })) + defer ts.Close() + + headers := http.Header{} + headers.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil) + if err != nil { + t.Fatalf("ExchangeToken failed with error: %v", err) + } + + if expectedToken != *resp { + t.Errorf("mismatched messages received by mock server. \nWant: \n%v\n\nGot:\n%v", expectedToken, *resp) + } + +} + +func TestExchangeToken_Err(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("what's wrong with this response?")) + })) + defer ts.Close() + + headers := http.Header{} + headers.Add("Content-Type", "application/x-www-form-urlencoded") + _, err := ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil) + if err == nil { + t.Errorf("Expected handled error; instead got nil.") + } +} + +/* Lean test specifically for options, as the other features are tested earlier. */ +type testOpts struct { + First string `json:"first"` + Second string `json:"second"` +} + +var optsValues = [][]string{{"foo", "bar"}, {"cat", "pan"}} + +func TestExchangeToken_Opts(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed reading request body: %v.", err) + } + data, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("Failed to parse request body: %v", err) + } + strOpts, ok := data["options"] + if !ok { + t.Errorf("Server didn't recieve an \"options\" field.") + } else if len(strOpts) < 1 { + t.Errorf("\"options\" field has length 0.") + } + var opts map[string]interface{} + err = json.Unmarshal([]byte(strOpts[0]), &opts) + if len(opts) < 2 { + t.Errorf("Too few options received.") + } + + val, ok := opts["one"] + if !ok { + t.Errorf("Couldn't find first option parameter.") + } else { + tOpts1, ok := val.(map[string]interface{}) + if !ok { + t.Errorf("Failed to assert the first option parameter as type testOpts.") + } else { + if got, want := tOpts1["first"].(string), optsValues[0][0]; got != want { + t.Errorf("First value in first options field is incorrect; got %v but want %v", got, want) + } + if got, want := tOpts1["second"].(string), optsValues[0][1]; got != want { + t.Errorf("Second value in first options field is incorrect; got %v but want %v", got, want) + } + } + } + + val2, ok := opts["two"] + if !ok { + t.Errorf("Couldn't find second option parameter.") + } else { + tOpts2, ok := val2.(map[string]interface{}) + if !ok { + t.Errorf("Failed to assert the second option parameter as type testOpts.") + } else { + if got, want := tOpts2["first"].(string), optsValues[1][0]; got != want { + t.Errorf("First value in second options field is incorrect; got %v but want %v", got, want) + } + if got, want := tOpts2["second"].(string), optsValues[1][1]; got != want { + t.Errorf("Second value in second options field is incorrect; got %v but want %v", got, want) + } + } + } + + // Send a proper reply so that no other errors crop up. + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(responseBody)) + + })) + defer ts.Close() + headers := http.Header{} + headers.Add("Content-Type", "application/x-www-form-urlencoded") + + firstOption := testOpts{optsValues[0][0], optsValues[0][1]} + secondOption := testOpts{optsValues[1][0], optsValues[1][1]} + inputOpts := make(map[string]interface{}) + inputOpts["one"] = firstOption + inputOpts["two"] = secondOption + ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, inputOpts) +}