From 2611201c2796807e78f0b88e2f5fdd98bddeb062 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Thu, 1 Feb 2024 15:39:35 -0800 Subject: [PATCH] [v13] Fix app redirection loop on browser's incognito mode and 3rd party cookie block --- integration/helpers/cookies.go | 6 + lib/web/apiserver.go | 12 +- lib/web/app/auth.go | 269 +++++++++++++-- lib/web/app/handler.go | 203 +++++------ lib/web/app/handler_test.go | 317 +++++++++++++++--- lib/web/app/middleware.go | 121 ++++++- lib/web/app/redirect.go | 62 +++- .../src/AppLauncher/AppLauncher.test.tsx | 65 ++-- .../teleport/src/AppLauncher/AppLauncher.tsx | 114 +++++-- .../teleport/src/services/apps/apps.ts | 2 - 10 files changed, 942 insertions(+), 229 deletions(-) diff --git a/integration/helpers/cookies.go b/integration/helpers/cookies.go index 803f5ee93c411..51f4a128b7cb4 100644 --- a/integration/helpers/cookies.go +++ b/integration/helpers/cookies.go @@ -25,6 +25,7 @@ import ( type AppCookies struct { SessionCookie *http.Cookie SubjectSessionCookie *http.Cookie + AuthStateCookie *http.Cookie } // WithSubjectCookie returns a copy of AppCookies with the specified subject session cookie. @@ -43,6 +44,9 @@ func (ac *AppCookies) ToSlice() []*http.Cookie { if ac.SubjectSessionCookie != nil { out = append(out, ac.SubjectSessionCookie) } + if ac.AuthStateCookie != nil { + out = append(out, ac.AuthStateCookie) + } return out } @@ -56,6 +60,8 @@ func ParseCookies(t *testing.T, cookies []*http.Cookie) *AppCookies { out.SessionCookie = c case app.SubjectCookieName: out.SubjectSessionCookie = c + case app.AuthStateCookieName: + out.AuthStateCookie = c default: t.Fatalf("unrecognized cookie name: %q", c.Name) } diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 64bf9df81c024..e311c3c2061b4 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -302,7 +302,7 @@ func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // request has a session cookie or a client cert, forward to // application handlers. If the request is requesting a // FQDN that is not of the proxy, redirect to application launcher. - if h.appHandler != nil && (app.HasFragment(r) || app.HasSession(r) || app.HasClientCert(r)) { + if h.appHandler != nil && (app.HasFragment(r) || app.HasSessionCookie(r) || app.HasClientCert(r)) { h.appHandler.ServeHTTP(w, r) return } @@ -493,8 +493,15 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { httplib.SetNoCacheHeaders(w.Header()) - // app access needs to make a CORS fetch request, so we only set the default CSP on that page + // DELETE IN 17.0: Delete the first if block. Keep the else case. + // Kept for backwards compatibility. + // + // This case only adds an additional CSP `content-src` value of the + // app URL which allows requesting to the app domain required by + // the legacy app access. if strings.HasPrefix(r.URL.Path, "/web/launch/") { + // legacy app access needs to make a CORS fetch request, + // so we only set the default CSP on that page parts := strings.Split(r.URL.Path, "/") // grab the FQDN from the URL to allow in the connect-src CSP applicationURL := "https://" + parts[3] + ":*" @@ -503,6 +510,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { } else { httplib.SetIndexContentSecurityPolicy(w.Header(), cfg.ClusterFeatures) } + if err := indexPage.Execute(w, session); err != nil { h.log.WithError(err).Error("Failed to execute index page template.") } diff --git a/lib/web/app/auth.go b/lib/web/app/auth.go index 3954ec69cfc67..7ff99ca8c3294 100644 --- a/lib/web/app/auth.go +++ b/lib/web/app/auth.go @@ -18,17 +18,186 @@ package app import ( "crypto/subtle" + "fmt" "net/http" + "strings" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/utils" ) +type fragmentRequest struct { + StateValue string `json:"state_value"` + CookieValue string `json:"cookie_value"` + SubjectCookieValue string `json:"subject_cookie_value"` +} + +// startAppAuthExchange will do two actions depending on the following: +// +// 1): On initiating auth exchange (indicated by an empty "state" query param) +// we create a crypto safe random token and send it back as part of a "state" +// query param in the redirection URL, as well as in a cookie with attributes +// that makes the cookie unaccesible and hard to tamper with. We use this +// "double submit cookie" method to protect the entire auth exchange flow +// from CSRF. +// +// 2): If the "state" query param is present, we will serve a blank HTML page +// that has inline JS that contains logic to complete the auth exchange. +func (h *Handler) startAppAuthExchange(w http.ResponseWriter, r *http.Request, p httprouter.Params) error { + q := r.URL.Query() + + // Initiate auth exchange. + if q.Get("state") == "" { + // secretToken is the token we will look for in both the cookie + // and in the request "state" query param. + secretToken, err := utils.CryptoRandomHex(auth.TokenLenBytes) + if err != nil { + h.log.WithError(err).Debug("Failed to generate token required for app auth exchange") + return trace.AccessDenied("access denied") + } + + // cookieIdentifier is used to uniquely identify this cookie + // that will be used to store this secret token. + // + // This prevents a race condition (state token mismatch error) + // where we can overwrite existing cookie (with the same name) with a + // different token value eg: launch app in multiple tabs in quick succession + cookieIdentifier, err := utils.CryptoRandomHex(auth.TokenLenBytes) + if err != nil { + h.log.WithError(err).Debug("Failed to generate an UID for the app auth state cookie") + return trace.AccessDenied("access denied") + } + + h.setAuthStateCookie(w, secretToken, cookieIdentifier) + + webLauncherURLParams := launcherURLParams{ + clusterName: q.Get("cluster"), + publicAddr: q.Get("addr"), + arn: q.Get("arn"), + path: q.Get("path"), + // The state token concats both the secret token and the cookie ID. + // The server will break this token to its individual parts: + // - secretToken to compare against the one stored in cookie + // - cookieIdentifier to look up cookie sent by browser. + stateToken: fmt.Sprintf("%s_%s", secretToken, cookieIdentifier), + } + return h.redirectToLauncher(w, r, webLauncherURLParams) + } + + // Continue the auth exchange. + + nonce, err := utils.CryptoRandomHex(auth.TokenLenBytes) + if err != nil { + h.log.WithError(err).Debug("Failed to create a random nonce for the app redirection HTML inline script") + return trace.AccessDenied("access denied") + } + SetRedirectPageHeaders(w.Header(), nonce) + + // Serving the HTML page. + if err := appRedirectTemplate.Execute(w, nonce); err != nil { + h.log.WithError(err).Debug("Failed executing appRedirect template") + return trace.AccessDenied("access denied") + } + return nil +} + +// completeAppAuthExchange completes the auth exchange flow started by "startAppAuthExchange" handler +// by validating the values passed in the request body, and upon success sets cookies required +// for the current app session. +func (h *Handler) completeAppAuthExchange(w http.ResponseWriter, r *http.Request, p httprouter.Params) error { + httplib.SetNoCacheHeaders(w.Header()) + var req fragmentRequest + if err := httplib.ReadJSON(r, &req); err != nil { + h.log.WithError(err).Debug("Failed to decode JSON from request body") + return trace.AccessDenied("access denied") + } + + secretToken, cookieID, ok := strings.Cut(req.StateValue, "_") + if !ok { + h.log.Warn("Request failed: request state token is not in the expected format") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: req.CookieValue, + err: "state token was not in the expected format", + }) + return trace.AccessDenied("access denied") + } + + // Validate that the caller-provided state token matches the stored state token (CSRF check) + stateCookie, err := r.Cookie(getAuthStateCookieName(cookieID)) + if err != nil || stateCookie.Value == "" { + h.log.Warn("Request failed: state cookie is not set.") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: req.CookieValue, + err: "auth state cookie was not set", + }) + return trace.AccessDenied("access denied") + } + if subtle.ConstantTimeCompare([]byte(secretToken), []byte(stateCookie.Value)) != 1 { + h.log.Warn("Request failed: state token does not match.") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: req.CookieValue, + err: "state token did not match", + }) + return trace.AccessDenied("access denied") + } + + // Prevent reuse of the same state token. + clearAuthStateCookie(w, cookieID) + + // Validate that the caller is asking for a session that exists and that they have the secret + // session token for. + ws, err := h.c.AccessPoint.GetAppSession(r.Context(), types.GetAppSessionRequest{ + SessionID: req.CookieValue, + }) + if err != nil { + h.log.WithError(err).Warn("Request failed: session does not exist.") + return trace.AccessDenied("access denied") + } + if err := checkSubjectToken(req.SubjectCookieValue, ws); err != nil { + h.log.WithError(err).Warn("Request failed") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: req.CookieValue, + err: err.Error(), + loginName: ws.GetUser(), + }) + return trace.AccessDenied("access denied") + } + + // Set the "Set-Cookie" header on the response. + // Set Same-Site policy for the session cookies to None in order to + // support redirects that identity providers do during SSO auth. + // Otherwise the session cookie won't be sent and the user will + // get redirected to the application launcher. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: req.CookieValue, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + }) + http.SetCookie(w, &http.Cookie{ + Name: SubjectCookieName, + Value: ws.GetBearerToken(), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + }) + + return nil +} + +// DELETE IN 17.0: kept for backwards compat. +// // handleAuth handles authentication for an app // When a `POST` request comes in from a trusted proxy address, it'll set the value from the // `X-Cookie-Value` header to the `__Host-grv_app_session` cookie. @@ -37,12 +206,21 @@ func (h *Handler) handleAuth(w http.ResponseWriter, r *http.Request, p httproute cookieValue := r.Header.Get("X-Cookie-Value") if cookieValue == "" { + h.log.Warn("Request failed: missing X-Cookie-Value header") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + err: "missing X-Cookie-Value header", + }) return trace.AccessDenied("access denied") } subjectCookieValue := r.Header.Get("X-Subject-Cookie-Value") - if cookieValue == "" { - return trace.BadParameter("X-Subject-Cookie-Value header missing") + if subjectCookieValue == "" { + h.log.Warn("Request failed: X-Subject-Cookie-Value") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + err: "missing X-Subject-Cookie-Value header", + sessionID: cookieValue, + }) + return trace.AccessDenied("access denied") } // Validate that the caller is asking for a session that exists. @@ -57,23 +235,10 @@ func (h *Handler) handleAuth(w http.ResponseWriter, r *http.Request, p httproute if err := checkSubjectToken(subjectCookieValue, ws); err != nil { h.log.Warnf("Request failed: %v.", err) - h.c.AuthClient.EmitAuditEvent(h.closeContext, &apievents.AuthAttempt{ - Metadata: apievents.Metadata{ - Type: events.AuthAttemptEvent, - Code: events.AuthAttemptFailureCode, - }, - UserMetadata: apievents.UserMetadata{ - Login: ws.GetUser(), - User: "unknown", - }, - ConnectionMetadata: apievents.ConnectionMetadata{ - LocalAddr: r.Host, - RemoteAddr: r.RemoteAddr, - }, - Status: apievents.Status{ - Success: false, - Error: err.Error(), - }, + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: cookieValue, + err: err.Error(), + loginName: ws.GetUser(), }) return trace.AccessDenied("access denied") @@ -109,3 +274,69 @@ func checkSubjectToken(subjectCookieValue string, ws types.WebSession) error { } return nil } + +func (h *Handler) setAuthStateCookie(w http.ResponseWriter, cookieValue string, cookieID string) { + http.SetCookie(w, &http.Cookie{ + Name: getAuthStateCookieName(cookieID), + Value: cookieValue, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + MaxAge: 60, // Expire in 1 minute. + }) +} + +func clearAuthStateCookie(w http.ResponseWriter, cookieID string) { + http.SetCookie(w, &http.Cookie{ + Name: getAuthStateCookieName(cookieID), + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + MaxAge: -1, + }) +} + +func getAuthStateCookieName(cookieID string) string { + return fmt.Sprintf("%s_%s", AuthStateCookieName, cookieID) +} + +type emitErrorEventFields struct { + loginName string + err string + sessionID string +} + +func (h *Handler) emitErrorEventAndDeleteAppSession(r *http.Request, f emitErrorEventFields) { + // Attempt to delete app session. + if f.sessionID != "" { + if err := h.c.AuthClient.DeleteAppSession(r.Context(), types.DeleteAppSessionRequest{ + SessionID: f.sessionID, + }); err != nil { + h.log.WithError(err).Warn("Failed to delete app session after failing authentication") + } + } + + event := &apievents.AuthAttempt{ + Metadata: apievents.Metadata{ + Type: events.AuthAttemptEvent, + Code: events.AuthAttemptFailureCode, + }, + UserMetadata: apievents.UserMetadata{ + User: "unknown", + Login: f.loginName, + }, + ConnectionMetadata: apievents.ConnectionMetadata{ + LocalAddr: r.Host, + RemoteAddr: r.RemoteAddr, + }, + Status: apievents.Status{ + Success: false, + Error: fmt.Sprintf("Failed app access authentication: %s", f.err), + }, + } + + h.c.AuthClient.EmitAuditEvent(h.closeContext, event) +} diff --git a/lib/web/app/handler.go b/lib/web/app/handler.go index 82608ec17857c..fe47ab2e9e9d7 100644 --- a/lib/web/app/handler.go +++ b/lib/web/app/handler.go @@ -26,7 +26,7 @@ import ( "net" "net/http" "net/url" - "strconv" + "path" oxyutils "github.com/gravitational/oxy/utils" "github.com/gravitational/trace" @@ -129,7 +129,14 @@ func NewHandler(ctx context.Context, c *HandlerConfig) (*Handler, error) { // Create the application routes. h.router = httprouter.New() h.router.UseRawPath = true - h.router.POST("/x-teleport-auth", makeRouterHandler(h.handleAuth)) + h.router.GET("/x-teleport-auth", makeRouterHandler(h.startAppAuthExchange)) + // // DELETE IN 17.0 + // // Kept for legacy app access. + h.router.OPTIONS("/x-teleport-auth", makeRouterHandler(h.withCustomCORS(nil))) + // DELETE IN 17.0 + // when deleting, replace with the commented handler below: + // h.router.POST("/x-teleport-auth", makeRouterHandler(h.completeAppAuthExchange)) + h.router.POST("/x-teleport-auth", makeRouterHandler(h.withCustomCORS(h.handleAuth))) h.router.GET("/teleport-logout", h.withRouterAuth(h.handleLogout)) h.router.NotFound = h.withAuth(h.handleHttp) @@ -138,56 +145,6 @@ func NewHandler(ctx context.Context, c *HandlerConfig) (*Handler, error) { // ServeHTTP hands the request to the request router. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/x-teleport-auth" { - // Allow minimal CORS from only the proxy origin - // This allows for requests from the proxy to `POST` to `/x-teleport-auth` and only - // permits the headers `X-Cookie-Value` and `X-Subject-Cookie-Value`. - // This is for the web UI to post a request to the application to get the proper app session - // cookie set on the right application subdomain. - w.Header().Set("Access-Control-Allow-Methods", "POST") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "X-Cookie-Value, X-Subject-Cookie-Value") - - // Validate that the origin for the request matches any of the public proxy addresses. - // This is instead of protecting via CORS headers, as that only supports a single domain. - originValue := r.Header.Get("Origin") - origin, err := url.Parse(originValue) - if err != nil { - h.log.Errorf("malformed Origin header: %v", err) - - w.WriteHeader(http.StatusBadRequest) - - return - } - - var match bool - originPort := origin.Port() - if originPort == "" { - originPort = "443" - } - - for _, addr := range h.c.ProxyPublicAddrs { - if strconv.Itoa(addr.Port(0)) == originPort && addr.Host() == origin.Hostname() { - match = true - break - } - } - - if !match { - w.WriteHeader(http.StatusForbidden) - - return - } - - // As we've already checked the origin matches a public proxy address, we can allow requests from that origin - // We do this dynamically as this header can only contain one value - w.Header().Set("Access-Control-Allow-Origin", originValue) - - if r.Method == http.MethodOptions { - return - } - } - h.router.ServeHTTP(w, r) } @@ -346,7 +303,7 @@ func (h *Handler) handleForwardError(w http.ResponseWriter, req *http.Request, e // done to have a consistent UX to when launching an application. session, err := h.renewSession(req) if err != nil { - if redirectErr := h.redirectToLauncher(w, req); redirectErr == nil { + if redirectErr := h.redirectToLauncher(w, req, launcherURLParams{}); redirectErr == nil { return } @@ -551,8 +508,8 @@ func HasFragment(r *http.Request) bool { return r.URL.Path == "/x-teleport-auth" } -// HasSession checks if an application specific cookie exists. -func HasSession(r *http.Request) bool { +// HasSessionCookie checks if an application specific cookie exists. +func HasSessionCookie(r *http.Request) bool { _, err := r.Cookie(CookieName) return err == nil } @@ -592,7 +549,7 @@ func HasName(r *http.Request, proxyPublicAddrs []utils.NetAddr) (string, bool) { // At this point, it is assumed the caller is requesting an application and // not the proxy, redirect the caller to the application launcher. - urlString := makeAppRedirectURL(r, proxyPublicAddrs[0].String(), raddr.Host()) + urlString := makeAppRedirectURL(r, proxyPublicAddrs[0].String(), raddr.Host(), launcherURLParams{}) return urlString, true } @@ -602,61 +559,109 @@ const ( // SubjectCookieName is the name of the application session subject cookie. SubjectCookieName = "__Host-grv_app_session_subject" + + // AuthStateCookieName is the name of the state cookie used during the + // initial authentication flow. + AuthStateCookieName = "__Host-grv_app_auth_state" ) // makeAppRedirectURL constructs a URL that will redirect the user to the // application launcher route in the web UI. // -// Given app URL example: some-domain.com/arbitrary/path?foo=bar&baz=qux -// The original requested URL will be separated into three parts: -// - hostname (or fqdn): some-domain.com -// - path (the URL parts after the app's hostname): arbitrary/path -// - query: foo=bar&baz=qux +// Depending on how user initially accesses the app, the URL construction +// can take on two formats: // -// which will be constructed into a redirect URL using this form: -// - /web/launch/?path=&query= +// 1: When a user uses the web UI to launch the app, the webapp can +// determine the app's clusterId, publicAddr, and its AWS role name +// (this allows a direct lookup of the app when it's time to create an +// app session) and the launcher route is created with format: +// - /web/launch//:clusterID?/:publicAddr?/:arn? +// We will need to reconstruct this exact redirect URL when initiating +// an auth exchange (with a stateToken query param). // -// where the final result for the example URL will be: -// - /web/launch/some-domain.com?path=%2Farbitrary%2Fpath&query=foo%3Dbar%26baz%3Dqux +// 2: When a user requests an app outside of web UI (eg. clicking on bookmark) +// aside from knowing the `fqdn`, the other params of the web launcher +// cannot be determined so the launcher route will be constructed as: +// - /web/launch/?path=&query= +// Often bookmarked links will have additional path and queries we will +// need to preserve. // -// The URL is formed this way to help isolate the `fqdn` param -// from the rest of the URL. +// Example Flow: // -// The original path and query cannot be formed as `web/launch/` -// because `web/launch` route can differ depending on how the user hits the app -// endpoint: -// 1. /web/launch/:fqdn/:clusterID/:publicAddr?/:arn? -// This route is formed when user clicks on the web UI's app launcher -// button from the application listing screen. The app can be directly -// resolved since we are able to determine the app's cluster name, -// public address, and AWS role name (if defined). -// 2. /web/launch/?path=&query= -// This route is formed when a user hits the app endpoint outside of -// the web UI (clicking from a link or copy/pasta link), and the app will -// have to be resolved by the fqdn. +// 1. When a user requests the app endpoint directly, we will need to redirect +// the user to the web launcher first to start the auth exchange. +// Example app endpoint: https://some-domain.com/arbitrary/path?foo=bar&baz=qux // -// Isolating the `fqdn` prevents confusing the rest of the param reserved for -// clusterId, publicAddr, and arn (where the non-query param values are used to -// create app session). The `web/launcher` will reconstruct the original -// app URL when ready to redirect the user to the requested endpoint. -func makeAppRedirectURL(r *http.Request, proxyPublicAddr, hostname string) string { - // Note that r.URL.Path field is stored in decoded form where: - // - `/%47%6f%2f` becomes `/Go/` - // - `siema%20elo` becomes `siema elo` - // And QueryEscape() will encode spaces as `+` - // - // QueryEscape is used on the `r.URL.Path` since it is being placed - // into the query part of the URL. - query := fmt.Sprintf("path=%s", url.QueryEscape(r.URL.Path)) - if len(r.URL.RawQuery) > 0 { - query = fmt.Sprintf("%s&query=%s", query, url.QueryEscape(r.URL.RawQuery)) +// The original requested URL will be separated into three parts: +// - hostname (or fqdn): some-domain.com +// - path (the URL parts after the app's hostname): arbitrary/path +// - query: foo=bar&baz=qux +// +// which will be constructed into a redirect URL using this form: +// - /web/launch/?path=&query= +// +// where the final result for the example URL will be: +// - /web/launch/some-domain.com?path=%2Farbitrary%2Fpath&query=foo%3Dbar%26baz%3Dqux +// +// 2. Building off from previous step, the web app launcher can now redirect the user +// to the apps "x-teleport-auth" endpoint to start the auth exchange: +// https://some-domain.com/x-teleport-auth?path=%2Farbitrary%2Fpath&query=foo%3Dbar%26baz%3Dqux +// +// We will need to reconstruct the same URL ^ along with the stateToken created +// by server: +// - /web/launch/some-domain.com?path=%2Farbitrary%2Fpath&query=foo%3Dbar%26baz%3Dqux&state=ABCD +// +// The URL's are formed this way to help isolate the path params reserved for the app +// launchers route, where order and existence of previous params matter for this route. +func makeAppRedirectURL(r *http.Request, proxyPublicAddr, hostname string, req launcherURLParams) string { + u := url.URL{ + Scheme: "https", + Host: proxyPublicAddr, + Path: fmt.Sprintf("/web/launch/%s", hostname), } - u := url.URL{ - Scheme: "https", - Host: proxyPublicAddr, - Path: fmt.Sprintf("/web/launch/%s", hostname), - RawQuery: query, + // Presence of a stateToken means we are beginning an app auth exchange. + if req.stateToken != "" { + v := url.Values{} + v.Add("state", req.stateToken) + v.Add("path", req.path) + u.RawQuery = v.Encode() + + urlPath := []string{"web", "launch", hostname} + + // The order and existence of previous params matter. + // + // If the user requested app through our web UI (click launch button), + // the webapp populate these fields and will be defined. + // + // If the user requested the app endpoint outside of web UI (click from link), + // these fields can't be determined and will be empty. + if req.clusterName != "" && req.publicAddr != "" { + urlPath = append(urlPath, req.clusterName, req.publicAddr) + + if req.arn != "" { + urlPath = append(urlPath, req.arn) + } + } + + u.Path = path.Join(urlPath...) + + } else { + // Hitting this case means the user has hit an endpoint directly + // and will need to be redirected to the web launcher to + // start the auth exchange. + + // Note that r.URL.Path field is stored as decoded form where: + // - `/%47%6f%2f` becomes `/Go/` + // - `siema%20elo` becomes `siema elo` + // So Encode() will just encode it once (note that spaces will be convereted to `+`) + v := url.Values{} + v.Add("path", r.URL.Path) + + if len(r.URL.RawQuery) > 0 { + v.Add("query", r.URL.RawQuery) + } + u.RawQuery = v.Encode() } return u.String() diff --git a/lib/web/app/handler_test.go b/lib/web/app/handler_test.go index c50dc701a01bb..ad5ca5e9d7b70 100644 --- a/lib/web/app/handler_test.go +++ b/lib/web/app/handler_test.go @@ -21,6 +21,7 @@ import ( "context" "crypto/tls" "crypto/x509/pkix" + "encoding/json" "fmt" "io" "net" @@ -72,6 +73,176 @@ func hasAuditEventCount(want int) eventCheckFn { // TestAuthPOST tests the handler of POST /x-teleport-auth. func TestAuthPOST(t *testing.T) { + secretToken := "012ac605867e5a7d693cd6f49c7ff0fb" + cookieID := "cookie-name" + stateValue := fmt.Sprintf("%s_%s", secretToken, cookieID) + appCookieValue := "5588e2be54a2834b4f152c56bafcd789f53b15477129d2ab4044e9a3c1bf0f3b" + + fakeClock := clockwork.NewFakeClockAt(time.Date(2017, 05, 10, 18, 53, 0, 0, time.UTC)) + clusterName := "test-cluster" + publicAddr := "app.example.com" + // Generate CA TLS key and cert with the cluster and application DNS. + key, cert, err := tlsca.GenerateSelfSignedCA( + pkix.Name{CommonName: clusterName}, + []string{publicAddr, apiutils.EncodeClusterName(clusterName)}, + defaults.CATTL, + ) + require.NoError(t, err) + + tests := []struct { + desc string + sessionError error + outStatusCode int + makeRequestBody func(types.WebSession) fragmentRequest + getEventChecks func(types.WebSession) []eventCheckFn + }{ + { + desc: "success", + makeRequestBody: func(appSession types.WebSession) fragmentRequest { + return fragmentRequest{ + StateValue: stateValue, + CookieValue: appCookieValue, + SubjectCookieValue: appSession.GetBearerToken(), + } + }, + outStatusCode: http.StatusOK, + getEventChecks: func(types.WebSession) []eventCheckFn { + return []eventCheckFn{hasAuditEventCount(0)} + }, + }, + { + desc: "missing state token in request", + makeRequestBody: func(appSession types.WebSession) fragmentRequest { + return fragmentRequest{ + StateValue: "", + CookieValue: appCookieValue, + SubjectCookieValue: appSession.GetBearerToken(), + } + }, + outStatusCode: http.StatusForbidden, + getEventChecks: func(types.WebSession) []eventCheckFn { + return []eventCheckFn{ + hasAuditEventCount(1), + hasAuditEvent(0, &apievents.AuthAttempt{ + Metadata: apievents.Metadata{ + Type: events.AuthAttemptEvent, + Code: events.AuthAttemptFailureCode, + }, + UserMetadata: apievents.UserMetadata{ + User: "unknown", + }, + Status: apievents.Status{ + Success: false, + Error: "Failed app access authentication: missing required fields in JSON request body", + }, + }), + } + }, + }, + { + desc: "missing subject session token in request", + makeRequestBody: func(ws types.WebSession) fragmentRequest { + return fragmentRequest{ + StateValue: stateValue, + CookieValue: appCookieValue, + SubjectCookieValue: "", + } + }, + outStatusCode: http.StatusForbidden, + getEventChecks: func(appSession types.WebSession) []eventCheckFn { + return []eventCheckFn{ + hasAuditEventCount(1), + hasAuditEvent(0, &apievents.AuthAttempt{ + Metadata: apievents.Metadata{ + Type: events.AuthAttemptEvent, + Code: events.AuthAttemptFailureCode, + }, + UserMetadata: apievents.UserMetadata{ + User: "unknown", + }, + Status: apievents.Status{ + Success: false, + Error: "Failed app access authentication: missing required fields in JSON request body", + }, + }), + } + }, + }, + { + desc: "subject session token in request does not match", + makeRequestBody: func(ws types.WebSession) fragmentRequest { + return fragmentRequest{ + StateValue: stateValue, + CookieValue: appCookieValue, + SubjectCookieValue: "foobar", + } + }, + outStatusCode: http.StatusForbidden, + getEventChecks: func(appSession types.WebSession) []eventCheckFn { + return []eventCheckFn{ + hasAuditEventCount(1), + hasAuditEvent(0, &apievents.AuthAttempt{ + Metadata: apievents.Metadata{ + Type: events.AuthAttemptEvent, + Code: events.AuthAttemptFailureCode, + }, + UserMetadata: apievents.UserMetadata{ + Login: appSession.GetUser(), + User: "unknown", + }, + Status: apievents.Status{ + Success: false, + Error: "Failed app access authentication: subject session token does not match", + }, + }), + } + }, + }, + { + desc: "invalid session", + makeRequestBody: func(appSession types.WebSession) fragmentRequest { + return fragmentRequest{ + StateValue: stateValue, + CookieValue: appCookieValue, + SubjectCookieValue: appSession.GetBearerToken(), + } + }, + sessionError: trace.NotFound("invalid session"), + outStatusCode: http.StatusForbidden, + getEventChecks: func(types.WebSession) []eventCheckFn { + return []eventCheckFn{hasAuditEventCount(0)} + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + appSession := createAppSession(t, fakeClock, key, cert, clusterName, publicAddr) + authClient := &mockAuthClient{ + sessionError: test.sessionError, + appSession: appSession, + } + p := setup(t, fakeClock, authClient, nil, nil) + + reqBody := test.makeRequestBody(appSession) + req, err := json.Marshal(reqBody) + require.NoError(t, err) + + status, _ := p.makeRequest(t, "POST", "/x-teleport-auth", req, []http.Cookie{{ + Name: fmt.Sprintf("%s_%s", AuthStateCookieName, cookieID), + Value: secretToken, + }}) + require.Equal(t, status, test.outStatusCode) + for _, check := range test.getEventChecks(appSession) { + check(t, authClient.emittedEvents) + } + }) + } +} + +// DELETE IN 17.0 +func TestAuthPOST_Legacy(t *testing.T) { const ( cookieValue = "5588e2be54a2834b4f152c56bafcd789f53b15477129d2ab4044e9a3c1bf0f3b" // random value we set in the header and expect to get back as a cookie ) @@ -145,12 +316,38 @@ func TestAuthPOST(t *testing.T) { Code: events.AuthAttemptFailureCode, }, UserMetadata: apievents.UserMetadata{ - Login: appSession.GetUser(), - User: "unknown", + User: "unknown", + }, + Status: apievents.Status{ + Success: false, + Error: "Failed app access authentication: missing X-Subject-Cookie-Value header", + }, + }), + }, + proxyAddrs: []utils.NetAddr{ + *utils.MustParseAddr(publicAddr), + }, + }, + { + desc: "missing subject session token in request", + headers: map[string]string{ + "Origin": "https://proxy.goteleport.com", + "X-Subject-Cookie-Value": "foobar", + }, + outStatusCode: http.StatusForbidden, + eventChecks: []eventCheckFn{ + hasAuditEventCount(1), + hasAuditEvent(0, &apievents.AuthAttempt{ + Metadata: apievents.Metadata{ + Type: events.AuthAttemptEvent, + Code: events.AuthAttemptFailureCode, + }, + UserMetadata: apievents.UserMetadata{ + User: "unknown", }, Status: apievents.Status{ Success: false, - Error: "subject session token is not set", + Error: "Failed app access authentication: missing X-Cookie-Value header", }, }), }, @@ -179,7 +376,7 @@ func TestAuthPOST(t *testing.T) { }, Status: apievents.Status{ Success: false, - Error: "subject session token does not match", + Error: "Failed app access authentication: subject session token does not match", }, }), }, @@ -268,6 +465,39 @@ func TestAuthPOST(t *testing.T) { } } +// DELETE IN 17.0 +func (p *testServer) makeRequestWithHeaders(t *testing.T, endpoint string, headers map[string]string) *http.Response { + u := url.URL{ + Scheme: p.serverURL.Scheme, + Host: p.serverURL.Host, + Path: endpoint, + } + req, err := http.NewRequest(http.MethodPost, u.String(), nil) + require.NoError(t, err) + + for key, value := range headers { + req.Header.Add(key, value) + } + + // Issue request. + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + return resp +} + func TestHasName(t *testing.T) { for _, test := range []struct { desc string @@ -530,6 +760,7 @@ func (p *testServer) makeRequest(t *testing.T, method, endpoint string, reqBody } req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(reqBody)) require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") // Attach the cookie. for _, c := range cookies { @@ -558,38 +789,6 @@ func (p *testServer) makeRequest(t *testing.T, method, endpoint string, reqBody return resp.StatusCode, string(content) } -func (p *testServer) makeRequestWithHeaders(t *testing.T, endpoint string, headers map[string]string) *http.Response { - u := url.URL{ - Scheme: p.serverURL.Scheme, - Host: p.serverURL.Host, - Path: endpoint, - } - req, err := http.NewRequest(http.MethodPost, u.String(), nil) - require.NoError(t, err) - - for key, value := range headers { - req.Header.Add(key, value) - } - - // Issue request. - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - resp, err := client.Do(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - - return resp -} - type mockAuthClient struct { auth.ClientI clusterName string @@ -614,6 +813,10 @@ func (c *mockAuthClient) EmitAuditEvent(ctx context.Context, event apievents.Aud return nil } +func (c *mockAuthClient) DeleteAppSession(ctx context.Context, r types.DeleteAppSessionRequest) error { + return nil +} + func (c *mockAuthClient) GetClusterName(_ ...services.MarshalOption) (types.ClusterName, error) { return mockClusterName{name: c.clusterName}, nil } @@ -740,10 +943,12 @@ func createAppServer(t *testing.T, publicAddr string) types.AppServer { func TestMakeAppRedirectURL(t *testing.T) { for _, test := range []struct { - name string - reqURL string - expectedURL string + name string + reqURL string + expectedURL string + launderURLParams launcherURLParams }{ + // with launcherURLParams empty (will be empty if user did not launch app from our web UI) { name: "OK - no path", reqURL: "https://grafana.localhost", @@ -789,12 +994,44 @@ func TestMakeAppRedirectURL(t *testing.T) { reqURL: "https://grafana.localhost/alerting /list?search=state:inactive type:alerting health:nodata", expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2Falerting+%2Flist&query=search%3Dstate%3Ainactive+type%3Aalerting+health%3Anodata", }, + + // with launcherURLParams (defined if user used the "launcher" button from our web UI) + { + name: "OK - with clusterId and publicAddr", + launderURLParams: launcherURLParams{ + stateToken: "abc123", + clusterName: "im-a-cluster-name", + publicAddr: "grafana.localhost", + }, + expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost?path=&state=abc123", + }, + { + name: "OK - with clusterId, publicAddr, and arn", + launderURLParams: launcherURLParams{ + stateToken: "abc123", + clusterName: "im-a-cluster-name", + publicAddr: "grafana.localhost", + arn: "arn:aws:iam::123456789012:role%2Frole-name", + }, + expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost/arn:aws:iam::123456789012:role%252Frole-name?path=&state=abc123", + }, + { + name: "OK - with clusterId, publicAddr, arn and path", + launderURLParams: launcherURLParams{ + stateToken: "abc123", + clusterName: "im-a-cluster-name", + publicAddr: "grafana.localhost", + arn: "arn:aws:iam::123456789012:role%2Frole-name", + path: "/foo/bar?qux=qex", + }, + expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost/arn:aws:iam::123456789012:role%252Frole-name?path=%2Ffoo%2Fbar%3Fqux%3Dqex&state=abc123", + }, } { t.Run(test.name, func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, test.reqURL, nil) require.NoError(t, err) - urlStr := makeAppRedirectURL(req, "proxy.com", "grafana.localhost") + urlStr := makeAppRedirectURL(req, "proxy.com", "grafana.localhost", test.launderURLParams) require.Equal(t, test.expectedURL, urlStr) }) } diff --git a/lib/web/app/middleware.go b/lib/web/app/middleware.go index 1dbfd9fc50f56..22cfb8959bed6 100644 --- a/lib/web/app/middleware.go +++ b/lib/web/app/middleware.go @@ -17,11 +17,17 @@ limitations under the License. package app import ( + "bytes" + "encoding/json" + "io" "net/http" + "net/url" + "strconv" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + "github.com/gravitational/teleport" "github.com/gravitational/teleport/lib/utils" ) @@ -47,7 +53,7 @@ func (h *Handler) withAuth(handler handlerAuthFunc) http.HandlerFunc { // If the caller fails to authenticate, redirect the caller to Teleport. session, err := h.authenticate(r.Context(), r) if err != nil { - if redirectErr := h.redirectToLauncher(w, r); redirectErr == nil { + if redirectErr := h.redirectToLauncher(w, r, launcherURLParams{}); redirectErr == nil { return nil } return trace.Wrap(err) @@ -61,12 +67,13 @@ func (h *Handler) withAuth(handler handlerAuthFunc) http.HandlerFunc { // redirectToLauncher redirects to the proxy web's app launcher if the public // address of the proxy is set. -func (h *Handler) redirectToLauncher(w http.ResponseWriter, r *http.Request) error { - // The application launcher can only generate browser sessions (based on - // Cookies). Given this, we should only redirect to it when this format is - // already in use. - if !HasSession(r) { - return trace.BadParameter("redirecting to launcher when using client certificate is not valid") +func (h *Handler) redirectToLauncher(w http.ResponseWriter, r *http.Request, p launcherURLParams) error { + if p.stateToken == "" && !HasSessionCookie(r) { + // Reaching this block means the application was accessed through the CLI (eg: tsh app login) + // and there was a forwarding error and we could not renew the app web session. + // Since we can't redirect the user to the app launcher from the CLI, + // we just return an error instead. + return trace.BadParameter("redirecting to launcher when using client certificate, is not allowed") } if h.c.WebPublicAddr == "" { @@ -83,11 +90,92 @@ func (h *Handler) redirectToLauncher(w http.ResponseWriter, r *http.Request) err return trace.Wrap(err) } - urlString := makeAppRedirectURL(r, h.c.WebPublicAddr, addr.Host()) + urlString := makeAppRedirectURL(r, h.c.WebPublicAddr, addr.Host(), p) http.Redirect(w, r, urlString, http.StatusFound) return nil } +// DELETE IN 17.0 along with blocks of code that uses it. +// Kept for legacy app access. +func (h *Handler) withCustomCORS(handle routerFunc) routerFunc { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) error { + + // There can be two types of POST app launcher request. + // 1): legacy app access + // 2): new app access + // Legacy app access will send a POST request with an empty body. + if r.Method == http.MethodPost && r.Body != http.NoBody { + body, err := utils.ReadAtMost(r.Body, teleport.MaxHTTPRequestSize) + if err != nil { + return trace.Wrap(err) + } + + var req fragmentRequest + if err := json.Unmarshal(body, &req); err != nil { + h.log.Warn("Failed to decode JSON from request body") + return trace.AccessDenied("access denied") + } + // Replace the body with a new reader, allows re-reading the body. + // (the handler `completeAppAuthExchange` will also read the body) + r.Body = io.NopCloser(bytes.NewBuffer(body)) + + if req.CookieValue != "" && req.StateValue != "" && req.SubjectCookieValue != "" { + return h.completeAppAuthExchange(w, r, p) + } + + h.log.Warn("Missing fields from parsed JSON request body") + h.emitErrorEventAndDeleteAppSession(r, emitErrorEventFields{ + sessionID: req.CookieValue, + err: "missing required fields in JSON request body", + }) + return trace.AccessDenied("access denied") + } + + // Allow minimal CORS from only the proxy origin + // This allows for requests from the proxy to `POST` to `/x-teleport-auth` and only + // permits the headers `X-Cookie-Value` and `X-Subject-Cookie-Value`. + // This is for the web UI to post a request to the application to get the proper app session + // cookie set on the right application subdomain. + w.Header().Set("Access-Control-Allow-Methods", "POST") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "X-Cookie-Value, X-Subject-Cookie-Value") + + // Validate that the origin for the request matches any of the public proxy addresses. + // This is instead of protecting via CORS headers, as that only supports a single domain. + originValue := r.Header.Get("Origin") + origin, err := url.Parse(originValue) + if err != nil { + return trace.BadParameter("malformed Origin header: %v", err) + } + + var match bool + originPort := origin.Port() + if originPort == "" { + originPort = "443" + } + + for _, addr := range h.c.ProxyPublicAddrs { + if strconv.Itoa(addr.Port(0)) == originPort && addr.Host() == origin.Hostname() { + match = true + break + } + } + + if !match { + return trace.AccessDenied("port or hostname did not match") + } + + // As we've already checked the origin matches a public proxy address, we can allow requests from that origin + // We do this dynamically as this header can only contain one value + w.Header().Set("Access-Control-Allow-Origin", originValue) + if handle != nil { + return handle(w, r, p) + } + + return nil + } +} + // makeRouterHandler creates a httprouter.Handle. func makeRouterHandler(handler routerFunc) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { @@ -120,3 +208,20 @@ type routerAuthFunc func(http.ResponseWriter, *http.Request, httprouter.Params, type handlerAuthFunc func(http.ResponseWriter, *http.Request, *session) error type handlerFunc func(http.ResponseWriter, *http.Request) error + +type launcherURLParams struct { + // clusterName is the cluster within which this application is running. + clusterName string + // publicAddr is the public address of this application. + publicAddr string + // arn is the AWS role name, defined only when accessing AWS management console. + arn string + // stateToken if defined means initiating an app access auth exchange. + stateToken string + // path is the application URL path. + // It is only defined if an application was accessed without the web launcher + // (e.g: clicking on a bookmarked URL). + // This field is used to preserve the original requested path through + // the app access authentication redirections. + path string +} diff --git a/lib/web/app/redirect.go b/lib/web/app/redirect.go index 78b6b34bb29bc..eaae9353fba52 100644 --- a/lib/web/app/redirect.go +++ b/lib/web/app/redirect.go @@ -47,7 +47,9 @@ func SetRedirectPageHeaders(h http.Header, nonce string) { // Set content security policy flags scriptSrc := "none" if nonce != "" { - // Should match the + + + +` diff --git a/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx b/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx index aebe69577cd77..144a6a82b0e33 100644 --- a/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx +++ b/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx @@ -26,50 +26,46 @@ import service from 'teleport/services/apps'; import { AppLauncher } from './AppLauncher'; -const testCases: { name: string; query: string; expectedPath: string }[] = [ +const testCases: { name: string; path: string; expectedPath: string }[] = [ { - name: 'no path or query', - query: '?path=', - expectedPath: '', + name: 'no state and no path', + path: '?path=', + expectedPath: 'x-teleport-auth', }, { - name: 'root path', - query: '?path=%2F', - expectedPath: '/', + name: 'no state with path', + path: '?path=%2Ffoo%2Fbar', + expectedPath: 'x-teleport-auth?path=%2Ffoo%2Fbar', }, { - name: 'with multi path', - query: '?path=%2Ffoo%2Fbar', - expectedPath: '/foo/bar', - }, - { - name: 'with only query', - query: '?path=&query=foo%3Dbar', - expectedPath: '?foo=bar', + name: 'no state with other path params (clusterId, publicAddr, publicArn', + path: '/some-cluster-id/some-public-addr/arn::123/name', + expectedPath: + 'x-teleport-auth?cluster=some-cluster-id&addr=some-public-addr&arn=arn%3A%3A123', }, { - name: 'with query with same keys used to store the original path and query', - query: '?path=foo&query=foo%3Dbar%26query%3Dtest1%26path%3Dtest', - expectedPath: '/foo?foo=bar&query=test1&path=test', + name: 'no state with path and with other path params', + path: '/some-cluster-id/some-public-addr/arn::123/name?path=%2Ffoo%2Fbar', + expectedPath: + 'x-teleport-auth?path=%2Ffoo%2Fbar&cluster=some-cluster-id&addr=some-public-addr&arn=arn%3A%3A123', }, { - name: 'with query and root path', - query: '?path=%2F&query=foo%3Dbar%26baz%3Dqux%26fruit%3Dapple', - expectedPath: '/?foo=bar&baz=qux&fruit=apple', + name: 'with state', + path: '?state=ABC', + expectedPath: + 'x-teleport-auth?state=ABC&subject=subject-cookie-value#value=cookie-value', }, { - name: 'queries with encoded spaces', - query: - '?path=%2Falerting%2Flist&query=search%3Dstate%3Ainactive%2520type%3Aalerting%2520health%3Anodata', + name: 'with state and path', + path: '?state=ABC&path=%2Ffoo%2Fbar', expectedPath: - '/alerting/list?search=state:inactive%20type:alerting%20health:nodata', + 'x-teleport-auth?state=ABC&subject=subject-cookie-value&path=%2Ffoo%2Fbar#value=cookie-value', }, { - name: 'queries with non-encoded spaces', - query: - '?path=%2Falerting+%2Flist&query=search%3Dstate%3Ainactive+type%3Aalerting+health%3Anodata', + name: 'with state, path, and params', + path: '?state=ABC&path=%2Ffoo%2Fbar', expectedPath: - '/alerting /list?search=state:inactive type:alerting health:nodata', + 'x-teleport-auth?state=ABC&subject=subject-cookie-value&path=%2Ffoo%2Fbar#value=cookie-value', }, ]; @@ -81,6 +77,11 @@ describe('app launcher path is properly formed', () => { global.fetch = jest.fn(() => Promise.resolve({})) as jest.Mock; jest.spyOn(api, 'get').mockResolvedValue({}); jest.spyOn(api, 'post').mockResolvedValue({}); + jest.spyOn(service, 'createAppSession').mockResolvedValue({ + cookieValue: 'cookie-value', + subjectCookieValue: 'subject-cookie-value', + fqdn: '', + }); delete window.location; window.location = { ...realLocation, replace: assignMock }; @@ -91,7 +92,7 @@ describe('app launcher path is properly formed', () => { assignMock.mockClear(); }); - test.each(testCases)('$name', async ({ query, expectedPath }) => { + test.each(testCases)('$name', async ({ path: query, expectedPath }) => { const launcherPath = `/web/launch/grafana.localhost${query}`; const mockHistory = createMemoryHistory({ initialEntries: [launcherPath], @@ -107,7 +108,7 @@ describe('app launcher path is properly formed', () => { await waitFor(() => expect(window.location.replace).toHaveBeenCalledWith( - `https://grafana.localhost${expectedPath}` + `https://grafana.localhost/${expectedPath}` ) ); }); @@ -116,7 +117,7 @@ describe('app launcher path is properly formed', () => { jest.spyOn(service, 'createAppSession'); const launcherPath = - '/web/launch/test-app.test.teleport/test.teleport/test-app.test.teleport/arn:aws:iam::joe123:role%2FEC2FullAccess'; + '/web/launch/test-app.test.teleport/test.teleport/test-app.test.teleport/arn:aws:iam::joe123:role%2FEC2FullAccess?state=ABC'; const mockHistory = createMemoryHistory({ initialEntries: [launcherPath], }); diff --git a/web/packages/teleport/src/AppLauncher/AppLauncher.tsx b/web/packages/teleport/src/AppLauncher/AppLauncher.tsx index ef15ab6e954c2..fa1c7dee680c6 100644 --- a/web/packages/teleport/src/AppLauncher/AppLauncher.tsx +++ b/web/packages/teleport/src/AppLauncher/AppLauncher.tsx @@ -30,7 +30,7 @@ import service from 'teleport/services/apps'; export function AppLauncher() { const { attempt, setAttempt } = useAttempt('processing'); - const params = useParams(); + const pathParams = useParams(); const { search } = useLocation(); const queryParams = new URLSearchParams(search); @@ -39,28 +39,6 @@ export function AppLauncher() { const port = location.port ? `:${location.port}` : ''; try { - if (!fqdn) { - const app = await service.getAppFqdn(params); - fqdn = app.fqdn; - } - - // Decode URL encoded values from the ARN. - if (params.arn) { - params.arn = decodeURIComponent(params.arn); - } - - const session = await service.createAppSession(params); - - // Setting cookie - await fetch(`https://${fqdn}${port}/x-teleport-auth`, { - method: 'POST', - credentials: 'include', - headers: { - 'X-Cookie-Value': session.cookieValue, - 'X-Subject-Cookie-Value': session.subjectCookieValue, - }, - }); - let path = ''; if (queryParams.has('path')) { path = queryParams.get('path'); @@ -74,7 +52,33 @@ export function AppLauncher() { } } - window.location.replace(`https://${fqdn}${port}${path}`); + // Let the target app know of a new auth exchange. + const stateToken = queryParams.get('state'); + if (!stateToken) { + initiateNewAuthExchange({ fqdn, port, path, params }); + return; + } + + // Continue the auth exchange. + + if (params.arn) { + params.arn = decodeURIComponent(params.arn); + } + const session = await service.createAppSession(params); + + // Set all the fields expected by server to validate request. + const url = getXTeleportAuthUrl({ fqdn, port }); + url.searchParams.set('state', stateToken); + url.searchParams.set('subject', session.subjectCookieValue); + url.hash = `#value=${session.cookieValue}`; + + if (path) { + url.searchParams.set('path', path); + } + + // This will load an empty HTML with the inline JS containing + // logic to finish the auth exchange. + window.location.replace(url.toString()); } catch (err) { let statusText = 'Something went wrong'; @@ -93,8 +97,8 @@ export function AppLauncher() { }, []); useEffect(() => { - createAppSession(params); - }, [params]); + createAppSession(pathParams); + }, [pathParams]); if (attempt.status === 'failed') { return ; @@ -118,3 +122,61 @@ interface AppLauncherAccessDeniedProps { export function AppLauncherAccessDenied(props: AppLauncherAccessDeniedProps) { return ; } + +function getXTeleportAuthUrl({ fqdn, port }: { fqdn: string; port: string }) { + return new URL(`https://${fqdn}${port}/x-teleport-auth`); +} + +// initiateNewAuthExchange is the first step to gaining access to an +// application. +// +// It can be initiated in two ways: +// 1) user clicked our "launch" app button from the resource list +// screen which will route the user in-app to this launcher. +// 2) user hits the app endpoint directly (eg: cliking on a +// bookmarked URL), in which the server will redirect the user +// to this launcher. +function initiateNewAuthExchange({ + fqdn, + port, + params, + path, +}: { + fqdn: string; + port: string; + // params will only be defined if the user clicked our "launch" + // app button from the web UI. + // The route is formatted as (cfg.routes.appLauncher): + // "/web/launch/:fqdn/:clusterId?/:publicAddr?/:arn?" + params: UrlLauncherParams; + // path will only be defined, if a user hit the app endpoint + // directly. This path is created in the server. + // The path preserves both the path and query params of + // the original request. + path: string; +}) { + const url = getXTeleportAuthUrl({ fqdn, port }); + + if (path) { + url.searchParams.set('path', path); + } + + // Preserve "params" so that the initial auth exchange can + // reconstruct and redirect back to the original web + // launcher URL. + // + // These params are important when we create an app session + // later in the flow, where it enables the server to lookup + // the app directly. + if (params.clusterId) { + url.searchParams.set('cluster', params.clusterId); + } + if (params.publicAddr) { + url.searchParams.set('addr', params.publicAddr); + } + if (params.arn) { + url.searchParams.set('arn', params.arn); + } + + window.location.replace(url.toString()); +} diff --git a/web/packages/teleport/src/services/apps/apps.ts b/web/packages/teleport/src/services/apps/apps.ts index a9ecec2f6dd6a..bf5fc8bcc8d6b 100644 --- a/web/packages/teleport/src/services/apps/apps.ts +++ b/web/packages/teleport/src/services/apps/apps.ts @@ -48,8 +48,6 @@ const service = { }) .then(json => ({ fqdn: json.fqdn as string, - value: json.value as string, - subject: json.subject as string, cookieValue: json.cookie_value as string, subjectCookieValue: json.subject_cookie_value as string, }));