From b3d315918512dd89a2d94f6e85439cf258038b3c Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Fri, 6 Dec 2019 11:07:45 -0800 Subject: [PATCH] httputil : wrap handlers for additional context (#413) Signed-off-by: Bobby DeSimone --- authenticate/handlers.go | 103 +++++----- authenticate/handlers_test.go | 20 +- .../frontend/assets/html/dashboard.go.html | 41 ++-- internal/frontend/assets/html/error.go.html | 57 ++++-- internal/frontend/assets/html/footer.go.html | 12 -- internal/frontend/assets/html/header.go.html | 1 - internal/frontend/assets/img/error-24px.svg | 2 +- .../assets/img/pomerium_circle_96.svg | 3 + .../img/supervised_user_circle-24px.svg | 1 + internal/frontend/assets/style/main.css | 180 ++++++++++-------- internal/frontend/statik/statik.go | 2 +- internal/httputil/errors.go | 145 ++++++-------- internal/httputil/errors_test.go | 107 ++++++----- internal/httputil/handlers.go | 23 ++- internal/httputil/handlers_test.go | 26 +++ internal/httputil/router.go | 7 +- internal/httputil/router_test.go | 31 --- internal/middleware/middleware.go | 6 +- internal/middleware/middleware_test.go | 2 +- internal/urlutil/signed.go | 6 +- internal/urlutil/url.go | 2 +- proxy/forward_auth.go | 47 +++-- proxy/forward_auth_test.go | 24 +-- proxy/handlers.go | 64 +++---- proxy/handlers_test.go | 12 +- proxy/middleware.go | 30 ++- proxy/proxy.go | 4 +- 27 files changed, 495 insertions(+), 463 deletions(-) delete mode 100644 internal/frontend/assets/html/footer.go.html create mode 100644 internal/frontend/assets/img/pomerium_circle_96.svg create mode 100644 internal/frontend/assets/img/supervised_user_circle-24px.svg diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 673d0c0bf..c084d87cf 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -32,12 +32,12 @@ func (a *Authenticate) Handler() http.Handler { csrf.UnsafePaths([]string{callbackPath}), // enforce CSRF on "safe" handler csrf.FormValueName("state"), // rfc6749 section-10.12 csrf.CookieName(fmt.Sprintf("%s_csrf", a.cookieOptions.Name)), - csrf.ErrorHandler(http.HandlerFunc(httputil.CSRFFailureHandler)), + csrf.ErrorHandler(httputil.HandlerFunc(httputil.CSRFFailureHandler)), )) - r.HandleFunc("/robots.txt", a.RobotsTxt).Methods(http.MethodGet) + r.Path("/robots.txt").HandlerFunc(a.RobotsTxt).Methods(http.MethodGet) // Identity Provider (IdP) endpoints - r.HandleFunc("/oauth2/callback", a.OAuthCallback).Methods(http.MethodGet) + r.Path("/oauth2/callback").Handler(httputil.HandlerFunc(a.OAuthCallback)).Methods(http.MethodGet) // Proxy service endpoints v := r.PathPrefix("/.pomerium").Subrouter() @@ -56,13 +56,13 @@ func (a *Authenticate) Handler() http.Handler { v.Use(middleware.ValidateSignature(a.sharedKey)) v.Use(sessions.RetrieveSession(a.sessionLoaders...)) v.Use(a.VerifySession) - v.HandleFunc("/sign_in", a.SignIn) - v.HandleFunc("/sign_out", a.SignOut) + v.Path("/sign_in").Handler(httputil.HandlerFunc(a.SignIn)) + v.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut)) // programmatic access api endpoint api := r.PathPrefix("/api").Subrouter() api.Use(sessions.RetrieveSession(a.sessionLoaders...)) - api.HandleFunc("/v1/refresh", a.RefreshAPI) + api.Path("/v1/refresh").Handler(httputil.HandlerFunc(a.RefreshAPI)) return r } @@ -70,23 +70,22 @@ func (a *Authenticate) Handler() http.Handler { // VerifySession is the middleware used to enforce a valid authentication // session state is attached to the users's request context. func (a *Authenticate) VerifySession(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { state, err := sessions.FromContext(r.Context()) if errors.Is(err, sessions.ErrExpired) { if err := a.refresh(w, r, state); err != nil { log.FromRequest(r).Info().Err(err).Msg("authenticate: verify session, refresh") - a.reauthenticateOrFail(w, r, err) - return + return a.reauthenticateOrFail(w, r, err) } // redirect to restart middleware-chain following refresh httputil.Redirect(w, r, urlutil.GetAbsoluteURL(r).String(), http.StatusFound) - return + return nil } else if err != nil { log.FromRequest(r).Info().Err(err).Msg("authenticate: verify session") - a.reauthenticateOrFail(w, r, err) - return + return a.reauthenticateOrFail(w, r, err) } next.ServeHTTP(w, r) + return nil }) } @@ -109,11 +108,10 @@ func (a *Authenticate) RobotsTxt(w http.ResponseWriter, r *http.Request) { } // SignIn handles to authenticating a user. -func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { +func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error { redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI)) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("malformed redirect_uri", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } jwtAudience := []string{a.RedirectURL.Hostname(), redirectURL.Hostname()} @@ -123,8 +121,7 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { if callbackStr := r.FormValue(urlutil.QueryCallbackURI); callbackStr != "" { callbackURL, err = urlutil.ParseAndValidateURL(callbackStr) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } jwtAudience = append(jwtAudience, callbackURL.Hostname()) } else { @@ -141,16 +138,14 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { s, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } // user impersonation if impersonate := r.FormValue(urlutil.QueryImpersonateAction); impersonate != "" { s.SetImpersonation(r.FormValue(urlutil.QueryImpersonateEmail), r.FormValue(urlutil.QueryImpersonateGroups)) if err := a.sessionStore.SaveSession(w, r, s); err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } } @@ -162,8 +157,8 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { newSession.Programmatic = true encSession, err := a.encryptedEncoder.Marshal(newSession) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) + } callbackParams.Set(urlutil.QueryRefreshToken, string(encSession)) callbackParams.Set(urlutil.QueryIsProgrammatic, "true") @@ -172,8 +167,7 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { // sign the route session, as a JWT signedJWT, err := a.sharedEncoder.Marshal(newSession.RouteSession()) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } // encrypt our route-based token JWT avoiding any accidental logging @@ -190,28 +184,28 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { // proxy's callback URL which is responsible for setting our new route-session uri := urlutil.NewSignedURL(a.sharedKey, callbackURL) httputil.Redirect(w, r, uri.String(), http.StatusFound) + return nil } // SignOut signs the user out and attempts to revoke the user's identity session // Handles both GET and POST. -func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { +func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { session, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } a.sessionStore.ClearSession(w, r) err = a.provider.Revoke(r.Context(), session.AccessToken) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("could not revoke user session", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI)) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("malformed redirect_uri", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) + } httputil.Redirect(w, r, redirectURL.String(), http.StatusFound) + return nil } // reauthenticateOrFail starts the authenticate process by redirecting the @@ -224,11 +218,10 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { // https://openid.net/specs/openid-connect-core-1_0-final.html#AuthRequest // https://tools.ietf.org/html/rfc6749#section-4.2.1 // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest -func (a *Authenticate) reauthenticateOrFail(w http.ResponseWriter, r *http.Request, err error) { +func (a *Authenticate) reauthenticateOrFail(w http.ResponseWriter, r *http.Request, err error) error { // If request AJAX/XHR request, return a 401 instead . if reqType := r.Header.Get("X-Requested-With"); strings.EqualFold(reqType, "XmlHttpRequest") { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return + return httputil.NewError(http.StatusUnauthorized, err) } a.sessionStore.ClearSession(w, r) redirectURL := a.RedirectURL.ResolveReference(r.URL) @@ -239,19 +232,20 @@ func (a *Authenticate) reauthenticateOrFail(w http.ResponseWriter, r *http.Reque b = append(b, enc...) encodedState := base64.URLEncoding.EncodeToString(b) httputil.Redirect(w, r, a.provider.GetSignInURL(encodedState), http.StatusFound) + return nil } // OAuthCallback handles the callback from the identity provider. // // https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowSteps // https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse -func (a *Authenticate) OAuthCallback(w http.ResponseWriter, r *http.Request) { +func (a *Authenticate) OAuthCallback(w http.ResponseWriter, r *http.Request) error { redirect, err := a.getOAuthCallback(w, r) if err != nil { - httputil.ErrorResponse(w, r, fmt.Errorf("oauth callback : %w", err)) - return + return fmt.Errorf("oauth callback : %w", err) } httputil.Redirect(w, r, redirect.String(), http.StatusFound) + return nil } func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) (*url.URL, error) { @@ -259,12 +253,12 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) // // first, check if the identity provider returned an error if idpError := r.FormValue("error"); idpError != "" { - return nil, httputil.Error(idpError, http.StatusBadRequest, fmt.Errorf("identity provider: %v", idpError)) + return nil, httputil.NewError(http.StatusBadRequest, fmt.Errorf("identity provider: %v", idpError)) } // fail if no session redemption code is returned code := r.FormValue("code") if code == "" { - return nil, httputil.Error("identity provider returned empty code", http.StatusBadRequest, nil) + return nil, httputil.NewError(http.StatusBadRequest, fmt.Errorf("identity provider returned empty code")) } // Successful Authentication Response: rfc6749#section-4.1.2 & OIDC#3.1.2.5 @@ -277,20 +271,19 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) // state includes a csrf nonce (validated by middleware) and redirect uri bytes, err := base64.URLEncoding.DecodeString(r.FormValue("state")) if err != nil { - return nil, httputil.Error("malformed state", http.StatusBadRequest, err) + return nil, httputil.NewError(http.StatusBadRequest, err) } // split state into concat'd components // (nonce|timestamp|redirect_url|encrypted_data(redirect_url)+mac(nonce,ts)) statePayload := strings.SplitN(string(bytes), "|", 3) if len(statePayload) != 3 { - return nil, httputil.Error("'state' is malformed", http.StatusBadRequest, - fmt.Errorf("state malformed, size: %d", len(statePayload))) + return nil, httputil.NewError(http.StatusBadRequest, fmt.Errorf("state malformed, size: %d", len(statePayload))) } // verify that the returned timestamp is valid if err := cryptutil.ValidTimestamp(statePayload[1]); err != nil { - return nil, httputil.Error(err.Error(), http.StatusBadRequest, err) + return nil, httputil.NewError(http.StatusBadRequest, err) } // Use our AEAD construct to enforce secrecy and authenticity: @@ -299,12 +292,12 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) b := []byte(fmt.Sprint(statePayload[0], "|", statePayload[1], "|")) redirectString, err := cryptutil.Decrypt(a.cookieCipher, []byte(statePayload[2]), b) if err != nil { - return nil, httputil.Error("'state' has invalid hmac", http.StatusBadRequest, err) + return nil, httputil.NewError(http.StatusBadRequest, err) } redirectURL, err := urlutil.ParseAndValidateURL(string(redirectString)) if err != nil { - return nil, httputil.Error("'state' has invalid redirect uri", http.StatusBadRequest, err) + return nil, httputil.NewError(http.StatusBadRequest, err) } // OK. Looks good so let's persist our user session @@ -317,29 +310,25 @@ func (a *Authenticate) getOAuthCallback(w http.ResponseWriter, r *http.Request) // RefreshAPI loads a global state, and attempts to refresh the session's access // tokens and state with the identity provider. If successful, a new signed JWT // and refresh token (`refresh_token`) are returned as JSON -func (a *Authenticate) RefreshAPI(w http.ResponseWriter, r *http.Request) { +func (a *Authenticate) RefreshAPI(w http.ResponseWriter, r *http.Request) error { s, err := sessions.FromContext(r.Context()) if err != nil && !errors.Is(err, sessions.ErrExpired) { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } newSession, err := a.provider.Refresh(r.Context(), s) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusInternalServerError, err)) - return + return err } newSession = newSession.NewSession(s.Issuer, s.Audience) encSession, err := a.encryptedEncoder.Marshal(newSession) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusInternalServerError, err)) - return + return err } signedJWT, err := a.sharedEncoder.Marshal(newSession.RouteSession()) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusInternalServerError, err)) - return + return err } var response struct { JWT string `json:"jwt"` @@ -350,9 +339,9 @@ func (a *Authenticate) RefreshAPI(w http.ResponseWriter, r *http.Request) { jsonResponse, err := json.Marshal(&response) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } w.Header().Set("Content-Type", "application/json") w.Write(jsonResponse) + return nil } diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 82a87db5f..508a132b1 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/mock" @@ -154,8 +156,7 @@ func TestAuthenticate_SignIn(t *testing.T) { r = r.WithContext(ctx) w := httptest.NewRecorder() - - a.SignIn(w, r) + httputil.HandlerFunc(a.SignIn).ServeHTTP(w, r) if status := w.Code; status != tt.wantCode { t.Errorf("handler returned wrong status code: got %v want %v %s", status, tt.wantCode, uri) t.Errorf("\n%+v", w.Body) @@ -186,9 +187,9 @@ func TestAuthenticate_SignOut(t *testing.T) { wantBody string }{ {"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusFound, ""}, - {"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"error\":\"could not revoke user session\"}\n"}, - {"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"error\":\"Bad Request\"}\n"}, - {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"error\":\"malformed redirect_uri\"}\n"}, + {"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: OH NO\"}\n"}, + {"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: error\"}\n"}, + {"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "sig", "ts", identity.MockProvider{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@pomerium.io", AccessToken: &oauth2.Token{Expiry: time.Now().Add(10 * time.Second)}}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: corp.pomerium.io/ url does contain a valid scheme\"}\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -211,8 +212,7 @@ func TestAuthenticate_SignOut(t *testing.T) { r.Header.Set("Accept", "application/json") w := httptest.NewRecorder() - - a.SignOut(w, r) + httputil.HandlerFunc(a.SignOut).ServeHTTP(w, r) if status := w.Code; status != tt.wantCode { t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode) } @@ -299,8 +299,7 @@ func TestAuthenticate_OAuthCallback(t *testing.T) { r := httptest.NewRequest(tt.method, u.String(), nil) r.Header.Set("Accept", "application/json") w := httptest.NewRecorder() - - a.OAuthCallback(w, r) + httputil.HandlerFunc(a.OAuthCallback).ServeHTTP(w, r) if w.Result().StatusCode != tt.wantCode { t.Errorf("Authenticate.OAuthCallback() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantCode, w.Body.String()) return @@ -366,7 +365,6 @@ func TestAuthenticate_SessionValidatorMiddleware(t *testing.T) { got.ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("VerifySession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) - } }) } @@ -417,7 +415,7 @@ func TestAuthenticate_RefreshAPI(t *testing.T) { r.Header.Set("Accept", "application/json") w := httptest.NewRecorder() - a.RefreshAPI(w, r) + httputil.HandlerFunc(a.RefreshAPI).ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("VerifySession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) diff --git a/internal/frontend/assets/html/dashboard.go.html b/internal/frontend/assets/html/dashboard.go.html index e59ec53c5..9d965f54f 100644 --- a/internal/frontend/assets/html/dashboard.go.html +++ b/internal/frontend/assets/html/dashboard.go.html @@ -10,21 +10,21 @@
- {{if .Session.Picture }} - user image - {{else}} - - {{end}} +
+

Current user

+ {{if .Session.Picture }} + user image + {{else}} + + {{end}} +
-

Current user

Your current session details.

{{if .Session.Name}} @@ -189,11 +189,23 @@
+
+
+ {{if .IsAdmin}} + +
+
+
+

Sign-in-as

+ +
- {{if .IsAdmin}}
-

Sign-in-as

Administrators can temporarily impersonate another user.

@@ -235,7 +247,6 @@ {{ end }}
- {{template "footer.html"}} diff --git a/internal/frontend/assets/html/error.go.html b/internal/frontend/assets/html/error.go.html index c662f3725..56523adcf 100644 --- a/internal/frontend/assets/html/error.go.html +++ b/internal/frontend/assets/html/error.go.html @@ -2,34 +2,57 @@ - {{.Code}} - {{.Title}} + {{.Status}} - {{.StatusText}} {{template "header.html"}}
- -

{{.Title}}

+
+ +

{{.StatusText}}

+

{{.Status}}

+
-

- {{if .Message}}{{.Message}}{{end}} {{if .CanDebug}}Troubleshoot - your - session.{{end}} {{if .RequestID}} - Request {{.RequestID}}{{end}} -

+
+
{{.Error}}
+
+ {{if .CanDebug}} +
+ If you should have access, contact your administrator and provide + them with your + request details. +
+ + {{end}} {{if.RetryURL}} +
+ If you believe the error is temporary, you can + retry the request. +
+ {{end}}
+
- {{template "footer.html"}}
- {{end}} diff --git a/internal/frontend/assets/html/footer.go.html b/internal/frontend/assets/html/footer.go.html deleted file mode 100644 index beff39223..000000000 --- a/internal/frontend/assets/html/footer.go.html +++ /dev/null @@ -1,12 +0,0 @@ -{{define "footer.html"}} - -{{end}} diff --git a/internal/frontend/assets/html/header.go.html b/internal/frontend/assets/html/header.go.html index c21cbb6c0..ffd289643 100644 --- a/internal/frontend/assets/html/header.go.html +++ b/internal/frontend/assets/html/header.go.html @@ -8,5 +8,4 @@ type="text/css" href="/.pomerium/assets/style/main.css" /> - {{end}} diff --git a/internal/frontend/assets/img/error-24px.svg b/internal/frontend/assets/img/error-24px.svg index 6c2f5fbe2..1b58ba1cc 100644 --- a/internal/frontend/assets/img/error-24px.svg +++ b/internal/frontend/assets/img/error-24px.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/internal/frontend/assets/img/pomerium_circle_96.svg b/internal/frontend/assets/img/pomerium_circle_96.svg new file mode 100644 index 000000000..8e879a9af --- /dev/null +++ b/internal/frontend/assets/img/pomerium_circle_96.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/frontend/assets/img/supervised_user_circle-24px.svg b/internal/frontend/assets/img/supervised_user_circle-24px.svg new file mode 100644 index 000000000..074db6b32 --- /dev/null +++ b/internal/frontend/assets/img/supervised_user_circle-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/frontend/assets/style/main.css b/internal/frontend/assets/style/main.css index 9ffb43a67..0f8509059 100644 --- a/internal/frontend/assets/style/main.css +++ b/internal/frontend/assets/style/main.css @@ -7,35 +7,50 @@ box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; - font-size: 15px; + font-size: 1rem; line-height: 1.4em; } +.primary { + color: #6e43e8; +} +.light { + /* a571ff */ + color: rgb(165, 113, 255); +} +.dark { + /* #422D66 */ + color: rgb(66, 45, 102); +} + +.text-monospace { + font-size: 0.85rem; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace !important; +} + body { display: flex; flex-direction: row; align-items: center; - background: #f8f8ff; -} - -#main { - width: 100%; - height: 100vh; - text-align: center; - display: flex; - flex-direction: column; + background: rgba(165, 113, 255, 0.05); + color: rgb(33, 37, 41); justify-content: space-between; } -#info-box { - max-width: 480px; - width: 480px; - margin-top: 200px; - margin-right: auto; - margin-bottom: 0px; - margin-left: auto; +#main { + display: flex; + flex-direction: column; justify-content: center; - flex-grow: 1; + width: 100%; + min-height: 100vh; +} + +#info-box { + justify-content: center; + align-items: center; + display: flex; + padding-bottom: 2.2rem; } section { @@ -43,46 +58,49 @@ section { flex-direction: column; position: relative; text-align: left; + min-height: 12em; } h1 { - font-size: 36px; + font-size: 2.5rem; font-weight: 400; text-align: center; letter-spacing: 0.3px; text-transform: uppercase; - color: #32325d; + color: rgb(110, 67, 232); } h1.title { text-align: center; - background: #f8f8ff; - margin: 15px 0; + padding: 0.75rem 1.25rem; } h2 { - margin: 15px 0; - color: #32325d; + text-align: left; + color: #333333; text-transform: uppercase; letter-spacing: 0.3px; - font-size: 18px; + font-size: 1.25rem; font-weight: 650; - padding-top: 20px; } .card { - margin: 0 -30px; - padding: 20px 30px 30px; + border-radius: 0.5rem; border-radius: 4px; - border: 1px solid #e8e8fb; - background-color: #f8f8ff; + border: 1px solid rgba(0, 0, 0, 0.125); + flex-grow: 1; + flex-shrink: 1; + margin: 0 -30px; + max-width: 40%; + min-width: 480px; + padding: 1.25rem 1.25rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); } fieldset { margin-bottom: 20px; - background: #fcfcff; - box-shadow: 0 1px 3px 0 rgba(50, 50, 93, 0.15), - 0 4px 6px 0 rgba(112, 157, 199, 0.15); + background: #fcfcff5d; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); border-radius: 4px; border: none; font-size: 0; @@ -100,7 +118,7 @@ fieldset label { } fieldset label:not(:last-child) { - border-bottom: 1px solid #f0f5fa; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); } fieldset label span { @@ -109,55 +127,24 @@ fieldset label span { text-align: right; } -#group { - display: flex; - align-items: center; -} - -#group::before { - display: inline-flex; - content: ""; - height: 15px; - background-position: -1000px -1000px; - background-repeat: no-repeat; -} - -.icon { - display: inline-table; - margin-top: -72px; - text-align: center; - width: 75px; - height: auto; +img.icon { + width: auto; + height: 36px; border-radius: 50%; } -.icon svg { - fill: #6e43e8; - background: red; -} - -.logo { - padding-bottom: 20px; - padding-top: 20px; - width: 115px; - height: auto; -} - -p.message { - margin-top: 10px; - margin-bottom: 10px; - padding-bottom: 20px; +.message { + padding: 2.55rem 0.75rem; } .field { flex: 1; padding: 0 15px; - background: transparent; font-weight: 400; - color: #31325f; + color: rgb(66, 45, 102); + background: #fcfcff5d; outline: none; cursor: text; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -177,7 +164,6 @@ fieldset .select::after { input { border-style: none; outline: none; - color: #313b3f; } select { @@ -207,7 +193,6 @@ select { border-radius: 4px; border: 0; font-weight: 700; - width: 50%; height: 40px; outline: none; cursor: pointer; @@ -234,6 +219,49 @@ select { background: #5735b5; } -.powered-by-pomerium { - align-items: center; +.footer-icon { + display: inline-table; + margin-top: -12px; + height: 24px; + width: auto; + vertical-align: top; +} + +.text-muted { + color: #6c757d !important; + font-size: 0.75rem; +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: left; + margin-top: 0px; + margin-right: -1.25rem; + margin-bottom: -1.25rem; + margin-left: -1.25rem; + padding: 0.75rem 1.25rem; + background-color: rgba(0, 0, 0, 0.03); + border-top: 1px solid rgba(0, 0, 0, 0.125); + border-bottom-right-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0px; + margin-right: -1.25rem; + margin-top: -1.25rem; + margin-left: -1.25rem; + padding: 0.75rem 1.25rem; + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); + border-top-right-radius: 0.5rem; + border-top-left-radius: 0.5rem; +} + +.text-right { + text-align: right; } diff --git a/internal/frontend/statik/statik.go b/internal/frontend/statik/statik.go index 32a87ff45..31843d840 100644 --- a/internal/frontend/statik/statik.go +++ b/internal/frontend/statik/statik.go @@ -7,6 +7,6 @@ import ( ) func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00|\xa6{O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x16\x00 \x00html/dashboard.go.htmlUT\x05\x00\x01\xed\xe1\xde]\xecYKo\xe36\x10\xbe\xe7WL\x89=6R\x90\xee\xa1X\xc8F\x83}\x14\xb94\x01\x92=\xec)\xa0\xa5\x914\x05\x1f*I%\x0e\x0c\xff\xf7B\x0f;z9\x96\x1d%\x1b\x14\xcd%\x129\x1f\xc9\xf9>r\x86\x1e\xadV\x11\xc6\xa4\x10X\xc4m\xba\xd0\xdcD^\xea\xa4`\xeb\xf5I\xf0\xcb\x97\xab\xcf\xb7?\xae\xbfB\xd12? \x8a\x7f \xb8Jf\x0c\x15\x830\xe5\xc6\xa2\x9b\xb1\xdc\xc5\xa7\xbf\xb3\xf9 @\x90\"\x8f\x8a\x07\x80\xc0\x91\x138\xbf\xd6\x12\x0d\xe52\xf0\xab\xf7\xb2o\xb5r(3\xc1\x1d\x02+\x10h\xb6\x93\x02\x04~5H\xf1\xb8\xd0\xd1c=\\D\xf7@\xd1\x8cIN\x8aUm\x8dVR\xb1>]\xe8\xe5\xb6\xa7\xee\x0b\x05\xb7v\xc6Bn\xa2FW\xb1\x02\x8a\xc1\xbbAkI+\xef\x9aB\x97\x1b\x84r\xfe-\x9ed\xb2\xc1S\xa8\x15\x03k\xc2\x19[\xad\xba\xb0\xf5\x9a\x01\x17\x05\x0f\x16\x0d\x90\xe4 2\xf0\xdb\xb3\xa1\xb0\xd8\x1b\xbd\xf1\n\xad\x99Z\x1d\xe5\xac\xbe\x97\xd5D\xfa\xdcZt\xd6'\x99\xf8<\x0cu\xae\xdc]H&\x14xz\xfe1[z\xf6>i\xe3\x97R(;c\xa9s\xd9'\xdf\x7fxx\xf0\x1e~\xf3\xb4I\xfc\xf3\xb3\xb33\xbfg\xfe@\x91Kg\xec\xfcc\xbb9EJR\xd7m\xefz\xa9\xa2\xf5\xfa\xa4\xe9e\xac\x8d\x04\x89.\xd5\xd1\x8c]_\xdd\xdc2\xe0\xa1#\xadZ.YJ\xd4\x9d\xce]K\"\x80\xc0bi\xdbn-v\xd9\xf9\xfcsn\x0c*\x07\x05\xe7\x81\x9f\x9e\xf7l\xb2\x0d\xa3\x12\xad-$\x99\xff\xd0\xb9\x81\xb0\xc6\xd9JC\x88\xd0q\x12\xd6\x0b\xfc\xac7DL(\"\x8b\xae\xdb\xd1\xdd>\x7fq\xd9\x16\xb7\xc6\x0b\xbe@\xd1\x07\x17\x8ee\\\xcd\x0bX\xe0\x97\x8fC6\xa4\xb2\xdc\x0dt\x00\xb8\xc7\x0cg\xcc\xe1\xd2\xb1\xc1\xfe\xda\xf1r\xf9\xc3\x16\xf7\\\xe4\xd8\xda\xcb\x95\x0f\xc3\xd6\xe5\xc9\x1dm\x1d\x91\xe5\x0b\x81\xd1@\xa7\xdfw4\xf0w\xb0\xb493\x1d\xae\xff\xa4{TG\x12^b\xe1]\xd1\xdepg,\xf7{!\xd3 P\x1c\xe7\x0e\xff\xdf\xb8$\xf1x\xa4\x00\x15\xf8})\xd0th\xac\x04\xfb1\x93k0\xa4\xc5M\xbe\xf8\x1bCw\x84\x10\xdf-\x9a\xcb/\xefF\x83\xad#c\x05\xd8\x03\x98\x9c\xfd&\xeb_%'q\x04\xe7%\xee\x05\x94c\x81\x9f\x8e\xf3\xda\x8d\xb1\x8c?k\xfe\xaa|\x17{\xf5\x08\xba\xbf\x97\x17\x83\xa3\xd9\x9ev\x83W>\x8c\xe5\xfa9\xeb\xc9\xa96\\%\x08\x1f\xe8\xd7\x0fw\x9ff\x8d\x10\xedz\xee\xbd\x11\xc7;.d\x8d\xf5\xbc\xde\xdd\xac-\xc8\xff7\xb5\x81\xbfi\xf5>\xe9\x9a\x0f\xd7\x1c\x03\x7f\xb0\x14\xda,q\xc7\x02\x97\xac;O\x11\x1dBk\xe2o\xc5\xa8\xd0\x9fn\x91;\xa7\xd5f\x88\xfa-\xce\x85`5\xdb6_Hrl~C\x89\x82\xab\xdc\x05~e\xd4]^D\xf7\xcd\xa6\xc0\x8f\xb5\x91\xf3f)\xb8\n\x1e\x97\xf6\"\x92\xa4\xda\xa5\xf0qEbz:\x00\x07\xd4\x89\x8b\x85\x9f\x92:\xe5vd\x95\xb8\xa7T\xb9`\xb2\xcep\xa7\x8d\x85\x90+p(3m\xb8!\xf1\x08\x8de\x01W\xda\xa5h\xca\xa2\xb4\xd7\x93\xf6\x90\x02\xf3\xbe\xc8y|\xacT\\\xd6i\xa3\x1b\xe1v\xa6\x91\xe9\xea\x0f\xc3\x9d\x99\xe0!\xa6ZDh\xaa\x8f(\x7f\xe0\x92\xcbL\xa0\x17j9\x049\xe4\xa0\xedcrO\x90\xdb\xcb$t\xa8\xac\x82\xf3\x1e.'\x08b#\xa8D\x95\x90B4\xa4\x92\x97\xb1\xf8s\xc2Rou\x83[\xf7\xa2\\\xc0 \xdf5S\x16\x07\xb8\x1e\x08y=\x9bV\x08\xec\xf4\xf6\xb9k\xac\xa9G\xdfAA\xf3\xa9e\xb5\x02T-\x82Z\xa0\xd6K\xf3\x9bj\xac\xb5k}Sm\xd8\x06~\xf5Q5\xf0\xab\xaf\xba\x9bD\xf4o\x00\x00\x00\xff\xffPK\x07\x08\x0bHi\xe3[\x04\x00\x00\n\x1e\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00html/error.go.htmlUT\x05\x00\x01Hr\xdc]dS\xc1\x8e\xdb \x10\xbd\xe7+\xa6\xdcc\xb2\xe9\x1e\xaa\n\xfb\x92\xf4\xd0C\xd5\xaa\xca\xa5Gb\xc6\x06\xc9\x06\x17p\x9c\x15\xe2\xdf+\xc7\xf6\x16\xb2\xbe\x18\xde{3\x0c\xcf\xcf!\x08l\x94F h\xad\xb1\x85\xf4}Gb\xdc\xb1O\xe7\x9f\xa7\xcb\x9f_\xdf`F\xaa\x1d\x9b_\xd0q\xdd\x96\x045\x81Zr\xeb\xd0\x97d\xf4\xcd\xfe\x0b\xa9v\x00L\"\x17\xf3\x02\x80y\xe5;\xacB(NF`\x8c\xb0\x87\x10\x8a\xcb\x0c\xc6\xc8\xe8\xc2>\x94!x\xec\x87\x8e{\x042\xd7\xe3\xff\x11\x00\x18\xddZ\xb2\xab\x11oko\xa1n\xa0DIz\xae4Y\xb0\x04U\xba1\xfb\xab\xb9\xbf3+Ww\xdc\xb9\x92\xd4\xdc\x8a\x84\x02`\xaao\x93-lBU\x1bM2\xc2\xd9\xba$\xb4\x18L\x8fV\x8d=\xe5\xce\xa1wT\xf5-}x\xb7?\xbe\x0e\xf7\xc2\xdd\xda\xbc\xec\xdew\xda\x95Dz?|\xa5t\x9a\xa6b\xfa\\\x18\xdb\xd2\xe3\xe1p\xa0\x1f\xe4\x93\x12^\x96\xe4\xf8\x9a\xc3\x12U+\xfd3N\xb3\x9b\xc8\x97m\xf8\x87\xc1\xa4J=\x97/\x99\xd6a\xed\x95\xd1Uv\x08\x1b\xb6\x06=:\xc7[$9?\x7f.\xd5@\xf1cac\x0c!]\xa3\x161\xae\x92\x13\xd7g\xbc\x8em\x8c\x17k\xc6k\x87N\x1a\xe3\x9f\xba\xbd\x99\xd1>A\x8c\x83\xb4\xd8dF\x93\xca\xa1s\xcahFyU\xe4\xe7\xfc\xc6\xbf#:\xff\xfd\xfc\x08L\xfa\xac\xcc\x1c\xbcD\xb4V\xe7\xd7\xa6C\xe6\x0d\xfd`\x0e\xa3B\xdd\xde\xa3\x96n\xd2\xfc6\xc6\xf8,\xbf\x89\x96\xd1%\xc1\x8c.\xff\xd3n\x1b\xe4_\x00\x00\x00\xff\xffPK\x07\x08)\xb7\xe1\x1d\x97\x01\x00\x00\x81\x03\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00 \x00html/footer.go.htmlUT\x05\x00\x01Hr\xdc]L\xcf\xc1n\xc3 \x0c\x06\xe0{\x9e\xc2\xf2\xbd8\xea\xb4\xcb\x04}\x17\xd68\x80\x14B\x84\xd9\xd8\x84x\xf7iM\x13\xf5\x06\xd6\xa7\xdf\xfe[\x9bx\x0e+\x03\xce)\x15\xce\xca\x97\xb8`\xef\x83\xde\xff\xb7\x01@[\xf0\x99g\x83\xbe\x94M>\x88j\xadjK\x91s\xf8\x8a*$\xfcG\x00:D\xf7x\x00\xdc\x17+bpK\x953O\x97\xcf\xdf\xcb\xc1\xf1)$\xdf\x0d\xd2\x99BV\x84\x8bP\x88\x8e\xced\xf9v\x07\xff\x89\xcb*\xfb\x01\xcf\xfd\xf5M\xa5\xec\xe8:\x8e#\xbd@\xcf\xc1\xf9b\xf0\xfa\xbeO\xe8Q\x80\xecm\xd0t4j\x8d\xd7\xa9\xf7\xe1/\x00\x00\xff\xffPK\x07\x08{\x07T\xee\xa8\x00\x00\x00\xfc\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00 \x00html/header.go.htmlUT\x05\x00\x01Hr\xdc]D\x8dKn\xc30\x0cD\xf7:\x85\xa0ub\xa1\xfb(wa\xed DT\xa4\x0d\x91\xce\x07\x86\xef^T\x9b.\xdf\xc3\x0c\xdeq,x\xb0\"\xa6\nZ\xd0\xa7\xea\xd2\xd2y\x86\x9b\xc0)\xc4\xa8$(\xe9\xc9xmk\xf7\x14b\x9cWu\xa8\x97\xf4\xe2\xc5kY\xf0\xe4\x19\xd7\x01\x97\xc8\xca\xce\xd4\xae6SC\xf9\xbaD\xa17\xcb.\xffb7\xf4A\xf4\xddPtM!\xdf\xc3\xad\xb1\xfe\x84\x18;ZI\xe6\x9f\x06\xab\xc0\xc8\xf9gCI\x8e\xb7\xe7\xd9\xec\xcf\xd4\x8eGIy\xdaVA\xe7]2\x99\xc1-\x8f_\x16b\x9d\xc62\xdfC8\x0e\xe8r\x9e\xe17\x00\x00\xff\xffPK\x07\x08j\x97\x93v\xaa\x00\x00\x00\xe8\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x00img/account_circle-24px.svgUT\x05\x00\x01Hr\xdc]<\x90Ao\x830\x0c\x85\xff\xcaSv\x8eq\x9c\x90\xc2Tz\xd8.\xbb\xec\xb4_0\xb5\x8c \xb5\xa5\x1a\x88T\xfc\xfa\xc9\x80&\x81\xfc\xf9\xe5=K\xf6q\x9c;\xee\x82?}qb\x02\x1a\x1e\x8f'\x84Nsn\xe96\xf4\xe3\x1c\xd8R\xba\xbe\xd6u)\xa5*\xbbj\xfakk\x88H=\xe7\x96\xa9t\x97d\x81\xa1L\x16\xbb\xd6\xd2\x83s\x17\xcb\xdbt\x0b,$\x04%(\x9fO\xd7\x9fdt \xfc)$\x06\xcd\xd0\x0fY\x98~\xbb\xbe\x0f\xe9\xbe\x8d\x9f\xe1\xe5\xcf\xe3~\xb7\xd9/\xfb\xa7\xe7\x87\x87\xcd\xd6\xaaZ\xc3\x1e\x1f\x1f?\x96P3\x12IA\x9190R92X\x83\xa0\xf6 \x90l\x89\xf4\x99\x91\xd3\x91-\x01E+\xc4H\x1a\x14\xad\xba\xe3r\xb2 \xb6\x04\xd3\xb4f\x99\x84\x9cv\n\x8c\x1c\x90\xf3\x02\xad\xf6\x82\xccKhHi\xf6\xa5Z|#7#~\x0d\xbfgd\x83\x04\xc5^'m\xac\x8c\xa2s`\xc4f\xcb\x16\xae`\xbb\x9c\x02TBBk\x13\xa34r\x13\xfd/0\xb8!\xeb\"\xc8b\xdeLn\x86w8\xd3=Z\xf6_)\x05T\x1d\x0bF;\x07\xe4\x1e\xd8\xe8X\xa1\xda/\xa7\xd0\xd0L<\xcb_-7m\x1f\xea\xb5\xfc\xedY)z\x86\x81!u\xac\xbc\x00F\xecC\xed\x1bf<$rs\xe3\xc1X\x0bjD\xc9\xd2\x90\xb2\xd3'\x97S4\xea\xad\x0e\xcb\xb2\xe8\x87*k@\xd6\xfb`\x12EK7\xa3\xaa?\xc9\xd2>#wb\x187\x8e\xf8\x19v\xc4;\x13v\x80\xe9\xd9\x9d?\xf3\xfb\xaa\xdf\"|s'E\xce\xc3\\\x1d\xb5\x04\xe4\xb20\xa4L\xc9\x08t\xb3r\"\x84TH\xe9\x06\xd8\xeda%A\xab\xc3\x0c\\\xd0\x9a\xd1\xa1Cm7WO\xee\xa4\xd6j\xab\x19p$\xe4r91\xd4\xf8\xd54\xb1)\xc1W9\x9c\x15\xad\x06\x94Y\x90\xeb\xe4W\xc9\xcd;k\xa6\xac\x16\xbf\xc5\x9a\xd0uQt\xe7z \x19}\\T\xe4> \xc9M\x9c\x94\xc3\xca\xcd\x87\xcb\xc4\x84\xd4\x11\x8d\x05\xcd\x13\xa3\x99@\xed&\x10\xb2\x06D\xcb\xa6\xad\xe6\x96C2\x02/\xa7P`\x9e\xc4?a\xbe\x96\xff\x8d\xfa\xf2\x1f1/ze\xbe\xa0TJh\x06H\xb0.\x13J\xa6\x81\x1e}\x16\x0e\x15l4uJ\xe4\x9a\xd9\x04\xd1\xfa\x03\x94\xf9\x18l\xf6Z\x93v\x88\x82m&u\xeb\xad\x9e>r\x10\xfb\xca\n\x13S1]\"\xfd\x18h\xb8\xf1e]\x97\xd0\xc5\x9a\xab\x8d\xe6\xea\xa8\xd9\x9f.\xf2\x19\x85i\x95|\x85\xac\x875\xb8\xa2Y\xc7\x08A\xb1-^\xa6CMG\xf1\xe2\xf7\xc1\x0b.\x8b\xa0\xb3\xcf\xe2n\xa32\xd6\xd9d\x9a\xc4^\xe0f\xa4l\x9f\x19\xb1\xb1'\x12\x9c*N\x8b\xa0\xf1l\xab\xbf\xa9a\xb16\x88j\xbe|\xf6qx\xff\xa0\xa9\xa02qB\x9f\x14]\xc9\xcd\xaa\x98\xad\x837\x87&\xf0'\x01\x06\xc8\xc2\x05\x9cf;k \x99\xa7l\xac\xb8\x19\xe93S\\\xcc3\xcb\xbf\x11\x81T/\xa7\xa0\x1d\x9a(\xe1\xe7\xa1\x97\x13\x0bz\xa6\xf8\x0f\xde\xf4\x99\xd7X\xda\x1e\xd6\xff\xf3r\xb8\xfb+\x00\x00\xff\xffPK\x07\x08K\xfe\x8b#M\x03\x00\x00d\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00style/main.cssUT\x05\x00\x01Hr\xdc]\x8cVMo\xe38\x12\xbd\xebW\x10 \x1aH\x16\xa6!\xd9V\xe2\xb0o{X\xec\x1ev\x0e\xd3\x98\xc3\x1c)\xa9hqB\x91\x02I\xc5v\x0f\xf2\xdf\x07$\xf5A\xc9J'@>l\xb1T\xacz\xf5\xeaU\xfd\x0b\xfd\x9d \xd4P}\xe2\x92\xa0\xf4{\x82PK\xab\x8a\xcbS\xff\x0d\x9f\xa1x\xe5\x163%-6\x8dR\xb6\xf6\x87TZN\x05\xa7\x06*o\xd6\xa8\x9fX\x99\xcb\x8d\xddI\xd3\xab)\xa9\x80\xd8\x99\x85\x8b\xc5\x86\xff\x04L\xab\xbf:c \x92Jz\x8bB]\xdc\x81\x7f\xb5P\xba\x02\x8d\x0buq'\xde1\xa3\x0d\x17W\x820m[\x01\xd8\\\x8d\x85f\x83\xfe-\xb8|\xfd?-\x7f\xf8\xef\xffQ\xd2n\xd0\xdd\x0f8)@\x7f\xfc\xefn\x83~W\x85\xb2j\x93 \x84\xd0\xdd\x7fA\xbc\x81\xe5%E\xbfA\x07w\x1bd\xa84\xd8\x80\xe6l\xbc\xc7\xc5FP\x96\xb7\xfej\xc1%\xe0\x1a\xf8\xa9\xb6\x04e\xdb\x034\xdf\x93\xf7$)Tu\xf5\xf0U\xdc\xb4\x82^ b\x02B\xac\x02.\xb8\xe2\x1aJ\xcb\x95$H\xab\xb3{L\x05?I\xcc-4\x86\xa0\x12\xa4\x05\xeds\xa6\xe5\xebI\xabNV\x04\xdd\xb3#;2\xe6\xfd\xdf7\x94K\x7f\xc1\x99W\xb6&(K\xd3o\xee\x851\x944}\xab\xdd\x03\x8f\xa7\xf7\x1e\xfb\xfd4\xacR\x89\xae\x91\xee\xc4\x15\x81\xb3+.\x95\xb4 -A\xa6\xa5%\xe0\x02\xec\x19@\x86h\xb8d\xca\x15\xa3g\xcc\x05\xf7Q\x1d\x8ei\x80i\xf9=\xb0\n[\xd5\x12\xb4K\xe7\x0fu\xc8\x80vVEO\x0be\xadj\x08\x9a\xdb\n`\x91\xe9M\xa8S\xc2>\xbf\x93Vg\x822\x1f\xb3 \x99~\xadH\x13\x1a\xad2\xbc/\x1c\x08j\xf9\x1b,Av!\xf9\x1b\xea\xcc;\x8fH\xb3\x7fj'\xbe\x9e\xfbJ\x1d\xd2\xf4\xe3: \xb0\x164v\x90\x87\xbe\xdb\xee\x83\x0bon5\x95\x86)\xdd\x10\xd4\xb5-\xe8\x92\x1a\x1fN\xa9\x84\xd2\x04\xdd\xefw\xfb]^\xf5\xc1l-\xb7\x02|H\xebw\xadrm\x12\x00\xc7x\xd7\xf7\xce\xd9n&\x0d\xc3\xc9\xed\xc5\x9f\xc4\xf9avq\xa3\x1dW0{\xcac9\x1ah\xe4\x0c\xdf\x93d[R]\xcd\xb5\x0b\xe1}\xcf\x9bQ\xc1\x9c9\xda\x0f\x7f|\xfeAS4\xadxg\x08:\xc4O \xca\xda\x0b2J\xf0\n\xdd\xc3\x11\x8e\xac\x98C\x86\x87\xd4\xa3&e\x1cDe\xc0F\xb1\x8c4\xde\x0d\x97\xce@/Y\x19@\xf7ZW\xd3\xca\xf15\xf5w\xef\x1d\xc6H\x9f\n\xfa\x90\xa7\x1b\xe4~_\xf6\x1b\x94n\xb3\xfc1\xa8W\xeaBFO\x93]\x96\xed6(\xcb\x9f7({y\xe9M?Ot\x90\xdb\xa8\x06\xe9<\x1dA\x0b\x10>\xa9\xf5n\xf8\xb4\x9dz\xcd\x1b\xb4\xea\xb0[\xd4&K\x07B} \x8b\xbfj\xf4\x9b\xde\xba \x9dHe\x1f\x88\xa0\xc6\xe2\xb2\xe6\xa2z\xf4\xb9\x8c#%\x14(\xaa7KY\xce\xe8\x1a\x06\xa6\xa5AA\x1a.\x07\xcd\xcbv\xf9\"\x9dt\x9c\x16q\xe3y\x99\x0b\xf2\xe9\x08\xd0\xae+\xd1*\x00\xe3;\x84\x14\xc0\x94\x86\xf9\xbb\\\xfa\xa94\xb8\x18A\xba\xbb\x8bQ\x1fb\x8a8<\x95\x13g\xa9S\xe5\xe1\xff\xc2NC\x0b\xd4O\xe6\xfec\xe8;^.\xf5\xb4\x8f\xc4\xd2\"\x0c\xf9X\xf6\xf1\xf3\xee\x16\x94\xa9\x8e=\x9a\xcf}\x94C\xd4\x83\xd4/8\x9c\xbb\xe97\xc6`\xdeNAz\xb9\x10\x04\xdd?\xc1a\x0f\xc7e\xb7i\x08\xca\xb8\x15\xea\xa4\x02\x9b{9Y\xf6\xe8\x8a\xccLs7[\x0d\xf0=I\xdam\x03\xc6\xd0\x13\xc4\xed\xef]d\xf3\x1962nq\xdd<\x0c\x17\xa9g_\xc8L\xc0\xc5O\xb2U\x9a\xc5iz\xedm\xa9\x06i\xd7\xdb#R\xedl\xbf\xcb\xbd\xfc\xa8\xce\xba\xc2MbPv\xda8\x1bW\xab\xef\x89K\xbf\xe6\x16\xbcr{\xab\xb3\xa6\xad\x7f\xf1\x0d4\x13N\xb5j^U \xc7\xfaN\x07 \x04o\x0d7\xf3~\xda\x1a\x10PZB(\xb3\xa0}\x8e\x0b\xdeN\xe4\xa4\x85Q\xa2\xb3\x10\x95\xe1e^\x84\x1e\x87~\x93\x18J\xe6\xc1\xcf\xc3\x9e4c\xe2\xa0?\x8a;\xf6ax\x03i\xcd\x90\xfc{\x92p\xd9v6\x16 c\xaf\"B\xe7\x16\xae \xd1b\xcf\xfa}\xc3\xe5\xb7,\xde\x17\xfd\x0d\xcb1m[\xa0\x9a\xca2>s\xeb\xf5\xda\xc1\xda\xb3O#\x9dJ\xdd\xa3\xf1K>yR\n\xb8|m\x8b\xeae\xdf?v\x84!h\xa0\xcd\x17v\xccm\xd1Y\xdb\xeb\xcb8k\xa7y\x19\x8f\xd1\xa8\xdfgct\x18\x8fkC4{\xdcD\x83\xd6[\xa4\x1b\xd4\xffl\xd3\xe3\x17\x06gz\xd3_\xcf\xa1\xbfz\x8e\xe6\xf3\x0d\xfd\xd0\xb3\xf2\xc3^\x8b\n0\x9b\x8ccOUP*M\x03\xb6#W{\x9c\xb65\x15ld[\xbf\xf9\xa6#\xfc\xa6\xd6\\\xbe\xc6O\nj\xb8\x1b1T\x94\x0fy\xfa\x0da\x7f\xd7\xe3\xcc'\xeb\x84X\xfa\xccb\x0bR\xbb>\x0f;\xe6\xb4\xf3\xf9\x8f\x82Z\xf8\xf3\x01g\xde\xe7\xb22\xcf\xed\x05e\x87\x0f7\x9cq\xc1\xd9\xcf\x17\x9ce\x85\xde\x93\xadb,\xacb\xa1]cZ\xe4\xcf\xfb\xbc\xc8C\xb8\xad:\x83\x86\n\x17W\xdc\xaa\x064\xef\x1a\xff\xc2\x07\xe3\xf6\x9f\x00\x00\x00\xff\xffPK\x07\x08z\xea\xdb\xd3\x10\x05\x00\x00\x0b\x0f\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00|\xa6{O\x0bHi\xe3[\x04\x00\x00\n\x1e\x00\x00\x16\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00html/dashboard.go.htmlUT\x05\x00\x01\xed\xe1\xde]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zO)\xb7\xe1\x1d\x97\x01\x00\x00\x81\x03\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xa8\x04\x00\x00html/error.go.htmlUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zO{\x07T\xee\xa8\x00\x00\x00\xfc\x00\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x88\x06\x00\x00html/footer.go.htmlUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zOj\x97\x93v\xaa\x00\x00\x00\xe8\x00\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81z\x07\x00\x00html/header.go.htmlUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zO\x83\xba\x83\xe4\xf5\x00\x00\x00|\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81n\x08\x00\x00img/account_circle-24px.svgUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zO\xd8\xca9F\xb9\x00\x00\x00\xf9\x00\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xb5 \x00\x00img/error-24px.svgUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zOK\xfe\x8b#M\x03\x00\x00d\x08\x00\x00\x10\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xb7\n\x00\x00img/pomerium.svgUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zOz\xea\xdb\xd3\x10\x05\x00\x00\x0b\x0f\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81K\x0e\x00\x00style/main.cssUT\x05\x00\x01Hr\xdc]PK\x05\x06\x00\x00\x00\x00\x08\x00\x08\x00Q\x02\x00\x00\xa0\x13\x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x08\x00@\xb0\x81O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x16\x00 \x00html/dashboard.go.htmlUT\x05\x00\x01X8\xe4]\xecYKo\xeb6\x13\xdd\xe7W\xcc'd\xf9Y\x0c\xd2.\x8a\x0b\xd9hp\x1fE6M\x80\xe4.\xee*\xa0\xa5\xb1<\x05\x1f*I9\x0e\x0c\xff\xf7B\x0f;z9\x96\x15%7(\xeaM$r\x0e9s\x0e\xc9aF\x9bM\x84\x0bR\x08^\xc4\xedr\xae\xb9\x89\xfc\xa5\x93\xc2\xdbn\xcf\x82\xff}\xb9\xf9|\xff\xe3\xf6+d-\xb3\xb3 \xfb\x03\x82\xabx\xea\xa1\xf2 \\rc\xd1M\xbd\xd4-&\xbfy\xb33\x80`\x89<\xca\x1e\x00\x02GN\xe0\xecVK4\x94\xca\x80\x15\xefy\xdff\xe3P&\x82;\x04/C\xa0\xd9O\n\x10\xb0b\x90\xecq\xae\xa3\xa7r\xb8\x88V@\xd1\xd4\x93\x9c\x94W\xb4UZI-\xf4d\xae\xd7\xfb\x9e\xb2/\x14\xdc\xda\xa9\x17r\x13U\xba\xda\x9d\x93\xc2\x8d\x9aM\x16\xce\xe5\xecsj\x0c*\x07\xa9E\x13\xb0\xe5e\xddb\xb3\xa1\x05\xf8wh-i\xe5\xdfR\xe8R\x83\x90\xc7Q\x19\x86d\xbc\x9b\x8cB\xad<\xb0&\x9cz\x9bM\x13\xb8\xddz\xc0E\xc6\xa8E\x03$y\x8c\x1e\xb0\xe6\x8c(,v\xccPk\x80\xda|\x8d\xae|v\xe6'\xa54\x8c[\x8b\xce2\x921\xe3a\xa8S\xe5\x1eB2\xa1\xc0\xc9\xe5\xaf\xc9\xda\xb7\xab\xb89\xc2Z\ne\xa7\xde\xd2\xb9\xe4\x13c\x8f\x8f\x8f\xfe\xe3/\xbe61\xbb\xbc\xb8\xb8`-@;\x04\x15\xd5\"\x08XD\xab\\\xf2}\xcbB\x1b \x12\xddRGS\xef\xf6\xe6\xee\xde\x03\x1e:\xd2\xaa\xe6\xba\xa5X=\xe8\xd45\x85\xb3\x98\xdb\xce\x1a~\x07\xc9\x8e\x17\x89\xd6f\xf4\xce~\xe8\xd4@X\x8al\x0b= B\xc7IX?`Ik\x88\x05\xa1\x88,\xbafGs1\xfc\xc9eS\xa6\x1c/\xf8\x1cE\x1b\x9c9\x9dp5\xcb`\x01\xcb\x1f\xbblH%\xa9\xeb\xe8\x00pO N=\x87k\xd7T\xab\xf8\x95\x81\xe7\xeew[\xac\xb8H\xb1\xb6.\x8b\x18\xba\xad\xf3\xfd\xdc\xdb:\"\xcb\xe7\x02\xa3\x8eN\xd6\x0e4`\x07X\xda\xad\xfe\x06\xd7\x7f\xd0\n\xd5@\xc2s,|(\xda+\xe1\xf4\xe5\xfe(d<\x01\xb2\xbd\xdb\xe0\xff\x1b\x97$\x9e\x06\nP\x80?\x96\x02\xd5\x80\xfaJp\x1c3\xba\x06]Z\xdc\xa5\xf3\xbf0t\x03\x84\xf8n\xd1\\\x7f\xf90\x1a\xec\x03\xe9+\xc0\x11\xc0\xe8\xecWY\xff*9\x89\x01\x9c\xe7\xb8WP\x8e\x19~<\xce\xcb0\xfa2\xfe\xa2\xf9\x9b\xf2\x9d\xad\xd5\x01t\x7f\xcfoq\x83\xd9\x1ew\x81\x171\xf4\xe5\xfa%\xeb\xd1\xa96\\\xc5\x08\xe7\xf4\xff\xf3\x87O\xd3J\x9e5:M\xecI\xbc\xe7\xb2\xe1\xdfpNp\xd1\x01\xdcg\xe1l\xe4\xc3\xcat\xdez\xab\xf8\x17\xa1\x8d\xdb\xe6\x1e\xf9>r\x1f\x97\xf8\xddd\xad\x9dX\xeb\x84\xcc\xd3IZ\x96GV\x0e\xfc0\xbb\xa8p\xc7\xbf\xa7Sru\x0f\xd0\x9b\x92\x7fmm\x8a\xd1\xd5\x90,]@?\x0c\xfd\xbbHN\x14\xa0\x17\xec\xed%\x18\x92C\n\xe0\xcf\x16\xa0\x1d\xc8\x11\xea\xfb\x02F'}\x9fK\xa0\x9eL\xae\xd2\x88P\x85\xa7\xfd\xcb\xd03\x9d\xec\xc6\xfe7e\x94\x8a\x92pH\xbc\xca\xf28h3\xba\xc0\xb5]%\x134V+\xeep\xe8\x95\xf8y\x08R1\xbc\xf6\x82\xfc\x1a\xc6;N\xbbVx\xef\xc4\xf1\x81\x0bY\xc5\x9f\xb7\xbb\x9b\xd5\x05\xf9\xef\xa6\xd6\xf1\x1bW\xef\xb3\xa6yw\xcd1`\x9de\xcejm{!p\xed5\xe7\xc9N\x87\xd0\x9a\xc5\xb7l\xd4f\xb1\x1a \x98\xa7\xcei\xb5\x1b\xa2|[\xa4Bx%\xdb6\x9dKr\xde\xec\x8eb\x057\xa9\x0bXa\xd4t//\xeaV\x1b\x16\xda\xc8\xe7\x96\x9aA\xed\xa58S\xae\xedU$Im\xb7\xbb\xc2\xf0;\x94\xfb\xb3\x90&\xa4&\xdc\xb6\x8b\xfd\xa3U\xd9m\x9a\xa0Y\x91\xc5\xe8!\xb5h\xde\xb2\xda>\xb8\xb2N\xcf'\xcb\xf0\xe2zk\x81\xe7\x82\x92u\x86;m,\x84\\\x81C\x99h\xc3\x0d\x89'\xa8L\n\\i\xb7D\x93\x7fx\xf1[;\xe2\x94\xba\xfc\xb1\x843<\xc5(.\xcbl\xdbL\x0c\x07\xb3\xefxe\x9b\xee\xceD\xf0\x10\x97ZDh\x8a\xefH\xbf\xe3\x9a\xcbD\xa0\x1fj\xd9\x059\xe5|:\xc6\xe4\x91\xdcp\x94IhPY\xe4\xb4#\\\x8ep\xf6\xf7\xa0\x12UL\n\xd1\x90j\xedO8\x89\xc5\x9fs\x9a\xb7\xbc\xeb\\\xbaW\xb9\x03\x9d|\x97LY\xec\xe0\xba#S\xb4lj\x99\xa3\xd1\xdb\xe6\xae\xe2S\x8b\xbeA\xb9&\xe7 U\x8d\xa0C\xf9g\xff\x18\xb0\xe2\x03t\xc0\x8a/\xe0\xbb\xf4\xfcO\x00\x00\x00\xff\xffPK\x07\x08\x18\x02.\xfcj\x04\x00\x006\x1f\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00O\xb0\x81O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x12\x00 \x00html/error.go.htmlUT\x05\x00\x01v8\xe4]\x9cUMo\xdb8\x10\xbd\xe7W\xcc\xf2\x1c\x8b\x81w\xb1h\x0b\xda\x97$\x87\x00\x05\x1a\xa4i\x81\x9e\x02\x9a\x1cI\x04D\xd2%G\xfe\x80\xa0\xff^P\xb6S\x8br\x02\xb4'K\xf3\xc5\xf7F\xef\xd1]\xa7\xb14\x0e\x81a\x08>\x145\xd9\x86\xf5\xfd\x95\xf8\xe7\xee\xcb\xed\xf3\x8f\xc7{H\x91\xe5\x95H?\xd0HW-\x18:\x06\xaa\x96!\"-XK\xe5\xec\x03[^\x01\x88\x1a\xa5N\x0f\x00\x82\x0c5\xb8\xec\xba\xe2+Ijc\xdf\xc3\x0c^\xdf\x9eqG}/\xf8\xa1hh\xe8:B\xbbn$!\xb04\x06\x7f#\x01\x10\xfc4Y\xac\xbc\xde\x1f\x8f\xd0f\x03F/\x98\x95\xc6\xb1C\xec,j\\\xe9g+\xbf{\xcd\x1cs\xaa\x911.\x98\x92A\x9f\xa5\xa6\xc9\xd9\x01\xc5\xa8\x06@\x18[\x8d\x02p\xea1\xca;\x96\xa5bP\x0b\xc6\x8b\xb5\xb7\x18Lk\xb9\x8c\x11)rc+>,{6\xffo\xbd+\xe2\xa6\xca\x1bw\xb6qq\xc1j\xa2\xf5'\xce\xb7\xdbm\xb1\xfd\xb7\xf0\xa1\xe2\xf3\x9b\x9b\x1b>i\xe0\x19\xc8z\xbe\x9c\xec\xba\x9e\xbfS4-\x10\\\x9b\xcd(\x10Q\x91\xf1.\x9br\xb65\x8b1\xca\n\xb3\x8d\x8dk\x08w4\xb3\xde\xf9\xb8\x96\nYBp\x9f6\x91\x00d\x07^\xc0\x90dbJ(n\xa5\xbb\xc3U[\x0d\xe2\xb8|\xd0[`\x1eJ\xd8\xfb\x16b\xed\xdbFC-7\x08R)\x8c\xf1\x1a\x94w$\x15\xa5|\x00\xa9\xadq&R\x90\xe4\x03H\xa7a\x1d\xfc\xc6h\xcc\xe6Q\x8d\x16\xb6\x86\xea\xa1-g.\xa1\x0eX\x8e4\xc0\x96\x01\x7f\xb6\x18 4\x924M\x14\\.\x8bK\xbc3\xe2\xe8t\xdf\x0f\x0b(\x9e\x90\xc2\xfe\xdb\xd3\xe7\xbf\xe7\xbf\xc2\xc6\xe0\x06\x13|\x18\xa4\x08&Br\xa0\x0f2\xec\xaf\x87\x1a%\xdd[|\xba\xee\x0cCbDa\x9fx\x0c\xf3\x8e\xfc.r:\x0f\x1d)\x8dDwAd\x13c\x96\xde\xd3\xd4\x98'h\xc93\xf1h\x9a\xd3\xd2\x0b\xe3\xa7\xaa\x9c:\xf9]\xc3\x9eb/\xca\x04\xd5\xe0\xcb\xc7\xff/\x19\xf7\x8f\xad\x0b\xef\xdf \xb9\xb3\xb9\xcct11W0UMp\xf0YK\xa8!Z\xd94\x13\xfa\xc3\x17\x1c\xbe\xd3\xc3]\xdf\x83X\x85\xfc,\x80\xc7#\xe5T\xfc\x1dC4\xde\xe5\x8a\x9b\xdc\x12\xe3\xc0\xe8\xf5\xec\xe5\xf5Q\xf0\xc3\x85.\xf8\xe1_\xe6\xa4\x89_\x01\x00\x00\xff\xffPK\x07\x08\xe4\x92\xc0\x7fU\x02\x00\x00\x96\x06\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00@\xb0\x81O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00 \x00html/header.go.htmlUT\x05\x00\x01X8\xe4]D\x8dKn\xc30\x0cD\xf7:\x85\xa0ub\xa1\xfb(wa\xed DT\xa4\x0d\x91\xce\x07\x86\xef^T\x9b.\xdf\xc3\x0c\xdeq,x\xb0\"\xa6\nZ\xd0\xa7\xea\xd2\xd2y\x86\x9b\xc0)\xc4\xa8$(\xe9\xc9xmk\xf7\x14b\x9cWu\xa8\x97\xf4\xe2\xc5kY\xf0\xe4\x19\xd7\x01\x97\xc8\xca\xce\xd4\xae6SC\xf9\xbaD\xa17\xcb.\xffb7\xf4A\xf4\xddPtM!\xdf\xc3\xad\xb1\xfe\x84\x18;ZI\xe6\x9f\x06\xab\xc0\xc8\xf9gCI\x8e\xb7\xe7\xd9\xec\xcf\xd4\x8eGIy\xdaVA\xe7]2\x99\xc1-\x8f_\x16b\x9d\xc62\xdf\xc3q@\x97\xf3\x0c\xbf\x01\x00\x00\xff\xffPK\x07\x08\x9c\xd5a\xdc\xaa\x00\x00\x00\xe7\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\x9bN~O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x00img/account_circle-24px.svgUT\x05\x00\x01\xf7;\xe2]<\x90Ao\x830\x0c\x85\xff\xcaSv\x8eq\x9c\x90\xc2Tz\xd8.\xbb\xec\xb4_0\xb5\x8c \xb5\xa5\x1a\x88T\xfc\xfa\xc9\x80&\x81\xfc\xf9\xe5=K\xf6q\x9c;\xcd\xcb\xeb\xa5\xd1\xf5/\x00\x00\xff\xffPK\x07\x08\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00\xe2\x03zO\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00 \x00img/pomerium.svgUT\x05\x00\x01Hr\xdc]\xc4UMo#7\x0c\xfd+\x84{i\x0fz\x16I}\x16\xeb\x1c\xfaO\x06\x89\xe3Y\xc0N\x16q:Y\xf8\xd7\x17\xa4\xc6N\x8a\xdd\xf4\xd4\xa2\x08\xc2\x91\x1f)\x0d\xf9\x9e\xc8\xf9r^\x0e\xb4|\xdd\xbf\xfd\xf1\xfc}\xb7\x89\x14\x89\xb5\x93\xc6\x0d}?\x1d\x9f\xce\xbb\xcd\xfc\xfa\xfa\xed\xf7\xed\xf6\xed\xed\x0do\x8a\xe7\x97\xc3Vb\x8c\xdb\xf3r\xd8\xdc}9\xd0\xeb\xcb\xf4t~|~9\xed6\xbe\xe9\xbe\x8d\x9f\xe1\xe5\xcf\xe3~\xb7\xd9/\xfb\xa7\xe7\x87\x87\xcd\xd6\xaaZ\xc3\x1e\x1f\x1f?\x96P3\x12IA\x9190R92X\x83\xa0\xf6 \x90l\x89\xf4\x99\x91\xd3\x91-\x01E+\xc4H\x1a\x14\xad\xba\xe3r\xb2 \xb6\x04\xd3\xb4f\x99\x84\x9cv\n\x8c\x1c\x90\xf3\x02\xad\xf6\x82\xccKhHi\xf6\xa5Z|#7#~\x0d\xbfgd\x83\x04\xc5^'m\xac\x8c\xa2s`\xc4f\xcb\x16\xae`\xbb\x9c\x02TBBk\x13\xa34r\x13\xfd/0\xb8!\xeb\"\xc8b\xdeLn\x86w8\xd3=Z\xf6_)\x05T\x1d\x0bF;\x07\xe4\x1e\xd8\xe8X\xa1\xda/\xa7\xd0\xd0L<\xcb_-7m\x1f\xea\xb5\xfc\xedY)z\x86\x81!u\xac\xbc\x00F\xecC\xed\x1bf<$rs\xe3\xc1X\x0bjD\xc9\xd2\x90\xb2\xd3'\x97S4\xea\xad\x0e\xcb\xb2\xe8\x87*k@\xd6\xfb`\x12EK7\xa3\xaa?\xc9\xd2>#wb\x187\x8e\xf8\x19v\xc4;\x13v\x80\xe9\xd9\x9d?\xf3\xfb\xaa\xdf\"|s'E\xce\xc3\\\x1d\xb5\x04\xe4\xb20\xa4L\xc9\x08t\xb3r\"\x84TH\xe9\x06\xd8\xeda%A\xab\xc3\x0c\\\xd0\x9a\xd1\xa1Cm7WO\xee\xa4\xd6j\xab\x19p$\xe4r91\xd4\xf8\xd54\xb1)\xc1W9\x9c\x15\xad\x06\x94Y\x90\xeb\xe4W\xc9\xcd;k\xa6\xac\x16\xbf\xc5\x9a\xd0uQt\xe7z \x19}\\T\xe4> \xc9M\x9c\x94\xc3\xca\xcd\x87\xcb\xc4\x84\xd4\x11\x8d\x05\xcd\x13\xa3\x99@\xed&\x10\xb2\x06D\xcb\xa6\xad\xe6\x96C2\x02/\xa7P`\x9e\xc4?a\xbe\x96\xff\x8d\xfa\xf2\x1f1/ze\xbe\xa0TJh\x06H\xb0.\x13J\xa6\x81\x1e}\x16\x0e\x15l4uJ\xe4\x9a\xd9\x04\xd1\xfa\x03\x94\xf9\x18l\xf6Z\x93v\x88\x82m&u\xeb\xad\x9e>r\x10\xfb\xca\n\x13S1]\"\xfd\x18h\xb8\xf1e]\x97\xd0\xc5\x9a\xab\x8d\xe6\xea\xa8\xd9\x9f.\xf2\x19\x85i\x95|\x85\xac\x875\xb8\xa2Y\xc7\x08A\xb1-^\xa6CMG\xf1\xe2\xf7\xc1\x0b.\x8b\xa0\xb3\xcf\xe2n\xa32\xd6\xd9d\x9a\xc4^\xe0f\xa4l\x9f\x19\xb1\xb1'\x12\x9c*N\x8b\xa0\xf1l\xab\xbf\xa9a\xb16\x88j\xbe|\xf6qx\xff\xa0\xa9\xa02qB\x9f\x14]\xc9\xcd\xaa\x98\xad\x837\x87&\xf0'\x01\x06\xc8\xc2\x05\x9cf;k \x99\xa7l\xac\xb8\x19\xe93S\\\xcc3\xcb\xbf\x11\x81T/\xa7\xa0\x1d\x9a(\xe1\xe7\xa1\x97\x13\x0bz\xa6\xf8\x0f\xde\xf4\x99\xd7X\xda\x1e\xd6\xff\xf3r\xb8\xfb+\x00\x00\xff\xffPK\x07\x08K\xfe\x8b#M\x03\x00\x00d\x08\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00@\xb0\x81O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x00img/pomerium_circle_96.svgUT\x05\x00\x01X8\xe4]l\x95\xc7\x0e\xe4HvE\xf7\xf3\x15\x85\xdaR\x9a\xa47\xad\xa9\x06\x82Lz\x9f\xf4\xb9\x19\xd0{\xef\xf9\xf5B\xb5\xd0\x82\x16\n \x16\x07\xe7\xe2\xdd\xb7{\xffZ\x8f\xf2\xc7\xd5w\xc3\xfa\xebg\xb5m\xd3\x1f\xaf\xd7y\x9e\xff<\xb1\x7f\x8eK\xf9Ba\x18~\xadG\xf9\xf3\x7f\"\x7f\\]=\xb4\xff_\x10a\x18\xe6\xf5\x97\xfd\xf9\xe3\xac\xb3\xad\xfa\xf5\x93!\xff c?\x7fTy]V\xdb\xff\xe2Q\xe7';^\xbf~\xc2?\xe0\x1f\x0c\xfc\xd7\xff\xf9\xe7?~\xfc\xf8W\xdd\xc7e\xfe\xa3\xce~\xfd\xf4\xf3t\x1b\x97\x7f;}\xbcl\xff6\x93&O\xb7\x9f?\xb2x\x8b\xffs\x88\xfb\xfco\xff\xe3/\xff\xe3o\xffw+\x0c\xff\x9f\xce\xdf\xf0\xd7V\x7fTK^\xfc\xfa\xf9{\xc8\x1fu_\xbe\xa6\xa1\xfc\xaf$^s\x12\xff\x8f\xdag\xcd\xcf \xabb9\x02\x00\x80\xe1x\x15\xef\x95\x00\x00\xf17\xb2%\x07\"\x00\xc0\xbb\xee\xa5\x14\xff\xedI\xbe\xe3m\xff\x83\x0f&\x9aq!\xeb\xf9\x9fX<0\x9bs8n\xb5GPQ\xb2\x8d\x8f\\S\x86#I5{\xa9\x93K\x95\xd8j\xdb\xe8\x8f/\xec\xd1\x93\x98\x1c\xd9!{]v\xf2x\xd6S\xa2\xee:?\xce$\x18\xe5\xc6\xe4\xd6\xb1/9 \n\xab\xde\xf6v+s\x9c-\xdeL\xe6N\xf9\xb3\x11d\xe8b\x01u\xb8N1\x1fd;\xf9\x13\x031\x04A=\xd4\x82\x0d/3\xa7!D\x13\xdfV\xe0\x8eb)\x0d@\x83\xd9dd\x8b\x08\xac2k\x8c\x00\xb5\xc1\x93\x84\xa3\xe0\x81+eK\x1c\x98\xa7\xb8\x95\xc0\xe9\xc1a5X\xd2\x1e\xcf\x81*p\x95\xbe_g\xb8;\xe0\xddq\xc4\xc8\x1a\xa7^\x00\x86\x06\x11\xa4}\xf3\xe4\xd4S\x16;\x87\x84\xd2\x9e\x91q\x07\x18?\xdfRi\xa0\xc0M\x85\x916#\x14\x05:\xce\x1f%;\x80\xd9\x95\xf6\x1b\xdeZ\x80Xp\xa3\x95xWa\xc2]\xbe\xa7S>\x80\xb3c\xbe!\xb8r#\xd4m\xf3\xe5\xbf\xf5#\xf1g\xc9\xbd\x10\xdf\xc2\x87\xaa\x86C\x1f\xc2\xdd\xe85}\xe7\x17\xc50#xN\xb1\x00\x1b\xcc$M\xfa\x81\xd98b\x97g\x9a\x9c+\x17c.\x0b\xf7\xb5\xa0\xd5\x9a\xf9\xe8\xc9v\ns\xd9]\xe9\x9d\xf7<\xa8\xfd\xe9\x11_U\x9c\\\xf9\xbb\xb4\x91\xbc1\xfa\x0d8\x94M\xed\xc1\x81\x81\xc5\x01\x8a\xb8\x7f_(\x92\xb4\x1f,\xc2\xc1\xb2\xd9\xef\xeb\xbdG\xce;\xb1\xd2\x0b\\f\x17\xe6DS\xdd\xae\xc7\x02,\x0b\x1d\xfam\x81$\xe2\x05\x87\x0e\xc3S\x188\x06\x8b\x8eU\xac\x81\x14\x98\x8a~\xd41K\x86\xf1$4\xab\x18\xab(\xc1pN\x8dc\x0ch\x902\x8a8\xc9\xd6\xe3\x8e\xf6^5\x9c\xa2\x1aq!\xd1\x19\xd6\x99\xde.@t\xbe\x10D\x11\x1e\xfb\xe5Q\xca\";\x1c\xa5\x9a\x0f7d\xed\xb7|+b\nm\xd2\xabM\xd8\x8c\nr4\xe3{v(?\x1e\xc2)9\xaf\xb7=B=d\xcb^\xa6\x97\xf6\x88C\xc7\xf8c\x9b\x05w\xd6b\x0c^\xb7\x0b\xa1\xec\xa8Om\xe4\x18\x8a\xcc.\xfdU\xaa\xe9\x87\xff\\E\xa9AB\xdb\x88\x0ep<$\xf0\x13\xe9\xbdK`\xab\x1e\x0bP\xb8\x94\xbfC\xc7\xae\xa3[O\xa5m\xdeuu\xdd\xdc\xa5\xc2A\x03\xcdk\xbc]\x9f\x0fFX+4\xaa\xc7U3\xef\x18\x16\x05\xb4\xfbB\xcex\xe09\x06\xf2\xae\xbaE\xa4\x0f\xd6\x8a\xbf2h\xe6jI\xd8\xbb3jR\xc4\x9dV,ds\xe6\\O\xad\xa8\x84\xb9\xd4\x8emS\xe2\x98\xad}\xa1\xd5\xe8\xb0\xeaN\xb9\xa4!\xe9\x11H\xbbk\xcb\xee\xb8\xc8z.\x17\x94\xc2\xb5\xf4\x9b\xb03Hl\x86\xcf&\xc4k\xe3\xab,v\x83m\xc0n\xc7\x08\\\x87\xb9\xda%_z\x0e\xc1(\xe1xVf\xa2u\xe2\xf5\x99cOs\xe5\x99\x1b\xa5s\x83[\xd9\xbb\xba ;\x08\x84e\x16z\xb2\xd2\xa8@\x19\xee\xcd\xcf=\xed\x1e\x19\xd6qx\xdaT\xc3I\xf5\x99\x93\x8c\xb1\x93|\xdbx0\xcb\x10\xd2x\xa1-y*\x8c\x96\xe0\xbe[$(\x0e4\x19\xcd\x85\x1a\x9d\xa7\xda\x8ey\x17\x84\n7\xba\x13\xc6\xad\x93\xc6\x92\x13u\x0d`xm\x18\xcajL\x92\x99\x16t5\xb1/r\xfa\xaeB\xe0\xc0{\x19 \xaf\x07\xbb\n\x084\xaf\xa3T\xd47\xac\x9d]f\x9ej`[i\x9f\xab\x83\xc4\xb8b\x88\xe1):8 90/$s\xfb\x08.k\xa8\xa60\x18\x8cq8{\x86%F,\xdb\xed\xb3y\xc3\xd2\\/\x15+\xed\x086\n\x9d\xaaUY\xd8\x0b>\x14\xa9u\xc1V\xf4\xb0\x0c7\x03\xa1@t^ C\x18\x90:\xa2\xd7\xc1\xc2\xac.\x9f\x91K\x10\xab\xdaE\xec\xa4\xda&\x01\x83\x81\xef\xfdI\x0bXt\x94\xa2\xd9\x91\xb3\xb1\xfa\xdd\xb39\xbc\xb9\x9f\xf7\xbe\xc3\x19\xa1\n\xdf\xcf\xce2\xfdQ\xd6\x8b5\x9a5@\xd8+\xd4\xb1\x93\xa83\xf0\x10V\xb9v\xc2\x99~>\xce\xee{\x89(\xc9\x1fM\x19\xc50?\xbf6\xb7\x88\xcai\x1b\xa9\xdcg*\x0e\xf9\xf6\x96Bp\x08=\x0coF9\x9bz\xd4\xa8\xef\xc77\xc0d\x8e\\B\x1a*158\xa1\xc6\xc4\xd3\xf2I\x8dL\xa4]fh\xaf\x8dVj\xf2\xcb\x91\xd8w\xc6 \xf3\xa5\x1f\xe6\xf2\x92\xb8\x95\x15\xbff\xc9y\x0f\xfe%C<=\x97\xc78\xe8\xa2l\xe9\xec,h\xba2Z\xa6\xfb\x88\x8c\xd8~\xe7{,)d\x8b\xd6\xb3\n\npZn$\xf4|@}x\xeaH\x98\xd1\xb5\xbb\xdc\xf2s\x1aL\xd9\x8c{rl\x11\xd0\xac\xd1\nI\x84\xf7\xde\xbeXC~:#m\x91WE\xc75\xb8sJ\xb9##j!3\x1d\xdb\x93bh\xab,\"SJ`1\xb53\xb3\xef\xa8a\x9a\x1cf\xf6\x82,\x98\xb5\xc0\xb6\xcah\xbcs\xf3\x8c \xe7+\x9e\xaf\xb9\xa3\xad\xa2P\x1dz\xcf\xc9\xf2\xfe\xce\x13\xfb\xe9z\xd1\xad\xa4!\xfbb6\xb7\xd30\x9c.\xf2\xb6\xc1\xd0WOC3`\xc2o\x05\x90\x89]\x07\x9c\x80\x82g\x9a\xe5\xf2\xc9s\xde\xde\xf4DU|\xff+\xbeH\x0c\x87%m`\xf4\x99\x8ds\xd3\xa3=\xf5\xcamL\x1f\xca\x8aq\x91\xf8uA\xce\xc7\xe8\x84\xfeB ;-\x9b<\x7f\x16:a\x08F<4\xcc6\xd3\\8.\xec@\x94\x13\xa3\xf93y\x945\x10 \x9f'\xbeg_\xd5\xe5\x80\xe4[\xe2\xd7\x155\xb9;\x9aEo\x1e\x11\xe7Dtk7|\xe8|\xc3^\x0f\x86\x08\xa2\xd7\xd3\n\xf5\x12\x88\xb3\xef(\xf2Z\x837_\x14\xc7|1\xc7\x8b\xff\x16\xd7-\x0c\xee\x91\xdc\x06N\xcfk\xe7\x12\x89i\xc6Lt\xe8Q\x9c\x1f\x0dl3\\\x1eY\xe1\x10w\xdc\x8dm;\xfb(\xae\x19\x04Sf\x0dsK\xb4\xda\xe5\x0cJh\xab\xe9J\xe4\xc9\xb7\x0c\xcb\xeb\xb2\xb3*\xf7*1g\xba\x14\xc9q\x85,\x92\xb8E\xda\xf2fvk\xbe\\\x04\xaf$\x12\xa2\xeevh\xf2\xa3\xda\x92\xadK\xb46Y\xc2\xa7\x8d\x90\xb8\\\xf6-F\x8f\x18\xa5x\xf6]e\x80\xbe\x12\x14%\x0f\xaf8\xfa|I\xfc\x0eS\xd0\x01\x13\xc1\xa0\xaf\x8b\xa8\x99\x14\xcc\x1c\xc1\xef;v\xec*\xcf\x1cA\xa5\x80\xa97Br5\x96\xfd\x1e_\xf9\xd6\x95\xda\xaeu\xba,\x0e\xf1\x0e\xa1Tdn;\x11j\x13\xc1(\xa7\xaer\x9b\x97\xc5\xb0\x9b\x8bMe\xb3\xdc\x90H\xae=\xea\x95\xcfaCvB{h.%\xc3^\x8f\xcbl\xda\xb4\xdc\x06|e\xa3\xe2tC\x89m\xbb\x10B\xf4\x1b\xac\xe8\xb8[\x96\xc7\xe5\xe6\xd4\xdd\xd2\x1b\x7fI\xcfW\x0f\x9al\xeb\xe3G`\x9e[\x17.\x7f\xb4\"O!\xd8.\xc9\x01\x11\xf6\xe5Z\xca'\xbc\xd0\x93\xf9\xe5QChx\xb2,\x9a\xb2\x08.A5\xe3S\xc4\x06I4[h\xa5\xd5J\x11UUh\xa8w\xeb\xd0W\x15VR\xdb\xd0\xa3V\xc4\xbb\x80\xf4\xf7\xc4c\x16<\xb3\x10\xf2\xa5x^\x128J2B\x15*\xde\xe4\x18\x9cI\x828\xbd\xd5\xb8g\xad\x1bf\x99\xeeR\xdc\xd3\xd2\xb3\xdb\x86_\xcb\xda\xd9\xcb\x97\x9a\x87\x96\x97\xd2\x0bF\xbe\x85\x82~.]x\xa8`\x0dO&/l\x83\x88\xbb\\\xb7\x0dd\xe6\x0cUL\xaa\";\x10\xa6[\x96\xec\x03\xde\x02\x08j<\x11\x8a\xf0\x9a\x16U\xb6+n\x9b`D\"\x886\x9d;g\x1d_\xfaL#\xe8[7\xc5\x83\x17\xbf\xa3R\xe4\xab\x82j\x88\x90\xba\xf9}F\xf3}\xa9\xafb\xe7e!\xdd\xb3\x0c`\x99\xb1\xf6\xdfhL\xe4\xa1\x91\xbdN'\xec\xaa\x8aNmW+\xff^\x1a.\x0e1X\x1c/\xf2\xcd\x91\x04\x92\xf8\xea9\xf3\xb5\xfb\x852\x1cW\x94\x05_\xdf9\xc4\x1e>\xfd6\xdd\x8c\xa5ZAu\xd2o+\xafJ \xa1\x1a\xedJ\x0b,JC\x99i\xd5\xfa=R\x98h!\xb6`\xee R\x8b\x15\x9e\xd5{\x1e\x9d\x80\x87\xa1\xde\xe6\xf2\xb0\xabk\xd4\x87L\xe9\xeb\x12>'\xc3\xd5\xe4\xe9\x18\xee#\x98\x8c.p\xc5FQ?\xd6\x1e\x9b\xc2E\xd0\xd9\x98PG\xfb\x14\xd5\xcdt3\xf6p\xae\xfem\xa6vkO\xc8%\xfd\xaa\xd0\xed\xcb%\x98U\x81\x87V\x91\x0d\xd1\xbf-\x90\x7f\xf5Y\x12\xc1\xd8\xddJ^\xe5\x90\xf7*^4\xc3\xd8\x9b&\xaf\x96\x9f\x10\xb2\xd2]X\xb1\x80r\xc9\xe0s\xbb\xc1\xba['\xeel3\xd3\xf5\xc2\x03\x88*C\xbdo\x7f\xec\xf3tF\xa3\x00t\xfc\xad/\x8b\xdd\x9c\xca\x18\x0e.J,\xee\xd5\xc6\xf2Y\x7f\x9a\xad\xd1\xda;0\x95\xafRX\x1e\xecdC\"[A\xae\xb40Q\x18\xafy%\xb6\x82\x1e\xa8\x1c\x8b\xa7\xf7\x99\xcc\xd09\xe7\xfdW\xa1Z\xb6\xb5\xa4\x84?\xcb0X\x0d8\\\xf8\xd7\x05ZE\xba\xa1o\xd7Q\xad\xf7\x89*n\x9bIi\xb0\x1327S+\xeeb$\xab\xd4\x05\"\x02\xf7(\x86\x04\xed^/HF\x1bJ\xf0\x00\x19\xa0e\xa42\xf7\xecb\x9c2\xd1P\xc8\xe8\xc8\xc4\x13#u};\xc0\xe5v\xe3s\x01Gv:\xc7k\xa9[\x08\x81\xd7?\x0e\x8a\xd6\xcd\xb6\xde\xe0\xa5\xbc\x13\xe7\xb2\xc7\x98\x9a\xb3\xfd\xa1\xe54\xce\x84aC\x08d \x0e\xe0n\x0c\xd4\\\x06t\x0f\x1d\xaf8\x81\xad\xf4{>\x00~\xc8Q\xc7pG\x99\x8b\xa6\x96lv&\xc2\xbd\xb2\x93&\x7f\xbc\xd2\xbe\x0d\xeb\xb8f\xaa\x02\xad\xb4\x1e\xb9\xcd\x95?u/vL\xbc\x9c\xa0\xb8.5\x89\x13\x02X\xb1\x98l\x98\x97\xe9b\x86L*\xdb\\\xa8(-\x88\x03\xe2\xccc(\xbb\x91\xa8\xde\xb1\xfb\xd0\x9b-7\xaa\x9e4\xaf\xcb\x9cv:i\xcdbB\xb2\xac\x9bd\xfauKP\xcb\xea\xdf\xf9\x84O\xf7\x82\xc0\xe0\xb9\xb4\xaeo#\xb0\x84\xd5\xbf\x03_z2\x99\xd2\x02\xa15\x97q\xbb\x0e\xacgu\x15\xc93Y\x04\xd8Y\xfa\x9eR%\xa3\xb1\xd8\x9f\x1dE\x84\xad\x07\xe4r\xa9K\x92\x04:\xde\xc0~\x04\x9fc\xf8\xd5Z\xbe\xd25^\\\xb7V1\xc8\xd3\x99L\xbeK\xc45\x99\xcfFR|\xc3\x8cw\xebh\xec\xfe+\xdb\xfe\xeb\xc3\xe1\xe8~\xcdj\xec8\x9c\xe1Gi\xc8\xf2\x10s\x18\x03\xe7\xb6^\x1a\xbb\xa5Wv\xa1\xc6\xf6\x9f{.\x03\xca\x7fP\x85\xdc\xdf\x1f\x99\x8c C\x15<\xa7\x8a\xf32\xd0\xf6\x11|>\xa4\x82\x8cH\x83Q\x96\xd0M\xa7\xd3#ix\xe36\xa4\x84=dm0\xad\x8e\xcag\xbeK\x86c\xfb\x926i\x16\xda\xfa\n\xbb\x116\x94\x84\xcf\xdbY\xe7\x15\xbeFN\xd9\xad\x17\xe38s,s\xef\xbd\x08/.\xbd=\xd2\x9fO\xa8\xd3\xbbD\xd2v\x19n\x07\xe6\xc4\xe9\x85\x1f \x12#\x81Q\x82\xb3A\xfb\xbe\xcf\xe2e.\x89W\xf76\x15\xc6\xdf\xca\xc6\x10\x04\xf8\x9a\x88\xa8\xe4\x00\xdd\xa6\x1d.z\x9e+\xb2\x8e\x81\n\x12\x01\xd5_\xd7\x8ea9N?\xc9\xa8\xcf\xc2\\\xa7\x92\x94\xb5db\x90\xef1\xf1\x92\xe6\xc2\xaf 2\xbeU\xd9\xe3\x92d\xbfQ9\xea\x0f\x85|\xab2\xb7\xd9\x11\x14\xccX\xecY&\x01[\xd9\xcb\xab\x0e\xd8*\x85Z\xe4\xe7\xbd\xedk{J\x81\xe4\xa6\xa6U\xa9\x0f\x9d\xe5\xfd2\xc3\x11\xaew6\x11X\xf7\xbb\xea'd\x7f\x15'\x88\xbf3W\xeb\xbeU\xdaM\xb6S\x93\xd7O\xaa\x16\xe32\xa2-\xb0}\x9f\xe6\xd0\x8d\xed\x9d\xdb+\x92\x0c\x1fq\x91\x1b\x03\x83\x1b\xd0\xd9U\xf6\x1e\xce\xa9\xc0\xd0r\xa2\xe5*$\xb7)[%\xc6$=r\xc1:\"*3\x1ar\x90\xbd\xcd\\\x08k\xf7\xe8\xcfr\x7f\x9b\xc5S\xe1z\x06\xa0\xdb\xc8\x0e\xa2\xe3\xb7\x00?=\xd2\xc4\x02\xd2,\xc8\xd6\xdf\x97Q\xc1\xe9\x17\xb9Db\x80\xc7\xae\x1c\xcd\x94\x8b`\x1e\x9c6w\x8b\xc1\xc6\xc4\xc1\xe4\x12\x1a\xd4\x87b\x0c\xd5\xde\x85\xea\xf5\x91)\x14c\xdf\xf2\xc1$\x16sU\xe4\xf7\xa1?\xe5I\xac\"z\xc3\x90H,\x85\xe9X\x96\xda\xa5\xe3\xc7\xf6\x18\x01\xe5D\xe2\x1e\xb1\x93\xce\xcb<:D\x0c\xfe\x8a\\\xc3\xa20\"\x98\x8f\xcf\xde\xc4W\xeb\x1d\x99U\xe9YJ\xb2Q\x86mF\xa5\xa0\x0f\xceZ%\x82P\xbe\x17v\xc45#\xad\xe3\x15\xb3|\xf5\xcc|1\xa6\x04\xca)\xff\xd2P\x90\x91\x0f\x01Mu\x9a\xd6\xe7a\xb0k\xc9\x16\xae\x8e\xf3\xe5\x02\x95\x0c\x176lL\x08u\x96.O\xb1 l\x18#Ee\\\xf6\xea\xedak\x9fVW)d\xe3\x97\xc5\xfa \xe1q.]\xd2\xc1\x16\x86\xb0'/U\xfa\x81lPP_]\xb3\xa5\x04qY\x88\x1c\x86\x98\xf2\xaauT\x0d\\\xe2\xd6\x16\x0b\xd2\xaap-\x9e\xd1j\xef\xf0\x93\xbb#\xfe\xda\xdd\xd8\x0e\xcb\x81\xech\xea\xeb\xb4V\xcc#'t|`\x13\x9e\xc7\n\x97\xf9|\xa0D\xe7\x10\xd9e8\x06\x0bI/\xa9\xd6\xd4{\x19 )D\xc5N\x87\x91\xe6gU\x10\xacx\xfa* 3+\xa1\xe4\xe3\x9c\xc4\x18\xdbo\xf3z\xa6\xaf\xdb\xf5>\x0ffZ\x96\xcf\x1f]\xb7m\x1b6\x87\xc7\xbfs\xa7\"\xd2\xcd\xeb\xd9\xd0v\xf9X\xa6\xc1\xa874\x9d.\xe7ii\xbc^N\xdb\xcf\xc7\xd7`\x84\x84\xd4\x93z\xf3\xfe\xf6\xf9g\x99\xe8c0\xbf\xadE\xce\xa4#\x07\x04%a+\xe4\xe1\xfb\xb2[\x99\x0bR\xc5\xf6\xf0!\xf0\x81\xdc\xd0\xca~s\x88\x96\"\x9c\x1f-$\x91\x90Ev\xe8c\xdd\xdbR4I\xfc\x14\xf9\xe5p\xbbR\x85b\xf3\xcbf\x88\xad\xeeS\xfd\xbe\xb9\xdf\xb8H\xa1\x1f-Jl\x85+q%6\xaaK\xf9b\x8d\xde\xde\xf9\xe5\xcc\x15\xab\xc3/g,\x198[\x82\x84\xef\xa3\x95\xf6\x9bP\x86u\xabC\n#+<#\x05\xf6p\xac\x88\x1c`={\xe4\xd8\xeeZX%\x87\x98\xd8\"f\xaan!\x84V\x04\x85\xf4\xe5G\xa0Zt\x8f\xbe\x9c\xcc\xa4\x10= \xf6\xfb\xb3722\xb4\x94\x07\xa1\xd5\x03)3\xc4\xaf\xec!\xa9&\xed\x95\x14\xd9\xb3\xc2\xba\xd2\xbf\x06G#\x14Y\xe129\xf4\x9e,l\xcd/\x159\x95\x08\x91\x02$s\x82\x0f\x95v\xd3\x1d\xf3\xf1\xf7r\xbd\x0e\xe6\xfe\xb8\x9fL\x9d\x15!\x99\xd4\xaf\xea\x7fI=T\xc6\xee\xfd\x7f\x00\x00\x00\xff\xffPK\x07\x08uq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x08\x00@\xb0\x81O\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00style/main.cssUT\x05\x00\x01X8\xe4]\xbcX[o\xdc\xbc\x11}\xf7\xaf`\xd7\xf8\x00;\x10\x15]V\xbb\x8e\xf2\xd6\x14A\x0b\xd4yH\xd0\x87>R\xd2h\xc5\x9a\"\x05\x92\xf2\xaeS\xe4\xbf\x17\xbc\xe8\xba\xbb\xb6\x9b\x87/\x88\x01\x8b\xd7\x993g\xce\x0c\xfd\x01\xfd\xf7\x06\xa1\x96\xc8\x03\xe59\x8a>\xdf \xd4\x91\xaa\xa2\xfc\xe0\xbf\xf0\x11\x8a'\xaaq-\xb8\xc6\xaa\x15B7v\x92pM \xa3DAe\x97\xb5\xe2'\x16\xeat\xb6\xee \xc9\x8b* \x83\xf9a\x1aN\x1a+\xfa\x130\xa9\xfe\xd3+\x9d#.\xb8]Q\x88\x93\x99\xb0[\x0b!+\x90\xb8\x10'3c\x0f\xaeIK\xd9K\x8e0\xe9:\x06X\xbd(\x0dm\x80\xfe\xca(\x7fz$\xe5\x0f\xfb\xfdUp\x1d\xa0\xcd\x0f8\x08@\xff\xfa\xc7&@\xdfE!\xb4\x08n\x10Bh\xf3w`\xcf\xa0iI\xd07\xe8a\x13 E\xb8\xc2\n$\xad\xc7{\x8cm9\x8a%\xb4f\x88Q\x0e\xb8\x01zht\x8e\xe2pkF\x7f\xdd\xdc\x84\x9d\xa4-\x91/\x16\xc2R0!st\xbb\x83m\n\x0ff>df\x83\x9d\xfc\xf8\x01\x91l\x1f\xd75\xfa\xf0qZ+\x0f\xc5]\xbc\xcb\x02\x14\xc7i\x80\x92,\xbb\xb7\xdb*\"\x9f\x86]\xb7\xdb$\xf9\xdbnw\xb6m\xb7\x0b\xd0\xd6\xec\x8c\x92{g\x8b\x85\xb4\x15\\\xa8\x8e\x94`\xf7\xcf<\x89\xc2\x87\xcc;\xb3\xc0\xf1\xc7\xd7G\xc1\x05\xfe\x0e\x87\x9e\x11\x19\xa0G\xe0L\x04\xe8QpR\x8a\x00}\x11\\ FT\x806\xff\xa4\x05H\xa2\xa9\xe0fVl<\x98_D/)H\xf4\x0d\x8e\x9b\x00M\xf7\xff\x85\xb6\x9d\x90\x9apm\xcd+D\xe5`\xaa\xa8\xea\x18y\xc9Q\xcd\xc0\x85\x95\xc1 WTBi\xce\xce\x91\x14G3L\x18=pL5\xb4*G%p\x0d\xd2\xd2\x83\x94O\x07)z^Y\x1c\xc8\x12\xbf\x00EadP\\`\x95\xa6\x01J\xf7\x01\xda\xc6v\xc6\xf0\x8d\xd6/\xb8\x14\\\x03\xd79\xb2\x06\xe3\x02\xf4\x11\x80[ko[B\xf9\xfb\xcc-\x05\xeb[~\xf1\xdc\xc9\xea#\xadt\x93\xa38\x8a\xfe0\x9f-\xe5\x13\x9b\xa2\xe8\xb9q\x97R^\x0bCv{\xf1+\xa7]\x81\xe6\xccT\x9f\xc7\xb8\x10Z\x8b6GI\x98H\xcf\\\xe5\xcc\xff\x7f]\xec\x84\xa2>J\xc0\x88\xa6\xcf6e-\xf3\xacQ9bP\xeb3\x17\x13\x7fk\x13\xafi\x99\x84\x0bV\x1e\xfd\x8em\x14\xad\x0f\x9e\xfcd\xa05Hl\xa2\xe6T*L\xbb\xd3\xb8\\K\xc2U-d\x9b\xa3\xbe\xeb@\x96D\xc1\x8a\x0fq\x1c\x05h\xb7\x0fP\x92\xfa\xe4i\xe2PS\xcd\\\xd6\\\xbeu\x12\xc5polFq\x98d\x03\x9cMr\xb6s\x00b\x90\x85\xd4\xfe{\xd3\xcc\xab\xce\xcdUi\xb8y\x85\xda.\x8b\x9c\x14\x94DV\xd6 /\xa0\x92T\xb4W\xe6\xb0a\xdfjb\xeb\xeep\xa39\x8a\xbb\x13R\x82\xd1\xca\xa5X\x14 \xff?\x8c\x13\x97]\x96\"\x07)\x8e9\x8a\xc7o\xd5H\xca\x9f\xfc\xc8XQ\x10N#w|KN\xd8g\xc2vJ\x84a\xe4\xc1\xaf\x1a\x81\xf6n\xce\xdd\xb5\xc5\xa1!\x95\xb97r\xe6\x98\x15\x91_\xb9\xb66\xda;E\xbd\xa9)\xb0J\x81\x9e\xd5\xba)+\xfc\xbdse\xb9\xad\xcb\xba\xac\xeb\xac\xfa\xedK\xdf\x82x(ws\x89^\x9a\xcaH\x01\xcc\x1a|9\xeb\xdeL[/\xa4C\x16n\x935\xbeQwr%\xfe\x8a\xa0\xbc\"A\xe7\xd9zfz\xce\x85\xbe\xcb\x19Q\x1a\x97\x0de\xd5\xfd\x9c\x91\x03\xf8\xaf0\xed\xfe\x12\x1c\xaa#N\xb4f\xcc\x89\x93l\xe5Y\x84\xe2l& >#\xa5\xb1\xd6\x1eJ\xdbCHK/\x7f\xfe\x14\xd2k1\x87+\xdd\xcd\x036\x8613\xc459\xd6\x82R\xe4\xe0\x14c\xbc8 3\xcf\x8d\xfd\xa8\x0d\xa1u\xc1 \x1f\x83\x93O\x8f\x0b\xb6^R\xc0\xab\x15\xff\x15\xbe\x8a^\x9b~e\xe2X\xd9KeN1`\x98\xefcC5X\x89\xb1\x8b\x8e\x92tv\xdf3\xc8\x9a\x19\x967\xb4\xaa\x80\x8f\xf8M\x13\xc0\x18\xed\x14U\xcb\xd0\x84\n\x18\x94:\xcfI\xadA\xfaf\xc8Sf\xb3\xf9\xbc`0)\x94`\xbd\xb6vy\xe4?9\xf7\x07\xe0=\x1a\xd2}\x0d\xd9\xa9E\xe7\xc1\x1f\x13\xd8\x0e\xe1\x81\xd5\x82\x1anbx\x06\xae\xd5\xe0\xbb\x895\xefz=\xa7\x9e\xd2/l\x06\xce\n-[\x1b\x8d7\xeb\x80\xbdk\xf7\xd4\xe0\x92\xae\x03\" /\xe7s\xa6E\xbe4qi\xec<\x8aC!\x89\xd3\"\xad\xe7q\xf5\xbe\xafIaKLG$\xf8\x16,4\xde\xbc\xaf\xe2{\xe9\xb0\xc3\x86\x1e9\x1aH\xf2\x8e\xe6),z\xad}r\x0dF;~\x9e\xd1v\xe8\x95\xd7\"\xbb\xedNh\xd7\x9d\x9c(dQ\x80\xcc\xcf\xa7\xd4\xeaB|\x1f\x98\xa4\xe9N(\x1dV\xcc\xd5\xf7\xe1\x1d\xe2\x1b\x9d\xa5\xdb\xde\xa5\xdb\xa8\x95\x9euWSi\x06\xf9BO\xc7\x9c\xa9\xa0\x14\xae[\x9e1\xcb#\x136\x84\xd5#\xbf|\x19\x8d>\xaf\xcb\xe84R\x10E\x8d2\x13V\xdee\xd1\x1f\x08\xdb\xbb\xee\x17g\xd6=c\xeb3\xe3\xf9\x8a\xbc1y\xec\xfa\x94\xa9\xf9\xb0\xbf2\xa2\xe1\xdfw8\xb6g\xaec\xb1\xefN(6\x01\x89.\x86\xe3\xde\xbd\x03\"\x1b\x8d\xdd\xb4l\x1d\x93_7\xa1\xa8kl \xe1\xd2qN\x84l\x9ffE\xe6i*\x84\xc9\xe4Q\xa0G\xb6Rn\xdfa\x9a\x14\xee1\xb9\xd0\x818Y\xcaH\xe2c\xbe\x92\xf7g\x90\xe6\xe5\xc7\x86\xa2\xa0E7\x7f>\xf5\x1a\xaa\xe5k\xae\xdcg\xfbj\xf1\x9aY\xbd\xabfJo\xfa.\xec\xcc\xbf\x9cgoe\xcf\xaa\x14\x8f\xcd\xf4\xcc\xd1\xb1\x97\xb2C^%\xf1\xacGZ\xb57\x17\xa6\xcc\xb1\xcb\x89\xeb\xcd\xed\xd5\xaf2\x86\x9f-\x1a\x81o\x80T\xd7\x80\x7fo\x87s\x16\x8f\xdf\x81\xde\x13\xf2O\xc6\xfd\x1d-\xd5\x12z-\xba\xb7p7K\xae\x82n3F\x8e\x7f\xe2\xb8\xd8j\xfd/\x00\x00\xff\xffPK\x07\x08L\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81O\x18\x02.\xfcj\x04\x00\x006\x1f\x00\x00\x16\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00html/dashboard.go.htmlUT\x05\x00\x01X8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00O\xb0\x81O\xe4\x92\xc0\x7fU\x02\x00\x00\x96\x06\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xb7\x04\x00\x00html/error.go.htmlUT\x05\x00\x01v8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81O\x9c\xd5a\xdc\xaa\x00\x00\x00\xe7\x00\x00\x00\x13\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81U\x07\x00\x00html/header.go.htmlUT\x05\x00\x01X8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x9bN~O\x83\xba\x83\xe4\xf5\x00\x00\x00|\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81I\x08\x00\x00img/account_circle-24px.svgUT\x05\x00\x01\xf7;\xe2]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81O\xfc\xc6x\x8f\xb7\x00\x00\x00\xf9\x00\x00\x00\x12\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x90 \x00\x00img/error-24px.svgUT\x05\x00\x01X8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\xe2\x03zOK\xfe\x8b#M\x03\x00\x00d\x08\x00\x00\x10\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x90\n\x00\x00img/pomerium.svgUT\x05\x00\x01Hr\xdc]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81O\xf9\xfe\x13#\x13\x0f\x00\x00\xe5\x13\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81$\x0e\x00\x00img/pomerium_circle_96.svgUT\x05\x00\x01X8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81Ouq\x02\xd2d\x01\x00\x00\x9e\x02\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x88\x1d\x00\x00img/supervised_user_circle-24px.svgUT\x05\x00\x01X8\xe4]PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00@\xb0\x81OL\xbb\xd3^\xd3\x05\x00\x00^\x12\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81F\x1f\x00\x00style/main.cssUT\x05\x00\x01X8\xe4]PK\x05\x06\x00\x00\x00\x00 \x00 \x00\xb2\x02\x00\x00^%\x00\x00\x00\x00" fs.Register(data) } diff --git a/internal/httputil/errors.go b/internal/httputil/errors.go index c68d181c1..d2d07ebd6 100644 --- a/internal/httputil/errors.go +++ b/internal/httputil/errors.go @@ -2,110 +2,91 @@ package httputil // import "github.com/pomerium/pomerium/internal/httputil" import ( "encoding/json" - "errors" - "fmt" "html/template" - "io" "net/http" "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/internal/version" ) -// Error formats creates a HTTP error with code, user friendly (and safe) error -// message. If nil or empty, HTTP status code defaults to 500 and message -// defaults to the text of the status code. -func Error(message string, code int, err error) error { - if code == 0 { - code = http.StatusInternalServerError - } - if message == "" { - message = http.StatusText(code) - } - return &httpError{Message: message, Code: code, Err: err} -} +var errorTemplate = template.Must(frontend.NewTemplates()) +var fullVersion = version.FullVersion() -type httpError struct { - // Message to present to the end user. - Message string +// HTTPError contains an HTTP status code and wrapped error. +type HTTPError struct { // HTTP status codes as registered with IANA. - Code int - - Err error // the cause + Status int + // Err is the wrapped error + Err error } -func (e *httpError) Error() string { - s := fmt.Sprintf("%d %s: %s", e.Code, http.StatusText(e.Code), e.Message) - if e.Err != nil { - return s + ": " + e.Err.Error() - } - return s -} -func (e *httpError) Unwrap() error { return e.Err } - -// Timeout reports whether this error represents a user debuggable error. -func (e *httpError) Debugable() bool { - return e.Code == http.StatusUnauthorized || e.Code == http.StatusForbidden +// NewError returns an error that contains a HTTP status and error. +func NewError(status int, err error) error { + return &HTTPError{Status: status, Err: err} } -// ErrorResponse renders an error page given an error. If the error is a -// http error from this package, a user friendly message is set, http status code, -// the ability to debug are also set. -func ErrorResponse(w http.ResponseWriter, r *http.Request, e error) { - statusCode := http.StatusInternalServerError // default status code to return - errorString := e.Error() - var canDebug bool - var requestID string - var httpError *httpError - // if this is an HTTPError, we can add some additional useful information - if errors.As(e, &httpError) { - canDebug = httpError.Debugable() - statusCode = httpError.Code - errorString = httpError.Message - } +// Error implements the `error` interface. +func (e *HTTPError) Error() string { + return http.StatusText(e.Status) + ": " + e.Err.Error() +} +// Unwrap implements the `error` Unwrap interface. +func (e *HTTPError) Unwrap() error { return e.Err } + +// Debugable reports whether this error represents a user debuggable error. +func (e *HTTPError) Debugable() bool { + return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden +} + +// RetryURL returns the requests intended destination, if any. +func (e *HTTPError) RetryURL(r *http.Request) string { + return r.FormValue(urlutil.QueryRedirectURI) +} + +type errResponse struct { + Status int + Error string + + StatusText string `json:"-"` + RequestID string `json:",omitempty"` + CanDebug bool `json:"-"` + RetryURL string `json:"-"` + Version string `json:"-"` +} + +// ErrorResponse replies to the request with the specified error message and HTTP code. +// It does not otherwise end the request; the caller should ensure no further +// writes are done to w. +func (e *HTTPError) ErrorResponse(w http.ResponseWriter, r *http.Request) { // indicate to clients that the error originates from Pomerium, not the app w.Header().Set(HeaderPomeriumResponse, "true") + w.WriteHeader(e.Status) - log.FromRequest(r).Error().Err(e).Str("http-message", errorString).Int("http-code", statusCode).Msg("http-error") - + log.FromRequest(r).Info().Err(e).Msg("httputil: ErrorResponse") + var requestID string if id, ok := log.IDFromRequest(r); ok { requestID = id } + response := errResponse{ + Status: e.Status, + StatusText: http.StatusText(e.Status), + Error: e.Error(), + RequestID: requestID, + CanDebug: e.Debugable(), + RetryURL: e.RetryURL(r), + Version: fullVersion, + } + if r.Header.Get("Accept") == "application/json" { - var response struct { - Error string `json:"error"` + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } - response.Error = errorString - writeJSONResponse(w, statusCode, response) } else { - w.WriteHeader(statusCode) - w.Header().Set("Content-Type", "text/html") - - t := struct { - Code int - Title string - Message string - RequestID string - CanDebug bool - }{ - Code: statusCode, - Title: http.StatusText(statusCode), - Message: errorString, - RequestID: requestID, - CanDebug: canDebug, - } - template.Must(frontend.NewTemplates()).ExecuteTemplate(w, "error.html", t) - } -} - -// writeJSONResponse is a helper that sets the application/json header and writes a response. -func writeJSONResponse(w http.ResponseWriter, code int, response interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - - err := json.NewEncoder(w).Encode(response) - if err != nil { - io.WriteString(w, err.Error()) + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + errorTemplate.ExecuteTemplate(w, "error.html", response) } } diff --git a/internal/httputil/errors_test.go b/internal/httputil/errors_test.go index 18e130b0e..7069d9af7 100644 --- a/internal/httputil/errors_test.go +++ b/internal/httputil/errors_test.go @@ -9,68 +9,67 @@ import ( "github.com/google/go-cmp/cmp" ) -func TestErrorResponse(t *testing.T) { - tests := []struct { - name string - rw http.ResponseWriter - r *http.Request - e *httpError - }{ - {"good", httptest.NewRecorder(), &http.Request{Method: http.MethodGet}, &httpError{Code: http.StatusBadRequest, Message: "missing id token"}}, - {"good json", httptest.NewRecorder(), &http.Request{Method: http.MethodGet, Header: http.Header{"Accept": []string{"application/json"}}}, &httpError{Code: http.StatusBadRequest, Message: "missing id token"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ErrorResponse(tt.rw, tt.r, tt.e) - }) - } -} +func TestHTTPError_ErrorResponse(t *testing.T) { -func TestError_Error(t *testing.T) { - - tests := []struct { - name string - Message string - Code int - InnerErr error - want string - }{ - {"good", "short and stout", http.StatusTeapot, nil, "418 I'm a teapot: short and stout"}, - {"nested error", "short and stout", http.StatusTeapot, errors.New("another error"), "418 I'm a teapot: short and stout: another error"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := httpError{ - Message: tt.Message, - Code: tt.Code, - Err: tt.InnerErr, - } - got := h.Error() - if diff := cmp.Diff(got, tt.want); diff != "" { - t.Errorf("Error.Error() = %s", diff) - } - }) - } -} - -func Test_httpError_Error(t *testing.T) { tests := []struct { name string - message string - code int - err error - want string + Status int + Err error + reqType string + + wantStatus int + wantBody string }{ - {"good", "foobar", 200, nil, "200 OK: foobar"}, - {"no code", "foobar", 0, nil, "500 Internal Server Error: foobar"}, - {"no message or code", "", 0, nil, "500 Internal Server Error: Internal Server Error"}, + {"404 json", http.StatusNotFound, errors.New("route not known"), "application/json", http.StatusNotFound, "{\"Status\":404,\"Error\":\"Not Found: route not known\"}\n"}, + {"404 html", http.StatusNotFound, errors.New("route not known"), "", http.StatusNotFound, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - e := Error(tt.message, tt.code, tt.err) - if got := e.Error(); got != tt.want { - t.Errorf("httpError.Error() = %v, want %v", got, tt.want) + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := NewError(tt.Status, tt.Err) + var e *HTTPError + if errors.As(err, &e) { + e.ErrorResponse(w, r) + } else { + http.Error(w, "coulnd't convert error type", http.StatusTeapot) + } + }) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept", tt.reqType) + w := httptest.NewRecorder() + fn(w, r) + if diff := cmp.Diff(tt.wantStatus, w.Code); diff != "" { + t.Errorf("ErrorResponse status:\n %s", diff) } + if tt.reqType == "application/json" { + if diff := cmp.Diff(tt.wantBody, w.Body.String()); diff != "" { + t.Errorf("ErrorResponse status:\n %s", diff) + } + } + + }) + } +} + +func TestNewError(t *testing.T) { + tests := []struct { + name string + status int + err error + wantErr bool + }{ + {"good", 404, errors.New("error"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewError(tt.status, tt.err) + if (err != nil) != tt.wantErr { + t.Errorf("NewError() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && !errors.Is(err, tt.err) { + t.Errorf("NewError() unwrap fail = %v, wantErr %v", err, tt.wantErr) + } + }) } } diff --git a/internal/httputil/handlers.go b/internal/httputil/handlers.go index 48fd73463..0e55fe496 100644 --- a/internal/httputil/handlers.go +++ b/internal/httputil/handlers.go @@ -1,6 +1,8 @@ package httputil // import "github.com/pomerium/pomerium/internal/httputil" import ( + "errors" + "fmt" "net/http" ) @@ -14,7 +16,7 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) if r.Method == http.MethodGet { - w.Write([]byte(http.StatusText(http.StatusOK))) + fmt.Fprintln(w, http.StatusText(http.StatusOK)) } } @@ -24,3 +26,22 @@ func Redirect(w http.ResponseWriter, r *http.Request, url string, code int) { w.Header().Set(HeaderPomeriumResponse, "true") http.Redirect(w, r, url, code) } + +// The HandlerFunc type is an adapter to allow the use of +// ordinary functions as HTTP handlers. If f is a function +// with the appropriate signature, HandlerFunc(f) is a +// Handler that calls f. +// +// adapted from std library to suppport error wrapping +type HandlerFunc func(http.ResponseWriter, *http.Request) error + +// ServeHTTP calls f(w, r) error. +func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := f(w, r); err != nil { + var e *HTTPError + if !errors.As(err, &e) { + e = &HTTPError{http.StatusInternalServerError, err} + } + e.ErrorResponse(w, r) + } +} diff --git a/internal/httputil/handlers_test.go b/internal/httputil/handlers_test.go index 87e794859..2b7cb48be 100644 --- a/internal/httputil/handlers_test.go +++ b/internal/httputil/handlers_test.go @@ -1,9 +1,12 @@ package httputil import ( + "errors" "net/http" "net/http/httptest" "testing" + + "github.com/google/go-cmp/cmp" ) func TestHealthCheck(t *testing.T) { @@ -66,3 +69,26 @@ func TestRedirect(t *testing.T) { }) } } + +func TestHandlerFunc_ServeHTTP(t *testing.T) { + + tests := []struct { + name string + f HandlerFunc + wantBody string + }{ + {"good http error", func(w http.ResponseWriter, r *http.Request) error { return NewError(404, errors.New("404")) }, "{\"Status\":404,\"Error\":\"Not Found: 404\"}\n"}, + {"good std error", func(w http.ResponseWriter, r *http.Request) error { return errors.New("404") }, "{\"Status\":500,\"Error\":\"Internal Server Error: 404\"}\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + tt.f.ServeHTTP(w, r) + if diff := cmp.Diff(tt.wantBody, w.Body.String()); diff != "" { + t.Errorf("ErrorResponse status:\n %s", diff) + } + }) + } +} diff --git a/internal/httputil/router.go b/internal/httputil/router.go index 787d570f2..b6276b1cb 100644 --- a/internal/httputil/router.go +++ b/internal/httputil/router.go @@ -14,6 +14,9 @@ func NewRouter() *mux.Router { // CSRFFailureHandler sets a HTTP 403 Forbidden status and writes the // CSRF failure reason to the response. -func CSRFFailureHandler(w http.ResponseWriter, r *http.Request) { - ErrorResponse(w, r, Error("CSRF Failure", http.StatusForbidden, csrf.FailureReason(r))) +func CSRFFailureHandler(w http.ResponseWriter, r *http.Request) error { + if err := csrf.FailureReason(r); err != nil { + return NewError(http.StatusBadRequest, csrf.FailureReason(r)) + } + return nil } diff --git a/internal/httputil/router_test.go b/internal/httputil/router_test.go index 42b82481c..60bfb321c 100644 --- a/internal/httputil/router_test.go +++ b/internal/httputil/router_test.go @@ -1,43 +1,12 @@ package httputil import ( - "net/http" - "net/http/httptest" "reflect" "testing" - "github.com/google/go-cmp/cmp" "github.com/gorilla/mux" ) -func TestCSRFFailureHandler(t *testing.T) { - - tests := []struct { - name string - - wantBody string - wantStatus int - }{ - {"basic csrf failure", "{\"error\":\"CSRF Failure\"}\n", http.StatusForbidden}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.Header.Set("Accept", "application/json") - w := httptest.NewRecorder() - CSRFFailureHandler(w, r) - gotBody := w.Body.String() - gotStatus := w.Result().StatusCode - if diff := cmp.Diff(gotBody, tt.wantBody); diff != "" { - t.Errorf("RetrieveSession() = %s", diff) - } - if diff := cmp.Diff(gotStatus, tt.wantStatus); diff != "" { - t.Errorf("RetrieveSession() = %s", diff) - } - }) - } -} - func TestNewRouter(t *testing.T) { tests := []struct { name string diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 2628a4e0d..af13992b1 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -28,14 +28,14 @@ func SetHeaders(headers map[string]string) func(next http.Handler) http.Handler // the correspdoning client secret key func ValidateSignature(sharedSecret string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "middleware.ValidateSignature") defer span.End() if err := ValidateRequestURL(r, sharedSecret); err != nil { - httputil.ErrorResponse(w, r, httputil.Error("invalid signature", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } next.ServeHTTP(w, r.WithContext(ctx)) + return nil }) } } diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index b42bbc6e4..f3d00a4af 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -170,7 +170,7 @@ func TestValidateSignature(t *testing.T) { wantBody string }{ {"good", "secret", "secret", http.StatusOK, http.StatusText(http.StatusOK)}, - {"secret mistmatch", "secret", "hunter42", http.StatusBadRequest, "{\"error\":\"invalid signature\"}\n"}, + {"secret mistmatch", "secret", "hunter42", http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: internal/urlutil: hmac failed\"}\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/urlutil/signed.go b/internal/urlutil/signed.go index 039f5bbb4..a07dba3a1 100644 --- a/internal/urlutil/signed.go +++ b/internal/urlutil/signed.go @@ -65,12 +65,12 @@ func (su *SignedURL) Validate() error { issued, err := newNumericDateFromString(params.Get(QueryHmacIssued)) if err != nil { - return fmt.Errorf("internal/urlutil: issued %w", err) + return err } expiry, err := newNumericDateFromString(params.Get(QueryHmacExpiry)) if err != nil { - return fmt.Errorf("internal/urlutil: expiry %w", err) + return err } if expiry != nil && now.Add(-DefaultLeeway).After(expiry.Time()) { @@ -86,7 +86,7 @@ func (su *SignedURL) Validate() error { sig, su.key) if !validHMAC { - return fmt.Errorf("internal/urlutil: hmac failed %s", su.uri.String()) + return fmt.Errorf("internal/urlutil: hmac failed") } return nil } diff --git a/internal/urlutil/url.go b/internal/urlutil/url.go index 32109b065..f8ed0dd4d 100644 --- a/internal/urlutil/url.go +++ b/internal/urlutil/url.go @@ -52,7 +52,7 @@ func ValidateURL(u *url.URL) error { return fmt.Errorf("nil url") } if u.Scheme == "" { - return fmt.Errorf("%s url does contain a valid scheme. Did you mean https://%s?", u.String(), u.String()) + return fmt.Errorf("%s url does contain a valid scheme", u.String()) } if u.Host == "" { return fmt.Errorf("%s url does contain a valid hostname", u.String()) diff --git a/proxy/forward_auth.go b/proxy/forward_auth.go index cbf2cef15..e830bb7e3 100644 --- a/proxy/forward_auth.go +++ b/proxy/forward_auth.go @@ -16,13 +16,13 @@ func (p *Proxy) registerFwdAuthHandlers() http.Handler { r.StrictSlash(true) r.Use(sessions.RetrieveSession(p.sessionStore)) - r.Handle("/verify", http.HandlerFunc(p.nginxCallback)). + r.Handle("/verify", httputil.HandlerFunc(p.nginxCallback)). Queries("uri", "{uri}", urlutil.QuerySessionEncrypted, "", urlutil.QueryRedirectURI, "") - r.Handle("/", http.HandlerFunc(p.postSessionSetNOP)). + r.Handle("/", httputil.HandlerFunc(p.postSessionSetNOP)). Queries("uri", "{uri}", urlutil.QuerySessionEncrypted, "", urlutil.QueryRedirectURI, "") - r.Handle("/", http.HandlerFunc(p.traefikCallback)). + r.Handle("/", httputil.HandlerFunc(p.traefikCallback)). HeadersRegexp(httputil.HeaderForwardedURI, urlutil.QuerySessionEncrypted) r.Handle("/", p.Verify(false)).Queries("uri", "{uri}") r.Handle("/verify", p.Verify(true)).Queries("uri", "{uri}") @@ -31,37 +31,39 @@ func (p *Proxy) registerFwdAuthHandlers() http.Handler { } // postSessionSetNOP after successfully setting the -func (p *Proxy) postSessionSetNOP(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) postSessionSetNOP(w http.ResponseWriter, r *http.Request) error { w.Header().Set("Content-Type", "text/plain; charset=utf-8") httputil.Redirect(w, r, r.FormValue(urlutil.QueryRedirectURI), http.StatusFound) + return nil } -func (p *Proxy) nginxCallback(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) nginxCallback(w http.ResponseWriter, r *http.Request) error { encryptedSession := r.FormValue(urlutil.QuerySessionEncrypted) if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + + return httputil.NewError(http.StatusBadRequest, err) } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) + return nil } -func (p *Proxy) traefikCallback(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) traefikCallback(w http.ResponseWriter, r *http.Request) error { forwardedURL, err := url.Parse(r.Header.Get(httputil.HeaderForwardedURI)) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } q := forwardedURL.Query() redirectURLString := q.Get(urlutil.QueryRedirectURI) encryptedSession := q.Get(urlutil.QuerySessionEncrypted) if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + + return httputil.NewError(http.StatusBadRequest, err) } w.Header().Set("Content-Type", "text/plain; charset=utf-8") httputil.Redirect(w, r, redirectURLString, http.StatusFound) + return nil } // Verify checks a user's credentials for an arbitrary host. If the user @@ -70,18 +72,16 @@ func (p *Proxy) traefikCallback(w http.ResponseWriter, r *http.Request) { // will be redirected to the authenticate service to sign in with their identity // provider. If the user is unauthorized, a `401` error is returned. func (p *Proxy) Verify(verifyOnly bool) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { uri, err := urlutil.ParseAndValidateURL(r.FormValue("uri")) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("bad verification uri", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } s, err := sessions.FromContext(r.Context()) if errors.Is(err, sessions.ErrNoSessionFound) || errors.Is(err, sessions.ErrExpired) { if verifyOnly { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return + return httputil.NewError(http.StatusUnauthorized, err) } authN := *p.authenticateSigninURL q := authN.Query() @@ -90,25 +90,24 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler { q.Set(urlutil.QueryForwardAuth, urlutil.StripPort(r.Host)) // add fwd auth to trusted audience authN.RawQuery = q.Encode() httputil.Redirect(w, r, urlutil.NewSignedURL(p.SharedKey, &authN).String(), http.StatusFound) - return + return nil } else if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return + return httputil.NewError(http.StatusUnauthorized, err) } // depending on the configuration of the fronting proxy, the request Host // and/or `X-Forwarded-Host` may be untrustd or change so we reverify // the session's validity against the supplied uri if err := s.Verify(uri.Hostname()); err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return + return httputil.NewError(http.StatusUnauthorized, err) } p.addPomeriumHeaders(w, r) - if err := p.authorize(uri.Host, w, r); err != nil { - return + if err := p.authorize(uri.Host, r); err != nil { + return err } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Access to %s is allowed.", uri.Host) + return nil }) } diff --git a/proxy/forward_auth_test.go b/proxy/forward_auth_test.go index ef1b39b88..9afaef325 100644 --- a/proxy/forward_auth_test.go +++ b/proxy/forward_auth_test.go @@ -42,19 +42,19 @@ func TestProxy_ForwardAuth(t *testing.T) { }{ {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK, "Access to some.domain.example is allowed."}, {"good verify only, no redirect", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK, ""}, - {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{LoadError: sessions.ErrInvalidAudience}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"error\":\"internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, - {"bad naked domain uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "a.naked.domain", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"error\":\"bad verification uri\"}\n"}, - {"bad naked domain uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "a.naked.domain", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"error\":\"bad verification uri\"}\n"}, - {"bad empty verification uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", " ", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"error\":\"bad verification uri\"}\n"}, - {"bad empty verification uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", " ", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"error\":\"bad verification uri\"}\n"}, - {"not authorized", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"error\":\"user@test.example is not authorized for some.domain.example\"}\n"}, - {"not authorized verify endpoint", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"error\":\"user@test.example is not authorized for some.domain.example\"}\n"}, + {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{LoadError: sessions.ErrInvalidAudience}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, + {"bad naked domain uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "a.naked.domain", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, + {"bad naked domain uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "a.naked.domain", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, + {"bad empty verification uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", " ", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, + {"bad empty verification uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", " ", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, + {"not authorized", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: user@test.example is not authorized for some.domain.example\"}\n"}, + {"not authorized verify endpoint", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: user@test.example is not authorized for some.domain.example\"}\n"}, {"not authorized expired, redirect to auth", opts, sessions.ErrExpired, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusFound, ""}, - {"not authorized expired, don't redirect!", opts, sessions.ErrExpired, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"error\":\"internal/sessions: validation failed, token is expired (exp)\"}\n"}, - {"not authorized because of error", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeError: errors.New("authz error")}, http.StatusInternalServerError, "{\"error\":\"authz error\"}\n"}, - {"not authorized expired, do not redirect to auth", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"error\":\"internal/sessions: validation failed, token is expired (exp)\"}\n"}, - {"not authorized, bad audience request uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Audience: []string{"not.domain.example"}, Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"error\":\"internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, - {"not authorized, bad audience verify uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://fwdauth.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Audience: []string{"some.domain.example"}, Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"error\":\"internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, + {"not authorized expired, don't redirect!", opts, sessions.ErrExpired, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: internal/sessions: validation failed, token is expired (exp)\"}\n"}, + {"not authorized because of error", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeError: errors.New("authz error")}, http.StatusInternalServerError, "{\"Status\":500,\"Error\":\"Internal Server Error: authz error\"}\n"}, + {"not authorized expired, do not redirect to auth", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: internal/sessions: validation failed, token is expired (exp)\"}\n"}, + {"not authorized, bad audience request uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Audience: []string{"not.domain.example"}, Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, + {"not authorized, bad audience verify uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://fwdauth.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Audience: []string{"some.domain.example"}, Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: internal/sessions: validation failed, invalid audience claim (aud)\"}\n"}, // traefik {"good traefik callback", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusFound, ""}, {"bad traefik callback bad session", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString + "garbage"}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest, ""}, diff --git a/proxy/handlers.go b/proxy/handlers.go index e3abf7602..25bd16f0c 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -29,12 +29,12 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { p.cookieSecret, csrf.Secure(p.cookieOptions.Secure), csrf.CookieName(fmt.Sprintf("%s_csrf", p.cookieOptions.Name)), - csrf.ErrorHandler(http.HandlerFunc(httputil.CSRFFailureHandler)), + csrf.ErrorHandler(httputil.HandlerFunc(httputil.CSRFFailureHandler)), )) // dashboard endpoints can be used by user's to view, or modify their session - h.HandleFunc("/", p.UserDashboard).Methods(http.MethodGet) - h.HandleFunc("/impersonate", p.Impersonate).Methods(http.MethodPost) - h.HandleFunc("/sign_out", p.SignOut).Methods(http.MethodGet, http.MethodPost) + h.Path("/").Handler(httputil.HandlerFunc(p.UserDashboard)).Methods(http.MethodGet) + h.Path("/impersonate").Handler(httputil.HandlerFunc(p.Impersonate)).Methods(http.MethodPost) + h.Path("/sign_out").HandlerFunc(p.SignOut).Methods(http.MethodGet, http.MethodPost) // Authenticate service callback handlers and middleware // callback used to set route-scoped session and redirect back to destination @@ -42,14 +42,16 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router { c := r.PathPrefix(dashboardURL + "/callback").Subrouter() c.Use(middleware.ValidateSignature(p.SharedKey)) - c.Path("/").HandlerFunc(p.ProgrammaticCallback).Methods(http.MethodGet). + c.Path("/"). + Handler(httputil.HandlerFunc(p.ProgrammaticCallback)). + Methods(http.MethodGet). Queries(urlutil.QueryIsProgrammatic, "true") - c.Path("/").HandlerFunc(p.Callback).Methods(http.MethodGet) + c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet) // Programmatic API handlers and middleware a := r.PathPrefix(dashboardURL + "/api").Subrouter() // login api handler generates a user-navigable login url to authenticate - a.HandleFunc("/v1/login", p.ProgrammaticLogin). + a.Path("/v1/login").Handler(httputil.HandlerFunc(p.ProgrammaticLogin)). Queries(urlutil.QueryRedirectURI, ""). Methods(http.MethodGet) @@ -84,17 +86,15 @@ func (p *Proxy) SignOut(w http.ResponseWriter, r *http.Request) { // UserDashboard lets users investigate, and refresh their current session. // It also contains certain administrative actions like user impersonation. // Nota bene: This endpoint does authentication, not authorization. -func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) error { session, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r, err) - return + return err } isAdmin, err := p.AuthorizeClient.IsAdmin(r.Context(), session) if err != nil { - httputil.ErrorResponse(w, r, err) - return + return err } p.templates.ExecuteTemplate(w, "dashboard.html", map[string]interface{}{ @@ -105,23 +105,23 @@ func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) { "ImpersonateEmail": urlutil.QueryImpersonateEmail, "ImpersonateGroups": urlutil.QueryImpersonateGroups, }) + return nil } // Impersonate takes the result of a form and adds user impersonation details // to the user's current user sessions state if the user is currently an // administrative user. Requests are redirected back to the user dashboard. -func (p *Proxy) Impersonate(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) Impersonate(w http.ResponseWriter, r *http.Request) error { session, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r, err) - return + return err } isAdmin, err := p.AuthorizeClient.IsAdmin(r.Context(), session) - if err != nil || !isAdmin { - errStr := fmt.Sprintf("%s is not an administrator", session.RequestEmail()) - httpErr := httputil.Error(errStr, http.StatusForbidden, err) - httputil.ErrorResponse(w, r, httpErr) - return + if err != nil { + return err + } + if !isAdmin { + return httputil.NewError(http.StatusForbidden, fmt.Errorf("%s is not an administrator", session.RequestEmail())) } // OK to impersonation redirectURL := urlutil.GetAbsoluteURL(r) @@ -134,20 +134,20 @@ func (p *Proxy) Impersonate(w http.ResponseWriter, r *http.Request) { q.Set(urlutil.QueryImpersonateGroups, r.FormValue(urlutil.QueryImpersonateGroups)) signinURL.RawQuery = q.Encode() httputil.Redirect(w, r, urlutil.NewSignedURL(p.SharedKey, &signinURL).String(), http.StatusFound) + return nil } // Callback handles the result of a successful call to the authenticate service // and is responsible setting returned per-route session. -func (p *Proxy) Callback(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) Callback(w http.ResponseWriter, r *http.Request) error { redirectURLString := r.FormValue(urlutil.QueryRedirectURI) encryptedSession := r.FormValue(urlutil.QuerySessionEncrypted) if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } - httputil.Redirect(w, r, redirectURLString, http.StatusFound) + return nil } // saveCallbackSession takes an encrypted per-route session token, and decrypts @@ -172,11 +172,10 @@ func (p *Proxy) saveCallbackSession(w http.ResponseWriter, r *http.Request, enct // ProgrammaticLogin returns a signed url that can be used to login // using the authenticate service. -func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) error { redirectURI, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI)) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("malformed redirect uri", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } signinURL := *p.authenticateSigninURL callbackURI := urlutil.GetAbsoluteURL(r) @@ -191,31 +190,30 @@ func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(response)) + return nil } // ProgrammaticCallback handles a successful call to the authenticate service. // In addition to returning the individual route session (JWT) it also returns // the refresh token. -func (p *Proxy) ProgrammaticCallback(w http.ResponseWriter, r *http.Request) { +func (p *Proxy) ProgrammaticCallback(w http.ResponseWriter, r *http.Request) error { redirectURLString := r.FormValue(urlutil.QueryRedirectURI) encryptedSession := r.FormValue(urlutil.QuerySessionEncrypted) redirectURL, err := urlutil.ParseAndValidateURL(redirectURLString) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("malformed redirect uri", http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } rawJWT, err := p.saveCallbackSession(w, r, encryptedSession) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusBadRequest, err)) - return + return httputil.NewError(http.StatusBadRequest, err) } q := redirectURL.Query() q.Set(urlutil.QueryPomeriumJWT, string(rawJWT)) q.Set(urlutil.QueryRefreshToken, r.FormValue(urlutil.QueryRefreshToken)) redirectURL.RawQuery = q.Encode() - httputil.Redirect(w, r, redirectURL.String(), http.StatusFound) + return nil } diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index c4a5201ad..b840abf52 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -103,7 +103,7 @@ func TestProxy_UserDashboard(t *testing.T) { r.Header.Set("Accept", "application/json") w := httptest.NewRecorder() - p.UserDashboard(w, r) + httputil.HandlerFunc(p.UserDashboard).ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("status code: got %v want %v", status, tt.wantStatus) t.Errorf("\n%+v", opts) @@ -139,7 +139,7 @@ func TestProxy_Impersonate(t *testing.T) { {"good", false, opts, errors.New("error"), http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: true}, http.StatusInternalServerError}, {"session load error", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &sessions.MockSessionStore{LoadError: errors.New("err"), Session: &sessions.State{Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, {"non admin users rejected", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: false}, http.StatusForbidden}, - {"non admin users rejected on error", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: true, IsAdminError: errors.New("err")}, http.StatusForbidden}, + {"non admin users rejected on error", false, opts, nil, http.MethodPost, "user@blah.com", "", "", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: true, IsAdminError: errors.New("err")}, http.StatusInternalServerError}, {"groups", false, opts, nil, http.MethodPost, "user@blah.com", "group1,group2", "", &mock.Encoder{}, &sessions.MockSessionStore{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), Email: "user@test.example"}}, clients.MockAuthorize{IsAdminResponse: true}, http.StatusFound}, } for _, tt := range tests { @@ -165,7 +165,7 @@ func TestProxy_Impersonate(t *testing.T) { r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") w := httptest.NewRecorder() - p.Impersonate(w, r) + httputil.HandlerFunc(p.Impersonate).ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("status code: got %v want %v", status, tt.wantStatus) t.Errorf("\n%+v", opts) @@ -289,7 +289,7 @@ func TestProxy_Callback(t *testing.T) { } w := httptest.NewRecorder() - p.Callback(w, r) + httputil.HandlerFunc(p.Callback).ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("status code: got %v want %v", status, tt.wantStatus) t.Errorf("\n%+v", w.Body.String()) @@ -326,7 +326,7 @@ func TestProxy_ProgrammaticLogin(t *testing.T) { {"good body not checked", opts, http.MethodGet, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{urlutil.QueryRedirectURI: "http://localhost"}, http.StatusOK, ""}, {"good body not checked", opts, http.MethodGet, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{urlutil.QueryRedirectURI: "http://localhost"}, http.StatusOK, ""}, {"router miss, bad redirect_uri query", opts, http.MethodGet, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{"bad_redirect_uri": "http://localhost"}, http.StatusNotFound, ""}, - {"bad redirect_uri missing scheme", opts, http.MethodGet, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{urlutil.QueryRedirectURI: "localhost"}, http.StatusBadRequest, "{\"error\":\"malformed redirect uri\"}\n"}, + {"bad redirect_uri missing scheme", opts, http.MethodGet, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{urlutil.QueryRedirectURI: "localhost"}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: localhost url does contain a valid scheme\"}\n"}, {"bad http method", opts, http.MethodPost, "https", "corp.example.example", "/.pomerium/api/v1/login", nil, map[string]string{urlutil.QueryRedirectURI: "http://localhost"}, http.StatusMethodNotAllowed, ""}, } for _, tt := range tests { @@ -430,7 +430,7 @@ func TestProxy_ProgrammaticCallback(t *testing.T) { } w := httptest.NewRecorder() - p.ProgrammaticCallback(w, r) + httputil.HandlerFunc(p.ProgrammaticCallback).ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("status code: got %v want %v", status, tt.wantStatus) t.Errorf("\n%+v", w.Body.String()) diff --git a/proxy/middleware.go b/proxy/middleware.go index 7accdd780..29bfd5bd0 100644 --- a/proxy/middleware.go +++ b/proxy/middleware.go @@ -26,7 +26,7 @@ const ( // AuthenticateSession is middleware to enforce a valid authentication // session state is retrieved from the users's request context. func (p *Proxy) AuthenticateSession(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "proxy.AuthenticateSession") defer span.End() @@ -34,18 +34,17 @@ func (p *Proxy) AuthenticateSession(next http.Handler) http.Handler { log.FromRequest(r).Debug().Err(err).Msg("proxy: authenticate session") p.sessionStore.ClearSession(w, r) if s != nil && s.Programmatic { - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return + return httputil.NewError(http.StatusUnauthorized, err) } signinURL := *p.authenticateSigninURL q := signinURL.Query() q.Set(urlutil.QueryRedirectURI, urlutil.GetAbsoluteURL(r).String()) signinURL.RawQuery = q.Encode() httputil.Redirect(w, r, urlutil.NewSignedURL(p.SharedKey, &signinURL).String(), http.StatusFound) - return } p.addPomeriumHeaders(w, r) next.ServeHTTP(w, r.WithContext(ctx)) + return nil }) } @@ -65,31 +64,28 @@ func (p *Proxy) addPomeriumHeaders(w http.ResponseWriter, r *http.Request) { // AuthorizeSession is middleware to enforce a user is authorized for a request // session state is retrieved from the users's request context. func (p *Proxy) AuthorizeSession(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "proxy.AuthorizeSession") defer span.End() - if err := p.authorize(r.Host, w, r.WithContext(ctx)); err != nil { + if err := p.authorize(r.Host, r.WithContext(ctx)); err != nil { log.FromRequest(r).Debug().Err(err).Msg("proxy: AuthorizeSession") - return + return err } next.ServeHTTP(w, r.WithContext(ctx)) + return nil }) } -func (p *Proxy) authorize(host string, w http.ResponseWriter, r *http.Request) error { +func (p *Proxy) authorize(host string, r *http.Request) error { s, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r, httputil.Error("", http.StatusUnauthorized, err)) - return err + return httputil.NewError(http.StatusUnauthorized, err) } authorized, err := p.AuthorizeClient.Authorize(r.Context(), host, s) if err != nil { - httputil.ErrorResponse(w, r, err) return err } else if !authorized { - err = fmt.Errorf("%s is not authorized for %s", s.RequestEmail(), host) - httputil.ErrorResponse(w, r, httputil.Error(err.Error(), http.StatusUnauthorized, err)) - return err + return httputil.NewError(http.StatusUnauthorized, fmt.Errorf("%s is not authorized for %s", s.RequestEmail(), host)) } return nil } @@ -98,13 +94,12 @@ func (p *Proxy) authorize(host string, w http.ResponseWriter, r *http.Request) e // email, and group. Session state is retrieved from the users's request context func (p *Proxy) SignRequest(signer encoding.Marshaler) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "proxy.SignRequest") defer span.End() s, err := sessions.FromContext(r.Context()) if err != nil { - httputil.ErrorResponse(w, r.WithContext(ctx), httputil.Error("", http.StatusForbidden, err)) - return + return httputil.NewError(http.StatusForbidden, err) } newSession := s.NewSession(r.Host, []string{r.Host}) jwt, err := signer.Marshal(newSession.RouteSession()) @@ -115,6 +110,7 @@ func (p *Proxy) SignRequest(signer encoding.Marshaler) func(next http.Handler) h w.Header().Set(HeaderJWT, string(jwt)) } next.ServeHTTP(w, r.WithContext(ctx)) + return nil }) } } diff --git a/proxy/proxy.go b/proxy/proxy.go index 85d705e42..93e985c9b 100755 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -176,8 +176,8 @@ func (p *Proxy) UpdatePolicies(opts *config.Options) error { log.Warn().Msg("proxy: configuration has no policies") } r := httputil.NewRouter() - r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httputil.ErrorResponse(w, r, httputil.Error(fmt.Sprintf("%s route unknown", r.Host), http.StatusNotFound, nil)) + r.NotFoundHandler = httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return httputil.NewError(http.StatusNotFound, fmt.Errorf("%s route unknown", r.Host)) }) r.SkipClean(true) r.StrictSlash(true)