Skip to content

Commit

Permalink
Allow tokenVerification as function
Browse files Browse the repository at this point in the history
  • Loading branch information
aarne committed Nov 4, 2024
1 parent de24993 commit d2978fe
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 70 deletions.
3 changes: 2 additions & 1 deletion packages/plugins/jwt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"@types/jsonwebtoken": "^9.0.0",
"graphql": "^16.5.0",
"graphql-scalars": "^1.22.2",
"graphql-yoga": "workspace:*"
"graphql-yoga": "workspace:*",
"jose": "^5.9.6"
},
"publishConfig": {
"directory": "dist",
Expand Down
37 changes: 37 additions & 0 deletions packages/plugins/jwt/src/__tests__/jwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { createHmac } from 'node:crypto';
import { createServer } from 'node:http';
import { createSchema, createYoga, Plugin } from 'graphql-yoga';
import { decodeProtectedHeader, jwtVerify } from 'jose';
import jwt, { Algorithm, SignOptions } from 'jsonwebtoken';
import { useCookies } from '@whatwg-node/server-plugin-cookies';
import { JwtPluginOptions } from '../config';
Expand Down Expand Up @@ -351,6 +352,42 @@ describe('jwt plugin', () => {
});
});

test('auth is passing when token is valid (HS256) - jose', async () => {
const secret = 'topsecret';
const test = createTestServer({
singingKeyProviders: [createInlineSigningKeyProvider(secret)],
decodeTokenHeader: token => {
const header = decodeProtectedHeader(token);
return {
kid: header.kid,
};
},
async tokenVerification(token, signingKey) {
const secret = new TextEncoder().encode(signingKey);
const payload = await jwtVerify(token, secret);
return payload.payload;
},
});
const token = buildJWT({ sub: '123' }, { key: secret });
const response = await test.queryWithAuth(token);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
data: {
ctx: {
jwt: {
payload: {
sub: '123',
},
token: {
prefix: 'Bearer',
value: expect.any(String),
},
},
},
},
});
});

test('auth is passing when token is valid (RS256)', async () => {
const test = createTestServer({
singingKeyProviders: [createInlineSigningKeyProvider(JWKS_RSA512_PRIVATE_PEM)],
Expand Down
16 changes: 14 additions & 2 deletions packages/plugins/jwt/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type PromiseOrValue } from 'graphql-yoga';
import { VerifyOptions } from 'jsonwebtoken';
import { type JwtPayload, type VerifyOptions } from 'jsonwebtoken';
import { createVerifyTokenFunction, decodeHeader } from './jsonwebtoken.js';
import { extractFromHeader } from './utils.js';

type AtleastOneItem<T> = [T, ...T[]];
Expand Down Expand Up @@ -46,7 +47,13 @@ export type JwtPluginOptions = {
*
* By defualt, only the `RS256` and `HS256` algorithms are configured as validations.
*/
tokenVerification?: VerifyOptions;
tokenVerification?:
| VerifyOptions
| ((token: string, signingKey: string) => PromiseOrValue<JwtPayload>);
/**
* Function to decode the token header.
*/
decodeTokenHeader?: (token: string) => { kid?: string } | undefined;
/**
* Whether to reject requests/operations that does not meet criteria.
*
Expand Down Expand Up @@ -113,5 +120,10 @@ export function normalizeConfig(input: JwtPluginOptions) {
...input.reject,
},
extendContextFieldName,
decodeHeader: input.decodeTokenHeader ?? decodeHeader,
verifyToken:
typeof input.tokenVerification === 'function'
? input.tokenVerification
: createVerifyTokenFunction(input.tokenVerification),
};
}
35 changes: 35 additions & 0 deletions packages/plugins/jwt/src/jsonwebtoken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import jsonwebtoken, { VerifyOptions, type JwtPayload } from 'jsonwebtoken';
import { JwksClient, type Options as JwksClientOptions } from 'jwks-rsa';
import type { GetSigningKeyFunction } from './config.js';
import { unauthorizedError } from './utils.js';

export function decodeHeader(token: string) {
const resp = jsonwebtoken.decode(token, { complete: true });
return resp?.header;
}

export function createVerifyTokenFunction(options: VerifyOptions | undefined) {
return (token: string, signingKey: string) => {
return new Promise<JwtPayload>((resolve, reject) => {
jsonwebtoken.verify(token, signingKey, options, (err, result) => {
if (err) {
reject(unauthorizedError('Unauthenticated'));
} else {
resolve(result as JwtPayload);
}
});
});
};
}

export function createInlineSigningKeyProvider(signingKey: string): GetSigningKeyFunction {
return () => signingKey;
}

export function createRemoteJwksSigningKeyProvider(
jwksClientOptions: JwksClientOptions,
): GetSigningKeyFunction {
const client = new JwksClient(jwksClientOptions);

return kid => client.getSigningKey(kid)?.then(r => r.getPublicKey());
}
39 changes: 8 additions & 31 deletions packages/plugins/jwt/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import '@whatwg-node/server-plugin-cookies';
import { GraphQLError } from 'graphql';
import type { Plugin, YogaLogger } from 'graphql-yoga';
import jsonwebtoken, { type Jwt, type JwtPayload, type VerifyOptions } from 'jsonwebtoken';
import { type JwtPayload } from 'jsonwebtoken';
import { type OnRequestParseEventPayload } from 'packages/graphql-yoga/src/plugins/types.js';
import { normalizeConfig, type JwtPluginOptions } from './config.js';
import '@whatwg-node/server-plugin-cookies';
import { GraphQLError } from 'graphql';
import { badRequestError, unauthorizedError } from './utils.js';

export type JWTExtendContextFields = {
Expand Down Expand Up @@ -77,9 +77,9 @@ export function useJWT(options: JwtPluginOptions): Plugin<{

try {
// Decode the token first, in order to get the key id to use.
let decodedToken: Jwt | null;
let decodedToken: { kid?: string } | undefined | null;
try {
decodedToken = jsonwebtoken.decode(lookupResult.token, { complete: true });
decodedToken = normalizedOptions.decodeHeader(lookupResult.token);
} catch (e) {
logger.warn(`Failed to decode JWT authentication token: `, e);
throw badRequestError(`Invalid authentication token provided`);
Expand All @@ -94,23 +94,18 @@ export function useJWT(options: JwtPluginOptions): Plugin<{
}

// Fetch the signing key based on the key id.
const signingKey = await getSigningKey(decodedToken?.header.kid);
const signingKey = await getSigningKey(decodedToken?.kid);

if (!signingKey) {
logger.warn(
`Signing key is not available for the key id: ${decodedToken?.header.kid}. Please make sure signing key providers are configured correctly.`,
`Signing key is not available for the key id: ${decodedToken?.kid}. Please make sure signing key providers are configured correctly.`,
);

throw Error(`Authentication is not available at the moment.`);
}

// Verify the token with the signing key.
const verified = await verify(
logger,
lookupResult.token,
signingKey,
normalizedOptions.tokenVerification,
);
const verified = await normalizedOptions.verifyToken(lookupResult.token, signingKey);

if (!verified) {
logger.debug(`Token failed to verify, JWT plugin failed to authenticate.`);
Expand Down Expand Up @@ -166,21 +161,3 @@ export function useJWT(options: JwtPluginOptions): Plugin<{
},
};
}

function verify(
logger: YogaLogger,
token: string,
signingKey: string,
options: VerifyOptions | undefined,
) {
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, signingKey, options, (err, result) => {
if (err) {
logger.warn(`Failed to verify authentication token: `, err);
reject(unauthorizedError('Unauthenticated'));
} else {
resolve(result as JwtPayload);
}
});
});
}
Loading

0 comments on commit d2978fe

Please sign in to comment.