diff --git a/integration/helpers/cookies.go b/integration/helpers/cookies.go index bff65ede43b46..81f262d1dbe10 100644 --- a/integration/helpers/cookies.go +++ b/integration/helpers/cookies.go @@ -29,6 +29,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. @@ -47,6 +48,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 } @@ -60,6 +64,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/httplib/httpheaders.go b/lib/httplib/httpheaders.go index 930f7c1bbe869..af20ae6171d7c 100644 --- a/lib/httplib/httpheaders.go +++ b/lib/httplib/httpheaders.go @@ -217,8 +217,10 @@ func SetIndexContentSecurityPolicy(h http.Header, cfg proto.Features, urlPath st h.Set("Content-Security-Policy", cspString) } +// DELETE IN 17.0: Kept for legacy app access. var appLaunchCSPStringCache *cspCache = newCSPCache() +// DELETE IN 17.0: Kept for legacy app access. func getAppLaunchContentSecurityPolicyString(applicationURL string) string { if cspString, ok := appLaunchCSPStringCache.get(applicationURL); ok { return cspString @@ -236,6 +238,8 @@ func getAppLaunchContentSecurityPolicyString(applicationURL string) string { return cspString } +// DELETE IN 17.0: Kept for legacy app access. +// // SetAppLaunchContentSecurityPolicy sets the Content-Security-Policy header for /web/launch func SetAppLaunchContentSecurityPolicy(h http.Header, applicationURL string) { cspString := getAppLaunchContentSecurityPolicyString(applicationURL) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 69936e800eb17..e489af9b78174 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -325,7 +325,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 } @@ -535,8 +535,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] + ":*" @@ -545,6 +552,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { } else { httplib.SetIndexContentSecurityPolicy(w.Header(), cfg.ClusterFeatures, r.URL.Path) } + 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 0d9670b696e56..a7deb302d64e2 100644 --- a/lib/web/app/auth.go +++ b/lib/web/app/auth.go @@ -20,17 +20,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. @@ -39,12 +208,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. @@ -59,23 +237,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") @@ -111,3 +276,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 25a61378a6edb..f6e1118f4efce 100644 --- a/lib/web/app/handler.go +++ b/lib/web/app/handler.go @@ -28,6 +28,7 @@ import ( "net" "net/http" "net/url" + "path" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -130,8 +131,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.withCustomCORS(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) @@ -293,7 +300,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 } @@ -510,8 +517,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 } @@ -551,7 +558,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 } @@ -561,61 +568,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: +// +// 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). +// +// 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. // -// which will be constructed into a redirect URL using this form: -// - /web/launch/?path=&query= +// Example Flow: // -// where the final result for the example URL will be: -// - /web/launch/some-domain.com?path=%2Farbitrary%2Fpath&query=foo%3Dbar%26baz%3Dqux +// 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 // -// The URL is formed this way to help isolate the `fqdn` param -// from the rest of the URL. +// 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 // -// 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. +// which will be constructed into a redirect URL using this form: +// - /web/launch/?path=&query= // -// 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)) +// 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 4fc6b0aa63194..38f110f5dadf1 100644 --- a/lib/web/app/handler_test.go +++ b/lib/web/app/handler_test.go @@ -23,6 +23,7 @@ import ( "context" "crypto/tls" "crypto/x509/pkix" + "encoding/json" "fmt" "io" "net" @@ -74,6 +75,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 ) @@ -147,12 +318,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", }, }), }, @@ -181,7 +378,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", }, }), }, @@ -270,6 +467,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 @@ -532,6 +762,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 { @@ -560,38 +791,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 @@ -616,6 +815,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 } @@ -742,10 +945,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", @@ -791,12 +996,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 e5249ea3fa728..6735ae1d198a7 100644 --- a/lib/web/app/middleware.go +++ b/lib/web/app/middleware.go @@ -19,6 +19,9 @@ package app import ( + "bytes" + "encoding/json" + "io" "net/http" "net/url" "strconv" @@ -26,6 +29,7 @@ import ( "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + "github.com/gravitational/teleport" "github.com/gravitational/teleport/lib/utils" ) @@ -51,7 +55,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) @@ -65,12 +69,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 == "" { @@ -87,13 +92,47 @@ 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`. @@ -171,3 +210,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 9983405e69b03..bf7b4d119d64f 100644 --- a/lib/web/app/redirect.go +++ b/lib/web/app/redirect.go @@ -49,7 +49,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 91db97642a862..08b211f6fbdfd 100644 --- a/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx +++ b/web/packages/teleport/src/AppLauncher/AppLauncher.test.tsx @@ -28,50 +28,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', }, ]; @@ -83,6 +79,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 }; @@ -93,7 +94,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], @@ -109,7 +110,7 @@ describe('app launcher path is properly formed', () => { await waitFor(() => expect(window.location.replace).toHaveBeenCalledWith( - `https://grafana.localhost${expectedPath}` + `https://grafana.localhost/${expectedPath}` ) ); }); @@ -118,7 +119,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 553c2d9797188..5aa7926684cd0 100644 --- a/web/packages/teleport/src/AppLauncher/AppLauncher.tsx +++ b/web/packages/teleport/src/AppLauncher/AppLauncher.tsx @@ -32,7 +32,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); @@ -41,28 +41,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'); @@ -76,7 +54,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'; @@ -95,8 +99,8 @@ export function AppLauncher() { }, []); useEffect(() => { - createAppSession(params); - }, [params]); + createAppSession(pathParams); + }, [pathParams]); if (attempt.status === 'failed') { return ; @@ -120,3 +124,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 81a61a3a7c0e6..2c6f32cdab736 100644 --- a/web/packages/teleport/src/services/apps/apps.ts +++ b/web/packages/teleport/src/services/apps/apps.ts @@ -50,8 +50,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, }));