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
This commit is contained in:
Caleb Doxsey 2022-02-07 08:47:58 -07:00 committed by GitHub
parent 64d8748251
commit 2824faecbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 13373 additions and 1455 deletions

View file

@ -0,0 +1,30 @@
import Alert, { AlertColor } from "@mui/material/Alert";
import Dialog, { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import React, { FC } from "react";
export type AlertDialogProps = DialogProps & {
title?: React.ReactNode;
severity?: AlertColor;
actions?: React.ReactNode;
};
export const AlertDialog: FC<AlertDialogProps> = ({
title,
severity,
children,
actions,
...props
}) => {
return (
<Dialog transitionDuration={{ exit: 0 }} {...props}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Alert severity={severity || "info"}>{children}</Alert>
</DialogContent>
{actions ? <DialogActions>{actions}</DialogActions> : <></>}
</Dialog>
);
};
export default AlertDialog;

View file

@ -0,0 +1,24 @@
import IDField from "./IDField";
import { DateTime } from "luxon";
import React, { FC } from "react";
const unixSecondTimestampFields = new Set(["exp", "iat", "nbf", "auth_time"]);
const idFields = new Set(["groups", "jti", "oid", "tid", "wids"]);
type ClaimValueProps = {
claimKey: string;
claimValue: unknown;
};
const ClaimValue: FC<ClaimValueProps> = ({ claimKey, claimValue }) => {
if (unixSecondTimestampFields.has(claimKey)) {
return <>{DateTime.fromMillis((claimValue as number) * 1000).toISO()}</>;
}
if (idFields.has(claimKey)) {
return <IDField value={`${claimValue}`} />;
}
return <>{`${claimValue}`}</>;
};
export default ClaimValue;

View file

@ -0,0 +1,57 @@
import { Claims } from "../types";
import ClaimValue from "./ClaimValue";
import Alert from "@mui/material/Alert";
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 React, { FC } from "react";
type ClaimsTableProps = {
claims: Claims;
};
const ClaimsTable: FC<ClaimsTableProps> = ({ claims }) => {
const entries = Object.entries(claims || {});
entries.sort(([a], [b]) => a.localeCompare(b));
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell variant="head">Claims</TableCell>
<TableCell variant="head"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.length > 0 ? (
entries.map(([key, values]) => (
<TableRow key={key}>
<TableCell>{key}</TableCell>
<TableCell>
{values?.map((v, i) => (
<React.Fragment key={`${v}`}>
{i > 0 ? <br /> : <></>}
<ClaimValue claimKey={key} claimValue={v} />
</React.Fragment>
))}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={2} padding="none">
<Alert severity="warning" square={true}>
No Claims Found
</Alert>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default ClaimsTable;

View file

@ -0,0 +1,9 @@
import React, { FC } from "react";
export type CsrfInputProps = {
csrfToken: string;
};
export const CsrfInput: FC<CsrfInputProps> = ({ csrfToken }) => {
return <input type="hidden" name="_pomerium_csrf" value={csrfToken} />;
};
export default CsrfInput;

View file

@ -0,0 +1,72 @@
import IDField from "./IDField";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
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 React, { FC } from "react";
export type DeviceCredentialsTableProps = {
csrfToken: string;
ids: string[];
webAuthnUrl: string;
};
export const DeviceCredentialsTable: FC<DeviceCredentialsTableProps> = ({
csrfToken,
ids,
webAuthnUrl
}) => {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{ids?.length > 0 ? (
ids?.map((id) => (
<TableRow key={id}>
<TableCell>
<IDField value={id} />
</TableCell>
<TableCell>
<form action={webAuthnUrl} method="POST">
<input
type="hidden"
name="_pomerium_csrf"
value={csrfToken}
/>
<input type="hidden" name="action" value="unregister" />
<input
type="hidden"
name="pomerium_device_credential_id"
value={id}
/>
<Button size="small" type="submit" variant="contained">
Delete
</Button>
</form>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={2} padding="none">
<Alert severity="warning" square>
No device credentials found.
</Alert>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default DeviceCredentialsTable;

View file

@ -0,0 +1,19 @@
import HeroSection from "./HeroSection";
import Container from "@mui/material/Container";
import React, { FC } from "react";
import { DeviceEnrolledPageData } from "src/types";
type DeviceEnrolledPageProps = {
data: DeviceEnrolledPageData;
};
const DeviceEnrolledPage: FC<DeviceEnrolledPageProps> = () => {
return (
<Container>
<HeroSection
title="Device Enrolled"
text="Device Successfully Enrolled"
/>
</Container>
);
};
export default DeviceEnrolledPage;

View file

@ -0,0 +1,44 @@
import { ErrorPageData } from "../types";
import SectionFooter from "./SectionFooter";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import Box from "@mui/material/Box";
import Container from "@mui/material/Container";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import React, { FC } from "react";
export type ErrorPageProps = {
data: ErrorPageData;
};
export const ErrorPage: FC<ErrorPageProps> = ({ data }) => {
return (
<Container>
<Paper sx={{ overflow: "hidden" }}>
<Stack>
<Box sx={{ padding: "16px" }}>
<Alert severity="error">
<AlertTitle>
{data?.status || 500}{" "}
{data?.statusText || "Internal Server Error"}
</AlertTitle>
{data?.error || "Internal Server Error"}
</Alert>
</Box>
{data?.requestId ? (
<SectionFooter>
<Typography variant="caption">
If you should have access, contact your administrator with your
request id {data?.requestId}.
</Typography>
</SectionFooter>
) : (
<></>
)}
</Stack>
</Paper>
</Container>
);
};
export default ErrorPage;

View file

@ -0,0 +1,16 @@
import createSvgIcon from "@mui/material/utils/createSvgIcon";
import React from "react";
const ExperimentalIcon = createSvgIcon(
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
d="M20.5 19.34 16.14 12a1 1 0 0 1-.14-.51V2.5a.5.5 0 0 1 .5-.5H18a1 1 0 0 0 0-2H6a1 1 0 0 0 0 2h1.5a.5.5 0 0 1 .5.5v9a1 1 0 0 1-.14.51l-4.32 7.27A3 3 0 0 0 6 24h12a3 3 0 0 0 2.49-4.66ZM8.67 16a.5.5 0 0 1-.43-.25.5.5 0 0 1 0-.5l1.62-2.74A1 1 0 0 0 10 12V2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v2.75a.5.5 0 0 1-.5.5h-1.32a.75.75 0 0 0 0 1.5h1.32a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1.32a.75.75 0 0 0 0 1.5h1.32a.5.5 0 0 1 .5.5V12a1 1 0 0 0 .14.51l1.61 2.74a.47.47 0 0 1 0 .5.52.52 0 0 1-.44.25Zm.82 5.82a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5Zm4.22-3a1 1 0 0 1 0-2 1 1 0 0 1 0 2Zm2.49 3.09a1 1 0 1 1 1-1 1 1 0 0 1-1 .98Z"
style={{
fill: "#5e6a82"
}}
transform="scale(.83333)"
/>
</svg>,
"Experimental"
);
export default ExperimentalIcon;

View file

@ -0,0 +1,39 @@
import Box from "@mui/material/Box";
import Container from "@mui/material/Container";
import Stack from "@mui/material/Stack";
import React, { FC } from "react";
const Footer: FC = () => {
return (
<Container component="footer">
<Stack
direction="row"
spacing={2}
sx={{
fontSize: "0.85rem",
padding: "16px"
}}
>
<Box>
<a href="https://pomerium.com/">Home</a>
</Box>
<Box>
<a href="https://pomerium.com/docs">Docs</a>
</Box>
<Box>
<a href="https://pomerium.com/docs/community/">Support</a>
</Box>
<Box>
<a href="https://github.com/pomerium">GitHub</a>
</Box>
<Box>
<a href="https://twitter.com/pomerium_io">@pomerium_io</a>
</Box>
<Box flexGrow={1} sx={{ textAlign: "right" }}>
© Pomerium, Inc.
</Box>
</Stack>
</Container>
);
};
export default Footer;

View file

@ -0,0 +1,41 @@
import { Group } from "../types";
import IDField from "./IDField";
import Section from "./Section";
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 React, { FC } from "react";
export type GroupDetailsProps = {
groups: Group[];
};
export const GroupDetails: FC<GroupDetailsProps> = ({ groups }) => {
return (
<Section title="Groups">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
{groups?.map((group) => (
<TableRow key={group?.id}>
<TableCell>
<IDField value={group?.id} />
</TableCell>
<TableCell>{group?.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Section>
);
};
export default GroupDetails;

View file

@ -0,0 +1,35 @@
import CsrfInput from "./CsrfInput";
import Logo from "./Logo";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Toolbar from "@mui/material/Toolbar";
import React, { FC } from "react";
type HeaderProps = {
csrfToken: string;
signOutUrl: string;
};
const Header: FC<HeaderProps> = ({ csrfToken, signOutUrl }) => {
return (
<AppBar position="sticky">
<Toolbar>
<a href="/.pomerium">
<Logo />
</a>
<Box flexGrow={1} />
{signOutUrl ? (
<form action={signOutUrl}>
<CsrfInput csrfToken={csrfToken} />
<Button variant="text" color="inherit" type="submit">
Logout
</Button>
</form>
) : (
<></>
)}
</Toolbar>
</AppBar>
);
};
export default Header;

View file

@ -0,0 +1,24 @@
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import React, { FC } from "react";
export type HeroSectionProps = {
icon?: React.ReactNode;
title: React.ReactNode;
text?: React.ReactNode;
};
export const HeroSection: FC<HeroSectionProps> = ({ icon, title, text }) => {
return (
<Paper sx={{ padding: "16px" }}>
<Stack direction="row" spacing={2}>
{icon}
<Stack>
<Typography variant="h1">{title}</Typography>
{text ? <Typography>{text}</Typography> : <></>}
</Stack>
</Stack>
</Paper>
);
};
export default HeroSection;

View file

@ -0,0 +1,19 @@
import Box from "@mui/material/Box";
import React, { FC } from "react";
export type IDFieldProps = {
value: string;
};
export const IDField: FC<IDFieldProps> = ({ value }) => {
return (
<Box component="span" sx={{ fontFamily: '"DM Mono"', fontSize: "12px" }}>
{value?.split("")?.map((str, idx) => (
<React.Fragment key={idx}>
{str}
<wbr />
</React.Fragment>
))}
</Box>
);
};
export default IDField;

View file

@ -0,0 +1,59 @@
import createSvgIcon from "@mui/material/utils/createSvgIcon";
import React from "react";
const JwtIcon = createSvgIcon(
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 15 15"
>
<path
style={{
stroke: "none",
fillRule: "evenodd",
fill: "#fff",
fillOpacity: 1
}}
d="M8.586 4.04 8.57.042H6.34l.015 3.996L7.47 5.57ZM6.355 10.887v4.008h2.23v-4.008L7.47 9.355Zm0 0"
/>
<path
style={{
stroke: "none",
fillRule: "evenodd",
fill: "#00f2e6",
fillOpacity: 1
}}
d="m8.586 10.887 2.344 3.238 1.797-1.309-2.344-3.238L8.586 9ZM6.355 4.04 3.996.8 2.2 2.11l2.344 3.238 1.812.578Zm0 0"
/>
<path
style={{
stroke: "none",
fillRule: "evenodd",
fill: "#00b9f1",
fillOpacity: 1
}}
d="m4.543 5.348-3.8-1.235-.684 2.11 3.804 1.246 1.797-.594ZM9.266 8.05l1.117 1.528 3.8 1.235.684-2.11-3.805-1.234Zm0 0"
/>
<path
style={{
stroke: "none",
fillRule: "evenodd",
fill: "#d63aff",
fillOpacity: 1
}}
d="m11.063 7.469 3.804-1.246-.683-2.11-3.801 1.235-1.117 1.527ZM3.863 7.469.06 8.703l.683 2.11 3.801-1.235L5.66 8.051Zm0 0"
/>
<path
style={{
stroke: "none",
fillRule: "evenodd",
fill: "#fb015b",
fillOpacity: 1
}}
d="m4.543 9.578-2.344 3.238 1.797 1.309 2.36-3.238V9ZM10.383 5.348l2.344-3.239L10.93.801 8.586 4.039v1.887Zm0 0"
/>
</svg>,
"JWT"
);
export default JwtIcon;

View file

@ -0,0 +1,9 @@
import LogoURL from "../static/logo_white.svg";
import React from "react";
import type { FC } from "react";
const Logo: FC = () => {
return <img alt="Logo" src={LogoURL} height="30px" />;
};
export default Logo;

View file

@ -0,0 +1,10 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
import React, { FC } from "react";
import { User } from "react-feather";
export const PersonIcon: FC<SvgIconProps> = (props) => (
<SvgIcon {...props}>
<User />
</SvgIcon>
);
export default PersonIcon;

View file

@ -0,0 +1,41 @@
import SectionFooter from "./SectionFooter";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import React, { FC } from "react";
export type SectionProps = React.PropsWithChildren<{
title: React.ReactNode;
icon?: React.ReactNode;
footer?: React.ReactNode;
}>;
export const Section: FC<SectionProps> = ({
title,
icon,
children,
footer
}) => {
return (
<Paper sx={{ overflow: "hidden" }}>
<Stack>
<Toolbar>
<Typography variant="h4" flexGrow={1}>
{title}
</Typography>
{icon ? <Box>{icon}</Box> : <></>}
</Toolbar>
<Box sx={{ padding: 3, paddingTop: 0 }}>{children}</Box>
{footer ? (
<SectionFooter>
<Typography variant="caption">{footer}</Typography>
</SectionFooter>
) : (
<></>
)}
</Stack>
</Paper>
);
};
export default Section;

View file

@ -0,0 +1,9 @@
import Box from "@mui/material/Box";
import styled from "@mui/material/styles/styled";
import React, { FC } from "react";
export const SectionFooter = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.grey[100],
padding: theme.spacing(3)
}));
export default SectionFooter;

View file

@ -0,0 +1,47 @@
import { Session } from "../types";
import ClaimsTable from "./ClaimsTable";
import IDField from "./IDField";
import Section from "./Section";
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 TableRow from "@mui/material/TableRow";
import React, { FC } from "react";
export type SessionDetailsProps = {
session: Session;
};
export const SessionDetails: FC<SessionDetailsProps> = ({ session }) => {
return (
<Section title="Session Details">
<Stack spacing={3}>
<TableContainer>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>
<IDField value={session?.id} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>User ID</TableCell>
<TableCell>
<IDField value={session?.userId} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>Expires At</TableCell>
<TableCell>{session?.expiresAt || ""}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<ClaimsTable claims={session?.claims} />
</Stack>
</Section>
);
};
export default SessionDetails;

View file

@ -0,0 +1,81 @@
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";
export type SessionDeviceCredentialsProps = {
csrfToken: string;
user: User;
session: Session;
webAuthnUrl: string;
};
export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
csrfToken,
user,
session,
webAuthnUrl
}) => {
const currentSessionDeviceCredentialIds = [];
const otherDeviceCredentialIds = [];
user?.deviceCredentialIds?.forEach((id) => {
if (session?.deviceCredentials?.find((cred) => cred?.id === id)) {
currentSessionDeviceCredentialIds.push(id);
} else {
otherDeviceCredentialIds.push(id);
}
});
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
csrfToken={csrfToken}
ids={otherDeviceCredentialIds}
webAuthnUrl={webAuthnUrl}
/>
</Box>
</>
) : (
<></>
)}
<SectionFooter>
<Typography variant="caption">
Register device with <a href={webAuthnUrl}>WebAuthn</a>.
</Typography>
</SectionFooter>
</Stack>
</Paper>
);
};
export default SessionDeviceCredentials;

View file

@ -0,0 +1,17 @@
import { User } from "../types";
import ClaimsTable from "./ClaimsTable";
import JwtIcon from "./JwtIcon";
import Section from "./Section";
import React, { FC } from "react";
export type UserClaimsProps = {
user: User;
};
export const UserClaims: FC<UserClaimsProps> = ({ user }) => {
return (
<Section title="User Claims" icon={<JwtIcon />}>
<ClaimsTable claims={user?.claims} />
</Section>
);
};
export default UserClaims;

View file

@ -0,0 +1,55 @@
import GroupDetails from "./GroupDetails";
import HeroSection from "./HeroSection";
import PersonIcon from "./PersonIcon";
import SessionDetails from "./SessionDetails";
import SessionDeviceCredentials from "./SessionDeviceCredentials";
import UserClaims from "./UserClaims";
import MuiAvatar from "@mui/material/Avatar";
import Container from "@mui/material/Container";
import Stack from "@mui/material/Stack";
import styled from "@mui/material/styles/styled";
import React, { FC } from "react";
import { UserInfoPageData } from "src/types";
const Avatar = styled(MuiAvatar)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
height: 48,
width: 48
}));
type UserInfoPageProps = {
data: UserInfoPageData;
};
const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
const name = data?.user?.claims?.given_name?.[0] || data?.user?.name;
return (
<Container>
<Stack spacing={3}>
<HeroSection
icon={
<Avatar>
<PersonIcon />
</Avatar>
}
title={<>Hi {name}!</>}
text={
<>
Welcome to the user info endpoint. Here you can view your current
session details, and authorization context.
</>
}
/>
<SessionDetails session={data?.session} />
<UserClaims user={data?.user} />
<GroupDetails groups={data?.directoryGroups} />
<SessionDeviceCredentials
csrfToken={data?.csrfToken}
session={data?.session}
user={data?.user}
webAuthnUrl={data?.webAuthnUrl}
/>
</Stack>
</Container>
);
};
export default UserInfoPage;

View file

@ -0,0 +1,210 @@
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 Typography from "@mui/material/Typography";
import React, { FC, useRef, useState } from "react";
import {
WebAuthnCreationOptions,
WebAuthnRegistrationPageData,
WebAuthnRequestOptions
} from "src/types";
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;
}
type WebAuthnRegistrationPageProps = {
data: WebAuthnRegistrationPageData;
};
const WebAuthnRegistrationPage: FC<WebAuthnRegistrationPageProps> = ({
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 (
<Container>
<Stack spacing={3}>
<HeroSection
title={
<>
WebAuthn Registration <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}
/>
</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}
/>
</form>
</Stack>
<AlertDialog
title="Error"
severity="error"
open={!!error}
actions={<Button onClick={handleClickDialogOK}>OK</Button>}
>
{error}
</AlertDialog>
</Container>
);
};
export default WebAuthnRegistrationPage;