Path: blob/main/components/public-api-server/pkg/oidc/service_test.go
2500 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 oidc56import (7"context"8"encoding/json"9"fmt"10"io"11"log"12"net/http"13"net/http/httptest"14"net/url"15"testing"16"time"1718"github.com/coreos/go-oidc/v3/oidc"19goidc "github.com/coreos/go-oidc/v3/oidc"20db "github.com/gitpod-io/gitpod/components/gitpod-db/go"21"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"22"github.com/gitpod-io/gitpod/public-api-server/pkg/jws"23"github.com/gitpod-io/gitpod/public-api-server/pkg/jws/jwstest"24"github.com/go-chi/chi/v5"25"github.com/go-chi/chi/v5/middleware"26"github.com/golang-jwt/jwt/v5"27"github.com/google/go-cmp/cmp"28"github.com/google/uuid"29"github.com/stretchr/testify/require"30"golang.org/x/oauth2"31"gopkg.in/square/go-jose.v2"32"gorm.io/gorm"33)3435func TestGetStartParams(t *testing.T) {36const (37issuerG = "https://accounts.google.com"38clientID = "client-id-123"39redirectURL = "https://test.local/iam/oidc/callback"40)41service, _ := setupOIDCServiceForTests(t)42config := &ClientConfig{43Issuer: issuerG,44VerifierConfig: &oidc.Config{},45OAuth2Config: &oauth2.Config{46ClientID: clientID,47Endpoint: oauth2.Endpoint{48AuthURL: issuerG + "/o/oauth2/v2/auth",49},50},51}5253params, err := service.getStartParams(config, redirectURL, StateParams{54ClientConfigID: config.ID,55ReturnToURL: "/",56Activate: false,57})5859require.NoError(t, err)60require.NotNil(t, params.Nonce)61require.NotNil(t, params.State)6263// AuthCodeURL example:64// https://accounts.google.com/o/oauth2/v2/auth65// ?client_id=client-id-12366// &nonce=UFTMxxUtc5jVZbp2a2R9XEoRwpfzs-04FcmVQ-HdCsw67// &response_type=code68// &redirect_url=https...69// &state=Q4XzRcdo4jtOYeRbF17T9LHHwX-4HacT1_5pZH8mXLI70require.NotNil(t, params.AuthCodeURL)71require.Contains(t, params.AuthCodeURL, issuerG)72require.Contains(t, params.AuthCodeURL, clientID)73require.Contains(t, params.AuthCodeURL, url.QueryEscape(redirectURL))74require.Contains(t, params.AuthCodeURL, url.QueryEscape(params.Nonce))75require.Contains(t, params.AuthCodeURL, url.QueryEscape(params.State))76}7778func TestGetClientConfigFromStartRequest(t *testing.T) {79issuer := newFakeIdP(t)80service, dbConn := setupOIDCServiceForTests(t)81config, team := createConfig(t, dbConn, &ClientConfig{82Issuer: issuer,83Active: true,84VerifierConfig: &oidc.Config{},85OAuth2Config: &oauth2.Config{},86})87// create second org to emulate an installation with multiple orgs88createConfig(t, dbConn, &ClientConfig{89Issuer: issuer,90Active: true,91VerifierConfig: &oidc.Config{},92OAuth2Config: &oauth2.Config{},93})94configID := config.ID.String()9596testCases := []struct {97Location string98ExpectedError bool99ExpectedId string100}{101{102Location: "/start?word=abc",103ExpectedError: true,104ExpectedId: "",105},106{107Location: "/start?id=UNKNOWN",108ExpectedError: true,109ExpectedId: "",110},111{112Location: "/start?id=" + configID,113ExpectedError: false,114ExpectedId: configID,115},116{117Location: "/start?orgSlug=" + team.Slug,118ExpectedError: false,119ExpectedId: configID,120},121}122123for _, tc := range testCases {124t.Run(tc.Location, func(te *testing.T) {125request := httptest.NewRequest(http.MethodGet, tc.Location, nil)126config, err := service.getClientConfigFromStartRequest(request)127if tc.ExpectedError == true {128require.Error(te, err)129}130if tc.ExpectedError != true {131require.NoError(te, err)132require.NotNil(te, config)133require.Equal(te, tc.ExpectedId, config.ID)134}135})136}137138t.Cleanup(func() {139require.NoError(t, dbConn.Where("slug = ?", team.Slug).Delete(&db.Organization{}).Error)140})141}142143func TestGetClientConfigFromStartRequestSingleOrg(t *testing.T) {144issuer := newFakeIdP(t)145service, dbConn := setupOIDCServiceForTests(t)146// make sure no other organizations are in the db anymore147dbConn.Delete(&db.Organization{}, "1=1")148config, team := createConfig(t, dbConn, &ClientConfig{149Issuer: issuer,150Active: true,151VerifierConfig: &oidc.Config{},152OAuth2Config: &oauth2.Config{},153})154configID := config.ID.String()155156testCases := []struct {157Location string158ExpectedError bool159ExpectedId string160}{161{162Location: "/start",163ExpectedError: false,164ExpectedId: configID,165},166{167Location: "/start?word=abc",168ExpectedError: false,169ExpectedId: configID,170},171{172Location: "/start?id=UNKNOWN",173ExpectedError: true,174ExpectedId: "",175},176{177Location: "/start?id=" + configID,178ExpectedError: false,179ExpectedId: configID,180},181{182Location: "/start?orgSlug=" + team.Slug,183ExpectedError: false,184ExpectedId: configID,185},186}187188for _, tc := range testCases {189t.Run(tc.Location, func(te *testing.T) {190request := httptest.NewRequest(http.MethodGet, tc.Location, nil)191config, err := service.getClientConfigFromStartRequest(request)192if tc.ExpectedError == true {193require.Error(te, err)194}195if tc.ExpectedError != true {196require.NoError(te, err)197require.NotNil(te, config)198require.Equal(te, tc.ExpectedId, config.ID, "wrong config")199}200})201}202203t.Cleanup(func() {204require.NoError(t, dbConn.Where("slug = ?", team.Slug).Delete(&db.Organization{}).Error)205})206}207208func TestGetClientConfigFromCallbackRequest(t *testing.T) {209issuer := newFakeIdP(t)210service, dbConn := setupOIDCServiceForTests(t)211config, _ := createConfig(t, dbConn, &ClientConfig{212Issuer: issuer,213VerifierConfig: &oidc.Config{},214OAuth2Config: &oauth2.Config{},215})216configID := config.ID.String()217218state, err := service.encodeStateParam(StateParams{219ClientConfigID: configID,220ReturnToURL: "",221})222require.NoError(t, err, "failed encode state param")223224state_unknown, err := service.encodeStateParam(StateParams{225ClientConfigID: "UNKNOWN",226ReturnToURL: "",227})228require.NoError(t, err, "failed encode state param")229230testCases := []struct {231Location string232ExpectedError bool233ExpectedId string234}{235{236Location: "/callback?state=BAD",237ExpectedError: true,238ExpectedId: "",239},240{241Location: "/callback?state=" + state_unknown,242ExpectedError: true,243ExpectedId: "",244},245{246Location: "/callback?state=" + state,247ExpectedError: false,248ExpectedId: configID,249},250}251252for _, tc := range testCases {253t.Run(tc.Location, func(t *testing.T) {254request := httptest.NewRequest(http.MethodGet, tc.Location, nil)255config, _, err := service.getClientConfigFromCallbackRequest(request)256if tc.ExpectedError == true {257require.Error(t, err)258}259if tc.ExpectedError != true {260require.NoError(t, err)261require.NotNil(t, config)262require.Equal(t, tc.ExpectedId, config.ID)263}264})265}266}267268func TestCreateSession(t *testing.T) {269service, _ := setupOIDCServiceForTests(t)270271config := ClientConfig{272ID: "foo1",273OrganizationID: "org1",274}275276_, message, err := service.createSession(context.Background(), &AuthFlowResult{}, &config)277require.NoError(t, err, "failed to create session")278279got := map[string]interface{}{}280err = json.Unmarshal([]byte(message), &got)281require.NoError(t, err, "failed to parse response")282283expected := map[string]interface{}{284"claims": nil,285"idToken": nil,286"oidcClientConfigId": config.ID,287"organizationId": config.OrganizationID,288}289290if diff := cmp.Diff(expected, got); diff != "" {291t.Errorf("Unexpected create session payload (-want +got):\n%s", diff)292}293}294295func Test_validateRequiredClaims(t *testing.T) {296service, _ := setupOIDCServiceForTests(t)297298type data struct {299jwt.RegisteredClaims300Email string `json:"email,omitempty"`301Name string `json:"name,omitempty"`302}303304testCases := []struct {305Label string306ExpectedError string307Claims data308}{309{310Label: "Required claims present",311ExpectedError: "",312Claims: data{313RegisteredClaims: jwt.RegisteredClaims{314Audience: []string{"audience"},315},316Email: "me@localhost",317Name: "Admin",318},319},320{321Label: "Email claim is missing",322ExpectedError: "email claim is missing",323Claims: data{324RegisteredClaims: jwt.RegisteredClaims{325Audience: []string{"audience"},326},327Name: "Admin",328},329},330{331Label: "Name claim is missing",332ExpectedError: "name claim is missing",333Claims: data{334RegisteredClaims: jwt.RegisteredClaims{335Audience: []string{"audience"},336},337Email: "admin@localhost",338},339},340}341342for _, tc := range testCases {343t.Run(tc.Label, func(t *testing.T) {344token := createTestIDToken(t, tc.Claims)345346_, err := service.validateRequiredClaims(context.Background(), nil, token)347if tc.ExpectedError == "" {348require.NoError(t, err)349}350if tc.ExpectedError != "" {351require.Equal(t, err.Error(), tc.ExpectedError)352}353})354}355}356357func Test_verifyCelExpression(t *testing.T) {358service, _ := setupOIDCServiceForTests(t)359360testCases := []struct {361Label string362ExpectedError bool363ExpectedErrorMsg string364ExpectedErrorCode string365ExpectedResult bool366Claims jwt.MapClaims367CEL string368}{369{370Label: "email verify",371ExpectedError: true,372ExpectedErrorMsg: "CEL Expression did not evaluate to true [CEL:EVAL_FALSE]",373ExpectedErrorCode: "CEL:EVAL_FALSE",374ExpectedResult: false,375Claims: jwt.MapClaims{376"Audience": []string{"audience"},377"groups_direct": []string{378"gitpod-team",379"gitpod-team2/sub_group",380},381"email": "[email protected]",382"email_verified": false,383},384CEL: "claims.email_verified && claims.email_verified.email.endsWith('@gitpod.io')",385},386{387Label: "GitLab: groups restriction",388ExpectedError: false,389ExpectedResult: true,390Claims: jwt.MapClaims{391"Audience": []string{"audience"},392"groups_direct": []string{393"gitpod-team",394"gitpod-team2/sub_group",395},396"email": "[email protected]",397"email_verified": false,398},399CEL: "(claims.email_verified && claims.email_verified.email.endsWith('@gitpod.io')) || 'gitpod-team' in claims.groups_direct",400},401{402Label: "GitLab: groups restriction (not allowed)",403ExpectedError: true,404ExpectedErrorMsg: "CEL Expression did not evaluate to true [CEL:EVAL_FALSE]",405ExpectedErrorCode: "CEL:EVAL_FALSE",406ExpectedResult: false,407Claims: jwt.MapClaims{408"Audience": []string{"audience"},409"groups_direct": []string{410"gitpod-team2/sub_group",411},412"email": "[email protected]",413"email_verified": false,414},415CEL: "(claims.email_verified && claims.email_verified.email.endsWith('@gitpod.io')) || 'gitpod-team2' in claims.groups_direct",416},417{418Label: "invalidate cel",419ExpectedError: true,420ExpectedErrorCode: "CEL:INVALIDATE",421ExpectedResult: false,422Claims: jwt.MapClaims{423"Audience": []string{"audience"},424"groups_direct": []string{425"gitpod-team",426"gitpod-team2/sub_group",427},428"email": "[email protected]",429"email_verified": false,430},431CEL: "foo",432},433}434435for _, tc := range testCases {436t.Run(tc.Label, func(t *testing.T) {437result, err := service.verifyCelExpression(context.Background(), tc.CEL, tc.Claims)438if tc.ExpectedErrorCode != "" {439if celExprErr, ok := err.(*CelExprError); ok {440require.Equal(t, celExprErr.Code, tc.ExpectedErrorCode, "Unexpected CEL error code")441}442}443if !tc.ExpectedError {444require.NoError(t, err)445} else {446require.True(t, err != nil, "Should return error")447if tc.ExpectedErrorMsg != "" {448require.Equal(t, err.Error(), tc.ExpectedErrorMsg)449}450}451require.Equal(t, result, tc.ExpectedResult, "Unexpected result")452})453}454}455456func createTestIDToken(t *testing.T, claims jwt.Claims) *goidc.IDToken {457t.Helper()458459token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)460rawIDToken, err := token.SignedString([]byte("no-relevant-for-this-test"))461require.NoError(t, err)462463verifier := goidc.NewVerifier("http://localhost", nil, &goidc.Config{464SkipIssuerCheck: true,465SkipClientIDCheck: true,466SkipExpiryCheck: true,467InsecureSkipSignatureCheck: true,468})469470verifiedToken, err := verifier.Verify(context.Background(), rawIDToken)471require.NoError(t, err)472473return verifiedToken474}475476func setupOIDCServiceForTests(t *testing.T) (*Service, *gorm.DB) {477t.Helper()478479dbConn := dbtest.ConnectForTests(t)480cipher := dbtest.CipherSet(t)481482sessionServerAddress := newFakeSessionServer(t)483484keyset := jwstest.GenerateKeySet(t)485signerVerifier := jws.NewHS256FromKeySet(keyset)486487service := NewService(sessionServerAddress, dbConn, cipher, signerVerifier, 5*time.Minute)488service.skipVerifyIdToken = true489return service, dbConn490}491492func createConfig(t *testing.T, dbConn *gorm.DB, config *ClientConfig) (db.OIDCClientConfig, db.Organization) {493t.Helper()494495team := dbtest.CreateOrganizations(t, dbConn, db.Organization{})[0]496497data, err := db.EncryptJSON(dbtest.CipherSet(t), db.OIDCSpec{498ClientID: config.OAuth2Config.ClientID,499ClientSecret: config.OAuth2Config.ClientSecret,500})501require.NoError(t, err)502503created := dbtest.CreateOIDCClientConfigs(t, dbConn, db.OIDCClientConfig{504ID: uuid.New(),505OrganizationID: team.ID,506Issuer: config.Issuer,507Active: false,508Data: data,509}, db.OIDCClientConfig{510ID: uuid.New(),511OrganizationID: team.ID,512Issuer: config.Issuer,513Active: config.Active,514Data: data,515})[1]516517return created, team518}519520func newFakeSessionServer(t *testing.T) string {521router := chi.NewRouter()522ts := httptest.NewServer(router)523url, err := url.Parse(ts.URL)524if err != nil {525log.Fatal(err)526}527528router.Use(middleware.Logger)529router.Post("/session", func(w http.ResponseWriter, r *http.Request) {530http.SetCookie(w, &http.Cookie{531Name: "test-cookie",532Value: "chocolate-chips",533Path: "/",534HttpOnly: true,535Expires: time.Now().AddDate(0, 0, 1),536})537w.WriteHeader(http.StatusOK)538539// mirroring back the request body for testing540body, err := io.ReadAll(r.Body)541if err != nil {542body = []byte(err.Error())543}544_, err = w.Write(body)545if err != nil {546log.Fatal(err)547}548})549550t.Cleanup(ts.Close)551return url.Host552}553554func newFakeIdP(t *testing.T) string {555router := chi.NewRouter()556ts := httptest.NewServer(router)557url := ts.URL558559keyset := jwstest.GenerateKeySet(t)560rsa256, err := jws.NewRSA256(keyset)561require.NoError(t, err)562563type IDTokenClaims struct {564Nonce string `json:"nonce"`565Email string `json:"email"`566Name string `json:"name"`567jwt.RegisteredClaims568}569token := jwt.NewWithClaims(jwt.SigningMethodRS256, &IDTokenClaims{570Nonce: "111",571RegisteredClaims: jwt.RegisteredClaims{572Subject: "user-id",573Audience: jwt.ClaimStrings{"client-id"},574Issuer: url,575ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),576IssuedAt: jwt.NewNumericDate(time.Now()),577},578Email: "[email protected]",579Name: "User",580})581582idTokenValue, err := rsa256.Sign(token)583require.NoError(t, err)584585var jwks jose.JSONWebKeySet586jwks.Keys = append(jwks.Keys, jose.JSONWebKey{587Key: &keyset.Signing.Private.PublicKey,588KeyID: "0001",589Algorithm: string(jose.RS256),590})591keysValue, err := json.Marshal(jwks)592require.NoError(t, err)593594router.Use(middleware.Logger)595router.Get("/oauth2/v3/certs", func(w http.ResponseWriter, r *http.Request) {596_, err := w.Write(keysValue)597if err != nil {598log.Fatal(err)599}600})601router.Get("/o/oauth2/v2/auth", func(w http.ResponseWriter, r *http.Request) {602_, err := w.Write([]byte(r.URL.RawQuery))603if err != nil {604log.Fatal(err)605}606})607router.Post("/token", func(w http.ResponseWriter, r *http.Request) {608w.Header().Add("Content-Type", "application/json")609_, err := w.Write([]byte(fmt.Sprintf(`{610"access_token": "no-token-set",611"id_token": "%[1]s"612}`, idTokenValue)))613if err != nil {614log.Fatal(err)615}616})617router.Get("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {618w.Header().Add("Content-Type", "application/json")619_, err := w.Write([]byte(fmt.Sprintf(`{620"issuer": "%[1]s",621"authorization_endpoint": "%[1]s/o/oauth2/v2/auth",622"device_authorization_endpoint": "%[1]s/device/code",623"token_endpoint": "%[1]s/token",624"userinfo_endpoint": "%[1]s/v1/userinfo",625"revocation_endpoint": "%[1]s/revoke",626"jwks_uri": "%[1]s/oauth2/v3/certs",627"response_types_supported": [628"code",629"token",630"id_token",631"code token",632"code id_token",633"token id_token",634"code token id_token",635"none"636],637"subject_types_supported": [638"public"639],640"id_token_signing_alg_values_supported": [641"RS256"642],643"scopes_supported": [644"openid",645"email",646"profile"647],648"token_endpoint_auth_methods_supported": [649"client_secret_post",650"client_secret_basic"651],652"claims_supported": [653"aud",654"email",655"email_verified",656"exp",657"family_name",658"given_name",659"iat",660"iss",661"locale",662"name",663"picture",664"sub"665],666"code_challenge_methods_supported": [667"plain",668"S256"669],670"grant_types_supported": [671"authorization_code",672"refresh_token",673"urn:ietf:params:oauth:grant-type:device_code",674"urn:ietf:params:oauth:grant-type:jwt-bearer"675]676}`, url)))677if err != nil {678t.Error((err))679t.FailNow()680}681})682683t.Cleanup(ts.Close)684return url685}686687688