From 2824faecbf6ffddf7c2607beda5914779ecce0a6 Mon Sep 17 00:00:00 2001 From: Caleb Doxsey Date: Mon, 7 Feb 2022 08:47:58 -0700 Subject: [PATCH] frontend: react+mui (#3004) * mui v5 wip * wip * wip * wip * use compressor for all controlplane endpoints * wip * wip * add deps * fix authenticate URL * fix test * fix test * fix build * maybe fix build * fix integration test * remove image asset test * add yarn.lock --- .dockerignore | 3 + .github/workflows/test.yaml | 28 +- .gitignore | 7 +- Dockerfile | 21 +- Makefile | 27 +- authenticate/authenticate.go | 26 +- authenticate/handlers.go | 91 +- authenticate/handlers/device-enrolled.go | 13 +- authenticate/handlers/userinfo.go | 59 + authenticate/handlers/webauthn/webauthn.go | 37 +- authenticate/handlers_test.go | 10 +- authenticate/url.go | 57 - authorize/authorize.go | 4 - authorize/check_response_test.go | 6 +- integration/control_plane_test.go | 15 - internal/controlplane/http.go | 2 - .../assets/html/device-enrolled.go.html | 30 - internal/frontend/assets/html/error.go.html | 45 - internal/frontend/assets/html/header.go.html | 5 - internal/frontend/assets/html/userInfo.html | 276 - .../frontend/assets/html/webauthn.go.html | 68 - .../assets/img/account_circle-24px.svg | 1 - internal/frontend/assets/img/error-24px.svg | 1 - internal/frontend/assets/img/experimental.svg | 4 - internal/frontend/assets/img/jwt.svg | 15 - internal/frontend/assets/img/logo-long.svg | 1 - internal/frontend/assets/img/logo-only.svg | 1 - internal/frontend/assets/img/pomerium.svg | 1 - .../assets/img/pomerium_circle_96.svg | 1 - .../img/supervised_user_circle-24px.svg | 1 - internal/frontend/assets/js/webauthn.mjs | 119 - internal/frontend/assets/style/main.css | 507 - internal/frontend/templates.go | 119 - internal/frontend/templates_test.go | 20 - internal/httputil/errors.go | 25 +- internal/httputil/headers.go | 2 +- internal/testutil/testutil.go | 9 + internal/urlutil/known.go | 62 + .../urlutil/known_test.go | 21 +- pkg/webauthnutil/device_type.go | 3 +- proxy/proxy.go | 4 - scripts/build-dev-docker.bash | 2 +- ui/dist/apple-touch-icon.png | Bin 0 -> 5982 bytes ui/dist/favicon-16x16.png | Bin 0 -> 499 bytes ui/dist/favicon-32x32.png | Bin 0 -> 966 bytes ui/dist/favicon.ico | Bin 0 -> 15406 bytes ui/dist/index.html | 28 + ui/embed.go | 93 + ui/package.json | 53 + ui/scripts/esbuild.ts | 16 + ui/src/App.tsx | 47 + ui/src/components/AlertDialog.tsx | 30 + ui/src/components/ClaimValue.tsx | 24 + ui/src/components/ClaimsTable.tsx | 57 + ui/src/components/CsrfInput.tsx | 9 + ui/src/components/DeviceCredentialsTable.tsx | 72 + ui/src/components/DeviceEnrolledPage.tsx | 19 + ui/src/components/ErrorPage.tsx | 44 + ui/src/components/ExperimentalIcon.tsx | 16 + ui/src/components/Footer.tsx | 39 + ui/src/components/GroupDetails.tsx | 41 + ui/src/components/Header.tsx | 35 + ui/src/components/HeroSection.tsx | 24 + ui/src/components/IDField.tsx | 19 + ui/src/components/JwtIcon.tsx | 59 + ui/src/components/Logo.tsx | 9 + ui/src/components/PersonIcon.tsx | 10 + ui/src/components/Section.tsx | 41 + ui/src/components/SectionFooter.tsx | 9 + ui/src/components/SessionDetails.tsx | 47 + .../components/SessionDeviceCredentials.tsx | 81 + ui/src/components/UserClaims.tsx | 17 + ui/src/components/UserInfoPage.tsx | 55 + .../components/WebAuthnRegistrationPage.tsx | 210 + ui/src/globals.d.ts | 1 + ui/src/index.tsx | 5 + ui/src/static/logo_white.svg | 23 + ui/src/theme/index.ts | 161 + ui/src/theme/shadows.ts | 57 + ui/src/types/index.ts | 127 + .../js/base64.mjs => ui/src/util/base64.ts | 12 +- ui/tsconfig.json | 22 + ui/yarn.lock | 2472 +++++ yarn.lock | 8995 +++++++++++++++++ 84 files changed, 13373 insertions(+), 1455 deletions(-) create mode 100644 authenticate/handlers/userinfo.go delete mode 100644 authenticate/url.go delete mode 100644 internal/frontend/assets/html/device-enrolled.go.html delete mode 100644 internal/frontend/assets/html/error.go.html delete mode 100644 internal/frontend/assets/html/header.go.html delete mode 100644 internal/frontend/assets/html/userInfo.html delete mode 100644 internal/frontend/assets/html/webauthn.go.html delete mode 100644 internal/frontend/assets/img/account_circle-24px.svg delete mode 100644 internal/frontend/assets/img/error-24px.svg delete mode 100644 internal/frontend/assets/img/experimental.svg delete mode 100644 internal/frontend/assets/img/jwt.svg delete mode 100644 internal/frontend/assets/img/logo-long.svg delete mode 100644 internal/frontend/assets/img/logo-only.svg delete mode 100644 internal/frontend/assets/img/pomerium.svg delete mode 100644 internal/frontend/assets/img/pomerium_circle_96.svg delete mode 100644 internal/frontend/assets/img/supervised_user_circle-24px.svg delete mode 100644 internal/frontend/assets/js/webauthn.mjs delete mode 100644 internal/frontend/assets/style/main.css delete mode 100644 internal/frontend/templates.go delete mode 100644 internal/frontend/templates_test.go create mode 100644 internal/urlutil/known.go rename authenticate/url_test.go => internal/urlutil/known_test.go (67%) create mode 100644 ui/dist/apple-touch-icon.png create mode 100644 ui/dist/favicon-16x16.png create mode 100644 ui/dist/favicon-32x32.png create mode 100644 ui/dist/favicon.ico create mode 100644 ui/dist/index.html create mode 100644 ui/embed.go create mode 100644 ui/package.json create mode 100644 ui/scripts/esbuild.ts create mode 100644 ui/src/App.tsx create mode 100644 ui/src/components/AlertDialog.tsx create mode 100644 ui/src/components/ClaimValue.tsx create mode 100644 ui/src/components/ClaimsTable.tsx create mode 100644 ui/src/components/CsrfInput.tsx create mode 100644 ui/src/components/DeviceCredentialsTable.tsx create mode 100644 ui/src/components/DeviceEnrolledPage.tsx create mode 100644 ui/src/components/ErrorPage.tsx create mode 100644 ui/src/components/ExperimentalIcon.tsx create mode 100644 ui/src/components/Footer.tsx create mode 100644 ui/src/components/GroupDetails.tsx create mode 100644 ui/src/components/Header.tsx create mode 100644 ui/src/components/HeroSection.tsx create mode 100644 ui/src/components/IDField.tsx create mode 100644 ui/src/components/JwtIcon.tsx create mode 100644 ui/src/components/Logo.tsx create mode 100644 ui/src/components/PersonIcon.tsx create mode 100644 ui/src/components/Section.tsx create mode 100644 ui/src/components/SectionFooter.tsx create mode 100644 ui/src/components/SessionDetails.tsx create mode 100644 ui/src/components/SessionDeviceCredentials.tsx create mode 100644 ui/src/components/UserClaims.tsx create mode 100644 ui/src/components/UserInfoPage.tsx create mode 100644 ui/src/components/WebAuthnRegistrationPage.tsx create mode 100644 ui/src/globals.d.ts create mode 100644 ui/src/index.tsx create mode 100644 ui/src/static/logo_white.svg create mode 100644 ui/src/theme/index.ts create mode 100644 ui/src/theme/shadows.ts create mode 100644 ui/src/types/index.ts rename internal/frontend/assets/js/base64.mjs => ui/src/util/base64.ts (93%) create mode 100644 ui/tsconfig.json create mode 100644 ui/yarn.lock create mode 100644 yarn.lock 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 0000000000000000000000000000000000000000..04868ba5d3188d2dee48db2f8d34c07298890b42 GIT binary patch literal 5982 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 literal 0 HcmV?d00001 diff --git a/ui/dist/favicon.ico b/ui/dist/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d78ac8dee8ecfac012b06b9b900697aa0009be17 GIT binary patch literal 15406 zcmeHN4{%h)8NU`0#nMtND#dm(imlVmC@n*06kG342*wb!){HY!#qm!_A@A-&AXJo^ z0`ey+Myf(6f5ZTmD*kSWU-?MP#8rz%bG>Nfnqo%o1H93=(R@y7i?*M9qd$L^1UKzF=}p=S!=jcFeeG3Z zq4~RgM)TbX8J23aSsz@r?G0kHjZowHe=4KvOaWd!Ub;SMkEQ`l1H+?%BR?p+I$pH! zx`@p=F3{O|jasX*do5E%z4;z-*5aQ9?4Eo52M&BgjZRr6>bcqYXZnQMzxDg=?oe-= z`vy5p++a4b^^$+PmGnAXb@F$~^2Zq5q`(-nPTvk<%#U!$#N0>seko{sD()2CK`J4!3;FKWQ8XD?e_uJR{lP zoW%GKwf|yizVf*=`5E(1G5#0Q|3%ABjsL-%_5~b01&jxK=)J5y{F{xm{_IE1u5Yt? znnsKj6B$jc&`SI*UBKyCDyNa>1NtYf!QS~j@cbvmM|E)D0z!QkzNPD=e|N{K=zZ1e z(&~$=3BS{ce~`O}Z*ISlwTUtOot!TC@($p1#Uf76I7TB+PfUAqjGR6r5C4PuAQ|Np zW62^CTDgtz+a0Ha>vruF?_X$B(up${(R!+@!%82C;vi*ZBj|q*?+V!0ThK*2^n48c zNR5TE&3wP9F2hSU0Ee@mcetHCyKj_S2ifw1Y5w(se%|D`$ug79jktk(5&qORH>pYqc)s>b!1EO*ekiTP2E{mXKyN9ZW2$MZHVJozG>Q|Dl_l0To1 zoT1QCJs#IvCx>U(7O9;x45e+2SJQx|fh$u3^qd4ow7MtoYUd@08jCZ)6V(~sk$5OGhd~{52o)a@#R>P;P6DsP~^84Oj$2(n1 zybTRfjFQnRQ}{Hy;MX63tOK#4st&}!I&uEd>2G!aLP0;G&YjM{m;E%j zzUf~$(>v>Px_+qE#&P|;n}DN-`Ja)BIs*LEr~4@0_SfswcvuHdeU|LyAgpKSQ^-M^*lzx$%spM?DAgqzT`SPu`8KH!Q(8j;Z|$eC-vRVtk1}Qnzj12{1NC7LUk$#wo8tH!INr%- zL$Sh2eHtHl!;9ab_b@5%-$MJlabx(xkFU6R6xyi;b~n>w(Dg8*USf=I4|c=(jWmO&!GV^z@_8Rm#{L=GfOj*P`T&;*JpiM>=-yGw_4w zklIXpRrV-;cf}3LcGSMbl7%EP{}n=G+~MaN283TOI|YCJGc{UTCOo|=Ic~0gtS8%i z_U_Us?ydx_uTDTLcR6T2&EUn@g=#lZ9TCK2|8mUn{!}Gijo(8ZFITdQATbZ@uFvTjfH8bD*Zo;>O8IESxpyE& z>%v*aQJg#Y@hw0GT|+T1rF&!2dX$EbV!Tu@=5oYZPXf5l#_##lIe)9m3wv7B+1ZI3 z^w}TUmy^b-iGqQ + + + + + + + + + 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} +