Path: blob/main/components/public-api-server/pkg/apiv1/oidc.go
2499 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package apiv156import (7"context"8"encoding/json"9"errors"10"fmt"11"net/http"12"net/url"13"strings"14"time"1516connect "github.com/bufbuild/connect-go"17goidc "github.com/coreos/go-oidc/v3/oidc"18"github.com/gitpod-io/gitpod/common-go/experiments"19"github.com/gitpod-io/gitpod/common-go/log"20db "github.com/gitpod-io/gitpod/components/gitpod-db/go"21v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"22"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"23protocol "github.com/gitpod-io/gitpod/gitpod-protocol"24"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"25"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"26"github.com/google/uuid"27"google.golang.org/grpc/codes"28"google.golang.org/grpc/status"29"gorm.io/gorm"30)3132func NewOIDCService(connPool proxy.ServerConnectionPool, expClient experiments.Client, dbConn *gorm.DB, cipher db.Cipher) *OIDCService {33return &OIDCService{34connectionPool: connPool,35expClient: expClient,36cipher: cipher,37dbConn: dbConn,38}39}4041type OIDCService struct {42expClient experiments.Client43connectionPool proxy.ServerConnectionPool4445cipher db.Cipher46dbConn *gorm.DB4748v1connect.UnimplementedOIDCServiceHandler49}5051func (s *OIDCService) CreateClientConfig(ctx context.Context, req *connect.Request[v1.CreateClientConfigRequest]) (*connect.Response[v1.CreateClientConfigResponse], error) {52organizationID, err := validateOrganizationID(ctx, req.Msg.Config.GetOrganizationId())53if err != nil {54return nil, err55}5657conn, err := s.getConnection(ctx)58if err != nil {59return nil, err60}6162_, userID, err := s.getUser(ctx, conn)63if err != nil {64return nil, err65}6667if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {68return nil, authorizationErr69}7071config := req.Msg.GetConfig()72oidcConfig := config.GetOidcConfig()7374issuer, err := validateIssuerURL(oidcConfig.GetIssuer())75if err != nil {76return nil, err77}7879err = assertIssuerIsReachable(ctx, issuer)80if err != nil {81return nil, connect.NewError(connect.CodeInvalidArgument, err)82}83err = assertIssuerProvidesDiscovery(ctx, issuer)84if err != nil {85return nil, connect.NewError(connect.CodeInvalidArgument, err)86}8788oauth2Config := config.GetOauth2Config()89data, err := db.EncryptJSON(s.cipher, toDbOIDCSpec(oauth2Config))90if err != nil {91log.Extract(ctx).WithError(err).Error("Failed to encrypt oidc client config.")92return nil, status.Errorf(codes.Internal, "Failed to store OIDC client config.")93}9495active := config.GetActive()9697created, err := db.CreateOIDCClientConfig(ctx, s.dbConn, db.OIDCClientConfig{98ID: uuid.New(),99OrganizationID: organizationID,100Issuer: issuer.String(),101Data: data,102Active: active,103})104if err != nil {105log.Extract(ctx).WithError(err).Error("Failed to store oidc client config in the database.")106return nil, status.Errorf(codes.Internal, "Failed to store OIDC client config.")107}108109log.AddFields(ctx, log.OIDCClientConfigID(created.ID.String()))110111converted, err := dbOIDCClientConfigToAPI(created, s.cipher)112if err != nil {113log.Extract(ctx).WithError(err).Error("Failed to convert OIDC Client config to response.")114return 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()))115}116117return connect.NewResponse(&v1.CreateClientConfigResponse{118Config: converted,119}), nil120}121122func (s *OIDCService) GetClientConfig(ctx context.Context, req *connect.Request[v1.GetClientConfigRequest]) (*connect.Response[v1.GetClientConfigResponse], error) {123organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())124if err != nil {125return nil, err126}127128clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())129if err != nil {130return nil, err131}132133conn, err := s.getConnection(ctx)134if err != nil {135return nil, err136}137138_, userID, err := s.getUser(ctx, conn)139if err != nil {140return nil, err141}142143if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {144return nil, authorizationErr145}146147record, err := db.GetOIDCClientConfigForOrganization(ctx, s.dbConn, clientConfigID, organizationID)148if err != nil {149if errors.Is(err, db.ErrorNotFound) {150return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))151}152153log.Extract(ctx).WithError(err).Error("Failed to delete OIDC Client config.")154return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to delete OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))155}156157converted, err := dbOIDCClientConfigToAPI(record, s.cipher)158if err != nil {159log.Extract(ctx).WithError(err).Error("Failed to convert OIDC Client config to response.")160return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to convert OIDC Client Config %s for Organization %s to API response", clientConfigID.String(), organizationID.String()))161}162163return connect.NewResponse(&v1.GetClientConfigResponse{164Config: converted,165}), nil166}167168func (s *OIDCService) ListClientConfigs(ctx context.Context, req *connect.Request[v1.ListClientConfigsRequest]) (*connect.Response[v1.ListClientConfigsResponse], error) {169organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())170if err != nil {171return nil, err172}173174conn, err := s.getConnection(ctx)175if err != nil {176return nil, err177}178179_, userID, err := s.getUser(ctx, conn)180if err != nil {181return nil, err182}183184if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {185return nil, authorizationErr186}187188configs, err := db.ListOIDCClientConfigsForOrganization(ctx, s.dbConn, organizationID)189if err != nil {190return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to retrieve oidc client configs"))191}192193results, err := dbOIDCClientConfigsToAPI(configs, s.cipher)194if err != nil {195return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to decrypt client configs"))196}197198return connect.NewResponse(&v1.ListClientConfigsResponse{199ClientConfigs: results,200TotalResults: int64(len(results)),201}), nil202}203204func (s *OIDCService) UpdateClientConfig(ctx context.Context, req *connect.Request[v1.UpdateClientConfigRequest]) (*connect.Response[v1.UpdateClientConfigResponse], error) {205config := req.Msg.GetConfig()206207clientConfigID, err := validateOIDCClientConfigID(ctx, config.GetId())208if err != nil {209return nil, err210}211212organizationID, err := validateOrganizationID(ctx, config.GetOrganizationId())213if err != nil {214return nil, err215}216217conn, err := s.getConnection(ctx)218if err != nil {219return nil, err220}221222_, userID, err := s.getUser(ctx, conn)223if err != nil {224return nil, err225}226227if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {228return nil, authorizationErr229}230231oidcConfig := config.GetOidcConfig()232oauth2Config := config.GetOauth2Config()233234issuer := ""235if oidcConfig.GetIssuer() != "" {236// If we're updating the issuer, let's also check for reachability237issuerURL, err := validateIssuerURL(oidcConfig.GetIssuer())238if err != nil {239return nil, err240}241242err = assertIssuerIsReachable(ctx, issuerURL)243if err != nil {244return nil, connect.NewError(connect.CodeInvalidArgument, err)245}246247issuer = issuerURL.String()248}249250updateSpec := toDbOIDCSpec(oauth2Config)251252if err := db.UpdateOIDCClientConfig(ctx, s.dbConn, s.cipher, db.OIDCClientConfig{253ID: clientConfigID,254Issuer: issuer,255}, &updateSpec); err != nil {256if errors.Is(err, db.ErrorNotFound) {257return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s does not exist", clientConfigID.String()))258}259260log.Extract(ctx).WithError(err).Error("Failed to update OIDC Client config.")261return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to update OIDC Client Config %s", clientConfigID.String()))262}263264return connect.NewResponse(&v1.UpdateClientConfigResponse{}), nil265}266267func (s *OIDCService) DeleteClientConfig(ctx context.Context, req *connect.Request[v1.DeleteClientConfigRequest]) (*connect.Response[v1.DeleteClientConfigResponse], error) {268organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())269if err != nil {270return nil, err271}272273clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())274if err != nil {275return nil, err276}277278conn, err := s.getConnection(ctx)279if err != nil {280return nil, err281}282283_, userID, err := s.getUser(ctx, conn)284if err != nil {285return nil, err286}287288if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {289return nil, authorizationErr290}291292err = db.DeleteOIDCClientConfig(ctx, s.dbConn, clientConfigID, organizationID)293if err != nil {294if errors.Is(err, db.ErrorNotFound) {295return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))296}297298log.Extract(ctx).WithError(err).Error("Failed to delete OIDC Client config.")299return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to delete OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))300}301302return connect.NewResponse(&v1.DeleteClientConfigResponse{}), nil303}304305func (s *OIDCService) SetClientConfigActivation(ctx context.Context, req *connect.Request[v1.SetClientConfigActivationRequest]) (*connect.Response[v1.SetClientConfigActivationResponse], error) {306organizationID, err := validateOrganizationID(ctx, req.Msg.GetOrganizationId())307if err != nil {308return nil, err309}310311clientConfigID, err := validateOIDCClientConfigID(ctx, req.Msg.GetId())312if err != nil {313return nil, err314}315316conn, err := s.getConnection(ctx)317if err != nil {318return nil, err319}320321_, userID, err := s.getUser(ctx, conn)322if err != nil {323return nil, err324}325326if authorizationErr := s.userIsOrgOwner(ctx, userID, organizationID); authorizationErr != nil {327return nil, authorizationErr328}329330config, err := db.GetOIDCClientConfig(ctx, s.dbConn, clientConfigID)331if err != nil {332if errors.Is(err, db.ErrorNotFound) {333return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("OIDC Client Config %s for Organization %s does not exist", clientConfigID.String(), organizationID.String()))334}335336return nil, err337}338339if req.Msg.Activate {340if config.Verified == nil || !*config.Verified {341log.Extract(ctx).WithError(err).Error("Failed to activate an unverified OIDC Client Config.")342return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("Failed to activate an unverified OIDC Client Config %s for Organization %s", clientConfigID.String(), organizationID.String()))343}344}345346err = db.SetClientConfigActiviation(ctx, s.dbConn, clientConfigID, req.Msg.Activate)347if err != nil {348log.Extract(ctx).WithError(err).Error("Failed to set OIDC Client Config activation.")349return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to set OIDC Client Config activation (ID: %s) for Organization %s", clientConfigID.String(), organizationID.String()))350}351352return connect.NewResponse(&v1.SetClientConfigActivationResponse{}), nil353}354355func (s *OIDCService) getConnection(ctx context.Context) (protocol.APIInterface, error) {356token, err := auth.TokenFromContext(ctx)357if err != nil {358return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("No credentials present on request."))359}360361conn, err := s.connectionPool.Get(ctx, token)362if err != nil {363log.Extract(ctx).WithError(err).Error("Failed to get connection to server.")364return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to establish connection to downstream services. If this issue persists, please contact Gitpod Support."))365}366367return conn, nil368}369370func (s *OIDCService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) {371user, err := conn.GetLoggedInUser(ctx)372if err != nil {373return nil, uuid.Nil, proxy.ConvertError(err)374}375376log.AddFields(ctx, log.UserID(user.ID))377378if !s.isFeatureEnabled(ctx, conn, user) {379return 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."))380}381382userID, err := uuid.Parse(user.ID)383if err != nil {384return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support."))385}386387return user, userID, nil388}389390func (s *OIDCService) isFeatureEnabled(ctx context.Context, conn protocol.APIInterface, user *protocol.User) bool {391if user == nil {392return false393}394395if experiments.IsOIDCServiceEnabled(ctx, s.expClient, experiments.Attributes{UserID: user.ID}) {396return true397}398399teams, err := conn.GetTeams(ctx)400if err != nil {401log.Extract(ctx).WithError(err).Warnf("Failed to retreive Teams for user %s, personal access token feature flag will not evaluate team membership.", user.ID)402teams = nil403}404for _, team := range teams {405if experiments.IsOIDCServiceEnabled(ctx, s.expClient, experiments.Attributes{TeamID: team.ID}) {406return true407}408}409410return false411}412413func (s *OIDCService) userIsOrgOwner(ctx context.Context, userID, orgID uuid.UUID) error {414membership, err := db.GetOrganizationMembership(ctx, s.dbConn, userID, orgID)415if err != nil {416if errors.Is(err, db.ErrorNotFound) {417return connect.NewError(connect.CodeNotFound, fmt.Errorf("Organization %s does not exist", orgID.String()))418}419420return connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to verify user %s is owner of organization %s", userID.String(), orgID.String()))421}422423if membership.Role != db.OrganizationMembershipRole_Owner {424return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("user %s is not owner of organization %s", userID.String(), orgID.String()))425}426427return nil428}429430func dbOIDCClientConfigToAPI(config db.OIDCClientConfig, decryptor db.Decryptor) (*v1.OIDCClientConfig, error) {431decrypted, err := config.Data.Decrypt(decryptor)432if err != nil {433return nil, fmt.Errorf("failed to decrypt oidc client config: %w", err)434}435436return &v1.OIDCClientConfig{437Id: config.ID.String(),438OrganizationId: config.OrganizationID.String(),439Oauth2Config: &v1.OAuth2Config{440ClientId: decrypted.ClientID,441ClientSecret: "REDACTED",442AuthorizationEndpoint: decrypted.RedirectURL,443Scopes: decrypted.Scopes,444CelExpression: decrypted.CelExpression,445UsePkce: decrypted.UsePKCE,446},447OidcConfig: &v1.OIDCConfig{448Issuer: config.Issuer,449},450Active: config.Active,451Verified: config.Verified != nil && *config.Verified,452}, nil453}454455func dbOIDCClientConfigsToAPI(configs []db.OIDCClientConfig, decryptor db.Decryptor) ([]*v1.OIDCClientConfig, error) {456var results []*v1.OIDCClientConfig457458for _, c := range configs {459res, err := dbOIDCClientConfigToAPI(c, decryptor)460if err != nil {461return nil, err462}463464results = append(results, res)465}466467return results, nil468}469470func toDbOIDCSpec(oauth2Config *v1.OAuth2Config) db.OIDCSpec {471return db.OIDCSpec{472ClientID: oauth2Config.GetClientId(),473ClientSecret: oauth2Config.GetClientSecret(),474CelExpression: oauth2Config.GetCelExpression(),475UsePKCE: oauth2Config.GetUsePkce(),476RedirectURL: oauth2Config.GetAuthorizationEndpoint(),477Scopes: append([]string{goidc.ScopeOpenID, "profile", "email"}, oauth2Config.GetScopes()...),478}479}480481func assertIssuerIsReachable(ctx context.Context, issuer *url.URL) error {482tr := &http.Transport{483// TLSClientConfig: &tls.Config{InsecureSkipVerify: true},484Proxy: http.ProxyFromEnvironment,485}486client := &http.Client{487Transport: tr,488Timeout: 2 * time.Second,489// never follow redirects490CheckRedirect: func(*http.Request, []*http.Request) error {491return http.ErrUseLastResponse492},493}494495req, err := http.NewRequestWithContext(ctx, http.MethodHead, issuer.String()+"/.well-known/openid-configuration", nil)496if err != nil {497return err498}499resp, err := client.Do(req)500if err != nil {501return err502}503resp.Body.Close()504if resp.StatusCode > 499 {505return fmt.Errorf("returned status %d", resp.StatusCode)506}507return nil508}509510func assertIssuerProvidesDiscovery(ctx context.Context, issuer *url.URL) error {511tr := &http.Transport{512// TLSClientConfig: &tls.Config{InsecureSkipVerify: true},513Proxy: http.ProxyFromEnvironment,514}515client := &http.Client{516Transport: tr,517518// never follow redirects519CheckRedirect: func(*http.Request, []*http.Request) error {520return http.ErrUseLastResponse521},522}523524req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String()+"/.well-known/openid-configuration", nil)525if err != nil {526return err527}528resp, err := client.Do(req)529if err != nil {530return err531}532defer resp.Body.Close()533534if resp.StatusCode != http.StatusOK {535return fmt.Errorf("The identity providers needs to support OIDC Discovery.")536}537if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") {538return fmt.Errorf("OIDC Discovery configuration is of unexpected content type.")539}540541var config map[string]interface{}542err = json.NewDecoder(resp.Body).Decode(&config)543if err != nil {544return fmt.Errorf("OIDC Discovery configuration is not parsable.")545}546return nil547}548549func validateIssuerURL(issuer string) (*url.URL, error) {550parsed, err := url.Parse(strings.TrimSuffix(issuer, "/"))551if err != nil {552return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Issuer must contain a valid URL"))553}554555return parsed, nil556}557558559