Skip to content

Commit d987108

Browse files
jmortonPranav Subramanian
authored andcommitted
Add configurable JWT claim paths for OIDC flexibility
Adds environment variables to configure JWT claim paths, allowing deployments to use custom namespaces and claim names that match their OIDC provider's token structure: - JWT_CLAIMS_NAMESPACE: The namespace key in the JWT (default: https://hasura.io/jwt/claims) - JWT_CLAIMS_USER_ID: Claim name for user ID (default: x-hasura-user-id) - JWT_CLAIMS_ALLOWED_ROLES: Claim name for allowed roles (default: x-hasura-allowed-roles) - JWT_CLAIMS_DEFAULT_ROLE: Claim name for default role (default: x-hasura-default-role) This matches the claim path configurability in Aerie UI, enabling consistent configuration across both applications when using custom OIDC providers like Keycloak. Co-Authored-By: Pranav Subramanian <pranav@example.com>
1 parent 07eb90a commit d987108

7 files changed

Lines changed: 148 additions & 14 deletions

File tree

.env.template

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,11 @@ HASURA_API_URL=http://localhost:8080
99
# JWT configuration. For OIDC/JWKS, include jwk_url, issuer, and audience:
1010
# HASURA_GRAPHQL_JWT_SECRET='{ "jwk_url": "https://your-oidc-provider/.well-known/jwks", "issuer": "https://your-oidc-provider", "audience": "your-client-id" }'
1111
HASURA_GRAPHQL_JWT_SECRET=
12+
13+
# JWT claim paths - customize where user ID, roles, etc. are read from in the JWT.
14+
# Defaults follow Hasura's JWT claims namespace convention.
15+
# These should match your OIDC provider's token mapper configuration.
16+
# JWT_CLAIMS_NAMESPACE=https://hasura.io/jwt/claims
17+
# JWT_CLAIMS_USER_ID=x-hasura-user-id
18+
# JWT_CLAIMS_ALLOWED_ROLES=x-hasura-allowed-roles
19+
# JWT_CLAIMS_DEFAULT_ROLE=x-hasura-default-role

src/env.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import type { Algorithm } from 'jsonwebtoken';
22
import { GroupRoleMapping } from './types/auth';
33
import { StringValue } from 'ms';
44

5+
/**
6+
* JWT claim path configuration.
7+
* Allows customization of where user ID, roles, and other claims are read from in the JWT.
8+
* Defaults follow Hasura's JWT claims namespace convention.
9+
*/
10+
export type JwtClaimsConfig = {
11+
namespace: string;
12+
userId: string;
13+
allowedRoles: string;
14+
defaultRole: string;
15+
};
16+
517
export type Env = {
618
ALLOWED_ROLES: string[];
719
ALLOWED_ROLES_NO_AUTH: string[];
@@ -17,6 +29,7 @@ export type Env = {
1729
HASURA_API_URL: string;
1830
HASURA_GRAPHQL_JWT_SECRET: string;
1931
JWT_ALGORITHMS: Algorithm[];
32+
JWT_CLAIMS: JwtClaimsConfig;
2033
JWT_EXPIRATION: StringValue;
2134
LOG_FILE: string;
2235
LOG_LEVEL: string;
@@ -30,6 +43,13 @@ export type Env = {
3043
VERSION: string;
3144
};
3245

46+
export const defaultJwtClaims: JwtClaimsConfig = {
47+
namespace: 'https://hasura.io/jwt/claims',
48+
userId: 'x-hasura-user-id',
49+
allowedRoles: 'x-hasura-allowed-roles',
50+
defaultRole: 'x-hasura-default-role',
51+
};
52+
3353
export const defaultEnv: Env = {
3454
AERIE_DB_HOST: 'localhost',
3555
AERIE_DB_PORT: '5432',
@@ -49,6 +69,7 @@ export const defaultEnv: Env = {
4969
HASURA_API_URL: 'http://hasura:8080',
5070
HASURA_GRAPHQL_JWT_SECRET: '',
5171
JWT_ALGORITHMS: ['RS256'],
72+
JWT_CLAIMS: defaultJwtClaims,
5273
JWT_EXPIRATION: '36h' as StringValue,
5374
LOG_FILE: 'console',
5475
LOG_LEVEL: 'info',
@@ -121,6 +142,12 @@ export function getEnv(): Env {
121142
const HASURA_GRAPHQL_JWT_SECRET = env['HASURA_GRAPHQL_JWT_SECRET'] ?? defaultEnv.HASURA_GRAPHQL_JWT_SECRET;
122143
const HASURA_API_URL = env['HASURA_API_URL'] ?? defaultEnv.HASURA_API_URL;
123144
const JWT_ALGORITHMS = parseArray(env['JWT_ALGORITHMS'], defaultEnv.JWT_ALGORITHMS);
145+
const JWT_CLAIMS: JwtClaimsConfig = {
146+
namespace: env['JWT_CLAIMS_NAMESPACE'] ?? defaultJwtClaims.namespace,
147+
userId: env['JWT_CLAIMS_USER_ID'] ?? defaultJwtClaims.userId,
148+
allowedRoles: env['JWT_CLAIMS_ALLOWED_ROLES'] ?? defaultJwtClaims.allowedRoles,
149+
defaultRole: env['JWT_CLAIMS_DEFAULT_ROLE'] ?? defaultJwtClaims.defaultRole,
150+
};
124151
const JWT_EXPIRATION = (env['JWT_EXPIRATION'] as StringValue) ?? defaultEnv.JWT_EXPIRATION;
125152
const LOG_FILE = env['LOG_FILE'] ?? defaultEnv.LOG_FILE;
126153
const LOG_LEVEL = env['LOG_LEVEL'] ?? defaultEnv.LOG_LEVEL;
@@ -152,6 +179,7 @@ export function getEnv(): Env {
152179
HASURA_API_URL,
153180
HASURA_GRAPHQL_JWT_SECRET,
154181
JWT_ALGORITHMS,
182+
JWT_CLAIMS,
155183
JWT_EXPIRATION,
156184
LOG_FILE,
157185
LOG_LEVEL,

src/main.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1+
import cookieParser from 'cookie-parser';
12
import cors from 'cors';
23
import express from 'express';
34
import helmet from 'helmet';
45
import { getEnv } from './env.js';
56
import getLogger from './logger.js';
67
import initApiPlaygroundRoutes from './packages/api-playground/api-playground.js';
8+
import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js';
9+
import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js';
10+
import { validateGroupRoleMappings } from './packages/auth/functions.js';
711
import initAuthRoutes from './packages/auth/routes.js';
8-
import initExpansionRoutes from './packages/expansion/expansion.js';
912
import { DbMerlin } from './packages/db/db.js';
13+
import initExpansionRoutes from './packages/expansion/expansion.js';
14+
import initExternalSourceRoutes from './packages/external-source/external-source.js';
1015
import initFileRoutes from './packages/files/files.js';
1116
import initHasuraRoutes from './packages/hasura/hasura-events.js';
1217
import initHealthRoutes from './packages/health/health.js';
1318
import initPlanRoutes from './packages/plan/plan.js';
1419
import initSwaggerRoutes from './packages/swagger/swagger.js';
15-
import initExternalSourceRoutes from './packages/external-source/external-source.js';
16-
import cookieParser from 'cookie-parser';
1720
import { AuthAdapter } from './types/auth.js';
18-
import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js';
19-
import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js';
20-
import { validateGroupRoleMappings } from './packages/auth/functions.js';
2121

2222
async function main(): Promise<void> {
2323
const logger = getLogger('main');

src/packages/auth/functions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,15 @@ export function generateJwt(
211211
expiry: StringValue = getEnv().JWT_EXPIRATION,
212212
): string | null {
213213
try {
214-
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
214+
const { HASURA_GRAPHQL_JWT_SECRET, JWT_CLAIMS } = getEnv();
215215
const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
216216
if (key) {
217217
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
218218
const payload: JwtPayload = {
219-
'https://hasura.io/jwt/claims': {
220-
'x-hasura-allowed-roles': allowedRoles,
221-
'x-hasura-default-role': defaultRole,
222-
'x-hasura-user-id': username,
219+
[JWT_CLAIMS.namespace]: {
220+
[JWT_CLAIMS.allowedRoles]: allowedRoles,
221+
[JWT_CLAIMS.defaultRole]: defaultRole,
222+
[JWT_CLAIMS.userId]: username,
223223
},
224224
username,
225225
};

src/packages/auth/middleware.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextFunction, Request, Response } from 'express';
2+
import { getEnv } from '../../env.js';
23
import { decodeJwt, session } from './functions.js';
34

45
export const auth = async (req: Request, res: Response, next: NextFunction) => {
@@ -24,8 +25,15 @@ export const adminOnlyAuth = async (req: Request, res: Response, next: NextFunct
2425
return;
2526
}
2627

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

3038
const { headers } = req;
3139
const { 'x-hasura-role': role } = headers;

src/types/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export type JwtDecode = {
77
jwtPayload: JwtPayload | null;
88
};
99

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

test/jwt.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,91 @@ describe('decodeJwt with HS256 static key', () => {
301301
expect(result.jwtErrorMessage).toContain('invalid signature');
302302
});
303303
});
304+
305+
describe('configurable JWT claim paths', () => {
306+
beforeEach(() => {
307+
vi.stubEnv('JWT_ALGORITHMS', JSON.stringify(['RS256']));
308+
vi.stubEnv(
309+
'HASURA_GRAPHQL_JWT_SECRET',
310+
JSON.stringify({
311+
type: 'RS256',
312+
key: publicKey,
313+
}),
314+
);
315+
});
316+
317+
afterEach(() => {
318+
vi.unstubAllEnvs();
319+
});
320+
321+
test('reads claims from default Hasura namespace', async () => {
322+
// Default namespace: https://hasura.io/jwt/claims
323+
const payload = {
324+
'https://hasura.io/jwt/claims': {
325+
'x-hasura-allowed-roles': ['admin', 'user'],
326+
'x-hasura-default-role': 'admin',
327+
'x-hasura-user-id': 'user-123',
328+
},
329+
username: 'user-123',
330+
};
331+
const token = signToken(payload);
332+
333+
const result = await decodeJwt(`Bearer ${token}`);
334+
335+
expect(result.jwtErrorMessage).toBe('');
336+
expect(result.jwtPayload).not.toBeNull();
337+
const namespace = result.jwtPayload?.['https://hasura.io/jwt/claims'] as Record<string, unknown>;
338+
expect(namespace['x-hasura-user-id']).toBe('user-123');
339+
expect(namespace['x-hasura-allowed-roles']).toEqual(['admin', 'user']);
340+
expect(namespace['x-hasura-default-role']).toBe('admin');
341+
});
342+
343+
test('reads claims from custom namespace when configured', async () => {
344+
// Custom namespace
345+
vi.stubEnv('JWT_CLAIMS_NAMESPACE', 'custom/claims');
346+
vi.stubEnv('JWT_CLAIMS_USER_ID', 'sub');
347+
vi.stubEnv('JWT_CLAIMS_ALLOWED_ROLES', 'roles');
348+
vi.stubEnv('JWT_CLAIMS_DEFAULT_ROLE', 'primary_role');
349+
350+
const payload = {
351+
'custom/claims': {
352+
sub: 'custom-user-456',
353+
roles: ['editor', 'viewer'],
354+
primary_role: 'editor',
355+
},
356+
username: 'custom-user-456',
357+
};
358+
const token = signToken(payload);
359+
360+
const result = await decodeJwt(`Bearer ${token}`);
361+
362+
expect(result.jwtErrorMessage).toBe('');
363+
expect(result.jwtPayload).not.toBeNull();
364+
const namespace = result.jwtPayload?.['custom/claims'] as Record<string, unknown>;
365+
expect(namespace['sub']).toBe('custom-user-456');
366+
expect(namespace['roles']).toEqual(['editor', 'viewer']);
367+
expect(namespace['primary_role']).toBe('editor');
368+
});
369+
370+
test('supports Keycloak-style claim paths', async () => {
371+
// Keycloak typically uses realm_access.roles or resource_access
372+
vi.stubEnv('JWT_CLAIMS_NAMESPACE', 'realm_access');
373+
vi.stubEnv('JWT_CLAIMS_ALLOWED_ROLES', 'roles');
374+
375+
const payload = {
376+
realm_access: {
377+
roles: ['aerie_admin', 'aerie_user'],
378+
},
379+
preferred_username: 'keycloak-user',
380+
username: 'keycloak-user',
381+
};
382+
const token = signToken(payload);
383+
384+
const result = await decodeJwt(`Bearer ${token}`);
385+
386+
expect(result.jwtErrorMessage).toBe('');
387+
expect(result.jwtPayload).not.toBeNull();
388+
const namespace = result.jwtPayload?.['realm_access'] as Record<string, unknown>;
389+
expect(namespace['roles']).toEqual(['aerie_admin', 'aerie_user']);
390+
});
391+
});

0 commit comments

Comments
 (0)