Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document how authentication guards for GraphQL Subscriptions work #394

Closed
lorenzleutgeb opened this issue Apr 30, 2019 · 23 comments
Closed

Comments

@lorenzleutgeb
Copy link
Contributor

lorenzleutgeb commented Apr 30, 2019

nestjs/graphql#84 (comment) suggests that with Nest 6 it is now possible to use Authentication Guards in combination with GraphQL Subscriptions. It is unclear how this was implemented and also how the API now looks like. Please document it.

@scomans
Copy link

scomans commented Jun 12, 2019

@kamilmysliwiec Can you give us a hint on how to use it?

@lorenzleutgeb
Copy link
Contributor Author

lorenzleutgeb commented Jun 17, 2019

We're having some success for authentication in combination with guards and subscriptions:

GraphQLModule.forRoot({
  context: ({ req, connection }) => connection ? { req: connection.context } : { req }
  ...
})

On the client-side, to configure Apollo client:

connectionParams = () => {
  return {
    isWebSocket: true,
    headers: {
      authorization: `Bearer ${authService.getBearerToken()}`,
    }
  }
}

This will successfully authenticate and then authorize through guards. The problem is that we don't know whether this is intended, and therefore whether we should depend on this method to work in the future. The only way we can set this straight is by obtaining some information from the Nest team, and one good way would be through documentation.

/cc @madfist

@esistgut
Copy link

esistgut commented Jul 10, 2019

As I am using an auth schema based on http-bearer, to make it work I had to change the context to:

context: ({ req, connection }) => connection ? { req: { headers: connection.context } } : { req },

@solidarynetwork
Copy link

hello

After some struggle to put my auth guard with apollo subscriptions, I get it with the help of this issue and other info, I leave here the changes that I made in my AppModule, GqlContext, GqlAuthGuard maybe can be useful for other

1. Add connection to GqlContext

gql-context.ts

export interface GqlContext {
  req: Request;
  res: Response;
  payload?: GqlContextPayload;
  // required for subscription
  connection: any;
}

2. change AppModule with subscriptions and forRootAsync

here we must inject AuthModule/AuthService to check Authorization headers JWT, for this we must replace GraphQLModule.forRoot with GraphQLModule.forRootAsync and import AuthModule with imports: [AuthModule], and inject service with inject: [AuthService]

app.module.ts

@Module({
  imports: [
    AuthModule,
    UsersModule,
    // chaincode modules
    ParticipantModule,
    PersonModule,
    CauseModule,
    TransactionModule,
    // apolloServer config: use forRootAsync to import AuthModule and inject AuthService
    GraphQLModule.forRootAsync({
      // import AuthModule
      imports: [AuthModule],
      // inject authService
      useFactory: async (authService: AuthService) => ({
        debug: true,
        playground: true,
        installSubscriptionHandlers: true,
        autoSchemaFile: 'schema.gql',
        // pass the original req and res object into the graphql context,
        // get context with decorator `@Context() { req, res, payload, connection }: GqlContext`
        // req, res used in http/query&mutations, connection used in webSockets/subscriptions
        context: ({ req, res, payload, connection }: GqlContext) => ({ req, res, payload, connection }),
        // configure graphql cors here
        cors: {
          origin: e.corsOriginReactFrontend,
          credentials: true,
        },
        // subscriptions/webSockets authentication
        subscriptions: {
          // get headers
          onConnect: (connectionParams: ConnectionParams) => {
            // convert header keys to lowercase
            const connectionParamsLowerKeys = mapKeysToLowerCase(connectionParams);
            // get authToken from authorization header
            const authToken: string = ('authorization' in connectionParamsLowerKeys)
              && connectionParamsLowerKeys.authorization.split(' ')[1];
            if (authToken) {
              // verify authToken/getJwtPayLoad
              const jwtPayload: GqlContextPayload = authService.getJwtPayLoad(authToken);
              // the user/jwtPayload object found will be available as context.currentUser/jwtPayload in your GraphQL resolvers
              return { currentUser: jwtPayload.username, jwtPayload, headers: connectionParamsLowerKeys };
            }
            throw new AuthenticationError('authToken must be provided');
          },
        },
      }),
      // inject: AuthService
      inject: [AuthService],
    }),
  ],
})

3. Change GqlAuthGuard to return subscription connection.context.headers to passport-jwt service

gql-auth.guard.ts

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {

  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    // req used in http queries and mutations, connection is used in websocket subscription connections, check AppModule
    const { req, connection } = ctx.getContext();

    // if subscriptions/webSockets, let it pass headers from connection.context to passport-jwt
    return (connection && connection.context && connection.context.headers)
      ? connection.context
      : req;
  }
}

now use it in subscription

  @UseGuards(GqlAuthGuard)
  @Subscription(returns => Person)
  personAdded() {
    return pubSub.asyncIterator('personAdded');
  }

fire subscription with graphql playground with header

{
  "Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ....",
}

@BatuhanW
Copy link

BatuhanW commented Feb 6, 2020

@kamilmysliwiec Any thoughts?

@greenreign
Copy link

greenreign commented Feb 24, 2020

Any ideas on this subject? The above example by @solidarynetwork is nice but it's very specific to their implementation and IMO not really applicable in general. I would love to know how to make guards work in general for Subscriptions.
As-in make an existing guard - one used for Queries and Mutations - also work from subscriptions.

Not how to make a new guard-like thing inside my GQL configuration.

@madfist
Copy link

madfist commented Feb 24, 2020

Guards for Subscriptions works differently for Nest5 and Nest6. For Nest5 I recommend something like @solidarynetwork 's solution.
Nest6 puts the Subscription connections through the same guards and interceptors as all your other requests, so it is enough to extract the context from the connection and define the authorization header on the client-side connectionParams like @lorenzleutgeb wrote and in the Guard redefine getRequest(context: ExecutionContext) to get the graphql context like in the documentation: const ctx = GqlExecutionContext.create(context);
If you "fake" the authorization header in the subscription connection params and have your context unpacked, passport will be able to authenticate your websocket connection. (Quick note thought: last time I checked you can't set this information properly in graphiql)

@luqmanoop
Copy link

Can't seem to get this working with my custom @CurrentUser() decorator

@greenreign
Copy link

@madfist Is there something required from the client side? I see @lorenzleutgeb has mentioned additional client configuration including
isWebSocket: true,. I'm using the graphql playground. I don't see anything request-like anywhere in the context.connection.

I'm using nest 7.0.7.

@greenreign
Copy link

In fact I don't think any of these solutions are actually getting an IncomingMessage from a subscription query. They are picking off headers and setting them into the context.
I found my headers in context.connection.context?? Unfortunately, there is nothing else resembling an IncomingMessage. I could work around that except that I also need access to my variables in the request and those are nowhere to be found.
Beginning to work around all this:

    context: (context: Context): GraphqlContext => {
        console.log(context);
        if (!_.isNil(context.connection)) {
            return { request: { headers: context.connection.context, body: { variables: context.connection.variables } } };
        }
        return { request: context.req };
    },

Here is my context object:

{ connection:
   { query:
      'subscription {\n  threadMessageCreated(input: {threadId: "0934a5e7-906b-4ec6-a321-e8a009bb4fa9"}) {\n    id\n  }\n}\n',
     variables: {},
     operationName: null,
     context:
      { Authorization: 'xxx',
        'x-coast-business-id': 1 },
     formatResponse: [Function],
     formatError: undefined,
     callback: undefined,
     schema:
      GraphQLSchema {
        __validationErrors: [],
        extensions: undefined,
        astNode: undefined,
        extensionASTNodes: [],
        __allowedLegacyNames: [],
        _queryType: Query,
        _mutationType: Mutation,
        _subscriptionType: Subscription,
        _directives: [Array],
        _typeMap: [Object],
        _possibleTypeMap: [Object: null prototype] {},
        _implementations: [Object: null prototype] {} } },

There is a variables attribute but it's empty and as you can see from the query my variables are not empty.

Is there some express middleware magic I should look into? The variables are clearly present when the subscription method is invoked. Do I need to look into wherever nest is getting that information?

@madfist
Copy link

madfist commented Apr 11, 2020

You won't get an IncomingMessage because subscriptions work on websocket not http(s), but since the init message needs to go through your guards, you need to make it look like an http request (at least authorization).

If you want to act upon the variables you can use the @Subscription(name, {filter: (payload, variables, context,info)=>bool, resolve:(payload, variables,context,info)=> any}) decorator in your resolver.

@greenreign
Copy link

The problem is that I need variables in my guard. filter and resolve both appear to be methods invoked when an event is published. I need to prevent a connection when a user requests a subscription to a resource they don't have access to. For queries and mutations I use a guard to determine if the user has the right to access the data they have requested. For an incoming subscription I don't know what they are requesting access to because it's in a variable which I can't see to retrieve in a guard for subscriptions.

@madfist
Copy link

madfist commented Apr 14, 2020

Can you try writing your subscription like this on the client-side? (I don't know if this format is required to get the variables, but I found it works like this for me)

{
  query: `subscription ($threadId: ID!) {
     threadMessageCreated(threadId: $threadId) { id }
  }`,
  variables: { threadId }
}

Otherwise even if they can make the connection you can later filter out messages to them, so they won't get unauthorized information.

My other suggestion would be to write your variables next to the authorization header. Whatever you write there on the client-side can be read in the guards.

@babakoto
Copy link

babakoto commented May 1, 2020

This solution worked for me but your client must sent authorization not Authorization in headers

context: ({ req, connection }) => connection ? { req: { headers: connection.context } } : { req },

Solution :
GraphQLModule.forRoot( { autoSchemaFile:'schema.gql', installSubscriptionHandlers: true, context: ({ req, connection }) => connection ? { connection: { headers:{ authorization:connection.context["Authorization"] ?connection.context["Authorization"] :connection.context["authorization"]} } }:{req} }), AuthModule, UsersModule, ],

NB: Express converte all headers to lowercase
https://github.com/babakoto/NestJs-Passport-JWT-TypeORM-Graphql/tree/master/src

@tombarton
Copy link

tombarton commented May 14, 2020

It's been a huge pain but thanks to all the comments in this thread, I was able to figure out how to pass authentication cookies through for a GraphQL subscription to my JWT guard. As a GraphQL subscription is just a websocket under the hood, I was able to pull the headers off the incoming websocket like so:

    GraphQLModule.forRootAsync({
      useFactory: async (configService: ConfigService) => ({
        autoSchemaFile: GQL_CONFIG.schemaPath,
        installSubscriptionHandlers: true,
        debug: isEnabled(configService.get('GRAPHQL_DEBUG_ENABLED')),
        playground: isEnabled(configService.get('GRAPHQL_PLAYGROUND_ENABLED'))
          ? GQL_CONFIG.playgroundConfig
          : false,
        // Set CORS here as well as it overrides root CORS settings.
        // @TODO: Move CORS config to shared location.
        cors: {
          origin: 'http://localhost:3000',
          credentials: true,
        },
        context: ({ req, res, payload, connection }) => ({
          req,
          res,
          payload,
          connection,
        }),
        subscriptions: {
          // @TODO: Improve typings.
          onConnect: (
            connectionParams: { [key: string]: any },
            websocket: { [key: string]: any }
          ) => {
            return { headers: websocket?.upgradeReq?.headers };
          },
        },
      }),
      inject: [ConfigService],
    }),

We return the headers from the onConnect function, which injects them into the connection.context property. After that we return all four properties as part of the GraphQL context.

In our guard, we then extract the req and connection properties from the execution context and return them as per @solidarynetwork's example. I found that I wasn't able to override the req property as per the other examples in Nest 7.0.0. The req property in the guard would always return undefined, regardless of what I did.

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    const { req, connection } = ctx.getContext();
    return connection?.context?.headers ? connection.context : req;
  }
}

At this point, the JWT strategy treats the headers in the exact same fashion as a standard GraphQL query/mutation and authenticates appropriately.

@razorness
Copy link

I am using cookie session with redis.

Here is a quick and dirty solution for NestJS v7.

Write an onConnect handler:

import {IncomingMessage, ServerResponse} from 'http';
import cookie from 'cookie';
import {JSONCookies, signedCookies} from 'cookie-parser';
import session from 'express-session';
import redis, {ClientOpts} from 'redis';
import passport from 'passport';

class Request extends IncomingMessage {
	cookies: { [ k: string ]: any };
	signedCookies: { [ k: string ]: any };
}

export interface Options {
	cookie?: {
		secret?: string
	}
	redis: ClientOpts;
	session: session.SessionOptions;
}

function graphQlWebsocketMiddleware(opts: Options = null) {

	const RedisStore        = require('connect-redis')(session),
		  redisClient       = redis.createClient({
			  host: opts.redis.host
		  }),
		  sessionMiddleware = session({
			  store: new RedisStore({ client: redisClient }),
			  ...opts.session
		  }),
		  passportInit      = passport.initialize(),
		  passportSession   = passport.session();

	return (connectionParams: { [ key: string ]: any }, ws: { [ key: string ]: any }): Promise<any> => {

		const req = ws?.upgradeReq as Request;
		if (!req) {
			return new Promise((resolve => resolve({})));
		}

		if (req.headers.cookie) {
			req.cookies = cookie.parse(req.headers.cookie);
			if (opts.cookie?.secret) {
				req.signedCookies = signedCookies(req.cookies, opts.cookie.secret);
				req.signedCookies = JSONCookies(req.signedCookies);
			}
		} else {
			req.cookies       = Object.create(null);
			req.signedCookies = Object.create(null);
		}

		const res = new ServerResponse(req);

		return new Promise<any>(resolve => {
			sessionMiddleware(req as any, res as any,
				() => passportInit(req, res,
					() => passportSession(req, res,
						() => resolve({ req })
					)
				)
			);
		});
	};

};

Init GraphQLModule:

GraphQLModule.forRoot({
		  ...conf.graphQl,
		  context      : ctx => ctx.connection ? { ...ctx, req: ctx.connection.context?.req } : ctx,
		  subscriptions: {
			  onConnect: graphQlWebsocketMiddleware({
				  redis  : {
					  host: conf.redis.host
				  },
				  session: {
					  name             : conf.session.name,
					  secret           : conf.session.secret,
					  resave           : false,
					  saveUninitialized: false,
					  rolling          : true,
					  cookie           : {
						  secure: conf.session.cookie.secure,
						  maxAge: conf.session.cookie.maxAge
					  }
				  }
			  })
		  }
	  })

Then you can implement a session reload in your guard:

import {CanActivate, ExecutionContext, Injectable} from '@nestjs/common';
import {GqlContextType, GqlExecutionContext} from '@nestjs/graphql';

@Injectable()
export class AuthenticatedGuard implements CanActivate {

	async canActivate(context: ExecutionContext) {
		switch (context.getType<GqlContextType>()) {
			case 'graphql':
				const ctx = GqlExecutionContext.create(context).getContext();
				if (ctx.connection) {
					return new Promise<any>(resolve => ctx.req.session.reload(() => resolve(ctx.req?.isAuthenticated() || false)));
				}
				return ctx.req?.isAuthenticated() || false;
			case 'http':
				return context.switchToHttp().getRequest().isAuthenticated();

		}
	}

}

@Zony-Zhao
Copy link

Why there are so many ways to do this? @kamilmysliwiec Still not an official way to do this. It's a pain to try all this!

@otroboe
Copy link

otroboe commented Dec 14, 2020

Reading all of this, I don't know which way is the best 🤔

@hademo
Copy link

hademo commented Feb 19, 2021

We're having some success for authentication in combination with guards and subscriptions:

GraphQLModule.forRoot({
  context: ({ req, connection }) => connection ? { req: connection.context } : { req }
  ...
})

On the client-side, to configure Apollo client:

connectionParams = () => {
  return {
    isWebSocket: true,
    headers: {
      authorization: `Bearer ${authService.getBearerToken()}`,
    }
  }
}

This will successfully authenticate and then authorize through guards. The problem is that we don't know whether this is intended, and therefore whether we should depend on this method to work in the future. The only way we can set this straight is by obtaining some information from the Nest team, and one good way would be through documentation.

/cc @madfist

Worked Great. Make sure to set the "authorization" header in lower case, because the jwt passport strategy only parses lower case authorization This means that putting e.g. "Authorization: Bearer " will always be invalid, cause it parses by the property key of "authorization" and therefore it does not find the token. Another solution would be to convert all object keys of the connection context to lowercase.

@kamilmysliwiec
Copy link
Member

Let's track this here #1694

@jamesmeneghello
Copy link

Just for future reference, our solution looked like the below. We use passport-jwt, keycloak for authing, urql for gql client (but would work equally with Apollo client).

index.tsx - frontend:

const subscriptionClient = new SubscriptionClient(
  graphqlWsUrl,
  {
    reconnect: true,
    // whatever token works here is fine, we use keycloak
    // we use a function to resolve it because keycloak.token doesn't resolve immediately
    connectionParams: () => ({
      token: keycloak.token, 
    }),
  },
);

app.module.ts, imports:

GraphQLModule.forRoot({
      installSubscriptionHandlers: true,
      subscriptions: {
        onConnect: (connectionParams: any) => {
          const token = connectionParams.token;
          // the first few times urql tries to connect, keycloak hasn't got the token yet
          // returning false here will cause urql to try to connect again
          if (!token) {
            return false;
          }

          // this mocks the headers into the right place in the context
          // so none of the rest of our code has to change
          return { headers: { authorization: `Bearer ${token}` } };
        },
      },
      context: ({ req, res, connection }) =>
        connection
          ? {
              req: {
                ...req,
                ...connection.context,
              },
              res,
            }
          : { req, res },
    }),

Because we place the token in the correct place as though it came from a standard web request, passport-jwt, our GQL guards and user interceptors all work as normal without changes.

@dcal068
Copy link

dcal068 commented Aug 1, 2021

just made this changes, in your GqlGuard and it works for me.

  getRequest(context: ExecutionContext) {
    if (context.getType<ContextType | 'graphql'>() === 'graphql') {
      const ctx = GqlExecutionContext.create(context).getContext();

      // required for passport.js for websocket grapqhl subscriptions
      if (ctx.websocketHeader?.connectionParams) {
        const websocketHeader = ctx.websocketHeader?.connectionParams || {};

        return { headers: { ...websocketHeader } };
      }

      return ctx.req;
    }
    return context.switchToHttp().getRequest();
  }

@oldwolf
Copy link

oldwolf commented Oct 8, 2021

For those who are using graphql-ws package, you can do like this

context: (context) => {
  if (context?.extra?.request) {
    return {
      req: {
        ...context?.extra?.request,
        headers: {
          ...context?.extra?.request?.headers,
          ...context?.connectionParams,
        },
      },
    };
  }

  return { req: context?.req };
},

And for the GqlAuthGuard part, you may simply follow the nest.js documentation

@nestjs nestjs locked and limited conversation to collaborators Oct 11, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests