From 54c7bc41a8991e2b93327088463517ccdc2cf536 Mon Sep 17 00:00:00 2001 From: David Barroso Date: Thu, 30 Nov 2023 10:15:42 +0100 Subject: [PATCH] feat: added endpoint to "elevate" permissions using webauthn --- src/routes/elevate/index.ts | 47 ++++++++ src/routes/elevate/webauthn.ts | 206 +++++++++++++++++++++++++++++++++ src/routes/index.ts | 2 + src/utils/jwt/generate.ts | 9 +- src/utils/session.ts | 10 +- 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 src/routes/elevate/index.ts create mode 100644 src/routes/elevate/webauthn.ts diff --git a/src/routes/elevate/index.ts b/src/routes/elevate/index.ts new file mode 100644 index 000000000..be0a837da --- /dev/null +++ b/src/routes/elevate/index.ts @@ -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) +); + +/** + * 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) +); + +const elevateRouter = router; +export { elevateRouter }; diff --git a/src/routes/elevate/webauthn.ts b/src/routes/elevate/webauthn.ts new file mode 100644 index 000000000..0568025e5 --- /dev/null +++ b/src/routes/elevate/webauthn.ts @@ -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({ + 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({ + 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); +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 3fd43310e..a4602f761 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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'; @@ -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); diff --git a/src/utils/jwt/generate.ts b/src/utils/jwt/generate.ts index 2f05a327e..5338c4b77 100644 --- a/src/utils/jwt/generate.ts +++ b/src/utils/jwt/generate.ts @@ -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; }> => { @@ -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, @@ -57,7 +59,8 @@ const generateHasuraClaims = async ( * Create JWT ENV. */ export const createHasuraAccessToken = async ( - user: UserFieldsFragment + user: UserFieldsFragment, + extraClaims?: { [key: string]: ClaimValueType }, ): Promise => { const namespace = ENV.HASURA_GRAPHQL_JWT_SECRET.claims_namespace || @@ -65,7 +68,7 @@ export const createHasuraAccessToken = async ( return sign({ payload: { - [namespace]: await generateHasuraClaims(user), + [namespace]: await generateHasuraClaims(user, extraClaims), }, user, }); diff --git a/src/utils/session.ts b/src/utils/session.ts index 241520dd9..0701c6130 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -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'; @@ -17,9 +17,11 @@ import { getUser } from './user'; export const getNewOrUpdateCurrentSession = async ({ user, currentRefreshToken, + extraClaims, }: { user: UserFieldsFragment; currentRefreshToken?: string; + extraClaims?: { [key: string]: ClaimValueType }, }): Promise => { // update user's last seen gqlSdk.updateUser({ @@ -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))) || @@ -46,9 +48,11 @@ export const getNewOrUpdateCurrentSession = async ({ export const getSignInResponse = async ({ userId, checkMFA, + extraClaims, }: { userId: string; checkMFA: boolean; + extraClaims?: { [key: string]: ClaimValueType }, }): Promise => { const { user } = await gqlSdk.user({ id: userId, @@ -74,7 +78,7 @@ export const getSignInResponse = async ({ }, }; } - const session = await getNewOrUpdateCurrentSession({ user }); + const session = await getNewOrUpdateCurrentSession({ user, extraClaims }); return { session, mfa: null,