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
13 changes: 13 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@ GATEWAY_DB_PASSWORD=

HASURA_API_URL=http://localhost:8080

# JWT signing algorithms (JSON array). Default: ["RS256"]
# JWT_ALGORITHMS=["RS256"]

# JWT configuration. For OIDC/JWKS, include jwk_url, issuer, and audience:
# HASURA_GRAPHQL_JWT_SECRET='{ "jwk_url": "https://your-oidc-provider/.well-known/jwks", "issuer": "https://your-oidc-provider", "audience": "your-client-id" }'
HASURA_GRAPHQL_JWT_SECRET=

# JWT claim paths - customize where user ID, roles, etc. are read from in the JWT.
# Defaults follow Hasura's JWT claims namespace convention.
# These should match your OIDC provider's token mapper configuration.
# JWT_CLAIMS_NAMESPACE=https://hasura.io/jwt/claims
# JWT_CLAIMS_USER_ID=x-hasura-user-id
# JWT_CLAIMS_ALLOWED_ROLES=x-hasura-allowed-roles
# JWT_CLAIMS_DEFAULT_ROLE=x-hasura-default-role
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
37 changes: 33 additions & 4 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { Algorithm } from 'jsonwebtoken';
import { GroupRoleMapping } from './types/auth';
import { StringValue } from 'ms';

/**
* JWT claim path configuration.
* Allows customization of where user ID, roles, and other claims are read from in the JWT.
* Defaults follow Hasura's JWT claims namespace convention.
*/
export type JwtClaimsConfig = {
namespace: string;
userId: string;
allowedRoles: string;
defaultRole: string;
};

export type Env = {
ALLOWED_ROLES: string[];
Expand All @@ -16,7 +29,8 @@
HASURA_API_URL: string;
HASURA_GRAPHQL_JWT_SECRET: string;
JWT_ALGORITHMS: Algorithm[];
JWT_EXPIRATION: string;
JWT_CLAIMS: JwtClaimsConfig;
JWT_EXPIRATION: StringValue;
LOG_FILE: string;
LOG_LEVEL: string;
PORT: string;
Expand All @@ -29,6 +43,13 @@
VERSION: string;
};

export const defaultJwtClaims: JwtClaimsConfig = {
namespace: 'https://hasura.io/jwt/claims',
userId: 'x-hasura-user-id',
allowedRoles: 'x-hasura-allowed-roles',

Check failure on line 49 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint

Expected object keys to be in ascending order. 'allowedRoles' should be before 'userId'
defaultRole: 'x-hasura-default-role',
};

export const defaultEnv: Env = {
AERIE_DB_HOST: 'localhost',
AERIE_DB_PORT: '5432',
Expand All @@ -47,8 +68,9 @@
GQL_API_WS_URL: 'ws://localhost:8080/v1/graphql',
HASURA_API_URL: 'http://hasura:8080',
HASURA_GRAPHQL_JWT_SECRET: '',
JWT_ALGORITHMS: ['HS256'],
JWT_EXPIRATION: '36h',
JWT_ALGORITHMS: ['RS256'],
JWT_CLAIMS: defaultJwtClaims,
JWT_EXPIRATION: '36h' as StringValue,
LOG_FILE: 'console',
LOG_LEVEL: 'info',
PORT: '9000',
Expand Down Expand Up @@ -120,7 +142,13 @@
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_CLAIMS: JwtClaimsConfig = {
namespace: env['JWT_CLAIMS_NAMESPACE'] ?? defaultJwtClaims.namespace,
userId: env['JWT_CLAIMS_USER_ID'] ?? defaultJwtClaims.userId,
allowedRoles: env['JWT_CLAIMS_ALLOWED_ROLES'] ?? defaultJwtClaims.allowedRoles,

Check failure on line 148 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint

Expected object keys to be in ascending order. 'allowedRoles' should be before 'userId'
defaultRole: env['JWT_CLAIMS_DEFAULT_ROLE'] ?? defaultJwtClaims.defaultRole,
};
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 Expand Up @@ -151,6 +179,7 @@
HASURA_API_URL,
HASURA_GRAPHQL_JWT_SECRET,
JWT_ALGORITHMS,
JWT_CLAIMS,
JWT_EXPIRATION,
LOG_FILE,
LOG_LEVEL,
Expand Down
12 changes: 6 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { getEnv } from './env.js';
import getLogger from './logger.js';
import initApiPlaygroundRoutes from './packages/api-playground/api-playground.js';
import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js';
import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js';
import { validateGroupRoleMappings } from './packages/auth/functions.js';
import initAuthRoutes from './packages/auth/routes.js';
import initExpansionRoutes from './packages/expansion/expansion.js';
import { DbMerlin } from './packages/db/db.js';
import initExpansionRoutes from './packages/expansion/expansion.js';
import initExternalSourceRoutes from './packages/external-source/external-source.js';
import initFileRoutes from './packages/files/files.js';
import initHasuraRoutes from './packages/hasura/hasura-events.js';
import initHealthRoutes from './packages/health/health.js';
import initPlanRoutes from './packages/plan/plan.js';
import initSwaggerRoutes from './packages/swagger/swagger.js';
import initExternalSourceRoutes from './packages/external-source/external-source.js';
import cookieParser from 'cookie-parser';
import { AuthAdapter } from './types/auth.js';
import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js';
import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js';
import { validateGroupRoleMappings } from './packages/auth/functions.js';

async function main(): Promise<void> {
const logger = getLogger('main');
Expand Down
114 changes: 96 additions & 18 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,86 @@ 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 { type, key, jwk_url, issuer, audience }: 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 };

// Add issuer/audience validation if configured (used with JWKS/OIDC)
if (issuer) {
options.issuer = issuer;
}
if (audience) {
// jwt.verify expects string or non-empty array
options.audience = Array.isArray(audience) ? audience as [string, ...string[]] : audience;
}

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 (err) {
callback(err, null);
} else if (key) {
const signingKey = key.getPublicKey();
callback(null, signingKey);
} else {
callback(new Error('No signing key found'), null);
}
});
}

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 +208,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 { HASURA_GRAPHQL_JWT_SECRET, JWT_CLAIMS } = 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 = {
[JWT_CLAIMS.namespace]: {
[JWT_CLAIMS.allowedRoles]: allowedRoles,
[JWT_CLAIMS.defaultRole]: defaultRole,
[JWT_CLAIMS.userId]: 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 +288,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
14 changes: 11 additions & 3 deletions src/packages/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextFunction, Request, Response } from 'express';
import { getEnv } from '../../env.js';
import { decodeJwt, session } from './functions.js';

export const auth = async (req: Request, res: Response, next: NextFunction) => {
Expand All @@ -18,14 +19,21 @@ 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;
}

const defaultRole = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-default-role'] as string;
const allowedRoles = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-allowed-roles'] as string[];
const { JWT_CLAIMS } = getEnv();
const namespace = jwtPayload[JWT_CLAIMS.namespace] as Record<string, string | string[]>;
if (!namespace) {
res.status(401).send({ message: `JWT missing claims namespace: ${JWT_CLAIMS.namespace}` });
return;
}

const defaultRole = namespace[JWT_CLAIMS.defaultRole] as string;
const allowedRoles = namespace[JWT_CLAIMS.allowedRoles] as string[];

const { headers } = req;
const { 'x-hasura-role': role } = headers;
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
13 changes: 11 additions & 2 deletions src/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@ export type JwtDecode = {
jwtPayload: JwtPayload | null;
};

// JWT payload with configurable claims namespace
// The namespace key is dynamic (configured via JWT_CLAIMS_NAMESPACE)
export type JwtPayload = {
'https://hasura.io/jwt/claims': Record<string, string | string[]>;
[namespace: string]: Record<string, string | string[]> | string;
username: string;
};

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

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

// optional validation fields (used with JWKS/OIDC)
issuer?: string;
audience?: string | string[];
};

export type AuthResponse = {
Expand Down
Loading
Loading