From 1a43a35db2d2e21913808d5abd7f8aa156294bba Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 15 Dec 2022 00:36:52 +0000 Subject: [PATCH 1/5] feat: endpoint to logout app subdomain URLs --- coderd/apikey.go | 4 +- coderd/workspaceapps.go | 185 ++++++++++++++++++++++---- coderd/workspaceapps_internal_test.go | 2 +- coderd/workspaceapps_test.go | 181 +++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 32 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 101aa88f3ce4c..2900f8e4abe62 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -201,7 +201,7 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { } // Generates a new ID and secret for an API key. -func generateAPIKeyIDSecret() (id string, secret string, err error) { +func GenerateAPIKeyIDSecret() (id string, secret string, err error) { // Length of an API Key ID. id, err = cryptorand.String(10) if err != nil { @@ -239,7 +239,7 @@ func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error { } func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) { - keyID, keySecret, err := generateAPIKeyIDSecret() + keyID, keySecret, err := GenerateAPIKeyIDSecret() if err != nil { return nil, xerrors.Errorf("generate API key: %w", err) } diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 060a66870a889..5230a67b9765b 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/subtle" "database/sql" "encoding/base64" "encoding/json" @@ -36,7 +37,15 @@ const ( // conflict with query parameters that users may use. //nolint:gosec subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783" - redirectURIQueryParam = "redirect_uri" + // redirectURIQueryParam is the query param for the app URL to be passed + // back to the API auth endpoint on the main access URL. + redirectURIQueryParam = "redirect_uri" + // appLogoutHostname is the hostname to use for the logout redirect. When + // the dashboard logs out, it will redirect to this subdomain of the app + // hostname, and the server will remove the cookie and redirect to the main + // login page. + // It is important that this URL can never match a valid app hostname. + appLogoutHostname = "coder-logout" ) // nonCanonicalHeaders is a map from "canonical" headers to the actual header we @@ -257,6 +266,12 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt return httpapi.ApplicationURL{}, false } + // Check if the request is part of a logout flow. + if subdomain == appLogoutHostname { + api.handleWorkspaceAppLogout(rw, r) + return httpapi.ApplicationURL{}, false + } + // Parse the application URL from the subdomain. app, err := httpapi.ParseSubdomainAppURL(subdomain) if err != nil { @@ -273,6 +288,99 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt return app, true } +func (api *API) handleWorkspaceAppLogout(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Delete the API key and cookie first before attempting to parse/validate + // the redirect URI. + cookie, err := r.Cookie(httpmw.DevURLSessionTokenCookie) + if err == nil && cookie.Value != "" { + id, secret, err := httpmw.SplitAPIToken(cookie.Value) + // If it's not a valid token then we don't need to delete it from the + // database, but we'll still delete the cookie. + if err == nil { + // To avoid a situation where someone overloads the API with + // different auth formats, and tricks this endpoint into deleting an + // unchecked API key, we validate that the secret matches the secret + // we store in the database. + apiKey, err := api.Database.GetAPIKeyByID(ctx, id) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to lookup API key.", + Detail: err.Error(), + }) + return + } + // This is wrapped in `err == nil` because if the API key doesn't + // exist, we still want to delete the cookie. + if err == nil { + hashedSecret := sha256.Sum256([]byte(secret)) + if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 { + httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ + Message: httpmw.SignedOutErrorMessage, + Detail: "API key secret is invalid.", + }) + return + } + err = api.Database.DeleteAPIKeyByID(ctx, id) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete API key.", + Detail: err.Error(), + }) + return + } + } + } + } + if !api.setWorkspaceAppCookie(rw, r, "") { + return + } + + // Read the redirect URI from the query string. + redirectURI := r.URL.Query().Get(redirectURIQueryParam) + if redirectURI == "" { + redirectURI = api.AccessURL.String() + } else { + // Validate that the redirect URI is a valid URL and exists on the same + // host as the access URL or an app URL. + parsedRedirectURI, err := url.Parse(redirectURI) + if err != nil { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusBadRequest, + Title: "Invalid redirect URI", + Description: fmt.Sprintf("Could not parse redirect URI %q: %s", redirectURI, err.Error()), + RetryEnabled: false, + DashboardURL: api.AccessURL.String(), + }) + return + } + + // Check if the redirect URI is on the same host as the access URL or an + // app URL. + ok := httpapi.HostnamesMatch(api.AccessURL.Hostname(), parsedRedirectURI.Hostname()) + if !ok && api.AppHostnameRegex != nil { + // We could also check that it's a valid application URL for + // completeness, but this check should be good enough. + _, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, parsedRedirectURI.Hostname()) + } + if !ok { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusBadRequest, + Title: "Invalid redirect URI", + Description: fmt.Sprintf("Redirect URI %q is not on the same host as the access URL or an application URL", redirectURI), + RetryEnabled: false, + DashboardURL: api.AccessURL.String(), + }) + return + } + + redirectURI = parsedRedirectURI.String() + } + + http.Redirect(rw, r, redirectURI, http.StatusTemporaryRedirect) +} + // lookupWorkspaceApp looks up the workspace application by slug in the given // agent and returns it. If the application is not found or there was a server // error while looking it up, an HTML error page is returned and false is @@ -410,7 +518,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, // and strip that query parameter. if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" { // Exchange the encoded API key for a real one. - _, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey) + _, token, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey) if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, @@ -424,33 +532,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, return false } - hostSplit := strings.SplitN(api.AppHostname, ".", 2) - if len(hostSplit) != 2 { - // This should be impossible as we verify the app hostname on - // startup, but we'll check anyways. - api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname)) - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusInternalServerError, - Title: "Internal Server Error", - Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.", - RetryEnabled: false, - DashboardURL: api.AccessURL.String(), - }) - return false - } - - // Set the app cookie for all subdomains of api.AppHostname. This cookie - // is handled properly by the ExtractAPIKey middleware. - cookieHost := "." + hostSplit[1] - http.SetCookie(rw, &http.Cookie{ - Name: httpmw.DevURLSessionTokenCookie, - Value: apiKey, - Domain: cookieHost, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: api.SecureAuthCookie, - }) + api.setWorkspaceAppCookie(rw, r, token) // Strip the query parameter. path := r.URL.Path @@ -484,6 +566,51 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, return false } +// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app +// hostname cannot be parsed properly, a static error page is rendered and false +// is returned. +// +// If an empty token is supplied, it will clear the cookie. +func (api *API) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool { + hostSplit := strings.SplitN(api.AppHostname, ".", 2) + if len(hostSplit) != 2 { + // This should be impossible as we verify the app hostname on + // startup, but we'll check anyways. + api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname)) + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.", + RetryEnabled: false, + DashboardURL: api.AccessURL.String(), + }) + return false + } + + // Set the app cookie for all subdomains of api.AppHostname. This cookie is + // handled properly by the ExtractAPIKey middleware. + // + // We don't set an expiration because the key in the database already has an + // expiration. + maxAge := 0 + if token == "" { + maxAge = -1 + } + cookieHost := "." + hostSplit[1] + http.SetCookie(rw, &http.Cookie{ + Name: httpmw.DevURLSessionTokenCookie, + Value: token, + Domain: cookieHost, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: api.SecureAuthCookie, + }) + + return true +} + // workspaceApplicationAuth is an endpoint on the main router that handles // redirects from the subdomain handler. // diff --git a/coderd/workspaceapps_internal_test.go b/coderd/workspaceapps_internal_test.go index 9b98d427e1b26..b5eaf974573c5 100644 --- a/coderd/workspaceapps_internal_test.go +++ b/coderd/workspaceapps_internal_test.go @@ -17,7 +17,7 @@ func TestAPIKeyEncryption(t *testing.T) { t.Parallel() generateAPIKey := func(t *testing.T, db database.Store) (keyID, keySecret string, hashedSecret []byte, data encryptedAPIKeyPayload) { - keyID, keySecret, err := generateAPIKeyIDSecret() + keyID, keySecret, err := GenerateAPIKeyIDSecret() require.NoError(t, err) hashedSecretArray := sha256.Sum256([]byte(keySecret)) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 6b061181866eb..ea33d1ae08d6c 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -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" @@ -862,6 +863,186 @@ 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). + 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", + expectedStatus: http.StatusBadRequest, + expectedBodyContains: "not on the same host as the 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 + } + + 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") + + 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() From 7d6959a8f6a8ee5669ee34c4fbb9f621a9bebee2 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 15 Dec 2022 14:33:52 +0000 Subject: [PATCH 2/5] feat: logout app subdomains on logout button --- coderd/workspaceapps.go | 12 ++++-------- coderd/workspaceapps_test.go | 19 ++++++++++++++----- site/src/xServices/auth/authXService.ts | 16 +++++++++++++++- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 5230a67b9765b..4f9eb0db5aa81 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -365,14 +365,10 @@ func (api *API) handleWorkspaceAppLogout(rw http.ResponseWriter, r *http.Request _, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, parsedRedirectURI.Hostname()) } if !ok { - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusBadRequest, - Title: "Invalid redirect URI", - Description: fmt.Sprintf("Redirect URI %q is not on the same host as the access URL or an application URL", redirectURI), - RetryEnabled: false, - DashboardURL: api.AccessURL.String(), - }) - return + // The redirect URI they provided is not allowed, but we don't want + // to return an error page because it'll interrupt the logout flow, + // so we just use the default access URL. + parsedRedirectURI = api.AccessURL } redirectURI = parsedRedirectURI.String() diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index ea33d1ae08d6c..33a8071d1687f 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -891,6 +891,7 @@ func TestAppSubdomainLogout(t *testing.T) { // 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 }{ { @@ -942,11 +943,15 @@ func TestAppSubdomainLogout(t *testing.T) { expectedBodyContains: "Could not parse redirect URI", }, { - name: "DisallowedRedirectURI", - cookie: "-", - redirectURI: "https://github.com/coder/coder", - expectedStatus: http.StatusBadRequest, - expectedBodyContains: "not on the same host as the access URL", + 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", }, } @@ -983,6 +988,9 @@ func TestAppSubdomainLogout(t *testing.T) { 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", @@ -1013,6 +1021,7 @@ func TestAppSubdomainLogout(t *testing.T) { } 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 != "" { diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 79439dbc298a4..05c6a05893bfc 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -469,7 +469,21 @@ export const authMachine = signIn: async (_, event) => { return await API.login(event.email, event.password) }, - signOut: API.logout, + signOut: async () => { + // Get app hostname so we can see if we need to log out of app URLs. + // We need to load this before we log out of the API as this is an + // authenticated endpoint. + const appHost = await API.getApplicationsHost(); + await API.logout(); + + if (appHost.host) { + const redirect_uri = encodeURIComponent(window.location.href); + // The path doesn't matter but we use /api because the dev server + // proxies /api to the backend. + const uri = `${window.location.protocol}//${appHost.host.replace("*", "coder-logout")}/api/logout?redirect_uri=${redirect_uri}`; + window.location.replace(uri); + } + }, getMe: API.getUser, getMethods: API.getAuthMethods, updateProfile: async (context, event) => { From d3630416996ff7598ef9014a0057226dc6e86c2b Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 15 Dec 2022 14:37:26 +0000 Subject: [PATCH 3/5] fixup! feat: logout app subdomains on logout button --- site/src/xServices/auth/authXService.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 05c6a05893bfc..8319d9e39a701 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -473,15 +473,18 @@ export const authMachine = // Get app hostname so we can see if we need to log out of app URLs. // We need to load this before we log out of the API as this is an // authenticated endpoint. - const appHost = await API.getApplicationsHost(); - await API.logout(); + const appHost = await API.getApplicationsHost() + await API.logout() if (appHost.host) { - const redirect_uri = encodeURIComponent(window.location.href); + const redirect_uri = encodeURIComponent(window.location.href) // The path doesn't matter but we use /api because the dev server // proxies /api to the backend. - const uri = `${window.location.protocol}//${appHost.host.replace("*", "coder-logout")}/api/logout?redirect_uri=${redirect_uri}`; - window.location.replace(uri); + const uri = `${window.location.protocol}//${appHost.host.replace( + "*", + "coder-logout", + )}/api/logout?redirect_uri=${redirect_uri}` + window.location.replace(uri) } }, getMe: API.getUser, From f82fae4836e12b01519f0cdf831b83fd3fea3206 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 19 Dec 2022 17:35:00 +0000 Subject: [PATCH 4/5] Keep current state during redirect --- site/src/xServices/auth/authXService.ts | 36 ++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 8319d9e39a701..f0aa107309d70 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -140,6 +140,13 @@ export const authMachine = hasFirstUser: { data: boolean } + signOut: { + data: + | { + redirectUrl: string + } + | undefined + } }, }, context: { @@ -425,6 +432,10 @@ export const authMachine = src: "signOut", id: "signOut", onDone: [ + { + actions: ["unassignMe", "clearAuthError", "redirect"], + cond: "hasRedirectUrl", + }, { actions: ["unassignMe", "clearAuthError"], target: "gettingMethods", @@ -476,15 +487,21 @@ export const authMachine = const appHost = await API.getApplicationsHost() await API.logout() - if (appHost.host) { - const redirect_uri = encodeURIComponent(window.location.href) + if (appHost.host !== "") { + const { protocol, host } = window.location + const redirect_uri = encodeURIComponent( + `${protocol}://${host}/login`, + ) // The path doesn't matter but we use /api because the dev server // proxies /api to the backend. - const uri = `${window.location.protocol}//${appHost.host.replace( + const uri = `${protocol}//${appHost.host.replace( "*", "coder-logout", )}/api/logout?redirect_uri=${redirect_uri}` - window.location.replace(uri) + + return { + redirectUrl: uri, + } } }, getMe: API.getUser, @@ -594,9 +611,20 @@ export const authMachine = notifySuccessSSHKeyRegenerated: () => { displaySuccess(Language.successRegenerateSSHKey) }, + redirect: (_, { data }) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data can be undefined + if (!data) { + throw new Error( + "Redirect only should be called with data.redirectUrl", + ) + } + + window.location.replace(data.redirectUrl) + }, }, guards: { isTrue: (_, event) => event.data, + hasRedirectUrl: (_, { data }) => Boolean(data), }, }, ) From 5eccbd64424890778bb64e8b75a0b33f1e430c0a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 20 Dec 2022 10:35:25 -0800 Subject: [PATCH 5/5] Update site/src/xServices/auth/authXService.ts --- site/src/xServices/auth/authXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index f0aa107309d70..9ff04e305b0c7 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -490,7 +490,7 @@ export const authMachine = if (appHost.host !== "") { const { protocol, host } = window.location const redirect_uri = encodeURIComponent( - `${protocol}://${host}/login`, + `${protocol}//${host}/login`, ) // The path doesn't matter but we use /api because the dev server // proxies /api to the backend.