Skip to content

Commit 0b49973

Browse files
gIthurieltbpg
authored andcommitted
google: add ExchangeToken() to run STS exchanges.
Adds the ExchangeToken() function and support structs, but depends on #439 Change-Id: Id738a27b0c2ac083409156af1f60283b9140b159 GitHub-Last-Rev: 1aa066d GitHub-Pull-Request: #444 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/261918 Run-TryBot: Tyler Bui-Palsulich <[email protected]> TryBot-Result: Go Bot <[email protected]> Trust: Tyler Bui-Palsulich <[email protected]> Trust: Cody Oss <[email protected]> Reviewed-by: Tyler Bui-Palsulich <[email protected]>
1 parent 9fd6049 commit 0b49973

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package externalaccount
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"golang.org/x/oauth2"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strconv"
16+
"strings"
17+
)
18+
19+
// ExchangeToken performs an oauth2 token exchange with the provided endpoint.
20+
// The first 4 fields are all mandatory. headers can be used to pass additional
21+
// headers beyond the bare minimum required by the token exchange. options can
22+
// be used to pass additional JSON-structured options to the remote server.
23+
func ExchangeToken(ctx context.Context, endpoint string, request *STSTokenExchangeRequest, authentication ClientAuthentication, headers http.Header, options map[string]interface{}) (*STSTokenExchangeResponse, error) {
24+
25+
client := oauth2.NewClient(ctx, nil)
26+
27+
data := url.Values{}
28+
data.Set("audience", request.Audience)
29+
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
30+
data.Set("requested_token_type", "urn:ietf:params:oauth:token-type:access_token")
31+
data.Set("subject_token_type", request.SubjectTokenType)
32+
data.Set("subject_token", request.SubjectToken)
33+
data.Set("scope", strings.Join(request.Scope, " "))
34+
opts, err := json.Marshal(options)
35+
if err != nil {
36+
return nil, fmt.Errorf("oauth2/google: failed to marshal additional options: %v", err)
37+
}
38+
data.Set("options", string(opts))
39+
40+
authentication.InjectAuthentication(data, headers)
41+
encodedData := data.Encode()
42+
43+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(encodedData))
44+
if err != nil {
45+
return nil, fmt.Errorf("oauth2/google: failed to properly build http request: %v", err)
46+
47+
}
48+
for key, list := range headers {
49+
for _, val := range list {
50+
req.Header.Add(key, val)
51+
}
52+
}
53+
req.Header.Add("Content-Length", strconv.Itoa(len(encodedData)))
54+
55+
resp, err := client.Do(req)
56+
57+
if err != nil {
58+
return nil, fmt.Errorf("oauth2/google: invalid response from Secure Token Server: %v", err)
59+
}
60+
defer resp.Body.Close()
61+
62+
bodyJson := json.NewDecoder(io.LimitReader(resp.Body, 1<<20))
63+
var stsResp STSTokenExchangeResponse
64+
err = bodyJson.Decode(&stsResp)
65+
if err != nil {
66+
return nil, fmt.Errorf("oauth2/google: failed to unmarshal response body from Secure Token Server: %v", err)
67+
68+
}
69+
70+
return &stsResp, nil
71+
}
72+
73+
// STSTokenExchangeRequest contains fields necessary to make an oauth2 token exchange.
74+
type STSTokenExchangeRequest struct {
75+
ActingParty struct {
76+
ActorToken string
77+
ActorTokenType string
78+
}
79+
GrantType string
80+
Resource string
81+
Audience string
82+
Scope []string
83+
RequestedTokenType string
84+
SubjectToken string
85+
SubjectTokenType string
86+
}
87+
88+
// STSTokenExchangeResponse is used to decode the remote server response during an oauth2 token exchange.
89+
type STSTokenExchangeResponse struct {
90+
AccessToken string `json:"access_token"`
91+
IssuedTokenType string `json:"issued_token_type"`
92+
TokenType string `json:"token_type"`
93+
ExpiresIn int `json:"expires_in"`
94+
Scope string `json:"scope"`
95+
RefreshToken string `json:"refresh_token"`
96+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package externalaccount
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"golang.org/x/oauth2"
11+
"io/ioutil"
12+
"net/http"
13+
"net/http/httptest"
14+
"net/url"
15+
"testing"
16+
)
17+
18+
var auth = ClientAuthentication{
19+
AuthStyle: oauth2.AuthStyleInHeader,
20+
ClientID: clientID,
21+
ClientSecret: clientSecret,
22+
}
23+
24+
var tokenRequest = STSTokenExchangeRequest{
25+
ActingParty: struct {
26+
ActorToken string
27+
ActorTokenType string
28+
}{},
29+
GrantType: "urn:ietf:params:oauth:grant-type:token-exchange",
30+
Resource: "",
31+
Audience: "32555940559.apps.googleusercontent.com", //TODO: Make sure audience is correct in this test (might be mismatched)
32+
Scope: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
33+
RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
34+
SubjectToken: "Sample.Subject.Token",
35+
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
36+
}
37+
38+
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"
39+
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"}`
40+
var expectedToken = STSTokenExchangeResponse{
41+
AccessToken: "Sample.Access.Token",
42+
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
43+
TokenType: "Bearer",
44+
ExpiresIn: 3600,
45+
Scope: "https://www.googleapis.com/auth/cloud-platform",
46+
RefreshToken: "",
47+
}
48+
49+
func TestExchangeToken(t *testing.T) {
50+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+
if r.Method != "POST" {
52+
t.Errorf("Unexpected request method, %v is found", r.Method)
53+
}
54+
if r.URL.String() != "/" {
55+
t.Errorf("Unexpected request URL, %v is found", r.URL)
56+
}
57+
if got, want := r.Header.Get("Authorization"), "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
58+
t.Errorf("Unexpected authorization header, got %v, want %v", got, want)
59+
}
60+
if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
61+
t.Errorf("Unexpected Content-Type header, got %v, want %v", got, want)
62+
}
63+
body, err := ioutil.ReadAll(r.Body)
64+
if err != nil {
65+
t.Errorf("Failed reading request body: %v.", err)
66+
}
67+
if got, want := string(body), requestbody; got != want {
68+
t.Errorf("Unexpected exchange payload, got %v but want %v", got, want)
69+
}
70+
w.Header().Set("Content-Type", "application/json")
71+
w.Write([]byte(responseBody))
72+
}))
73+
defer ts.Close()
74+
75+
headers := http.Header{}
76+
headers.Add("Content-Type", "application/x-www-form-urlencoded")
77+
78+
resp, err := ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil)
79+
if err != nil {
80+
t.Fatalf("ExchangeToken failed with error: %v", err)
81+
}
82+
83+
if expectedToken != *resp {
84+
t.Errorf("mismatched messages received by mock server. \nWant: \n%v\n\nGot:\n%v", expectedToken, *resp)
85+
}
86+
87+
}
88+
89+
func TestExchangeToken_Err(t *testing.T) {
90+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
w.Header().Set("Content-Type", "application/json")
92+
w.Write([]byte("what's wrong with this response?"))
93+
}))
94+
defer ts.Close()
95+
96+
headers := http.Header{}
97+
headers.Add("Content-Type", "application/x-www-form-urlencoded")
98+
_, err := ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil)
99+
if err == nil {
100+
t.Errorf("Expected handled error; instead got nil.")
101+
}
102+
}
103+
104+
/* Lean test specifically for options, as the other features are tested earlier. */
105+
type testOpts struct {
106+
First string `json:"first"`
107+
Second string `json:"second"`
108+
}
109+
110+
var optsValues = [][]string{{"foo", "bar"}, {"cat", "pan"}}
111+
112+
func TestExchangeToken_Opts(t *testing.T) {
113+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+
body, err := ioutil.ReadAll(r.Body)
115+
if err != nil {
116+
t.Fatalf("Failed reading request body: %v.", err)
117+
}
118+
data, err := url.ParseQuery(string(body))
119+
if err != nil {
120+
t.Fatalf("Failed to parse request body: %v", err)
121+
}
122+
strOpts, ok := data["options"]
123+
if !ok {
124+
t.Errorf("Server didn't recieve an \"options\" field.")
125+
} else if len(strOpts) < 1 {
126+
t.Errorf("\"options\" field has length 0.")
127+
}
128+
var opts map[string]interface{}
129+
err = json.Unmarshal([]byte(strOpts[0]), &opts)
130+
if len(opts) < 2 {
131+
t.Errorf("Too few options received.")
132+
}
133+
134+
val, ok := opts["one"]
135+
if !ok {
136+
t.Errorf("Couldn't find first option parameter.")
137+
} else {
138+
tOpts1, ok := val.(map[string]interface{})
139+
if !ok {
140+
t.Errorf("Failed to assert the first option parameter as type testOpts.")
141+
} else {
142+
if got, want := tOpts1["first"].(string), optsValues[0][0]; got != want {
143+
t.Errorf("First value in first options field is incorrect; got %v but want %v", got, want)
144+
}
145+
if got, want := tOpts1["second"].(string), optsValues[0][1]; got != want {
146+
t.Errorf("Second value in first options field is incorrect; got %v but want %v", got, want)
147+
}
148+
}
149+
}
150+
151+
val2, ok := opts["two"]
152+
if !ok {
153+
t.Errorf("Couldn't find second option parameter.")
154+
} else {
155+
tOpts2, ok := val2.(map[string]interface{})
156+
if !ok {
157+
t.Errorf("Failed to assert the second option parameter as type testOpts.")
158+
} else {
159+
if got, want := tOpts2["first"].(string), optsValues[1][0]; got != want {
160+
t.Errorf("First value in second options field is incorrect; got %v but want %v", got, want)
161+
}
162+
if got, want := tOpts2["second"].(string), optsValues[1][1]; got != want {
163+
t.Errorf("Second value in second options field is incorrect; got %v but want %v", got, want)
164+
}
165+
}
166+
}
167+
168+
// Send a proper reply so that no other errors crop up.
169+
w.Header().Set("Content-Type", "application/json")
170+
w.Write([]byte(responseBody))
171+
172+
}))
173+
defer ts.Close()
174+
headers := http.Header{}
175+
headers.Add("Content-Type", "application/x-www-form-urlencoded")
176+
177+
firstOption := testOpts{optsValues[0][0], optsValues[0][1]}
178+
secondOption := testOpts{optsValues[1][0], optsValues[1][1]}
179+
inputOpts := make(map[string]interface{})
180+
inputOpts["one"] = firstOption
181+
inputOpts["two"] = secondOption
182+
ExchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, inputOpts)
183+
}

0 commit comments

Comments
 (0)