Redwood v0.37 updates

This commit is contained in:
A. David Thyresson
2021-10-01 16:26:41 -04:00
parent 78db0cc58b
commit f9fd46e710
17 changed files with 2206 additions and 1901 deletions

View File

@@ -3,6 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@redwoodjs/api": "^0.36.0" "@redwoodjs/api": "^0.37.0",
"@redwoodjs/graphql-server": "^0.37.0"
} }
} }

View File

@@ -0,0 +1,23 @@
import gql from 'graphql-tag'
import { createValidatorDirective } from '@redwoodjs/graphql-server'
import { requireAuth as applicationRequireAuth } from 'src/lib/auth'
export const schema = gql`
"""
Use to check whether or not a user is authenticated and is associated
with an optional set of roles.
"""
directive @requireAuth(roles: [String]) on FIELD_DEFINITION
`
const validate = ({ directiveArgs }) => {
const { roles } = directiveArgs
applicationRequireAuth({ roles: roles })
}
const requireAuth = createValidatorDirective(schema, validate)
export default requireAuth

View File

@@ -0,0 +1,18 @@
import { mockRedwoodDirective, getDirectiveName } from '@redwoodjs/testing/api'
import requireAuth from './requireAuth'
describe('requireAuth directive', () => {
it('declares the directive sdl as schema, with the correct name', () => {
expect(requireAuth.schema).toBeTruthy()
expect(getDirectiveName(requireAuth.schema)).toBe('requireAuth')
})
it('requireAuth has stub implementation. Should not throw when current user', () => {
// If you want to set values in context, pass it through e.g.
// mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }})
const mockExecution = mockRedwoodDirective(requireAuth, { context: {} })
expect(mockExecution).not.toThrowError()
})
})

View File

@@ -0,0 +1,16 @@
import gql from 'graphql-tag'
import { createValidatorDirective } from '@redwoodjs/graphql-server'
export const schema = gql`
"""
Use to skip authentication checks and allow public access.
"""
directive @skipAuth on FIELD_DEFINITION
`
const skipAuth = createValidatorDirective(schema, () => {
return
})
export default skipAuth

View File

@@ -0,0 +1,10 @@
import { getDirectiveName } from '@redwoodjs/testing/api'
import skipAuth from './skipAuth'
describe('skipAuth directive', () => {
it('declares the directive sdl as schema, with the correct name', () => {
expect(skipAuth.schema).toBeTruthy()
expect(getDirectiveName(skipAuth.schema)).toBe('skipAuth')
})
})

View File

@@ -1,10 +1,7 @@
import { import { createGraphQLHandler } from '@redwoodjs/graphql-server'
createGraphQLHandler,
makeMergedSchema,
makeServices,
} from '@redwoodjs/api'
import schemas from 'src/graphql/**/*.{js,ts}' import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}' import services from 'src/services/**/*.{js,ts}'
import { getCurrentUser } from 'src/lib/auth' import { getCurrentUser } from 'src/lib/auth'
@@ -14,10 +11,10 @@ import { logger } from 'src/lib/logger'
export const handler = createGraphQLHandler({ export const handler = createGraphQLHandler({
loggerConfig: { logger, options: {} }, loggerConfig: { logger, options: {} },
getCurrentUser, getCurrentUser,
schema: makeMergedSchema({ directives,
schemas, sdls,
services: makeServices({ services }), services,
}),
onException: () => { onException: () => {
// Disconnect from your database with an unhandled exception. // Disconnect from your database with an unhandled exception.
db.$disconnect() db.$disconnect()

View File

@@ -8,7 +8,7 @@ export const schema = gql`
} }
type Query { type Query {
contacts: [Contact!]! contacts: [Contact!]! @skipAuth
} }
input CreateContactInput { input CreateContactInput {
@@ -24,6 +24,6 @@ export const schema = gql`
} }
type Mutation { type Mutation {
createContact(input: CreateContactInput!): Contact createContact(input: CreateContactInput!): Contact @skipAuth
} }
` `

View File

@@ -7,8 +7,8 @@ export const schema = gql`
} }
type Query { type Query {
posts: [Post!]! posts: [Post!]! @skipAuth
post(id: Int!): Post post(id: Int!): Post @skipAuth
} }
input CreatePostInput { input CreatePostInput {
@@ -22,8 +22,8 @@ export const schema = gql`
} }
type Mutation { type Mutation {
createPost(input: CreatePostInput!): Post! createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! deletePost(id: Int!): Post! @requireAuth(roles: ["FOO"])
} }
` `

View File

@@ -1,104 +1,78 @@
// Define what you want `currentUser` to return throughout your app. For example, import {
// to return a real user from your database, you could do something like: AuthenticationError,
// ForbiddenError,
// export const getCurrentUser = async ({ email }) => { parseJWT,
// return await db.user.fineUnique({ where: { email } }) } from '@redwoodjs/graphql-server'
// } import { logger } from 'src/lib/logger'
// /**
// If you want to enforce role-based access ... * getCurrentUser returns the user information together with
// * an optional collection of roles used by requireAuth() to check
// You'll need to set the currentUser's roles attributes to the * if the user is authenticated or has role-based access
// collection of roles as defined by your app. *
// * @param decoded - The decoded access token containing user info and JWT claims like `sub`
// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side * @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type
// to check if the user is assigned a given role or not. * @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
// How you set the currentUser's roles depends on your auth provider and its implementation. *
// * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`: */
// export const getCurrentUser = async (
// { decoded,
// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } }, { _token, _type },
// 'https://example.com/user_metadata': {}, { _event, _context }
// iss: 'https://app.us.auth0.com/', ) => {
// sub: 'email|1234', if (!decoded) {
// aud: [ logger.warn('Missing decoded user')
// 'https://example.com', return null
// 'https://app.us.auth0.com/userinfo' }
// ],
// iat: 1596481520,
// exp: 1596567920,
// azp: '1l0w6JXXXXL880T',
// scope: 'openid profile email'
// }
//
// The parseJWT utility will extract the roles from decoded token.
//
// The app_medata claim may or may not be namespaced based on the auth provider.
// Note: Auth0 requires namespacing custom JWT claims
//
// Some providers, such as with Auth0, will set roles an authorization
// attribute in app_metadata (namespaced or not):
//
// 'app_metadata': { authorization: { roles: ['publisher'] } }
// 'https://example.com/app_metadata': { authorization: { roles: ['publisher'] } }
//
// Other providers may include roles simply within app_metadata:
//
// 'app_metadata': { roles: ['author'] }
// 'https://example.com/app_metadata': { roles: ['author'] }
//
// And yet other may define roles as a custom claim at the root of the decoded token:
//
// roles: ['admin']
//
// The function `getCurrentUser` should return the user information
// together with a collection of roles to check for role assignment:
import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api' const { roles } = parseJWT({ decoded })
if (roles) {
return { ...decoded, roles }
}
return { ...decoded }
}
/** /**
* Use requireAuth in your services to check that a user is logged in, * The user is authenticated if there is a currentUser in the context
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
* *
* @param {string=, string[]=} role - An optional role * @returns {boolean} - If the currentUser is authenticated
*
* @example - No role-based access control.
*
* export const getCurrentUser = async (decoded) => {
* return await db.user.fineUnique({ where: { decoded.email } })
* }
*
* @example - User info is conatined in the decoded token and roles extracted
*
* export const getCurrentUser = async (decoded, { _token, _type }) => {
* return { ...decoded, roles: parseJWT({ decoded }).roles }
* }
*
* @example - User record query by email with namespaced app_metadata roles
*
* export const getCurrentUser = async (decoded) => {
* const currentUser = await db.user.fineUnique({ where: { email: decoded.email } })
*
* return {
* ...currentUser,
* roles: parseJWT({ decoded: decoded, namespace: NAMESPACE }).roles,
* }
* }
*
* @example - User record query by an identity with app_metadata roles
*
* const getCurrentUser = async (decoded) => {
* const currentUser = await db.user.fineUnique({ where: { userIdentity: decoded.sub } })
* return {
* ...currentUser,
* roles: parseJWT({ decoded: decoded }).roles,
* }
* }
*/ */
export const getCurrentUser = async (decoded, { _token, _type }) => { export const isAuthenticated = () => {
return { ...decoded, roles: parseJWT({ decoded }).roles } return !!context.currentUser
}
/**
* Checks if the currentUser is authenticated (and assigned one of the given roles)
*
* @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
*
* @returns {boolean} - Returns true if the currentUser is authenticated (and assigned one of the given roles)
*/
export const hasRole = ({ roles }) => {
if (!isAuthenticated()) {
return false
}
if (
typeof roles !== 'undefined' &&
typeof roles === 'string' &&
context.currentUser.roles?.includes(roles)
) {
return true
}
if (
typeof roles !== 'undefined' &&
Array.isArray(roles) &&
context.currentUser.roles?.some((r) => roles.includes(r))
) {
return true
}
return false
} }
/** /**
@@ -106,39 +80,21 @@ export const getCurrentUser = async (decoded, { _token, _type }) => {
* whether or not they are assigned a role, and optionally raise an * whether or not they are assigned a role, and optionally raise an
* error if they're not. * error if they're not.
* *
* @param {string=} roles - An optional role or list of roles * @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
* @param {string[]=} roles - An optional list of roles
* @example
* *
* // checks if currentUser is authenticated * @returns - If the currentUser is authenticated (and assigned one of the given roles)
* requireAuth()
* *
* @example * @throws {AuthenticationError} - If the currentUser is not authenticated
* @throws {ForbiddenError} If the currentUser is not allowed due to role permissions
* *
* // checks if currentUser is authenticated and assigned one of the given roles * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
* requireAuth({ role: 'admin' })
* requireAuth({ role: ['editor', 'author'] })
* requireAuth({ role: ['publisher'] })
*/ */
export const requireAuth = ({ role } = {}) => { export const requireAuth = ({ roles } = {}) => {
if (!context.currentUser) { if (!isAuthenticated) {
throw new AuthenticationError("You don't have permission to do that.") throw new AuthenticationError("You don't have permission to do that.")
} }
if ( if (!hasRole({ roles })) {
typeof role !== 'undefined' &&
typeof role === 'string' &&
!context.currentUser.roles?.includes(role)
) {
throw new ForbiddenError("You don't have access to do that.")
}
if (
typeof role !== 'undefined' &&
Array.isArray(role) &&
!context.currentUser.roles?.some((r) => role.includes(r))
) {
throw new ForbiddenError("You don't have access to do that.") throw new ForbiddenError("You don't have access to do that.")
} }
} }

View File

@@ -1,5 +1,5 @@
import { UserInputError } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
import { UserInputError } from '@redwoodjs/api'
const validate = (input) => { const validate = (input) => {
if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) { if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) {

View File

@@ -1,9 +1,11 @@
export const standard = defineScenario({ export const standard = defineScenario({
contact: { contact: {
john: { john: {
data: {
name: 'John Doe', name: 'John Doe',
email: 'john.doe@example.com', email: 'john.doe@example.com',
message: 'I love RedwoodJS', message: 'I love RedwoodJS',
}
}, },
}, },
}) })

View File

@@ -1,10 +1,4 @@
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth'
// Used when the environment variable REDWOOD_SECURE_SERVICES=1
export const beforeResolver = (rules) => {
rules.add(requireAuth)
}
export const posts = () => { export const posts = () => {
return db.post.findMany() return db.post.findMany()

View File

@@ -1,6 +1,10 @@
export const standard = defineScenario({ export const standard = defineScenario({
post: { post: {
one: { title: 'String', body: 'String' }, one: {
two: { title: 'String', body: 'String' }, data: { title: 'String', body: 'String' }
},
two: {
data: { title: 'String', body: 'String' }
},
}, },
}) })

View File

@@ -7,7 +7,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/core": "^0.36.0" "@redwoodjs/core": "^0.37.0"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "@redwoodjs/eslint-config" "extends": "@redwoodjs/eslint-config"

View File

@@ -13,10 +13,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@redwoodjs/auth": "^0.36.0", "@redwoodjs/auth": "^0.37.0",
"@redwoodjs/forms": "^0.36.0", "@redwoodjs/forms": "^0.37.0",
"@redwoodjs/router": "^0.36.0", "@redwoodjs/router": "^0.37.0",
"@redwoodjs/web": "^0.36.0", "@redwoodjs/web": "^0.37.0",
"netlify-identity-widget": "^1.9.1", "netlify-identity-widget": "^1.9.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.2", "react": "^17.0.2",

View File

@@ -34,12 +34,11 @@ const ContactPage = () => {
console.log(data) console.log(data)
} }
return ( return <>
<>
<Toaster /> <Toaster />
<Form <Form
onSubmit={onSubmit} onSubmit={onSubmit}
validation={{ mode: 'onBlur' }} config={{ mode: 'onBlur' }}
error={error} error={error}
formMethods={formMethods} formMethods={formMethods}
> >
@@ -103,8 +102,7 @@ const ContactPage = () => {
Save Save
</Submit> </Submit>
</Form> </Form>
</> </>;
)
} }
export default ContactPage export default ContactPage

3640
yarn.lock

File diff suppressed because it is too large Load Diff