Skip to content

Commit

Permalink
feat: added endpoint to "elevate" permissions using webauthn
Browse files Browse the repository at this point in the history
  • Loading branch information
dbarrosop committed Nov 30, 2023
1 parent 19316ad commit 54c7bc4
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 6 deletions.
47 changes: 47 additions & 0 deletions src/routes/elevate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Router } from 'express';

import { asyncWrapper as aw } from '@/utils';
import { bodyValidator } from '@/validation';

import {
elevateVerifyWebauthnHandler,
elevateVerifyWebauthnSchema,
elevateWebauthnHandler,
elevateWebauthnSchema,
} from './webauthn';

const router = Router();

// TODO add @return payload on success
/**
* POST /elevate/webauthn
* @summary Elevate access for an already signed in user using FIDO2 Webauthn
* @param {ElevateWebauthnSchema} request.body.required
* @return {InvalidRequestError} 400 - The payload is invalid - application/json
* @return {DisabledEndpointError} 404 - The feature is not activated - application/json
* @tags Authentication
*/
router.post(
'/elevate/webauthn',
bodyValidator(elevateWebauthnSchema),
aw(elevateWebauthnHandler)

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
);

/**
* POST /elevate/webauthn/verify
* @summary Verfiy FIDO2 Webauthn authentication using public-key cryptography
* @param {ElevateVerifyWebauthnSchema} request.body.required
* @return {SessionPayload} 200 - Signed in successfully - application/json
* @return {InvalidRequestError} 400 - The payload is invalid - application/json
* @return {UnauthorizedError} 401 - Invalid email or password, or user is not verified - application/json
* @return {DisabledEndpointError} 404 - The feature is not activated - application/json
* @tags Authentication
*/
router.post(
'/elevate/webauthn/verify',
bodyValidator(elevateVerifyWebauthnSchema),
aw(elevateVerifyWebauthnHandler)

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
);

const elevateRouter = router;
export { elevateRouter };
206 changes: 206 additions & 0 deletions src/routes/elevate/webauthn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { sendError } from '@/errors';
import {
ENV,
getSignInResponse,
getUser,
getUserByEmail,
gqlSdk,
getWebAuthnRelyingParty,
getCurrentChallenge,
} from '@/utils';
import { RequestHandler } from 'express';

import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import {
AuthenticationCredentialJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/typescript-types';
import { email, Joi } from '@/validation';
import { SignInResponse } from '@/types';

export type ElevateWebAuthnRequestBody = { email: string };
export type ElevateWebAuthnResponseBody = PublicKeyCredentialRequestOptionsJSON;

export const elevateWebauthnSchema = Joi.object<ElevateWebAuthnRequestBody>({
email: email.required(),
}).meta({ className: 'ElevateWebauthnSchema' });

export const elevateWebauthnHandler: RequestHandler<
{},
ElevateWebAuthnResponseBody,
ElevateWebAuthnRequestBody
> = async (req, res) => {
if (!ENV.AUTH_WEBAUTHN_ENABLED) {
return sendError(res, 'disabled-endpoint');
}

const { userId } = req.auth as RequestAuth;

const userRequestAuth = await getUser({ userId });

if (!userRequestAuth) {
return sendError(res, 'user-not-found');
}

const { body } = req;
const { email } = body;

const user = await getUserByEmail(email);

// ? Do we know to let anyone know if the user doesn't exist?
if (!user) {
return sendError(res, 'user-not-found');
}

if (user.id !== userRequestAuth.id) {
return sendError(res, 'bad-request');
}

if (user.disabled) {
return sendError(res, 'disabled-user');
}

if (ENV.AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED && !user.emailVerified) {
return sendError(res, 'unverified-user');
}

const { authUserSecurityKeys } = await gqlSdk.getUserSecurityKeys({
id: user.id,
});

const options = generateAuthenticationOptions({
rpID: getWebAuthnRelyingParty(),
userVerification: 'preferred',
timeout: ENV.AUTH_WEBAUTHN_ATTESTATION_TIMEOUT,
allowCredentials: authUserSecurityKeys.map((securityKey) => ({
id: Buffer.from(securityKey.credentialId, 'base64url'),
type: 'public-key',
})),
});

await gqlSdk.updateUserChallenge({
userId: user.id,
challenge: options.challenge,
});

return res.send(options);
};

export type ElevnateVerifyWebAuthnRequestBody = {
credential: AuthenticationCredentialJSON;
email: string;
};

export type ElevateVerifyWebAuthnResponseBody = SignInResponse;

export const elevateVerifyWebauthnSchema =
Joi.object<ElevnateVerifyWebAuthnRequestBody>({
email: email.required(),
credential: Joi.object().required(),
}).meta({ className: 'ElevateVerifyWebauthnSchema' });

export const elevateVerifyWebauthnHandler: RequestHandler<
{},
ElevateVerifyWebAuthnResponseBody,
ElevnateVerifyWebAuthnRequestBody
> = async (req, res) => {
if (!ENV.AUTH_WEBAUTHN_ENABLED) {
return sendError(res, 'disabled-endpoint');
}

const { userId } = req.auth as RequestAuth;

const userRequestAuth = await getUser({ userId });

if (!userRequestAuth) {
return sendError(res, 'user-not-found');
}

const { credential, email } = req.body;

const user = await getUserByEmail(email);

if (!user) {
return sendError(res, 'user-not-found');
}

if (user.id !== userRequestAuth.id) {
return sendError(res, 'bad-request');
}

if (user.disabled) {
return sendError(res, 'disabled-user');
}

if (ENV.AUTH_EMAIL_SIGNIN_EMAIL_VERIFIED_REQUIRED && !user.emailVerified) {
return sendError(res, 'unverified-user');
}

const expectedChallenge = await getCurrentChallenge(user.id);

const { authUserSecurityKeys } = await gqlSdk.getUserSecurityKeys({
id: user.id,
});
const securityKey = authUserSecurityKeys?.find(
({ credentialId }) => credentialId === credential.id
);

if (!securityKey) {
return sendError(res, 'invalid-request');
}

const securityKeyDevice = {
counter: securityKey.counter,
credentialID: Buffer.from(securityKey.credentialId, 'base64url'),
credentialPublicKey: Buffer.from(
securityKey.credentialPublicKey.substr(2),
'hex'
),
};

let verification;
try {
verification = verifyAuthenticationResponse({
credential,
expectedChallenge,
expectedOrigin: ENV.AUTH_WEBAUTHN_RP_ORIGINS,
expectedRPID: getWebAuthnRelyingParty(),
authenticator: securityKeyDevice,
requireUserVerification: true,
});
} catch (e) {
const error = e as Error;
return sendError(res, 'invalid-webauthn-security-key', {
customMessage: error.message,
});
}

const { verified } = verification;

if (!verified) {
return sendError(res, 'invalid-webauthn-verification');
}

const { authenticationInfo } = verification;
const { newCounter } = authenticationInfo;

if (securityKey.counter != newCounter) {
await gqlSdk.updateUserSecurityKey({
id: securityKey.id,
counter: newCounter,
});
}

const signInResponse = await getSignInResponse({
userId: user.id,
checkMFA: false,
extraClaims: {
[`x-nhost-auth-elevated`]: true,
},
});

return res.send(signInResponse);
};
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mfaRouter } from './mfa';
import { oauthProviders } from './oauth';
import { patRouter } from './pat';
import { signInRouter } from './signin';
import { elevateRouter } from './elevate';
import { signOutRouter } from './signout';
import { signUpRouter } from './signup';
import { tokenRouter } from './token';
Expand All @@ -28,6 +29,7 @@ router.get('/version', (_req, res) =>
router.use(signUpRouter);
router.use(signInRouter);
router.use(signOutRouter);
router.use(elevateRouter);
router.use(userRouter);
router.use(mfaRouter);
router.use(tokenRouter);
Expand Down
9 changes: 6 additions & 3 deletions src/utils/jwt/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export const sign = async ({
* @param jwt if true, add a 'x-hasura-' prefix to the property names, and stringifies the values (required by Hasura)
*/
const generateHasuraClaims = async (
user: UserFieldsFragment
user: UserFieldsFragment,
extraClaims?: { [key: string]: ClaimValueType },
): Promise<{
[key: string]: ClaimValueType;
}> => {
Expand All @@ -47,6 +48,7 @@ const generateHasuraClaims = async (
const customClaims = await generateCustomClaims(user.id);
return {
...customClaims,
...extraClaims,
[`x-hasura-allowed-roles`]: allowedRoles,
[`x-hasura-default-role`]: user.defaultRole,
[`x-hasura-user-id`]: user.id,
Expand All @@ -57,15 +59,16 @@ const generateHasuraClaims = async (
* Create JWT ENV.
*/
export const createHasuraAccessToken = async (
user: UserFieldsFragment
user: UserFieldsFragment,
extraClaims?: { [key: string]: ClaimValueType },
): Promise<string> => {
const namespace =
ENV.HASURA_GRAPHQL_JWT_SECRET.claims_namespace ||
'https://hasura.io/jwt/claims';

return sign({
payload: {
[namespace]: await generateHasuraClaims(user),
[namespace]: await generateHasuraClaims(user, extraClaims),
},
user,
});
Expand Down
10 changes: 7 additions & 3 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Session, SignInResponse } from '@/types';
import { ClaimValueType, Session, SignInResponse } from '@/types';
import { v4 as uuidv4 } from 'uuid';
import { UserFieldsFragment } from './__generated__/graphql-request';
import { ENV } from './env';
Expand All @@ -17,9 +17,11 @@ import { getUser } from './user';
export const getNewOrUpdateCurrentSession = async ({
user,
currentRefreshToken,
extraClaims,
}: {
user: UserFieldsFragment;
currentRefreshToken?: string;
extraClaims?: { [key: string]: ClaimValueType },
}): Promise<Session> => {
// update user's last seen
gqlSdk.updateUser({
Expand All @@ -29,7 +31,7 @@ export const getNewOrUpdateCurrentSession = async ({
},
});
const sessionUser = await getUser({ userId: user.id });
const accessToken = await createHasuraAccessToken(user);
const accessToken = await createHasuraAccessToken(user, extraClaims);
const { refreshToken, id: refreshTokenId } =
(currentRefreshToken &&
(await updateRefreshTokenExpiry(currentRefreshToken))) ||
Expand All @@ -46,9 +48,11 @@ export const getNewOrUpdateCurrentSession = async ({
export const getSignInResponse = async ({
userId,
checkMFA,
extraClaims,
}: {
userId: string;
checkMFA: boolean;
extraClaims?: { [key: string]: ClaimValueType },
}): Promise<SignInResponse> => {
const { user } = await gqlSdk.user({
id: userId,
Expand All @@ -74,7 +78,7 @@ export const getSignInResponse = async ({
},
};
}
const session = await getNewOrUpdateCurrentSession({ user });
const session = await getNewOrUpdateCurrentSession({ user, extraClaims });
return {
session,
mfa: null,
Expand Down

0 comments on commit 54c7bc4

Please sign in to comment.