Skip to content

Commit 63e9fe2

Browse files
committed
Add configurable JWT claim paths for OIDC
Add environment variables to configure JWT claim namespace and paths: - OIDC_CLAIMS_NAMESPACE (server) / PUBLIC_OIDC_CLAIMS_NAMESPACE (client) - OIDC_CLAIMS_USER_ID / PUBLIC_OIDC_CLAIMS_USER_ID - OIDC_CLAIMS_ALLOWED_ROLES / PUBLIC_OIDC_CLAIMS_ALLOWED_ROLES - OIDC_CLAIMS_DEFAULT_ROLE / PUBLIC_OIDC_CLAIMS_DEFAULT_ROLE Defaults to Hasura's standard claim structure: https://hasura.io/jwt/claims -> x-hasura-user-id, x-hasura-allowed-roles, x-hasura-default-role Add extractClaims() helper functions in both oidc.ts (server) and auth.ts (client) to centralize claim extraction with proper validation. IMPORTANT: These settings must match: - Hasura's HASURA_GRAPHQL_JWT_SECRET claims_map - Aerie Gateway's JWT parsing logic - Your IdP's token mapper configuration
1 parent 02f021a commit 63e9fe2

3 files changed

Lines changed: 134 additions & 30 deletions

File tree

src/lib/server/oidc.ts

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { browser, dev } from '$app/environment';
2+
import { env as dynamicEnv } from '$env/dynamic/private';
23
import * as env from '$env/static/private';
3-
import type { HasuraToken, MaybeToken, Rule } from '$lib/types/oidc';
4+
import type { MaybeToken, Rule } from '$lib/types/oidc';
45
import { type Cookies, type RequestEvent } from '@sveltejs/kit';
56
import * as arctic from 'arctic';
67
import crypto from 'crypto';
@@ -27,6 +28,68 @@ const DEFAULT_JWKS_CLIENT = (() => {
2728
// Can be overridden via OIDC_ALGORITHMS env var (space-separated, e.g., "RS256 RS384 RS512")
2829
const SUPPORTED_ALGORITHMS = (env.OIDC_ALGORITHMS?.split(' ') || ['RS256']) as jwt.Algorithm[];
2930

31+
/**
32+
* JWT claim path configuration.
33+
* These paths specify where to find user identity and role information in the JWT.
34+
*
35+
* Default paths follow Hasura's JWT claims namespace convention:
36+
* https://hasura.io/jwt/claims -> x-hasura-user-id, x-hasura-allowed-roles, x-hasura-default-role
37+
*
38+
* For custom IdP configurations, override with environment variables:
39+
* OIDC_CLAIMS_NAMESPACE: The top-level claim key (default: "https://hasura.io/jwt/claims")
40+
* OIDC_CLAIMS_USER_ID: The user ID claim within the namespace (default: "x-hasura-user-id")
41+
* OIDC_CLAIMS_ALLOWED_ROLES: The allowed roles claim (default: "x-hasura-allowed-roles")
42+
* OIDC_CLAIMS_DEFAULT_ROLE: The default role claim (default: "x-hasura-default-role")
43+
*
44+
* IMPORTANT: These must match the JWT configuration in:
45+
* - Hasura's HASURA_GRAPHQL_JWT_SECRET claims_map
46+
* - Aerie Gateway's JWT parsing logic
47+
* - Your IdP's token mapper configuration
48+
*/
49+
export const CLAIMS_CONFIG = {
50+
namespace: dynamicEnv.OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims',
51+
userId: dynamicEnv.OIDC_CLAIMS_USER_ID || 'x-hasura-user-id',
52+
allowedRoles: dynamicEnv.OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles',
53+
defaultRole: dynamicEnv.OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role',
54+
};
55+
56+
/**
57+
* Extract claims from a decoded JWT token using the configured claim paths.
58+
* Supports nested claims via the namespace configuration.
59+
*
60+
* @param token - The decoded JWT payload
61+
* @returns Object with userId, allowedRoles, and defaultRole
62+
* @throws Error if required claims are missing
63+
*/
64+
export function extractClaims(token: jwt.JwtPayload): {
65+
userId: string;
66+
allowedRoles: string[];
67+
defaultRole: string;
68+
} {
69+
const namespace = token[CLAIMS_CONFIG.namespace];
70+
if (!namespace || typeof namespace !== 'object') {
71+
throw new Error(`JWT missing claims namespace: ${CLAIMS_CONFIG.namespace}`);
72+
}
73+
74+
const userId = namespace[CLAIMS_CONFIG.userId];
75+
const allowedRoles = namespace[CLAIMS_CONFIG.allowedRoles];
76+
const defaultRole = namespace[CLAIMS_CONFIG.defaultRole];
77+
78+
if (!userId || typeof userId !== 'string') {
79+
throw new Error(`JWT missing or invalid user ID claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.userId}`);
80+
}
81+
if (!Array.isArray(allowedRoles)) {
82+
throw new Error(
83+
`JWT missing or invalid allowed roles claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.allowedRoles}`,
84+
);
85+
}
86+
if (!defaultRole || typeof defaultRole !== 'string') {
87+
throw new Error(`JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`);
88+
}
89+
90+
return { userId, allowedRoles, defaultRole };
91+
}
92+
3093
/**
3194
* Base verification options for all tokens (signature, issuer, expiration).
3295
* Access tokens are treated as opaque by OIDC clients - audience validation
@@ -320,30 +383,25 @@ const mutation = `mutation InsertUser($input: users_insert_input!) {
320383
}
321384
}`; // TODO: update other user tables in permissions schema?
322385

323-
async function upsertUser(decodedAccessToken: HasuraToken, accessToken: string): Promise<void> {
324-
const username = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-user-id'];
325-
// const defaultRole = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-default-role'];
326-
const allowedRoles = decodedAccessToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles'];
386+
async function upsertUser(decodedAccessToken: jwt.JwtPayload, accessToken: string): Promise<void> {
387+
const claims = extractClaims(decodedAccessToken);
388+
const username = claims.userId;
389+
const allowedRoles = claims.allowedRoles;
327390

328-
// set the active and default role manually:
391+
// Set the active and default role based on priority (aerie_admin > user > viewer)
329392
let defaultRole = 'viewer';
330-
switch (true) {
331-
case allowedRoles.includes('aerie_admin'):
332-
defaultRole = 'aerie_admin';
333-
break;
334-
case allowedRoles.includes('user'):
335-
defaultRole = 'user';
336-
break;
337-
default:
338-
defaultRole = 'viewer';
393+
if (allowedRoles.includes('aerie_admin')) {
394+
defaultRole = 'aerie_admin';
395+
} else if (allowedRoles.includes('user')) {
396+
defaultRole = 'user';
339397
}
340398

341399
const input = { default_role: defaultRole, username };
342400
const user: User = {
343-
activeRole: defaultRole, // TODO: check allowed roles and pick highest. forget about default role.
401+
activeRole: defaultRole,
344402
allowedRoles,
345403
defaultRole,
346-
id: username, // TODO: not exactly. I think this is supposed to be decodedAccessToken.sub. but we don't even use it.
404+
id: username,
347405
permissibleQueries: null,
348406
rolePermissions: null,
349407
token: accessToken,
@@ -375,7 +433,7 @@ export async function updateWithNewTokens(cookies: Cookies, tokens: arctic.OAuth
375433

376434
// sort of an edge case, but if default role does change at the idp, it wouldn't hurt to update the local entry
377435
// TODO: should this be here? Where else could it go?
378-
await upsertUser(accessJwt as HasuraToken, tokens.accessToken());
436+
await upsertUser(accessJwt as jwt.JwtPayload, tokens.accessToken());
379437
return true;
380438
}
381439

src/routes/auth/login/+server.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { dev } from '$app/environment';
2+
import { extractClaims } from '$lib/server/oidc';
23
import type { RequestHandler } from '@sveltejs/kit';
34
import { json } from '@sveltejs/kit';
45
import { jwtDecode } from 'jwt-decode';
5-
import type { BaseUser, ParsedUserToken } from '../../../types/app';
6+
import type { BaseUser } from '../../../types/app';
67
import type { LoginRequestBody, ReqAuthResponse } from '../../../types/auth';
78
import effects from '../../../utilities/effects';
89

@@ -18,10 +19,10 @@ export const POST: RequestHandler = async event => {
1819
const user: BaseUser = { id: username, token };
1920
const userStr = JSON.stringify(user);
2021
const userCookie = Buffer.from(userStr).toString('base64');
21-
const parsedUserToken: ParsedUserToken = jwtDecode(user.token);
22-
const defaultRole = parsedUserToken['https://hasura.io/jwt/claims']['x-hasura-default-role'];
22+
const decodedToken = jwtDecode(user.token) as Record<string, unknown>;
23+
const claims = extractClaims(decodedToken);
2324

24-
event.cookies.set('activeRole', defaultRole, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev });
25+
event.cookies.set('activeRole', claims.defaultRole, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev });
2526
event.cookies.set('user', userCookie, { httpOnly: false, path: '/', sameSite: 'lax', secure: !dev });
2627
return json({ success: true, user });
2728
} else {

src/utilities/auth.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,54 @@
11
import { env } from '$env/dynamic/public';
22
import { jwtDecode } from 'jwt-decode';
3-
import type { BaseUser, ParsedUserToken, User } from '../types/app';
3+
import type { BaseUser, User } from '../types/app';
44
import effects from './effects';
55

6+
/**
7+
* JWT claim path configuration (client-side).
8+
* Must match the server-side CLAIMS_CONFIG in oidc.ts.
9+
*
10+
* Uses PUBLIC_ prefixed env vars for client accessibility.
11+
* Falls back to Hasura's standard claim namespace.
12+
*/
13+
const CLAIMS_CONFIG = {
14+
namespace: env.PUBLIC_OIDC_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims',
15+
userId: env.PUBLIC_OIDC_CLAIMS_USER_ID || 'x-hasura-user-id',
16+
allowedRoles: env.PUBLIC_OIDC_CLAIMS_ALLOWED_ROLES || 'x-hasura-allowed-roles',
17+
defaultRole: env.PUBLIC_OIDC_CLAIMS_DEFAULT_ROLE || 'x-hasura-default-role',
18+
};
19+
20+
/**
21+
* Extract claims from a decoded JWT token using the configured claim paths.
22+
*/
23+
function extractClaims(token: Record<string, unknown>): {
24+
userId: string;
25+
allowedRoles: string[];
26+
defaultRole: string;
27+
} {
28+
const namespace = token[CLAIMS_CONFIG.namespace] as Record<string, unknown> | undefined;
29+
if (!namespace || typeof namespace !== 'object') {
30+
throw new Error(`JWT missing claims namespace: ${CLAIMS_CONFIG.namespace}`);
31+
}
32+
33+
const userId = namespace[CLAIMS_CONFIG.userId] as string;
34+
const allowedRoles = namespace[CLAIMS_CONFIG.allowedRoles] as string[];
35+
const defaultRole = namespace[CLAIMS_CONFIG.defaultRole] as string;
36+
37+
if (!userId || typeof userId !== 'string') {
38+
throw new Error(`JWT missing or invalid user ID claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.userId}`);
39+
}
40+
if (!Array.isArray(allowedRoles)) {
41+
throw new Error(
42+
`JWT missing or invalid allowed roles claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.allowedRoles}`,
43+
);
44+
}
45+
if (!defaultRole || typeof defaultRole !== 'string') {
46+
throw new Error(`JWT missing or invalid default role claim: ${CLAIMS_CONFIG.namespace}.${CLAIMS_CONFIG.defaultRole}`);
47+
}
48+
49+
return { userId, allowedRoles, defaultRole };
50+
}
51+
652
export async function computeRolesFromCookies(
753
userCookie: string | null,
854
activeRoleCookie: string | null,
@@ -37,20 +83,19 @@ export async function computeRolesFromJWT(baseUser: BaseUser, activeRole: string
3783
return null; // expect to return in non-oidc case
3884
}
3985

40-
const decodedToken: ParsedUserToken = jwtDecode(baseUser.token);
86+
const decodedToken = jwtDecode(baseUser.token) as Record<string, unknown>;
87+
const claims = extractClaims(decodedToken);
4188

4289
if (baseUser.id === null && env.PUBLIC_AUTH_OIDC_ENABLED === 'true') {
43-
// since our scope is always one that includes email, and that's also a unique id, we can use that
44-
// BUT sub is the one that matches hasura's expected x-hasura-user-id, which is important.
45-
baseUser.id = decodedToken.sub;
90+
// Use the configured user ID claim, which should match Hasura's expected x-hasura-user-id
91+
baseUser.id = claims.userId;
4692
}
4793

48-
const allowedRoles = decodedToken['https://hasura.io/jwt/claims']['x-hasura-allowed-roles'];
49-
const defaultRole = decodedToken['https://hasura.io/jwt/claims']['x-hasura-default-role'];
94+
const { allowedRoles, defaultRole } = claims;
5095

5196
const user: User = {
5297
...baseUser,
53-
activeRole: activeRole && allowedRoles.includes(activeRole) ? activeRole : defaultRole, // check to make sure whatever was passed in as activeRole if not null is still in allowedRoles
98+
activeRole: activeRole && allowedRoles.includes(activeRole) ? activeRole : defaultRole,
5499
allowedRoles,
55100
defaultRole,
56101
permissibleQueries: null,

0 commit comments

Comments
 (0)