package sessions import ( "net/http" "net/url" "testing" "github.com/oauth2-proxy/mockoidc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/xlgmokha/x/pkg/serde" "github.com/xlgmokha/x/pkg/test" "github.com/xlgmokha/x/pkg/x" xcfg "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/cfg" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/oidc" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/web" ) func TestSessions(t *testing.T) { srv := oidc.NewTestServer(t) defer srv.Close() clientID := srv.MockOIDC.Config().ClientID clientSecret := srv.MockOIDC.Config().ClientSecret cfg, err := oidc.New( t.Context(), srv.Issuer(), clientID, clientSecret, "callback_url", ) require.NoError(t, err) controller := New(cfg, http.DefaultClient) mux := http.NewServeMux() controller.MountTo(mux) t.Run("GET /session/new", func(t *testing.T) { t.Run("without an authenticated session", func(t *testing.T) { r, w := test.RequestResponse("GET", "/session/new") mux.ServeHTTP(w, r) t.Run("redirect to the OIDC Provider", func(t *testing.T) { require.Equal(t, http.StatusFound, w.Code) require.NotEmpty(t, w.Header().Get("Location")) redirectURL, err := url.Parse(w.Header().Get("Location")) require.NoError(t, err) assert.Equal(t, srv.AuthorizationEndpoint(), redirectURL.Scheme+"://"+redirectURL.Host+redirectURL.Path) assert.NotEmpty(t, redirectURL.Query().Get("state")) assert.Equal(t, srv.MockOIDC.Config().ClientID, redirectURL.Query().Get("client_id")) assert.Equal(t, "openid profile email", redirectURL.Query().Get("scope")) assert.Equal(t, cfg.Config.ClientID, redirectURL.Query().Get("audience")) assert.Equal(t, cfg.Config.RedirectURL, redirectURL.Query().Get("redirect_uri")) assert.Equal(t, "code", redirectURL.Query().Get("response_type")) }) t.Run("generates a CSRF token", func(t *testing.T) { cookieHeader := w.Header().Get("Set-Cookie") require.NotEmpty(t, cookieHeader) cookie, err := http.ParseSetCookie(w.Header().Get("Set-Cookie")) require.NoError(t, err) require.NotZero(t, cookie) assert.Equal(t, xcfg.CSRFCookie, cookie.Name) }) }) t.Run("with an active authenicated session", func(t *testing.T) { t.Run("redirects to the dashboard", func(t *testing.T) { user := &domain.User{} r, w := test.RequestResponse( "GET", "/session/new", test.WithContextKeyValue(t.Context(), xcfg.CurrentUser, user), ) mux.ServeHTTP(w, r) require.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/dashboard", w.Header().Get("Location")) }) }) }) t.Run("GET /session/callback", func(t *testing.T) { t.Run("with an invalid csrf token", func(t *testing.T) { user := mockoidc.DefaultUser() code := srv.CreateAuthorizationCodeFor(user) nonce := pls.GenerateRandomHex(32) r, w := test.RequestResponse( "GET", "/session/callback?code="+code+"&state=invalid", test.WithCookie(web.NewCookie(xcfg.CSRFCookie, nonce)), ) mux.ServeHTTP(w, r) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("with an invalid authorization code grant", func(t *testing.T) { r, w := test.RequestResponse("GET", "/session/callback?code=invalid") mux.ServeHTTP(w, r) assert.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("when already logged in", func(t *testing.T) { t.Run("redirects to the dashboard", func(t *testing.T) { user := &domain.User{} r, w := test.RequestResponse( "GET", "/session/callback?code=valid", test.WithContextKeyValue(t.Context(), xcfg.CurrentUser, user), ) mux.ServeHTTP(w, r) require.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/dashboard", w.Header().Get("Location")) }) }) t.Run("with a valid authorization code grant", func(t *testing.T) { user := mockoidc.DefaultUser() code := srv.CreateAuthorizationCodeFor(user) nonce := pls.GenerateRandomHex(32) r, w := test.RequestResponse( "GET", "/session/callback?code="+code+"&state="+nonce, test.WithCookie(web.NewCookie(xcfg.CSRFCookie, nonce)), ) mux.ServeHTTP(w, r) cookieValues := w.Header().Values("Set-Cookie") cookies := x.Map(cookieValues, func(line string) *http.Cookie { ck, err := http.ParseSetCookie(line) require.NoError(t, err) return ck }) t.Run("stores the id token in a session cookie", func(t *testing.T) { cookie := x.Find(cookies, func(item *http.Cookie) bool { return item.Name == xcfg.IDTokenCookie }) require.NotNil(t, cookie) idToken := srv.Verify(cookie.Value) assert.Equal(t, user.Subject, idToken.Subject) }) t.Run("stores the access token in a session cookie", func(t *testing.T) { cookie := x.Find(cookies, func(item *http.Cookie) bool { return item.Name == xcfg.BearerTokenCookie }) require.NotNil(t, cookie) keypair, err := mockoidc.DefaultKeypair() require.NoError(t, err) token, err := keypair.VerifyJWT(cookie.Value, nil) require.NoError(t, err) sub, err := token.Claims.GetSubject() require.NoError(t, err) assert.Equal(t, user.Subject, sub) }) t.Run("stores the refresh token in a session cookie", func(t *testing.T) { cookie := x.Find(cookies, func(item *http.Cookie) bool { return item.Name == xcfg.RefreshTokenCookie }) require.NotNil(t, cookie) keypair, err := mockoidc.DefaultKeypair() require.NoError(t, err) token, err := keypair.VerifyJWT(cookie.Value, nil) require.NoError(t, err) sub, err := token.Claims.GetSubject() require.NoError(t, err) assert.Equal(t, user.Subject, sub) }) t.Run("redirects to the homepage", func(t *testing.T) { require.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/dashboard", w.Header().Get("Location")) }) t.Run("applies the appropriate cookie settings", func(t *testing.T) { x.Each(cookies, func(cookie *http.Cookie) { t.Logf("%v: %v\n", cookie.Name, cookie.Value) assert.Equal(t, "/", cookie.Path) assert.NotEmpty(t, cookie.Name) assert.True(t, cookie.HttpOnly) assert.True(t, cookie.Secure) }) }) }) }) t.Run("GET /session", func(t *testing.T) { t.Run("with an id_token cookie", func(t *testing.T) { user := mockoidc.DefaultUser() _, rawIDToken := srv.CreateTokensFor(user) cookie := &http.Cookie{Name: xcfg.IDTokenCookie, Value: rawIDToken} r, w := test.RequestResponse("GET", "/session", test.WithCookie(cookie)) mux.ServeHTTP(w, r) require.Equal(t, http.StatusOK, w.Code) items, err := serde.FromJSON[map[string]interface{}](w.Body) require.NoError(t, err) assert.Equal(t, srv.Issuer(), items["iss"]) }) t.Run("without an id_token cookie", func(t *testing.T) { r, w := test.RequestResponse("GET", "/session") mux.ServeHTTP(w, r) require.Equal(t, http.StatusNotFound, w.Code) }) }) t.Run("POST /session/destroy", func(t *testing.T) { t.Run("clears the session cookie", func(t *testing.T) { cookie := web.NewCookie(xcfg.IDTokenCookie, "value") r, w := test.RequestResponse("POST", "/session/destroy", test.WithCookie(cookie)) mux.ServeHTTP(w, r) require.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/", w.Header().Get("Location")) expected := []string{ "__csrf=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Secure", "id_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Secure", "bearer_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Secure", "refresh_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Secure", } assert.ElementsMatch(t, expected, w.Header().Values("Set-Cookie")) }) }) }