diff --git a/.changeset/@envelop_generic-auth-2281-dependencies.md b/.changeset/@envelop_generic-auth-2281-dependencies.md new file mode 100644 index 000000000..9f52df276 --- /dev/null +++ b/.changeset/@envelop_generic-auth-2281-dependencies.md @@ -0,0 +1,5 @@ +--- +"@envelop/generic-auth": patch +--- +dependencies updates: + - Updated dependency [`@graphql-tools/utils@^10.5.1` ↗︎](https://www.npmjs.com/package/@graphql-tools/utils/v/10.5.1) (from `^10.0.6`, in `dependencies`) diff --git a/.changeset/sweet-apples-confess.md b/.changeset/sweet-apples-confess.md new file mode 100644 index 000000000..2bd8ea817 --- /dev/null +++ b/.changeset/sweet-apples-confess.md @@ -0,0 +1,97 @@ +--- +'@envelop/generic-auth': major +'@envelop/extended-validation': minor +--- + +Refactor Generic Auth plugin; + +- [BREAKING] - Now `@auth` directive is renamed to `@authenticated`. If you want to keep the old name you can configure the plugin to use the old name. + +```ts +useGenericAuth({ + // ... + authDirectiveName: 'auth', +}); +``` + +- [BREAKING] - Now `directiveOrExtensionFieldName` is renamed to `authDirectiveName`. + +```diff +useGenericAuth({ + // ... +- directiveOrExtensionFieldName: 'auth', ++ authDirectiveName: 'auth', +}); +``` + +- Now auth directives support `OBJECT` and `INTERFACE` locations, so you can use the auth directive on types as well. + +```graphql +directive @authenticated on OBJECT | INTERFACE + +type User @authenticated { + id: ID! + name: String! +} +``` + +- `validateUser` function does not receive `fieldAuthDirectiveNode` and `fieldAuthExtension` anymore. Instead, it takes `fieldAuthArgs` which is an object that contains the arguments of the auth directive or extension. So you don't need to parse the arguments manually anymore. + +```ts +const validateUser: ValidateUserFn = params => { + if (!params.fieldAuthArgs.roles.includes('admin')) { + return createUnauthorizedError(params); + } +}; +``` + +- `validateUser`'s `objectType` parameter is now renamed to `parentType`. And it takes the original composite type instead of the `GraphQLObjectType` instance. Now it can be `GraphQLInterfaceType` as well. + +- `validateUser`'s current parameters are now; + +```ts +export type ValidateUserFnParams = { + /** The user object. */ + user: UserType; + /** The field node from the operation that is being validated. */ + fieldNode: FieldNode; + /** The parent type which has the field that is being validated. */ + parentType: GraphQLObjectType | GraphQLInterfaceType; + /** The auth directive arguments for the type */ + typeAuthArgs?: Record; + /** The directives for the type */ + typeDirectives?: ReturnType; + /** Scopes that type requires */ + typeScopes?: string[][]; + /** Policies that type requires */ + typePolicies?: string[][]; + /** The object field */ + field: GraphQLField; + /** The auth directive arguments for the field */ + fieldAuthArgs?: Record; + /** The directives for the field */ + fieldDirectives?: ReturnType; + /** Scopes that field requires */ + fieldScopes?: string[][]; + /** Policies that field requires */ + fieldPolicies?: string[][]; + /** Extracted scopes from the user object */ + userScopes: string[]; + /** Policies for the user */ + userPolicies: string[]; + /** The args passed to the execution function (including operation context and variables) **/ + executionArgs: ExecutionArgs; + /** Resolve path */ + path: ReadonlyArray; +}; +``` + +- New directives for role-based auth are added `@requiresScopes` and `@policy` for more granular control over the auth logic. + +```graphql +directive @requiresScopes(scopes: [String!]!) on OBJECT | INTERFACE | FIELD_DEFINITION + +directive @policy(policy: String!) on OBJECT | INTERFACE | FIELD_DEFINITION +``` + +Check README for more information. diff --git a/packages/plugins/extended-validation/src/plugin.ts b/packages/plugins/extended-validation/src/plugin.ts index 00e34c083..79d5cc2fe 100644 --- a/packages/plugins/extended-validation/src/plugin.ts +++ b/packages/plugins/extended-validation/src/plugin.ts @@ -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'); @@ -30,6 +38,12 @@ export const useExtendedValidation = * 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 => { let schemaTypeInfo: TypeInfo; @@ -57,8 +71,18 @@ export const useExtendedValidation = } 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, + ), }; }; @@ -66,14 +90,14 @@ function buildHandler( name: 'execute' | 'subscribe', getTypeInfo: () => TypeInfo | undefined, onValidationFailed?: OnValidationFailedCallback, + rejectOnErrors = true, ) { return function handler({ args, setResultAndStopExecution, - }: { - args: TypedSubscriptionArgs; - setResultAndStopExecution: (newResult: ExecutionResult) => void; - }) { + }: OnExecuteEventPayload | OnSubscribeEventPayload): + | (OnExecuteHookResult & OnSubscribeHookResult) + | 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 @@ -101,17 +125,74 @@ 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; + setResult: (result: AsyncIterableIteratorOrValue) => 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], + }; + function visitPath(path: readonly (string | number)[], data: any = {}) { + let currentData = (data ||= typeof path[0] === 'number' ? [] : {}); + for (const pathItemIndex in path.slice(0, -1)) { + const pathItem = path[pathItemIndex]; + currentData = currentData[pathItem] ||= + typeof path[Number(pathItemIndex) + 1] === 'number' || + path[Number(pathItemIndex) + 1] + ? [] + : {}; + if (Array.isArray(currentData)) { + let pathItemIndexInArray = Number(pathItemIndex) + 1; + if (path[pathItemIndexInArray] === '@') { + pathItemIndexInArray = Number(pathItemIndex) + 2; + } + currentData = currentData.map((c, i) => + visitPath(path.slice(pathItemIndexInArray), c), + ); + } + } + currentData[path[path.length - 1]] = null; + return data; + } + errors.forEach(e => { + if (e.path?.length) { + newResult.data = visitPath(e.path, newResult.data); + } + }); + setResult(newResult); + } + return { + onSubscribeResult: onResult, + onExecuteDone: onResult, + }; } - setResultAndStopExecution(result); } } } diff --git a/packages/plugins/generic-auth/README.md b/packages/plugins/generic-auth/README.md index 701d790b1..b6d3edf04 100644 --- a/packages/plugins/generic-auth/README.md +++ b/packages/plugins/generic-auth/README.md @@ -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): @@ -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 @@ -70,7 +70,7 @@ const validateUser: ValidateUserFn = 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!`) @@ -134,8 +134,8 @@ type Query { > You can apply that directive to any GraphQL `field` definition, not only to root fields. -> If you are using a different directive for authentication, you can pass -> `directiveOrExtensionFieldName` configuration to customize it. +> If you are using a different directive for authentication, you can pass `authDirectiveName` +> configuration to customize it. ##### Allow unauthenticated access for specific fields using a field extension @@ -149,15 +149,17 @@ const GraphQLQueryType = new GraphQLObjectType({ type: GraphQLInt, resolve: () => 1, extensions: { - skipAuth: true + directives: { + skipAuth: true + } } } } }) ``` -> If you want to use a different directive for authentication, you can use the -> `directiveOrExtensionFieldName` configuration to customize it. +> If you want to use a different directive for authentication, you can use the `authDirectiveName` +> configuration to customize it. #### Option #2 - `resolve-only` @@ -213,8 +215,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' @@ -244,28 +246,28 @@ const getEnveloped = envelop({ }) ``` -##### Protect a field using a field `directive` +##### Protect a field using a field or type `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 | OBJECT | INTERFACE`). -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 } ``` > You can apply that directive to any GraphQL `field` definition, not only to root fields. -> If you are using a different directive for authentication, you can pass -> `directiveOrExtensionFieldName` configuration to customize it. +> If you are using a different directive for authentication, you can pass `authDirectiveName` +> configuration to customize it. -##### Protect a field using a field extension +##### Protect a field or type using extensions ```typescript import { GraphQLInt, GraphQLObjectType } from 'graphql' @@ -277,15 +279,17 @@ const GraphQLQueryType = new GraphQLObjectType({ type: GraphQLInt, resolve: () => 1, extensions: { - auth: true + directives: { + authenticated: true + } } } } }) ``` -> If you are using a different field extension for authentication, you can pass -> `directiveOrExtensionFieldName` configuration to customize it. +> If you are using a different field extension for authentication, you can pass `authDirectiveName` +> configuration to customize it. #### Extend authentication with custom logic @@ -306,73 +310,117 @@ const validateUser: ValidateUserFn = ({ user }) => { } ``` -##### With a custom directive with arguments +##### Role/scope based authentication with `@requiresScope` directive -It is possible to add custom parameters to your `@auth` directive. Here's an example for adding -role-aware authentication: +You can use `@requiresScope` directive to protect your schema based on the user's role or scope. +Here's an example of how you can use it: ```graphql -enum Role { - ADMIN - MEMBER -} +directive @requiresScopes(scopes: [[String!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE -directive @auth(role: Role!) on FIELD_DEFINITION +type Query { + me: User! @requiresScopes(scopes: [["read:user"]]) + protectedField: String @requiresScopes(scopes: [["read:admin"]]) + publicField: String +} ``` -Then, you use the `directiveNode` parameter to check the arguments: +By default, the plugin will try to extract available scopes for the current user from `scope` +property which is expected to be a string like `read:user read:admin`. However you can customize +this behavior by providing a custom `extractScopes` function. ```ts -import { ValidateUserFn } from '@envelop/generic-auth' +useGenericAuth({ + resolveUserFn, + validateUser, + mode: 'protect-granular', + extractScopes: user => user.scopes // Expected to return an array of strings +}) +``` -const validateUser: ValidateUserFn = ({ user, fieldAuthDirectiveNode }) => { - // Now you can use the fieldAuthDirectiveNode parameter to implement custom logic for user validation, with access - // to the resolver auth directive arguments. +You can also apply `AND` or `OR` logic to the scopes: - if (!user) { - return new Error(`Unauthenticated!`) - } +```graphql +type Query { + # This field requires the user to have `read:user` OR `read:admin` scopes + me: User! @requiresScopes(scopes: [["read:user"], ["read:admin"]]) + # This field requires the user to have `read:user` AND `read:admin` scopes + protectedField: String @requiresScopes(scopes: [["read:admin", "read:user"]]) + publicField: String +} +``` - const valueNode = fieldAuthDirectiveNode.arguments.find(arg => arg.name.value === 'role') - .value as EnumValueNode - const role = valueNode.value +##### `@policy` directive to fetch the roles from a policy service - if (role !== user.role) { - return new Error(`No permissions!`) - } +You can use the `@policy` directive to fetch the roles from a policy service. Here's an example of +how you can use it: + +```graphql +directive @policy(name: String!) on FIELD_DEFINITION | OBJECT | INTERFACE + +type Query { + me: User! @policy(policies: [["read:user"]]) + protectedField: String @policy(policies: [["read:admin"]]) + publicField: String } ``` -##### With a custom field extensions - -You can use custom field extension to pass data to your `validateUser` function instead of using a -directive. Here's an example for adding role-aware authentication: +It has the same logic with `@requiresScopes` but it can asynchronously fetch the roles from a +source; ```ts -import { ValidateUserFn } from '@envelop/generic-auth' +useGenericAuth({ + resolveUserFn, + validateUser, + mode: 'protect-granular', + fetchPolicies: async user => { + const res = await fetch('https://policy-service.com', { + headers: { + Authorization: `Bearer ${user.token}` + } + }) + // Expected to return an array of strings + return res.json() + } +}) +``` -const validateUser: ValidateUserFn = ({ user, fieldAuthExtension }) => { - // Now you can use the fieldAuthDirectiveNode parameter to implement custom logic for user validation, with access - // to the resolver auth directive arguments. +##### Reject the whole operation if the user is not authenticated for the entire selection set - if (!user) { - return new Error(`Unauthenticated!`) - } +By default, the plugin will reject the whole operation if the user is not authenticated for the +selection set fully. But if you want to allow partial execution, you can set `rejectUnauthorized` to +`false`. - const role = fieldAuthExtension.role +When `rejectUnauthorized` is set to `false`, the plugin will behave like below; - if (role !== user.role) { - return new Error(`No permissions!`) +```graphql +query { + me { + # This field will not be executed if the user is not authenticated + id + name + email } + protectedField # This field will not be executed if the user is not authenticated + publicField # This field will be executed even if the user is not authenticated } +``` + +##### With a custom field extensions +You can use custom field extension to pass data to your `validateUser` function instead of using a +directive. Here's an example for adding role-aware authentication: + +```ts const resolvers = { Query: { user: { me: (_, __, { currentUser }) => currentUser, extensions: { - auth: { - role: 'USER' + directives: { + requiresScopes: { + scopes: [['read:user']] + } } } } @@ -410,11 +458,13 @@ const resolvers = { user: { resolve: (_, { userId }) => getUser(userId), extensions: { - auth: { - 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) { - return new Error(`Unauthorized`) + directives: { + 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) { + return new Error(`Unauthorized`) + } } } } diff --git a/packages/plugins/generic-auth/package.json b/packages/plugins/generic-auth/package.json index 7fef9b879..443a872cf 100644 --- a/packages/plugins/generic-auth/package.json +++ b/packages/plugins/generic-auth/package.json @@ -52,7 +52,7 @@ }, "dependencies": { "@envelop/extended-validation": "^4.0.0", - "@graphql-tools/utils": "^10.0.6", + "@graphql-tools/utils": "^10.5.1", "tslib": "^2.5.0" }, "devDependencies": { diff --git a/packages/plugins/generic-auth/src/index.ts b/packages/plugins/generic-auth/src/index.ts index 3bf559a74..2dd5ba65d 100644 --- a/packages/plugins/generic-auth/src/index.ts +++ b/packages/plugins/generic-auth/src/index.ts @@ -1,21 +1,29 @@ import { - DirectiveNode, + ASTNode, ExecutionArgs, FieldNode, getNamedType, + getOperationAST, GraphQLError, GraphQLField, + GraphQLInterfaceType, GraphQLObjectType, + GraphQLOutputType, + isAbstractType, isInterfaceType, isIntrospectionType, + isListType, isObjectType, isUnionType, + OperationTypeNode, } from 'graphql'; import { DefaultContext, Maybe, Plugin, PromiseOrValue } from '@envelop/core'; import { useExtendedValidation } from '@envelop/extended-validation'; -import { shouldIncludeNode } from '@graphql-tools/utils'; - -export class UnauthenticatedError extends GraphQLError {} +import { + createGraphQLError, + getDirectiveExtensions, + shouldIncludeNode, +} from '@graphql-tools/utils'; export type ResolveUserFn = ( context: ContextType, @@ -26,26 +34,54 @@ export type ValidateUserFnParams = { user: UserType; /** The field node from the operation that is being validated. */ fieldNode: FieldNode; - /** The object type which has the field that is being validated. */ - objectType: GraphQLObjectType; - /** The directive node used for the authentication (If using an SDL flow). */ - fieldAuthDirectiveNode: DirectiveNode | undefined; - /** The extensions used for authentication (If using an extension based flow). */ - fieldAuthExtension: unknown | undefined; + /** The parent type which has the field that is being validated. */ + parentType: GraphQLObjectType | GraphQLInterfaceType; + /** The auth directive arguments for the type */ + typeAuthArgs?: Record; + /** The directives for the type */ + typeDirectives?: ReturnType; + /** Scopes that type requires */ + typeScopes?: string[][]; + /** Policies that type requires */ + typePolicies?: string[][]; + /** The object field */ + field: GraphQLField; + /** The auth directive arguments for the field */ + fieldAuthArgs?: Record; + /** The directives for the field */ + fieldDirectives?: ReturnType; + /** Scopes that field requires */ + fieldScopes?: string[][]; + /** Policies that field requires */ + fieldPolicies?: string[][]; + /** Extracted scopes from the user object */ + userScopes: string[]; + /** Policies for the user */ + userPolicies: string[]; /** The args passed to the execution function (including operation context and variables) **/ executionArgs: ExecutionArgs; + /** Resolve path */ + path: ReadonlyArray; }; export type ValidateUserFn = ( params: ValidateUserFnParams, -) => void | UnauthenticatedError; +) => void | GraphQLError; export const DIRECTIVE_SDL = /* GraphQL */ ` - directive @auth on FIELD_DEFINITION + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE `; export const SKIP_AUTH_DIRECTIVE_SDL = /* GraphQL */ ` - directive @skipAuth on FIELD_DEFINITION + directive @skipAuth on FIELD_DEFINITION | OBJECT | INTERFACE +`; + +export const REQUIRES_SCOPES_DIRECTIVE_SDL = /* GraphQL */ ` + directive @requiresScopes(scopes: [[String!]!]!) on FIELD_DEFINITION | OBJECT | INTERFACE +`; + +export const POLICY_DIRECTIVE_SDL = /* GraphQL */ ` + directive @policy(policies: [String!]!) on FIELD_DEFINITION | OBJECT | INTERFACE `; export type GenericAuthPluginOptions< @@ -65,6 +101,28 @@ export type GenericAuthPluginOptions< * @default currentUser */ contextFieldName?: CurrentUserKey; + /** + * Overrides the default directive name for marking a field that requires specific scopes. + * + * @default requiresScopes + */ + scopesDirectiveName?: 'requiresScopes'; + /** + * Extracts the scopes from the user object. + * + * @default defaultExtractScopes + */ + extractScopes?(user: UserType): string[]; + /** + * Overrides the default directive name for @policy directive + * + * @default policy + */ + policyDirectiveName?: string; + /** + * Extracts the policies for the user object. + */ + extractPolicies?(user: UserType, context: ContextType): PromiseOrValue; } & ( | { /** @@ -77,7 +135,7 @@ export type GenericAuthPluginOptions< * Overrides the default directive name or extension field for marking a field available for unauthorized users. * @default skipAuth */ - directiveOrExtensionFieldName?: 'skipAuth' | string; + authDirectiveName?: 'skipAuth' | string; /** * Customize how the user is validated. E.g. apply authorization role based validation. * The validation is applied during the extended validation phase. @@ -100,38 +158,125 @@ export type GenericAuthPluginOptions< mode: 'protect-granular'; /** * Overrides the default directive name or extension field for marking a field available only for authorized users. - * @default auth + * @default authenticated */ - directiveOrExtensionFieldName?: 'auth' | string; + authDirectiveName?: 'authenticated' | string; /** * Customize how the user is validated. E.g. apply authorization role based validation. * The validation is applied during the extended validation phase. * @default `defaultProtectSingleValidateFn` */ validateUser?: ValidateUserFn; + /** + * Reject on unauthenticated requests. + * @default true + */ + rejectUnauthenticated?: boolean; } ); +export function createUnauthenticatedError(params?: { + fieldNode?: FieldNode; + path?: ReadonlyArray; + message?: string; + statusCode?: number; +}) { + return createGraphQLError(params?.message ?? 'Unauthorized field or type', { + nodes: params?.fieldNode ? [params.fieldNode] : undefined, + path: params?.path, + extensions: { + code: 'UNAUTHORIZED_FIELD_OR_TYPE', + http: { + status: params?.statusCode ?? 401, + }, + }, + }); +} + export function defaultProtectAllValidateFn( params: ValidateUserFnParams, -): void | UnauthenticatedError { - if (params.user == null && !params.fieldAuthDirectiveNode && !params.fieldAuthExtension) { - const schemaCoordinate = `${params.objectType.name}.${params.fieldNode.name.value}`; - return new UnauthenticatedError(`Accessing '${schemaCoordinate}' requires authentication.`, [ - params.fieldNode, - ]); +): void | GraphQLError { + if (params.user == null && !params.fieldAuthArgs && !params.typeAuthArgs) { + return createUnauthenticatedError({ + fieldNode: params.fieldNode, + path: params.path, + }); + } + return validateScopesAndPolicies(params); +} + +function areRolesValid(requiredRoles: string[][], userRoles: string[]) { + for (const roles of requiredRoles) { + if (roles.every(role => userRoles.includes(role))) { + return true; + } + } + return false; +} + +function validateRoles( + params: ValidateUserFnParams, + requiredRoles: string[][], + userRoles: string[], +): void | GraphQLError { + if (!areRolesValid(requiredRoles, userRoles)) { + return createUnauthenticatedError({ + fieldNode: params.fieldNode, + path: params.path, + }); } } +function validateScopesAndPolicies( + params: ValidateUserFnParams, +): void | GraphQLError { + if (params.typeScopes) { + const error = validateRoles(params, params.typeScopes, params.userScopes); + if (error) { + return error; + } + } + if (params.typePolicies?.length) { + const error = validateRoles(params, params.typePolicies, params.userPolicies); + if (error) { + return error; + } + } + if (params.fieldScopes?.length) { + const error = validateRoles(params, params.fieldScopes, params.userScopes); + if (error) { + return error; + } + } + if (params.fieldPolicies?.length) { + const error = validateRoles(params, params.fieldPolicies, params.userPolicies); + if (error) { + return error; + } + } +} export function defaultProtectSingleValidateFn( params: ValidateUserFnParams, -): void | UnauthenticatedError { - if (params.user == null && (params.fieldAuthDirectiveNode || params.fieldAuthExtension)) { - const schemaCoordinate = `${params.objectType.name}.${params.fieldNode.name.value}`; - return new UnauthenticatedError(`Accessing '${schemaCoordinate}' requires authentication.`, [ - params.fieldNode, - ]); +): void | GraphQLError { + if (params.user == null && (params.fieldAuthArgs || params.typeAuthArgs)) { + return createUnauthenticatedError({ + fieldNode: params.fieldNode, + path: params.path, + }); } + return validateScopesAndPolicies(params); +} + +export function defaultExtractScopes(user: UserType): string[] { + if (user != null && typeof user === 'object' && 'scope' in user) { + if (typeof user.scope === 'string') { + return user.scope.split(' '); + } + if (Array.isArray(user.scope)) { + return user.scope; + } + } + return []; } export const useGenericAuth = < @@ -148,74 +293,158 @@ export const useGenericAuth = < const contextFieldName = options.contextFieldName || 'currentUser'; if (options.mode === 'protect-all' || options.mode === 'protect-granular') { - const directiveOrExtensionFieldName = - options.directiveOrExtensionFieldName ?? - (options.mode === 'protect-all' ? 'skipAuth' : 'auth'); + const authDirectiveName = + options.authDirectiveName ?? (options.mode === 'protect-all' ? 'skipAuth' : 'authenticated'); + const requiresScopesDirectiveName = options.scopesDirectiveName ?? 'requiresScopes'; + const policyDirectiveName = options.policyDirectiveName ?? 'policy'; const validateUser = options.validateUser ?? (options.mode === 'protect-all' ? defaultProtectAllValidateFn : defaultProtectSingleValidateFn); - const extractAuthMeta = ( - input: GraphQLField, - ): { fieldAuthDirectiveNode: DirectiveNode | undefined; fieldAuthExtension: unknown } => { - return { - fieldAuthExtension: input.extensions?.[directiveOrExtensionFieldName], - fieldAuthDirectiveNode: input.astNode?.directives?.find( - directive => directive.name.value === directiveOrExtensionFieldName, - ), - }; - }; + const extractScopes = options.extractScopes ?? defaultExtractScopes; + const rejectUnauthenticated = + 'rejectUnauthenticated' in options ? options.rejectUnauthenticated !== false : true; + + const policiesByContext = new WeakMap(); return { onPluginInit({ addPlugin }) { addPlugin( useExtendedValidation({ + rejectOnErrors: rejectUnauthenticated, rules: [ function AuthorizationExtendedValidationRule(context, args) { const user = (args.contextValue as any)[contextFieldName]; - const handleField = (fieldNode: FieldNode, objectType: GraphQLObjectType) => { - const field = objectType.getFields()[fieldNode.name.value]; + const handleField = ( + { + node: fieldNode, + path, + }: { + node: FieldNode; + key: string | number | undefined; + parent: ASTNode | readonly ASTNode[] | undefined; + path: readonly (string | number)[]; + ancestors: readonly (ASTNode | readonly ASTNode[])[]; + }, + parentType: GraphQLInterfaceType | GraphQLObjectType, + ) => { + const field = parentType.getFields()[fieldNode.name.value]; if (field == null) { // field is null/undefined if this is an introspection field return; } - const { fieldAuthExtension, fieldAuthDirectiveNode } = extractAuthMeta(field); - const error = validateUser({ + const schema = context.getSchema(); + // @ts-expect-error - Fix this + const typeDirectives = parentType && getDirectiveExtensions(parentType, schema); + const typeAuthArgs = typeDirectives[authDirectiveName]?.[0]; + const typeScopes = typeDirectives[requiresScopesDirectiveName]?.[0]?.scopes; + const typePolicies = typeDirectives[policyDirectiveName]?.[0]?.policies; + const fieldDirectives = getDirectiveExtensions(field, schema); + const fieldAuthArgs = fieldDirectives[authDirectiveName]?.[0]; + const fieldScopes = fieldDirectives[requiresScopesDirectiveName]?.[0]?.scopes; + const fieldPolicies = fieldDirectives[policyDirectiveName]?.[0]?.policies; + const userScopes = extractScopes(user); + const userPolicies = + policiesByContext.get(args.contextValue as unknown as ContextType) ?? []; + + const resolvePath: (string | number)[] = []; + + let curr: any = args.document; + const operationAST = getOperationAST(args.document, args.operationName); + const operationType = operationAST?.operation ?? OperationTypeNode.QUERY; + let currType: GraphQLOutputType | undefined | null = + args.schema.getRootType(operationType); + for (const pathItem of path) { + curr = curr[pathItem]; + if (curr?.kind === 'Field') { + const fieldName = curr.name.value; + const responseKey = curr.alias?.value ?? fieldName; + let field: GraphQLField | undefined; + if (isObjectType(currType)) { + field = currType.getFields()[fieldName]; + } else if (isAbstractType(currType)) { + for (const possibleType of schema.getPossibleTypes(currType)) { + field = possibleType.getFields()[fieldName]; + if (field) { + break; + } + } + } + if (isListType(field?.type)) { + resolvePath.push('@'); + } + resolvePath.push(responseKey); + if (field?.type) { + currType = getNamedType(field.type); + } + } + } + + return validateUser({ user, fieldNode, - objectType, - fieldAuthDirectiveNode, - fieldAuthExtension, + parentType, + typeScopes, + typePolicies, + typeAuthArgs, + typeDirectives, executionArgs: args, + field, + fieldDirectives, + fieldAuthArgs, + fieldScopes, + fieldPolicies, + userScopes, + path: resolvePath, + userPolicies, }); - if (error) { - context.reportError(error); - } }; return { - Field(node) { + Field(node, key, parent, path, ancestors) { if (!shouldIncludeNode(args.variableValues, node)) { return; } const fieldType = getNamedType(context.getParentType()!); if (isIntrospectionType(fieldType)) { - return false; + return node; } - if (isObjectType(fieldType)) { - handleField(node, fieldType); - } else if (isUnionType(fieldType)) { + if (isUnionType(fieldType)) { for (const objectType of fieldType.getTypes()) { - handleField(node, objectType); + const error = handleField( + { + node, + key, + parent, + path, + ancestors, + }, + objectType, + ); + if (error) { + context.reportError(error); + return null; + } } - } else if (isInterfaceType(fieldType)) { - for (const objectType of args.schema.getImplementations(fieldType).objects) { - handleField(node, objectType); + } else if (isObjectType(fieldType) || isInterfaceType(fieldType)) { + const error = handleField( + { + node, + key, + parent, + path, + ancestors, + }, + fieldType, + ); + if (error) { + context.reportError(error); + return null; } } return undefined; @@ -228,9 +457,17 @@ export const useGenericAuth = < }, async onContextBuilding({ context, extendContext }) { const user = await options.resolveUserFn(context as unknown as ContextType); - extendContext({ - [contextFieldName]: user, - } as any); + if (options.extractPolicies) { + const policies = await options.extractPolicies(user!, context as unknown as ContextType); + policiesByContext.set(context as unknown as ContextType, policies); + } + // @ts-expect-error - Fix this + if (context[contextFieldName] !== user) { + // @ts-expect-error - Fix this + extendContext({ + [contextFieldName]: user, + }); + } }, }; } diff --git a/packages/plugins/generic-auth/tests/use-generic-auth.spec.ts b/packages/plugins/generic-auth/tests/use-generic-auth.spec.ts index f7c9c068f..dd339f075 100644 --- a/packages/plugins/generic-auth/tests/use-generic-auth.spec.ts +++ b/packages/plugins/generic-auth/tests/use-generic-auth.spec.ts @@ -1,13 +1,11 @@ -import { EnumValueNode, FieldNode, getIntrospectionQuery } from 'graphql'; +import { getIntrospectionQuery } from 'graphql'; import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; -import { Maybe } from '@envelop/types'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { shouldIncludeNode } from '@graphql-tools/utils'; +import { createGraphQLError } from '@graphql-tools/utils'; import { DIRECTIVE_SDL, ResolveUserFn, SKIP_AUTH_DIRECTIVE_SDL, - UnauthenticatedError, useGenericAuth, ValidateUserFn, } from '../src/index.js'; @@ -282,7 +280,7 @@ describe('useGenericAuth', () => { DIRECTIVE_SDL, /* GraphQL */ ` type Query { - protected: String @auth + protected: String @authenticated public: String } `, @@ -360,35 +358,53 @@ describe('useGenericAuth', () => { const result = await testInstance.execute(`query { protected }`); assertSingleExecutionValue(result); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe( - `Accessing 'Query.protected' requires authentication.`, + expect(result.errors?.[0].message).toBe(`Unauthorized field or type`); + }); + + it('Should prevent field execution when user is not authenticated correctly but continue execution for public fields', async () => { + const testInstance = createTestkit( + [ + useGenericAuth({ + mode: 'protect-granular', + resolveUserFn: invalidresolveUserFn, + rejectUnauthenticated: false, + }), + ], + schemaWithDirective, ); + + const result = await testInstance.execute(`query { public protected }`); + assertSingleExecutionValue(result); + expect(result).toMatchObject({ + data: { + public: 'public', + protected: null, + }, + errors: [ + { + message: `Unauthorized field or type`, + path: ['protected'], + }, + ], + }); }); describe('auth directive with role', () => { type UserTypeWithRole = UserType & { role: 'ADMIN' | 'USER' }; const validateUserFn: ValidateUserFn = params => { - const schemaCoordinate = `${params.objectType.name}.${params.fieldNode.name.value}`; + const schemaCoordinate = `${params.parentType.name}.${params.fieldNode.name.value}`; if (!params.user) { - return new UnauthenticatedError( - `Accessing '${schemaCoordinate}' requires authentication.`, - [params.fieldNode], - ); + return createGraphQLError(`Accessing '${schemaCoordinate}' requires authentication.`, { + nodes: [params.fieldNode], + }); } - if (params.fieldAuthDirectiveNode?.arguments) { - const valueNode = params.fieldAuthDirectiveNode.arguments.find( - arg => arg.name.value === 'role', - )?.value as EnumValueNode | undefined; - if (valueNode) { - const role = valueNode.value; - - if (role !== params.user.role) { - return new UnauthenticatedError( - `Missing permissions for accessing field '${schemaCoordinate}'. Requires role '${role}'. Request is authenticated with role '${params.user.role}'.`, - [params.fieldNode], - ); - } + if (params.fieldAuthArgs) { + if (params.fieldAuthArgs.role !== params.user.role) { + return createGraphQLError( + `Missing permissions for accessing field '${schemaCoordinate}'. Requires role '${params.fieldAuthArgs.role}'. Request is authenticated with role '${params.user.role}'.`, + { nodes: [params.fieldNode] }, + ); } } @@ -418,11 +434,11 @@ describe('useGenericAuth', () => { USER } - directive @auth(role: Role! = USER) on FIELD_DEFINITION + directive @authenticated(role: Role! = USER) on FIELD_DEFINITION type Query { - protected: String @auth - admin: String @auth(role: ADMIN) + protected: String @authenticated + admin: String @authenticated(role: ADMIN) public: String } `, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0a1f9f0c..65e7daa1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,8 +956,8 @@ importers: specifier: ^4.0.0 version: 4.0.0(@envelop/core@packages+core+dist)(graphql@16.6.0) '@graphql-tools/utils': - specifier: ^10.0.6 - version: 10.0.6(graphql@16.6.0) + specifier: ^10.5.1 + version: 10.5.1(graphql@16.6.0) tslib: specifier: ^2.5.0 version: 2.5.0 @@ -3160,8 +3160,8 @@ packages: peerDependencies: graphql: 16.6.0 - '@graphql-tools/utils@10.0.6': - resolution: {integrity: sha512-hZMjl/BbX10iagovakgf3IiqArx8TPsotq5pwBld37uIX1JiZoSbgbCIFol7u55bh32o6cfDEiiJgfAD5fbeyQ==} + '@graphql-tools/utils@10.5.0': + resolution: {integrity: sha512-TtdmI5nKMl7QKWENudj7MnaE1skH9y7x2XuG//kHzIox9c6Q8Z2QiWoeD6EuDxCZ4nkRoQpn/YT7kT8FoaV/xQ==} engines: {node: '>=16.0.0'} peerDependencies: graphql: 16.6.0 @@ -13412,9 +13412,10 @@ snapshots: graphql: 16.6.0 tslib: 2.5.0 - '@graphql-tools/utils@10.0.6(graphql@16.6.0)': + '@graphql-tools/utils@10.5.0(graphql@16.6.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) + cross-inspect: 1.0.1 dset: 3.1.2 graphql: 16.6.0 tslib: 2.6.2