forked from gravitational/teleport
/
cloud.go
389 lines (343 loc) · 12.8 KB
/
cloud.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package app
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go/aws/credentials/ssocreds"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/endpoints"
awssession "github.com/aws/aws-sdk-go/aws/session"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/sirupsen/logrus"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/tlsca"
awsutils "github.com/gravitational/teleport/lib/utils/aws"
)
// Cloud provides cloud provider access related methods such as generating
// sign in URLs for management consoles.
type Cloud interface {
// GetAWSSigninURL generates AWS management console federation sign-in URL.
GetAWSSigninURL(AWSSigninRequest) (*AWSSigninResponse, error)
}
// AWSSigninRequest is a request to generate AWS console signin URL.
type AWSSigninRequest struct {
// Identity is the identity of the user requesting signin URL.
Identity *tlsca.Identity
// TargetURL is the target URL within the console.
TargetURL string
// Issuer is the application public URL.
Issuer string
// ExternalID is the AWS external ID.
ExternalID string
}
// CheckAndSetDefaults validates the request.
func (r *AWSSigninRequest) CheckAndSetDefaults() error {
if r.Identity == nil {
return trace.BadParameter("missing Identity")
}
_, err := awsutils.ParseRoleARN(r.Identity.RouteToApp.AWSRoleARN)
if err != nil {
return trace.Wrap(err)
}
if r.TargetURL == "" {
return trace.BadParameter("missing TargetURL")
}
if r.Issuer == "" {
return trace.BadParameter("missing Issuer")
}
return nil
}
// AWSSigninResponse contains AWS console signin URL.
type AWSSigninResponse struct {
// SigninURL is the console signin URL.
SigninURL string
}
// CloudConfig is the configuration for cloud service.
type CloudConfig struct {
// Session is AWS session.
Session *awssession.Session
// Clock is used to override time in tests.
Clock clockwork.Clock
}
// CheckAndSetDefaults validates the config.
func (c *CloudConfig) CheckAndSetDefaults() error {
if c.Session == nil {
useFIPSEndpoint := endpoints.FIPSEndpointStateUnset
if modules.GetModules().IsBoringBinary() {
useFIPSEndpoint = endpoints.FIPSEndpointStateEnabled
}
session, err := awssession.NewSessionWithOptions(awssession.Options{
SharedConfigState: awssession.SharedConfigEnable,
Config: aws.Config{
EC2MetadataEnableFallback: aws.Bool(false),
UseFIPSEndpoint: useFIPSEndpoint,
},
})
if err != nil {
return trace.Wrap(err)
}
c.Session = session
}
if c.Clock == nil {
c.Clock = clockwork.NewRealClock()
}
return nil
}
type cloud struct {
cfg CloudConfig
log logrus.FieldLogger
}
// NewCloud creates a new cloud service.
func NewCloud(cfg CloudConfig) (Cloud, error) {
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return &cloud{
cfg: cfg,
log: logrus.WithField(trace.Component, "cloud"),
}, nil
}
// GetAWSSigninURL generates AWS management console federation sign-in URL.
//
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
func (c *cloud) GetAWSSigninURL(req AWSSigninRequest) (*AWSSigninResponse, error) {
err := req.CheckAndSetDefaults()
if err != nil {
return nil, trace.Wrap(err)
}
federationURL := getFederationURL(req.TargetURL)
signinToken, err := c.getAWSSigninToken(&req, federationURL)
if err != nil {
return nil, trace.Wrap(err)
}
signinURL, err := url.Parse(federationURL)
if err != nil {
return nil, trace.Wrap(err)
}
signinURL.RawQuery = url.Values{
"Action": []string{"login"},
"SigninToken": []string{signinToken},
"Destination": []string{req.TargetURL},
"Issuer": []string{req.Issuer},
}.Encode()
return &AWSSigninResponse{
SigninURL: signinURL.String(),
}, nil
}
// getAWSSigninToken gets the signin token required for the AWS sign in URL.
//
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
func (c *cloud) getAWSSigninToken(req *AWSSigninRequest, endpoint string, options ...func(*stscreds.AssumeRoleProvider)) (string, error) {
// It is stated in the user guide linked above:
// When you use DurationSeconds in an AssumeRole* operation, you must call
// it as an IAM user with long-term credentials. Otherwise, the call to the
// federation endpoint in step 3 fails.
//
// Experiments showed that the getSigninToken call will still succeed as
// long as the "SessionDuration" is not provided when calling the API, when
// the AWS session is using temporary credentials. However, when the
// "SessionDuration" is not provided, the web console session duration will
// be bound to the duration used in the next AssumeRole call.
temporarySession, err := isSessionUsingTemporaryCredentials(c.cfg.Session)
if err != nil {
return "", trace.Wrap(err)
}
duration, err := c.getFederationDuration(req, temporarySession)
if err != nil {
return "", trace.Wrap(err)
}
options = append(options, func(creds *stscreds.AssumeRoleProvider) {
// Setting role session name to Teleport username will allow to
// associate CloudTrail events with the Teleport user.
creds.RoleSessionName = req.Identity.Username
// Setting web console session duration through AssumeRole call for AWS
// sessions with temporary credentials.
// Technically the session duration can be set this way for
// non-temporary sessions. However, the AssumeRole call will fail if we
// are requesting duration longer than the maximum session duration of
// the role we are assuming. In addition, the session credentials may
// not have permission to perform a get-role on the role. Therefore,
// "SessionDuration" parameter will be defined when calling federation
// endpoint below instead of here, for non-temporary sessions.
//
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
if temporarySession {
creds.Duration = duration
}
if req.ExternalID != "" {
creds.ExternalID = aws.String(req.ExternalID)
}
})
stsCredentials, err := stscreds.NewCredentials(c.cfg.Session, req.Identity.RouteToApp.AWSRoleARN, options...).Get()
if err != nil {
return "", trace.Wrap(err)
}
tokenURL, err := url.Parse(endpoint)
if err != nil {
return "", trace.Wrap(err)
}
sessionBytes, err := json.Marshal(stsSession{
SessionID: stsCredentials.AccessKeyID,
SessionKey: stsCredentials.SecretAccessKey,
SessionToken: stsCredentials.SessionToken,
})
if err != nil {
return "", trace.Wrap(err)
}
values := url.Values{
"Action": []string{"getSigninToken"},
"Session": []string{string(sessionBytes)},
}
if !temporarySession {
values["SessionDuration"] = []string{strconv.Itoa(int(duration.Seconds()))}
}
tokenURL.RawQuery = values.Encode()
resp, err := http.Get(tokenURL.String())
if err != nil {
return "", trace.Wrap(err)
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", trace.Wrap(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", trace.BadParameter("non-200 response from AWS federation endpoint: %q %v %q",
resp.Status, resp.Header, string(respBytes))
}
var fedResp federationResponse
if err := json.Unmarshal(respBytes, &fedResp); err != nil {
return "", trace.Wrap(err)
}
return fedResp.SigninToken, nil
}
// isSessionUsingTemporaryCredentials checks if the current aws session is
// using temporary credentials.
//
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html
func isSessionUsingTemporaryCredentials(session *awssession.Session) (bool, error) {
if session.Config == nil || session.Config.Credentials == nil {
return false, trace.NotFound("session credentials not found")
}
credentials, err := session.Config.Credentials.Get()
if err != nil {
return false, trace.Wrap(err)
}
switch credentials.ProviderName {
case ec2rolecreds.ProviderName:
return false, nil
case
// stscreds.AssumeRoleProvider retrieves temporary credentials from the
// STS service, and keeps track of their expiration time.
// https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/stscreds/#AssumeRoleProvider
stscreds.ProviderName,
// stscreds.WebIdentityRoleProvider is used to retrieve credentials
// using an OIDC token.
// https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/stscreds/#WebIdentityRoleProvider
//
// IAM roles for EKS service accounts are also granted through the OIDC tokens.
// https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/
stscreds.WebIdentityProviderName,
// ssocreds.Provider is an AWS credential provider that retrieves
// temporary AWS credentials by exchanging an SSO login token.
// https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/ssocreds/#Provider
ssocreds.ProviderName:
return true, nil
}
// For other providers, make an assumption that a session token is only
// required for temporary security credentials retrieved via STS, otherwise
// it is an empty string.
// https://docs.aws.amazon.com/sdk-for-go/api/aws/credentials/#NewStaticCredentials
return credentials.SessionToken != "", nil
}
// getFederationDuration calculates the duration of the federated session.
func (c *cloud) getFederationDuration(req *AWSSigninRequest, temporarySession bool) (time.Duration, error) {
maxDuration := maxSessionDuration
if temporarySession {
maxDuration = maxTemporarySessionDuration
}
duration := req.Identity.Expires.Sub(c.cfg.Clock.Now())
if duration > maxDuration {
duration = maxDuration
}
if duration < minimumSessionDuration {
return 0, trace.AccessDenied("minimum AWS session duration is %v but Teleport identity expires in %v", minimumSessionDuration, duration)
}
return duration, nil
}
// stsSession combines parameters describing session built from temporary credentials.
type stsSession struct {
// SessionID is the assumed credentials access key ID.
SessionID string `json:"sessionId"`
// SessionKey is the assumed credentials secret access key.
SessionKey string `json:"sessionKey"`
// SessionToken is the assumed credentials session token.
SessionToken string `json:"sessionToken"`
}
// federationResponse describes response returned by the federation endpoint.
type federationResponse struct {
// SigninToken is the AWS console federation signin token.
SigninToken string `json:"SigninToken"`
}
// getFederationURL picks the AWS federation endpoint based on the AWS
// partition of the target URL.
//
// https://docs.aws.amazon.com/general/latest/gr/signin-service.html
// https://docs.amazonaws.cn/en_us/aws/latest/userguide/endpoints-Beijing.html
func getFederationURL(targetURL string) string {
// TODO(greedy52) support region based sign-in.
switch {
// AWS GovCloud (US) Partition.
case strings.HasPrefix(targetURL, constants.AWSUSGovConsoleURL):
return "https://signin.amazonaws-us-gov.com/federation"
// AWS China Partition.
case strings.HasPrefix(targetURL, constants.AWSCNConsoleURL):
return "https://signin.amazonaws.cn/federation"
// AWS Standard Partition.
default:
return "https://signin.aws.amazon.com/federation"
}
}
const (
// maxSessionDuration is the max federation session duration, which is 12
// hours. The federation endpoint will error out if we request more.
//
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
maxSessionDuration = 12 * time.Hour
// maxTemporarySessionDuration is the max federation session duration when
// the AWS session is using temporary credentials. The maximum is one hour,
// which is the maximum duration you can set when role chaining.
//
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html
maxTemporarySessionDuration = time.Hour
// minimumSessionDuration is the minimum federation session duration. The
// AssumeRole call will error out if we request less than 15 minutes.
//
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
minimumSessionDuration = 15 * time.Minute
)