Skip to content
Open
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
163 changes: 115 additions & 48 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"helmet": "^7.0.0",
"jwks-rsa": "^3.2.0",
"jsonwebtoken": "^9.0.1",
"multer": "^1.4.5-lts.1",
"nanoid": "^4.0.2",
Expand Down
7 changes: 4 additions & 3 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Algorithm } from 'jsonwebtoken';
import { GroupRoleMapping } from './types/auth';
import { StringValue } from 'ms';

export type Env = {
ALLOWED_ROLES: string[];
Expand All @@ -16,7 +17,7 @@ export type Env = {
HASURA_API_URL: string;
HASURA_GRAPHQL_JWT_SECRET: string;
JWT_ALGORITHMS: Algorithm[];
JWT_EXPIRATION: string;
JWT_EXPIRATION: StringValue;
LOG_FILE: string;
LOG_LEVEL: string;
PORT: string;
Expand Down Expand Up @@ -48,7 +49,7 @@ export const defaultEnv: Env = {
HASURA_API_URL: 'http://hasura:8080',
HASURA_GRAPHQL_JWT_SECRET: '',
JWT_ALGORITHMS: ['HS256'],
JWT_EXPIRATION: '36h',
JWT_EXPIRATION: '36h' as StringValue,
LOG_FILE: 'console',
LOG_LEVEL: 'info',
PORT: '9000',
Expand Down Expand Up @@ -120,7 +121,7 @@ export function getEnv(): Env {
const HASURA_GRAPHQL_JWT_SECRET = env['HASURA_GRAPHQL_JWT_SECRET'] ?? defaultEnv.HASURA_GRAPHQL_JWT_SECRET;
const HASURA_API_URL = env['HASURA_API_URL'] ?? defaultEnv.HASURA_API_URL;
const JWT_ALGORITHMS = parseArray(env['JWT_ALGORITHMS'], defaultEnv.JWT_ALGORITHMS);
const JWT_EXPIRATION = env['JWT_EXPIRATION'] ?? defaultEnv.JWT_EXPIRATION;
const JWT_EXPIRATION = (env['JWT_EXPIRATION'] as StringValue) ?? defaultEnv.JWT_EXPIRATION;
const LOG_FILE = env['LOG_FILE'] ?? defaultEnv.LOG_FILE;
const LOG_LEVEL = env['LOG_LEVEL'] ?? defaultEnv.LOG_LEVEL;
const PORT = env['PORT'] ?? defaultEnv.PORT;
Expand Down
108 changes: 89 additions & 19 deletions src/packages/auth/functions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import jwt, { Algorithm } from 'jsonwebtoken';
import jwt, { Algorithm, JwtHeader, VerifyOptions } from 'jsonwebtoken';
import type { Response } from 'node-fetch';
import fetch from 'node-fetch';
import { getEnv } from '../../env.js';
Expand All @@ -14,6 +14,8 @@ import type {
UserRoles,
} from '../../types/auth.js';
import { loginSSO } from './adapters/CAMAuthAdapter.js';
import { JwksClient } from 'jwks-rsa';
import { StringValue } from 'ms';

const logger = getLogger('packages/auth/functions');

Expand Down Expand Up @@ -107,14 +109,78 @@ export async function syncRolesToDB(username: string, default_role: string, allo
await db.query('commit;');
}

export function decodeJwt(authorizationHeader: string | undefined): JwtDecode {
function enforcePEMFormatting(publicKey: string): string {
if (publicKey.includes('-----BEGIN PUBLIC KEY-----') && publicKey.includes('-----END PUBLIC KEY-----')) {
return publicKey;
}
else {
return '-----BEGIN PUBLIC KEY-----\n' + publicKey + '\n-----END PUBLIC KEY-----'
}
}

export async function decodeJwt(authorizationHeader: string | undefined): Promise<JwtDecode> {
try {
const token = authorizationHeaderToToken(authorizationHeader);
const { HASURA_GRAPHQL_JWT_SECRET, JWT_ALGORITHMS } = getEnv();
const { key }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
const options: jwt.VerifyOptions = { algorithms: JWT_ALGORITHMS };
const jwtPayload = jwt.verify(token, key, options) as JwtPayload;
return { jwtErrorMessage: '', jwtPayload };
// 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...
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
const { type, key, jwk_url }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);

// 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
const options: jwt.VerifyOptions = { algorithms: ['RS256', 'HS256'] };

type getKeyType = (header: JwtHeader, callback: any) => void;
let realKey: string | getKeyType;

// if they are using a jwk_url instead, pull the key!
if (!key && jwk_url) {
// https://www.npmjs.com/package/jsonwebtoken
const client = new JwksClient({
jwksUri: jwk_url
});

realKey = function(header, callback) {
client.getSigningKey(header.kid, function(err, key) {
if (key) {
const signingKey = key?.getPublicKey();
callback(null, signingKey);
}
else {
console.log(err)
}
});
}

const verifyJwt = async function(token: string, options: VerifyOptions = {}): Promise<any> {
return new Promise((resolve, reject) => {
jwt.verify(token, realKey, options, (err, decoded) => {
if (err) return reject(err);
resolve(decoded);
});
});
}

try {
const jwtPayload = await verifyJwt(token, options);
return {jwtErrorMessage: '', jwtPayload: jwtPayload}
} catch (err) {
return {jwtErrorMessage: 'JWT verification failed: ' + err, jwtPayload: null}
}
}
else if (key) {
if (type === "RS256") {
realKey = enforcePEMFormatting(key);
}
else {
realKey = key;
}

const jwtPayload = jwt.verify(token, realKey, options) as JwtPayload;
return { jwtErrorMessage: '', jwtPayload };
}
else {
const jwtErrorMessage = 'Neither a valid JWT Key or JWK URL were provided. A type (algorithm) and either of those two must be provided.'
return { jwtErrorMessage, jwtPayload: null };
}
} catch (e) {
console.error(e);

Expand All @@ -134,22 +200,26 @@ export function generateJwt(
username: string,
defaultRole: string,
allowedRoles: string[],
expiry: string = getEnv().JWT_EXPIRATION,
expiry: StringValue = getEnv().JWT_EXPIRATION,
): string | null {
try {
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
const payload: JwtPayload = {
'https://hasura.io/jwt/claims': {
'x-hasura-allowed-roles': allowedRoles,
'x-hasura-default-role': defaultRole,
'x-hasura-user-id': username,
},
username,
};
if (key) {
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
const payload: JwtPayload = {
'https://hasura.io/jwt/claims': {
'x-hasura-allowed-roles': allowedRoles,
'x-hasura-default-role': defaultRole,
'x-hasura-user-id': username,
},
username,
};

return jwt.sign(payload, key, options);
return jwt.sign(payload, key, options);
}
console.error('using JWKS URL, so this JWT generation will not work. You also shouldn\'t be using this method if using JWKS')
return null;
} catch (e) {
console.error(e);
return null;
Expand Down Expand Up @@ -210,7 +280,7 @@ export async function login(username: string, password: string): Promise<AuthRes
}

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

if (jwtPayload) {
return { message: 'Token is valid', success: true };
Expand Down
2 changes: 1 addition & 1 deletion src/packages/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const adminOnlyAuth = async (req: Request, res: Response, next: NextFunct
const response = await session(authorizationHeader);

if (response.success) {
const { jwtPayload } = decodeJwt(authorizationHeader);
const { jwtPayload } = await decodeJwt(authorizationHeader);
if (jwtPayload == null) {
res.status(401).send({ message: 'No authorization headers present.' });
return;
Expand Down
2 changes: 1 addition & 1 deletion src/packages/hasura/hasura-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default (app: Express) => {
* - Hasura
*/
app.post('/modelExtraction', refreshLimiter, adminOnlyAuth, async (req, res) => {
const { jwtPayload } = decodeJwt(req.get('authorization'));
const { jwtPayload } = await decodeJwt(req.get('authorization'));
const username = jwtPayload?.username as string;

const { body } = req;
Expand Down
5 changes: 4 additions & 1 deletion src/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export type JwtPayload = {
};

export type JwtSecret = {
key: string;
type: string;

// either key or jwk_url
key?: string;
jwk_url?: string;
};

export type AuthResponse = {
Expand Down
Loading