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

View File

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

View File

@@ -7,8 +7,8 @@ export const schema = gql`
}
type Query {
posts: [Post!]!
post(id: Int!): Post
posts: [Post!]! @skipAuth
post(id: Int!): Post @skipAuth
}
input CreatePostInput {
@@ -22,8 +22,8 @@ export const schema = gql`
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: Int!, input: UpdatePostInput!): Post!
deletePost(id: Int!): Post!
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
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,
// to return a real user from your database, you could do something like:
//
// export const getCurrentUser = async ({ email }) => {
// return await db.user.fineUnique({ where: { email } })
// }
//
// If you want to enforce role-based access ...
//
// You'll need to set the currentUser's roles attributes to the
// collection of roles as defined by your app.
//
// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side
// to check if the user is assigned a given role or not.
//
// How you set the currentUser's roles depends on your auth provider and its implementation.
//
// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`:
//
// {
// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } },
// 'https://example.com/user_metadata': {},
// iss: 'https://app.us.auth0.com/',
// sub: 'email|1234',
// aud: [
// 'https://example.com',
// '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/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`
* @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,
{ _token, _type },
{ _event, _context }
) => {
if (!decoded) {
logger.warn('Missing decoded user')
return null
}
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,
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
* The user is authenticated if there is a currentUser in the context
*
* @param {string=, string[]=} role - An optional role
*
* @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,
* }
* }
* @returns {boolean} - If the currentUser is authenticated
*/
export const getCurrentUser = async (decoded, { _token, _type }) => {
return { ...decoded, roles: parseJWT({ decoded }).roles }
export const isAuthenticated = () => {
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
* error if they're not.
*
* @param {string=} roles - An optional role or list of roles
* @param {string[]=} roles - An optional list of roles
* @example
* @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
*
* // checks if currentUser is authenticated
* requireAuth()
* @returns - If the currentUser is authenticated (and assigned one of the given roles)
*
* @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
* requireAuth({ role: 'admin' })
* requireAuth({ role: ['editor', 'author'] })
* requireAuth({ role: ['publisher'] })
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*/
export const requireAuth = ({ role } = {}) => {
if (!context.currentUser) {
export const requireAuth = ({ roles } = {}) => {
if (!isAuthenticated) {
throw new AuthenticationError("You don't have permission to do that.")
}
if (
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))
) {
if (!hasRole({ roles })) {
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 { UserInputError } from '@redwoodjs/api'
const validate = (input) => {
if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) {

View File

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

View File

@@ -1,10 +1,4 @@
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 = () => {
return db.post.findMany()

View File

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