Skip to content

Commit

Permalink
chore: add codemod for clerk fix in v4.2.0 (#7676)
Browse files Browse the repository at this point in the history
* chore: add codemod for clerk fix in v4.2.0

* fix transform file

* rename dir for consistency

* fix test paths

* fix path again
  • Loading branch information
jtoar authored Feb 23, 2023
1 parent f20f803 commit 315503e
Show file tree
Hide file tree
Showing 5 changed files with 354 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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.")
}
}
Original file line number Diff line number Diff line change
@@ -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.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
describe('clerk', () => {
it('updates the getCurrentUser function', async () => {
await matchTransformSnapshot('updateClerkGetCurrentUser', 'default')
})
})
Original file line number Diff line number Diff line change
@@ -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',
})
}
Original file line number Diff line number Diff line change
@@ -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')
})
}

0 comments on commit 315503e

Please sign in to comment.