github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/accounts/oauth_test.go (about)

     1  package accounts
     2  
     3  import (
     4  	"net/http"
     5  	"net/http/httptest"
     6  	"net/url"
     7  	"strings"
     8  	"testing"
     9  
    10  	"github.com/cozy/cozy-stack/model/account"
    11  	"github.com/cozy/cozy-stack/model/instance"
    12  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    13  	"github.com/cozy/cozy-stack/model/session"
    14  	build "github.com/cozy/cozy-stack/pkg/config"
    15  	"github.com/cozy/cozy-stack/pkg/config/config"
    16  	"github.com/cozy/cozy-stack/pkg/consts"
    17  	"github.com/cozy/cozy-stack/pkg/couchdb"
    18  	"github.com/cozy/cozy-stack/pkg/prefixer"
    19  	"github.com/cozy/cozy-stack/tests/testutils"
    20  	"github.com/gavv/httpexpect/v2"
    21  	"github.com/labstack/echo/v4"
    22  	"github.com/stretchr/testify/assert"
    23  	"github.com/stretchr/testify/require"
    24  )
    25  
    26  func TestOauth(t *testing.T) {
    27  	if testing.Short() {
    28  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    29  	}
    30  
    31  	var testInstance *instance.Instance
    32  
    33  	config.UseTestFile(t)
    34  	build.BuildMode = build.ModeDev
    35  	testutils.NeedCouchdb(t)
    36  
    37  	setup := testutils.NewSetup(t, t.Name())
    38  	ts := setup.GetTestServer("/accounts", Routes, func(r *echo.Echo) *echo.Echo {
    39  		r.POST("/login", func(c echo.Context) error {
    40  			sess, _ := session.New(testInstance, session.LongRun)
    41  			cookie, _ := sess.ToCookie()
    42  			t.Logf("cookie: %q", cookie)
    43  			c.SetCookie(cookie)
    44  			return c.HTML(http.StatusOK, "OK")
    45  		})
    46  		return r
    47  	})
    48  	t.Cleanup(ts.Close)
    49  
    50  	testInstance = setup.GetTestInstance(&lifecycle.Options{
    51  		Domain: strings.Replace(ts.URL, "http://127.0.0.1", "cozy.localhost", 1),
    52  	})
    53  	_ = couchdb.ResetDB(prefixer.SecretsPrefixer, consts.AccountTypes)
    54  	t.Cleanup(func() { _ = couchdb.DeleteDB(prefixer.SecretsPrefixer, consts.AccountTypes) })
    55  
    56  	t.Run("AccessCodeOauthFlow", func(t *testing.T) {
    57  		e := testutils.CreateTestClient(t, ts.URL)
    58  
    59  		// Retrieve the cozysessid cookie
    60  		cozysessID := e.POST("/login").
    61  			WithHost(testInstance.Domain).
    62  			Expect().Status(200).
    63  			Cookie("cozysessid").Value().Raw()
    64  
    65  		redirectURI := ts.URL + "/accounts/test-service/redirect"
    66  
    67  		// Register the client inside the database.
    68  		service := makeTestACService(redirectURI)
    69  		t.Cleanup(service.Close)
    70  
    71  		serviceType := account.AccountType{
    72  			DocID:                 "test-service",
    73  			GrantMode:             account.AuthorizationCode,
    74  			ClientID:              "the-client-id",
    75  			ClientSecret:          "the-client-secret",
    76  			AuthEndpoint:          service.URL + "/oauth2/v2/auth",
    77  			TokenEndpoint:         service.URL + "/oauth2/v4/token",
    78  			RegisteredRedirectURI: redirectURI,
    79  		}
    80  		err := couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
    81  		require.NoError(t, err)
    82  		t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
    83  
    84  		// Start the oauth flow
    85  		rawURL := e.GET("/accounts/test-service/start").
    86  			WithQuery("scope", "the world").
    87  			WithQuery("state", "somesecretstate").
    88  			WithCookie("cozysessid", cozysessID).
    89  			Expect().Status(200).
    90  			Body().Raw()
    91  
    92  		okURL, err := url.Parse(rawURL)
    93  		require.NoError(t, err)
    94  
    95  		// the user click the oauth link
    96  		rawFinalURL := e.GET(okURL.Path).
    97  			WithQueryString(okURL.RawQuery).
    98  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
    99  			Expect().Status(303).
   100  			Header("Location").NotEmpty().Contains("home").
   101  			Raw()
   102  
   103  		finalURL, err := url.Parse(rawFinalURL)
   104  		require.NoError(t, err)
   105  
   106  		var out couchdb.JSONDoc
   107  		err = couchdb.GetDoc(testInstance, consts.Accounts, finalURL.Query().Get("account"), &out)
   108  		assert.NoError(t, err)
   109  		assert.Equal(t, "the-access-token", out.M["oauth"].(map[string]interface{})["access_token"])
   110  		out.Type = consts.Accounts
   111  		out.M["manual_cleaning"] = true
   112  		_ = couchdb.DeleteDoc(testInstance, &out)
   113  	})
   114  
   115  	t.Run("RedirectURLOauthFlow", func(t *testing.T) {
   116  		e := testutils.CreateTestClient(t, ts.URL)
   117  
   118  		// Retrieve the cozysessid cookie
   119  		cozysessID := e.POST("/login").
   120  			WithHost(testInstance.Domain).
   121  			Expect().Status(200).
   122  			Cookie("cozysessid").Value().Raw()
   123  
   124  		redirectURI := "http://" + testInstance.Domain + "/accounts/test-service2/redirect"
   125  		service := makeTestRedirectURLService(redirectURI)
   126  		t.Cleanup(service.Close)
   127  
   128  		serviceType := account.AccountType{
   129  			DocID:        "test-service2",
   130  			GrantMode:    account.ImplicitGrantRedirectURL,
   131  			AuthEndpoint: service.URL + "/oauth2/v2/auth",
   132  		}
   133  		err := couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
   134  		require.NoError(t, err)
   135  		t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
   136  
   137  		// Start the oauth flow
   138  		rawURL := e.GET("/accounts/test-service2/start").
   139  			WithQuery("scope", "the world").
   140  			WithQuery("state", "somesecretstate").
   141  			WithCookie("cozysessid", cozysessID).
   142  			Expect().Status(200).
   143  			Body().Raw()
   144  
   145  		okURL, err := url.Parse(rawURL)
   146  		require.NoError(t, err)
   147  
   148  		// the user click the oauth link
   149  		rawFinalURL := e.GET(okURL.Path).
   150  			WithQueryString(okURL.RawQuery).
   151  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   152  			WithHost(testInstance.Domain).
   153  			Expect().Status(303).
   154  			Header("Location").NotEmpty().Contains("home").
   155  			Raw()
   156  
   157  		finalURL, err := url.Parse(rawFinalURL)
   158  		require.NoError(t, err)
   159  
   160  		var out couchdb.JSONDoc
   161  		err = couchdb.GetDoc(testInstance, consts.Accounts, finalURL.Query().Get("account"), &out)
   162  		assert.NoError(t, err)
   163  		assert.Equal(t, "the-access-token2", out.M["oauth"].(map[string]interface{})["access_token"])
   164  		out.Type = consts.Accounts
   165  		out.M["manual_cleaning"] = true
   166  		_ = couchdb.DeleteDoc(testInstance, &out)
   167  	})
   168  
   169  	t.Run("DoNotRecreateAccountIfItAlreadyExists", func(t *testing.T) {
   170  		e := testutils.CreateTestClient(t, ts.URL)
   171  
   172  		// Retrieve the cozysessid cookie
   173  		cozysessID := e.POST("/login").
   174  			WithHost(testInstance.Domain).
   175  			Expect().Status(200).
   176  			Cookie("cozysessid").Value().Raw()
   177  
   178  		existingAccount := &couchdb.JSONDoc{
   179  			Type: consts.Accounts,
   180  			M: map[string]interface{}{
   181  				"account_type": "test-service3",
   182  				"oauth": map[string]interface{}{
   183  					"query": map[string]interface{}{
   184  						"connection_id": []interface{}{
   185  							"1750",
   186  						},
   187  					},
   188  				},
   189  			},
   190  		}
   191  		err := couchdb.CreateDoc(testInstance, existingAccount)
   192  		require.NoError(t, err)
   193  		t.Cleanup(func() {
   194  			existingAccount.M["manual_cleaning"] = true
   195  			_ = couchdb.DeleteDoc(testInstance, existingAccount)
   196  		})
   197  
   198  		redirectURI := "http://" + testInstance.Domain + "/accounts/test-service3/redirect"
   199  		service := makeTestRedirectURLService(redirectURI)
   200  		t.Cleanup(service.Close)
   201  
   202  		serviceType := account.AccountType{
   203  			DocID:        "test-service3",
   204  			GrantMode:    account.ImplicitGrantRedirectURL,
   205  			AuthEndpoint: service.URL + "/oauth2/v2/auth",
   206  		}
   207  		err = couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
   208  		require.NoError(t, err)
   209  		t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
   210  
   211  		// Start the oauth flow
   212  		rawURL := e.GET("/accounts/test-service3/start").
   213  			WithQuery("scope", "the world").
   214  			WithQuery("state", "somesecretstate").
   215  			WithCookie("cozysessid", cozysessID).
   216  			Expect().Status(200).
   217  			Body().Raw()
   218  
   219  		okURL, err := url.Parse(rawURL)
   220  		require.NoError(t, err)
   221  
   222  		// the user click the oauth link
   223  		rawFinalURL := e.GET(okURL.Path).
   224  			WithQueryString(okURL.RawQuery).
   225  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   226  			WithHost(testInstance.Domain).
   227  			Expect().Status(303).
   228  			Header("Location").NotEmpty().Contains("home").
   229  			Raw()
   230  
   231  		finalURL, err := url.Parse(rawFinalURL)
   232  		require.NoError(t, err)
   233  
   234  		assert.Equal(t, finalURL.Query().Get("account"), existingAccount.ID())
   235  	})
   236  
   237  	t.Run("FixedRedirectURIOauthFlow", func(t *testing.T) {
   238  		e := testutils.CreateTestClient(t, ts.URL)
   239  
   240  		// Retrieve the cozysessid cookie
   241  		cozysessID := e.POST("/login").
   242  			WithHost(testInstance.Domain).
   243  			Expect().Status(200).
   244  			Cookie("cozysessid").Value().Raw()
   245  
   246  		redirectURI := "http://oauth_callback.cozy.localhost/accounts/test-service3/redirect"
   247  		service := makeTestACService(redirectURI)
   248  		t.Cleanup(service.Close)
   249  
   250  		serviceType := account.AccountType{
   251  			DocID:                 "test-service3",
   252  			GrantMode:             account.AuthorizationCode,
   253  			ClientID:              "the-client-id",
   254  			ClientSecret:          "the-client-secret",
   255  			AuthEndpoint:          service.URL + "/oauth2/v2/auth",
   256  			TokenEndpoint:         service.URL + "/oauth2/v4/token",
   257  			RegisteredRedirectURI: redirectURI,
   258  		}
   259  		err := couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
   260  		require.NoError(t, err)
   261  		t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
   262  
   263  		// Start the oauth flow
   264  		rawURL := e.GET("/accounts/test-service3/start").
   265  			WithQuery("scope", "the world").
   266  			WithQuery("state", "somesecretstate").
   267  			WithCookie("cozysessid", cozysessID).
   268  			Expect().Status(200).
   269  			Body().Raw()
   270  
   271  		okURL, err := url.Parse(rawURL)
   272  		require.NoError(t, err)
   273  
   274  		// hack, we want to speak with ts.URL but setting Host to _oauth_callback
   275  		rawFinalURL := e.GET(okURL.Path).
   276  			WithQueryString(okURL.RawQuery).
   277  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   278  			WithHost(okURL.Host).
   279  			Expect().Status(303).
   280  			Header("Location").NotEmpty().Contains("home").
   281  			Raw()
   282  
   283  		finalURL, err := url.Parse(rawFinalURL)
   284  		require.NoError(t, err)
   285  
   286  		var out couchdb.JSONDoc
   287  		err = couchdb.GetDoc(testInstance, consts.Accounts, finalURL.Query().Get("account"), &out)
   288  		assert.NoError(t, err)
   289  		assert.Equal(t, "the-access-token", out.M["oauth"].(map[string]interface{})["access_token"])
   290  		out.Type = consts.Accounts
   291  		out.M["manual_cleaning"] = true
   292  		_ = couchdb.DeleteDoc(testInstance, &out)
   293  	})
   294  
   295  	t.Run("CheckLogin", func(t *testing.T) {
   296  		e := testutils.CreateTestClient(t, ts.URL)
   297  
   298  		serviceType := account.AccountType{
   299  			DocID:                 "test-service4",
   300  			GrantMode:             account.AuthorizationCode,
   301  			ClientID:              "the-client-id",
   302  			ClientSecret:          "the-client-secret",
   303  			AuthEndpoint:          "https://test-service4/auth",
   304  			TokenEndpoint:         "https://test-service4/token",
   305  			RegisteredRedirectURI: "https://oauth_callback.cozy.localhost/accounts/test-service4/redirect",
   306  		}
   307  		err := couchdb.CreateNamedDoc(prefixer.SecretsPrefixer, &serviceType)
   308  		require.NoError(t, err)
   309  		t.Cleanup(func() { _ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, &serviceType) })
   310  
   311  		// Start the oauth flow without cookie
   312  		e.GET("/accounts/test-service4/start").
   313  			WithQuery("scope", "foo").
   314  			WithQuery("state", "bar").
   315  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   316  			Expect().Status(403)
   317  
   318  		sessionCode, err := testInstance.CreateSessionCode()
   319  		require.NoError(t, err)
   320  
   321  		// Start again with a session_code query param.
   322  		res := e.GET("/accounts/test-service4/start").
   323  			WithQuery("session_code", sessionCode).
   324  			WithQuery("scope", "foo").
   325  			WithQuery("state", "bar").
   326  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   327  			Expect().Status(303)
   328  
   329  		res.Header("Location").HasPrefix(serviceType.AuthEndpoint)
   330  		res.Cookies().Length().Equal(1)
   331  		res.Cookie("cozysessid").Value().NotEmpty()
   332  	})
   333  }
   334  
   335  func makeTestRedirectURLService(redirectURI string) *httptest.Server {
   336  	serviceHandler := echo.New()
   337  	serviceHandler.GET("/oauth2/v2/auth", func(c echo.Context) error {
   338  		ok := c.QueryParam("scope") == "the world" &&
   339  			c.QueryParam("response_type") == "token" &&
   340  			c.QueryParam("redirect_url") == redirectURI
   341  
   342  		if !ok {
   343  			return echo.NewHTTPError(400, "Bad Params "+c.QueryParams().Encode())
   344  		}
   345  		opts := &url.Values{}
   346  		opts.Add("access_token", "the-access-token2")
   347  		opts.Add("connection_id", "1750")
   348  		return c.String(200, c.QueryParam("redirect_url")+"?"+opts.Encode())
   349  	})
   350  	return httptest.NewServer(serviceHandler)
   351  }
   352  
   353  func makeTestACService(redirectURI string) *httptest.Server {
   354  	serviceHandler := echo.New()
   355  	serviceHandler.GET("/oauth2/v2/auth", func(c echo.Context) error {
   356  		ok := c.QueryParam("scope") == "the world" &&
   357  			c.QueryParam("client_id") == "the-client-id" &&
   358  			c.QueryParam("response_type") == "code" &&
   359  			c.QueryParam("redirect_uri") == redirectURI
   360  
   361  		if !ok {
   362  			return echo.NewHTTPError(400, "Bad Params "+c.QueryParams().Encode())
   363  		}
   364  		opts := &url.Values{}
   365  		opts.Add("code", "myaccesscode")
   366  		opts.Add("state", c.QueryParam("state"))
   367  		return c.String(200, c.QueryParam("redirect_uri")+"?"+opts.Encode())
   368  	})
   369  	serviceHandler.POST("/oauth2/v4/token", func(c echo.Context) error {
   370  		ok := c.FormValue("code") == "myaccesscode" &&
   371  			c.FormValue("client_id") == "the-client-id" &&
   372  			c.FormValue("client_secret") == "the-client-secret"
   373  
   374  		if !ok {
   375  			vv, _ := c.FormParams()
   376  			return echo.NewHTTPError(400, "Bad Params "+vv.Encode())
   377  		}
   378  		return c.JSON(200, map[string]interface{}{
   379  			"access_token":  "the-access-token",
   380  			"refresh_token": "the-refresh-token",
   381  			"expires_in":    3600,
   382  			"token_type":    "Bearer",
   383  		})
   384  	})
   385  	return httptest.NewServer(serviceHandler)
   386  }