Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add codemod for clerk fix in v4.2.0 #7676

Merged
merged 6 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
})
}