Skip to content

Commit c4b53f9

Browse files
authored
Support validate claims with CEL expr for SSO (#20083)
* [papi] proto update * [dashboard] nit TODO * [papi] implement cel expression verify * drop me: Add debug logs * 1 * tidy * export cel error message * 💄 dashboard * improve error * nit doc
1 parent 9037679 commit c4b53f9

File tree

15 files changed

+631
-199
lines changed

15 files changed

+631
-199
lines changed

components/dashboard/src/dedicated-setup/SSOSetupStep.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const SSOSetupStep: FC<Props> = ({ config, onComplete, progressCurrent, p
2727
issuer: config?.oidcConfig?.issuer ?? "",
2828
clientId: config?.oauth2Config?.clientId ?? "",
2929
clientSecret: config?.oauth2Config?.clientSecret ?? "",
30+
celExpression: config?.oauth2Config?.celExpression ?? "",
3031
});
3132
const configIsValid = isValid(ssoConfig);
3233

components/dashboard/src/teams/sso/OIDCClientConfigModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const OIDCClientConfigModal: FC<Props> = ({ clientConfig, onSaved, onClos
2626
issuer: clientConfig?.oidcConfig?.issuer ?? "",
2727
clientId: clientConfig?.oauth2Config?.clientId ?? "",
2828
clientSecret: clientConfig?.oauth2Config?.clientSecret ?? "",
29+
celExpression: clientConfig?.oauth2Config?.celExpression ?? "",
2930
});
3031
const configIsValid = isValid(ssoConfig);
3132

components/dashboard/src/teams/sso/SSOConfigForm.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ export const SSOConfigForm: FC<Props> = ({ config, readOnly = false, onChange })
7171
onBlur={clientSecretError.onBlur}
7272
onChange={(val) => onChange({ clientSecret: val })}
7373
/>
74+
75+
<Subheading className="mt-8">
76+
<strong>3.</strong> Restrict available accounts in your Identity Providers.
77+
<a
78+
href="https://www.gitpod.io/docs/enterprise/setup-gitpod/configure-sso#restrict-available-accounts-in-your-identity-providers"
79+
target="_blank"
80+
rel="noreferrer noopener"
81+
className="gp-link"
82+
>
83+
Learn more
84+
</a>
85+
.
86+
</Subheading>
87+
88+
<InputField label="CEL Expression (optional)">
89+
<textarea
90+
style={{ height: "160px" }}
91+
className="w-full resize-none"
92+
value={config.celExpression}
93+
onChange={(val) => onChange({ celExpression: val.target.value })}
94+
/>
95+
</InputField>
7496
</>
7597
);
7698
};
@@ -80,6 +102,7 @@ export type SSOConfig = {
80102
issuer: string;
81103
clientId: string;
82104
clientSecret: string;
105+
celExpression?: string;
83106
};
84107

85108
export const ssoConfigReducer = (state: SSOConfig, action: Partial<SSOConfig>) => {
@@ -122,6 +145,7 @@ export const useSaveSSOConfig = () => {
122145
const trimmedIssuer = ssoConfig.issuer.trim();
123146
const trimmedClientId = ssoConfig.clientId.trim();
124147
const trimmedClientSecret = ssoConfig.clientSecret.trim();
148+
const trimmedCelExpression = ssoConfig.celExpression?.trim();
125149

126150
return upsertClientConfig.mutateAsync({
127151
config: !ssoConfig.id
@@ -130,6 +154,7 @@ export const useSaveSSOConfig = () => {
130154
oauth2Config: {
131155
clientId: trimmedClientId,
132156
clientSecret: trimmedClientSecret,
157+
celExpression: trimmedCelExpression,
133158
},
134159
oidcConfig: {
135160
issuer: trimmedIssuer,
@@ -142,6 +167,7 @@ export const useSaveSSOConfig = () => {
142167
clientId: trimmedClientId,
143168
// TODO: determine how we should handle when user doesn't change their secret
144169
clientSecret: trimmedClientSecret.toLowerCase() === "redacted" ? "" : trimmedClientSecret,
170+
celExpression: trimmedCelExpression,
145171
},
146172
oidcConfig: {
147173
issuer: trimmedIssuer,

components/gitpod-db/go/oidc_client_config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ type OIDCSpec struct {
5757

5858
// Scope specifies optional requested permissions.
5959
Scopes []string `json:"scopes"`
60+
61+
// CelExpression is an optional expression that can be used to determine if the client should be allowed to authenticate.
62+
CelExpression string `json:"celExpression"`
6063
}
6164

6265
func CreateOIDCClientConfig(ctx context.Context, conn *gorm.DB, cfg OIDCClientConfig) (OIDCClientConfig, error) {
@@ -348,6 +351,8 @@ func partialUpdateOIDCSpec(old, new OIDCSpec) OIDCSpec {
348351
old.RedirectURL = new.RedirectURL
349352
}
350353

354+
old.CelExpression = new.CelExpression
355+
351356
if !oidcScopesEqual(old.Scopes, new.Scopes) {
352357
old.Scopes = new.Scopes
353358
}

components/public-api-server/go.mod

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/go-chi/chi/v5 v5.0.8
1515
github.com/golang-jwt/jwt/v5 v5.0.0
1616
github.com/golang/mock v1.6.0
17+
github.com/google/cel-go v0.20.1
1718
github.com/google/go-cmp v0.6.0
1819
github.com/google/uuid v1.3.0
1920
github.com/gorilla/handlers v1.5.1
@@ -28,27 +29,31 @@ require (
2829
github.com/stretchr/testify v1.8.4
2930
github.com/stripe/stripe-go/v72 v72.122.0
3031
github.com/zitadel/oidc v1.13.0
31-
golang.org/x/oauth2 v0.6.0
32-
google.golang.org/grpc v1.55.0
32+
golang.org/x/oauth2 v0.7.0
33+
google.golang.org/grpc v1.57.0
3334
google.golang.org/protobuf v1.33.0
3435
gopkg.in/square/go-jose.v2 v2.6.0
3536
gorm.io/gorm v1.25.1
3637
)
3738

3839
require (
3940
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
41+
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
4042
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
4143
github.com/gitpod-io/gitpod/components/scrubber v0.0.0-00010101000000-000000000000 // indirect
4244
github.com/go-logr/logr v1.3.0 // indirect
4345
github.com/go-logr/stdr v1.2.2 // indirect
4446
github.com/mitchellh/reflectwalk v1.0.2 // indirect
47+
github.com/stoewer/go-strcase v1.2.0 // indirect
4548
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 // indirect
4649
go.opentelemetry.io/otel v1.16.0 // indirect
4750
go.opentelemetry.io/otel/metric v1.16.0 // indirect
4851
go.opentelemetry.io/otel/trace v1.16.0 // indirect
4952
golang.org/x/crypto v0.16.0 // indirect
53+
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
5054
golang.org/x/sync v0.2.0 // indirect
51-
google.golang.org/genproto/googleapis/rpc v0.0.0-20230526161137-0005af68ea54 // indirect
55+
google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect
56+
google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5 // indirect
5257
gorm.io/driver/mysql v1.4.4 // indirect
5358
gorm.io/plugin/opentelemetry v0.1.3 // indirect
5459
)

components/public-api-server/go.sum

Lines changed: 16 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/public-api-server/pkg/apiv1/oidc.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ func dbOIDCClientConfigToAPI(config db.OIDCClientConfig, decryptor db.Decryptor)
442442
ClientSecret: "REDACTED",
443443
AuthorizationEndpoint: decrypted.RedirectURL,
444444
Scopes: decrypted.Scopes,
445+
CelExpression: decrypted.CelExpression,
445446
},
446447
OidcConfig: &v1.OIDCConfig{
447448
Issuer: config.Issuer,
@@ -468,10 +469,11 @@ func dbOIDCClientConfigsToAPI(configs []db.OIDCClientConfig, decryptor db.Decryp
468469

469470
func toDbOIDCSpec(oauth2Config *v1.OAuth2Config) db.OIDCSpec {
470471
return db.OIDCSpec{
471-
ClientID: oauth2Config.GetClientId(),
472-
ClientSecret: oauth2Config.GetClientSecret(),
473-
RedirectURL: oauth2Config.GetAuthorizationEndpoint(),
474-
Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),
472+
ClientID: oauth2Config.GetClientId(),
473+
ClientSecret: oauth2Config.GetClientSecret(),
474+
CelExpression: oauth2Config.GetCelExpression(),
475+
RedirectURL: oauth2Config.GetAuthorizationEndpoint(),
476+
Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),
475477
}
476478
}
477479

components/public-api-server/pkg/oidc/router.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ func (s *Service) getCallbackHandler() http.HandlerFunc {
137137
if err != nil {
138138
log.WithError(err).Warn("OIDC authentication failed")
139139
reportLoginCompleted("failed_client", "sso")
140-
respondeWithError(rw, r, "We've not been able to authenticate you with the OIDC Provider.", http.StatusInternalServerError, useHttpErrors)
140+
responseMsg := "We've not been able to authenticate you with the OIDC Provider."
141+
if celExprErr, ok := err.(*CelExprError); ok {
142+
responseMsg = fmt.Sprintf("%s [%s]", responseMsg, celExprErr.Code)
143+
}
144+
respondeWithError(rw, r, responseMsg, http.StatusInternalServerError, useHttpErrors)
141145
return
142146
}
143147

components/public-api-server/pkg/oidc/service.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
2222
"github.com/gitpod-io/gitpod/public-api-server/pkg/jws"
2323
"github.com/golang-jwt/jwt/v5"
24+
"github.com/google/cel-go/cel"
25+
"github.com/google/cel-go/checker/decls"
2426
"github.com/google/uuid"
2527
"golang.org/x/oauth2"
2628
"google.golang.org/grpc/codes"
@@ -49,6 +51,7 @@ type ClientConfig struct {
4951
Active bool
5052
OAuth2Config *oauth2.Config
5153
VerifierConfig *goidc.Config
54+
CelExpression string
5255
}
5356

5457
type StartParams struct {
@@ -248,6 +251,7 @@ func (s *Service) convertClientConfig(ctx context.Context, dbEntry db.OIDCClient
248251
Endpoint: provider.Endpoint(),
249252
Scopes: spec.Scopes,
250253
},
254+
CelExpression: spec.CelExpression,
251255
VerifierConfig: &goidc.Config{
252256
ClientID: spec.ClientID,
253257
},
@@ -260,6 +264,15 @@ type authenticateParams struct {
260264
NonceCookieValue string
261265
}
262266

267+
type CelExprError struct {
268+
Msg string
269+
Code string
270+
}
271+
272+
func (e *CelExprError) Error() string {
273+
return fmt.Sprintf("%s [%s]", e.Msg, e.Code)
274+
}
275+
263276
func (s *Service) authenticate(ctx context.Context, params authenticateParams) (*AuthFlowResult, error) {
264277
rawIDToken, ok := params.OAuth2Result.OAuth2Token.Extra("id_token").(string)
265278
if !ok {
@@ -285,6 +298,13 @@ func (s *Service) authenticate(ctx context.Context, params authenticateParams) (
285298
if err != nil {
286299
return nil, fmt.Errorf("failed to validate required claims: %w", err)
287300
}
301+
validatedCelExpression, err := s.verifyCelExpression(ctx, params.Config.CelExpression, validatedClaims)
302+
if err != nil {
303+
return nil, err
304+
}
305+
if !validatedCelExpression {
306+
return nil, &CelExprError{Msg: "CEL expression did not evaluate to true", Code: "CEL:EVAL_FALSE"}
307+
}
288308
return &AuthFlowResult{
289309
IDToken: idToken,
290310
Claims: validatedClaims,
@@ -364,6 +384,45 @@ func (s *Service) validateRequiredClaims(ctx context.Context, provider *oidc.Pro
364384
return claims, nil
365385
}
366386

387+
func (s *Service) verifyCelExpression(ctx context.Context, celExpression string, claims jwt.MapClaims) (bool, error) {
388+
if celExpression == "" {
389+
return true, nil
390+
}
391+
env, err := cel.NewEnv(cel.Declarations(decls.NewVar("claims", decls.NewMapType(decls.String, decls.Dyn))))
392+
if err != nil {
393+
return false, &CelExprError{Msg: fmt.Errorf("failed to create claims env: %w", err).Error(), Code: "CEL:INVALIDATE"}
394+
}
395+
ast, issues := env.Compile(celExpression)
396+
if issues != nil {
397+
if issues.Err() != nil {
398+
return false, &CelExprError{Msg: fmt.Errorf("failed to compile CEL Expression: %w", issues.Err()).Error(), Code: "CEL:INVALIDATE"}
399+
}
400+
// should not happen
401+
log.WithField("issues", issues).Error("failed to compile CEL Expression")
402+
return false, &CelExprError{Msg: fmt.Errorf("failed to compile CEL Expression").Error(), Code: "CEL:INVALIDATE"}
403+
}
404+
prg, err := env.Program(ast)
405+
if err != nil {
406+
log.WithError(err).Error("failed to create CEL program")
407+
return false, &CelExprError{Msg: fmt.Errorf("failed to create CEL program").Error(), Code: "CEL:INVALIDATE"}
408+
}
409+
input := map[string]interface{}{
410+
"claims": claims,
411+
}
412+
val, _, err := prg.ContextEval(ctx, input)
413+
if err != nil {
414+
return false, &CelExprError{Msg: fmt.Errorf("failed to evaluate CEL program: %w", err).Error(), Code: "CEL:EVAL_ERR"}
415+
}
416+
result, ok := val.Value().(bool)
417+
if !ok {
418+
return false, &CelExprError{Msg: fmt.Errorf("CEL Expression did not evaluate to a boolean").Error(), Code: "CEL:EVAL_NOT_BOOL"}
419+
}
420+
if !result {
421+
return false, &CelExprError{Msg: fmt.Errorf("CEL Expression did not evaluate to true").Error(), Code: "CEL:EVAL_FALSE"}
422+
}
423+
return result, nil
424+
}
425+
367426
func (s *Service) fillClaims(ctx context.Context, provider *oidc.Provider, claims jwt.MapClaims, missingClaims []string) error {
368427
oauth2Info := GetOAuth2ResultFromContext(ctx)
369428
if oauth2Info == nil {

0 commit comments

Comments
 (0)