Skip to content

Commit

Permalink
Refactor Generic Auth plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Aug 6, 2024
1 parent 277fac5 commit 9b9885b
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 72 deletions.
6 changes: 6 additions & 0 deletions .changeset/sweet-apples-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@envelop/generic-auth': major
'@envelop/extended-validation': minor
---

TODO
93 changes: 78 additions & 15 deletions packages/plugins/extended-validation/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ import {
visitInParallel,
visitWithTypeInfo,
} from 'graphql';
import { Plugin, TypedSubscriptionArgs } from '@envelop/core';
import {
AsyncIterableIteratorOrValue,
isAsyncIterable,
OnExecuteEventPayload,
OnExecuteHookResult,
OnSubscribeEventPayload,
OnSubscribeHookResult,
Plugin,
} from '@envelop/core';
import { ExtendedValidationRule } from './common.js';

const symbolExtendedValidationRules = Symbol('extendedValidationContext');
Expand All @@ -30,6 +38,12 @@ export const useExtendedValidation = <PluginContext extends Record<string, any>
* Callback that is invoked if the extended validation yields any errors.
*/
onValidationFailed?: OnValidationFailedCallback;
/**
* Reject the execution if the validation fails.
*
* @default true
*/
rejectOnErrors?: boolean;
}): Plugin<PluginContext & { [symbolExtendedValidationRules]?: ExtendedValidationContext }> => {
let schemaTypeInfo: TypeInfo;

Expand Down Expand Up @@ -57,23 +71,33 @@ export const useExtendedValidation = <PluginContext extends Record<string, any>
}
validationRulesContext.rules.push(...options.rules);
},
onSubscribe: buildHandler('subscribe', getTypeInfo, options.onValidationFailed),
onExecute: buildHandler('execute', getTypeInfo, options.onValidationFailed),
onSubscribe: buildHandler(
'subscribe',
getTypeInfo,
options.onValidationFailed,
options.rejectOnErrors !== false,
),
onExecute: buildHandler(
'execute',
getTypeInfo,
options.onValidationFailed,
options.rejectOnErrors !== false,
),
};
};

function buildHandler(
name: 'execute' | 'subscribe',
getTypeInfo: () => TypeInfo | undefined,
onValidationFailed?: OnValidationFailedCallback,
rejectOnErrors = true,
) {
return function handler({
args,
setResultAndStopExecution,
}: {
args: TypedSubscriptionArgs<any>;
setResultAndStopExecution: (newResult: ExecutionResult) => void;
}) {
}: OnExecuteEventPayload<any> | OnSubscribeEventPayload<any>):
| (OnExecuteHookResult<any> & OnSubscribeHookResult<any>)
| void {
// We hook into onExecute/onSubscribe even though this is a validation pattern. The reasoning behind
// it is that hooking right after validation and before execution has started is the
// same as hooking into the validation step. The benefit of this approach is that
Expand Down Expand Up @@ -101,17 +125,56 @@ function buildHandler(
const visitor = visitInParallel(
validationRulesContext.rules.map(rule => rule(validationContext, args)),
);
visit(args.document, visitWithTypeInfo(typeInfo, visitor));

args.document = visit(args.document, visitWithTypeInfo(typeInfo, visitor));

if (errors.length > 0) {
let result: ExecutionResult = {
data: null,
errors,
};
if (onValidationFailed) {
onValidationFailed({ args, result, setResult: newResult => (result = newResult) });
if (rejectOnErrors) {
let result: ExecutionResult = {
data: null,
errors,
};
if (onValidationFailed) {
onValidationFailed({ args, result, setResult: newResult => (result = newResult) });
}
setResultAndStopExecution(result);
} else {
// eslint-disable-next-line no-inner-declarations
function onResult({
result,
setResult,
}: {
result: AsyncIterableIteratorOrValue<ExecutionResult>;
setResult: (result: AsyncIterableIteratorOrValue<ExecutionResult>) => void;
}) {
if (isAsyncIterable(result)) {
// rejectOnErrors is false doesn't work with async iterables
setResult({
data: null,
errors,
});
return;
}
const newResult = {
...result,
errors: [...(result.errors || []), ...errors],
};
errors.forEach(e => {
if (e.path?.length) {
let currentData: any = (newResult.data ||= {});
for (const pathItem of e.path.slice(0, -1)) {
currentData = currentData[pathItem] ||= {};
}
currentData[e.path[e.path.length - 1]] = null;
}
});
setResult(newResult);
}
return {
onSubscribeResult: onResult,
onExecuteDone: onResult,
};
}
setResultAndStopExecution(result);
}
}
}
Expand Down
34 changes: 17 additions & 17 deletions packages/plugins/generic-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ This plugin allows you to implement custom authentication flow by providing a cu
based on the original HTTP request. The resolved user is injected into the GraphQL execution
`context`, and you can use it in your resolvers to fetch the current user.

> The plugin also comes with an optional `@auth` directive that can be added to your GraphQL schema
> and helps you to protect your GraphQL schema in a declarative way.
> The plugin also comes with an optional `@authenticated` directive that can be added to your
> GraphQL schema and helps you to protect your GraphQL schema in a declarative way.
There are several possible flows for using this plugin (see below for setup examples):

Expand All @@ -15,8 +15,8 @@ There are several possible flows for using this plugin (see below for setup exam
- **Option #2 - Manual Validation**: the plugin will just resolve the user and injects it into the
`context` without validating access to schema field.
- **Option #3 - Granular field access by using schema field directives or field extensions**: Look
for an `@auth` directive or `auth` extension field and automatically protect those specific
GraphQL fields.
for an `@authenticated` directive or `authenticated` extension field and automatically protect
those specific GraphQL fields.

## Getting Started

Expand Down Expand Up @@ -70,7 +70,7 @@ const validateUser: ValidateUserFn<UserType> = params => {
// This method is being triggered in different flows, based on the mode you chose to implement.

// If you are using the `protect-auth-directive` mode, you'll also get 2 additional parameters: the resolver parameters as object and the DirectiveNode of the auth directive.
// In `protect-auth-directive` mode, this function will always get called and you can use these parameters to check if the field has the `@auth` or `@skipAuth` directive
// In `protect-auth-directive` mode, this function will always get called and you can use these parameters to check if the field has the `@authenticated` or `@skipAuth` directive

if (!user) {
return new Error(`Unauthenticated!`)
Expand Down Expand Up @@ -213,8 +213,8 @@ const resolvers = {

#### Option #3 - `protect-granular`

This mode is similar to option #2, but it uses the `@auth` SDL directive or `auth` field extension
for protecting specific GraphQL fields.
This mode is similar to option #2, but it uses the `@authenticated` SDL directive or `auth` field
extension for protecting specific GraphQL fields.

```ts
import { execute, parse, specifiedRules, subscribe, validate } from 'graphql'
Expand Down Expand Up @@ -247,15 +247,15 @@ const getEnveloped = envelop({
##### Protect a field using a field `directive`

> By default, we assume that you have the GraphQL directive definition as part of your GraphQL
> schema (`directive @auth on FIELD_DEFINITION`).
> schema (`directive @authenticated on FIELD_DEFINITION`).
Then, in your GraphQL schema SDL, you can add `@auth` directive to your fields, and the
Then, in your GraphQL schema SDL, you can add `@authenticated` directive to your fields, and the
`validateUser` will get called only while resolving that specific field:

```graphql
type Query {
me: User! @auth
protectedField: String @auth
me: User! @authenticated
protectedField: String @authenticated
# publicField: String
}
```
Expand All @@ -277,7 +277,7 @@ const GraphQLQueryType = new GraphQLObjectType({
type: GraphQLInt,
resolve: () => 1,
extensions: {
auth: true
authenticated: true
}
}
}
Expand Down Expand Up @@ -308,16 +308,16 @@ const validateUser: ValidateUserFn<UserType> = ({ user }) => {

##### With a custom directive with arguments

It is possible to add custom parameters to your `@auth` directive. Here's an example for adding
role-aware authentication:
It is possible to add custom parameters to your `@authenticated` directive. Here's an example for
adding role-aware authentication:

```graphql
enum Role {
ADMIN
MEMBER
}

directive @auth(role: Role!) on FIELD_DEFINITION
directive @authenticated(role: Role!) on FIELD_DEFINITION
```

Then, you use the `directiveNode` parameter to check the arguments:
Expand Down Expand Up @@ -371,7 +371,7 @@ const resolvers = {
user: {
me: (_, __, { currentUser }) => currentUser,
extensions: {
auth: {
authenticated: {
role: 'USER'
}
}
Expand Down Expand Up @@ -410,7 +410,7 @@ const resolvers = {
user: {
resolve: (_, { userId }) => getUser(userId),
extensions: {
auth: {
authenticated: {
validate: ({ user, variables, context }) => {
// We can now have access to the operation and variables to decide if the user can execute the query
if (user.id !== variables.userId) {
Expand Down
Loading

0 comments on commit 9b9885b

Please sign in to comment.