Skip to content

Commit

Permalink
Support validate claims with CEL expr for SSO (#20083)
Browse files Browse the repository at this point in the history
* [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
  • Loading branch information
mustard-mh authored Aug 7, 2024
1 parent 9037679 commit c4b53f9
Show file tree
Hide file tree
Showing 15 changed files with 631 additions and 199 deletions.
1 change: 1 addition & 0 deletions components/dashboard/src/dedicated-setup/SSOSetupStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const SSOSetupStep: FC<Props> = ({ config, onComplete, progressCurrent, p
issuer: config?.oidcConfig?.issuer ?? "",
clientId: config?.oauth2Config?.clientId ?? "",
clientSecret: config?.oauth2Config?.clientSecret ?? "",
celExpression: config?.oauth2Config?.celExpression ?? "",
});
const configIsValid = isValid(ssoConfig);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const OIDCClientConfigModal: FC<Props> = ({ clientConfig, onSaved, onClos
issuer: clientConfig?.oidcConfig?.issuer ?? "",
clientId: clientConfig?.oauth2Config?.clientId ?? "",
clientSecret: clientConfig?.oauth2Config?.clientSecret ?? "",
celExpression: clientConfig?.oauth2Config?.celExpression ?? "",
});
const configIsValid = isValid(ssoConfig);

Expand Down
26 changes: 26 additions & 0 deletions components/dashboard/src/teams/sso/SSOConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ export const SSOConfigForm: FC<Props> = ({ config, readOnly = false, onChange })
onBlur={clientSecretError.onBlur}
onChange={(val) => onChange({ clientSecret: val })}
/>

<Subheading className="mt-8">
<strong>3.</strong> Restrict available accounts in your Identity Providers.
<a
href="https://www.gitpod.io/docs/enterprise/setup-gitpod/configure-sso#restrict-available-accounts-in-your-identity-providers"
target="_blank"
rel="noreferrer noopener"
className="gp-link"
>
Learn more
</a>
.
</Subheading>

<InputField label="CEL Expression (optional)">
<textarea
style={{ height: "160px" }}
className="w-full resize-none"
value={config.celExpression}
onChange={(val) => onChange({ celExpression: val.target.value })}
/>
</InputField>
</>
);
};
Expand All @@ -80,6 +102,7 @@ export type SSOConfig = {
issuer: string;
clientId: string;
clientSecret: string;
celExpression?: string;
};

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

return upsertClientConfig.mutateAsync({
config: !ssoConfig.id
Expand All @@ -130,6 +154,7 @@ export const useSaveSSOConfig = () => {
oauth2Config: {
clientId: trimmedClientId,
clientSecret: trimmedClientSecret,
celExpression: trimmedCelExpression,
},
oidcConfig: {
issuer: trimmedIssuer,
Expand All @@ -142,6 +167,7 @@ export const useSaveSSOConfig = () => {
clientId: trimmedClientId,
// TODO: determine how we should handle when user doesn't change their secret
clientSecret: trimmedClientSecret.toLowerCase() === "redacted" ? "" : trimmedClientSecret,
celExpression: trimmedCelExpression,
},
oidcConfig: {
issuer: trimmedIssuer,
Expand Down
5 changes: 5 additions & 0 deletions components/gitpod-db/go/oidc_client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ type OIDCSpec struct {

// Scope specifies optional requested permissions.
Scopes []string `json:"scopes"`

// CelExpression is an optional expression that can be used to determine if the client should be allowed to authenticate.
CelExpression string `json:"celExpression"`
}

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

old.CelExpression = new.CelExpression

if !oidcScopesEqual(old.Scopes, new.Scopes) {
old.Scopes = new.Scopes
}
Expand Down
11 changes: 8 additions & 3 deletions components/public-api-server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/go-chi/chi/v5 v5.0.8
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/golang/mock v1.6.0
github.com/google/cel-go v0.20.1
github.com/google/go-cmp v0.6.0
github.com/google/uuid v1.3.0
github.com/gorilla/handlers v1.5.1
Expand All @@ -28,27 +29,31 @@ require (
github.com/stretchr/testify v1.8.4
github.com/stripe/stripe-go/v72 v72.122.0
github.com/zitadel/oidc v1.13.0
golang.org/x/oauth2 v0.6.0
google.golang.org/grpc v1.55.0
golang.org/x/oauth2 v0.7.0
google.golang.org/grpc v1.57.0
google.golang.org/protobuf v1.33.0
gopkg.in/square/go-jose.v2 v2.6.0
gorm.io/gorm v1.25.1
)

require (
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gitpod-io/gitpod/components/scrubber v0.0.0-00010101000000-000000000000 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/sync v0.2.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230526161137-0005af68ea54 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5 // indirect
gorm.io/driver/mysql v1.4.4 // indirect
gorm.io/plugin/opentelemetry v0.1.3 // indirect
)
Expand Down
22 changes: 16 additions & 6 deletions components/public-api-server/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions components/public-api-server/pkg/apiv1/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ func dbOIDCClientConfigToAPI(config db.OIDCClientConfig, decryptor db.Decryptor)
ClientSecret: "REDACTED",
AuthorizationEndpoint: decrypted.RedirectURL,
Scopes: decrypted.Scopes,
CelExpression: decrypted.CelExpression,
},
OidcConfig: &v1.OIDCConfig{
Issuer: config.Issuer,
Expand All @@ -468,10 +469,11 @@ func dbOIDCClientConfigsToAPI(configs []db.OIDCClientConfig, decryptor db.Decryp

func toDbOIDCSpec(oauth2Config *v1.OAuth2Config) db.OIDCSpec {
return db.OIDCSpec{
ClientID: oauth2Config.GetClientId(),
ClientSecret: oauth2Config.GetClientSecret(),
RedirectURL: oauth2Config.GetAuthorizationEndpoint(),
Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),
ClientID: oauth2Config.GetClientId(),
ClientSecret: oauth2Config.GetClientSecret(),
CelExpression: oauth2Config.GetCelExpression(),
RedirectURL: oauth2Config.GetAuthorizationEndpoint(),
Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),
}
}

Expand Down
6 changes: 5 additions & 1 deletion components/public-api-server/pkg/oidc/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ func (s *Service) getCallbackHandler() http.HandlerFunc {
if err != nil {
log.WithError(err).Warn("OIDC authentication failed")
reportLoginCompleted("failed_client", "sso")
respondeWithError(rw, r, "We've not been able to authenticate you with the OIDC Provider.", http.StatusInternalServerError, useHttpErrors)
responseMsg := "We've not been able to authenticate you with the OIDC Provider."
if celExprErr, ok := err.(*CelExprError); ok {
responseMsg = fmt.Sprintf("%s [%s]", responseMsg, celExprErr.Code)
}
respondeWithError(rw, r, responseMsg, http.StatusInternalServerError, useHttpErrors)
return
}

Expand Down
59 changes: 59 additions & 0 deletions components/public-api-server/pkg/oidc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
"github.com/gitpod-io/gitpod/public-api-server/pkg/jws"
"github.com/golang-jwt/jwt/v5"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
"github.com/google/uuid"
"golang.org/x/oauth2"
"google.golang.org/grpc/codes"
Expand Down Expand Up @@ -49,6 +51,7 @@ type ClientConfig struct {
Active bool
OAuth2Config *oauth2.Config
VerifierConfig *goidc.Config
CelExpression string
}

type StartParams struct {
Expand Down Expand Up @@ -248,6 +251,7 @@ func (s *Service) convertClientConfig(ctx context.Context, dbEntry db.OIDCClient
Endpoint: provider.Endpoint(),
Scopes: spec.Scopes,
},
CelExpression: spec.CelExpression,
VerifierConfig: &goidc.Config{
ClientID: spec.ClientID,
},
Expand All @@ -260,6 +264,15 @@ type authenticateParams struct {
NonceCookieValue string
}

type CelExprError struct {
Msg string
Code string
}

func (e *CelExprError) Error() string {
return fmt.Sprintf("%s [%s]", e.Msg, e.Code)
}

func (s *Service) authenticate(ctx context.Context, params authenticateParams) (*AuthFlowResult, error) {
rawIDToken, ok := params.OAuth2Result.OAuth2Token.Extra("id_token").(string)
if !ok {
Expand All @@ -285,6 +298,13 @@ func (s *Service) authenticate(ctx context.Context, params authenticateParams) (
if err != nil {
return nil, fmt.Errorf("failed to validate required claims: %w", err)
}
validatedCelExpression, err := s.verifyCelExpression(ctx, params.Config.CelExpression, validatedClaims)
if err != nil {
return nil, err
}
if !validatedCelExpression {
return nil, &CelExprError{Msg: "CEL expression did not evaluate to true", Code: "CEL:EVAL_FALSE"}
}
return &AuthFlowResult{
IDToken: idToken,
Claims: validatedClaims,
Expand Down Expand Up @@ -364,6 +384,45 @@ func (s *Service) validateRequiredClaims(ctx context.Context, provider *oidc.Pro
return claims, nil
}

func (s *Service) verifyCelExpression(ctx context.Context, celExpression string, claims jwt.MapClaims) (bool, error) {
if celExpression == "" {
return true, nil
}
env, err := cel.NewEnv(cel.Declarations(decls.NewVar("claims", decls.NewMapType(decls.String, decls.Dyn))))
if err != nil {
return false, &CelExprError{Msg: fmt.Errorf("failed to create claims env: %w", err).Error(), Code: "CEL:INVALIDATE"}
}
ast, issues := env.Compile(celExpression)
if issues != nil {
if issues.Err() != nil {
return false, &CelExprError{Msg: fmt.Errorf("failed to compile CEL Expression: %w", issues.Err()).Error(), Code: "CEL:INVALIDATE"}
}
// should not happen
log.WithField("issues", issues).Error("failed to compile CEL Expression")
return false, &CelExprError{Msg: fmt.Errorf("failed to compile CEL Expression").Error(), Code: "CEL:INVALIDATE"}
}
prg, err := env.Program(ast)
if err != nil {
log.WithError(err).Error("failed to create CEL program")
return false, &CelExprError{Msg: fmt.Errorf("failed to create CEL program").Error(), Code: "CEL:INVALIDATE"}
}
input := map[string]interface{}{
"claims": claims,
}
val, _, err := prg.ContextEval(ctx, input)
if err != nil {
return false, &CelExprError{Msg: fmt.Errorf("failed to evaluate CEL program: %w", err).Error(), Code: "CEL:EVAL_ERR"}
}
result, ok := val.Value().(bool)
if !ok {
return false, &CelExprError{Msg: fmt.Errorf("CEL Expression did not evaluate to a boolean").Error(), Code: "CEL:EVAL_NOT_BOOL"}
}
if !result {
return false, &CelExprError{Msg: fmt.Errorf("CEL Expression did not evaluate to true").Error(), Code: "CEL:EVAL_FALSE"}
}
return result, nil
}

func (s *Service) fillClaims(ctx context.Context, provider *oidc.Provider, claims jwt.MapClaims, missingClaims []string) error {
oauth2Info := GetOAuth2ResultFromContext(ctx)
if oauth2Info == nil {
Expand Down
Loading

0 comments on commit c4b53f9

Please sign in to comment.