aerogear / keycloak-connect-graphql

Add Keyloak Authentication and Authorization to your GraphQL server.
Apache License 2.0
155 stars 22 forks source link

Apollo server express v3.0.0 + not working with keycloak-connect-graphql #127

Closed jsfeutz closed 2 years ago

jsfeutz commented 3 years ago

Has anyone gotten keycloak-connect-graphql working with apollo-server-express 3.0.0. It seems to only work up to apollo-server-express@2.25.2

wtrocki commented 3 years ago

Thank you for logging issue.

Do you have more info what doesnt work? We could start with updating example and add support for it.

My take is that we should be good when using graphql-tools to build schema and then pass schema to apollo server (no matter version)

Let me know if that works for you.

jsfeutz commented 3 years ago

Thanks for responding :) Let me know what I'm doing wrong.

It seems like apollo-connect-express completely ignores the directives @auth, @hasRole when I upgrade to apollo v3

To replicate what works: 1) Take the vanilla instructions from the keycloak-connect-graphql readme. install apollo-connect-express@2.25.2 or earlier adding a @auth, @hasRole, to a query/mutation results in "user authentication required" message when authorization header not provided.
what doesnt work:
2) Changing only the version of apollo-connect-express@3.0.0+ few new modifications on how apollo server 3 needs to be started. keycloak-connect-graphql seems to be ignored and you can access the query/mutations that have @auth/@hasRole set.

 `      //apollo v 2.25.2, cant move to v3 until we fix keycloak integrations. 
  const server = new ApolloServer({
        // plugins: [ApolloServerPluginInlineTrace()], //v3 option
        typeDefs: mergeTypeDefs([...Globals.TypeDefs, ServiceModule.typeDefs]),
        schemaDirectives: KeycloakSchemaDirectives,
        resolvers: ServiceModule.resolvers,
        context: async({ req, connection }) => {
              return {
                    kauth: new KeycloakContext({ req }, keycloak) //add the KeycloakContext to `kauth`
              }
        },
  });

  //await server.start() //v3 start 
  server.applyMiddleware({ app, path: URI_GQL })

  const httpServer = http.createServer(app)
  //server.installSubscriptionHandlers(httpServer);

  // ⚠️ Pay attention to the fact that we are calling `listen` on the http server variable, and not on `app`.
  httpServer.listen(HTTP_PORT, () => {
        log.info(`🚀 LOG_LEVEL=${process.env.LOG_LEVEL}: Server ready at http://0.0.0.0:${HTTP_PORT}${server.graphqlPath}`)
        log.info(`🚀 LOG_LEVEL=${process.env.LOG_LEVEL}: Subscriptions ready at ws://0.0.0.0:${HTTP_PORT}${server.subscriptionsPath}`)
  })`
wtrocki commented 3 years ago

Yes. Because Apollo 3.0 has it is own ways of building schema and support directives (to support federation etc). We are using graphql-tools that is vendor agnositc so it works with graphql-express and apollo - assuming that we pass schema to the apollo server.

wtrocki commented 3 years ago

Actually this is issue with readme that doesn't seem to be using GraphQL tools. All we recommend is graphql server express that stopped be compatible with tools from 3.x

https://github.com/aerogear/keycloak-connect-graphql

We should update docs how to use graphql tools.

wtrocki commented 3 years ago

Overal we could update project to work with graphql express (and stop supporting graphql-tools).

Place that causes issues: https://github.com/aerogear/keycloak-connect-graphql/blob/228ec2bfca66644b5168a07a6d2db025701afc1f/src/directives/index.ts#L3

Format is different for the 3.0

jsfeutz commented 3 years ago

So new config using makeExecutableSchema and passing just a schema to apollo 3.1.2 or 2.25.2. I added console.log markers in keycloak-connect-graphql at KeyContext and KeyContextBase. From the log, Keycontext nor KeyContextBase ever get the token from the authorization header. Not sure if I'm not setting up the Keycloak context correctly. It allows access to the query/mutation. code ``
const { makeExecutableSchema } = require('@graphql-tools/schema'); const { mergeTypeDefs } = require('@graphql-tools/merge'); const Keycloak = require('keycloak-connect') const { KeycloakTypeDefs, KeycloakContext, KeycloakSchemaDirectives } = require('keycloak-connect-graphql')

  let schema = makeExecutableSchema({
        typeDefs: mergeTypeDefs([KeycloakTypeDefs, ServiceModule.typeDefs, ...Globals.TypeDefs]),
        resolvers: [ServiceModule.resolvers], // optional
        allowUndefinedInResolve: false, // optional
        resolverValidationOptions: {}, // optional
        schemaDirectives: KeycloakSchemaDirectives, // optiona
        parseOptions: {}, // optional
        inheritResolversFromInterfaces: false // optional
  });

  //apollo v 3.1.2 config
  const server = new ApolloServer({
        schema: schema,
        context: async({ req, connection }) => {
             console.log("Apollo Server Context: Calling Context.")
              console.log("Authorization Header::")
              console.log(req.headers.authorization)
              return {
                    kauth: new KeycloakContext({ req }, keycloak)  //or new KeycloakContext({ req }) same result
              }
        },
  });

``

log:: `Apollo Server Context: Calling Context.

Authorization Header::

Bearer eyJhbGciOiJSU#####

Calling Keycloak Context Constructor

Token Not Found:

calling KeycloakBase

undefined

Keycloak Object found`

wtrocki commented 3 years ago

This issue is definitely something that needs to be addressed by example and code. I have no capacity to check it this week, however i think you are close to success without it

As for error this happens when code is missing: https://github.com/aerogear/keycloak-connect-graphql/blob/master/examples/lib/common.js#L24

for blocking unauthenticated access

https://github.com/aerogear/keycloak-connect-graphql/blob/master/examples/basic.js#L19

fynncfchen commented 3 years ago

Here's my solution using Apollo 3 with graphql-modules:

  1. Transform string KeycloakTypeDefs into AST: gpl(KeycloakTypeDefs).
  2. Import and rewrite transformers with latest syntax of @graphql-tools/utils instead of using KeycloakSchemaDirectives.
  3. Transform schema.
  4. Apply directive in your GraphQL schema
  5. Send request with Authorization: Bearer xxx header in client side.

The main part of this solution is replacing KeycloakSchemaDirectives with our own transformers, we can found some clue from the example in @graphql-tools/utils:

const transformer = (schema, directiveName = 'auth') =>
  mapSchema(schema, {
    [MapperKind.FIELD]: (fieldConfig) => {
      // Check whether this field has the specified directive
      const [directive] =
        getDirective(schema, fieldConfig, directiveName) || [];
      if (directive) {
        // Get this field's original resolver
        const { resolve = defaultFieldResolver } = fieldConfig;
        // Replace the original resolver with a function that *first* calls
        // the original resolver, then converts its result
        fieldConfig.resolve = async (source, args, context, info) => {
          const result = await resolve(source, args, context, info);
          // TODO: Convert the result here
          return result;
        };
      }
    },
  });

Full example below:

In graphql/Keycloak.mjs: I copied some content of the schemaDirectiveVisitors.ts

import { gql, createModule } from 'graphql-modules';

import { defaultFieldResolver } from 'graphql';
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

import {
  KeycloakTypeDefs,
  auth as resolveAuth,
  hasRole as resolveHasRole,
  hasPermission as resolveHasPermission,
} from 'keycloak-connect-graphql';

export const authDirectiveTransformer = (schema, directiveName = 'auth') =>
  mapSchema(schema, {
    [MapperKind.FIELD]: (fieldConfig) => {
      const [directive] =
        getDirective(schema, fieldConfig, directiveName) || [];
      if (directive) {
        // AuthDirective.visitFieldDefinition
        const { resolve = defaultFieldResolver } = fieldConfig;
        fieldConfig.resolve = resolveAuth(resolve);
      }
    },
  });

// HasRoleDirective.parseAndValidateArgs
function parseAndValidateRoles(args) {
  const keys = Object.keys(args);
  if (keys.length === 1 && keys[0] === 'role') {
    const role = args[keys[0]];
    if (typeof role === 'string') return [role];
    if (Array.isArray(role)) return role.map((val) => String(val));
    throw new Error(
      `invalid hasRole args. role must be a String or an Array of Strings`
    );
  }
  throw Error("invalid hasRole args. must contain only a 'role argument");
}
export const hasRoleDirectiveTransformer = (
  schema,
  directiveName = 'hasRole'
) =>
  mapSchema(schema, {
    [MapperKind.FIELD]: (fieldConfig) => {
      const [directive] =
        getDirective(schema, fieldConfig, directiveName) || [];
      if (directive) {
        // HasRoleDirective.visitFieldDefinition
        const { resolve = defaultFieldResolver } = fieldConfig;
        const roles = parseAndValidateRoles(directive);
        fieldConfig.resolve = resolveHasRole(roles)(resolve);
      }
    },
  });

// HasPermissionDirective.parseAndValidateArgs
function parseAndValidateResources(args) {
  const keys = Object.keys(args);
  if (keys.length === 1 && keys[0] === 'resources') {
    const resources = args[keys[0]];
    if (typeof resources === 'string') return [resources];
    if (Array.isArray(resources)) return resources.map((val) => String(val));
    throw new Error(
      `invalid hasPermission args. resources must be a String or an Array of Strings`
    );
  }
  throw Error(
    "invalid hasPermission args. must contain only a 'resources argument"
  );
}
export const hasPermissionDirectiveTransformer = (
  schema,
  directiveName = 'hasPermission'
) =>
  mapSchema(schema, {
    [MapperKind.FIELD]: (fieldConfig) => {
      const [directive] =
        getDirective(schema, fieldConfig, directiveName) || [];
      if (directive) {
        // HasPermissionDirective.visitFieldDefinition
        const { resolve = defaultFieldResolver } = fieldConfig;
        const resources = parseAndValidateResources(directive);
        fieldConfig.resolve = resolveHasPermission(resources)(resolve);
      }
    },
  });

export default createModule({
  id: 'keycloak',
  dirname,
  typeDefs: gql(KeycloakTypeDefs), // Transform string to AST
});

The graphql/Application.mjs:

import { createApplication } from 'graphql-modules';

import keycloak from './Keycloak.mjs';

const application = createApplication({
  modules: [
    // ...other modules
    keycloak,
  ],
});

export default application;

and the apollo.mjs which provide the main server functionality:

import { ApolloServer } from 'apollo-server-express';

import { KeycloakContext } from 'keycloak-connect-graphql';

import { compose } from './utils.mjs';

import keycloak from './keycloak.mjs';

import graphqlApp from './graphql/Application.mjs';
import {
  authDirectiveTransformer,
  hasRoleDirectiveTransformer,
  hasPermissionDirectiveTransformer,
} from './graphql/Keycloak.mjs';

const schema = graphqlApp.createSchemaForApollo();

const context = ({ req }) => ({
  kauth: new KeycloakContext({ req }, keycloak),
});

// Here I use a compose utility to avoid nesting functions
// authDirectiveTransformer(hasRoleDirectiveTransformer(hasPermissionDirectiveTransformer(schema)))
const composedSchema = compose(
  authDirectiveTransformer,
  hasRoleDirectiveTransformer,
  hasPermissionDirectiveTransformer
)(schema);

export default async function start({ app }) {
  const server = new ApolloServer({
    schema: composedSchema,
    context,
  });
  await server.start();

  server.applyMiddleware({ app });
}

The compose utility is a simple reduce function:

export const compose =
  (...fns) =>
  (x) =>
    fns.reduceRight((v, f) => f(v), x);

Hope it can help, thanks.

cjohn001 commented 2 years ago

Would be great if you guys could add a full example. I unfortuanetly do not understand the sketched solution but cope with the same problem.

BigBallard commented 2 years ago

We are about two months since any activity and this is still an issue with no realistic examples that doesn't require copy pasta and workarounds. Are there going to be any changes or documentation updates for Apollo 3?

wtrocki commented 2 years ago

Looking for volunteers to help with maintenance. Reviewed @fynncfchen solution here and it looks perfect! I guess we need to create example for that https://github.com/aerogear/keycloak-connect-graphql/issues/127#issuecomment-907741906

BigBallard commented 2 years ago

Ya I can probably help out a bit. I pulled last night and actually started creating transformers and was going to do a PR lol

BigBallard commented 2 years ago

@wtrocki is it ok to create a PR without unit tests and get some advice on how to implement them for my additions? I have not done unit testing in js/ts before...

wtrocki commented 2 years ago

No unit tests needed for examples. Anything that will let me to test and review change will be great.

BigBallard commented 2 years ago

Well doesn't look like the transformers will work with this library since apollo-server-express v3 doesnt have SchemaDirectiveVisitor class. Should have figured that before hand, so no PR. Will have to be a workaround like above.

BigBallard commented 2 years ago

@wtrocki added PR

BigBallard commented 2 years ago

@wtrocki I think we can close this issue now that we have documentation on getting things to work with v3. In the meantime, what are your thoughts on moving this library to provide v3 support out of the lib? I would definitely volunteer to be a maintainer as well.