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  }