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

Generic JWT plugin #1921

Merged
merged 19 commits into from
Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/popular-cycles-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-jwt': major
---

New Generic JWT Plugin
2 changes: 1 addition & 1 deletion packages/graphql-yoga/__tests__/404.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('404', () => {

expect(response.status).toEqual(200);
const body = await response.text();
expect(body).toContain('<!DOCTYPE html>');
expect(body).toMatch(/<!DOCTYPE html>/i);
expect(body).toContain('GraphQL Yoga');
});
it('returns 404 without landing page when accepting text/html and sending a GET request but disabled landing page', async () => {
Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/jwt/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"name": "@graphql-yoga/plugin-jwt",
"version": "0.0.0",
"type": "module",
"description": "jwt plugin for GraphQL Yoga.",
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/graphql-yoga.git",
"directory": "packages/plugins/jwt"
},
"author": "Arda TANRIKULU <ardatanrikulu@gmail.com>",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"exports": {
".": {
"require": {
"types": "./dist/typings/index.d.cts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./package.json": "./package.json"
},
"typings": "dist/typings/index.d.ts",
"scripts": {
"check": "tsc --pretty --noEmit"
},
"peerDependencies": {
"graphql": "^16.5.0",
"graphql-yoga": "^4.0.3"
},
"dependencies": {
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.1.5",
"tslib": "^2.4.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^8.5.8",
"graphql": "^16.5.0",
"graphql-scalars": "^1.22.2"
},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}
211 changes: 211 additions & 0 deletions packages/plugins/jwt/src/__tests__/jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import crypto from 'node:crypto';
import { createSchema, createYoga } from 'graphql-yoga';
import jwt from 'jsonwebtoken';
import { JwtPluginOptions, useJWT } from '@graphql-yoga/plugin-jwt';

describe('jwt', () => {
it('should throw if no signing key or jwksUri is provided', () => {
// @ts-expect-error testing invalid options fo JS users
expect(() => useJWT({ issuer: 'yoga' })).toThrow(
'You need to provide either a signingKey or a jwksUri',
);
});

it('should throw if both signing key and jwksUri are provided', () => {
expect(() =>
// @ts-expect-error testing invalid options fo JS users
useJWT({ signingKey: 'test', jwksUri: 'test', issuer: 'yoga' }),
).toThrow('You need to provide either a signingKey or a jwksUri, not both');
});

it('should throw on unsupported header type', async () => {
const server = createTestServer();

await expect(server.queryWithAuth('Basic 123')).rejects.toMatchObject({
message: 'Unsupported token type provided: "Basic"',
extensions: { http: { status: 401 } },
});
});

it('should throw on invalid token', async () => {
const server = createTestServer();

await expect(server.queryWithAuth('Bearer abcd')).rejects.toMatchObject({
message: 'Failed to decode authentication token',
extensions: { http: { status: 401 } },
});
});

it('should not accept token without algorithm', async () => {
const server = createTestServer();

await expect(server.queryWithAuth(buildJWTWithoutAlg())).rejects.toMatchObject({
message: 'Failed to decode authentication token',
extensions: { http: { status: 401 } },
});
});

it('should not allow non matching issuer', async () => {
const server = createTestServer();

await expect(server.queryWithAuth(buildJWT({ iss: 'test' }))).rejects.toMatchObject({
message: 'Failed to decode authentication token',
extensions: { http: { status: 401 } },
});
});

it('should not allow non matching audience', async () => {
const server = createTestServer({ audience: 'test' });

await expect(server.queryWithAuth(buildJWT({ aud: 'wrong' }))).rejects.toMatchObject({
message: 'Failed to decode authentication token',
extensions: { http: { status: 401 } },
});
});

it('should verify token against provided jwsk', async () => {
const server = createTestServer({ signingKey: undefined, jwksUri: 'test' });

const response = await server.queryWithAuth(buildJWT({}, { keyid: 'yoga' }));

expect(response.status).toBe(200);
});

it('should not allow unknown key id', async () => {
const server = createTestServer({ signingKey: undefined, jwksUri: 'test' });

await expect(server.queryWithAuth(buildJWT({}, { keyid: 'unknown' }))).rejects.toMatchObject({
message: 'Failed to decode authentication token. Unknown key id.',
extensions: { http: { status: 401 } },
});
});

it('should give access to the jwt payload in the context', async () => {
const server = createTestServer();

const response = await server.queryWithAuth(buildJWT({ claims: { test: 'test' } }));

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
data: {
ctx: {
jwt: {
iss: 'http://yoga',
claims: {
test: 'test',
},
},
},
},
});
});

it('should allow to customize the constructor', async () => {
const server = createTestServer({ extendContextField: 'custom' });

const response = await server.queryWithAuth(buildJWT({ claims: { test: 'test' } }));

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
data: {
ctx: {
custom: {
iss: 'http://yoga',
},
},
},
});
});

it('should allow to get token from something else than headers', async () => {
const server = createTestServer({
getToken: () => buildJWT({}).split(' ')[1],
});

const response = await server.queryWithAuth('');
expect(response.status).toBe(200);
});
});

const createTestServer = (options?: Partial<JwtPluginOptions>) => {
const yoga = createYoga({
schema,
plugins: [
// @ts-expect-error testing invalid options fo JS users
useJWT({
issuer: 'http://yoga',
signingKey: 'very secret key',
algorithms: ['HS256'],
...options,
}),
],
});

return {
yoga,
queryWithAuth: (authorization: string) =>
yoga.fetch('http://yoga/graphql', {
method: 'POST',
body,
headers: {
'content-type': 'application/json',
accept: 'application/json',
authorization,
},
}),
};
};

const schema = createSchema({
typeDefs: /* GraphQL */ `
scalar JSON
type Query {
ctx: JSON
}
`,
resolvers: {
Query: {
ctx: (_, __, ctx) => ctx,
},
},
});

const body = JSON.stringify({
query: /* GraphQL */ `
query {
ctx
}
`,
});

const buildJWT = (
payload: object,
options: Parameters<typeof jwt.sign>[2] & { key?: string } = {},
) => {
const { key = 'very secret key', ...signOptions } = options;

const token = jwt.sign({ iss: 'http://yoga', ...payload }, key, signOptions);

return `Bearer ${token}`;
};

function buildJWTWithoutAlg(payload: object = {}, key = 'very secret key') {
const header = Buffer.from(JSON.stringify({ typ: 'JWT' })).toString('base64');
const encodedPayload = Buffer.from(JSON.stringify({ iss: 'http://yoga', ...payload })).toString(
'base64',
);
const encodedJWT = `${header}.${encodedPayload}`;
const signature = crypto.createHmac('sha256', key).update(encodedJWT).digest('base64');
return `Bearer ${encodedJWT}.${signature}`;
}

jest.mock('jwks-rsa', () => ({
JwksClient: jest.fn(() => ({
getSigningKey: jest.fn((kid: string) => {
if (kid !== 'yoga') {
return null;
}
return { getPublicKey: () => 'very secret key' };
}),
})),
}));
Loading