Skip to content

Commit f47c900

Browse files
authored
feat(plugin): jwt auth plugin (#7304)
1 parent fef0f24 commit f47c900

File tree

11 files changed

+895
-85
lines changed

11 files changed

+895
-85
lines changed

.changeset/beige-boxes-reflect.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@graphql-mesh/serve-runtime': patch
3+
'@graphql-mesh/fusion-runtime': patch
4+
---
5+
6+
Pass context type from `OnSubgraphExecute` to `ExecutionRequest`

.changeset/rich-bears-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-mesh/plugin-jwt-auth': patch
3+
---
4+
5+
Initial commit and introduce a new plugin.

packages/fusion/runtime/src/utils.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,19 +299,19 @@ export function wrapExecutorWithHooks({
299299
};
300300
}
301301

302-
export interface UnifiedGraphPlugin {
303-
onSubgraphExecute?: OnSubgraphExecuteHook;
302+
export interface UnifiedGraphPlugin<TContext> {
303+
onSubgraphExecute?: OnSubgraphExecuteHook<TContext>;
304304
}
305305

306-
export type OnSubgraphExecuteHook = (
307-
payload: OnSubgraphExecutePayload,
306+
export type OnSubgraphExecuteHook<TContext = any> = (
307+
payload: OnSubgraphExecutePayload<TContext>,
308308
) => Promise<Maybe<OnSubgraphExecuteDoneHook | void>> | Maybe<OnSubgraphExecuteDoneHook | void>;
309309

310-
export interface OnSubgraphExecutePayload {
310+
export interface OnSubgraphExecutePayload<TContext> {
311311
subgraph: GraphQLSchema;
312312
subgraphName: string;
313313
transportEntry?: TransportEntry;
314-
executionRequest: ExecutionRequest;
314+
executionRequest: ExecutionRequest<any, TContext>;
315315
setExecutionRequest(executionRequest: ExecutionRequest): void;
316316
executor: Executor;
317317
setExecutor(executor: Executor): void;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@graphql-mesh/plugin-jwt-auth",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"repository": {
6+
"type": "git",
7+
"url": "ardatan/graphql-mesh",
8+
"directory": "packages/plugins/jwt-auth"
9+
},
10+
"license": "MIT",
11+
"engines": {
12+
"node": ">=16.0.0"
13+
},
14+
"main": "dist/cjs/index.js",
15+
"module": "dist/esm/index.js",
16+
"exports": {
17+
".": {
18+
"require": {
19+
"types": "./dist/typings/index.d.cts",
20+
"default": "./dist/cjs/index.js"
21+
},
22+
"import": {
23+
"types": "./dist/typings/index.d.ts",
24+
"default": "./dist/esm/index.js"
25+
},
26+
"default": {
27+
"types": "./dist/typings/index.d.ts",
28+
"default": "./dist/esm/index.js"
29+
}
30+
},
31+
"./package.json": "./package.json"
32+
},
33+
"typings": "dist/typings/index.d.ts",
34+
"peerDependencies": {
35+
"@graphql-mesh/types": "^0.99.0",
36+
"@graphql-mesh/utils": "^0.99.0",
37+
"graphql": "*",
38+
"tslib": "^2.4.0"
39+
},
40+
"dependencies": {
41+
"@graphql-yoga/plugin-jwt": "3.0.0"
42+
},
43+
"devDependencies": {
44+
"@graphql-mesh/serve-runtime": "^0.5.0",
45+
"graphql-yoga": "^5.6.0",
46+
"jsonwebtoken": "9.0.2"
47+
},
48+
"publishConfig": {
49+
"access": "public",
50+
"directory": "dist"
51+
},
52+
"sideEffects": false,
53+
"typescript": {
54+
"definition": "dist/typings/index.d.ts"
55+
}
56+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { type Plugin as YogaPlugin } from 'graphql-yoga';
2+
import type { MeshServePlugin } from '@graphql-mesh/serve-runtime';
3+
import {
4+
useJWT as useYogaJWT,
5+
type JWTExtendContextFields,
6+
type JwtPluginOptions,
7+
} from '@graphql-yoga/plugin-jwt';
8+
9+
export {
10+
createInlineSigningKeyProvider,
11+
createRemoteJwksSigningKeyProvider,
12+
extractFromCookie,
13+
extractFromHeader,
14+
type GetSigningKeyFunction,
15+
type JWTExtendContextFields,
16+
type JwtPluginOptions,
17+
type ExtractTokenFunction,
18+
} from '@graphql-yoga/plugin-jwt';
19+
20+
export type JWTAuthPluginOptions = Omit<JwtPluginOptions, 'extendContext'> & {
21+
forward?: {
22+
payload?: boolean | string;
23+
token?: boolean | string;
24+
extensionsFieldName?: string;
25+
};
26+
};
27+
28+
/**
29+
* This Yoga plugin is used to extracted the forwarded (from Mesh gateway) the JWT token and claims.
30+
* Use this plugin in your Yoga server to extract the JWT token and claims from the forwarded extensions.
31+
*/
32+
export function useForwardedJWT(config: {
33+
extensionsFieldName?: string;
34+
extendContextFieldName?: string;
35+
}): YogaPlugin<{ jwt?: JWTExtendContextFields }> {
36+
const extensionsJwtFieldName = config.extensionsFieldName ?? 'jwt';
37+
const extendContextFieldName = config.extendContextFieldName ?? 'jwt';
38+
39+
return {
40+
onContextBuilding({ context, extendContext }) {
41+
if (context.params.extensions?.[extensionsJwtFieldName]) {
42+
const jwt = context.params.extensions[extensionsJwtFieldName]!;
43+
44+
extendContext({
45+
[extendContextFieldName]: jwt,
46+
});
47+
}
48+
},
49+
};
50+
}
51+
52+
/**
53+
* This Mesh Gateway plugin is used to extract the JWT token and payload from the request and forward it to the subgraph.
54+
*/
55+
export function useJWT(
56+
options: JWTAuthPluginOptions,
57+
): MeshServePlugin<{ jwt?: JWTExtendContextFields }> {
58+
const forwardPayload = options?.forward?.payload ?? true;
59+
const forwardToken = options?.forward?.token ?? false;
60+
const shouldForward = forwardPayload || forwardToken;
61+
const fieldName = options?.forward?.extensionsFieldName ?? 'jwt';
62+
63+
return {
64+
onPluginInit({ addPlugin }) {
65+
// TODO: fix useYogaJWT typings to inherit the context
66+
addPlugin(useYogaJWT(options) as any);
67+
},
68+
// When a subgraph is about to be executed, we check if the initial request has a JWT token
69+
// that needs to be passed. At the moment, only GraphQL subgraphs will have the option to forward tokens/payload.
70+
// The JWT info will be passed to the subgraph execution request.
71+
onSubgraphExecute({ executionRequest, setExecutionRequest }) {
72+
if (shouldForward && executionRequest.context.jwt) {
73+
const jwtData: Partial<JWTExtendContextFields> = {
74+
payload: forwardPayload ? executionRequest.context.jwt.payload : undefined,
75+
token: forwardToken ? executionRequest.context.jwt.token : undefined,
76+
};
77+
78+
setExecutionRequest({
79+
...executionRequest,
80+
extensions: {
81+
...executionRequest.extensions,
82+
[fieldName]: jwtData,
83+
},
84+
});
85+
}
86+
},
87+
};
88+
}
89+
90+
export default useJWT;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { createSchema, createYoga, type Plugin } from 'graphql-yoga';
2+
import jwt from 'jsonwebtoken';
3+
import { createServeRuntime } from '@graphql-mesh/serve-runtime';
4+
import {
5+
createInlineSigningKeyProvider,
6+
type JWTExtendContextFields,
7+
} from '@graphql-yoga/plugin-jwt';
8+
import useJWTAuth, { useForwardedJWT } from './index';
9+
10+
describe('useExtractedJWT', () => {
11+
it('full flow with extraction on Yoga subgraph', async () => {
12+
const upstream = createYoga<{ jwt?: JWTExtendContextFields }>({
13+
schema: createSchema({
14+
typeDefs: /* GraphQL */ `
15+
type Query {
16+
hello: String
17+
}
18+
`,
19+
resolvers: {
20+
Query: {
21+
hello: (_, args, context) => `Hi, user ${context.jwt?.payload.sub}`,
22+
},
23+
},
24+
}),
25+
plugins: [useForwardedJWT({})],
26+
logging: false,
27+
});
28+
29+
const secret = 'topsecret';
30+
const serveRuntime = createServeRuntime({
31+
proxy: {
32+
endpoint: 'https://example.com/graphql',
33+
},
34+
fetchAPI: {
35+
fetch: upstream.fetch as any,
36+
},
37+
plugins: () => [
38+
useJWTAuth({
39+
forward: {
40+
payload: true,
41+
token: true,
42+
},
43+
singingKeyProviders: [createInlineSigningKeyProvider(secret)],
44+
}),
45+
],
46+
logging: false,
47+
});
48+
49+
const token = jwt.sign({ sub: '123' }, secret, {});
50+
51+
const response = await serveRuntime.fetch('http://localhost:4000/graphql', {
52+
method: 'POST',
53+
headers: {
54+
authorization: `Bearer ${token}`,
55+
'content-type': 'application/json',
56+
},
57+
body: JSON.stringify({
58+
query: /* GraphQL */ `
59+
query {
60+
hello
61+
}
62+
`,
63+
}),
64+
});
65+
66+
expect(response.status).toBe(200);
67+
const body = await response.json<any>();
68+
expect(body.data?.hello).toBe('Hi, user 123');
69+
});
70+
});
71+
72+
describe('useJWTAuth', () => {
73+
describe('forwarding of token and payload to the upstream, using extensions', () => {
74+
const requestTrackerPlugin = {
75+
onParams: jest.fn((() => {}) as Plugin['onParams']),
76+
};
77+
const upstream = createYoga({
78+
schema: createSchema({
79+
typeDefs: /* GraphQL */ `
80+
type Query {
81+
hello: String
82+
}
83+
`,
84+
resolvers: {
85+
Query: {
86+
hello: () => 'world',
87+
},
88+
},
89+
}),
90+
plugins: [requestTrackerPlugin],
91+
logging: false,
92+
});
93+
beforeEach(() => {
94+
requestTrackerPlugin.onParams.mockClear();
95+
});
96+
97+
it('should passthrough jwt token, jwt payload, and signature to the upstream api calls', async () => {
98+
const secret = 'topsecret';
99+
const serveRuntime = createServeRuntime({
100+
proxy: {
101+
endpoint: 'https://example.com/graphql',
102+
},
103+
fetchAPI: {
104+
fetch: upstream.fetch as any,
105+
},
106+
plugins: () => [
107+
useJWTAuth({
108+
forward: {
109+
payload: true,
110+
token: true,
111+
},
112+
singingKeyProviders: [createInlineSigningKeyProvider(secret)],
113+
}),
114+
],
115+
logging: false,
116+
});
117+
118+
const token = jwt.sign({ sub: '123' }, secret, {});
119+
120+
const response = await serveRuntime.fetch('http://localhost:4000/graphql', {
121+
method: 'POST',
122+
headers: {
123+
authorization: `Bearer ${token}`,
124+
'content-type': 'application/json',
125+
},
126+
body: JSON.stringify({
127+
query: /* GraphQL */ `
128+
query {
129+
hello
130+
}
131+
`,
132+
}),
133+
});
134+
135+
expect(response.status).toBe(200);
136+
expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes(2);
137+
const extensions = requestTrackerPlugin.onParams.mock.calls[1][0].params.extensions;
138+
expect(extensions.jwt.token.value).toBe(token);
139+
expect(extensions.jwt.token.prefix).toBe('Bearer');
140+
expect(extensions.jwt.payload).toMatchObject({
141+
sub: '123',
142+
});
143+
});
144+
});
145+
});

packages/serve-cli/rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const deps = {
4444
// extras for docker only
4545
'node_modules/@graphql-mesh/plugin-prometheus/index': '../plugins/prometheus/src/index.ts',
4646
'node_modules/@graphql-mesh/plugin-http-cache/index': '../plugins/http-cache/src/index.ts',
47+
'node_modules/@graphql-mesh/plugin-jwt-auth/index': '../plugins/jwt-auth/src/index.ts',
4748
};
4849

4950
if (process.env.E2E_SERVE_RUNNER === 'docker') {

packages/serve-runtime/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export type MeshServePlugin<
7070
TPluginContext extends Record<string, any> = Record<string, any>,
7171
TContext extends Record<string, any> = Record<string, any>,
7272
> = YogaPlugin<Partial<TPluginContext> & MeshServeContext & TContext> &
73-
UnifiedGraphPlugin & {
73+
UnifiedGraphPlugin<Partial<TPluginContext> & MeshServeContext & TContext> & {
7474
onFetch?: OnFetchHook<Partial<TPluginContext> & MeshServeContext & TContext>;
7575
} & Partial<Disposable | AsyncDisposable>;
7676

website/src/pages/v1/serve/features/auth/index.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ security issues.
1616
- [Auth0](/v1/serve/features/auth/auth0): We recommend using an existing third-party service such as
1717
[Auth0](https://auth0.com/) for most users. With the Auth0 plugin, you can simply bootstrap the
1818
authorization process.
19-
- [JSON Web Tokens (JWT)](/v1/serve/features/auth/jwt): Mesh provides a plugin to easily integratye
20-
JWT into your API
19+
- [JSON Web Tokens (JWT)](/v1/serve/features/auth/jwt): Mesh provides a plugin to easily integrate
20+
JWT token verification into your API.
2121
- [Generic Auth](/v1/serve/features/auth/generic-auth): In some cases using a third party auth
2222
provider is not possible. But now worries, the generic auth plugin has you covered!
2323

0 commit comments

Comments
 (0)