userinfo: fix logout button, add sign out confirm page (#3058)

* userinfo: fix logout button, add sign out confirm page

* fix test
This commit is contained in:
Caleb Doxsey 2022-02-23 08:15:00 -07:00 committed by GitHub
parent 9300208e87
commit 38c7089642
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 251 additions and 162 deletions

View file

@ -8,7 +8,7 @@ linters-settings:
lines: 100 lines: 100
statements: 50 statements: 50
gci: gci:
local-prefixes: github.com/pomerium/pomerium local-prefixes: github.com/pomerium
goconst: goconst:
min-len: 2 min-len: 2
min-occurrences: 2 min-occurrences: 2
@ -28,7 +28,7 @@ linters-settings:
gocyclo: gocyclo:
min-complexity: 15 min-complexity: 15
goimports: goimports:
local-prefixes: github.com/pomerium/pomerium local-prefixes: github.com/pomerium
govet: govet:
check-shadowing: false check-shadowing: false
lll: lll:

View file

@ -12,11 +12,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pomerium/csrf"
"github.com/rs/cors" "github.com/rs/cors"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/csrf"
"github.com/pomerium/pomerium/authenticate/handlers" "github.com/pomerium/pomerium/authenticate/handlers"
"github.com/pomerium/pomerium/authenticate/handlers/webauthn" "github.com/pomerium/pomerium/authenticate/handlers/webauthn"
"github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/httputil"
@ -55,8 +55,7 @@ func (a *Authenticate) Mount(r *mux.Router) {
csrf.Path("/"), csrf.Path("/"),
csrf.UnsafePaths( csrf.UnsafePaths(
[]string{ []string{
"/oauth2/callback", // rfc6749#section-10.12 accepts GET "/oauth2/callback", // rfc6749#section-10.12 accepts GET
"/.pomerium/sign_out", // https://openid.net/specs/openid-connect-frontchannel-1_0.html
}), }),
csrf.FormValueName("state"), // rfc6749#section-10.12 csrf.FormValueName("state"), // rfc6749#section-10.12
csrf.CookieName(csrfKey), csrf.CookieName(csrfKey),
@ -96,14 +95,10 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
sr.Use(a.VerifySession) sr.Use(a.VerifySession)
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo)) sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn)) sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut)) sr.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut))
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState)) sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
authenticateURL, err := a.options.Load().GetAuthenticateURL() handlers.DeviceEnrolled().ServeHTTP(w, r)
if err != nil {
return err
}
handlers.DeviceEnrolled(authenticateURL, a.state.Load().sharedKey).ServeHTTP(w, r)
return nil return nil
})) }))
@ -276,6 +271,25 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error {
// SignOut signs the user out and attempts to revoke the user's identity session // SignOut signs the user out and attempts to revoke the user's identity session
// Handles both GET and POST. // Handles both GET and POST.
func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error {
// check for an HMAC'd URL. If none is found, show a confirmation page.
err := middleware.ValidateRequestURL(a.getExternalRequest(r), a.state.Load().sharedKey)
if err != nil {
authenticateURL, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return err
}
handlers.SignOutConfirm(handlers.SignOutConfirmData{
URL: urlutil.SignOutURL(r, authenticateURL, a.state.Load().sharedKey),
}).ServeHTTP(w, r)
return nil
}
// otherwise actually do the sign out
return a.signOutRedirect(w, r)
}
func (a *Authenticate) signOutRedirect(w http.ResponseWriter, r *http.Request) error {
ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut") ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut")
defer span.End() defer span.End()
@ -553,7 +567,6 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
DirectoryUser: pbDirectoryUser, DirectoryUser: pbDirectoryUser,
IsImpersonated: isImpersonated, IsImpersonated: isImpersonated,
Session: pbSession, Session: pbSession,
SignOutURL: urlutil.SignOutURL(r, authenticateURL, state.sharedKey),
User: pbUser, User: pbUser,
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()), WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
}).ServeHTTP(w, r) }).ServeHTTP(w, r)

View file

@ -2,18 +2,14 @@ package handlers
import ( import (
"net/http" "net/http"
"net/url"
"github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/ui" "github.com/pomerium/pomerium/ui"
) )
// DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device. // DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device.
func DeviceEnrolled(authenticateURL *url.URL, sharedKey []byte) http.Handler { func DeviceEnrolled() http.Handler {
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{ return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{})
"signOutUrl": urlutil.SignOutURL(r, authenticateURL, sharedKey),
})
}) })
} }

View file

@ -0,0 +1,27 @@
package handlers
import (
"net/http"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/ui"
)
// SignOutConfirmData is the data for the SignOutConfirm page.
type SignOutConfirmData struct {
URL string
}
// ToJSON converts the data into a JSON map.
func (data SignOutConfirmData) ToJSON() map[string]interface{} {
return map[string]interface{}{
"url": data.URL,
}
}
// SignOutConfirm returns a handler that renders the sign out confirm page.
func SignOutConfirm(data SignOutConfirmData) http.Handler {
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return ui.ServePage(w, r, "SignOutConfirm", data.ToJSON())
})
}

View file

@ -20,7 +20,6 @@ type UserInfoData struct {
DirectoryUser *directory.User DirectoryUser *directory.User
IsImpersonated bool IsImpersonated bool
Session *session.Session Session *session.Session
SignOutURL string
User *user.User User *user.User
WebAuthnURL string WebAuthnURL string
} }
@ -43,7 +42,6 @@ func (data UserInfoData) ToJSON() map[string]interface{} {
if bs, err := protojson.Marshal(data.Session); err == nil { if bs, err := protojson.Marshal(data.Session); err == nil {
m["session"] = json.RawMessage(bs) m["session"] = json.RawMessage(bs)
} }
m["signOutUrl"] = data.SignOutURL
if bs, err := protojson.Marshal(data.User); err == nil { if bs, err := protojson.Marshal(data.User); err == nil {
m["user"] = json.RawMessage(bs) m["user"] = json.RawMessage(bs)
} }

View file

@ -13,7 +13,6 @@ import (
"net/url" "net/url"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pomerium/webauthn"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
@ -30,6 +29,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/pkg/webauthnutil" "github.com/pomerium/pomerium/pkg/webauthnutil"
"github.com/pomerium/pomerium/ui" "github.com/pomerium/pomerium/ui"
"github.com/pomerium/webauthn"
) )
const maxAuthenticateResponses = 5 const maxAuthenticateResponses = 5
@ -373,7 +373,6 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat
"creationOptions": creationOptions, "creationOptions": creationOptions,
"requestOptions": requestOptions, "requestOptions": requestOptions,
"selfUrl": r.URL.String(), "selfUrl": r.URL.String(),
"signOutUrl": urlutil.SignOutURL(r, state.AuthenticateURL, state.SharedKey),
}) })
} }

View file

@ -227,12 +227,84 @@ func TestAuthenticate_SignOut(t *testing.T) {
wantCode int wantCode int
wantBody string wantBody string
}{ }{
{"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, {
{"signout redirect url", http.MethodPost, nil, "", "https://signout-redirect-url.example.com", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, "good post",
{"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, http.MethodPost,
{"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, nil,
{"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""}, "https://corp.pomerium.io/",
{"no redirect uri", http.MethodPost, nil, "", "", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusOK, "{\"Status\":200,\"Error\":\"OK: user logged out\"}\n"}, "",
"sig",
"ts",
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusFound,
"",
},
{
"signout redirect url",
http.MethodPost,
nil,
"",
"https://signout-redirect-url.example.com",
"sig",
"ts",
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusFound,
"",
},
{
"failed revoke",
http.MethodPost,
nil,
"https://corp.pomerium.io/",
"",
"sig",
"ts",
identity.MockProvider{RevokeError: errors.New("OH NO")},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusFound,
"",
},
{
"load session error",
http.MethodPost,
errors.New("error"),
"https://corp.pomerium.io/",
"",
"sig",
"ts",
identity.MockProvider{RevokeError: errors.New("OH NO")},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusFound,
"",
},
{
"bad redirect uri",
http.MethodPost,
nil,
"corp.pomerium.io/",
"",
"sig",
"ts",
identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusFound,
"",
},
{
"no redirect uri",
http.MethodPost,
nil,
"",
"",
"sig",
"ts",
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
http.StatusOK,
"{\"Status\":200,\"Error\":\"OK: user logged out\"}\n",
},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
@ -295,7 +367,7 @@ func TestAuthenticate_SignOut(t *testing.T) {
r.Header.Set("Accept", "application/json") r.Header.Set("Accept", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
httputil.HandlerFunc(a.SignOut).ServeHTTP(w, r) httputil.HandlerFunc(a.signOutRedirect).ServeHTTP(w, r)
if status := w.Code; status != tt.wantCode { if status := w.Code; status != tt.wantCode {
t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode) t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode)
} }
@ -727,89 +799,6 @@ func (m mockDirectoryServiceClient) RefreshUser(ctx context.Context, in *directo
return nil, status.Error(codes.Unimplemented, "") return nil, status.Error(codes.Unimplemented, "")
} }
func TestAuthenticate_SignOut_CSRF(t *testing.T) {
now := time.Now()
signer, err := jws.NewHS256Signer(nil)
if err != nil {
t.Fatal(err)
}
pbNow, _ := ptypes.TimestampProto(now)
a := &Authenticate{
options: config.NewAtomicOptions(),
state: newAtomicAuthenticateState(&authenticateState{
// sessionStore: tt.sessionStore,
cookieSecret: cryptutil.NewKey(),
encryptedEncoder: signer,
sharedEncoder: signer,
dataBrokerClient: mockDataBrokerServiceClient{
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
data, err := ptypes.MarshalAny(&session.Session{
Id: "SESSION_ID",
UserId: "USER_ID",
IdToken: &session.IDToken{IssuedAt: pbNow},
})
if err != nil {
return nil, err
}
return &databroker.GetResponse{
Record: &databroker.Record{
Version: 1,
Type: data.GetTypeUrl(),
Id: "SESSION_ID",
Data: data,
},
}, nil
},
},
directoryClient: new(mockDirectoryServiceClient),
}),
}
tests := []struct {
name string
setCSRFCookie bool
method string
wantStatus int
wantBody string
}{
{"GET without CSRF should fail", false, "GET", 400, "{\"Status\":400,\"Error\":\"Bad Request: CSRF token invalid\"}\n"},
{"POST without CSRF should fail", false, "POST", 400, "{\"Status\":400,\"Error\":\"Bad Request: CSRF token invalid\"}\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := a.Handler()
// Obtain a CSRF cookie via a GET request.
orr, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.ServeHTTP(rr, orr)
r, err := http.NewRequest(tt.method, "/.pomerium/sign_out", nil)
if err != nil {
t.Fatal(err)
}
if tt.setCSRFCookie {
r.Header.Set("Cookie", rr.Header().Get("Set-Cookie"))
}
r.Header.Set("Accept", "application/json")
r.Header.Set("Referer", "/")
rr = httptest.NewRecorder()
s.ServeHTTP(rr, r)
if rr.Code != tt.wantStatus {
t.Errorf("status: got %v want %v", rr.Code, tt.wantStatus)
}
body := rr.Body.String()
if diff := cmp.Diff(body, tt.wantBody); diff != "" {
t.Errorf("handler returned wrong body Body: %s", diff)
}
})
}
}
func mustParseURL(rawurl string) *url.URL { func mustParseURL(rawurl string) *url.URL {
u, err := url.Parse(rawurl) u, err := url.Parse(rawurl)
if err != nil { if err != nil {

View file

@ -9,7 +9,6 @@ import (
"sync/atomic" "sync/atomic"
"github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3"
"github.com/pomerium/webauthn"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding"
@ -24,6 +23,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/directory" "github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/webauthnutil" "github.com/pomerium/pomerium/pkg/webauthnutil"
"github.com/pomerium/webauthn"
) )
var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn) var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)

View file

@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pomerium/csrf"
"github.com/pomerium/csrf"
"github.com/pomerium/pomerium/ui" "github.com/pomerium/pomerium/ui"
) )

View file

@ -3,13 +3,13 @@ package webauthnutil
import ( import (
"context" "context"
"github.com/pomerium/webauthn"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/pomerium/pomerium/pkg/encoding/base58" "github.com/pomerium/pomerium/pkg/encoding/base58"
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device" "github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/webauthn"
) )
// CredentialStorage stores credentials in the databroker. // CredentialStorage stores credentials in the databroker.

View file

@ -4,13 +4,13 @@ import (
"context" "context"
"testing" "testing"
"github.com/pomerium/webauthn"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/webauthn"
) )
type mockDataBrokerServiceClient struct { type mockDataBrokerServiceClient struct {

View file

@ -3,7 +3,6 @@ package webauthnutil
import ( import (
"context" "context"
"github.com/pomerium/webauthn/cose"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -11,6 +10,7 @@ import (
"github.com/pomerium/pomerium/internal/urlutil" "github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/device" "github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/webauthn/cose"
) )
// DefaultDeviceType is the default device type when none is specified. // DefaultDeviceType is the default device type when none is specified.

View file

@ -3,12 +3,12 @@ package webauthnutil
import ( import (
"testing" "testing"
"github.com/pomerium/webauthn"
"github.com/pomerium/webauthn/cose"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/pomerium/pomerium/pkg/grpc/device" "github.com/pomerium/pomerium/pkg/grpc/device"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/webauthn"
"github.com/pomerium/webauthn/cose"
) )
func TestGenerateCreationOptions(t *testing.T) { func TestGenerateCreationOptions(t *testing.T) {

View file

@ -2,9 +2,9 @@ package webauthnutil
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pomerium/webauthn"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/webauthn"
) )
var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859") var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859")

View file

@ -2,16 +2,17 @@ import DeviceEnrolledPage from "./components/DeviceEnrolledPage";
import ErrorPage from "./components/ErrorPage"; import ErrorPage from "./components/ErrorPage";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import Header from "./components/Header"; import Header from "./components/Header";
import SignOutConfirmPage from "./components/SignOutConfirmPage";
import { ToolbarOffset } from "./components/ToolbarOffset";
import UserInfoPage from "./components/UserInfoPage"; import UserInfoPage from "./components/UserInfoPage";
import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage"; import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage";
import { SubpageContextProvider } from "./context/Subpage";
import { createTheme } from "./theme"; import { createTheme } from "./theme";
import { PageData } from "./types"; import { PageData } from "./types";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import React, { FC } from "react"; import React, { FC } from "react";
import {ToolbarOffset} from "./components/ToolbarOffset";
import Box from "@mui/material/Box";
import {SubpageContextProvider} from "./context/Subpage";
const theme = createTheme(); const theme = createTheme();
@ -25,6 +26,9 @@ const App: FC = () => {
case "Error": case "Error":
body = <ErrorPage data={data} />; body = <ErrorPage data={data} />;
break; break;
case "SignOutConfirm":
body = <SignOutConfirmPage data={data} />;
break;
case "UserInfo": case "UserInfo":
body = <UserInfoPage data={data} />; body = <UserInfoPage data={data} />;
break; break;
@ -36,15 +40,16 @@ const App: FC = () => {
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<SubpageContextProvider> <SubpageContextProvider>
<Header <Header includeSidebar={data?.page === "UserInfo"} data={data} />
includeSidebar={data?.page === "UserInfo"}
data={data}
/>
<ToolbarOffset /> <ToolbarOffset />
<Box <Box sx={{ overflow: "hidden", height: "calc(100vh - 120px)" }}>
sx={{overflow: 'hidden', height: 'calc(100vh - 120px)'}} <Box
> sx={{
<Box sx={{overflow: 'auto', height: '100%', paddingTop: theme.spacing(5)}}> overflow: "auto",
height: "100%",
paddingTop: theme.spacing(5),
}}
>
{body} {body}
<ToolbarOffset /> <ToolbarOffset />
</Box> </Box>

View file

@ -1,23 +1,29 @@
import { PageData } from "../types";
import { Avatar } from "./Avatar";
import Logo from "./Logo"; import Logo from "./Logo";
import { ToolbarOffset } from "./ToolbarOffset";
import UserSidebarContent from "./UserSidebarContent";
import {
Drawer,
IconButton,
Menu,
MenuItem,
useMediaQuery,
} from "@mui/material";
import AppBar from "@mui/material/AppBar"; import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import React, {FC, useState} from "react"; import { useTheme } from "@mui/material/styles";
import {useTheme} from "@mui/material/styles";
import {Drawer, IconButton, Menu, MenuItem, useMediaQuery} from "@mui/material";
import {ToolbarOffset} from "./ToolbarOffset";
import UserSidebarContent from "./UserSidebarContent";
import {ChevronLeft, ChevronRight, Menu as MenuIcon} from "react-feather";
import styled from "@mui/material/styles/styled"; import styled from "@mui/material/styles/styled";
import {Avatar} from "./Avatar"; import { get } from "lodash";
import {PageData} from "../types"; import React, { FC, useState } from "react";
import {get} from 'lodash'; import { ChevronLeft, ChevronRight, Menu as MenuIcon } from "react-feather";
const DrawerHeader = styled('div')(({ theme }) => ({ const DrawerHeader = styled("div")(({ theme }) => ({
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
padding: theme.spacing(0, 1), padding: theme.spacing(0, 1),
justifyContent: 'flex-end', justifyContent: "flex-end",
})); }));
type HeaderProps = { type HeaderProps = {
@ -26,33 +32,34 @@ type HeaderProps = {
}; };
const Header: FC<HeaderProps> = ({ includeSidebar, data }) => { const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
const theme = useTheme(); const theme = useTheme();
const mdUp = useMediaQuery(() => theme.breakpoints.up('md'), { const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), {
defaultMatches: true, defaultMatches: true,
noSsr: false noSsr: false,
}); });
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [anchorEl, setAnchorEl] = React.useState(null); const [anchorEl, setAnchorEl] = React.useState(null);
const handleMenuOpen = e => { const handleMenuOpen = (e) => {
setAnchorEl(e.currentTarget); setAnchorEl(e.currentTarget);
}; };
const handleMenuClose = () => { const handleMenuClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
const userName = get(data, 'user.name') || get(data, 'user.claims.given_name'); const userName =
get(data, "user.name") || get(data, "user.claims.given_name");
const handleDrawerOpen = () => { const handleDrawerOpen = () => {
setDrawerOpen(true); setDrawerOpen(true);
}; };
const handleDrawerClose = ():void => { const handleDrawerClose = (): void => {
setDrawerOpen(false); setDrawerOpen(false);
}; };
const handleLogout = (evt: React.MouseEvent):void => { const handleLogout = (evt: React.MouseEvent): void => {
evt.preventDefault(); evt.preventDefault();
location.href = "/.pomerium/sign_out"; location.href = "/.pomerium/sign_out";
} };
return ( return (
<AppBar <AppBar
@ -67,7 +74,7 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
aria-label="open drawer" aria-label="open drawer"
onClick={handleDrawerOpen} onClick={handleDrawerOpen}
edge="start" edge="start"
sx={{ mr: 2, ...(drawerOpen && { display: 'none' }) }} sx={{ mr: 2, ...(drawerOpen && { display: "none" }) }}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
@ -75,11 +82,11 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
sx={{ sx={{
width: 256, width: 256,
flexShrink: 0, flexShrink: 0,
'& .MuiDrawer-paper': { "& .MuiDrawer-paper": {
width: 256, width: 256,
boxSizing: 'border-box', boxSizing: "border-box",
backgroundColor: 'neutral.900', backgroundColor: "neutral.900",
height: '100vh', height: "100vh",
}, },
}} }}
variant="persistent" variant="persistent"
@ -88,10 +95,14 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
> >
<DrawerHeader> <DrawerHeader>
<IconButton onClick={handleDrawerClose}> <IconButton onClick={handleDrawerClose}>
{theme.direction === 'ltr' ? <ChevronLeft /> : <ChevronRight />} {theme.direction === "ltr" ? (
<ChevronLeft />
) : (
<ChevronRight />
)}
</IconButton> </IconButton>
</DrawerHeader> </DrawerHeader>
<UserSidebarContent close={handleDrawerClose}/> <UserSidebarContent close={handleDrawerClose} />
<ToolbarOffset /> <ToolbarOffset />
</Drawer> </Drawer>
</> </>
@ -104,13 +115,16 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
{userName && ( {userName && (
<> <>
<IconButton color="inherit" onClick={handleMenuOpen}> <IconButton color="inherit" onClick={handleMenuOpen}>
<Avatar name={userName} url={get(data, 'user.claims.picture', null)} /> <Avatar
name={userName}
url={get(data, "user.claims.picture", null)}
/>
</IconButton> </IconButton>
<Menu <Menu
onClose={handleMenuClose} onClose={handleMenuClose}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: "bottom",
horizontal: 'center' horizontal: "center",
}} }}
keepMounted keepMounted
open={!!anchorEl} open={!!anchorEl}

View file

@ -0,0 +1,43 @@
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import styled from "@mui/material/styles/styled";
import React, { FC } from "react";
import { SignOutConfirmPageData } from "src/types";
type SignOutConfirmPageProps = {
data: SignOutConfirmPageData;
};
const SignOutConfirmPage: FC<SignOutConfirmPageProps> = ({ data }) => {
function handleClickCancel(evt: React.MouseEvent) {
evt.preventDefault();
history.back();
}
function handleClickLogout(evt: React.MouseEvent) {
evt.preventDefault();
location.href = data.url;
}
return (
<Container>
<Dialog open={true} disableEscapeKeyDown={true}>
<DialogTitle>Logout?</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to logout?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClickCancel}>Cancel</Button>
<Button onClick={handleClickLogout}>Logout</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default SignOutConfirmPage;

View file

@ -81,7 +81,6 @@ export type WebAuthnRequestOptions = {
type BasePageData = { type BasePageData = {
csrfToken?: string; csrfToken?: string;
signOutUrl?: string;
}; };
export type ErrorPageData = BasePageData & { export type ErrorPageData = BasePageData & {
@ -100,6 +99,11 @@ export type DeviceEnrolledPageData = BasePageData & {
page: "DeviceEnrolled"; page: "DeviceEnrolled";
}; };
export type SignOutConfirmPageData = BasePageData & {
page: "SignOutConfirm";
url: string;
};
export type UserInfoPageData = BasePageData & { export type UserInfoPageData = BasePageData & {
page: "UserInfo"; page: "UserInfo";
@ -123,5 +127,6 @@ export type WebAuthnRegistrationPageData = BasePageData & {
export type PageData = export type PageData =
| ErrorPageData | ErrorPageData
| DeviceEnrolledPageData | DeviceEnrolledPageData
| SignOutConfirmPageData
| UserInfoPageData | UserInfoPageData
| WebAuthnRegistrationPageData; | WebAuthnRegistrationPageData;