mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-18 03:27:16 +02:00
userinfo: add webauthn buttons to user info page (#3075)
* userinfo: add webauthn buttons to user info page * use new buttons on original page * fix test
This commit is contained in:
parent
38c7089642
commit
35f697e491
14 changed files with 423 additions and 288 deletions
|
@ -7,6 +7,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
"github.com/pomerium/pomerium/pkg/cryptutil"
|
||||
|
@ -33,9 +34,10 @@ func ValidateOptions(o *config.Options) error {
|
|||
|
||||
// Authenticate contains data required to run the authenticate service.
|
||||
type Authenticate struct {
|
||||
cfg *authenticateConfig
|
||||
options *config.AtomicOptions
|
||||
state *atomicAuthenticateState
|
||||
cfg *authenticateConfig
|
||||
options *config.AtomicOptions
|
||||
state *atomicAuthenticateState
|
||||
webauthn *webauthn.Handler
|
||||
}
|
||||
|
||||
// New validates and creates a new authenticate service from a set of Options.
|
||||
|
@ -45,6 +47,7 @@ func New(cfg *config.Config, options ...Option) (*Authenticate, error) {
|
|||
options: config.NewAtomicOptions(),
|
||||
state: newAtomicAuthenticateState(newAuthenticateState()),
|
||||
}
|
||||
a.webauthn = webauthn.New(a.getWebauthnState)
|
||||
|
||||
state, err := newAuthenticateStateFromConfig(cfg)
|
||||
if err != nil {
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/pomerium/csrf"
|
||||
|
||||
"github.com/pomerium/pomerium/authenticate/handlers"
|
||||
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
||||
"github.com/pomerium/pomerium/internal/httputil"
|
||||
|
@ -96,7 +97,7 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
|
|||
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
|
||||
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
|
||||
sr.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut))
|
||||
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
|
||||
sr.Path("/webauthn").Handler(a.webauthn)
|
||||
sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
handlers.DeviceEnrolled().ServeHTTP(w, r)
|
||||
return nil
|
||||
|
@ -561,6 +562,8 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
|
|||
groups = append(groups, pbDirectoryGroup)
|
||||
}
|
||||
|
||||
creationOptions, requestOptions, _ := a.webauthn.GetOptions(ctx)
|
||||
|
||||
handlers.UserInfo(handlers.UserInfoData{
|
||||
CSRFToken: csrf.Token(r),
|
||||
DirectoryGroups: groups,
|
||||
|
@ -568,7 +571,10 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
|
|||
IsImpersonated: isImpersonated,
|
||||
Session: pbSession,
|
||||
User: pbUser,
|
||||
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
|
||||
|
||||
WebAuthnCreationOptions: creationOptions,
|
||||
WebAuthnRequestOptions: requestOptions,
|
||||
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
|
||||
}).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/pomerium/pomerium/pkg/grpc/session"
|
||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||
"github.com/pomerium/pomerium/ui"
|
||||
"github.com/pomerium/webauthn"
|
||||
)
|
||||
|
||||
// UserInfoData is the data for the UserInfo page.
|
||||
|
@ -21,7 +22,10 @@ type UserInfoData struct {
|
|||
IsImpersonated bool
|
||||
Session *session.Session
|
||||
User *user.User
|
||||
WebAuthnURL string
|
||||
|
||||
WebAuthnCreationOptions *webauthn.PublicKeyCredentialCreationOptions
|
||||
WebAuthnRequestOptions *webauthn.PublicKeyCredentialRequestOptions
|
||||
WebAuthnURL string
|
||||
}
|
||||
|
||||
// ToJSON converts the data into a JSON map.
|
||||
|
@ -45,6 +49,8 @@ func (data UserInfoData) ToJSON() map[string]interface{} {
|
|||
if bs, err := protojson.Marshal(data.User); err == nil {
|
||||
m["user"] = json.RawMessage(bs)
|
||||
}
|
||||
m["webAuthnCreationOptions"] = data.WebAuthnCreationOptions
|
||||
m["webAuthnRequestOptions"] = data.WebAuthnRequestOptions
|
||||
m["webAuthnUrl"] = data.WebAuthnURL
|
||||
return m
|
||||
}
|
||||
|
|
|
@ -72,11 +72,50 @@ func New(getState StateProvider) *Handler {
|
|||
}
|
||||
}
|
||||
|
||||
// GetOptions returns the creation and request options for WebAuthn.
|
||||
func (h *Handler) GetOptions(ctx context.Context) (
|
||||
creationOptions *webauthn.PublicKeyCredentialCreationOptions,
|
||||
requestOptions *webauthn.PublicKeyCredentialRequestOptions,
|
||||
err error,
|
||||
) {
|
||||
state, err := h.getState(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return h.getOptions(ctx, state, webauthnutil.DefaultDeviceType)
|
||||
}
|
||||
|
||||
// ServeHTTP serves the HTTP handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.HandlerFunc(h.handle).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) getOptions(ctx context.Context, state *State, deviceTypeParam string) (
|
||||
creationOptions *webauthn.PublicKeyCredentialCreationOptions,
|
||||
requestOptions *webauthn.PublicKeyCredentialRequestOptions,
|
||||
err error,
|
||||
) {
|
||||
// get the user information
|
||||
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// get the device credentials
|
||||
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// get the stored device type
|
||||
deviceType := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
|
||||
|
||||
creationOptions = webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u)
|
||||
requestOptions = webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials)
|
||||
return creationOptions, requestOptions, nil
|
||||
}
|
||||
|
||||
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) error {
|
||||
s, err := h.getState(r.Context())
|
||||
if err != nil {
|
||||
|
@ -351,24 +390,11 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat
|
|||
return errMissingDeviceType
|
||||
}
|
||||
|
||||
// get the user information
|
||||
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
|
||||
creationOptions, requestOptions, err := h.getOptions(ctx, state, deviceTypeParam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the device credentials
|
||||
knownDeviceCredentials, err := getKnownDeviceCredentials(ctx, state.Client, u.GetDeviceCredentialIds()...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the stored device type
|
||||
deviceType := webauthnutil.GetDeviceType(ctx, state.Client, deviceTypeParam)
|
||||
|
||||
creationOptions := webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u)
|
||||
requestOptions := webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials)
|
||||
|
||||
return ui.ServePage(w, r, "WebAuthnRegistration", map[string]interface{}{
|
||||
"creationOptions": creationOptions,
|
||||
"requestOptions": requestOptions,
|
||||
|
|
|
@ -24,6 +24,7 @@ import (
|
|||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/pomerium/pomerium/internal/encoding"
|
||||
"github.com/pomerium/pomerium/internal/encoding/jws"
|
||||
|
@ -748,6 +749,7 @@ func TestAuthenticate_userInfo(t *testing.T) {
|
|||
directoryClient: new(mockDirectoryServiceClient),
|
||||
}),
|
||||
}
|
||||
a.webauthn = webauthn.New(a.getWebauthnState)
|
||||
r := httptest.NewRequest(tt.method, tt.url.String(), nil)
|
||||
state, err := tt.sessionStore.LoadSession(r)
|
||||
if err != nil {
|
||||
|
|
|
@ -49,5 +49,12 @@
|
|||
"prettier": "^2.4.1",
|
||||
"ts-node": "^10.4.0",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"prettier": {
|
||||
"importOrder": [
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
import React from 'react';
|
||||
import {User} from "react-feather";
|
||||
import MuiAvatar from "@mui/material/Avatar";
|
||||
import { isArray } from "lodash";
|
||||
import React from "react";
|
||||
import { User } from "react-feather";
|
||||
|
||||
type AvatarProps = {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const Avatar = ({url, name}:AvatarProps): JSX.Element => {
|
||||
if (url === 'https://graph.microsoft.com/v1.0/me/photo/$value') {
|
||||
export const Avatar = ({ url, name }: AvatarProps): JSX.Element => {
|
||||
if (isArray(url)) {
|
||||
url = url?.pop();
|
||||
}
|
||||
|
||||
if (url === "https://graph.microsoft.com/v1.0/me/photo/$value") {
|
||||
url = null;
|
||||
}
|
||||
|
||||
return url ? (
|
||||
<MuiAvatar alt={name} src={url} />
|
||||
) : (
|
||||
<User />
|
||||
);
|
||||
return url ? <MuiAvatar alt={name} src={url} /> : <User />;
|
||||
};
|
||||
|
|
|
@ -1,30 +1,35 @@
|
|||
import DeviceCredentialsTable from "../components/DeviceCredentialsTable";
|
||||
import SectionFooter from "../components/SectionFooter";
|
||||
import { Session, User } from "../types";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC } from "react";
|
||||
|
||||
import DeviceCredentialsTable from "../components/DeviceCredentialsTable";
|
||||
import {
|
||||
Session,
|
||||
User,
|
||||
WebAuthnCreationOptions,
|
||||
WebAuthnRequestOptions,
|
||||
} from "../types";
|
||||
import WebAuthnAuthenticateButton from "./WebAuthnAuthenticateButton";
|
||||
import WebAuthnRegisterButton from "./WebAuthnRegisterButton";
|
||||
|
||||
export type SessionDeviceCredentialsProps = {
|
||||
csrfToken: string;
|
||||
user: User;
|
||||
session: Session;
|
||||
webAuthnCreationOptions: WebAuthnCreationOptions;
|
||||
webAuthnRequestOptions: WebAuthnRequestOptions;
|
||||
webAuthnUrl: string;
|
||||
};
|
||||
export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
|
||||
csrfToken,
|
||||
user,
|
||||
session,
|
||||
webAuthnUrl
|
||||
webAuthnCreationOptions,
|
||||
webAuthnRequestOptions,
|
||||
webAuthnUrl,
|
||||
}) => {
|
||||
const currentSessionDeviceCredentialIds = [];
|
||||
const otherDeviceCredentialIds = [];
|
||||
|
@ -37,45 +42,62 @@ export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Current Session Device Credentials
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
csrfToken={csrfToken}
|
||||
ids={currentSessionDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
/>
|
||||
</Box>
|
||||
{otherDeviceCredentialIds?.length > 0 ? (
|
||||
<>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Other Device Credentials
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
<>
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Current Session Device Credentials
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" justifyContent="center" spacing={1}>
|
||||
<WebAuthnRegisterButton
|
||||
creationOptions={webAuthnCreationOptions}
|
||||
csrfToken={csrfToken}
|
||||
ids={otherDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
url={webAuthnUrl}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<SectionFooter>
|
||||
<Typography variant="caption">
|
||||
Register device with <a href={webAuthnUrl}>WebAuthn</a>.
|
||||
</Typography>
|
||||
</SectionFooter>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<WebAuthnAuthenticateButton
|
||||
requestOptions={webAuthnRequestOptions}
|
||||
csrfToken={csrfToken}
|
||||
url={webAuthnUrl}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
csrfToken={csrfToken}
|
||||
ids={currentSessionDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{otherDeviceCredentialIds?.length > 0 ? (
|
||||
<>
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Other Device Credentials
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
csrfToken={csrfToken}
|
||||
ids={otherDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SessionDeviceCredentials;
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
import { Drawer, useMediaQuery } from "@mui/material";
|
||||
import Container from "@mui/material/Container";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import React, { FC, useContext } from "react";
|
||||
import { UserInfoPageData } from "src/types";
|
||||
|
||||
import { SubpageContext } from "../context/Subpage";
|
||||
import GroupDetails from "./GroupDetails";
|
||||
import SessionDetails from "./SessionDetails";
|
||||
import SessionDeviceCredentials from "./SessionDeviceCredentials";
|
||||
import Container from "@mui/material/Container";
|
||||
import React, {FC, useContext} from "react";
|
||||
import { UserInfoPageData } from "src/types";
|
||||
import {Drawer, useMediaQuery} from "@mui/material";
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { ToolbarOffset } from "./ToolbarOffset";
|
||||
import {UserSidebarContent} from "./UserSidebarContent";
|
||||
import {SubpageContext} from "../context/Subpage";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { UserSidebarContent } from "./UserSidebarContent";
|
||||
|
||||
type UserInfoPageProps = {
|
||||
data: UserInfoPageData;
|
||||
};
|
||||
const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
|
||||
const theme = useTheme();
|
||||
const mdUp = useMediaQuery(() => theme.breakpoints.up('md'), {
|
||||
const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), {
|
||||
defaultMatches: true,
|
||||
noSsr: false
|
||||
noSsr: false,
|
||||
});
|
||||
const {subpage} = useContext(SubpageContext);
|
||||
|
||||
const { subpage } = useContext(SubpageContext);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false}>
|
||||
|
@ -31,37 +31,37 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
|
|||
open
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: 'neutral.900',
|
||||
backgroundColor: "neutral.900",
|
||||
width: 256,
|
||||
height: '100vh',
|
||||
}
|
||||
height: "100vh",
|
||||
},
|
||||
}}
|
||||
variant="persistent"
|
||||
>
|
||||
<ToolbarOffset />
|
||||
<UserSidebarContent close={null}/>
|
||||
<UserSidebarContent close={null} />
|
||||
<ToolbarOffset />
|
||||
</Drawer>
|
||||
)}
|
||||
<Stack
|
||||
spacing={3}
|
||||
sx={{
|
||||
marginLeft: mdUp ? '256px' : '0px',
|
||||
}}>
|
||||
marginLeft: mdUp ? "256px" : "0px",
|
||||
}}
|
||||
>
|
||||
{subpage === "User" && <SessionDetails session={data?.session} />}
|
||||
|
||||
{subpage === 'User' && (
|
||||
<SessionDetails session={data?.session} />
|
||||
)}
|
||||
|
||||
{subpage === 'Groups Info' && (
|
||||
{subpage === "Groups Info" && (
|
||||
<GroupDetails groups={data?.directoryGroups} />
|
||||
)}
|
||||
|
||||
{subpage === 'Devices Info' && (
|
||||
{subpage === "Devices Info" && (
|
||||
<SessionDeviceCredentials
|
||||
csrfToken={data?.csrfToken}
|
||||
session={data?.session}
|
||||
user={data?.user}
|
||||
webAuthnCreationOptions={data?.webAuthnCreationOptions}
|
||||
webAuthnRequestOptions={data?.webAuthnRequestOptions}
|
||||
webAuthnUrl={data?.webAuthnUrl}
|
||||
/>
|
||||
)}
|
||||
|
|
70
ui/src/components/WebAuthnAuthenticateButton.tsx
Normal file
70
ui/src/components/WebAuthnAuthenticateButton.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import React, { FC, useRef, useState } from "react";
|
||||
|
||||
import { WebAuthnRequestOptions } from "../types";
|
||||
import { decode, encodeUrl } from "../util/base64";
|
||||
import WebAuthnButton, { WebAuthnButtonProps } from "./WebAuthnButton";
|
||||
|
||||
type CredentialForAuthenticate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
authenticatorData: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
userHandle: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function authenticateCredential(
|
||||
requestOptions: WebAuthnRequestOptions
|
||||
): Promise<CredentialForAuthenticate> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
return credential as CredentialForAuthenticate;
|
||||
}
|
||||
|
||||
export type WebAuthnAuthenticateButtonProps = Omit<
|
||||
WebAuthnButtonProps,
|
||||
"action" | "enable" | "onClick" | "text"
|
||||
> & {
|
||||
requestOptions: WebAuthnRequestOptions;
|
||||
};
|
||||
export const WebAuthnAuthenticateButton: FC<
|
||||
WebAuthnAuthenticateButtonProps
|
||||
> = ({ requestOptions, ...props }) => {
|
||||
async function authenticate(): Promise<unknown> {
|
||||
const credential = await authenticateCredential(requestOptions);
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeUrl(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON),
|
||||
signature: encodeUrl(credential.response.signature),
|
||||
userHandle: encodeUrl(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<WebAuthnButton
|
||||
action="authenticate"
|
||||
enable={requestOptions?.allowCredentials?.length > 0}
|
||||
onClick={authenticate}
|
||||
text={"Authenticate Existing Device"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default WebAuthnAuthenticateButton;
|
71
ui/src/components/WebAuthnButton.tsx
Normal file
71
ui/src/components/WebAuthnButton.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import Button, { ButtonProps } from "@mui/material/Button";
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
|
||||
import AlertDialog from "./AlertDialog";
|
||||
|
||||
export type WebAuthnButtonProps = Omit<ButtonProps, "action"> & {
|
||||
action: string;
|
||||
csrfToken: string;
|
||||
enable: boolean;
|
||||
onClick: () => Promise<unknown>;
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
export const WebAuthnButton: FC<WebAuthnButtonProps> = ({
|
||||
action,
|
||||
csrfToken,
|
||||
enable,
|
||||
onClick,
|
||||
text,
|
||||
url,
|
||||
...props
|
||||
}) => {
|
||||
const formRef = useRef<HTMLFormElement>();
|
||||
const responseRef = useRef<HTMLInputElement>();
|
||||
const [error, setError] = useState<string>(null);
|
||||
|
||||
function handleClickButton(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await onClick();
|
||||
responseRef.current.value = JSON.stringify(response);
|
||||
formRef.current.submit();
|
||||
} catch (e) {
|
||||
setError(`${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
function handleClickDialogOK(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClickButton}
|
||||
variant="contained"
|
||||
disabled={!enable}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
<form ref={formRef} method="post" action={url}>
|
||||
<input type="hidden" name="_pomerium_csrf" value={csrfToken} />
|
||||
<input type="hidden" name="action" value={action} />
|
||||
<input type="hidden" name={action + "_response"} ref={responseRef} />
|
||||
</form>
|
||||
<AlertDialog
|
||||
title="Error"
|
||||
severity="error"
|
||||
open={!!error}
|
||||
actions={<Button onClick={handleClickDialogOK}>OK</Button>}
|
||||
>
|
||||
{error}
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default WebAuthnButton;
|
89
ui/src/components/WebAuthnRegisterButton.tsx
Normal file
89
ui/src/components/WebAuthnRegisterButton.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { FC } from "react";
|
||||
|
||||
import { WebAuthnCreationOptions } from "../types";
|
||||
import { decode, encodeUrl } from "../util/base64";
|
||||
import WebAuthnButton, { WebAuthnButtonProps } from "./WebAuthnButton";
|
||||
|
||||
type CredentialForCreate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
attestationObject: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function createCredential(
|
||||
creationOptions: WebAuthnCreationOptions
|
||||
): Promise<CredentialForCreate> {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
return credential as CredentialForCreate;
|
||||
}
|
||||
|
||||
export type WebAuthnRegisterButtonProps = Omit<
|
||||
WebAuthnButtonProps,
|
||||
"action" | "enable" | "onClick" | "text"
|
||||
> & {
|
||||
creationOptions: WebAuthnCreationOptions;
|
||||
csrfToken: string;
|
||||
url: string;
|
||||
};
|
||||
export const WebAuthnRegisterButton: FC<WebAuthnRegisterButtonProps> = ({
|
||||
creationOptions,
|
||||
...props
|
||||
}) => {
|
||||
async function register(): Promise<unknown> {
|
||||
const credential = await createCredential(creationOptions);
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeUrl(credential.response.attestationObject),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<WebAuthnButton
|
||||
action="register"
|
||||
enable={!!creationOptions}
|
||||
onClick={register}
|
||||
text={"Register New Device"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default WebAuthnRegisterButton;
|
|
@ -1,202 +1,32 @@
|
|||
import { decode, encodeUrl } from "../util/base64";
|
||||
import AlertDialog from "./AlertDialog";
|
||||
import ExperimentalIcon from "./ExperimentalIcon";
|
||||
import HeroSection from "./HeroSection";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import {
|
||||
WebAuthnCreationOptions,
|
||||
WebAuthnRegistrationPageData,
|
||||
WebAuthnRequestOptions
|
||||
} from "src/types";
|
||||
import JwtIcon from "./JwtIcon";
|
||||
import ClaimsTable from "./ClaimsTable";
|
||||
import React, { FC } from "react";
|
||||
import { WebAuthnRegistrationPageData } from "src/types";
|
||||
|
||||
import ExperimentalIcon from "./ExperimentalIcon";
|
||||
import Section from "./Section";
|
||||
|
||||
type CredentialForAuthenticate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
authenticatorData: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
userHandle: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function authenticateCredential(
|
||||
requestOptions: WebAuthnRequestOptions
|
||||
): Promise<CredentialForAuthenticate> {
|
||||
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
|
||||
}
|
||||
});
|
||||
return credential as CredentialForAuthenticate;
|
||||
}
|
||||
|
||||
type CredentialForCreate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
attestationObject: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function createCredential(
|
||||
creationOptions: WebAuthnCreationOptions
|
||||
): Promise<CredentialForCreate> {
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
return credential as CredentialForCreate;
|
||||
}
|
||||
import WebAuthnAuthenticateButton from "./WebAuthnAuthenticateButton";
|
||||
import WebAuthnRegisterButton from "./WebAuthnRegisterButton";
|
||||
|
||||
type WebAuthnRegistrationPageProps = {
|
||||
data: WebAuthnRegistrationPageData;
|
||||
};
|
||||
const WebAuthnRegistrationPage: FC<WebAuthnRegistrationPageProps> = ({
|
||||
data
|
||||
data,
|
||||
}) => {
|
||||
const authenticateFormRef = useRef<HTMLFormElement>();
|
||||
const authenticateResponseRef = useRef<HTMLInputElement>();
|
||||
const registerFormRef = useRef<HTMLFormElement>();
|
||||
const registerResponseRef = useRef<HTMLInputElement>();
|
||||
|
||||
const [error, setError] = useState<string>(null);
|
||||
|
||||
const enableAuthenticate = data?.requestOptions?.allowCredentials?.length > 0;
|
||||
|
||||
function handleClickAuthenticate(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = await authenticateCredential(data?.requestOptions);
|
||||
authenticateResponseRef.current.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeUrl(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON),
|
||||
signature: encodeUrl(credential.response.signature),
|
||||
userHandle: encodeUrl(credential.response.userHandle)
|
||||
}
|
||||
});
|
||||
authenticateFormRef.current.submit();
|
||||
} catch (e) {
|
||||
setError(`${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
function handleClickDialogOK(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
setError(null);
|
||||
}
|
||||
function handleClickRegister(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = await createCredential(data?.creationOptions);
|
||||
registerResponseRef.current.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeUrl(credential.response.attestationObject),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON)
|
||||
}
|
||||
});
|
||||
registerFormRef.current.submit();
|
||||
} catch (e) {
|
||||
setError(`${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="WebAuthn Registration" icon={<ExperimentalIcon />}>
|
||||
<Paper sx={{ padding: "16px" }}>
|
||||
<Stack direction="row" justifyContent="center" spacing={3}>
|
||||
<Button onClick={handleClickRegister} variant="contained">
|
||||
Register New Device
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClickAuthenticate}
|
||||
variant="contained"
|
||||
disabled={!enableAuthenticate}
|
||||
>
|
||||
Authenticate Existing Device
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<form ref={authenticateFormRef} method="post" action={data?.selfUrl}>
|
||||
<input type="hidden" name="_pomerium_csrf" value={data?.csrfToken} />
|
||||
<input type="hidden" name="action" value="authenticate" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticate_response"
|
||||
ref={authenticateResponseRef}
|
||||
<Stack direction="row" justifyContent="center" spacing={1}>
|
||||
<WebAuthnRegisterButton
|
||||
creationOptions={data?.creationOptions}
|
||||
csrfToken={data?.csrfToken}
|
||||
url={data?.selfUrl}
|
||||
/>
|
||||
</form>
|
||||
<form ref={registerFormRef} method="POST" action={data?.selfUrl}>
|
||||
<input type="hidden" name="_pomerium_csrf" value={data?.csrfToken} />
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="register_response"
|
||||
ref={registerResponseRef}
|
||||
<WebAuthnAuthenticateButton
|
||||
requestOptions={data?.requestOptions}
|
||||
csrfToken={data?.csrfToken}
|
||||
url={data?.selfUrl}
|
||||
/>
|
||||
</form>
|
||||
<AlertDialog
|
||||
title="Error"
|
||||
severity="error"
|
||||
open={!!error}
|
||||
actions={<Button onClick={handleClickDialogOK}>OK</Button>}
|
||||
>
|
||||
{error}
|
||||
</AlertDialog>
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -112,6 +112,8 @@ export type UserInfoPageData = BasePageData & {
|
|||
directoryUser?: DirectoryUser;
|
||||
session?: Session;
|
||||
user?: User;
|
||||
webAuthnCreationOptions?: WebAuthnCreationOptions;
|
||||
webAuthnRequestOptions?: WebAuthnRequestOptions;
|
||||
webAuthnUrl?: string;
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue