mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: endpoint to logout app subdomain URLs (#5428)
Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
co-authored by
Bruno Quaresma
parent
86257ce7fc
commit
50dfc2082b
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
@@ -892,6 +893,195 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppSubdomainLogout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyID, keySecret, err := coderd.GenerateAPIKeyIDSecret()
|
||||
require.NoError(t, err)
|
||||
fakeAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
// The cookie to send with the request. The regular API key header
|
||||
// is also sent to bypass any auth checks on this value, and to
|
||||
// ensure that the logout code is safe when multiple keys are
|
||||
// passed.
|
||||
// Empty value means no cookie is sent, "-" means send a valid
|
||||
// API key, and "bad-secret" means send a valid key ID with a bad
|
||||
// secret.
|
||||
cookie string
|
||||
// You can use "access_url" to use the site access URL as the
|
||||
// redirect URI, or "app_host" to use a valid app host.
|
||||
redirectURI string
|
||||
|
||||
// If expectedStatus is not an error status, we expect the cookie to
|
||||
// be deleted if it was set.
|
||||
expectedStatus int
|
||||
expectedBodyContains string
|
||||
// If empty, the expected location is the redirectURI if the
|
||||
// expected status code is http.StatusTemporaryRedirect (using the
|
||||
// access URL if not set).
|
||||
// You can use "access_url" to force the access URL.
|
||||
expectedLocation string
|
||||
}{
|
||||
{
|
||||
name: "OKAccessURL",
|
||||
cookie: "-",
|
||||
redirectURI: "access_url",
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "OKAppHost",
|
||||
cookie: "-",
|
||||
redirectURI: "app_host",
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "OKNoAPIKey",
|
||||
cookie: "",
|
||||
redirectURI: "access_url",
|
||||
// Even if the devurl cookie is missing, we still redirect without
|
||||
// any complaints.
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "OKBadAPIKey",
|
||||
cookie: "test-api-key",
|
||||
redirectURI: "access_url",
|
||||
// Even if the devurl cookie is bad, we still delete the cookie and
|
||||
// redirect without any complaints.
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "OKUnknownAPIKey",
|
||||
cookie: fakeAPIKey,
|
||||
redirectURI: "access_url",
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "BadAPIKeySecret",
|
||||
cookie: "bad-secret",
|
||||
redirectURI: "access_url",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedBodyContains: "API key secret is invalid",
|
||||
},
|
||||
{
|
||||
name: "InvalidRedirectURI",
|
||||
cookie: "-",
|
||||
redirectURI: string([]byte{0x00}),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedBodyContains: "Could not parse redirect URI",
|
||||
},
|
||||
{
|
||||
name: "DisallowedRedirectURI",
|
||||
cookie: "-",
|
||||
redirectURI: "https://github.com/coder/coder",
|
||||
// We don't allow redirecting to a different host, but we don't
|
||||
// show an error page and just redirect to the access URL to avoid
|
||||
// breaking the logout flow if the user is accessing from the wrong
|
||||
// host.
|
||||
expectedStatus: http.StatusTemporaryRedirect,
|
||||
expectedLocation: "access_url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, _, _ := setupProxyTest(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// The token should work.
|
||||
_, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
appHost, err := client.GetAppHost(ctx)
|
||||
require.NoError(t, err, "get app host")
|
||||
|
||||
if c.cookie == "-" {
|
||||
c.cookie = client.SessionToken()
|
||||
} else if c.cookie == "bad-secret" {
|
||||
keyID, _, err := httpmw.SplitAPIToken(client.SessionToken())
|
||||
require.NoError(t, err)
|
||||
c.cookie = fmt.Sprintf("%s-%s", keyID, keySecret)
|
||||
}
|
||||
if c.redirectURI == "access_url" {
|
||||
c.redirectURI = client.URL.String()
|
||||
} else if c.redirectURI == "app_host" {
|
||||
c.redirectURI = "http://" + strings.Replace(appHost.Host, "*", "something--something--something--something", 1) + "/"
|
||||
}
|
||||
if c.expectedLocation == "" && c.expectedStatus == http.StatusTemporaryRedirect {
|
||||
c.expectedLocation = c.redirectURI
|
||||
}
|
||||
if c.expectedLocation == "access_url" {
|
||||
c.expectedLocation = client.URL.String()
|
||||
}
|
||||
|
||||
logoutURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: strings.Replace(appHost.Host, "*", "coder-logout", 1),
|
||||
Path: "/",
|
||||
}
|
||||
if c.redirectURI != "" {
|
||||
q := logoutURL.Query()
|
||||
q.Set("redirect_uri", c.redirectURI)
|
||||
logoutURL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoutURL.String(), nil)
|
||||
require.NoError(t, err, "create logout request")
|
||||
// The header is prioritized over the devurl cookie if both are
|
||||
// set, so this ensures we can trigger the logout code path with
|
||||
// bad cookies during tests.
|
||||
req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
|
||||
if c.cookie != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: httpmw.DevURLSessionTokenCookie,
|
||||
Value: c.cookie,
|
||||
})
|
||||
}
|
||||
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
resp, err := client.HTTPClient.Do(req)
|
||||
require.NoError(t, err, "do logout request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, c.expectedStatus, resp.StatusCode, "logout response status code")
|
||||
if c.expectedStatus < 400 && c.cookie != "" {
|
||||
cookies := resp.Cookies()
|
||||
require.Len(t, cookies, 1, "logout response cookies")
|
||||
cookie := cookies[0]
|
||||
require.Equal(t, httpmw.DevURLSessionTokenCookie, cookie.Name)
|
||||
require.Equal(t, "", cookie.Value)
|
||||
require.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired")
|
||||
|
||||
// The token shouldn't work anymore if it was the original valid
|
||||
// session token.
|
||||
if c.cookie == client.SessionToken() {
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
if c.expectedBodyContains != "" {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), c.expectedBodyContains, "logout response body")
|
||||
}
|
||||
if c.expectedLocation != "" {
|
||||
location := resp.Header.Get("Location")
|
||||
require.Equal(t, c.expectedLocation, location, "logout response location")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSharing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user