Skip to content

Commit cdfc47c

Browse files
Add user roles support (#82)
* Initial * fix defaults
1 parent 9370e47 commit cdfc47c

File tree

5 files changed

+178
-0
lines changed

5 files changed

+178
-0
lines changed

src/auth.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe('auth', () => {
108108
accessToken: 'new-access-token',
109109
organizationId: 'org_123456' as string | undefined,
110110
role: 'admin' as string | undefined,
111+
roles: ['admin', 'member'] as string[] | undefined,
111112
permissions: ['read', 'write'] as string[] | undefined,
112113
entitlements: ['premium'] as string[] | undefined,
113114
impersonator: null,
@@ -338,6 +339,7 @@ describe('auth', () => {
338339
sessionId: 'session-123',
339340
organizationId: 'org-456',
340341
role: 'admin',
342+
roles: ['admin', 'member'],
341343
permissions: ['read', 'write'],
342344
entitlements: ['feature-1', 'feature-2'],
343345
exp: Date.now() / 1000 + 3600, // 1 hour from now
@@ -359,6 +361,7 @@ describe('auth', () => {
359361
sessionId: mockClaims.sessionId,
360362
organizationId: mockClaims.organizationId,
361363
role: mockClaims.role,
364+
roles: mockClaims.roles,
362365
permissions: mockClaims.permissions,
363366
entitlements: mockClaims.entitlements,
364367
impersonator: mockSession.impersonator,
@@ -392,6 +395,7 @@ describe('auth', () => {
392395
sessionId: 'session-123',
393396
organizationId: 'org-456',
394397
role: 'admin',
398+
roles: ['admin', 'member'],
395399
permissions: ['read', 'write'],
396400
entitlements: ['feature-1', 'feature-2'],
397401
exp: Date.now() / 1000 - 3600, // 1 hour ago (expired)
@@ -419,6 +423,110 @@ describe('auth', () => {
419423
consoleWarnSpy.mockRestore();
420424
});
421425

426+
it('should handle multiple roles array properly', async () => {
427+
// Mock session with valid access token
428+
const mockSession = {
429+
accessToken: 'valid-access-token',
430+
refreshToken: 'refresh-token',
431+
user: {
432+
id: 'user-1',
433+
434+
firstName: 'Test',
435+
lastName: 'User',
436+
emailVerified: true,
437+
profilePictureUrl: 'https://example.com/profile.jpg',
438+
object: 'user' as const,
439+
createdAt: '2023-01-01T00:00:00Z',
440+
updatedAt: '2023-01-01T00:00:00Z',
441+
lastSignInAt: '2023-01-01T00:00:00Z',
442+
externalId: null,
443+
},
444+
impersonator: undefined,
445+
headers: {},
446+
};
447+
448+
// Mock claims with multiple roles
449+
const mockClaims = {
450+
sessionId: 'session-123',
451+
organizationId: 'org-456',
452+
role: 'user',
453+
roles: ['user', 'editor', 'viewer'],
454+
permissions: ['read'],
455+
entitlements: ['basic'],
456+
exp: Date.now() / 1000 + 3600,
457+
iss: 'https://api.workos.com',
458+
};
459+
460+
getSessionFromCookie.mockResolvedValue(mockSession);
461+
getClaimsFromAccessToken.mockReturnValue(mockClaims);
462+
463+
const result = await withAuth(createMockRequest('wos-session=valid-session-data'));
464+
465+
expect(result).toEqual({
466+
user: mockSession.user,
467+
sessionId: mockClaims.sessionId,
468+
organizationId: mockClaims.organizationId,
469+
role: mockClaims.role,
470+
roles: mockClaims.roles,
471+
permissions: mockClaims.permissions,
472+
entitlements: mockClaims.entitlements,
473+
impersonator: mockSession.impersonator,
474+
accessToken: mockSession.accessToken,
475+
});
476+
});
477+
478+
it('should handle missing roles field gracefully', async () => {
479+
// Mock session with valid access token
480+
const mockSession = {
481+
accessToken: 'valid-access-token',
482+
refreshToken: 'refresh-token',
483+
user: {
484+
id: 'user-1',
485+
486+
firstName: 'Test',
487+
lastName: 'User',
488+
emailVerified: true,
489+
profilePictureUrl: 'https://example.com/profile.jpg',
490+
object: 'user' as const,
491+
createdAt: '2023-01-01T00:00:00Z',
492+
updatedAt: '2023-01-01T00:00:00Z',
493+
lastSignInAt: '2023-01-01T00:00:00Z',
494+
externalId: null,
495+
},
496+
impersonator: undefined,
497+
headers: {},
498+
};
499+
500+
// Mock claims without roles field (for backward compatibility)
501+
const mockClaims = {
502+
sessionId: 'session-123',
503+
organizationId: 'org-456',
504+
role: 'user',
505+
roles: ['user'],
506+
permissions: ['read'],
507+
entitlements: ['basic'],
508+
exp: Date.now() / 1000 + 3600,
509+
iss: 'https://api.workos.com',
510+
};
511+
512+
getSessionFromCookie.mockResolvedValue(mockSession);
513+
getClaimsFromAccessToken.mockReturnValue(mockClaims);
514+
515+
const result = await withAuth(createMockRequest('wos-session=valid-session-data'));
516+
517+
expect(result).toEqual({
518+
user: mockSession.user,
519+
sessionId: mockClaims.sessionId,
520+
organizationId: mockClaims.organizationId,
521+
role: mockClaims.role,
522+
roles: mockClaims.roles,
523+
permissions: mockClaims.permissions,
524+
entitlements: mockClaims.entitlements,
525+
impersonator: mockSession.impersonator,
526+
accessToken: mockSession.accessToken,
527+
});
528+
});
529+
422530
it('should return NoUserInfo when no session exists', async () => {
423531
// Mock no session
424532
getSessionFromCookie.mockResolvedValue(null);

src/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export async function withAuth(args: LoaderFunctionArgs): Promise<UserInfo | NoU
5050
permissions,
5151
entitlements,
5252
role,
53+
roles,
5354
exp = 0,
5455
} = getClaimsFromAccessToken(session.accessToken);
5556

@@ -69,6 +70,7 @@ export async function withAuth(args: LoaderFunctionArgs): Promise<UserInfo | NoU
6970
sessionId,
7071
organizationId,
7172
role,
73+
roles,
7274
permissions,
7375
entitlements,
7476
impersonator: session.impersonator,

src/interfaces.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface AccessToken {
5656
sid: string;
5757
org_id?: string;
5858
role?: string;
59+
roles?: string[];
5960
permissions?: string[];
6061
entitlements?: string[];
6162
}
@@ -65,6 +66,7 @@ export interface UserInfo {
6566
sessionId: string;
6667
organizationId?: string;
6768
role?: string;
69+
roles?: string[];
6870
permissions?: string[];
6971
entitlements?: string[];
7072
impersonator?: Impersonator;
@@ -76,6 +78,7 @@ export interface NoUserInfo {
7678
sessionId?: undefined;
7779
organizationId?: undefined;
7880
role?: undefined;
81+
roles?: undefined;
7982
permissions?: undefined;
8083
entitlements?: undefined;
8184
impersonator?: undefined;
@@ -103,6 +106,7 @@ export interface AuthorizedData {
103106
sessionId: string;
104107
organizationId: string | null;
105108
role: string | null;
109+
roles: string[] | null;
106110
permissions: string[];
107111
entitlements: string[];
108112
impersonator: Impersonator | null;
@@ -113,6 +117,7 @@ export interface UnauthorizedData {
113117
sessionId: null;
114118
organizationId: null;
115119
role: null;
120+
roles: null;
116121
permissions: null;
117122
entitlements: null;
118123
impersonator: null;

src/session.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ describe('session', () => {
290290
permissions: null,
291291
entitlements: null,
292292
role: null,
293+
roles: null,
293294
sessionId: null,
294295
});
295296
});
@@ -356,6 +357,7 @@ describe('session', () => {
356357
sid: 'test-session-id',
357358
org_id: 'org-123',
358359
role: 'admin',
360+
roles: ['admin', 'member'],
359361
permissions: ['read', 'write'],
360362
entitlements: ['premium'],
361363
});
@@ -406,6 +408,56 @@ describe('session', () => {
406408
permissions: ['read', 'write'],
407409
entitlements: ['premium'],
408410
role: 'admin',
411+
roles: ['admin', 'member'],
412+
sessionId: 'test-session-id',
413+
});
414+
});
415+
416+
it('should handle roles array with multiple roles', async () => {
417+
// Override the JWT decoding to return multiple roles
418+
(jose.decodeJwt as jest.Mock).mockReturnValueOnce({
419+
sid: 'test-session-id',
420+
org_id: 'org-123',
421+
role: 'admin',
422+
roles: ['admin', 'member', 'viewer'],
423+
permissions: ['read', 'write'],
424+
entitlements: ['premium'],
425+
});
426+
427+
const { data } = await authkitLoader(createLoaderArgs(createMockRequest()));
428+
429+
expect(data).toEqual({
430+
user: mockSessionData.user,
431+
impersonator: null,
432+
organizationId: 'org-123',
433+
permissions: ['read', 'write'],
434+
entitlements: ['premium'],
435+
role: 'admin',
436+
roles: ['admin', 'member', 'viewer'],
437+
sessionId: 'test-session-id',
438+
});
439+
});
440+
441+
it('should handle missing roles field gracefully', async () => {
442+
// Override the JWT decoding to not include roles at all
443+
(jose.decodeJwt as jest.Mock).mockReturnValueOnce({
444+
sid: 'test-session-id',
445+
org_id: 'org-123',
446+
role: 'admin',
447+
permissions: ['read', 'write'],
448+
entitlements: ['premium'],
449+
});
450+
451+
const { data } = await authkitLoader(createLoaderArgs(createMockRequest()));
452+
453+
expect(data).toEqual({
454+
user: mockSessionData.user,
455+
impersonator: null,
456+
organizationId: 'org-123',
457+
permissions: ['read', 'write'],
458+
entitlements: ['premium'],
459+
role: 'admin',
460+
roles: null,
409461
sessionId: 'test-session-id',
410462
});
411463
});
@@ -775,6 +827,7 @@ describe('session', () => {
775827
sid: 'new-session-id',
776828
org_id: 'org-123',
777829
role: 'user',
830+
roles: ['user', 'viewer'],
778831
permissions: ['read'],
779832
entitlements: ['basic'],
780833
};
@@ -799,6 +852,7 @@ describe('session', () => {
799852
sessionId: 'new-session-id',
800853
organizationId: 'org-123',
801854
role: 'user',
855+
roles: ['user', 'viewer'],
802856
permissions: ['read'],
803857
entitlements: ['basic'],
804858
}),
@@ -942,6 +996,7 @@ describe('session', () => {
942996
sid: 'new-session-id',
943997
org_id: 'org-123',
944998
role: 'user',
999+
roles: ['user', 'viewer'],
9451000
permissions: ['read'],
9461001
entitlements: ['basic'],
9471002
});
@@ -966,6 +1021,7 @@ describe('session', () => {
9661021
accessToken: 'new.valid.token',
9671022
organizationId: 'org-123',
9681023
role: 'user',
1024+
roles: ['user', 'viewer'],
9691025
permissions: ['read'],
9701026
entitlements: ['basic'],
9711027
impersonator: null,

src/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export async function refreshSession(request: Request, { organizationId }: { org
7272
sessionId,
7373
organizationId: newOrgId,
7474
role,
75+
roles,
7576
permissions,
7677
entitlements,
7778
} = getClaimsFromAccessToken(accessToken);
@@ -82,6 +83,7 @@ export async function refreshSession(request: Request, { organizationId }: { org
8283
accessToken,
8384
organizationId: newOrgId,
8485
role,
86+
roles,
8587
permissions,
8688
entitlements,
8789
impersonator: impersonator ?? null,
@@ -329,6 +331,7 @@ export async function authkitLoader<Data = unknown>(
329331
permissions: null,
330332
entitlements: null,
331333
role: null,
334+
roles: null,
332335
sessionId: null,
333336
};
334337

@@ -340,6 +343,7 @@ export async function authkitLoader<Data = unknown>(
340343
sessionId,
341344
organizationId = null,
342345
role = null,
346+
roles = null,
343347
permissions = [],
344348
entitlements = [],
345349
} = getClaimsFromAccessToken(session.accessToken);
@@ -361,6 +365,7 @@ export async function authkitLoader<Data = unknown>(
361365
sessionId,
362366
organizationId,
363367
role,
368+
roles,
364369
permissions,
365370
entitlements,
366371
impersonator,
@@ -545,6 +550,7 @@ export function getClaimsFromAccessToken(accessToken: string) {
545550
sid: sessionId,
546551
org_id: organizationId,
547552
role,
553+
roles,
548554
permissions,
549555
entitlements,
550556
exp,
@@ -557,6 +563,7 @@ export function getClaimsFromAccessToken(accessToken: string) {
557563
sessionId,
558564
organizationId,
559565
role,
566+
roles,
560567
permissions,
561568
entitlements,
562569
};

0 commit comments

Comments
 (0)