Skip to content

feat: endpoint to logout app subdomain URLs #5428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 20, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
181 changes: 152 additions & 29 deletions coderd/workspaceapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -273,6 +288,95 @@ 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 {
// 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()
}

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
Expand Down Expand Up @@ -410,7 +514,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,
Expand All @@ -424,33 +528,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
Expand Down Expand Up @@ -484,6 +562,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.
//
Expand Down
2 changes: 1 addition & 1 deletion coderd/workspaceapps_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading