go-loose/internal/behaviour/clientlogin_test.go
Daniel Noulet 49ab89ae46 refactor: keep tenant applications out of the administrator's reach
Claiming the system administrator role no longer grants access to every
application in the installation. Owning a tenant is what lets an administrator
administer it; entering the applications running inside it is a separate,
explicit grant that can be withdrawn again. Handing it out at claim time gave
every installation a standing administrator account inside every customer
application, which is the opposite of the boundary this product draws.

The behaviour suite now covers single sign-on between applications directly: a
second application on the same tenant must receive a code and identify the same
person with no sign-in page in between, once Go Loose already holds a session.

Also adds theme-token tests against the shared styling contract and store
coverage for application grants, and drops the redundant server and security
scheme declarations from the published specification.
2026-10-05 21:48:05 +02:00

464 lines
19 KiB
Go

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
}