Skip to content

Commit 1a8de26

Browse files
psubram3jmorton
andcommitted
add JWKS support
Co-authored-by: Pranav Subramanian <[email protected]> Co-authored-by: Jonathan Morton <[email protected]>
1 parent 8f1b9e5 commit 1a8de26

File tree

4 files changed

+93
-21
lines changed

4 files changed

+93
-21
lines changed

src/packages/auth/functions.ts

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import jwt, { Algorithm } from 'jsonwebtoken';
1+
import jwt, { Algorithm, JwtHeader, VerifyOptions } from 'jsonwebtoken';
22
import type { Response } from 'node-fetch';
33
import fetch from 'node-fetch';
44
import { getEnv } from '../../env.js';
@@ -14,6 +14,7 @@ import type {
1414
UserRoles,
1515
} from '../../types/auth.js';
1616
import { loginSSO } from './adapters/CAMAuthAdapter.js';
17+
import { JwksClient } from 'jwks-rsa';
1718
import { StringValue } from 'ms';
1819

1920
const logger = getLogger('packages/auth/functions');
@@ -107,14 +108,78 @@ export async function syncRolesToDB(username: string, default_role: string, allo
107108
await db.query('commit;');
108109
}
109110

110-
export function decodeJwt(authorizationHeader: string | undefined): JwtDecode {
111+
function enforcePEMFormatting(publicKey: string): string {
112+
if (publicKey.includes('-----BEGIN PUBLIC KEY-----') && publicKey.includes('-----END PUBLIC KEY-----')) {
113+
return publicKey;
114+
}
115+
else {
116+
return '-----BEGIN PUBLIC KEY-----\n' + publicKey + '\n-----END PUBLIC KEY-----'
117+
}
118+
}
119+
120+
export async function decodeJwt(authorizationHeader: string | undefined): Promise<JwtDecode> {
111121
try {
112122
const token = authorizationHeaderToToken(authorizationHeader);
113-
const { HASURA_GRAPHQL_JWT_SECRET, JWT_ALGORITHMS } = getEnv();
114-
const { key }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
115-
const options: jwt.VerifyOptions = { algorithms: JWT_ALGORITHMS };
116-
const jwtPayload = jwt.verify(token, key, options) as JwtPayload;
117-
return { jwtErrorMessage: '', jwtPayload };
123+
// TODO: this ignores the JWT_ALGORITHMS env variable, because that's included in HASURA_GRAPHQL_JWT_SECRET, both the keycloak version, and even the local version...
124+
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
125+
const { type, key, jwk_url }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
126+
127+
// TODO: figure out the defaults, for some reason the JWT_ALGORITHM env variable and it's default were getting messed up...default _should_ be HS256, but if using Keycloak, need RS256
128+
const options: jwt.VerifyOptions = { algorithms: ['RS256', 'HS256'] };
129+
130+
type getKeyType = (header: JwtHeader, callback: any) => void;
131+
let realKey: string | getKeyType;
132+
133+
// if they are using a jwk_url instead, pull the key!
134+
if (!key && jwk_url) {
135+
// https://www.npmjs.com/package/jsonwebtoken
136+
const client = new JwksClient({
137+
jwksUri: jwk_url
138+
});
139+
140+
realKey = function(header, callback) {
141+
client.getSigningKey(header.kid, function(err, key) {
142+
if (key) {
143+
const signingKey = key?.getPublicKey();
144+
callback(null, signingKey);
145+
}
146+
else {
147+
console.log(err)
148+
}
149+
});
150+
}
151+
152+
const verifyJwt = async function(token: string, options: VerifyOptions = {}): Promise<any> {
153+
return new Promise((resolve, reject) => {
154+
jwt.verify(token, realKey, options, (err, decoded) => {
155+
if (err) return reject(err);
156+
resolve(decoded);
157+
});
158+
});
159+
}
160+
161+
try {
162+
const jwtPayload = await verifyJwt(token, options);
163+
return {jwtErrorMessage: '', jwtPayload: jwtPayload}
164+
} catch (err) {
165+
return {jwtErrorMessage: 'JWT verification failed: ' + err, jwtPayload: null}
166+
}
167+
}
168+
else if (key) {
169+
if (type === "RS256") {
170+
realKey = enforcePEMFormatting(key);
171+
}
172+
else {
173+
realKey = key;
174+
}
175+
176+
const jwtPayload = jwt.verify(token, realKey, options) as JwtPayload;
177+
return { jwtErrorMessage: '', jwtPayload };
178+
}
179+
else {
180+
const jwtErrorMessage = 'Neither a valid JWT Key or JWK URL were provided. A type (algorithm) and either of those two must be provided.'
181+
return { jwtErrorMessage, jwtPayload: null };
182+
}
118183
} catch (e) {
119184
console.error(e);
120185

@@ -139,17 +204,21 @@ export function generateJwt(
139204
try {
140205
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
141206
const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
142-
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
143-
const payload: JwtPayload = {
144-
'https://hasura.io/jwt/claims': {
145-
'x-hasura-allowed-roles': allowedRoles,
146-
'x-hasura-default-role': defaultRole,
147-
'x-hasura-user-id': username,
148-
},
149-
username,
150-
};
207+
if (key) {
208+
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
209+
const payload: JwtPayload = {
210+
'https://hasura.io/jwt/claims': {
211+
'x-hasura-allowed-roles': allowedRoles,
212+
'x-hasura-default-role': defaultRole,
213+
'x-hasura-user-id': username,
214+
},
215+
username,
216+
};
151217

152-
return jwt.sign(payload, key, options);
218+
return jwt.sign(payload, key, options);
219+
}
220+
console.error('using JWKS URL, so this JWT generation will not work. You also shouldn\'t be using this method if using JWKS')
221+
return null;
153222
} catch (e) {
154223
console.error(e);
155224
return null;
@@ -210,7 +279,7 @@ export async function login(username: string, password: string): Promise<AuthRes
210279
}
211280

212281
export async function session(authorizationHeader: string | undefined): Promise<SessionResponse> {
213-
const { jwtErrorMessage, jwtPayload } = decodeJwt(authorizationHeader);
282+
const { jwtErrorMessage, jwtPayload } = await decodeJwt(authorizationHeader);
214283

215284
if (jwtPayload) {
216285
return { message: 'Token is valid', success: true };

src/packages/auth/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const adminOnlyAuth = async (req: Request, res: Response, next: NextFunct
1818
const response = await session(authorizationHeader);
1919

2020
if (response.success) {
21-
const { jwtPayload } = decodeJwt(authorizationHeader);
21+
const { jwtPayload } = await decodeJwt(authorizationHeader);
2222
if (jwtPayload == null) {
2323
res.status(401).send({ message: 'No authorization headers present.' });
2424
return;

src/packages/hasura/hasura-events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default (app: Express) => {
5252
* - Hasura
5353
*/
5454
app.post('/modelExtraction', refreshLimiter, adminOnlyAuth, async (req, res) => {
55-
const { jwtPayload } = decodeJwt(req.get('authorization'));
55+
const { jwtPayload } = await decodeJwt(req.get('authorization'));
5656
const username = jwtPayload?.username as string;
5757

5858
const { body } = req;

src/types/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ export type JwtPayload = {
1313
};
1414

1515
export type JwtSecret = {
16-
key: string;
1716
type: string;
17+
18+
// either key or jwk_url
19+
key?: string;
20+
jwk_url?: string;
1821
};
1922

2023
export type AuthResponse = {

0 commit comments

Comments
 (0)