package behaviour import ( "encoding/base64" "net/http" "net/url" "strings" "testing" ) // clientPhases walks the whole hosted-login dance a tenant application performs: // it configures a client, sends a member to the provider, comes back with a code, // exchanges it for a bearer token, reads the identity, and signs out again. func clientLoginPhases(t *testing.T, j *journey) { t.Helper() host := j.adminHost() redirectURI := "https://app.behaviour.test/auth/callback" phase(t, "a redirect URI that is not HTTPS is refused", func(t *testing.T) { response := j.admin.json(http.MethodPost, host, "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ "redirect_uris": []string{"http://app.behaviour.test/callback"}, }) statusOnly(t, response, http.StatusBadRequest) }) phase(t, "a redirect URI carrying a fragment is refused", func(t *testing.T) { response := j.admin.json(http.MethodPost, host, "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ "redirect_uris": []string{"https://app.behaviour.test/callback#token"}, }) statusOnly(t, response, http.StatusBadRequest) }) phase(t, "duplicate redirect URIs are refused", func(t *testing.T) { response := j.admin.json(http.MethodPost, host, "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ "redirect_uris": []string{redirectURI, redirectURI}, }) statusOnly(t, response, http.StatusBadRequest) }) phase(t, "client login is configured and the application becomes ready", func(t *testing.T) { type configured struct { Application struct { ID string `json:"id"` ClientID string `json:"client_id"` RedirectURIs []string `json:"redirect_uris"` ClientReady bool `json:"client_ready"` } `json:"application"` ClientSecret string `json:"client_secret"` } response := j.admin.json(http.MethodPost, host, "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ "redirect_uris": []string{redirectURI}, }) result := decode[configured](t, response, http.StatusOK) if result.ClientSecret == "" { t.Fatal("no client secret returned") } if result.Application.ClientID == "" { t.Error("no client id assigned") } if !result.Application.ClientReady { t.Error("the application is not reported as ready for client login") } if len(result.Application.RedirectURIs) != 1 { t.Errorf("redirect URIs = %v", result.Application.RedirectURIs) } j.clientID, j.clientSecret = result.Application.ClientID, result.ClientSecret }) phase(t, "an unknown client is refused at the authorize endpoint", func(t *testing.T) { browser := j.browser() response := browser.get(host, "/connect/authorize?response_type=code&client_id=glc_unknown"+ "&redirect_uri="+url.QueryEscape(redirectURI)+"&state=xyz", nil) statusOnly(t, response, http.StatusBadRequest) }) phase(t, "incomplete authorize parameters are refused", func(t *testing.T) { browser := j.browser() for _, query := range []string{ "client_id=" + j.clientID, "response_type=token&client_id=" + j.clientID + "&state=s", "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI), } { response := browser.get(host, "/connect/authorize?"+query, nil) if response.StatusCode != http.StatusBadRequest { t.Errorf("authorize with %q = %d, want 400", query, response.StatusCode) } response.Body.Close() } }) phase(t, "an apex request is redirected onto the tenant hostname", func(t *testing.T) { browser := j.browser() query := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=tenant-host" response := browser.get(j.h.apex(), "/connect/authorize?"+query, nil) if response.StatusCode != http.StatusTemporaryRedirect { t.Fatalf("status = %d, want 307: %s", response.StatusCode, bodyOf(t, response)) } if !strings.Contains(location(t, response), j.h.tenantHost(j.scopedTenant)) { t.Errorf("redirect = %q, want the tenant host", location(t, response)) } }) phase(t, "a signed-out visitor is sent to the tenant sign-in page", func(t *testing.T) { browser := j.browser() tenantHost := j.h.tenantHost(j.scopedTenant) query := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=signin" response := browser.get(tenantHost, "/connect/authorize?"+query, nil) if response.StatusCode != http.StatusSeeOther { t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) } target := location(t, response) if !strings.Contains(target, "/client/login") { t.Fatalf("redirect = %q, want the client sign-in page", target) } page := bodyOf(t, browser.get(tenantHost, target, nil)) if !strings.Contains(page, "csrf_token") { t.Errorf("the sign-in page has no form: %.200s", page) } }) phase(t, "a member without an application grant is refused", func(t *testing.T) { // This person is a member of the tenant but was never granted the // application, which is the distinction the endpoint has to enforce. browser := j.browser() tenantHost := j.h.tenantHost(j.scopedTenant) query := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=grant" response := browser.get(tenantHost, "/connect/authorize?"+query, nil) target := location(t, response) token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil))) response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ "csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword, "return": returnParam(t, target), }), nil) if response.StatusCode != http.StatusSeeOther { t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) } response = browser.get(tenantHost, "/connect/authorize?"+query, nil) statusOnly(t, response, http.StatusForbidden) }) phase(t, "a granted member completes the flow and receives a session token", func(t *testing.T) { browser := j.browser() tenantHost := j.h.tenantHost(j.scopedTenant) query := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=happy" response := browser.get(tenantHost, "/connect/authorize?"+query, nil) target := location(t, response) token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil))) response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, "return": returnParam(t, target), }), nil) if response.StatusCode != http.StatusSeeOther { t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) } // Back to the authorize endpoint, now with a session: a code comes back. response = browser.get(tenantHost, "/connect/authorize?"+query, nil) if response.StatusCode != http.StatusFound { t.Fatalf("authorize = %d, want 302: %s", response.StatusCode, bodyOf(t, response)) } callback, err := url.Parse(location(t, response)) if err != nil { t.Fatalf("parse callback: %v", err) } if callback.Host != "app.behaviour.test" { t.Errorf("callback host = %q", callback.Host) } if callback.Query().Get("state") != "happy" { t.Errorf("state = %q, want happy", callback.Query().Get("state")) } code := callback.Query().Get("code") if code == "" { t.Fatal("no authorization code issued") } // The application exchanges the code for a token using HTTP Basic auth. type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` User struct { ID string `json:"id"` Email string `json:"email"` Role string `json:"role"` Permissions []string `json:"permissions"` TenantSlug string `json:"tenant_slug"` Application string `json:"application"` } `json:"user"` } exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader(url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, }.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), }) granted := decode[tokenResponse](t, exchange, http.StatusOK) if granted.AccessToken == "" { t.Fatal("no access token returned") } if granted.TokenType != "Bearer" { t.Errorf("token_type = %q", granted.TokenType) } if granted.ExpiresIn <= 0 { t.Errorf("expires_in = %d", granted.ExpiresIn) } if granted.User.Email != j.memberEmail { t.Errorf("user email = %q, want %s", granted.User.Email, j.memberEmail) } if granted.User.TenantSlug != j.scopedTenant { t.Errorf("tenant = %q, want %s", granted.User.TenantSlug, j.scopedTenant) } if granted.User.Role == "" || len(granted.User.Permissions) == 0 { t.Errorf("the token payload carries no role or permissions: %+v", granted.User) } j.accessToken = granted.AccessToken // The code is single use. replay := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader(url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, }.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), }) statusOnly(t, replay, http.StatusUnauthorized) }) phase(t, "a second application is signed in without asking again", func(t *testing.T) { // The single sign-on property: once Go Loose holds a session for this person // in this tenant, another application on the same tenant must receive a // code straight away, with no sign-in page in between. type application struct { ID string `json:"id"` ClientID string `json:"client_id"` RedirectURI []string `json:"redirect_uris"` } type setupResponse struct { Application application `json:"application"` Secret string `json:"client_secret"` } created := decode[application](t, j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ "tenant_id": j.scopedTenantID, "slug": "second-app", "name": "Second App", "allowed_hosts": []string{"second.example.test"}, }), http.StatusCreated) setup := decode[setupResponse](t, j.admin.json(http.MethodPost, host, "/api/v1/applications/"+created.ID+"/client-config", map[string]any{ "redirect_uris": []string{"https://second.example.test/callback"}, }), http.StatusOK) if setup.Application.ClientID == "" || setup.Secret == "" { t.Fatal("the second application was not configured for client login") } // The member needs access to it, the same as any other application. access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.memberID+"/access", map[string]any{ "tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID, "application_ids": []string{j.applicationID, created.ID}, }) if access.StatusCode != http.StatusNoContent { t.Fatalf("grant the second application = %d: %s", access.StatusCode, bodyOf(t, access)) } // Sign in once, through the first application. browser := j.browser() tenantHost := j.h.tenantHost(j.scopedTenant) firstQuery := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=first" signIn := location(t, browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil)) token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil))) response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, "return": returnParam(t, signIn), }), nil) if response.StatusCode != http.StatusSeeOther { t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) } if response := browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil); response.StatusCode != http.StatusFound { t.Fatalf("first application = %d", response.StatusCode) } else { response.Body.Close() } // The second application must not see a sign-in page. secondQuery := "response_type=code&client_id=" + setup.Application.ClientID + "&redirect_uri=" + url.QueryEscape("https://second.example.test/callback") + "&state=second" response = browser.get(tenantHost, "/connect/authorize?"+secondQuery, nil) if response.StatusCode != http.StatusFound { t.Fatalf("second application = %d, want a code with no prompt: %s", response.StatusCode, bodyOf(t, response)) } callback, err := url.Parse(location(t, response)) if err != nil { t.Fatalf("parse callback: %v", err) } if callback.Host != "second.example.test" { t.Errorf("callback host = %q", callback.Host) } if callback.Query().Get("code") == "" { t.Error("no authorization code for the second application") } if callback.Query().Get("state") != "second" { t.Errorf("state = %q", callback.Query().Get("state")) } // And it identifies the same person, with no further prompt. exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader(url.Values{ "grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")}, "redirect_uri": {"https://second.example.test/callback"}, }.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + basicAuth(setup.Application.ClientID, setup.Secret), }) type secondToken struct { User struct { Email string `json:"email"` } `json:"user"` } granted := decode[secondToken](t, exchange, http.StatusOK) if granted.User.Email != j.memberEmail { t.Errorf("the second application saw %q, want %s", granted.User.Email, j.memberEmail) } }) phase(t, "the token identifies the user at the userinfo endpoint", func(t *testing.T) { type identity struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` Permissions []string `json:"permissions"` Application string `json:"application"` } response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, map[string]string{"Authorization": "Bearer " + j.accessToken}) user := decode[identity](t, response, http.StatusOK) if user.Email != j.memberEmail { t.Errorf("email = %q", user.Email) } if user.DisplayName == "" { t.Error("no display name") } if user.Application != "behaviour-app" { t.Errorf("application = %q", user.Application) } }) phase(t, "a wrong client secret cannot exchange a code", func(t *testing.T) { response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader(url.Values{ "grant_type": {"authorization_code"}, "code": {"glc_code_" + strings.Repeat("Q", 40)}, "redirect_uri": {redirectURI}, }.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + basicAuth(j.clientID, "glc_wrong_secret_value"), }) statusOnly(t, response, http.StatusUnauthorized) }) phase(t, "a token without basic authentication is refused", func(t *testing.T) { response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader("grant_type=authorization_code"), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }) if response.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", response.StatusCode) } response.Body.Close() if response.Header.Get("WWW-Authenticate") == "" { t.Error("no WWW-Authenticate challenge was sent") } }) phase(t, "userinfo refuses a request without a bearer token", func(t *testing.T) { statusOnly(t, j.browser().get(j.h.apex(), "/connect/userinfo", nil), http.StatusUnauthorized) }) phase(t, "userinfo refuses a made-up token", func(t *testing.T) { response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, map[string]string{"Authorization": "Bearer glc_session_" + strings.Repeat("Z", 43)}) statusOnly(t, response, http.StatusUnauthorized) }) phase(t, "signing out of the client invalidates the token at once", func(t *testing.T) { response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/logout", nil, map[string]string{"Authorization": "Bearer " + j.accessToken}) if response.StatusCode != http.StatusNoContent { t.Fatalf("logout = %d: %s", response.StatusCode, bodyOf(t, response)) } response.Body.Close() response = j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, map[string]string{"Authorization": "Bearer " + j.accessToken}) statusOnly(t, response, http.StatusUnauthorized) }) } func basicAuth(user, password string) string { return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) } // returnParam extracts the authorize URL that the sign-in page will return to, // which is carried in the sign-in page's own query string. func returnParam(t *testing.T, signInURL string) string { t.Helper() parsed, err := url.Parse(signInURL) if err != nil { t.Fatalf("parse sign-in URL %q: %v", signInURL, err) } value := parsed.Query().Get("return") if value == "" { t.Fatalf("the sign-in URL carries no return target: %q", signInURL) } return value } // establishClientSession runs the whole hosted-login dance and returns the bearer // token, so a phase can assert on the token's lifetime rather than on the flow. func establishClientSession(t *testing.T, j *journey, state string) string { t.Helper() browser := j.browser() tenantHost := j.h.tenantHost(j.scopedTenant) redirectURI := "https://app.behaviour.test/auth/callback" query := "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=" + state response := browser.get(tenantHost, "/connect/authorize?"+query, nil) signIn := location(t, response) token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil))) response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, "return": returnParam(t, signIn), }), nil) if response.StatusCode != http.StatusSeeOther { t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) } response = browser.get(tenantHost, "/connect/authorize?"+query, nil) if response.StatusCode != http.StatusFound { t.Fatalf("authorize = %d: %s", response.StatusCode, bodyOf(t, response)) } callback, err := url.Parse(location(t, response)) if err != nil { t.Fatalf("parse callback: %v", err) } type tokenResponse struct { AccessToken string `json:"access_token"` } exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", strings.NewReader(url.Values{ "grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")}, "redirect_uri": {redirectURI}, }.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), }) return decode[tokenResponse](t, exchange, http.StatusOK).AccessToken }