-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add authorization directive (#33)
- Loading branch information
Showing
18 changed files
with
1,271 additions
and
143 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
lib | ||
node_modules | ||
coverage |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.