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 }