Skip to content

Commit

Permalink
fix(jwt/introspection): ability to disable introspection based on JWT…
Browse files Browse the repository at this point in the history
… result
  • Loading branch information
ardatan committed Jan 18, 2025
1 parent 2ef5f7d commit 0538f0a
Show file tree
Hide file tree
Showing 8 changed files with 210 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/funny-baboons-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-jwt': patch
---

Ensure the JWT context has been added before any GraphQL Execution hooks when the plugin is used via Yoga
26 changes: 26 additions & 0 deletions .changeset/tasty-seas-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@graphql-yoga/plugin-disable-introspection': minor
---

Expose the server context as the second parameter, so introspection can be disabled based on the
context

```ts "Disabling GraphQL schema introspection based on the context" {7}
import { createYoga } from 'graphql-yoga'
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection'

// Provide your schema
const yoga = createYoga({
graphiql: false,
plugins: [
useDisableIntrospection({
isDisabled: (_req, ctx) => !ctx.jwt,
})
]
})

const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})
```
9 changes: 4 additions & 5 deletions packages/plugins/disable-introspection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { NoSchemaIntrospectionCustomRule } from 'graphql';
import type { Plugin, PromiseOrValue } from 'graphql-yoga';

type UseDisableIntrospectionArgs = {
isDisabled?: (request: Request) => PromiseOrValue<boolean>;
isDisabled?: (request: Request, context: Record<string, unknown>) => PromiseOrValue<boolean>;
};

const store = new WeakMap<Request, boolean>();

export const useDisableIntrospection = (props?: UseDisableIntrospectionArgs): Plugin => {
const store = new WeakMap<Request, boolean>();
return {
async onRequest({ request }) {
const isDisabled = props?.isDisabled ? await props.isDisabled(request) : true;
async onRequestParse({ request, serverContext }) {
const isDisabled = props?.isDisabled ? await props.isDisabled(request, serverContext) : true;
store.set(request, isDisabled);
},
onValidate({ addValidationRule, context }) {
Expand Down
59 changes: 57 additions & 2 deletions packages/plugins/jwt/src/__tests__/jwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import { createHmac } from 'node:crypto';
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
import { createClient } from 'graphql-ws';
import { useServer } from 'graphql-ws/use/ws';
import { createSchema, createYoga, Plugin } from 'graphql-yoga';
import jwt, { Algorithm, SignOptions } from 'jsonwebtoken';
import WebSocket from 'ws';
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection';
import { useCookies } from '@whatwg-node/server-plugin-cookies';
import { JwtPluginOptions } from '../config';
import { useJWT } from '../plugin';
Expand Down Expand Up @@ -714,13 +716,66 @@ describe('jwt plugin', () => {
}
await client.dispose();
});

test('handles introspection query in case of conditional rejections', async () => {
const secret = 'topsecret';
const test = createTestServer(
{
signingKeyProviders: [createInlineSigningKeyProvider(secret)],
tokenLookupLocations: [
extractFromHeader({
name: 'Authorization',
prefix: 'Bearer',
}),
],
reject: {
missingToken: false,
invalidToken: false,
},
extendContext: 'jwt',
},
[],
[
useDisableIntrospection({
isDisabled(_request, context) {
if ('jwt' in context) {
return false;
}
return true;
},
}),
],
);
const token = buildJWT({ sub: '123' }, { key: secret }, '');
const resSuccessful = await test.yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({
query: getIntrospectionQuery(),
}),
headers: {
'content-type': 'application/json',
accept: 'application/json',
authorization: `Bearer ${token}`,
},
});
expect(resSuccessful.status).toBe(200);
const introspectionQueryResult = await resSuccessful.json();
expect(introspectionQueryResult.errors).toBeUndefined();
const introspection = introspectionQueryResult.data;
const schemaFromIntrospection = buildClientSchema(introspection);
expect(printSchema(schemaFromIntrospection)).toBe(printSchema(schema));
});
});

const createTestServer = (options: JwtPluginOptions, initPlugins: Plugin[] = []) => {
const createTestServer = (
options: JwtPluginOptions,
initPlugins: Plugin[] = [],
afterPlugins: Plugin[] = [],
) => {
const yoga = createYoga({
schema,
logging: !!process.env['DEBUG'],
plugins: [...initPlugins, useJWT(options)],
plugins: [...initPlugins, useJWT(options), ...afterPlugins],
});

return {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/jwt/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ type AtleastOneItem<T> = [T, ...T[]];

export type ExtractTokenFunctionParams = {
request: Request;
serverContext: Record<string, unknown>;
url: URL;
serverContext: Record<string, unknown>;
};

export type ExtractTokenFunction = (
Expand Down
79 changes: 54 additions & 25 deletions packages/plugins/jwt/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,42 +156,71 @@ export function useJWT(options: JwtPluginOptions): Plugin<{
}
};

async function ensureContext({
request,
url,
serverContext,
extendContext,
}: {
request: Request;
url: URL;
serverContext: Record<string, unknown>;
extendContext: (newContext: Record<string, unknown>) => void;
}) {
if (normalizedOptions.extendContextFieldName === null) {
return;
}

if (serverContext[normalizedOptions.extendContextFieldName]) {
return;
}

// Ensure the request has been validated before extending the context.
await lookupAndValidate({
request,
url,
serverContext,
});

// Then check the result
const result = request ? payloadByRequest.get(request) : payloadByContext.get(serverContext);

if (result && normalizedOptions.extendContextFieldName) {
extendContext({
[normalizedOptions.extendContextFieldName]: {
payload: result.payload,
token: result.token,
},
});
}
}

let fetchAPI: FetchAPI;
return {
onYogaInit({ yoga }) {
logger = yoga.logger;
fetchAPI = yoga.fetchAPI;
},
async onRequestParse(payload) {
await lookupAndValidate(payload);
onRequestParse({ request, url, serverContext }) {
return ensureContext({
request,
url,
serverContext,
extendContext: newContext => {
Object.assign(serverContext, newContext);
},
});
},
async onContextBuilding({ context, extendContext }) {
if (normalizedOptions.extendContextFieldName === null) {
return;
}

// Ensure the request has been validated before extending the context.
await lookupAndValidate({
request: context.request,
onContextBuilding({ context, extendContext }) {
const request = context.request;
return ensureContext({
request,
get url() {
return new fetchAPI.URL(context.request.url);
return request && new fetchAPI.URL(request.url);
},
serverContext: context,
extendContext,
});

// Then check the result
const result = context.request
? payloadByRequest.get(context.request)
: payloadByContext.get(context);

if (result && normalizedOptions.extendContextFieldName) {
extendContext({
[normalizedOptions.extendContextFieldName]: {
payload: result.payload,
token: result.token,
},
});
}
},
};
}
Expand Down
25 changes: 25 additions & 0 deletions website/src/pages/docs/features/introspection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ server.listen(4000, () => {
})
```

## Disable Introspection based on the context

You can also disable introspection based on the context. This is useful when you want to disable it
via another auth plugin like [JWT Plugin](/docs/features/jwt).

```ts "Disabling GraphQL schema introspection based on the context" {7}
import { createYoga } from 'graphql-yoga'
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection'

// Provide your schema
const yoga = createYoga({
graphiql: false,
plugins: [
useDisableIntrospection({
isDisabled: (_req, ctx) => !ctx.jwt
})
]
})

const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})
```

## Disabling Field Suggestions

<Callout>
Expand Down
38 changes: 38 additions & 0 deletions website/src/pages/docs/features/jwt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,41 @@ const yoga = createYoga({
]
})
```

### Disable Introspection based on JWT

If you disabled rejection on missing token and/or invalid token, you can disable introspection based
on the validity of JWT token still. You can combine JWT plugin with
[Disable Introspection plugin](/docs/features/introspection#disabling-introspection) in this case.

```sh npm2yarn
npm i @graphql-yoga/plugin-disable-introspection
```

Then you can use it like this:

```ts
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection'
import { useJWT } from '@graphql-yoga/plugin-jwt'

const yoga = createYoga({
// ...
plugins: [
useJWT({
reject: {
missingToken: false,
invalidToken: false
}
}),
useDisableIntrospection({
disableIf(_req, ctx) {
// If there is no JWT token(unauthorized), disable introspection
if (!ctx.jwt) {
return true
}
return false
}
})
]
})
```

0 comments on commit 0538f0a

Please sign in to comment.