mercurius-auth
Advanced tools
Comparing version
@@ -9,6 +9,80 @@ # mercurius-auth | ||
* **applyPolicy** `(authDirectiveAST: DirectiveNode, parent: object, args: Record<string, any>, context: MercuriusContext, info: GraphQLResolveInfo) => Promise<boolean | Error>` - the policy promise to run when an auth directive protected field is selected by the query. This must return `true` in order to pass the check and allow access to the protected field. | ||
* **authDirective** `string` - the name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be `auth`. | ||
* **applyPolicy** `(policy: any, parent: object, args: Record<string, any>, context: MercuriusContext, info: GraphQLResolveInfo) => Promise<boolean | Error>` - the policy promise to run when an auth protected field is selected by the query. This must return `true` in order to pass the check and allow access to the protected field. | ||
* **authContext** `(context: MercuriusContext) => object | Promise<object>` (optional) - assigns the returned data to `MercuriusContext.auth` for use in the `applyPolicy` function. This runs within a [`preExecution`](https://mercurius.dev/#/docs/hooks?id=preexecution) Mercurius GraphQL request hook. | ||
* **mode** `'directive' | 'external'` (optional, default: `'directive'`) - the mode of operation for the plugin. Depending on the mode of operation selected, this has the following options: | ||
### `directive` (default) mode | ||
* **authDirective** `string` - the name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be `auth`. | ||
### `external` mode | ||
* **policy** `MercuriusAuthPolicy` (optional) - the auth policy definition. The field definition is passed as the first argument when `applyPolicy` is called for the associated field. | ||
#### Parameter: `MercuriusAuthPolicy` | ||
Extends: `Record<string, MercuriusAuthTypePolicy>` | ||
Each key within the `MercuriusAuthPolicy` type corresponds with the GraphQL type name. For example, if we wanted to protect an object type: | ||
```graphql | ||
type Message { | ||
... | ||
} | ||
``` | ||
We would use the key: `Message`: | ||
```js | ||
{ | ||
Message: { ... } | ||
} | ||
``` | ||
#### Parameter: `MercuriusAuthTypePolicy` | ||
Extends: `Record<string, any>` | ||
- **__typePolicy** `any` (optional) - The policy definition for the type. | ||
Each key within the `MercuriusAuthTypePolicy` type corresponds with the GraphQL field name on a type. For example, if we wanted to protect type field `message`: | ||
```graphql | ||
type Message { | ||
title: String | ||
message: String | ||
} | ||
``` | ||
We would use the key: `message`: | ||
```js | ||
{ | ||
Message: { | ||
message: { requires: 'user' } | ||
} | ||
} | ||
``` | ||
If we want to protect the entire type, we would use `__typePolicy`: | ||
```js | ||
{ | ||
Message: { | ||
__typePolicy: { requires: 'user' } | ||
} | ||
} | ||
``` | ||
This also works alongside specific field policies on the type: | ||
```js | ||
{ | ||
Message: { | ||
__typePolicy: { requires: 'user', } | ||
message: { requires: 'admin' } | ||
} | ||
} | ||
``` | ||
## Registration | ||
@@ -18,2 +92,4 @@ | ||
### Directive (default) mode | ||
```js | ||
@@ -70,1 +146,77 @@ 'use strict' | ||
``` | ||
### External Policy mode | ||
```js | ||
'use strict' | ||
const Fastify = require('fastify') | ||
const mercurius = require('mercurius') | ||
const mercuriusAuth = require('..') | ||
const app = Fastify() | ||
const schema = ` | ||
type Message { | ||
title: String | ||
message: String | ||
adminMessage: String | ||
} | ||
type Query { | ||
messages: [Message] | ||
message(title: String): Message | ||
} | ||
` | ||
const messages = [ | ||
{ | ||
title: 'one', | ||
message: 'one', | ||
adminMessage: 'admin message one' | ||
}, | ||
{ | ||
title: 'two', | ||
message: 'two', | ||
adminMessage: 'admin message two' | ||
} | ||
] | ||
const resolvers = { | ||
Query: { | ||
messages: async (parent, args, context, info) => { | ||
return messages | ||
}, | ||
message: async (parent, args, context, info) => { | ||
return messages.find(message => message.title === args.title) | ||
} | ||
} | ||
} | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
app.register(mercuriusAuth, { | ||
authContext (context) { | ||
const permissions = context.reply.request.headers['x-user'] || '' | ||
return { permissions } | ||
}, | ||
async applyPolicy (policy, parent, args, context, info) { | ||
return context.auth.permissions.includes(policy.requires) | ||
}, | ||
mode: 'external', | ||
policy: { | ||
Message: { | ||
__typePolicy: { requires: 'user' }, | ||
adminMessage: { requires: 'admin' } | ||
}, | ||
Query: { | ||
messages: { requires: 'user' } | ||
} | ||
} | ||
}) | ||
app.listen(3000) | ||
``` |
# Apply Policy | ||
When called, the `applyPolicy` Promise provides the matching authDirective as a parameter in addition to exactly the same parameters that a `graphql-js` resolver will use. This allows us to tap into the auth directive definition and make policy decisions based on the associated type information. | ||
- [Directive mode](#directive-mode) | ||
- [External Policy mode](#external-policy-mode) | ||
When called, the `applyPolicy` Promise provides the matching policy as a parameter in addition to exactly the same parameters that a `graphql-js` resolver will use. This allows us to tap into the policy definition and make policy decisions based on the associated type information. | ||
The value of the policy parameter is dependent on the mode of operation: | ||
## Directive mode | ||
Here, the first parameter is the matching auth directive AST. This can be used to read the directive policy definition and apply accordingly. | ||
```js | ||
async applyPolicy (authDirectiveAST, parent, args, context, info) { ... } | ||
``` | ||
**Example usage**: | ||
```js | ||
'use strict' | ||
@@ -60,1 +75,100 @@ | ||
``` | ||
## External Policy mode | ||
Here, the first parameter is the matching policy for the field. This can be used to read the directive policy definition and apply accordingly. | ||
If we have the following policy: | ||
```js | ||
const policy = { | ||
Message: { | ||
__typePolicy: { requires: 'user' }, | ||
adminMessage: { requires: 'admin' } | ||
}, | ||
Query: { | ||
messages: { requires: 'user' } | ||
} | ||
} | ||
``` | ||
Then when `applyPolicy` for `messages` is called, the value of `policy` argument is `{ requires: 'user' }`. | ||
**Example usage**: | ||
```js | ||
'use strict' | ||
const Fastify = require('fastify') | ||
const mercurius = require('mercurius') | ||
const mercuriusAuth = require('..') | ||
const app = Fastify() | ||
const schema = ` | ||
type Message { | ||
title: String | ||
message: String | ||
adminMessage: String | ||
} | ||
type Query { | ||
messages: [Message] | ||
message(title: String): Message | ||
} | ||
` | ||
const messages = [ | ||
{ | ||
title: 'one', | ||
message: 'one', | ||
adminMessage: 'admin message one' | ||
}, | ||
{ | ||
title: 'two', | ||
message: 'two', | ||
adminMessage: 'admin message two' | ||
} | ||
] | ||
const resolvers = { | ||
Query: { | ||
messages: async (parent, args, context, info) => { | ||
return messages | ||
}, | ||
message: async (parent, args, context, info) => { | ||
return messages.find(message => message.title === args.title) | ||
} | ||
} | ||
} | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
app.register(mercuriusAuth, { | ||
authContext (context) { | ||
const permissions = context.reply.request.headers['x-user'] || '' | ||
return { permissions } | ||
}, | ||
async applyPolicy (policy, parent, args, context, info) { | ||
// For field `Message.adminMessage` | ||
// policy: { requires: 'admin' } | ||
// context.auth.permissions: ['user', 'admin'] - the permissions associated with the user | ||
return context.auth.permissions.includes(policy.requires) | ||
}, | ||
mode: 'external', | ||
policy: { | ||
Message: { | ||
__typePolicy: { requires: 'user' }, | ||
adminMessage: { requires: 'admin' } | ||
}, | ||
Query: { | ||
messages: { requires: 'user' } | ||
} | ||
} | ||
}) | ||
app.listen(3000) | ||
``` |
@@ -167,1 +167,18 @@ # Errors | ||
``` | ||
### Status Code | ||
Mercurius defaults all errors with the HTTP 500 status code. You can customize this property by using the built-in `ErrorWithProps` custom error provided by the underlining Mercurius plug-in | ||
```js | ||
... | ||
async applyPolicy (authDirectiveAST, parent, args, context, info) { | ||
if (context.auth.identity !== 'admin') { | ||
const err = new mercurius.ErrorWithProps(`custom auth error on ${info.fieldName}`); | ||
err.statusCode = 200; | ||
return err // or throw err | ||
} | ||
return true | ||
} | ||
... | ||
``` |
@@ -6,7 +6,27 @@ import { FastifyPluginAsync } from 'fastify' | ||
/** | ||
* the policy promise to run when an auth directive protected field is selected by the query. | ||
* The auth policy definitions used to protect the type and fields within the GraphQL Object type. | ||
*/ | ||
export interface MercuriusAuthTypePolicy extends Record<string, any> { | ||
/** | ||
* Define the auth policy for the associated GraphQL type. | ||
*/ | ||
__typePolicy?: any; | ||
} | ||
/** | ||
* The auth policy definitions used to protect the types and fields within a GraphQL schema. | ||
*/ | ||
export type MercuriusAuthPolicy = Record<string, MercuriusAuthTypePolicy> | ||
/** | ||
* The mode of operation for Mercurius Auth (default: 'directive'). | ||
*/ | ||
export type MercuriusAuthMode = 'directive' | 'external' | ||
/** | ||
* The policy promise to run when an auth directive protected field is selected by the query. | ||
* This must return true in order to pass the check and allow access to the protected field. | ||
*/ | ||
export type ApplyPolicyHandler<TParent=any, TArgs=any, TContext=MercuriusContext> = ( | ||
authDirectiveAST: DirectiveNode, | ||
export type ApplyPolicyHandler<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=any> = ( | ||
policy: TPolicy, | ||
parent: TParent, | ||
@@ -18,17 +38,45 @@ args: TArgs, | ||
/** assigns the returned data to `MercuriusContext.auth` for use in the `applyPolicy` function. */ | ||
/** Assigns the returned data to `MercuriusContext.auth` for use in the `applyPolicy` function. */ | ||
export type AuthContextHandler<TContext=MercuriusContext> = (context: TContext) => object | Promise<object>; | ||
export interface MercuriusAuthOptions<TParent=any, TArgs=any, TContext=MercuriusContext> { | ||
/** The name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be auth. */ | ||
authDirective: string; | ||
export interface MercuriusAuthBaseOptions<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=any> { | ||
/** | ||
* the policy promise to run when an auth directive protected field is selected by the query. | ||
* The policy promise to run when a protected field is selected by the query. | ||
* This must return true in order to pass the check and allow access to the protected field. | ||
*/ | ||
applyPolicy: ApplyPolicyHandler<TParent, TArgs, TContext> | ||
/** assigns the returned data to `MercuriusContext.auth` for use in the `applyPolicy` function. */ | ||
applyPolicy: ApplyPolicyHandler<TParent, TArgs, TContext, TPolicy> | ||
/** | ||
* Assigns the returned data to `MercuriusContext.auth` for use in the `applyPolicy` function. | ||
*/ | ||
authContext?: AuthContextHandler<TContext>; | ||
/** | ||
* The mode of operation for Mercurius Auth (default: `'directive'`). | ||
*/ | ||
mode?: MercuriusAuthMode; | ||
} | ||
export interface MercuriusAuthDirectiveOptions<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=DirectiveNode> extends MercuriusAuthBaseOptions<TParent, TArgs, TContext, TPolicy> { | ||
/** | ||
* The Directive mode of operation for Mercurius Auth. | ||
*/ | ||
mode?: 'directive'; | ||
/** | ||
* The name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be auth. | ||
*/ | ||
authDirective: string; | ||
} | ||
export interface MercuriusAuthExternalPolicyOptions<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=any> extends MercuriusAuthBaseOptions<TParent, TArgs, TContext, TPolicy> { | ||
/** | ||
* The External Policy mode of operation for Mercurius Auth. | ||
*/ | ||
mode: 'external'; | ||
/** | ||
* The auth policy definitions used to protect the types and fields within a GraphQL schema. | ||
*/ | ||
policy?: MercuriusAuthPolicy; | ||
} | ||
export type MercuriusAuthOptions<TParent=any, TArgs=any, TContext=MercuriusContext, TPolicy=any> = MercuriusAuthDirectiveOptions<TParent, TArgs, TContext, TPolicy> | MercuriusAuthExternalPolicyOptions<TParent, TArgs, TContext, TPolicy> | ||
/** Mercurius Auth is a plugin for `mercurius` that adds configurable Authentication and Authorization support. */ | ||
@@ -35,0 +83,0 @@ declare const mercuriusAuth: FastifyPluginAsync<MercuriusAuthOptions> |
10
index.js
@@ -14,8 +14,12 @@ 'use strict' | ||
// Override resolvers with auth handlers | ||
auth.registerAuthHandlers(app.graphql.schema) | ||
// Get auth policy | ||
const authSchema = auth.getPolicy(app.graphql.schema) | ||
// Wrap resolvers with auth handlers | ||
auth.registerAuthHandlers(app.graphql.schema, authSchema) | ||
// Add hook to regenerate the resolvers when the schema is refreshed | ||
app.graphql.addHook('onGatewayReplaceSchema', async (instance, schema) => { | ||
auth.registerAuthHandlers(schema) | ||
const authSchema = auth.getPolicy(schema) | ||
auth.registerAuthHandlers(schema, authSchema) | ||
}) | ||
@@ -22,0 +26,0 @@ |
119
lib/auth.js
'use strict' | ||
const { kApplyPolicy, kAuthContext, kAuthDirective, kGetAuthDirectiveAST, kMakeProtectedResolver } = require('./symbols') | ||
const { | ||
kApplyPolicy, | ||
kAuthContext, | ||
kAuthDirective, | ||
kGetAuthDirectiveAST, | ||
kMakeProtectedResolver, | ||
kMode, | ||
kPolicy, | ||
kBuildPolicy, | ||
kSetTypePolicy, | ||
kSetFieldPolicy, | ||
kWrapFieldResolver | ||
} = require('./symbols') | ||
const { MER_AUTH_ERR_FAILED_POLICY_CHECK } = require('./errors') | ||
class Auth { | ||
constructor ({ applyPolicy, authContext, authDirective }) { | ||
constructor ({ applyPolicy, authContext, authDirective, mode, policy }) { | ||
this[kApplyPolicy] = applyPolicy | ||
this[kAuthContext] = authContext | ||
this[kAuthDirective] = authDirective | ||
this[kMode] = mode | ||
this[kPolicy] = policy | ||
} | ||
@@ -23,6 +37,6 @@ | ||
[kMakeProtectedResolver] (authDirectiveAST, resolverFn) { | ||
[kMakeProtectedResolver] (policy, resolverFn) { | ||
return async (parent, args, context, info) => { | ||
// Adding support for returned errors to match graphql-js resolver handling | ||
const result = await this[kApplyPolicy](authDirectiveAST, parent, args, context, info) | ||
const result = await this[kApplyPolicy](policy, parent, args, context, info) | ||
if (result instanceof Error) { | ||
@@ -38,25 +52,34 @@ throw result | ||
wrapFields (schemaType, authDirective) { | ||
// Handle fields on schema type | ||
if (typeof schemaType.getFields === 'function') { | ||
for (const [fieldName, field] of Object.entries(schemaType.getFields())) { | ||
if (typeof field.astNode !== 'undefined') { | ||
// Override resolvers on protected fields | ||
const authDirectiveASTForField = authDirective || this[kGetAuthDirectiveAST](field.astNode) | ||
if (authDirectiveASTForField !== null) { | ||
if (typeof field.resolve === 'function') { | ||
const originalFieldResolver = field.resolve | ||
field.resolve = this[kMakeProtectedResolver](authDirectiveASTForField, originalFieldResolver) | ||
} else { | ||
field.resolve = this[kMakeProtectedResolver](authDirectiveASTForField, (parent) => parent[fieldName]) | ||
} | ||
} | ||
} | ||
[kSetTypePolicy] (policy, typeName, typePolicy) { | ||
// This is never going to be defined because it is always the first check for a type | ||
policy[typeName] = { __typePolicy: typePolicy } | ||
return policy | ||
} | ||
[kSetFieldPolicy] (policy, typeName, fieldName, fieldPolicy) { | ||
const typePolicy = policy[typeName] | ||
if (typeof typePolicy === 'object') { | ||
typePolicy[fieldName] = fieldPolicy | ||
} else { | ||
policy[typeName] = { | ||
[fieldName]: fieldPolicy | ||
} | ||
} | ||
return policy | ||
} | ||
registerAuthHandlers (schema) { | ||
// Traverse schema types and override resolvers with auth protection where necessary | ||
const schemaTypeMap = schema.getTypeMap() | ||
[kWrapFieldResolver] (schemaTypeField, fieldPolicy) { | ||
// Overwrite field resolver | ||
const fieldName = schemaTypeField.name | ||
if (typeof schemaTypeField.resolve === 'function') { | ||
const originalFieldResolver = schemaTypeField.resolve | ||
schemaTypeField.resolve = this[kMakeProtectedResolver](fieldPolicy, originalFieldResolver) | ||
} else { | ||
schemaTypeField.resolve = this[kMakeProtectedResolver](fieldPolicy, (parent) => parent[fieldName]) | ||
} | ||
} | ||
[kBuildPolicy] (graphQLSchema) { | ||
const policy = {} | ||
const schemaTypeMap = graphQLSchema.getTypeMap() | ||
for (const schemaType of Object.values(schemaTypeMap)) { | ||
@@ -67,11 +90,51 @@ // Handle directive on type | ||
if (authDirectiveASTForType !== null) { | ||
if (typeof schemaType.resolveReference === 'function') { | ||
const originalResolveReference = schemaType.resolveReference | ||
schemaType.resolveReference = this[kMakeProtectedResolver](authDirectiveASTForType, originalResolveReference) | ||
this[kSetTypePolicy](policy, schemaType.name, authDirectiveASTForType) | ||
} | ||
} | ||
if (typeof schemaType.getFields === 'function') { | ||
for (const field of Object.values(schemaType.getFields())) { | ||
if (typeof field.astNode !== 'undefined') { | ||
// Override resolvers on protected fields | ||
const authDirectiveASTForField = this[kGetAuthDirectiveAST](field.astNode) | ||
if (authDirectiveASTForField !== null) { | ||
this[kSetFieldPolicy](policy, schemaType.name, field.name, authDirectiveASTForField) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
return policy | ||
} | ||
getPolicy (graphQLSchema) { | ||
if (this[kMode] === 'external') { | ||
return this[kPolicy] || {} | ||
} | ||
return this[kBuildPolicy](graphQLSchema) | ||
} | ||
registerAuthHandlers (graphQLSchema, policy) { | ||
for (const [typeName, typePolicy] of Object.entries(policy)) { | ||
const schemaType = graphQLSchema.getType(typeName) | ||
if (typeof schemaType !== 'undefined' && typeof schemaType.getFields === 'function') { | ||
for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { | ||
if (fieldName === '__typePolicy') { | ||
if (typeof schemaType.resolveReference === 'function') { | ||
// If type is a reference resolver, we wrap this function | ||
const originalResolveReference = schemaType.resolveReference | ||
schemaType.resolveReference = this[kMakeProtectedResolver](fieldPolicy, originalResolveReference) | ||
} else { | ||
// Wrap each field for a protected schema type | ||
for (const schemaTypeField of Object.values(schemaType.getFields())) { | ||
this[kWrapFieldResolver](schemaTypeField, fieldPolicy) | ||
} | ||
} | ||
} else { | ||
this.wrapFields(schemaType, authDirectiveASTForType) | ||
const schemaTypeField = schemaType.getFields()[fieldName] | ||
if (typeof schemaTypeField !== 'undefined') { | ||
this[kWrapFieldResolver](schemaTypeField, fieldPolicy) | ||
} | ||
} | ||
} | ||
} | ||
this.wrapFields(schemaType) | ||
} | ||
@@ -78,0 +141,0 @@ } |
'use strict' | ||
module.exports = { | ||
kMode: Symbol('mode'), | ||
kPolicy: Symbol('policy'), | ||
kApplyPolicy: Symbol('apply policy'), | ||
@@ -8,3 +10,7 @@ kAuthContext: Symbol('auth context'), | ||
kGetAuthDirectiveAST: Symbol('get auth directive ast'), | ||
kMakeProtectedResolver: Symbol('make protected resolver') | ||
kMakeProtectedResolver: Symbol('make protected resolver'), | ||
kBuildPolicy: Symbol('build policy'), | ||
kSetTypePolicy: Symbol('set type policy'), | ||
kSetFieldPolicy: Symbol('set field policy'), | ||
kWrapFieldResolver: Symbol('wrap field resolver') | ||
} |
@@ -6,14 +6,38 @@ 'use strict' | ||
function validateOpts (opts) { | ||
// Auth context is optional | ||
if (typeof opts.authContext !== 'undefined' && typeof opts.authContext !== 'function') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.authContext must be a function.') | ||
} | ||
// Mandatory | ||
if (typeof opts.applyPolicy !== 'function') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.applyPolicy must be a function.') | ||
} | ||
if (typeof opts.authDirective !== 'string') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.authDirective must be a string.') | ||
// Optional | ||
if (typeof opts.mode !== 'undefined' && typeof opts.mode !== 'string') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.mode must be a string.') | ||
} | ||
// External policy mode | ||
if (opts.mode === 'external') { | ||
if (typeof opts.policy !== 'undefined') { | ||
if (typeof opts.policy !== 'object' || opts.policy === null) { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.policy must be an object.') | ||
} | ||
for (const [typeName, typePolicy] of Object.entries(opts.policy)) { | ||
if (typeof typePolicy !== 'object' || typePolicy === null) { | ||
throw new MER_AUTH_ERR_INVALID_OPTS(`opts.policy.${typeName} must be an object.`) | ||
} | ||
} | ||
} | ||
// Default mode | ||
} else { | ||
// Mandatory | ||
if (typeof opts.authDirective !== 'string') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.authDirective must be a string.') | ||
} | ||
// Optional | ||
if (typeof opts.authContext !== 'undefined' && typeof opts.authContext !== 'function') { | ||
throw new MER_AUTH_ERR_INVALID_OPTS('opts.authContext must be a function.') | ||
} | ||
} | ||
} | ||
module.exports.validateOpts = validateOpts |
{ | ||
"name": "mercurius-auth", | ||
"version": "1.2.1", | ||
"version": "1.3.0", | ||
"description": "Mercurius Auth Plugin adds configurable Authentication and Authorization support to Mercurius.", | ||
@@ -46,3 +46,3 @@ "main": "index.js", | ||
"tap": "^15.0.2", | ||
"tsd": "^0.17.0", | ||
"tsd": "^0.18.0", | ||
"typescript": "^4.0.3", | ||
@@ -49,0 +49,0 @@ "wait-on": "^6.0.0" |
@@ -20,2 +20,4 @@ # mercurius-auth | ||
- [Quick Start](#quick-start) | ||
- [Directive (default) mode](#directive-default-mode) | ||
- [External Policy mode](#external-policy-mode) | ||
- [Examples](#examples) | ||
@@ -27,2 +29,3 @@ - [Benchmarks](#benchmarks) | ||
- [Auth Directive](docs/auth-directive.md) | ||
- [External Policy](docs/external-policy.md) | ||
- [Errors](docs/errors.md) | ||
@@ -39,2 +42,8 @@ - [Federation](docs/federation.md) | ||
We have two modes of operation for Mercurius Auth: | ||
### Directive (default) mode | ||
Setup in Directive mode as follows (this is the default mode of operation): | ||
```js | ||
@@ -92,2 +101,90 @@ 'use strict' | ||
### External Policy mode | ||
Instead of using GraphQL Directives, you can implement an External Policy at plugin registration to protect GraphQL fields and types. You can find more information about implementing policy systems and how to build external policies for a GraphQL schema in the [External Policy documentation](docs/external-policy.md). | ||
```js | ||
'use strict' | ||
const Fastify = require('fastify') | ||
const mercurius = require('mercurius') | ||
const mercuriusAuth = require('..') | ||
const app = Fastify() | ||
const schema = ` | ||
type Message { | ||
title: String | ||
message: String | ||
adminMessage: String | ||
} | ||
type Query { | ||
messages: [Message] | ||
message(title: String): Message | ||
} | ||
` | ||
const messages = [ | ||
{ | ||
title: 'one', | ||
message: 'one', | ||
adminMessage: 'admin message one' | ||
}, | ||
{ | ||
title: 'two', | ||
message: 'two', | ||
adminMessage: 'admin message two' | ||
} | ||
] | ||
const resolvers = { | ||
Query: { | ||
messages: async (parent, args, context, info) => { | ||
return messages | ||
}, | ||
message: async (parent, args, context, info) => { | ||
return messages.find(message => message.title === args.title) | ||
} | ||
} | ||
} | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
app.register(mercuriusAuth, { | ||
// Load the permissions into the context from the request headers | ||
authContext (context) { | ||
const permissions = context.reply.request.headers['x-user'] || '' | ||
return { permissions } | ||
}, | ||
async applyPolicy (policy, parent, args, context, info) { | ||
// When called on field `Message.adminMessage` | ||
// policy: { requires: 'admin' } | ||
// context.auth.permissions: ['user', 'admin'] - the permissions associated with the user (passed as headers in authContext) | ||
return context.auth.permissions.includes(policy.requires) | ||
}, | ||
// Enable External Policy mode | ||
mode: 'external', | ||
policy: { | ||
// Associate policy with the 'Message' Object type | ||
Message: { | ||
// Define policy for 'Message' Object type | ||
__typePolicy: { requires: 'user' }, | ||
// Define policy for 'adminMessage' field | ||
adminMessage: { requires: 'admin' } | ||
}, | ||
// Associate policy with the Query root type | ||
Query: { | ||
// Define policy for 'message' Query | ||
messages: { requires: 'user' } | ||
} | ||
} | ||
}) | ||
app.listen(3000) | ||
``` | ||
## Examples | ||
@@ -94,0 +191,0 @@ |
@@ -1182,1 +1182,80 @@ 'use strict' | ||
}) | ||
test('basic - should be able to turn off directive based auth by setting mode to "external"', async (t) => { | ||
t.plan(1) | ||
const app = Fastify() | ||
t.teardown(app.close.bind(app)) | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
app.register(mercuriusAuth, { | ||
authContext (context) { | ||
return { | ||
identity: context.reply.request.headers['x-user'] | ||
} | ||
}, | ||
async applyPolicy (authDirectiveAST, parent, args, context, info) { | ||
return context.auth.identity === 'admin' | ||
}, | ||
authDirective: 'auth', | ||
mode: 'external' | ||
}) | ||
const query = `query { | ||
four: add(x: 2, y: 2) | ||
six: add(x: 3, y: 3) | ||
subtract(x: 3, y: 3) | ||
messages { | ||
title | ||
public | ||
private | ||
} | ||
adminMessages { | ||
title | ||
public | ||
private | ||
} | ||
}` | ||
const response = await app.inject({ | ||
method: 'POST', | ||
headers: { 'content-type': 'application/json', 'X-User': 'user' }, | ||
url: '/graphql', | ||
body: JSON.stringify({ query }) | ||
}) | ||
t.same(JSON.parse(response.body), { | ||
data: { | ||
four: 4, | ||
six: 6, | ||
subtract: 0, | ||
messages: [ | ||
{ | ||
title: 'one', | ||
public: 'public one', | ||
private: 'private one' | ||
}, | ||
{ | ||
title: 'two', | ||
public: 'public two', | ||
private: 'private two' | ||
} | ||
], | ||
adminMessages: [ | ||
{ | ||
title: 'admin one', | ||
public: 'admin public one', | ||
private: 'admin private one' | ||
}, | ||
{ | ||
title: 'admin two', | ||
public: 'admin public two', | ||
private: 'admin private two' | ||
} | ||
] | ||
} | ||
}) | ||
}) |
@@ -60,3 +60,3 @@ 'use strict' | ||
try { | ||
await app.register(mercuriusAuth, { authContext: '' }) | ||
await app.register(mercuriusAuth, { applyPolicy: () => {}, authDirective: 'auth', authContext: '' }) | ||
} catch (error) { | ||
@@ -123,1 +123,83 @@ t.same(error, new MER_AUTH_ERR_INVALID_OPTS('opts.authContext must be a function.')) | ||
}) | ||
test('should error if mode is not a string', async (t) => { | ||
t.plan(1) | ||
const app = Fastify() | ||
t.teardown(app.close.bind(app)) | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
await t.rejects(app.register(mercuriusAuth, { | ||
authContext: () => {}, | ||
applyPolicy: () => {}, | ||
mode: {} | ||
}), new MER_AUTH_ERR_INVALID_OPTS('opts.mode must be a string.')) | ||
}) | ||
test('registration - external policy', t => { | ||
t.plan(3) | ||
t.test('should error if policy is not an object', async (t) => { | ||
t.plan(1) | ||
const app = Fastify() | ||
t.teardown(app.close.bind(app)) | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
await t.rejects(app.register(mercuriusAuth, { | ||
applyPolicy: () => {}, | ||
mode: 'external', | ||
policy: '' | ||
}), new MER_AUTH_ERR_INVALID_OPTS('opts.policy must be an object.')) | ||
}) | ||
t.test('should error if policy type is not an object', async (t) => { | ||
t.plan(1) | ||
const app = Fastify() | ||
t.teardown(app.close.bind(app)) | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
await t.rejects(app.register(mercuriusAuth, { | ||
applyPolicy: () => {}, | ||
mode: 'external', | ||
policy: { | ||
Query: { | ||
add: { some: 'policy' } | ||
}, | ||
wrong: 'string' | ||
} | ||
}), new MER_AUTH_ERR_INVALID_OPTS('opts.policy.wrong must be an object.')) | ||
}) | ||
t.test('registration - should register the plugin', async (t) => { | ||
t.plan(1) | ||
const app = Fastify() | ||
t.teardown(app.close.bind(app)) | ||
app.register(mercurius, { | ||
schema, | ||
resolvers | ||
}) | ||
await app.register(mercuriusAuth, { | ||
applyPolicy: () => {}, | ||
mode: 'external', | ||
policy: { | ||
Query: { | ||
add: { some: 'policy' } | ||
} | ||
} | ||
}) | ||
t.ok('mercurius auth plugin is registered') | ||
}) | ||
}) |
@@ -1,2 +0,2 @@ | ||
import { expectType } from 'tsd' | ||
import { expectAssignable, expectType } from 'tsd' | ||
import fastify from 'fastify' | ||
@@ -18,3 +18,3 @@ import { DirectiveNode, GraphQLResolveInfo } from 'graphql' | ||
async applyPolicy (authDirectiveAST, parent, args, context, info) { | ||
expectType<DirectiveNode>(authDirectiveAST) | ||
expectAssignable<DirectiveNode>(authDirectiveAST) | ||
expectType<any>(parent) | ||
@@ -53,3 +53,3 @@ expectType<any>(args) | ||
) { | ||
expectType<DirectiveNode>(authDirectiveAST) | ||
expectAssignable<DirectiveNode>(authDirectiveAST) | ||
expectType<CustomParent>(parent) | ||
@@ -74,3 +74,3 @@ expectType<CustomArgs>(args) | ||
async applyPolicy (authDirectiveAST, parent, args, context, info) { | ||
expectType<DirectiveNode>(authDirectiveAST) | ||
expectAssignable<DirectiveNode>(authDirectiveAST) | ||
expectType<CustomParent>(parent) | ||
@@ -99,3 +99,3 @@ expectType<CustomArgs>(args) | ||
async (authDirectiveAST, parent, args, context, info) => { | ||
expectType<DirectiveNode>(authDirectiveAST) | ||
expectAssignable<DirectiveNode>(authDirectiveAST) | ||
expectType<{}>(parent) | ||
@@ -114,1 +114,86 @@ expectType<{}>(args) | ||
}) | ||
app.register(mercuriusAuth, { | ||
applyPolicy, | ||
authContext, | ||
authDirective: 'auth', | ||
mode: 'directive' | ||
}) | ||
// External policy for fields only | ||
app.register(mercuriusAuth, { | ||
async applyPolicy (policy: string, parent, args, context, info) { | ||
expectType<string>(policy) | ||
expectType<any>(parent) | ||
expectType<any>(args) | ||
expectType<MercuriusContext>(context) | ||
expectType<GraphQLResolveInfo>(info) | ||
expectType<MercuriusAuthContext | undefined>(context.auth) | ||
return true | ||
}, | ||
authContext, | ||
mode: 'external', | ||
policy: { | ||
Message: { | ||
message: 'user' | ||
}, | ||
Query: { | ||
messages: 'user' | ||
} | ||
} | ||
}) | ||
// External policy for field and types | ||
app.register(mercuriusAuth, { | ||
async applyPolicy (policy: string, parent, args, context, info) { | ||
expectType<string>(policy) | ||
expectType<any>(parent) | ||
expectType<any>(args) | ||
expectType<MercuriusContext>(context) | ||
expectType<GraphQLResolveInfo>(info) | ||
expectType<MercuriusAuthContext | undefined>(context.auth) | ||
return true | ||
}, | ||
authContext, | ||
mode: 'external', | ||
policy: { | ||
Message: { | ||
__typePolicy: 'user', | ||
message: 'admin' | ||
}, | ||
Query: { | ||
messages: 'user' | ||
} | ||
} | ||
}) | ||
// External Policy with a custom Policy type | ||
interface CustomPolicy { | ||
requires: string[] | ||
} | ||
const externalPolicyOptions: MercuriusAuthOptions<CustomParent, CustomArgs, CustomContext, CustomPolicy> = { | ||
async applyPolicy (policy, parent, args, context, info) { | ||
expectType<CustomPolicy>(policy) | ||
expectType<CustomParent>(parent) | ||
expectType<CustomArgs>(args) | ||
expectType<CustomContext>(context) | ||
expectType<GraphQLResolveInfo>(info) | ||
expectType<string | undefined>(context?.auth?.identity) | ||
return true | ||
}, | ||
authContext (context) { | ||
expectType<CustomContext>(context) | ||
return { identity: context.reply.request.headers['x-auth'] } | ||
}, | ||
mode: 'external', | ||
policy: { | ||
Message: { | ||
__typePolicy: 'user', | ||
message: 'admin' | ||
}, | ||
Query: { | ||
messages: 'user' | ||
} | ||
} | ||
} | ||
app.register(mercuriusAuth, externalPolicyOptions) |
Sorry, the diff of this file is not supported yet
192060
51.66%50
11.11%5867
51.02%283
52.15%