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.
This commit is contained in:
parent
8d8970d5a7
commit
49ab89ae46
6 changed files with 319 additions and 20 deletions
|
|
@ -232,6 +232,102 @@ func clientLoginPhases(t *testing.T, j *journey) {
|
|||
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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue