diff --git a/components/common-go/experiments/flags.go b/components/common-go/experiments/flags.go index a34b0c4bdd..4d525db4cb 100644 --- a/components/common-go/experiments/flags.go +++ b/components/common-go/experiments/flags.go @@ -11,6 +11,7 @@ const ( OIDCServiceEnabledFlag = "oidcServiceEnabled" SupervisorPersistServerAPIChannelWhenStartFlag = "supervisor_persist_serverapi_channel_when_start" SupervisorUsePublicAPIFlag = "supervisor_experimental_publicapi" + JWTSessionsEnabledFlag = "jwtSessionCookieEnabled" ) func IsPersonalAccessTokensEnabled(ctx context.Context, client Client, attributes Attributes) bool { @@ -28,3 +29,7 @@ func SupervisorPersistServerAPIChannelWhenStart(ctx context.Context, client Clie func SupervisorUsePublicAPI(ctx context.Context, client Client, attributes Attributes) bool { return client.GetBoolValue(ctx, SupervisorUsePublicAPIFlag, false, attributes) } + +func JWTSessionsEnabled(ctx context.Context, client Client, attributes Attributes) bool { + return client.GetBoolValue(ctx, JWTSessionsEnabledFlag, false, attributes) +} diff --git a/components/public-api-server/pkg/auth/metrics.go b/components/public-api-server/pkg/auth/metrics.go new file mode 100644 index 0000000000..38bb9f8956 --- /dev/null +++ b/components/public-api-server/pkg/auth/metrics.go @@ -0,0 +1,28 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package auth + +import ( + "strconv" + + "github.com/prometheus/client_golang/prometheus" +) + +func reportRequestWithJWT(jwtPresent bool) { + requestsWithJWTSessionsTotal.WithLabelValues(strconv.FormatBool(jwtPresent)).Inc() +} + +var ( + requestsWithJWTSessionsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "gitpod", + Subsystem: "public_api", + Name: "requests_with_jwt_sessions_total", + Help: "Count of sessions with, or without JWT sessions", + }, []string{"with_jwt"}) +) + +func RegisterMetrics(registry *prometheus.Registry) { + registry.MustRegister(requestsWithJWTSessionsTotal) +} diff --git a/components/public-api-server/pkg/auth/session_jwt.go b/components/public-api-server/pkg/auth/session_jwt.go index 5ac10e17e2..130b675e45 100644 --- a/components/public-api-server/pkg/auth/session_jwt.go +++ b/components/public-api-server/pkg/auth/session_jwt.go @@ -5,9 +5,14 @@ package auth import ( + "context" "fmt" + "net/http" "time" + "github.com/bufbuild/connect-go" + "github.com/gitpod-io/gitpod/common-go/experiments" + "github.com/gitpod-io/gitpod/common-go/log" "github.com/gitpod-io/gitpod/public-api-server/pkg/jws" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -41,3 +46,58 @@ func VerifySessionJWT(token string, verifier jws.Verifier, expectedIssuer string return claims, nil } + +func NewJWTCookieInterceptor(exp experiments.Client, cookieName string, expectedIssuer string, verifier jws.Verifier) connect.UnaryInterceptorFunc { + interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + + return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + validJWT := false + defer func() { + reportRequestWithJWT(validJWT) + }() + + if req.Spec().IsClient { + return next(ctx, req) + } + + token, err := TokenFromContext(ctx) + if err != nil { + return next(ctx, req) + } + + if !experiments.JWTSessionsEnabled(ctx, exp, experiments.Attributes{}) { + return next(ctx, req) + } + + if token.Type != CookieTokenType { + return next(ctx, req) + } + + jwtSessionCookie, err := cookieFromString(token.Value, cookieName) + if err != nil { + return next(ctx, req) + } + + claims, err := VerifySessionJWT(jwtSessionCookie.Value, verifier, expectedIssuer) + if err != nil { + log.Extract(ctx).WithError(err).Warnf("Failed to verify JWT session token") + return next(ctx, req) + } + + validJWT = claims != nil + + return next(ctx, req) + }) + } + return connect.UnaryInterceptorFunc(interceptor) +} + +func cookieFromString(rawCookieHeader, name string) (*http.Cookie, error) { + // To access the cookie as an http.Cookie, we sadly have to construct a request with the appropriate header such + // that we can then extract the cookie. + header := http.Header{} + header.Add("Cookie", rawCookieHeader) + req := http.Request{Header: header} + + return req.Cookie(name) +} diff --git a/components/public-api-server/pkg/server/server.go b/components/public-api-server/pkg/server/server.go index 1bdca1e79f..dd6acf6070 100644 --- a/components/public-api-server/pkg/server/server.go +++ b/components/public-api-server/pkg/server/server.go @@ -97,7 +97,7 @@ func Start(logger *logrus.Entry, version string, cfg *config.Configuration) erro if err != nil { return fmt.Errorf("failed to setup JWS Keyset: %w", err) } - _, err = jws.NewRSA256(keyset) + rsa256, err := jws.NewRSA256(keyset) if err != nil { return fmt.Errorf("failed to setup jws.RSA256: %w", err) } @@ -139,13 +139,15 @@ func Start(logger *logrus.Entry, version string, cfg *config.Configuration) erro } if registerErr := register(srv, ®isterDependencies{ - connPool: connPool, - expClient: expClient, - dbConn: dbConn, - signer: signer, - cipher: cipherSet, - oidcService: oidcService, - idpService: idpService, + connPool: connPool, + expClient: expClient, + dbConn: dbConn, + signer: signer, + cipher: cipherSet, + oidcService: oidcService, + idpService: idpService, + authCfg: cfg.Auth, + sessionVerifier: rsa256, }); registerErr != nil { return fmt.Errorf("failed to register services: %w", registerErr) } @@ -165,10 +167,14 @@ type registerDependencies struct { cipher db.Cipher oidcService *oidc.Service idpService *identityprovider.Service + + sessionVerifier jws.SignerVerifier + authCfg config.AuthConfiguration } func register(srv *baseserver.Server, deps *registerDependencies) error { proxy.RegisterMetrics(srv.MetricsRegistry()) + auth.RegisterMetrics(srv.MetricsRegistry()) connectMetrics := NewConnectMetrics() err := connectMetrics.Register(srv.MetricsRegistry()) @@ -186,6 +192,7 @@ func register(srv *baseserver.Server, deps *registerDependencies) error { NewLogInterceptor(log.Log), auth.NewServerInterceptor(), origin.NewInterceptor(), + auth.NewJWTCookieInterceptor(deps.expClient, deps.authCfg.Session.Cookie.Name, deps.authCfg.Session.Issuer, deps.sessionVerifier), ), }