Skip to content

Commit

Permalink
feat: Add authorization directive (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
ktutnik committed Apr 6, 2023
1 parent efba1a1 commit a46d4a2
Show file tree
Hide file tree
Showing 18 changed files with 1,271 additions and 143 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
{
"type": "node",
"request": "launch",
"name": "jest /core-validator",
"cwd": "${workspaceFolder}/packages/core-validator",
"name": "jest /auth",
"cwd": "${workspaceFolder}/packages/auth",
"program": "node_modules/.bin/jest",
"args": [
"${fileBasenameNoExtension}",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"test": "yarn workspaces run test",
"prebuild": "yarn clean",
"build": "yarn workspaces run build",
"build": "nx run-many --target=build",
"clean": "yarn workspaces run clean",
"publish:canary": "yarn build && npx lerna publish --canary",
"publish:stable": "yarn build && yarn test && npx lerna publish --conventional-commits --create-release github",
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lib
node_modules
coverage
10 changes: 10 additions & 0 deletions packages/auth/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
test
src
coverage
.npmignore
tsconfig.json
jest.config.debug.ts
jest.config.ts
package-lock.json
yarn-error.log
yarn.lock
13 changes: 13 additions & 0 deletions packages/auth/jest.config.debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Config} from "jest"

const config:Config = {
clearMocks: true,
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
moduleNameMapper: {
"@graphql-directive/(.*)": "<rootDir>/../$1/src"
}
}

export default config
16 changes: 16 additions & 0 deletions packages/auth/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Config} from "jest"

const config:Config = {
clearMocks: true,
collectCoverage: true,
coverageDirectory: "coverage",
collectCoverageFrom: ["src/*"],
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
moduleNameMapper: {
"@graphql-directive/(.*)": "<rootDir>/../$1/src"
}
}

export default config
49 changes: 49 additions & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@graphql-directive/auth",
"version": "1.0.0",
"description": "GraphQL authorization directive",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"test": "jest",
"build": "tsc",
"clean": "trash lib coverage"
},
"keywords": [
"graphql",
"auth",
"authorize",
"authorization",
"directive",
"graphql-directive"
],
"author": "I Ketut Sandiarsa",
"license": "MIT",
"peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
},
"dependencies": {
"@graphql-directive/core": "^0.0.0"
},
"funding": [
"https://github.com/sponsors/ktutnik"
],
"devDependencies": {
"@graphql-tools/schema": "^9.0.16",
"@types/jest": "^29.4.0",
"graphql": "^16.6.0",
"jest": "^29.4.3",
"trash-cli": "^5.0.0",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1"
},
"bugs": {
"url": "https://github.com/ktutnik/graphql-directive/issues"
},
"repository": {
"url": "https://github.com/ktutnik/graphql-directive"
},
"publishConfig": {
"access": "public"
}
}
60 changes: 60 additions & 0 deletions packages/auth/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# GraphQL Authorization Directive

[![Node.js CI](https://github.com/ktutnik/graphql-directive/actions/workflows/test.yml/badge.svg)](https://github.com/ktutnik/graphql-directive/actions/workflows/test.yml)
[![Coverage Status](https://coveralls.io/repos/github/ktutnik/graphql-directive/badge.svg?branch=master)](https://coveralls.io/github/ktutnik/graphql-directive?branch=master)

A TypeScript/JavaScript library that provides an easy way to add authorization logic to your Node.js GraphQL API using directives.

## Motivation
GraphQL Authorization Directive aims to simplify the process of adding authorization to your GraphQL API. By using directives, you can easily apply authorization logic to your schema without having to manually implement complex middleware functions.

## Example Usage
```javascript
import auth from "@graphql-directive/auth"
import { ApolloServer } from "@apollo/server"
import { startStandaloneServer } from "@apollo/server/standalone"
import { makeExecutableSchema } from "@graphql-tools/schema"

const typeDefs = `
type User {
name: String!
email: String!
role: String @authorize(policy: "admin")
}
input UserInput {
name: String!
email: String!
role: String @authorize(policy: "admin")
}
type Query {
getUsers: [User]!
}
type Mutation {
addUser(user:UserInput!): Boolean!
modifyUser(user: UserInput!): Boolean!
}
`

const transform = auth.createTransformer({
policies: {
admin: ({ contextValue }) => contextValue.user.role === "admin"
isLogin: ({ contextValue }) => !!contextValue.user
}
})

const schema = transform(makeExecutableSchema({
typeDefs: [auth.typeDefs, typeDefs],
resolver: {
Query: { getUsers: () => ([]) },
Mutation: {
addUser: () => true,
modifyUser: ()=> true
}
}
}))

const server = new ApolloServer({ schema })
startStandaloneServer(server, { context: async ({ req, res }) => ({}) }).then(x => console.log(x.url))
```

## API Documentation
80 changes: 80 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { InvocationContext, InvokerHook, createDirectiveInvokerPipeline, createDirectiveInvoker } from "@graphql-directive/core"
import { MapperKind, mapSchema, getDirective } from "@graphql-tools/utils"
import { GraphQLError, GraphQLSchema, OperationTypeNode, defaultFieldResolver, isNonNullType } from "graphql"
import { Path } from "graphql/jsutils/Path"

const typeDefs = /* GraphQL */ `
directive @authorize(
policy: String!
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION | FIELD_DEFINITION
`

type AuthorizeContext = Omit<InvocationContext, "directives"> & { directiveArgs: any }

type PolicyFunction = (ctx: AuthorizeContext) => boolean | Promise<boolean>

type AuthorizeOptions = {
policies: Record<string, PolicyFunction>
queryResolution: "ThrowError" | "Filter"
}

const transform = (schema: GraphQLSchema, options: AuthorizeOptions): GraphQLSchema => {
const directiveName = "authorize"
const getPath = (path: Path | undefined): string => !!path ? [getPath(path.prev), path.key].filter(Boolean).join(".") : ""

const hook: InvokerHook<string> = async (value, { directives, ...ctx }) => {
const policies = (directives[0].policy as string).split(",").map(x => x.trim())
const result = await Promise.all(policies.map(name => {
const policy = options.policies[name]
if (!policy) throw Error(`Unknown policy "${name}" on ${ctx.path}`)
return policy({ ...ctx, directiveArgs: policy })
}))
return result.some(v => v) ? [] : [ctx.path]
}

const pipeline = createDirectiveInvokerPipeline(directiveName, hook)

return mapSchema(schema, {
[MapperKind.INPUT_OBJECT_FIELD]: pipeline.addInputField,
[MapperKind.OBJECT_FIELD]: (config, name, type, schema) => {
const isNotNull = isNonNullType(config.type)
const directives = getDirective(schema, config, directiveName) ?? []
if (directives.length > 0 && options.queryResolution === "Filter" && type !== "Mutation" && isNotNull)
throw new Error(`Nullable data type is required on ${type}.${name} when Filter resolution enable`)
const invoker = createDirectiveInvoker(name, hook, directives, undefined)
const { resolve = defaultFieldResolver } = config
return {
...config,
resolve: async (parent, args, context, info) => {
const operation = info.operation.operation
const path = getPath(info.path)
const [inputError, fieldError] = await Promise.all([
pipeline.invoke(args, path, config.args!, [parent, args, context, info]),
invoker.invoke(args, path, [parent, args, context, info])
]);
if (inputError.length > 0) {
throw new GraphQLError("AUTHORIZATION_ERROR", { extensions: { paths: inputError } })
}
if (fieldError.length === 0) {
return resolve(parent, args, context, info)
}
if (operation === OperationTypeNode.MUTATION) {
throw new GraphQLError("AUTHORIZATION_ERROR", { extensions: { paths: fieldError } })
}
if (options.queryResolution === "ThrowError") {
throw new GraphQLError("AUTHORIZATION_ERROR", { extensions: { paths: fieldError } })
}
return undefined
}
}
}
})
}

const createTransformer = (options: Partial<AuthorizeOptions>) => {
return (schema: GraphQLSchema) => transform(schema, { ... { policies: {}, queryResolution: "Filter" }, ...options })
}

export default {
typeDefs, createTransformer
}
Loading

0 comments on commit a46d4a2

Please sign in to comment.