Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/public-api-server/pkg/apiv1/oidc.go
2499 views
1
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package apiv1
6
7
import (
8
"context"
9
"encoding/json"
10
"errors"
11
"fmt"
12
"net/http"
13
"net/url"
14
"strings"
15
"time"
16
17
connect "github.com/bufbuild/connect-go"
18
goidc "github.com/coreos/go-oidc/v3/oidc"
19
"github.com/gitpod-io/gitpod/common-go/experiments"
20
"github.com/gitpod-io/gitpod/common-go/log"
21
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
22
v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
23
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
24
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
25
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
26
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
27
"github.com/google/uuid"
28
"google.golang.org/grpc/codes"
29
"google.golang.org/grpc/status"
30
"gorm.io/gorm"
31
)
32
33
func NewOIDCService(connPool proxy.ServerConnectionPool, expClient experiments.Client, dbConn *gorm.DB, cipher db.Cipher) *OIDCService {
34
return &OIDCService{
35
connectionPool: connPool,
36
expClient: expClient,
37
cipher: cipher,
38
dbConn: dbConn,
39
}
40
}
41
42
type OIDCService struct {
43
expClient experiments.Client
44
connectionPool proxy.ServerConnectionPool
45
46
cipher db.Cipher
47
dbConn *gorm.DB
48
49
v1connect.UnimplementedOIDCServiceHandler
50
}
51
52
func (s *OIDCService) CreateClientConfig(ctx context.Context, req *connect.Request[v1.CreateClientConfigRequest]) (*connect.Response[v1.CreateClientConfigResponse], error) {
53
organizationID, err := validateOrganizationID(ctx, req.Msg.Config.GetOrganizationId())
54
if err != nil {
55
return nil, err
56
}
57
58
conn, err := s.getConnection(ctx)
59
if err != nil {
60
return nil, err
61
}
62
63
_, userID, err := s.getUser(ctx, conn)
64
if err != nil {
65
return nil, err
66
}
67
68
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
69
return nil, authorizationErr
70
}
71
72
config := req.Msg.GetConfig()
73
oidcConfig := config.GetOidcConfig()
74
75
issuer, err := validateIssuerURL(oidcConfig.GetIssuer())
76
if err != nil {
77
return nil, err
78
}
79
80
err = assertIssuerIsReachable(ctx, issuer)
81
if err != nil {
82
return nil, connect.NewError(connect.CodeInvalidArgument, err)
83
}
84
err = assertIssuerProvidesDiscovery(ctx, issuer)
85
if err != nil {
86
return nil, connect.NewError(connect.CodeInvalidArgument, err)
87
}
88
89
oauth2Config := config.GetOauth2Config()
90
data, err := db.EncryptJSON(s.cipher, toDbOIDCSpec(oauth2Config))
91
if err != nil {
92
log.Extract(ctx).WithError(err).Error("Failed to encrypt oidc client config.")
93
return nil, status.Errorf(codes.Internal, "Failed to store OIDC client config.")
94
}
95
96
active := config.GetActive()
97
98
created, err := db.CreateOIDCClientConfig(ctx, s.dbConn, db.OIDCClientConfig{
99
ID: uuid.New(),
100
OrganizationID: organizationID,
101
Issuer: issuer.String(),
102
Data: data,
103
Active: active,
104
})
105
if err != nil {
106
log.Extract(ctx).WithError(err).Error("Failed to store oidc client config in the database.")
107
return nil, status.Errorf(codes.Internal, "Failed to store OIDC client config.")
108
}
109
110
log.AddFields(ctx, log.OIDCClientConfigID(created.ID.String()))
111
112
converted, err := dbOIDCClientConfigToAPI(created, s.cipher)
113
if err != nil {
114
log.Extract(ctx).WithError(err).Error("Failed to convert OIDC Client config to response.")
115
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to convert OIDC Client Config %s for Organization %s to API response", created.ID.String(), organizationID.String()))
116
}
117
118
return connect.NewResponse(&v1.CreateClientConfigResponse{
119
Config: converted,
120
}), nil
121
}
122
123
func (s *OIDCService) GetClientConfig(ctx context.Context, req *connect.Request[v1.GetClientConfigRequest]) (*connect.Response[v1.GetClientConfigResponse], error) {
124
organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())
125
if err != nil {
126
return nil, err
127
}
128
129
clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())
130
if err != nil {
131
return nil, err
132
}
133
134
conn, err := s.getConnection(ctx)
135
if err != nil {
136
return nil, err
137
}
138
139
_, userID, err := s.getUser(ctx, conn)
140
if err != nil {
141
return nil, err
142
}
143
144
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
145
return nil, authorizationErr
146
}
147
148
record, err := db.GetOIDCClientConfigForOrganization(ctx, s.dbConn, clientConfigID, organizationID)
149
if err != nil {
150
if errors.Is(err, db.ErrorNotFound) {
151
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))
152
}
153
154
log.Extract(ctx).WithError(err).Error("Failed to delete OIDC Client config.")
155
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to delete OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))
156
}
157
158
converted, err := dbOIDCClientConfigToAPI(record, s.cipher)
159
if err != nil {
160
log.Extract(ctx).WithError(err).Error("Failed to convert OIDC Client config to response.")
161
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to convert OIDC Client Config %s for Organization %s to API response", clientConfigID.String(), organizationID.String()))
162
}
163
164
return connect.NewResponse(&v1.GetClientConfigResponse{
165
Config: converted,
166
}), nil
167
}
168
169
func (s *OIDCService) ListClientConfigs(ctx context.Context, req *connect.Request[v1.ListClientConfigsRequest]) (*connect.Response[v1.ListClientConfigsResponse], error) {
170
organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())
171
if err != nil {
172
return nil, err
173
}
174
175
conn, err := s.getConnection(ctx)
176
if err != nil {
177
return nil, err
178
}
179
180
_, userID, err := s.getUser(ctx, conn)
181
if err != nil {
182
return nil, err
183
}
184
185
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
186
return nil, authorizationErr
187
}
188
189
configs, err := db.ListOIDCClientConfigsForOrganization(ctx, s.dbConn, organizationID)
190
if err != nil {
191
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to retrieve oidc client configs"))
192
}
193
194
results, err := dbOIDCClientConfigsToAPI(configs, s.cipher)
195
if err != nil {
196
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to decrypt client configs"))
197
}
198
199
return connect.NewResponse(&v1.ListClientConfigsResponse{
200
ClientConfigs: results,
201
TotalResults: int64(len(results)),
202
}), nil
203
}
204
205
func (s *OIDCService) UpdateClientConfig(ctx context.Context, req *connect.Request[v1.UpdateClientConfigRequest]) (*connect.Response[v1.UpdateClientConfigResponse], error) {
206
config := req.Msg.GetConfig()
207
208
clientConfigID, err := validateOIDCClientConfigID(ctx, config.GetId())
209
if err != nil {
210
return nil, err
211
}
212
213
organizationID, err := validateOrganizationID(ctx, config.GetOrganizationId())
214
if err != nil {
215
return nil, err
216
}
217
218
conn, err := s.getConnection(ctx)
219
if err != nil {
220
return nil, err
221
}
222
223
_, userID, err := s.getUser(ctx, conn)
224
if err != nil {
225
return nil, err
226
}
227
228
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
229
return nil, authorizationErr
230
}
231
232
oidcConfig := config.GetOidcConfig()
233
oauth2Config := config.GetOauth2Config()
234
235
issuer := ""
236
if oidcConfig.GetIssuer() != "" {
237
// If we're updating the issuer, let's also check for reachability
238
issuerURL, err := validateIssuerURL(oidcConfig.GetIssuer())
239
if err != nil {
240
return nil, err
241
}
242
243
err = assertIssuerIsReachable(ctx, issuerURL)
244
if err != nil {
245
return nil, connect.NewError(connect.CodeInvalidArgument, err)
246
}
247
248
issuer = issuerURL.String()
249
}
250
251
updateSpec := toDbOIDCSpec(oauth2Config)
252
253
if err := db.UpdateOIDCClientConfig(ctx, s.dbConn, s.cipher, db.OIDCClientConfig{
254
ID: clientConfigID,
255
Issuer: issuer,
256
}, &updateSpec); err != nil {
257
if errors.Is(err, db.ErrorNotFound) {
258
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s does not exist", clientConfigID.String()))
259
}
260
261
log.Extract(ctx).WithError(err).Error("Failed to update OIDC Client config.")
262
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to update OIDC Client Config %s", clientConfigID.String()))
263
}
264
265
return connect.NewResponse(&v1.UpdateClientConfigResponse{}), nil
266
}
267
268
func (s *OIDCService) DeleteClientConfig(ctx context.Context, req *connect.Request[v1.DeleteClientConfigRequest]) (*connect.Response[v1.DeleteClientConfigResponse], error) {
269
organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())
270
if err != nil {
271
return nil, err
272
}
273
274
clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())
275
if err != nil {
276
return nil, err
277
}
278
279
conn, err := s.getConnection(ctx)
280
if err != nil {
281
return nil, err
282
}
283
284
_, userID, err := s.getUser(ctx, conn)
285
if err != nil {
286
return nil, err
287
}
288
289
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
290
return nil, authorizationErr
291
}
292
293
err = db.DeleteOIDCClientConfig(ctx, s.dbConn, clientConfigID, organizationID)
294
if err != nil {
295
if errors.Is(err, db.ErrorNotFound) {
296
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))
297
}
298
299
log.Extract(ctx).WithError(err).Error("Failed to delete OIDC Client config.")
300
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to delete OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))
301
}
302
303
return connect.NewResponse(&v1.DeleteClientConfigResponse{}), nil
304
}
305
306
func (s *OIDCService) SetClientConfigActivation(ctx context.Context, req *connect.Request[v1.SetClientConfigActivationRequest]) (*connect.Response[v1.SetClientConfigActivationResponse], error) {
307
organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())
308
if err != nil {
309
return nil, err
310
}
311
312
clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())
313
if err != nil {
314
return nil, err
315
}
316
317
conn, err := s.getConnection(ctx)
318
if err != nil {
319
return nil, err
320
}
321
322
_, userID, err := s.getUser(ctx, conn)
323
if err != nil {
324
return nil, err
325
}
326
327
if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {
328
return nil, authorizationErr
329
}
330
331
config, err := db.GetOIDCClientConfig(ctx, s.dbConn, clientConfigID)
332
if err != nil {
333
if errors.Is(err, db.ErrorNotFound) {
334
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))
335
}
336
337
return nil, err
338
}
339
340
if req.Msg.Activate {
341
if config.Verified == nil || !*config.Verified {
342
log.Extract(ctx).WithError(err).Error("Failed to activate an unverified OIDC Client Config.")
343
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("Failed to activate an unverified OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))
344
}
345
}
346
347
err = db.SetClientConfigActiviation(ctx, s.dbConn, clientConfigID, req.Msg.Activate)
348
if err != nil {
349
log.Extract(ctx).WithError(err).Error("Failed to set OIDC Client Config activation.")
350
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to set OIDC Client Config activation (ID: %s) for Organization %s", clientConfigID.String(), organizationID.String()))
351
}
352
353
return connect.NewResponse(&v1.SetClientConfigActivationResponse{}), nil
354
}
355
356
func (s *OIDCService) getConnection(ctx context.Context) (protocol.APIInterface, error) {
357
token, err := auth.TokenFromContext(ctx)
358
if err != nil {
359
return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("No credentials present on request."))
360
}
361
362
conn, err := s.connectionPool.Get(ctx, token)
363
if err != nil {
364
log.Extract(ctx).WithError(err).Error("Failed to get connection to server.")
365
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to establish connection to downstream services. If this issue persists, please contact Gitpod Support."))
366
}
367
368
return conn, nil
369
}
370
371
func (s *OIDCService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) {
372
user, err := conn.GetLoggedInUser(ctx)
373
if err != nil {
374
return nil, uuid.Nil, proxy.ConvertError(err)
375
}
376
377
log.AddFields(ctx, log.UserID(user.ID))
378
379
if !s.isFeatureEnabled(ctx, conn, user) {
380
return nil, uuid.Nil, connect.NewError(connect.CodePermissionDenied, errors.New("This feature is currently in beta. If you would like to be part of the beta, please contact us."))
381
}
382
383
userID, err := uuid.Parse(user.ID)
384
if err != nil {
385
return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support."))
386
}
387
388
return user, userID, nil
389
}
390
391
func (s *OIDCService) isFeatureEnabled(ctx context.Context, conn protocol.APIInterface, user *protocol.User) bool {
392
if user == nil {
393
return false
394
}
395
396
if experiments.IsOIDCServiceEnabled(ctx, s.expClient, experiments.Attributes{UserID: user.ID}) {
397
return true
398
}
399
400
teams, err := conn.GetTeams(ctx)
401
if err != nil {
402
log.Extract(ctx).WithError(err).Warnf("Failed to retreive Teams for user %s, personal access token feature flag will not evaluate team membership.", user.ID)
403
teams = nil
404
}
405
for _, team := range teams {
406
if experiments.IsOIDCServiceEnabled(ctx, s.expClient, experiments.Attributes{TeamID: team.ID}) {
407
return true
408
}
409
}
410
411
return false
412
}
413
414
func (s *OIDCService) userIsOrgOwner(ctx context.Context, userID, orgID uuid.UUID) error {
415
membership, err := db.GetOrganizationMembership(ctx, s.dbConn, userID, orgID)
416
if err != nil {
417
if errors.Is(err, db.ErrorNotFound) {
418
return connect.NewError(connect.CodeNotFound, fmt.Errorf("Organization %s does not exist", orgID.String()))
419
}
420
421
return connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to verify user %s is owner of organization %s", userID.String(), orgID.String()))
422
}
423
424
if membership.Role != db.OrganizationMembershipRole_Owner {
425
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("user %s is not owner of organization %s", userID.String(), orgID.String()))
426
}
427
428
return nil
429
}
430
431
func dbOIDCClientConfigToAPI(config db.OIDCClientConfig, decryptor db.Decryptor) (*v1.OIDCClientConfig, error) {
432
decrypted, err := config.Data.Decrypt(decryptor)
433
if err != nil {
434
return nil, fmt.Errorf("failed to decrypt oidc client config: %w", err)
435
}
436
437
return &v1.OIDCClientConfig{
438
Id: config.ID.String(),
439
OrganizationId: config.OrganizationID.String(),
440
Oauth2Config: &v1.OAuth2Config{
441
ClientId: decrypted.ClientID,
442
ClientSecret: "REDACTED",
443
AuthorizationEndpoint: decrypted.RedirectURL,
444
Scopes: decrypted.Scopes,
445
CelExpression: decrypted.CelExpression,
446
UsePkce: decrypted.UsePKCE,
447
},
448
OidcConfig: &v1.OIDCConfig{
449
Issuer: config.Issuer,
450
},
451
Active: config.Active,
452
Verified: config.Verified != nil && *config.Verified,
453
}, nil
454
}
455
456
func dbOIDCClientConfigsToAPI(configs []db.OIDCClientConfig, decryptor db.Decryptor) ([]*v1.OIDCClientConfig, error) {
457
var results []*v1.OIDCClientConfig
458
459
for _, c := range configs {
460
res, err := dbOIDCClientConfigToAPI(c, decryptor)
461
if err != nil {
462
return nil, err
463
}
464
465
results = append(results, res)
466
}
467
468
return results, nil
469
}
470
471
func toDbOIDCSpec(oauth2Config *v1.OAuth2Config) db.OIDCSpec {
472
return db.OIDCSpec{
473
ClientID: oauth2Config.GetClientId(),
474
ClientSecret: oauth2Config.GetClientSecret(),
475
CelExpression: oauth2Config.GetCelExpression(),
476
UsePKCE: oauth2Config.GetUsePkce(),
477
RedirectURL: oauth2Config.GetAuthorizationEndpoint(),
478
Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),
479
}
480
}
481
482
func assertIssuerIsReachable(ctx context.Context, issuer *url.URL) error {
483
tr := &http.Transport{
484
// TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
485
Proxy: http.ProxyFromEnvironment,
486
}
487
client := &http.Client{
488
Transport: tr,
489
Timeout: 2 * time.Second,
490
// never follow redirects
491
CheckRedirect: func(*http.Request, []*http.Request) error {
492
return http.ErrUseLastResponse
493
},
494
}
495
496
req, err := http.NewRequestWithContext(ctx, http.MethodHead, issuer.String()+"/.well-known/openid-configuration", nil)
497
if err != nil {
498
return err
499
}
500
resp, err := client.Do(req)
501
if err != nil {
502
return err
503
}
504
resp.Body.Close()
505
if resp.StatusCode > 499 {
506
return fmt.Errorf("returned status %d", resp.StatusCode)
507
}
508
return nil
509
}
510
511
func assertIssuerProvidesDiscovery(ctx context.Context, issuer *url.URL) error {
512
tr := &http.Transport{
513
// TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
514
Proxy: http.ProxyFromEnvironment,
515
}
516
client := &http.Client{
517
Transport: tr,
518
519
// never follow redirects
520
CheckRedirect: func(*http.Request, []*http.Request) error {
521
return http.ErrUseLastResponse
522
},
523
}
524
525
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String()+"/.well-known/openid-configuration", nil)
526
if err != nil {
527
return err
528
}
529
resp, err := client.Do(req)
530
if err != nil {
531
return err
532
}
533
defer resp.Body.Close()
534
535
if resp.StatusCode != http.StatusOK {
536
return fmt.Errorf("The identity providers needs to support OIDC Discovery.")
537
}
538
if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
539
return fmt.Errorf("OIDC Discovery configuration is of unexpected content type.")
540
}
541
542
var config map[string]interface{}
543
err = json.NewDecoder(resp.Body).Decode(&config)
544
if err != nil {
545
return fmt.Errorf("OIDC Discovery configuration is not parsable.")
546
}
547
return nil
548
}
549
550
func validateIssuerURL(issuer string) (*url.URL, error) {
551
parsed, err := url.Parse(strings.TrimSuffix(issuer, "/"))
552
if err != nil {
553
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Issuer must contain a valid URL"))
554
}
555
556
return parsed, nil
557
}
558
559