diff --git a/.dockerignore b/.dockerignore index 62cb5190c..e29b8c524 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,5 @@ dist/ bin/ +ui/dist/index.js +ui/dist/index.css +ui/node_modules/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 03f50ff8f..65a2015fa 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,12 +10,18 @@ jobs: strategy: matrix: go-version: [1.17.x] + node-version: [16.x] platform: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: set env vars run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - uses: actions/checkout@v2 @@ -50,10 +56,18 @@ jobs: cover: runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.17.x] + node-version: [16.x] steps: - uses: actions/setup-go@v2 with: - go-version: 1.17.x + go-version: ${{ matrix.go-version }} + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} - uses: actions/checkout@v2 with: @@ -99,6 +113,7 @@ jobs: fail-fast: false matrix: go-version: [1.17.x] + node-version: [16.x] platform: [ubuntu-latest] deployment: [kubernetes, multi, nginx, single, traefik] idp: [auth0, azure, github, gitlab, google, oidc, okta, onelogin, ping] @@ -107,6 +122,11 @@ jobs: - uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: set env vars run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - uses: actions/checkout@v2 @@ -141,12 +161,18 @@ jobs: strategy: matrix: go-version: [1.17.x] + node-version: [16.x] platform: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} + + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - uses: actions/checkout@v2 with: fetch-depth: 0 diff --git a/.gitignore b/.gitignore index 52ce61052..5743f1bae 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ tags # dependencies ui/node_modules ui/bower_components +ui/dist/index.js +ui/dist/index.css # for building static assets node_modules @@ -74,7 +76,6 @@ lib/core/MetadataBlog.js translated_docs build/ -yarn.lock node_modules i18n/* docs/.vuepress/dist/ @@ -91,4 +92,6 @@ docs/.vuepress/dist/ /bazel-* internal/envoy/files/envoy-*-????? internal/envoy/files/envoy-*-?????.sha256 -internal/envoy/files/envoy-*-?????.version \ No newline at end of file +internal/envoy/files/envoy-*-?????.version + +yarn-error.log diff --git a/Dockerfile b/Dockerfile index 5cd0a6423..567f333fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,19 @@ -FROM golang:latest as build +FROM node:16 as ui +WORKDIR /build + +COPY .git ./.git +COPY Makefile ./Makefile + +# download yarn dependencies +COPY ui/yarn.lock ./ui/yarn.lock +COPY ui/package.json ./ui/package.json +RUN make yarn + +# build ui +COPY ./ui/ ./ui/ +RUN make build-ui + +FROM golang:1.17-buster as build WORKDIR /go/src/github.com/pomerium/pomerium RUN apt-get update \ @@ -8,10 +23,10 @@ RUN apt-get update \ COPY go.mod go.sum ./ RUN go mod download COPY . . +COPY --from=ui /build/ui/dist ./ui/dist # build -RUN make build-deps -RUN make build NAME=pomerium +RUN make build-go NAME=pomerium RUN touch /config.yaml # build our own root trust store from current stable diff --git a/Makefile b/Makefile index b69204381..3f9b0adb5 100644 --- a/Makefile +++ b/Makefile @@ -71,25 +71,29 @@ tag: ## Create a new git tag to prepare to build a release git tag -sa $(VERSION) -m "$(VERSION)" @echo "Run git push origin $(VERSION) to push your new tag to GitHub." -.PHONY: frontend -frontend: ## Runs go generate on the static assets package. - @echo "==> $@" - @CGO_ENABLED=0 GO111MODULE=on $(GO) generate github.com/pomerium/pomerium/internal/frontend - .PHONY: proto proto: @echo "==> $@" cd pkg/grpc && ./protoc.bash .PHONY: build -build: build-deps ## Builds dynamic executables and/or packages. +build: build-go build-ui + @echo "==> $@" + +.PHONY: build-debug +build-debug: build-deps build-ui ## Builds binaries appropriate for debugging + @echo "==> $@" + @CGO_ENABLED=0 GO111MODULE=on $(GO) build -gcflags="all=-N -l" -o $(BINDIR)/$(NAME) ./cmd/"$(NAME)" + +.PHONY: build-go +build-go: build-deps @echo "==> $@" @CGO_ENABLED=0 GO111MODULE=on $(GO) build -tags "$(BUILDTAGS)" ${GO_LDFLAGS} -o $(BINDIR)/$(NAME) ./cmd/"$(NAME)" -.PHONY: build-debug -build-debug: build-deps ## Builds binaries appropriate for debugging +.PHONY: build-ui +build-ui: yarn @echo "==> $@" - @CGO_ENABLED=0 GO111MODULE=on $(GO) build -gcflags="all=-N -l" -o $(BINDIR)/$(NAME) ./cmd/"$(NAME)" + @cd ui; yarn build .PHONY: lint lint: ## Verifies `golint` passes. @@ -129,6 +133,11 @@ snapshot: build-deps ## Builds the cross-compiled binaries, naming them in such @echo "==> $@" @goreleaser release --rm-dist -f .github/goreleaser.yaml --snapshot +.PHONY: yarn +yarn: + @echo "==> $@" + cd ui ; yarn install + .PHONY: help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 640d3094f..5a7163c9f 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -6,11 +6,8 @@ import ( "context" "errors" "fmt" - "html/template" - "net/url" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/identity" "github.com/pomerium/pomerium/internal/identity/oauth" "github.com/pomerium/pomerium/internal/log" @@ -48,8 +45,6 @@ func ValidateOptions(o *config.Options) error { // Authenticate contains data required to run the authenticate service. type Authenticate struct { - templates *template.Template - options *config.AtomicOptions provider *identity.AtomicAuthenticator state *atomicAuthenticateState @@ -58,10 +53,9 @@ type Authenticate struct { // New validates and creates a new authenticate service from a set of Options. func New(cfg *config.Config) (*Authenticate, error) { a := &Authenticate{ - templates: template.Must(frontend.NewTemplates()), - options: config.NewAtomicOptions(), - provider: identity.NewAtomicAuthenticator(), - state: newAtomicAuthenticateState(newAuthenticateState()), + options: config.NewAtomicOptions(), + provider: identity.NewAtomicAuthenticator(), + state: newAtomicAuthenticateState(newAuthenticateState()), } state, err := newAuthenticateStateFromConfig(cfg) @@ -123,17 +117,3 @@ func (a *Authenticate) updateProvider(cfg *config.Config) error { return nil } - -// buildURLValues creates a new url.Values map by traversing the keys in `defaults` and using the values -// from `values` if they exist, otherwise the provided defaults -func buildURLValues(values, defaults url.Values) url.Values { - result := make(url.Values) - for k, vs := range defaults { - if values.Has(k) { - result[k] = values[k] - } else if vs != nil { - result[k] = vs - } - } - return result -} diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 25a51591c..6175dce16 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -33,6 +33,7 @@ import ( "github.com/pomerium/pomerium/pkg/grpc/directory" "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" + "github.com/pomerium/pomerium/ui" ) // Handler returns the authenticate service's handler chain. @@ -99,7 +100,27 @@ func (a *Authenticate) mountDashboard(r *mux.Router) { sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn)) sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut)) sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState)) - sr.Path("/device-enrolled").Handler(handlers.DeviceEnrolled()) + sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + authenticateURL, err := a.options.Load().GetAuthenticateURL() + if err != nil { + return err + } + handlers.DeviceEnrolled(authenticateURL, a.state.Load().sharedKey).ServeHTTP(w, r) + return nil + })) + for _, fileName := range []string{ + "apple-touch-icon.png", + "favicon-16x16.png", + "favicon-32x32.png", + "favicon.ico", + "index.css", + "index.js", + } { + fileName := fileName + sr.Path("/" + fileName).Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return ui.ServeFile(w, r, fileName) + })) + } cr := sr.PathPrefix("/callback").Subrouter() cr.Use(func(h http.Handler) http.Handler { @@ -463,6 +484,11 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error { state := a.state.Load() + authenticateURL, err := a.options.Load().GetAuthenticateURL() + if err != nil { + return err + } + s, err := a.getSessionFromCtx(ctx) if err != nil { s.ID = uuid.New().String() @@ -500,52 +526,17 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error { groups = append(groups, pbDirectoryGroup) } - signoutURL, err := a.getSignOutURL(r) - if err != nil { - return fmt.Errorf("invalid signout url: %w", err) - } - - webAuthnURL, err := a.getWebAuthnURL(r.URL.Query()) - if err != nil { - return fmt.Errorf("invalid webauthn url: %w", err) - } - - type DeviceCredentialInfo struct { - ID string - } - var currentDeviceCredentials, otherDeviceCredentials []DeviceCredentialInfo - for _, id := range pbUser.GetDeviceCredentialIds() { - selected := false - for _, c := range pbSession.GetDeviceCredentials() { - if c.GetId() == id { - selected = true - } - } - if selected { - currentDeviceCredentials = append(currentDeviceCredentials, DeviceCredentialInfo{ - ID: id, - }) - } else { - otherDeviceCredentials = append(otherDeviceCredentials, DeviceCredentialInfo{ - ID: id, - }) - } - } - - input := map[string]interface{}{ - "IsImpersonated": isImpersonated, - "State": s, // local session state (cookie, header, etc) - "Session": pbSession, // current access, refresh, id token - "User": pbUser, // user details inferred from oidc id_token - "CurrentDeviceCredentials": currentDeviceCredentials, - "OtherDeviceCredentials": otherDeviceCredentials, - "DirectoryUser": pbDirectoryUser, // user details inferred from idp directory - "DirectoryGroups": groups, // user's groups inferred from idp directory - "csrfField": csrf.TemplateField(r), - "SignOutURL": signoutURL, - "WebAuthnURL": webAuthnURL, - } - return a.templates.ExecuteTemplate(w, "userInfo.html", input) + handlers.UserInfo(handlers.UserInfoData{ + CSRFToken: csrf.Token(r), + DirectoryGroups: groups, + DirectoryUser: pbDirectoryUser, + IsImpersonated: isImpersonated, + Session: pbSession, + SignOutURL: urlutil.SignOutURL(r, authenticateURL, state.sharedKey), + User: pbUser, + WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()), + }).ServeHTTP(w, r) + return nil } func (a *Authenticate) saveSessionToDataBroker( @@ -682,12 +673,18 @@ func (a *Authenticate) getWebauthnState(ctx context.Context) (*webauthn.State, e return nil, err } + authenticateURL, err := a.options.Load().GetAuthenticateURL() + if err != nil { + return nil, err + } + pomeriumDomains, err := a.options.Load().GetAllRouteableHTTPDomains() if err != nil { return nil, err } return &webauthn.State{ + AuthenticateURL: authenticateURL, SharedKey: state.sharedKey, Client: state.dataBrokerClient, PomeriumDomains: pomeriumDomains, diff --git a/authenticate/handlers/device-enrolled.go b/authenticate/handlers/device-enrolled.go index 236c72e1d..fcb3d885b 100644 --- a/authenticate/handlers/device-enrolled.go +++ b/authenticate/handlers/device-enrolled.go @@ -1,18 +1,19 @@ package handlers import ( - "html/template" "net/http" + "net/url" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/ui" ) // DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device. -func DeviceEnrolled() http.Handler { - tpl := template.Must(frontend.NewTemplates()) - type TemplateData struct{} +func DeviceEnrolled(authenticateURL *url.URL, sharedKey []byte) http.Handler { return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - return tpl.ExecuteTemplate(w, "device-enrolled.html", TemplateData{}) + return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{ + "signOutUrl": urlutil.SignOutURL(r, authenticateURL, sharedKey), + }) }) } diff --git a/authenticate/handlers/userinfo.go b/authenticate/handlers/userinfo.go new file mode 100644 index 000000000..54ff9bd38 --- /dev/null +++ b/authenticate/handlers/userinfo.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "google.golang.org/protobuf/encoding/protojson" + + "github.com/pomerium/pomerium/internal/directory" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/pkg/grpc/session" + "github.com/pomerium/pomerium/pkg/grpc/user" + "github.com/pomerium/pomerium/ui" +) + +// UserInfoData is the data for the UserInfo page. +type UserInfoData struct { + CSRFToken string + DirectoryGroups []*directory.Group + DirectoryUser *directory.User + IsImpersonated bool + Session *session.Session + SignOutURL string + User *user.User + WebAuthnURL string +} + +// ToJSON converts the data into a JSON map. +func (data UserInfoData) ToJSON() map[string]interface{} { + m := map[string]interface{}{} + m["csrfToken"] = data.CSRFToken + var directoryGroups []json.RawMessage + for _, directoryGroup := range data.DirectoryGroups { + if bs, err := protojson.Marshal(directoryGroup); err == nil { + directoryGroups = append(directoryGroups, json.RawMessage(bs)) + } + } + m["directoryGroups"] = directoryGroups + if bs, err := protojson.Marshal(data.DirectoryUser); err == nil { + m["directoryUser"] = json.RawMessage(bs) + } + m["isImpersonated"] = data.IsImpersonated + if bs, err := protojson.Marshal(data.Session); err == nil { + m["session"] = json.RawMessage(bs) + } + m["signOutUrl"] = data.SignOutURL + if bs, err := protojson.Marshal(data.User); err == nil { + m["user"] = json.RawMessage(bs) + } + m["webAuthnUrl"] = data.WebAuthnURL + return m +} + +// UserInfo returns a handler that renders the user info page. +func UserInfo(data UserInfoData) http.Handler { + return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return ui.ServePage(w, r, "UserInfo", data.ToJSON()) + }) +} diff --git a/authenticate/handlers/webauthn/webauthn.go b/authenticate/handlers/webauthn/webauthn.go index 7c1c86f82..74b5eaa93 100644 --- a/authenticate/handlers/webauthn/webauthn.go +++ b/authenticate/handlers/webauthn/webauthn.go @@ -8,21 +8,17 @@ import ( "encoding/json" "errors" "fmt" - "html/template" - "io" "net" "net/http" "net/url" "github.com/google/uuid" - "github.com/pomerium/csrf" "github.com/pomerium/webauthn" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" "github.com/pomerium/pomerium/internal/encoding/jws" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" @@ -33,6 +29,7 @@ import ( "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/webauthnutil" + "github.com/pomerium/pomerium/ui" ) const maxAuthenticateResponses = 5 @@ -50,13 +47,14 @@ var ( // State is the state needed by the Handler to handle requests. type State struct { - SharedKey []byte + AuthenticateURL *url.URL Client databroker.DataBrokerServiceClient PomeriumDomains []string + RelyingParty *webauthn.RelyingParty Session *session.Session SessionState *sessions.State SessionStore sessions.SessionStore - RelyingParty *webauthn.RelyingParty + SharedKey []byte } // A StateProvider provides state for the handler. @@ -64,15 +62,13 @@ type StateProvider = func(context.Context) (*State, error) // Handler is the WebAuthn device handler. type Handler struct { - getState StateProvider - templates *template.Template + getState StateProvider } // New creates a new Handler. func New(getState StateProvider) *Handler { return &Handler{ - getState: getState, - templates: template.Must(frontend.NewTemplates()), + getState: getState, } } @@ -373,23 +369,12 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat creationOptions := webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u) requestOptions := webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials) - var buf bytes.Buffer - err = h.templates.ExecuteTemplate(&buf, "webauthn.html", map[string]interface{}{ - "csrfField": csrf.TemplateField(r), - "Data": map[string]interface{}{ - "creationOptions": creationOptions, - "requestOptions": requestOptions, - }, - "SelfURL": r.URL.String(), + return ui.ServePage(w, r, "WebAuthnRegistration", map[string]interface{}{ + "creationOptions": creationOptions, + "requestOptions": requestOptions, + "selfUrl": r.URL.String(), + "signOutUrl": urlutil.SignOutURL(r, state.AuthenticateURL, state.SharedKey), }) - if err != nil { - return err - } - - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - _, err = io.Copy(w, &buf) - return err } func (h *Handler) saveSessionAndRedirect(w http.ResponseWriter, r *http.Request, state *State, rawRedirectURI string) error { diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 1ea13ba2a..adea3a124 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "errors" "fmt" - "html/template" "net/http" "net/http/httptest" "net/url" @@ -29,7 +28,6 @@ import ( "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/mock" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/identity" "github.com/pomerium/pomerium/internal/identity/oidc" @@ -49,7 +47,6 @@ func testAuthenticate() *Authenticate { redirectURL: redirectURL, cookieSecret: cryptutil.NewKey(), }) - auth.templates = template.Must(frontend.NewTemplates()) auth.options = config.NewAtomicOptions() auth.options.Store(&config.Options{ SharedKey: cryptutil.NewBase64Key(), @@ -268,9 +265,8 @@ func TestAuthenticate_SignOut(t *testing.T) { }, directoryClient: new(mockDirectoryServiceClient), }), - templates: template.Must(frontend.NewTemplates()), - options: config.NewAtomicOptions(), - provider: identity.NewAtomicAuthenticator(), + options: config.NewAtomicOptions(), + provider: identity.NewAtomicAuthenticator(), } if tt.signoutRedirectURL != "" { opts := a.options.Load() @@ -671,7 +667,6 @@ func TestAuthenticate_userInfo(t *testing.T) { }, directoryClient: new(mockDirectoryServiceClient), }), - templates: template.Must(frontend.NewTemplates()), } r := httptest.NewRequest(tt.method, tt.url.String(), nil) state, err := tt.sessionStore.LoadSession(r) @@ -761,7 +756,6 @@ func TestAuthenticate_SignOut_CSRF(t *testing.T) { }, directoryClient: new(mockDirectoryServiceClient), }), - templates: template.Must(frontend.NewTemplates()), } tests := []struct { name string diff --git a/authenticate/url.go b/authenticate/url.go deleted file mode 100644 index 6f6b7b9b9..000000000 --- a/authenticate/url.go +++ /dev/null @@ -1,57 +0,0 @@ -package authenticate - -import ( - "net/http" - "net/url" - - "github.com/pomerium/pomerium/internal/urlutil" - "github.com/pomerium/pomerium/pkg/webauthnutil" -) - -func (a *Authenticate) getRedirectURI(r *http.Request) (string, bool) { - if v := r.FormValue(urlutil.QueryRedirectURI); v != "" { - return v, true - } - - if c, err := r.Cookie(urlutil.QueryRedirectURI); err == nil { - return c.Value, true - } - - return "", false -} - -func (a *Authenticate) getSignOutURL(r *http.Request) (*url.URL, error) { - uri, err := a.options.Load().GetAuthenticateURL() - if err != nil { - return nil, err - } - - uri = uri.ResolveReference(&url.URL{ - Path: "/.pomerium/sign_out", - }) - if redirectURI, ok := a.getRedirectURI(r); ok { - uri.RawQuery = (&url.Values{ - urlutil.QueryRedirectURI: {redirectURI}, - }).Encode() - } - return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil -} - -func (a *Authenticate) getWebAuthnURL(values url.Values) (*url.URL, error) { - uri, err := a.options.Load().GetAuthenticateURL() - if err != nil { - return nil, err - } - - uri = uri.ResolveReference(&url.URL{ - Path: "/.pomerium/webauthn", - RawQuery: buildURLValues(values, url.Values{ - urlutil.QueryDeviceType: {webauthnutil.DefaultDeviceType}, - urlutil.QueryEnrollmentToken: nil, - urlutil.QueryRedirectURI: {uri.ResolveReference(&url.URL{ - Path: "/.pomerium/device-enrolled", - }).String()}, - }).Encode(), - }) - return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil -} diff --git a/authorize/authorize.go b/authorize/authorize.go index 88a67f8c2..5ebf308b6 100644 --- a/authorize/authorize.go +++ b/authorize/authorize.go @@ -5,12 +5,10 @@ package authorize import ( "context" "fmt" - "html/template" "sync" "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry/metrics" "github.com/pomerium/pomerium/internal/telemetry/trace" @@ -22,7 +20,6 @@ type Authorize struct { state *atomicAuthorizeState store *evaluator.Store currentOptions *config.AtomicOptions - templates *template.Template dataBrokerInitialSync chan struct{} @@ -37,7 +34,6 @@ func New(cfg *config.Config) (*Authorize, error) { a := Authorize{ currentOptions: config.NewAtomicOptions(), store: evaluator.NewStore(), - templates: template.Must(frontend.NewTemplates()), dataBrokerInitialSync: make(chan struct{}), } diff --git a/authorize/check_response_test.go b/authorize/check_response_test.go index 222cf560e..bdc30533c 100644 --- a/authorize/check_response_test.go +++ b/authorize/check_response_test.go @@ -2,7 +2,6 @@ package authorize import ( "context" - "html/template" "net/http" "net/url" "testing" @@ -19,7 +18,6 @@ import ( "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/encoding/jws" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/testutil" "github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/user" @@ -114,7 +112,6 @@ func TestAuthorize_deniedResponse(t *testing.T) { }}, }}, }) - a.templates = template.Must(frontend.NewTemplates()) tests := []struct { name string @@ -138,7 +135,6 @@ func TestAuthorize_deniedResponse(t *testing.T) { Code: envoy_type_v3.StatusCode(codes.InvalidArgument), }, Headers: []*envoy_config_core_v3.HeaderValueOption{ - mkHeader("Content-Type", "text/html; charset=UTF-8", false), mkHeader("X-Pomerium-Intercepted-Response", "true", false), }, Body: "Access Denied", @@ -155,7 +151,7 @@ func TestAuthorize_deniedResponse(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.want.Status.Code, got.Status.Code) assert.Equal(t, tc.want.Status.Message, got.Status.Message) - assert.Equal(t, tc.want.GetDeniedResponse().GetHeaders(), got.GetDeniedResponse().GetHeaders()) + testutil.AssertProtoEqual(t, tc.want.GetDeniedResponse().GetHeaders(), got.GetDeniedResponse().GetHeaders()) }) } } diff --git a/integration/control_plane_test.go b/integration/control_plane_test.go index ca335f484..3138172cd 100644 --- a/integration/control_plane_test.go +++ b/integration/control_plane_test.go @@ -45,21 +45,6 @@ func TestDashboard(t *testing.T) { assert.Equal(t, 3, res.StatusCode/100, "unexpected status code") }) - t.Run("image asset", func(t *testing.T) { - req, err := http.NewRequestWithContext(ctx, "GET", "https://authenticate.localhost.pomerium.io/.pomerium/assets/img/pomerium.svg", nil) - if err != nil { - t.Fatal(err) - } - - res, err := getClient().Do(req) - if !assert.NoError(t, err, "unexpected http error") { - return - } - defer res.Body.Close() - - assert.Equal(t, http.StatusOK, res.StatusCode, "unexpected status code") - assert.Equal(t, "image/svg+xml", res.Header.Get("Content-Type")) - }) } func TestHealth(t *testing.T) { diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index b8c2d4281..cbb8c36b4 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -9,7 +9,6 @@ import ( "github.com/CAFxX/httpcompression" "github.com/gorilla/handlers" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry" @@ -48,7 +47,6 @@ func (srv *Server) addHTTPMiddleware() { }, srv.name)) root.HandleFunc("/healthz", httputil.HealthCheck) root.HandleFunc("/ping", httputil.HealthCheck) - root.PathPrefix("/.pomerium/assets/").Handler(http.StripPrefix("/.pomerium/assets/", frontend.MustAssetHandler())) // pprof root.Path("/debug/pprof/cmdline").HandlerFunc(pprof.Cmdline) diff --git a/internal/frontend/assets/html/device-enrolled.go.html b/internal/frontend/assets/html/device-enrolled.go.html deleted file mode 100644 index 9597d8e0d..000000000 --- a/internal/frontend/assets/html/device-enrolled.go.html +++ /dev/null @@ -1,30 +0,0 @@ -{{define "device-enrolled.html"}} - - - Device Successfully Enrolled - {{template "header.html"}} - - - -
-
-
-
-
-
-
-
- Device Successfully Enrolled - -
-
-
-
-
- - -{{end}} diff --git a/internal/frontend/assets/html/error.go.html b/internal/frontend/assets/html/error.go.html deleted file mode 100644 index 9caa5e9c3..000000000 --- a/internal/frontend/assets/html/error.go.html +++ /dev/null @@ -1,45 +0,0 @@ -{{define "error.html"}} - - - - - {{.Status}} - {{.StatusText}} - {{template "header.html"}} - - - -
-
-
-
-
-
-
- -
- {{.Status}} {{.StatusText}} - -
-
- -
-
-
- - -{{end}} diff --git a/internal/frontend/assets/html/header.go.html b/internal/frontend/assets/html/header.go.html deleted file mode 100644 index 65db5e567..000000000 --- a/internal/frontend/assets/html/header.go.html +++ /dev/null @@ -1,5 +0,0 @@ -{{define "header.html"}} - - - -{{end}} diff --git a/internal/frontend/assets/html/userInfo.html b/internal/frontend/assets/html/userInfo.html deleted file mode 100644 index bcbaa3dc1..000000000 --- a/internal/frontend/assets/html/userInfo.html +++ /dev/null @@ -1,276 +0,0 @@ -{{define "userInfo.html"}} - - - - - User info endpoint - {{template "header.html"}} - - - -
-
-
- - -
- {{.csrfField}} - -
-
-
-
-
-
-
- {{range .User.GetClaim "picture"}} - user image - {{else}} - - {{end}} -
- - {{with .User.Name}} - Hi {{.}}! - {{else}} - {{range .User.GetClaim "given_name"}} - Hi {{.}}! - {{end}} - {{end}} - - -
-
-
- -
-
-
-
- Session Details -
- {{if .Session}} - - - - - - - - - {{with .Session.UserId}} - - - - - {{end}} - {{with .Session.Id}} - - - - - {{end}} - {{with .Session.ExpiresAt}} - - - - - {{end}} - - - - - -
Claims
User ID{{.}}
ID{{.}}
Expires At{{.AsTime | formatTime}}
Impersonated{{.IsImpersonated}}
- {{else}} - No session details found! - {{end}} -
-
-
- - -
-
-
-
- User Claims - {{with .Session.IdToken}} - - - - {{end}} -
- {{if .Session}} - - - - - - - - - {{range $k,$v:=.Session.Claims}} - - - - - {{end}} - -
Claims
{{$k}} - {{range $v.AsSlice}} - {{if eq $k "exp" "iat" "updated_at"}} -

{{formatTime .}}

- {{else}} -

{{.}}

- {{end}} - {{end}} -
- {{else}} - No user claims found! - {{end}} -
- -
-
- -
-
-
-
- Groups -
- {{if .DirectoryGroups}} - - - - - - - - - - {{range .DirectoryGroups}} - - - - {{end}} - -
IDName
{{.Id}} {{.Name}}
- {{else}} - No groups found! - {{end}} -
- -
-
- -
-
-
-
- Current Session Device Credentials - -
- {{if .CurrentDeviceCredentials}} - - - - - - - - {{range .CurrentDeviceCredentials}} - - - - - {{end}} - -
ID
{{.ID}} -
- {{$.csrfField}} - - - -
-
- {{else}} - No device credentials found! - {{end}} -
- {{if .OtherDeviceCredentials}} -
-
- Other Device Credentials -
- - - - - - - - {{range .OtherDeviceCredentials}} - - - - - {{end}} - -
ID
{{.ID}} -
- {{$.csrfField}} - - - -
-
-
- {{end}} - -
-
- - -
-
- - - - - - -{{end}} diff --git a/internal/frontend/assets/html/webauthn.go.html b/internal/frontend/assets/html/webauthn.go.html deleted file mode 100644 index a1f4d29b6..000000000 --- a/internal/frontend/assets/html/webauthn.go.html +++ /dev/null @@ -1,68 +0,0 @@ -{{define "webauthn.html"}} - - - - {{template "header.html"}} - WebAuthn - - - - -
-
-
-
-
-
-
-
- WebAuthn Registration - -
-
-
-
-
-
-
- {{.csrfField}} - - - -
-
- {{.csrfField}} - - - -
-
-
-
-
-
- - - - -{{end}} diff --git a/internal/frontend/assets/img/account_circle-24px.svg b/internal/frontend/assets/img/account_circle-24px.svg deleted file mode 100644 index fdcb5a87f..000000000 --- a/internal/frontend/assets/img/account_circle-24px.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/internal/frontend/assets/img/error-24px.svg b/internal/frontend/assets/img/error-24px.svg deleted file mode 100644 index 1b58ba1cc..000000000 --- a/internal/frontend/assets/img/error-24px.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/internal/frontend/assets/img/experimental.svg b/internal/frontend/assets/img/experimental.svg deleted file mode 100644 index f3130353e..000000000 --- a/internal/frontend/assets/img/experimental.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/internal/frontend/assets/img/jwt.svg b/internal/frontend/assets/img/jwt.svg deleted file mode 100644 index e3ccfa5aa..000000000 --- a/internal/frontend/assets/img/jwt.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/internal/frontend/assets/img/logo-long.svg b/internal/frontend/assets/img/logo-long.svg deleted file mode 100644 index a37bb65e8..000000000 --- a/internal/frontend/assets/img/logo-long.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/internal/frontend/assets/img/logo-only.svg b/internal/frontend/assets/img/logo-only.svg deleted file mode 100644 index a9ef3e86b..000000000 --- a/internal/frontend/assets/img/logo-only.svg +++ /dev/null @@ -1 +0,0 @@ -logo-only diff --git a/internal/frontend/assets/img/pomerium.svg b/internal/frontend/assets/img/pomerium.svg deleted file mode 100644 index e585c45bb..000000000 --- a/internal/frontend/assets/img/pomerium.svg +++ /dev/null @@ -1 +0,0 @@ - \ 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 deleted file mode 100644 index 3308276c8..000000000 --- a/internal/frontend/assets/img/pomerium_circle_96.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/internal/frontend/assets/img/supervised_user_circle-24px.svg b/internal/frontend/assets/img/supervised_user_circle-24px.svg deleted file mode 100644 index 074db6b32..000000000 --- a/internal/frontend/assets/img/supervised_user_circle-24px.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/internal/frontend/assets/js/webauthn.mjs b/internal/frontend/assets/js/webauthn.mjs deleted file mode 100644 index 23de203f8..000000000 --- a/internal/frontend/assets/js/webauthn.mjs +++ /dev/null @@ -1,119 +0,0 @@ -import { - encodeUrl as base64encode, - decode as base64decode, -} from "./base64.mjs"; - -function decode(raw) { - return base64decode(raw); -} - -function encode(raw) { - return base64encode(raw); -} - -async function authenticate(requestOptions) { - const credential = await navigator.credentials.get({ - publicKey: { - allowCredentials: requestOptions.allowCredentials.map((c) => ({ - type: c.type, - id: decode(c.id), - })), - challenge: decode(requestOptions.challenge), - timeout: requestOptions.timeout, - userVerification: requestOptions.userVerification, - }, - }); - const inputEl = document.getElementById("authenticate_response"); - inputEl.value = JSON.stringify({ - id: credential.id, - type: credential.type, - rawId: encode(credential.rawId), - response: { - authenticatorData: encode(credential.response.authenticatorData), - clientDataJSON: encode(credential.response.clientDataJSON), - signature: encode(credential.response.signature), - userHandle: encode(credential.response.userHandle), - }, - }); - inputEl.parentElement.submit(); -} - -async function register(creationOptions) { - const credential = await navigator.credentials.create({ - publicKey: { - attestation: creationOptions.attestation || undefined, - authenticatorSelection: { - authenticatorAttachment: - creationOptions.authenticatorSelection.authenticatorAttachment || - undefined, - requireResidentKey: - creationOptions.authenticatorSelection.requireResidentKey || - undefined, - residentKey: creationOptions.authenticatorSelection.residentKey, - userVerification: - creationOptions.authenticatorSelection.userVerification || undefined, - }, - challenge: decode(creationOptions.challenge), - pubKeyCredParams: creationOptions.pubKeyCredParams.map((p) => ({ - type: p.type, - alg: p.alg, - })), - rp: { - name: creationOptions.rp.name, - }, - timeout: creationOptions.timeout, - user: { - id: decode(creationOptions.user.id), - name: creationOptions.user.name, - displayName: creationOptions.user.displayName, - }, - }, - }); - const inputEl = document.getElementById("register_response"); - inputEl.value = JSON.stringify({ - id: credential.id, - type: credential.type, - rawId: encode(credential.rawId), - response: { - attestationObject: encode(credential.response.attestationObject), - clientDataJSON: encode(credential.response.clientDataJSON), - }, - }); - inputEl.parentElement.submit(); -} - -function init() { - if (!("PomeriumData" in window)) { - return; - } - - const requestOptions = window.PomeriumData.requestOptions; - const authenticateButton = document.getElementById("authenticate_button"); - if (authenticateButton) { - if ( - requestOptions.allowCredentials && - requestOptions.allowCredentials.length > 0 - ) { - authenticateButton.addEventListener("click", function(evt) { - evt.preventDefault(); - authenticate(requestOptions); - }); - } else { - authenticateButton.addEventListener("click", function(evt) { - evt.preventDefault(); - }); - authenticateButton.setAttribute("disabled", "DISABLED"); - } - } - - const creationOptions = window.PomeriumData.creationOptions; - const registerButton = document.getElementById("register_button"); - if (registerButton) { - registerButton.addEventListener("click", function(evt) { - evt.preventDefault(); - register(creationOptions); - }); - } -} - -init(); diff --git a/internal/frontend/assets/style/main.css b/internal/frontend/assets/style/main.css deleted file mode 100644 index 27d141fa6..000000000 --- a/internal/frontend/assets/style/main.css +++ /dev/null @@ -1,507 +0,0 @@ -/******* Global *******/ - -body, -div, -dl, -dt, -dd, -ul, -ol, -li, -h1, -h2, -h3, -h4, -h5, -h6, -pre, -code, -form, -fieldset, -legend, -input, -button, -textarea, -p, -blockquote, -th, -td { - margin: 0; - padding: 0; -} - -fieldset, -img { - border: 0; -} -address, -caption, -cite, -code, -dfn, -em, -strong, -th, -var, -optgroup { - font-style: inherit; - font-weight: inherit; -} -del, -ins { - text-decoration: none; -} -li { - list-style: none; -} -caption, -th { - text-align: left; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: 100%; - font-weight: normal; -} -q:before, -q:after { - content: ""; -} -abbr, -acronym { - border: 0; - font-variant: normal; -} -sup { - vertical-align: baseline; -} -sub { - vertical-align: baseline; -} -legend { - color: #000; -} -input, -button, -textarea, -select, -optgroup, -option { - font-family: inherit; - font-size: inherit; - font-style: inherit; - font-weight: inherit; -} -input, -button, -textarea, -select { - *font-size: 100%; -} - -aside, -dialog, -figure, -footer, -header, -hgroup, -menu, -nav, -section { - display: block; -} - -html, -body { - margin: 0; - padding: 0; -} - -html { - background: #f6f9fc; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.inner { - width: 614px; - margin: auto; - margin-bottom: 2em; -} - -.box { - overflow: hidden; - border-radius: 4px; - box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); -} - -.box-inner { - padding: 35px; -} -.box-inner ~ .box-inner { - padding-top: 0; -} - -.white { - background: white; -} - -h2 { - font-size: 1.5em; - font-style: normal; - color: #444; - margin: 0 0 0.8em 0; - padding-bottom: 0.2em; - border-bottom: 1px solid #eee; -} - -ul.plain { - list-style: none; - -webkit-padding-start: 0; - padding-left: 0; -} - -a { - color: #6e43e8; - text-decoration: none; -} -a:hover { - color: #32325d; -} - -/******* Status/Graph Colors *******/ - -.status-up .status-time { - color: #3ecf8e; -} - -.status-up { - background: #3ecf8e; -} - -.status-down .status-time { - color: #ffe7cb; -} - -.status-down { - background: #e25950; -} - -/******* Clearfix *******/ - -.clearfix:after { - visibility: hidden; - display: block; - font-size: 0; - content: " "; - clear: both; - height: 0; -} - -* html .clearfix { - zoom: 1; -} /* IE6 */ -*:first-child + html .clearfix { - zoom: 1; -} /* IE7 */ - -/******* Header *******/ - -.header { - padding: 30px 0; - height: 40px; - position: relative; -} - -.header span { - color: #6e43e8; - font-size: 16px; - line-height: 31px; - position: absolute; - left: 540px; -} - -.heading { - float: left; - margin: 7px 1px; -} - -.statuses { - float: left; -} - -.logo { - display: inline-block; - position: relative; - background: url(/.pomerium/assets/img/logo-long.svg) no-repeat; - width: 663px; - height: 26px; - cursor: pointer; -} - -.logo:hover { - opacity: 0.7; -} - -/******* Content *******/ -.largestatus { - border: 0; - position: relative; - z-index: 10; - - padding: 0 36px; - padding-left: 84px; - - min-height: 155px; -} - -.largestatus .title { - display: block; - padding-top: 46px; - margin-bottom: 10px; - - font-size: 29px; - color: #32325d; -} - -.largestatus .status-time { - display: block; - font-size: 14px; - color: #8898aa; - padding-bottom: 46px; -} - -.category { - margin-top: 40px; -} - -/******* Statuses *******/ - -.statuses { - font-size: 0.7em; -} - -.status-bubble { - width: 44px; - height: 44px; - position: absolute; - left: 24px; - top: 52px; - background-position: center; - background-repeat: no-repeat; - border-radius: 50%; -} - -.title-wrapper { - display: inline-block; - color: #333; - min-height: 155px; -} - -.status-time { - color: #999; -} - -/******* category *******/ -.category { - background: white; -} - -div.category-header { - margin-bottom: 25px; -} - -.category-title { - color: #525f7f; - font-size: 15px; - padding-right: 10px; -} - -.category-icon { - display: block; - position: relative; - top: 2px; - float: right; - width: 27px; - height: 25px; - background: url(/.pomerium/assets/img/jwt.svg) 100% 0 no-repeat; -} - - -.experimental-icon { - display: block; - position: relative; - top: 2px; - float: right; - width: 27px; - height: 25px; - background: url(/.pomerium/assets/img/experimental.svg) 100% 0 no-repeat; -} - -div.category-link { - background: #f6f9fc; - - padding: 25px 36px; - - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - - font-size: 13px; - - color: #6b7c93; -} - -/* Footer */ -div#footer { - /*background: rgba(0,0,0,0.05);*/ - margin: 0 0 0; - padding: 40px 0 0px; - position: relative; - font-size: 13px; -} - -div#footer ul { - padding-left: 10px; -} -div#footer li { - display: inline; - padding-right: 15px; -} -div#footer a { - color: #8898aa; - text-decoration: none; -} -div#footer a:hover { - color: #32325d; -} - -div#footer li.last { - border: 0; -} - -div#footer p { - color: #8898aa; - position: absolute; - right: 10px; - top: 40px; -} - -/* - Tables */ -table tbody tr td { - border-color: #525f7f; - border-style: solid; - border: 0; - padding: 16px 16px; -} -table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - margin: auto; -} -table:not(:first-child) { - margin-top: 20px; -} -table table { - margin: 16px 0 8px 0; -} -table thead tr th { - font-weight: 500; - font-size: 13px; - color: #6e43e8; - text-transform: uppercase; - text-align: left; - padding: 0 0 8px 16px; -} -table thead tr th p { - font-size: 13px; - - text-transform: uppercase; - padding: 0; - line-height: 15px; -} -table tbody, -table tbody td > * { - font-size: 14px; - line-height: 20px; - vertical-align: top; - padding-top: 0; - overflow-wrap: anywhere; -} - -table tbody tr td { - border-color: rgb(227, 232, 238); - border-style: solid; - padding: 16px 16px; - min-width: 80px; -} -table tbody tr td:first-child { - border-left-width: 1px; -} -table tbody tr td:last-child { - border-right-width: 1px; -} -table tbody tr:first-child > td { - border-top-width: 1px; -} -table tbody tr td { - border-bottom-width: 1px; -} -table tbody tr:first-child > td:first-child { - border-top-left-radius: 1px; -} -table tbody tr:first-child > td:last-child { - border-top-right-radius: 1px; -} -table tbody tr:last-child > td:first-child { - border-bottom-left-radius: 1px; -} -table tbody tr:last-child > td:last-child { - border-bottom-right-radius: 1px; -} -table tbody tr:nth-child(2n + 1) td { - background: #f6f9fc; -} - -input, -button, -a.button { - background: #6e43e8; - box-shadow: 0 2px 5px 0 rgba(50, 50, 93, 0.1), 0 1px 1px 0 rgba(0, 0, 0, 0.07); - border-radius: 4px; - height: 32px; - font-size: 16px; - color: #f6f9fc; - font-weight: 500; - padding: 0 12px; - cursor: pointer; - outline: none; - display: inline-block; - text-decoration: none; - text-transform: none; - white-space: nowrap; - transition: box-shadow 150ms ease-in-out; -} - -button:disabled, input:disabled { - cursor: default; - background:#cccccc; - color: #838383; -} - -.webauthn .box-inner { - text-align: center; -} - -.webauthn form { - display: inline-block; -} diff --git a/internal/frontend/templates.go b/internal/frontend/templates.go deleted file mode 100644 index 00a373339..000000000 --- a/internal/frontend/templates.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package frontend handles the generation, and instantiation of Pomerium's -// html templates. -package frontend - -import ( - "embed" - "encoding/base64" - "fmt" - "html/template" - "io/fs" - "mime" - "net/http" - "os" - "path" - "strings" - "time" -) - -// FS is the frontend assets file system. -//go:embed assets -var FS embed.FS - -// NewTemplates loads pomerium's templates. Panics on failure. -func NewTemplates() (*template.Template, error) { - assetsFS, err := fs.Sub(FS, "assets") - if err != nil { - return nil, err - } - - dataURLs := map[string]template.URL{} - err = fs.WalkDir(assetsFS, ".", func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - bs, err := fs.ReadFile(assetsFS, p) - if err != nil { - return fmt.Errorf("internal/frontend: error reading %s: %w", p, err) - } - - encoded := base64.StdEncoding.EncodeToString(bs) - dataURLs[p] = template.URL(fmt.Sprintf( - "data:%s;base64,%s", mime.TypeByExtension(path.Ext(p)), encoded)) - - return nil - }) - if err != nil { - return nil, err - } - - t := template.New("pomerium-templates").Funcs(map[string]interface{}{ - "safeURL": func(arg interface{}) template.URL { - return template.URL(fmt.Sprint(arg)) - }, - "safeHTML": func(arg interface{}) template.HTML { - return template.HTML(fmt.Sprint(arg)) - }, - "safeHTMLAttr": func(arg interface{}) template.HTMLAttr { - return template.HTMLAttr(fmt.Sprint(arg)) - }, - "dataURL": func(p string) template.URL { - return dataURLs[strings.TrimPrefix(p, "/.pomerium/assets/")] - }, - "formatTime": func(arg interface{}) string { - var tm time.Time - switch t := arg.(type) { - case float64: - tm = time.Unix(int64(t), 0) - case int: - tm = time.Unix(int64(t), 0) - case int64: - tm = time.Unix(t, 0) - case time.Time: - tm = t - default: - return "" - } - return tm.Format("2006-01-02 15:04:05 MST") - }, - }) - - err = fs.WalkDir(assetsFS, "html", func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if !d.IsDir() { - bs, err := fs.ReadFile(assetsFS, p) - if err != nil { - return fmt.Errorf("internal/frontend: error reading %s: %w", p, err) - } - - _, err = t.Parse(string(bs)) - if err != nil { - return fmt.Errorf("internal/frontend: error parsing template %s: %w", p, err) - } - } - return nil - }) - if err != nil { - return nil, err - } - - return t, nil -} - -// MustAssetHandler wraps a call to the embedded static file system and panics -// if the error is non-nil. It is intended for use in variable initializations -func MustAssetHandler() http.Handler { - assetsFS, err := fs.Sub(FS, "assets") - if err != nil { - panic(err) - } - return http.FileServer(http.FS(assetsFS)) -} diff --git a/internal/frontend/templates_test.go b/internal/frontend/templates_test.go deleted file mode 100644 index f88a2e2b0..000000000 --- a/internal/frontend/templates_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package frontend - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewTemplates(t *testing.T) { - tpl, err := NewTemplates() - require.NoError(t, err) - - var buf bytes.Buffer - err = tpl.ExecuteTemplate(&buf, "header.html", nil) - require.NoError(t, err) - - assert.Contains(t, buf.String(), ``) -} diff --git a/internal/httputil/errors.go b/internal/httputil/errors.go index 4096ae3f4..8bf21beaf 100644 --- a/internal/httputil/errors.go +++ b/internal/httputil/errors.go @@ -1,16 +1,13 @@ package httputil import ( - "html/template" "net/http" "net/url" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/telemetry/requestid" + "github.com/pomerium/pomerium/ui" ) -var errorTemplate = template.Must(frontend.NewTemplates()) - // HTTPError contains an HTTP status code and wrapped error. type HTTPError struct { // HTTP status codes as registered with IANA. @@ -68,7 +65,21 @@ func (e *HTTPError) ErrorResponse(w http.ResponseWriter, r *http.Request) { RenderJSON(w, e.Status, response) return } - w.Header().Set("Content-Type", "text/html; charset=UTF-8") - w.WriteHeader(e.Status) - errorTemplate.ExecuteTemplate(w, "error.html", response) + + m := map[string]interface{}{ + "canDebug": response.CanDebug, + "error": response.Error, + "requestId": response.RequestID, + "status": response.Status, + "statusText": response.StatusText, + "version": response.Version, + } + if response.DebugURL != nil { + m["debugUrl"] = response.DebugURL.String() + } + + w.WriteHeader(response.Status) + if err := ui.ServePage(w, r, "Error", m); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } diff --git a/internal/httputil/headers.go b/internal/httputil/headers.go index a1deb62c8..66f17fa16 100644 --- a/internal/httputil/headers.go +++ b/internal/httputil/headers.go @@ -38,7 +38,7 @@ const ( // by default includes profile photo exceptions for supported identity providers. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src var HeadersContentSecurityPolicy = map[string]string{ - "Content-Security-Policy": "default-src 'none'; style-src 'self' data:; img-src * data:; script-src 'self' 'unsafe-inline'", + "Content-Security-Policy": "default-src 'none'; style-src 'self' 'unsafe-inline' data:; img-src * data:; script-src 'self' 'unsafe-inline'; font-src data:", "Referrer-Policy": "Same-origin", } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index bb52ed17e..73f4b20a7 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -9,10 +9,19 @@ import ( "testing" "github.com/golang/protobuf/proto" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" ) +// AssertProtoEqual asserts that two protobuf messages equal. Slices of messages are also supported. +func AssertProtoEqual(t *testing.T, expected, actual interface{}, msgAndArgs ...interface{}) bool { + t.Helper() + return assert.True(t, cmp.Equal(expected, actual, protocmp.Transform()), + cmp.Diff(expected, actual, protocmp.Transform())) +} + // AssertProtoJSONEqual asserts that a protobuf message matches the given JSON. The protoMsg can also be a slice // of protobuf messages. func AssertProtoJSONEqual(t *testing.T, expected string, protoMsg interface{}, msgAndArgs ...interface{}) bool { diff --git a/internal/urlutil/known.go b/internal/urlutil/known.go new file mode 100644 index 000000000..55c2f502b --- /dev/null +++ b/internal/urlutil/known.go @@ -0,0 +1,62 @@ +package urlutil + +import ( + "net/http" + "net/url" +) + +// DefaultDeviceType is the default device type when none is specified. +const DefaultDeviceType = "any" + +// RedirectURL returns the redirect URL from the query string or a cookie. +func RedirectURL(r *http.Request) (string, bool) { + if v := r.FormValue(QueryRedirectURI); v != "" { + return v, true + } + + if c, err := r.Cookie(QueryRedirectURI); err == nil { + return c.Value, true + } + + return "", false +} + +// SignOutURL returns the /.pomerium/sign_out URL. +func SignOutURL(r *http.Request, authenticateURL *url.URL, key []byte) string { + u := authenticateURL.ResolveReference(&url.URL{ + Path: "/.pomerium/sign_out", + }) + if redirectURI, ok := RedirectURL(r); ok { + u.RawQuery = (&url.Values{ + QueryRedirectURI: {redirectURI}, + }).Encode() + } + return NewSignedURL(key, u).Sign().String() +} + +// WebAuthnURL returns the /.pomerium/webauthn URL. +func WebAuthnURL(r *http.Request, authenticateURL *url.URL, key []byte, values url.Values) string { + u := authenticateURL.ResolveReference(&url.URL{ + Path: "/.pomerium/webauthn", + RawQuery: buildURLValues(values, url.Values{ + QueryDeviceType: {DefaultDeviceType}, + QueryEnrollmentToken: nil, + QueryRedirectURI: {authenticateURL.ResolveReference(&url.URL{ + Path: "/.pomerium/device-enrolled", + }).String()}, + }).Encode(), + }) + return NewSignedURL(key, u).Sign().String() +} + +func buildURLValues(values, defaults url.Values) url.Values { + result := make(url.Values) + for k, vs := range defaults { + if values.Has(k) { + result[k] = values[k] + } else if vs != nil { + result[k] = vs + } + } + return result +} diff --git a/authenticate/url_test.go b/internal/urlutil/known_test.go similarity index 67% rename from authenticate/url_test.go rename to internal/urlutil/known_test.go index ff2e5dd06..df25b9f40 100644 --- a/authenticate/url_test.go +++ b/internal/urlutil/known_test.go @@ -1,4 +1,4 @@ -package authenticate +package urlutil import ( "net/http" @@ -8,31 +8,27 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/pomerium/pomerium/internal/urlutil" ) -func TestAuthenticate_getRedirectURI(t *testing.T) { +func TestRedirectURI(t *testing.T) { t.Run("query", func(t *testing.T) { r, err := http.NewRequest("GET", "https://www.example.com?"+(url.Values{ - urlutil.QueryRedirectURI: {"https://www.example.com/redirect"}, + QueryRedirectURI: {"https://www.example.com/redirect"}, }).Encode(), nil) require.NoError(t, err) - a := new(Authenticate) - redirectURI, ok := a.getRedirectURI(r) + redirectURI, ok := RedirectURL(r) assert.True(t, ok) assert.Equal(t, "https://www.example.com/redirect", redirectURI) }) t.Run("form", func(t *testing.T) { r, err := http.NewRequest("POST", "https://www.example.com", strings.NewReader((url.Values{ - urlutil.QueryRedirectURI: {"https://www.example.com/redirect"}, + QueryRedirectURI: {"https://www.example.com/redirect"}, }).Encode())) require.NoError(t, err) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") - a := new(Authenticate) - redirectURI, ok := a.getRedirectURI(r) + redirectURI, ok := RedirectURL(r) assert.True(t, ok) assert.Equal(t, "https://www.example.com/redirect", redirectURI) }) @@ -40,12 +36,11 @@ func TestAuthenticate_getRedirectURI(t *testing.T) { r, err := http.NewRequest("GET", "https://www.example.com", nil) require.NoError(t, err) r.AddCookie(&http.Cookie{ - Name: urlutil.QueryRedirectURI, + Name: QueryRedirectURI, Value: "https://www.example.com/redirect", }) - a := new(Authenticate) - redirectURI, ok := a.getRedirectURI(r) + redirectURI, ok := RedirectURL(r) assert.True(t, ok) assert.Equal(t, "https://www.example.com/redirect", redirectURI) }) diff --git a/pkg/webauthnutil/device_type.go b/pkg/webauthnutil/device_type.go index 4ffedee5b..de66f9dd2 100644 --- a/pkg/webauthnutil/device_type.go +++ b/pkg/webauthnutil/device_type.go @@ -8,12 +8,13 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" + "github.com/pomerium/pomerium/internal/urlutil" "github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/device" ) // DefaultDeviceType is the default device type when none is specified. -const DefaultDeviceType = "any" +const DefaultDeviceType = urlutil.DefaultDeviceType var supportedPublicKeyCredentialParameters = []*device.WebAuthnOptions_PublicKeyCredentialParameters{ {Type: device.WebAuthnOptions_PUBLIC_KEY, Alg: int64(cose.AlgorithmES256)}, diff --git a/proxy/proxy.go b/proxy/proxy.go index dfcc9a0fd..780f7677e 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -7,14 +7,12 @@ package proxy import ( "context" "fmt" - "html/template" "net/http" "sync/atomic" "github.com/gorilla/mux" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry/metrics" @@ -49,7 +47,6 @@ func ValidateOptions(o *config.Options) error { // Proxy stores all the information associated with proxying a request. type Proxy struct { - templates *template.Template state *atomicProxyState currentOptions *config.AtomicOptions currentRouter atomic.Value @@ -64,7 +61,6 @@ func New(cfg *config.Config) (*Proxy, error) { } p := &Proxy{ - templates: template.Must(frontend.NewTemplates()), state: newAtomicProxyState(state), currentOptions: config.NewAtomicOptions(), } diff --git a/scripts/build-dev-docker.bash b/scripts/build-dev-docker.bash index 6ab104b03..2bf6a9c13 100755 --- a/scripts/build-dev-docker.bash +++ b/scripts/build-dev-docker.bash @@ -5,7 +5,7 @@ _dir=/tmp/pomerium-dev-docker mkdir -p "$_dir" # build linux binary -env GOOS=linux make build-deps build +env GOOS=linux make build cp bin/pomerium $_dir/ # build docker image diff --git a/ui/dist/apple-touch-icon.png b/ui/dist/apple-touch-icon.png new file mode 100644 index 000000000..04868ba5d Binary files /dev/null and b/ui/dist/apple-touch-icon.png differ diff --git a/ui/dist/favicon-16x16.png b/ui/dist/favicon-16x16.png new file mode 100644 index 000000000..451e15ab6 Binary files /dev/null and b/ui/dist/favicon-16x16.png differ diff --git a/ui/dist/favicon-32x32.png b/ui/dist/favicon-32x32.png new file mode 100644 index 000000000..60b28879a Binary files /dev/null and b/ui/dist/favicon-32x32.png differ diff --git a/ui/dist/favicon.ico b/ui/dist/favicon.ico new file mode 100644 index 000000000..d78ac8dee Binary files /dev/null and b/ui/dist/favicon.ico differ diff --git a/ui/dist/index.html b/ui/dist/index.html new file mode 100644 index 000000000..c1f0141d4 --- /dev/null +++ b/ui/dist/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + User info dashboard + + + + +
+ + + + diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 000000000..5f3e27c8a --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,93 @@ +// Package ui contains the user info dashboard ui. +package ui + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/pomerium/csrf" +) + +var ( + //go:embed dist/* + uiFS embed.FS +) + +// ServeFile serves a file. +func ServeFile(w http.ResponseWriter, r *http.Request, filePath string) error { + f, etag, err := openLocalOrEmbeddedFile(filepath.Join("dist", filePath)) + if err != nil { + return err + } + defer f.Close() + + w.Header().Set("ETag", `"`+etag+`"`) + http.ServeContent(w, r, filepath.Base(filePath), time.Time{}, f.(io.ReadSeeker)) + return nil +} + +// ServePage serves the index.html page. +func ServePage(w http.ResponseWriter, r *http.Request, page string, data map[string]interface{}) error { + if data == nil { + data = make(map[string]interface{}) + } + data["csrfToken"] = csrf.Token(r) + data["page"] = page + + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + f, _, err := openLocalOrEmbeddedFile("dist/index.html") + if err != nil { + return err + } + bs, err := io.ReadAll(f) + _ = f.Close() + if err != nil { + return err + } + + bs = bytes.Replace(bs, + []byte("window.POMERIUM_DATA = {}"), + append([]byte("window.POMERIUM_DATA = "), jsonData...), + 1) + + http.ServeContent(w, r, "index.html", time.Now(), bytes.NewReader(bs)) + return nil +} + +var startTime = time.Now() + +func openLocalOrEmbeddedFile(name string) (f fs.File, etag string, err error) { + f, err = os.Open(filepath.Join("ui", name)) + if os.IsNotExist(err) { + f, err = uiFS.Open(name) + } + if err != nil { + return nil, "", err + } + + fi, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, "", err + } + + modTime := fi.ModTime() + if modTime.IsZero() { + modTime = startTime + } + etag = fmt.Sprintf("%x", modTime.UnixNano()) + + return f, etag, nil +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..c31ef1661 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,53 @@ +{ + "name": "pomerium", + "version": "1.0.0", + "main": "src/index.tsx", + "license": "Apache-2.0", + "scripts": { + "build": "ts-node ./scripts/esbuild.ts", + "format": "prettier --write .", + "lint": "eslint .", + "watch": "ts-node ./scripts/esbuild.ts --watch" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "@babel/core": "^7.0.0", + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@fontsource/dm-mono": "^4.5.2", + "@fontsource/dm-sans": "^4.5.1", + "@mui/icons-material": "^5.3.1", + "@mui/material": "^5.4.0", + "luxon": "^2.3.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-feather": "^2.0.9" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "2.0.4", + "@types/luxon": "^2.0.9", + "@types/node": "^17.0.14", + "@types/react": "^17.0.34", + "@types/react-dom": "^17.0.11", + "@typescript-eslint/eslint-plugin": "^5.10.2", + "@typescript-eslint/parser": "^5.10.2", + "esbuild": "^0.13.12", + "eslint": "7.32.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-react": "^7.28.0", + "prettier": "^2.4.1", + "ts-node": "^10.4.0", + "typescript": "^4.4.4" + } +} diff --git a/ui/scripts/esbuild.ts b/ui/scripts/esbuild.ts new file mode 100644 index 000000000..2ac470346 --- /dev/null +++ b/ui/scripts/esbuild.ts @@ -0,0 +1,16 @@ +import { build } from "esbuild"; + +build({ + entryPoints: ["src/index.tsx"], + bundle: true, + outfile: "dist/index.js", + sourcemap: "inline", + watch: process.argv.includes("--watch"), + minify: !process.argv.includes("--watch"), + logLevel: "info", + loader: { + ".svg": "dataurl", + ".woff": "dataurl", + ".woff2": "dataurl", + }, +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 000000000..f0230eee1 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,47 @@ +import DeviceEnrolledPage from "./components/DeviceEnrolledPage"; +import ErrorPage from "./components/ErrorPage"; +import Footer from "./components/Footer"; +import Header from "./components/Header"; +import UserInfoPage from "./components/UserInfoPage"; +import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage"; +import { createTheme } from "./theme"; +import { PageData } from "./types"; +import Container from "@mui/material/Container"; +import CssBaseline from "@mui/material/CssBaseline"; +import Stack from "@mui/material/Stack"; +import { ThemeProvider } from "@mui/material/styles"; +import React, { FC } from "react"; + +const theme = createTheme(); + +const App: FC = () => { + const data = (window["POMERIUM_DATA"] || {}) as PageData; + let body: React.ReactNode = <>; + switch (data?.page) { + case "DeviceEnrolled": + body = ; + break; + case "Error": + body = ; + break; + case "UserInfo": + body = ; + break; + case "WebAuthnRegistration": + body = ; + break; + } + return ( + + + + +
+ {body} +