github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/oidc/oidc_test.go (about) 1 package oidc 2 3 import ( 4 "fmt" 5 "net/http" 6 "net/url" 7 "testing" 8 9 "github.com/cozy/cozy-stack/model/instance/lifecycle" 10 "github.com/cozy/cozy-stack/model/job" 11 "github.com/cozy/cozy-stack/model/oauth" 12 "github.com/cozy/cozy-stack/pkg/assets/dynamic" 13 "github.com/cozy/cozy-stack/pkg/config/config" 14 "github.com/cozy/cozy-stack/tests/testutils" 15 "github.com/cozy/cozy-stack/web/errors" 16 "github.com/cozy/cozy-stack/web/middlewares" 17 "github.com/cozy/cozy-stack/web/statik" 18 "github.com/gavv/httpexpect/v2" 19 "github.com/labstack/echo/v4" 20 "github.com/stretchr/testify/assert" 21 "github.com/stretchr/testify/require" 22 ) 23 24 func TestOidc(t *testing.T) { 25 if testing.Short() { 26 t.Skip("an instance is required for this test: test skipped due to the use of --short flag") 27 } 28 29 var redirectURL *url.URL 30 31 config.UseTestFile(t) 32 config.GetConfig().Assets = "../../assets" 33 testutils.NeedCouchdb(t) 34 setup := testutils.NewSetup(t, t.Name()) 35 render, _ := statik.NewDirRenderer("../../assets") 36 middlewares.BuildTemplates() 37 38 // Declaring a dummy worker for the 2FA (sendmail) 39 wl := &job.WorkerConfig{ 40 WorkerType: "sendmail", 41 Concurrency: 4, 42 WorkerFunc: func(ctx *job.TaskContext) error { 43 return nil 44 }, 45 } 46 job.AddWorker(wl) 47 48 testInstance := setup.GetTestInstance(&lifecycle.Options{ContextName: "foocontext"}) 49 50 // Mocking API endpoint to validate token 51 ts := setup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){ 52 "/oidc": Routes, 53 "/admin-oidc": AdminRoutes, 54 "/token": func(g *echo.Group) { 55 g.POST("/getToken", func(c echo.Context) error { 56 return c.JSON(http.StatusOK, echo.Map{"access_token": "foobar"}) 57 }) 58 g.GET("/:domain", func(c echo.Context) error { 59 return c.JSON(http.StatusOK, echo.Map{"domain": c.Param("domain")}) 60 }) 61 }, 62 "/api": func(g *echo.Group) { 63 g.GET("/v1/userinfo", func(c echo.Context) error { 64 auth := c.Request().Header.Get(echo.HeaderAuthorization) 65 if auth != "Bearer fc_token" { 66 return c.NoContent(http.StatusBadRequest) 67 } 68 return c.JSON(http.StatusOK, echo.Map{ 69 "sub": "fc_sub", 70 "email": "jerome@example.org", 71 }) 72 }) 73 }, 74 }) 75 76 ts.Config.Handler.(*echo.Echo).Renderer = render 77 ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler 78 t.Cleanup(ts.Close) 79 80 // Creating a custom context with oidc and franceconnect authentication 81 tokenURL := ts.URL + "/token/getToken" 82 userInfoURL := ts.URL + "/token/" + testInstance.Domain 83 authentication := map[string]interface{}{ 84 "oidc": map[string]interface{}{ 85 "redirect_uri": "http://foobar.com/redirect", 86 "client_id": "foo", 87 "client_secret": "bar", 88 "scope": "foo", 89 "authorize_url": "http://foobar.com/authorize", 90 "token_url": tokenURL, 91 "userinfo_url": userInfoURL, 92 "userinfo_instance_field": "domain", 93 }, 94 "franceconnect": map[string]interface{}{ 95 "redirect_uri": "http://foobar.com/redirect", 96 "client_id": "fc_client_id", 97 "client_secret": "fc_client_secret", 98 "scope": "openid profile", 99 "authorize_url": "https://franceconnect.gouv.fr/api/v1/authorize", 100 "token_url": "https://franceconnect.gouv.fr/api/v1/token", 101 "userinfo_url": ts.URL + "/api/v1/userinfo", 102 }, 103 } 104 conf := config.GetConfig() 105 conf.Authentication = map[string]interface{}{ 106 "foocontext": authentication, 107 } 108 109 require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String()), "Could not init dynamic FS") 110 111 t.Run("StartWithOnboardingNotFinished", func(t *testing.T) { 112 e := testutils.CreateTestClient(t, ts.URL) 113 114 // Should get a 200 with body "activate your cozy" 115 e.GET("/oidc/start"). 116 WithHost(testInstance.Domain). 117 Expect().Status(200). 118 ContentType("text/html"). 119 Body().Contains("Onboarding Not activated") 120 }) 121 122 t.Run("StartWithOnboardingFinished", func(t *testing.T) { 123 var err error 124 125 e := testutils.CreateTestClient(t, ts.URL) 126 127 onboardingFinished := true 128 _ = lifecycle.Patch(testInstance, &lifecycle.Options{OnboardingFinished: &onboardingFinished}) 129 130 // Should return a 303 redirect 131 u := e.GET("/oidc/start"). 132 WithHost(testInstance.Domain). 133 WithRedirectPolicy(httpexpect.DontFollowRedirects). 134 Expect().Status(303). 135 Header("Location").Raw() 136 137 redirectURL, err = url.Parse(u) 138 require.NoError(t, err) 139 140 assert.Equal(t, "foobar.com", redirectURL.Host) 141 assert.Equal(t, "/authorize", redirectURL.Path) 142 assert.NotNil(t, redirectURL.Query().Get("client_id")) 143 assert.NotNil(t, redirectURL.Query().Get("nonce")) 144 assert.NotNil(t, redirectURL.Query().Get("redirect_uri")) 145 assert.NotNil(t, redirectURL.Query().Get("response_type")) 146 assert.NotNil(t, redirectURL.Query().Get("state")) 147 assert.NotNil(t, redirectURL.Query().Get("scope")) 148 }) 149 150 // Get the login page, assert we have an error if state is missing 151 t.Run("Success", func(t *testing.T) { 152 e := testutils.CreateTestClient(t, ts.URL) 153 154 e.GET("/oidc/login"). 155 WithHost(testInstance.Domain). 156 WithRedirectPolicy(httpexpect.DontFollowRedirects). 157 WithQueryString(redirectURL.RawQuery). // Reuse the query for the request above. 158 Expect().Status(303). 159 Header("Location").Equal(testInstance.DefaultRedirection().String()) 160 }) 161 162 t.Run("WithoutState", func(t *testing.T) { 163 e := testutils.CreateTestClient(t, ts.URL) 164 165 queryWithoutState := redirectURL.Query() 166 queryWithoutState.Del("state") 167 168 e.GET("/oidc/login"). 169 WithHost(testInstance.Domain). 170 WithRedirectPolicy(httpexpect.DontFollowRedirects). 171 WithQueryString(queryWithoutState.Encode()). 172 Expect().Status(404) 173 }) 174 175 t.Run("LoginWith2FA", func(t *testing.T) { 176 e := testutils.CreateTestClient(t, ts.URL) 177 178 onboardingFinished := true 179 _ = lifecycle.Patch(testInstance, &lifecycle.Options{OnboardingFinished: &onboardingFinished, AuthMode: "two_factor_mail"}) 180 181 u := e.GET("/oidc/start"). 182 WithHost(testInstance.Domain). 183 WithRedirectPolicy(httpexpect.DontFollowRedirects). 184 Expect().Status(303). 185 Header("Location").Raw() 186 187 redirectURL, err := url.Parse(u) 188 require.NoError(t, err) 189 190 // Get the login page, assert we have the 2FA activated 191 queryWithToken := redirectURL.Query() 192 queryWithToken.Add("token", "foo") 193 194 body := e.GET("/oidc/login"). 195 WithHost(testInstance.Domain). 196 WithRedirectPolicy(httpexpect.DontFollowRedirects). 197 WithQueryString(queryWithToken.Encode()). 198 Expect().Status(200). 199 ContentType("text/html"). 200 Body() 201 202 body.Contains(`<form id="oidc-twofactor-form"`) 203 matches := body.Match(`name="access-token" value="(\w+)"`) 204 matches.Length().Equal(2) 205 accessToken := matches.Index(1).NotEmpty().Raw() 206 207 // Check that the user is redirected to the 2FA page 208 u = e.POST("/oidc/twofactor"). 209 WithHost(testInstance.Domain). 210 WithRedirectPolicy(httpexpect.DontFollowRedirects). 211 WithFormField("access-token", accessToken). 212 WithFormField("trusted-device-token", ""). 213 WithFormField("redirect", ""). 214 WithFormField("confirm", ""). 215 Expect().Status(303). 216 Header("Location").Raw() 217 218 redirectURL, err = url.Parse(u) 219 require.NoError(t, err) 220 221 assert.Equal(t, "/auth/twofactor", redirectURL.Path) 222 assert.NotNil(t, redirectURL.Query().Get("two_factor_token")) 223 }) 224 225 t.Run("DelegatedCode", func(t *testing.T) { 226 e := testutils.CreateTestClient(t, ts.URL) 227 228 sub := "fc_sub" 229 email := "jerome@example.org" 230 231 onboardingFinished := true 232 _ = lifecycle.Patch(testInstance, &lifecycle.Options{ 233 OnboardingFinished: &onboardingFinished, 234 FranceConnectID: sub, 235 AuthMode: "basic", 236 }) 237 238 obj := e.POST("/admin-oidc/"+testInstance.ContextName+"/franceconnect/code"). 239 WithHeader("Content-Type", "application/json"). 240 WithBytes([]byte(`{ "access_token": "fc_token" }`)). 241 Expect().Status(200). 242 JSON(). 243 Object() 244 obj.Value("sub").String().Equal(sub) 245 obj.Value("email").String().Equal(email) 246 code := obj.Value("delegated_code").String().NotEmpty().Raw() 247 248 oauthClient := &oauth.Client{ 249 RedirectURIs: []string{"cozy://flagship"}, 250 ClientName: "Cozy Flagship", 251 ClientKind: "mobile", 252 SoftwareID: "cozy-flagship", 253 SoftwareVersion: "0.1.0", 254 } 255 require.Nil(t, oauthClient.Create(testInstance)) 256 client, err := oauth.FindClient(testInstance, oauthClient.ClientID) 257 require.NoError(t, err) 258 client.CertifiedFromStore = true 259 require.NoError(t, client.SetFlagship(testInstance)) 260 261 obj2 := e.POST("/oidc/access_token"). 262 WithHost(testInstance.Domain). 263 WithHeader("Content-Type", "application/json"). 264 WithBytes([]byte(fmt.Sprintf(`{ 265 "client_id": "%s", 266 "client_secret": "%s", 267 "scope": "*", 268 "code": "%s" 269 }`, oauthClient.ClientID, oauthClient.ClientSecret, code))). 270 Expect().Status(200). 271 JSON(httpexpect.ContentOpts{MediaType: "application/json"}). 272 Object() 273 obj2.Value("token_type").String().Equal("bearer") 274 obj2.Value("scope").String().Equal("*") 275 obj2.Value("access_token").String().NotEmpty() 276 obj2.Value("refresh_token").String().NotEmpty() 277 }) 278 }