From 315503e098757c7c8e8efc425df690cdbd0907a9 Mon Sep 17 00:00:00 2001 From: Dominic Saadi Date: Wed, 22 Feb 2023 17:57:47 -0800 Subject: [PATCH] chore: add codemod for clerk fix in v4.2.0 (#7676) * chore: add codemod for clerk fix in v4.2.0 * fix transform file * rename dir for consistency * fix test paths * fix path again --- .../__testfixtures__/default.input.js | 122 +++++++++++++++++ .../__testfixtures__/default.output.js | 129 ++++++++++++++++++ .../updateClerkGetCurrentUser.test.ts | 5 + .../updateClerkGetCurrentUser.ts | 74 ++++++++++ .../updateClerkGetCurrentUser.yargs.ts | 24 ++++ 5 files changed, 354 insertions(+) create mode 100644 packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.input.js create mode 100644 packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.output.js create mode 100644 packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__tests__/updateClerkGetCurrentUser.test.ts create mode 100644 packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.ts create mode 100644 packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.yargs.ts diff --git a/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.input.js b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.input.js new file mode 100644 index 000000000000..488fe1323898 --- /dev/null +++ b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.input.js @@ -0,0 +1,122 @@ +import { parseJWT } from '@redwoodjs/api' +import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' + +import { logger } from 'src/lib/logger' + +/** + * getCurrentUser returns the user information together with + * an optional collection of roles used by requireAuth() to check + * if the user is authenticated or has role-based access + * + * @param decoded - The decoded access token containing user info and JWT claims like `sub`. Note could be null. + * @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type + * @param { APIGatewayEvent event, Context context } - An object which contains information from the invoker + * such as headers and cookies, and the context information about the invocation such as IP Address + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const getCurrentUser = async ( + decoded, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + { token, type }, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + { event, context } +) => { + if (!decoded) { + logger.warn('Missing decoded user') + return null + } + + const { roles } = parseJWT({ decoded }) + + if (roles) { + return { ...decoded, roles } + } + + return { ...decoded } +} + +/** + * The user is authenticated if there is a currentUser in the context + * + * @returns {boolean} - If the currentUser is authenticated + */ +export const isAuthenticated = () => { + return !!context.currentUser +} + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ +type AllowedRoles = string | string[] | undefined + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ + +/** + * Checks if the currentUser is authenticated (and assigned one of the given roles) + * + * @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles + * + * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles, + * or when no roles are provided to check against. Otherwise returns false. + */ +export const hasRole = (roles: AllowedRoles): boolean => { + if (!isAuthenticated()) { + return false + } + + const currentUserRoles = context.currentUser?.roles + + if (typeof roles === 'string') { + if (typeof currentUserRoles === 'string') { + // roles to check is a string, currentUser.roles is a string + return currentUserRoles === roles + } else if (Array.isArray(currentUserRoles)) { + // roles to check is a string, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => roles === allowedRole) + } + } + + if (Array.isArray(roles)) { + if (Array.isArray(currentUserRoles)) { + // roles to check is an array, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => + roles.includes(allowedRole) + ) + } else if (typeof currentUserRoles === 'string') { + // roles to check is an array, currentUser.roles is a string + return roles.some((allowedRole) => currentUserRoles === allowedRole) + } + } + + // roles not found + return false +} + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param roles: {@link AllowedRoles} - When checking role membership, these roles grant access. + * + * @returns - If the currentUser is authenticated (and assigned one of the given roles) + * + * @throws {@link AuthenticationError} - If the currentUser is not authenticated + * @throws {@link ForbiddenError} If the currentUser is not allowed due to role permissions + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => { + if (!isAuthenticated()) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if (roles && !hasRole(roles)) { + throw new ForbiddenError("You don't have access to do that.") + } +} diff --git a/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.output.js b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.output.js new file mode 100644 index 000000000000..4dcab72e8b60 --- /dev/null +++ b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__testfixtures__/default.output.js @@ -0,0 +1,129 @@ +import { parseJWT } from '@redwoodjs/api' +import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' + +import { logger } from 'src/lib/logger' + +/** + * getCurrentUser returns the user information together with + * an optional collection of roles used by requireAuth() to check + * if the user is authenticated or has role-based access + * + * @param decoded - The decoded access token containing user info and JWT claims like `sub`. Note could be null. + * @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type + * @param { APIGatewayEvent event, Context context } - An object which contains information from the invoker + * such as headers and cookies, and the context information about the invocation such as IP Address + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const getCurrentUser = async ( + decoded, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + { token, type }, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + { event, context } +) => { + if (!decoded) { + logger.warn('Missing decoded user') + return null + } + + const { roles } = parseJWT({ decoded }) + + const { privateMetadata, ...userWithoutPrivateMetadata } = decoded + + if (roles) { + return { + roles, + ...userWithoutPrivateMetadata, + } + } + + return { + ...userWithoutPrivateMetadata + } +} + +/** + * The user is authenticated if there is a currentUser in the context + * + * @returns {boolean} - If the currentUser is authenticated + */ +export const isAuthenticated = () => { + return !!context.currentUser +} + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ +type AllowedRoles = string | string[] | undefined + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ + +/** + * Checks if the currentUser is authenticated (and assigned one of the given roles) + * + * @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles + * + * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles, + * or when no roles are provided to check against. Otherwise returns false. + */ +export const hasRole = (roles: AllowedRoles): boolean => { + if (!isAuthenticated()) { + return false + } + + const currentUserRoles = context.currentUser?.roles + + if (typeof roles === 'string') { + if (typeof currentUserRoles === 'string') { + // roles to check is a string, currentUser.roles is a string + return currentUserRoles === roles + } else if (Array.isArray(currentUserRoles)) { + // roles to check is a string, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => roles === allowedRole) + } + } + + if (Array.isArray(roles)) { + if (Array.isArray(currentUserRoles)) { + // roles to check is an array, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => + roles.includes(allowedRole) + ) + } else if (typeof currentUserRoles === 'string') { + // roles to check is an array, currentUser.roles is a string + return roles.some((allowedRole) => currentUserRoles === allowedRole) + } + } + + // roles not found + return false +} + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param roles: {@link AllowedRoles} - When checking role membership, these roles grant access. + * + * @returns - If the currentUser is authenticated (and assigned one of the given roles) + * + * @throws {@link AuthenticationError} - If the currentUser is not authenticated + * @throws {@link ForbiddenError} If the currentUser is not allowed due to role permissions + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => { + if (!isAuthenticated()) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if (roles && !hasRole(roles)) { + throw new ForbiddenError("You don't have access to do that.") + } +} diff --git a/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__tests__/updateClerkGetCurrentUser.test.ts b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__tests__/updateClerkGetCurrentUser.test.ts new file mode 100644 index 000000000000..42b0deeae5ab --- /dev/null +++ b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/__tests__/updateClerkGetCurrentUser.test.ts @@ -0,0 +1,5 @@ +describe('clerk', () => { + it('updates the getCurrentUser function', async () => { + await matchTransformSnapshot('updateClerkGetCurrentUser', 'default') + }) +}) diff --git a/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.ts b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.ts new file mode 100644 index 000000000000..b90d69aac46e --- /dev/null +++ b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.ts @@ -0,0 +1,74 @@ +import type { FileInfo, API, ObjectExpression } from 'jscodeshift' + +const newReturn = `userWithoutPrivateMetadata` +const destructureStatement = `const { privateMetadata, ...${newReturn} } = decoded` + +export default function transform(file: FileInfo, api: API) { + const j = api.jscodeshift + const ast = j(file.source) + + // Insert `const { privateMetadata, ...userWithoutPrivateMetadata } = decoded` after `const { roles } = parseJWT({ decoded })` + // + // So, before... + // + // ```ts + // const { roles } = parseJWT({ decoded }) + // ``` + // + // and after... + // + // ```ts + // const { roles } = parseJWT({ decoded }) + // + // const { privateMetadata, ...userWithoutPrivateMetadata } = decoded + // ``` + const parseJWTStatement = ast.find(j.VariableDeclaration, { + declarations: [ + { + type: 'VariableDeclarator', + init: { + type: 'CallExpression', + callee: { + name: 'parseJWT', + }, + }, + }, + ], + }) + + parseJWTStatement.insertAfter(destructureStatement) + + // Swap `decoded` with `userWithoutPrivateMetadata` in the two return statements + ast + .find(j.ReturnStatement, { + argument: { + type: 'ObjectExpression', + properties: [ + { + type: 'SpreadElement', + argument: { + name: 'decoded', + }, + }, + ], + }, + }) + .replaceWith((path) => { + const properties = ( + path.value.argument as ObjectExpression + ).properties.filter( + (property) => + property.type !== 'SpreadElement' && property.name !== 'decoded' + ) + + properties.push(j.spreadElement(j.identifier(newReturn))) + + return j.returnStatement(j.objectExpression(properties)) + }) + + return ast.toSource({ + trailingComma: true, + quote: 'single', + lineTerminator: '\n', + }) +} diff --git a/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.yargs.ts b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.yargs.ts new file mode 100644 index 000000000000..a4ca472a298f --- /dev/null +++ b/packages/codemods/src/codemods/v4.2.x/updateClerkGetCurrentUser/updateClerkGetCurrentUser.yargs.ts @@ -0,0 +1,24 @@ +import path from 'path' + +import task, { TaskInnerAPI } from 'tasuku' + +import getRWPaths from '../../../lib/getRWPaths' +import isTSProject from '../../../lib/isTSProject' +import runTransform from '../../../lib/runTransform' + +export const command = 'update-clerk-get-current-user' +export const description = + '(v4.1.x->v4.2.x) For Clerk users; updates the getCurrentUser function' + +export const handler = () => { + task('Update getCurrentUser', async ({ setOutput }: TaskInnerAPI) => { + const authFile = isTSProject ? 'auth.ts' : 'auth.js' + + await runTransform({ + transformPath: path.join(__dirname, 'updateClerkGetCurrentUser.js'), + targetPaths: [path.join(getRWPaths().api.base, 'src', 'lib', authFile)], + }) + + setOutput('All done! Run `yarn rw lint --fix` to prettify your code') + }) +}