google/externalaccount: validate tokenURL and ServiceAccountImpersonationURL
Change-Id: Iab70cc967fd97ac8e349a14760df0f8b02ddf074
GitHub-Last-Rev: ddf4dbd0b7096a0d34677047b9c3992bb6ed359b
GitHub-Pull-Request: golang/oauth2#514
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/340569
Reviewed-by: Patrick Jones <ithuriel@google.com>
Reviewed-by: Cody Oss <codyoss@google.com>
Reviewed-by: Chris Broadfoot <cbro@golang.org>
Trust: Cody Oss <codyoss@google.com>
Run-TryBot: Cody Oss <codyoss@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Patrick Jones authored 2 years ago
Cody Oss committed 2 years ago
176 | 176 | QuotaProjectID: f.QuotaProjectID, |
177 | 177 | Scopes: params.Scopes, |
178 | 178 | } |
179 | return cfg.TokenSource(ctx), nil | |
179 | return cfg.TokenSource(ctx) | |
180 | 180 | case "": |
181 | 181 | return nil, errors.New("missing 'type' field in credentials") |
182 | 182 | default: |
27 | 27 | |
28 | 28 | func setEnvironment(env map[string]string) func(string) string { |
29 | 29 | return func(key string) string { |
30 | value, _ := env[key] | |
31 | return value | |
30 | return env[key] | |
32 | 31 | } |
33 | 32 | } |
34 | 33 | |
649 | 648 | getenv = setEnvironment(map[string]string{ |
650 | 649 | "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", |
651 | 650 | "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", |
652 | "AWS_DEFAULT_REGION": "us-west-1", | |
651 | "AWS_DEFAULT_REGION": "us-west-1", | |
653 | 652 | }) |
654 | 653 | |
655 | 654 | base, err := tfc.parse(context.Background()) |
687 | 686 | "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", |
688 | 687 | "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", |
689 | 688 | "AWS_REGION": "us-west-1", |
690 | "AWS_DEFAULT_REGION": "us-east-1", | |
689 | "AWS_DEFAULT_REGION": "us-east-1", | |
691 | 690 | }) |
692 | 691 | |
693 | 692 | base, err := tfc.parse(context.Background()) |
6 | 6 | import ( |
7 | 7 | "context" |
8 | 8 | "fmt" |
9 | "net/http" | |
10 | "net/url" | |
11 | "regexp" | |
12 | "strconv" | |
13 | "strings" | |
14 | "time" | |
15 | ||
9 | 16 | "golang.org/x/oauth2" |
10 | "net/http" | |
11 | "strconv" | |
12 | "time" | |
13 | 17 | ) |
14 | 18 | |
15 | 19 | // now aliases time.Now for testing |
21 | 25 | type Config struct { |
22 | 26 | // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload |
23 | 27 | // identity pool or the workforce pool and the provider identifier in that pool. |
24 | Audience string | |
28 | Audience string | |
25 | 29 | // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec |
26 | 30 | // e.g. `urn:ietf:params:oauth:token-type:jwt`. |
27 | SubjectTokenType string | |
31 | SubjectTokenType string | |
28 | 32 | // TokenURL is the STS token exchange endpoint. |
29 | TokenURL string | |
33 | TokenURL string | |
30 | 34 | // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( |
31 | 35 | // user attributes like account identifier, eg. email, username, uid, etc). This is |
32 | 36 | // needed for gCloud session account identification. |
33 | TokenInfoURL string | |
37 | TokenInfoURL string | |
34 | 38 | // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only |
35 | 39 | // required for workload identity pools when APIs to be accessed have not integrated with UberMint. |
36 | 40 | ServiceAccountImpersonationURL string |
37 | 41 | // ClientSecret is currently only required if token_info endpoint also |
38 | 42 | // needs to be called with the generated GCP access token. When provided, STS will be |
39 | 43 | // called with additional basic authentication using client_id as username and client_secret as password. |
40 | ClientSecret string | |
44 | ClientSecret string | |
41 | 45 | // ClientID is only required in conjunction with ClientSecret, as described above. |
42 | ClientID string | |
46 | ClientID string | |
43 | 47 | // CredentialSource contains the necessary information to retrieve the token itself, as well |
44 | 48 | // as some environmental information. |
45 | CredentialSource CredentialSource | |
49 | CredentialSource CredentialSource | |
46 | 50 | // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries |
47 | 51 | // will set the x-goog-user-project which overrides the project associated with the credentials. |
48 | QuotaProjectID string | |
52 | QuotaProjectID string | |
49 | 53 | // Scopes contains the desired scopes for the returned access token. |
50 | Scopes []string | |
54 | Scopes []string | |
55 | } | |
56 | ||
57 | // Each element consists of a list of patterns. validateURLs checks for matches | |
58 | // that include all elements in a given list, in that order. | |
59 | ||
60 | var ( | |
61 | validTokenURLPatterns = []*regexp.Regexp{ | |
62 | // The complicated part in the middle matches any number of characters that | |
63 | // aren't period, spaces, or slashes. | |
64 | regexp.MustCompile(`(?i)^[^\.\s\/\\]+\.sts\.googleapis\.com$`), | |
65 | regexp.MustCompile(`(?i)^sts\.googleapis\.com$`), | |
66 | regexp.MustCompile(`(?i)^sts\.[^\.\s\/\\]+\.googleapis\.com$`), | |
67 | regexp.MustCompile(`(?i)^[^\.\s\/\\]+-sts\.googleapis\.com$`), | |
68 | } | |
69 | validImpersonateURLPatterns = []*regexp.Regexp{ | |
70 | regexp.MustCompile(`^[^\.\s\/\\]+\.iamcredentials\.googleapis\.com$`), | |
71 | regexp.MustCompile(`^iamcredentials\.googleapis\.com$`), | |
72 | regexp.MustCompile(`^iamcredentials\.[^\.\s\/\\]+\.googleapis\.com$`), | |
73 | regexp.MustCompile(`^[^\.\s\/\\]+-iamcredentials\.googleapis\.com$`), | |
74 | } | |
75 | ) | |
76 | ||
77 | func validateURL(input string, patterns []*regexp.Regexp, scheme string) bool { | |
78 | parsed, err := url.Parse(input) | |
79 | if err != nil { | |
80 | return false | |
81 | } | |
82 | if !strings.EqualFold(parsed.Scheme, scheme) { | |
83 | return false | |
84 | } | |
85 | toTest := parsed.Host | |
86 | ||
87 | for _, pattern := range patterns { | |
88 | ||
89 | if valid := pattern.MatchString(toTest); valid { | |
90 | return true | |
91 | } | |
92 | } | |
93 | return false | |
51 | 94 | } |
52 | 95 | |
53 | 96 | // TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials. |
54 | func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { | |
97 | func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { | |
98 | return c.tokenSource(ctx, validTokenURLPatterns, validImpersonateURLPatterns, "https") | |
99 | } | |
100 | ||
101 | // tokenSource is a private function that's directly called by some of the tests, | |
102 | // because the unit test URLs are mocked, and would otherwise fail the | |
103 | // validity check. | |
104 | func (c *Config) tokenSource(ctx context.Context, tokenURLValidPats []*regexp.Regexp, impersonateURLValidPats []*regexp.Regexp, scheme string) (oauth2.TokenSource, error) { | |
105 | valid := validateURL(c.TokenURL, tokenURLValidPats, scheme) | |
106 | if !valid { | |
107 | return nil, fmt.Errorf("oauth2/google: invalid TokenURL provided while constructing tokenSource") | |
108 | } | |
109 | ||
110 | if c.ServiceAccountImpersonationURL != "" { | |
111 | valid := validateURL(c.ServiceAccountImpersonationURL, impersonateURLValidPats, scheme) | |
112 | if !valid { | |
113 | return nil, fmt.Errorf("oauth2/google: invalid ServiceAccountImpersonationURL provided while constructing tokenSource") | |
114 | } | |
115 | } | |
116 | ||
55 | 117 | ts := tokenSource{ |
56 | 118 | ctx: ctx, |
57 | 119 | conf: c, |
58 | 120 | } |
59 | 121 | if c.ServiceAccountImpersonationURL == "" { |
60 | return oauth2.ReuseTokenSource(nil, ts) | |
122 | return oauth2.ReuseTokenSource(nil, ts), nil | |
61 | 123 | } |
62 | 124 | scopes := c.Scopes |
63 | 125 | ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} |
67 | 129 | scopes: scopes, |
68 | 130 | ts: oauth2.ReuseTokenSource(nil, ts), |
69 | 131 | } |
70 | return oauth2.ReuseTokenSource(nil, imp) | |
132 | return oauth2.ReuseTokenSource(nil, imp), nil | |
71 | 133 | } |
72 | 134 | |
73 | 135 | // Subject token file types. |
77 | 139 | ) |
78 | 140 | |
79 | 141 | type format struct { |
80 | // Type is either "text" or "json". When not provided "text" type is assumed. | |
142 | // Type is either "text" or "json". When not provided "text" type is assumed. | |
81 | 143 | Type string `json:"type"` |
82 | // SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure. | |
144 | // SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure. | |
83 | 145 | SubjectTokenFieldName string `json:"subject_token_field_name"` |
84 | 146 | } |
85 | 147 | |
127 | 189 | subjectToken() (string, error) |
128 | 190 | } |
129 | 191 | |
130 | // tokenSource is the source that handles external credentials. It is used to retrieve Tokens. | |
192 | // tokenSource is the source that handles external credentials. It is used to retrieve Tokens. | |
131 | 193 | type tokenSource struct { |
132 | 194 | ctx context.Context |
133 | 195 | conf *Config |
8 | 8 | "io/ioutil" |
9 | 9 | "net/http" |
10 | 10 | "net/http/httptest" |
11 | "strings" | |
11 | 12 | "testing" |
12 | 13 | "time" |
13 | 14 | ) |
94 | 95 | } |
95 | 96 | |
96 | 97 | } |
98 | ||
99 | func TestValidateURLTokenURL(t *testing.T) { | |
100 | var urlValidityTests = []struct { | |
101 | tokURL string | |
102 | expectSuccess bool | |
103 | }{ | |
104 | {"https://east.sts.googleapis.com", true}, | |
105 | {"https://sts.googleapis.com", true}, | |
106 | {"https://sts.asfeasfesef.googleapis.com", true}, | |
107 | {"https://us-east-1-sts.googleapis.com", true}, | |
108 | {"https://sts.googleapis.com/your/path/here", true}, | |
109 | {"https://.sts.googleapis.com", false}, | |
110 | {"https://badsts.googleapis.com", false}, | |
111 | {"https://sts.asfe.asfesef.googleapis.com", false}, | |
112 | {"https://sts..googleapis.com", false}, | |
113 | {"https://-sts.googleapis.com", false}, | |
114 | {"https://us-ea.st-1-sts.googleapis.com", false}, | |
115 | {"https://sts.googleapis.com.evil.com/whatever/path", false}, | |
116 | {"https://us-eas\\t-1.sts.googleapis.com", false}, | |
117 | {"https:/us-ea/st-1.sts.googleapis.com", false}, | |
118 | {"https:/us-east 1.sts.googleapis.com", false}, | |
119 | {"https://", false}, | |
120 | {"http://us-east-1.sts.googleapis.com", false}, | |
121 | {"https://us-east-1.sts.googleapis.comevil.com", false}, | |
122 | } | |
123 | ctx := context.Background() | |
124 | for _, tt := range urlValidityTests { | |
125 | t.Run(" "+tt.tokURL, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability. | |
126 | config := testConfig | |
127 | config.TokenURL = tt.tokURL | |
128 | _, err := config.TokenSource(ctx) | |
129 | ||
130 | if tt.expectSuccess && err != nil { | |
131 | t.Errorf("got %v but want nil", err) | |
132 | } else if !tt.expectSuccess && err == nil { | |
133 | t.Errorf("got nil but expected an error") | |
134 | } | |
135 | }) | |
136 | } | |
137 | for _, el := range urlValidityTests { | |
138 | el.tokURL = strings.ToUpper(el.tokURL) | |
139 | } | |
140 | for _, tt := range urlValidityTests { | |
141 | t.Run(" "+tt.tokURL, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability. | |
142 | config := testConfig | |
143 | config.TokenURL = tt.tokURL | |
144 | _, err := config.TokenSource(ctx) | |
145 | ||
146 | if tt.expectSuccess && err != nil { | |
147 | t.Errorf("got %v but want nil", err) | |
148 | } else if !tt.expectSuccess && err == nil { | |
149 | t.Errorf("got nil but expected an error") | |
150 | } | |
151 | }) | |
152 | } | |
153 | } | |
154 | ||
155 | func TestValidateURLImpersonateURL(t *testing.T) { | |
156 | var urlValidityTests = []struct { | |
157 | impURL string | |
158 | expectSuccess bool | |
159 | }{ | |
160 | {"https://east.iamcredentials.googleapis.com", true}, | |
161 | {"https://iamcredentials.googleapis.com", true}, | |
162 | {"https://iamcredentials.asfeasfesef.googleapis.com", true}, | |
163 | {"https://us-east-1-iamcredentials.googleapis.com", true}, | |
164 | {"https://iamcredentials.googleapis.com/your/path/here", true}, | |
165 | {"https://.iamcredentials.googleapis.com", false}, | |
166 | {"https://badiamcredentials.googleapis.com", false}, | |
167 | {"https://iamcredentials.asfe.asfesef.googleapis.com", false}, | |
168 | {"https://iamcredentials..googleapis.com", false}, | |
169 | {"https://-iamcredentials.googleapis.com", false}, | |
170 | {"https://us-ea.st-1-iamcredentials.googleapis.com", false}, | |
171 | {"https://iamcredentials.googleapis.com.evil.com/whatever/path", false}, | |
172 | {"https://us-eas\\t-1.iamcredentials.googleapis.com", false}, | |
173 | {"https:/us-ea/st-1.iamcredentials.googleapis.com", false}, | |
174 | {"https:/us-east 1.iamcredentials.googleapis.com", false}, | |
175 | {"https://", false}, | |
176 | {"http://us-east-1.iamcredentials.googleapis.com", false}, | |
177 | {"https://us-east-1.iamcredentials.googleapis.comevil.com", false}, | |
178 | } | |
179 | ctx := context.Background() | |
180 | for _, tt := range urlValidityTests { | |
181 | t.Run(" "+tt.impURL, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability. | |
182 | config := testConfig | |
183 | config.TokenURL = "https://sts.googleapis.com" // Setting the most basic acceptable tokenURL | |
184 | config.ServiceAccountImpersonationURL = tt.impURL | |
185 | _, err := config.TokenSource(ctx) | |
186 | ||
187 | if tt.expectSuccess && err != nil { | |
188 | t.Errorf("got %v but want nil", err) | |
189 | } else if !tt.expectSuccess && err == nil { | |
190 | t.Errorf("got nil but expected an error") | |
191 | } | |
192 | }) | |
193 | } | |
194 | for _, el := range urlValidityTests { | |
195 | el.impURL = strings.ToUpper(el.impURL) | |
196 | } | |
197 | for _, tt := range urlValidityTests { | |
198 | t.Run(" "+tt.impURL, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability. | |
199 | config := testConfig | |
200 | config.TokenURL = "https://sts.googleapis.com" // Setting the most basic acceptable tokenURL | |
201 | config.ServiceAccountImpersonationURL = tt.impURL | |
202 | _, err := config.TokenSource(ctx) | |
203 | ||
204 | if tt.expectSuccess && err != nil { | |
205 | t.Errorf("got %v but want nil", err) | |
206 | } else if !tt.expectSuccess && err == nil { | |
207 | t.Errorf("got nil but expected an error") | |
208 | } | |
209 | }) | |
210 | } | |
211 | } |
5 | 5 | |
6 | 6 | import ( |
7 | 7 | "encoding/base64" |
8 | "golang.org/x/oauth2" | |
9 | 8 | "net/http" |
10 | 9 | "net/url" |
10 | ||
11 | "golang.org/x/oauth2" | |
11 | 12 | ) |
12 | 13 | |
13 | 14 | // clientAuthentication represents an OAuth client ID and secret and the mechanism for passing these credentials as stated in rfc6749#2.3.1. |
4 | 4 | package externalaccount |
5 | 5 | |
6 | 6 | import ( |
7 | "golang.org/x/oauth2" | |
8 | 7 | "net/http" |
9 | 8 | "net/url" |
10 | 9 | "reflect" |
11 | 10 | "testing" |
11 | ||
12 | "golang.org/x/oauth2" | |
12 | 13 | ) |
13 | 14 | |
14 | 15 | var clientID = "rbrgnognrhongo3bi4gb9ghg9g" |
8 | 8 | "context" |
9 | 9 | "encoding/json" |
10 | 10 | "fmt" |
11 | "golang.org/x/oauth2" | |
12 | 11 | "io" |
13 | 12 | "io/ioutil" |
14 | 13 | "net/http" |
15 | 14 | "time" |
15 | ||
16 | "golang.org/x/oauth2" | |
16 | 17 | ) |
17 | 18 | |
18 | 19 | // generateAccesstokenReq is used for service account impersonation |
8 | 8 | "io/ioutil" |
9 | 9 | "net/http" |
10 | 10 | "net/http/httptest" |
11 | "regexp" | |
11 | 12 | "testing" |
12 | 13 | ) |
13 | 14 | |
75 | 76 | defer targetServer.Close() |
76 | 77 | |
77 | 78 | testImpersonateConfig.TokenURL = targetServer.URL |
78 | ourTS := testImpersonateConfig.TokenSource(context.Background()) | |
79 | allURLs := regexp.MustCompile(".+") | |
80 | ourTS, err := testImpersonateConfig.tokenSource(context.Background(), []*regexp.Regexp{allURLs}, []*regexp.Regexp{allURLs}, "http") | |
81 | if err != nil { | |
82 | t.Fatalf("Failed to create TokenSource: %v", err) | |
83 | } | |
79 | 84 | |
80 | 85 | oldNow := now |
81 | 86 | defer func() { now = oldNow }() |
64 | 64 | defer resp.Body.Close() |
65 | 65 | |
66 | 66 | body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
67 | if err != nil { | |
68 | return nil, err | |
69 | } | |
67 | 70 | if c := resp.StatusCode; c < 200 || c > 299 { |
68 | 71 | return nil, fmt.Errorf("oauth2/google: status code %d: %s", c, body) |
69 | 72 | } |
6 | 6 | import ( |
7 | 7 | "context" |
8 | 8 | "encoding/json" |
9 | "golang.org/x/oauth2" | |
10 | 9 | "io/ioutil" |
11 | 10 | "net/http" |
12 | 11 | "net/http/httptest" |
13 | 12 | "net/url" |
14 | 13 | "testing" |
14 | ||
15 | "golang.org/x/oauth2" | |
15 | 16 | ) |
16 | 17 | |
17 | 18 | var auth = clientAuthentication{ |
126 | 127 | } |
127 | 128 | var opts map[string]interface{} |
128 | 129 | err = json.Unmarshal([]byte(strOpts[0]), &opts) |
130 | if err != nil { | |
131 | t.Fatalf("Couldn't parse received \"options\" field.") | |
132 | } | |
129 | 133 | if len(opts) < 2 { |
130 | 134 | t.Errorf("Too few options received.") |
131 | 135 | } |