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:
Caleb Doxsey 2022-02-23 10:08:24 -07:00 committed by GitHub
parent 38c7089642
commit 35f697e491
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 423 additions and 288 deletions

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/pkg/cryptutil" "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. // Authenticate contains data required to run the authenticate service.
type Authenticate struct { type Authenticate struct {
cfg *authenticateConfig cfg *authenticateConfig
options *config.AtomicOptions options *config.AtomicOptions
state *atomicAuthenticateState state *atomicAuthenticateState
webauthn *webauthn.Handler
} }
// New validates and creates a new authenticate service from a set of Options. // 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(), options: config.NewAtomicOptions(),
state: newAtomicAuthenticateState(newAuthenticateState()), state: newAtomicAuthenticateState(newAuthenticateState()),
} }
a.webauthn = webauthn.New(a.getWebauthnState)
state, err := newAuthenticateStateFromConfig(cfg) state, err := newAuthenticateStateFromConfig(cfg)
if err != nil { if err != nil {

View file

@ -17,6 +17,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/csrf" "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"
@ -96,7 +97,7 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
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(httputil.HandlerFunc(a.SignOut)) 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 { sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
handlers.DeviceEnrolled().ServeHTTP(w, r) handlers.DeviceEnrolled().ServeHTTP(w, r)
return nil return nil
@ -561,6 +562,8 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
groups = append(groups, pbDirectoryGroup) groups = append(groups, pbDirectoryGroup)
} }
creationOptions, requestOptions, _ := a.webauthn.GetOptions(ctx)
handlers.UserInfo(handlers.UserInfoData{ handlers.UserInfo(handlers.UserInfoData{
CSRFToken: csrf.Token(r), CSRFToken: csrf.Token(r),
DirectoryGroups: groups, DirectoryGroups: groups,
@ -568,7 +571,10 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
IsImpersonated: isImpersonated, IsImpersonated: isImpersonated,
Session: pbSession, Session: pbSession,
User: pbUser, 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) }).ServeHTTP(w, r)
return nil return nil
} }

View file

@ -11,6 +11,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/ui" "github.com/pomerium/pomerium/ui"
"github.com/pomerium/webauthn"
) )
// UserInfoData is the data for the UserInfo page. // UserInfoData is the data for the UserInfo page.
@ -21,7 +22,10 @@ type UserInfoData struct {
IsImpersonated bool IsImpersonated bool
Session *session.Session Session *session.Session
User *user.User User *user.User
WebAuthnURL string
WebAuthnCreationOptions *webauthn.PublicKeyCredentialCreationOptions
WebAuthnRequestOptions *webauthn.PublicKeyCredentialRequestOptions
WebAuthnURL string
} }
// ToJSON converts the data into a JSON map. // 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 { if bs, err := protojson.Marshal(data.User); err == nil {
m["user"] = json.RawMessage(bs) m["user"] = json.RawMessage(bs)
} }
m["webAuthnCreationOptions"] = data.WebAuthnCreationOptions
m["webAuthnRequestOptions"] = data.WebAuthnRequestOptions
m["webAuthnUrl"] = data.WebAuthnURL m["webAuthnUrl"] = data.WebAuthnURL
return m return m
} }

View file

@ -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. // ServeHTTP serves the HTTP handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
httputil.HandlerFunc(h.handle).ServeHTTP(w, r) 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 { func (h *Handler) handle(w http.ResponseWriter, r *http.Request) error {
s, err := h.getState(r.Context()) s, err := h.getState(r.Context())
if err != nil { if err != nil {
@ -351,24 +390,11 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat
return errMissingDeviceType return errMissingDeviceType
} }
// get the user information creationOptions, requestOptions, err := h.getOptions(ctx, state, deviceTypeParam)
u, err := user.Get(ctx, state.Client, state.Session.GetUserId())
if err != nil { if err != nil {
return err 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{}{ return ui.ServePage(w, r, "WebAuthnRegistration", map[string]interface{}{
"creationOptions": creationOptions, "creationOptions": creationOptions,
"requestOptions": requestOptions, "requestOptions": requestOptions,

View file

@ -24,6 +24,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding"
"github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/jws"
@ -748,6 +749,7 @@ func TestAuthenticate_userInfo(t *testing.T) {
directoryClient: new(mockDirectoryServiceClient), directoryClient: new(mockDirectoryServiceClient),
}), }),
} }
a.webauthn = webauthn.New(a.getWebauthnState)
r := httptest.NewRequest(tt.method, tt.url.String(), nil) r := httptest.NewRequest(tt.method, tt.url.String(), nil)
state, err := tt.sessionStore.LoadSession(r) state, err := tt.sessionStore.LoadSession(r)
if err != nil { if err != nil {

View file

@ -49,5 +49,12 @@
"prettier": "^2.4.1", "prettier": "^2.4.1",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",
"typescript": "^4.4.4" "typescript": "^4.4.4"
},
"prettier": {
"importOrder": [
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
} }
} }

View file

@ -1,20 +1,21 @@
import React from 'react';
import {User} from "react-feather";
import MuiAvatar from "@mui/material/Avatar"; import MuiAvatar from "@mui/material/Avatar";
import { isArray } from "lodash";
import React from "react";
import { User } from "react-feather";
type AvatarProps = { type AvatarProps = {
name: string; name: string;
url?: string; url?: string;
} };
export const Avatar = ({url, name}:AvatarProps): JSX.Element => { export const Avatar = ({ url, name }: AvatarProps): JSX.Element => {
if (url === 'https://graph.microsoft.com/v1.0/me/photo/$value') { if (isArray(url)) {
url = url?.pop();
}
if (url === "https://graph.microsoft.com/v1.0/me/photo/$value") {
url = null; url = null;
} }
return url ? ( return url ? <MuiAvatar alt={name} src={url} /> : <User />;
<MuiAvatar alt={name} src={url} />
) : (
<User />
);
}; };

View file

@ -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 Box from "@mui/material/Box";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack"; 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 Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import React, { FC } from "react"; 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 = { export type SessionDeviceCredentialsProps = {
csrfToken: string; csrfToken: string;
user: User; user: User;
session: Session; session: Session;
webAuthnCreationOptions: WebAuthnCreationOptions;
webAuthnRequestOptions: WebAuthnRequestOptions;
webAuthnUrl: string; webAuthnUrl: string;
}; };
export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({ export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
csrfToken, csrfToken,
user, user,
session, session,
webAuthnUrl webAuthnCreationOptions,
webAuthnRequestOptions,
webAuthnUrl,
}) => { }) => {
const currentSessionDeviceCredentialIds = []; const currentSessionDeviceCredentialIds = [];
const otherDeviceCredentialIds = []; const otherDeviceCredentialIds = [];
@ -37,45 +42,62 @@ export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
}); });
return ( return (
<Paper sx={{ overflow: "hidden" }}> <>
<Stack> <Paper sx={{ overflow: "hidden" }}>
<Toolbar> <Stack>
<Typography variant="h4" flexGrow={1}> <Toolbar>
Current Session Device Credentials <Typography variant="h4" flexGrow={1}>
</Typography> Current Session Device Credentials
</Toolbar> </Typography>
<Box sx={{ padding: 3, paddingTop: 0 }}>
<DeviceCredentialsTable <Stack direction="row" justifyContent="center" spacing={1}>
csrfToken={csrfToken} <WebAuthnRegisterButton
ids={currentSessionDeviceCredentialIds} creationOptions={webAuthnCreationOptions}
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} csrfToken={csrfToken}
ids={otherDeviceCredentialIds} url={webAuthnUrl}
webAuthnUrl={webAuthnUrl} size="small"
/> />
</Box> <WebAuthnAuthenticateButton
</> requestOptions={webAuthnRequestOptions}
) : ( csrfToken={csrfToken}
<></> url={webAuthnUrl}
)} size="small"
<SectionFooter> />
<Typography variant="caption"> </Stack>
Register device with <a href={webAuthnUrl}>WebAuthn</a>. </Toolbar>
</Typography> <Box sx={{ padding: 3, paddingTop: 0 }}>
</SectionFooter> <DeviceCredentialsTable
</Stack> csrfToken={csrfToken}
</Paper> 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; export default SessionDeviceCredentials;

View file

@ -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 GroupDetails from "./GroupDetails";
import SessionDetails from "./SessionDetails"; import SessionDetails from "./SessionDetails";
import SessionDeviceCredentials from "./SessionDeviceCredentials"; 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 { ToolbarOffset } from "./ToolbarOffset";
import {UserSidebarContent} from "./UserSidebarContent"; import { UserSidebarContent } from "./UserSidebarContent";
import {SubpageContext} from "../context/Subpage";
import Stack from "@mui/material/Stack";
type UserInfoPageProps = { type UserInfoPageProps = {
data: UserInfoPageData; data: UserInfoPageData;
}; };
const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => { const UserInfoPage: FC<UserInfoPageProps> = ({ 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 {subpage} = useContext(SubpageContext); const { subpage } = useContext(SubpageContext);
return ( return (
<Container maxWidth={false}> <Container maxWidth={false}>
@ -31,37 +31,37 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
open open
PaperProps={{ PaperProps={{
sx: { sx: {
backgroundColor: 'neutral.900', backgroundColor: "neutral.900",
width: 256, width: 256,
height: '100vh', height: "100vh",
} },
}} }}
variant="persistent" variant="persistent"
> >
<ToolbarOffset /> <ToolbarOffset />
<UserSidebarContent close={null}/> <UserSidebarContent close={null} />
<ToolbarOffset /> <ToolbarOffset />
</Drawer> </Drawer>
)} )}
<Stack <Stack
spacing={3} spacing={3}
sx={{ sx={{
marginLeft: mdUp ? '256px' : '0px', marginLeft: mdUp ? "256px" : "0px",
}}> }}
>
{subpage === "User" && <SessionDetails session={data?.session} />}
{subpage === 'User' && ( {subpage === "Groups Info" && (
<SessionDetails session={data?.session} />
)}
{subpage === 'Groups Info' && (
<GroupDetails groups={data?.directoryGroups} /> <GroupDetails groups={data?.directoryGroups} />
)} )}
{subpage === 'Devices Info' && ( {subpage === "Devices Info" && (
<SessionDeviceCredentials <SessionDeviceCredentials
csrfToken={data?.csrfToken} csrfToken={data?.csrfToken}
session={data?.session} session={data?.session}
user={data?.user} user={data?.user}
webAuthnCreationOptions={data?.webAuthnCreationOptions}
webAuthnRequestOptions={data?.webAuthnRequestOptions}
webAuthnUrl={data?.webAuthnUrl} webAuthnUrl={data?.webAuthnUrl}
/> />
)} )}

View 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;

View 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;

View 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;

View file

@ -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 Stack from "@mui/material/Stack";
import React, { FC, useRef, useState } from "react"; import React, { FC } from "react";
import { import { WebAuthnRegistrationPageData } from "src/types";
WebAuthnCreationOptions,
WebAuthnRegistrationPageData, import ExperimentalIcon from "./ExperimentalIcon";
WebAuthnRequestOptions
} from "src/types";
import JwtIcon from "./JwtIcon";
import ClaimsTable from "./ClaimsTable";
import Section from "./Section"; import Section from "./Section";
import WebAuthnAuthenticateButton from "./WebAuthnAuthenticateButton";
type CredentialForAuthenticate = { import WebAuthnRegisterButton from "./WebAuthnRegisterButton";
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 = { type WebAuthnRegistrationPageProps = {
data: WebAuthnRegistrationPageData; data: WebAuthnRegistrationPageData;
}; };
const WebAuthnRegistrationPage: FC<WebAuthnRegistrationPageProps> = ({ 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 ( return (
<Section title="WebAuthn Registration" icon={<ExperimentalIcon />}> <Section title="WebAuthn Registration" icon={<ExperimentalIcon />}>
<Paper sx={{ padding: "16px" }}> <Stack direction="row" justifyContent="center" spacing={1}>
<Stack direction="row" justifyContent="center" spacing={3}> <WebAuthnRegisterButton
<Button onClick={handleClickRegister} variant="contained"> creationOptions={data?.creationOptions}
Register New Device csrfToken={data?.csrfToken}
</Button> url={data?.selfUrl}
<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> <WebAuthnAuthenticateButton
<form ref={registerFormRef} method="POST" action={data?.selfUrl}> requestOptions={data?.requestOptions}
<input type="hidden" name="_pomerium_csrf" value={data?.csrfToken} /> csrfToken={data?.csrfToken}
<input type="hidden" name="action" value="register" /> url={data?.selfUrl}
<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>
</Section> </Section>
); );
}; };

View file

@ -112,6 +112,8 @@ export type UserInfoPageData = BasePageData & {
directoryUser?: DirectoryUser; directoryUser?: DirectoryUser;
session?: Session; session?: Session;
user?: User; user?: User;
webAuthnCreationOptions?: WebAuthnCreationOptions;
webAuthnRequestOptions?: WebAuthnRequestOptions;
webAuthnUrl?: string; webAuthnUrl?: string;
}; };