Skip to content

Commit 7610090

Browse files
authored
Merge branch 'main' into nicknisi/session-refresh-callbacks
2 parents b0d550a + a1f64d2 commit 7610090

File tree

5 files changed

+290
-7
lines changed

5 files changed

+290
-7
lines changed

src/auth.spec.ts

+201-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { User } from '@workos-inc/node';
2-
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
2+
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization, withAuth } from './auth.js';
33
import * as authorizationUrl from './get-authorization-url.js';
44
import * as session from './session.js';
5-
import { data, redirect } from '@remix-run/node';
5+
import * as configModule from './config.js';
6+
import { data, redirect, LoaderFunctionArgs } from '@remix-run/node';
67
import { assertIsResponse } from './test-utils/test-helpers.js';
78

89
const terminateSession = jest.mocked(session.terminateSession);
910
const refreshSession = jest.mocked(session.refreshSession);
11+
const getSessionFromCookie = jest.mocked(session.getSessionFromCookie);
12+
const getClaimsFromAccessToken = jest.mocked(session.getClaimsFromAccessToken);
13+
const getConfig = jest.mocked(configModule.getConfig);
1014

1115
jest.mock('./session', () => ({
1216
terminateSession: jest.fn().mockResolvedValue(new Response()),
1317
refreshSession: jest.fn(),
18+
getSessionFromCookie: jest.fn(),
19+
getClaimsFromAccessToken: jest.fn(),
20+
}));
21+
22+
jest.mock('./config', () => ({
23+
getConfig: jest.fn(),
1424
}));
1525

1626
// Mock redirect and data from react-router
@@ -283,4 +293,193 @@ describe('auth', () => {
283293
});
284294
});
285295
});
296+
297+
describe('withAuth', () => {
298+
const createMockRequest = (cookie?: string) => {
299+
return {
300+
request: new Request('https://example.com', {
301+
headers: cookie ? { Cookie: cookie } : {},
302+
}),
303+
} as LoaderFunctionArgs;
304+
};
305+
306+
beforeEach(() => {
307+
jest.clearAllMocks();
308+
getConfig.mockReturnValue('wos-session');
309+
});
310+
311+
it('should return user info when a valid session exists', async () => {
312+
// Mock session with valid access token
313+
const mockSession = {
314+
accessToken: 'valid-access-token',
315+
refreshToken: 'refresh-token',
316+
user: {
317+
id: 'user-1',
318+
319+
firstName: 'Test',
320+
lastName: 'User',
321+
emailVerified: true,
322+
profilePictureUrl: 'https://example.com/profile.jpg',
323+
object: 'user' as const,
324+
createdAt: '2023-01-01T00:00:00Z',
325+
updatedAt: '2023-01-01T00:00:00Z',
326+
lastSignInAt: '2023-01-01T00:00:00Z',
327+
externalId: null,
328+
},
329+
impersonator: {
330+
331+
reason: 'testing',
332+
},
333+
headers: {},
334+
};
335+
336+
// Mock claims from access token
337+
const mockClaims = {
338+
sessionId: 'session-123',
339+
organizationId: 'org-456',
340+
role: 'admin',
341+
permissions: ['read', 'write'],
342+
entitlements: ['feature-1', 'feature-2'],
343+
exp: Date.now() / 1000 + 3600, // 1 hour from now
344+
iss: 'https://api.workos.com',
345+
};
346+
347+
getSessionFromCookie.mockResolvedValue(mockSession);
348+
getClaimsFromAccessToken.mockReturnValue(mockClaims);
349+
350+
const result = await withAuth(createMockRequest('wos-session=valid-session-data'));
351+
352+
// Verify called with correct params
353+
expect(getSessionFromCookie).toHaveBeenCalledWith('wos-session=valid-session-data');
354+
expect(getClaimsFromAccessToken).toHaveBeenCalledWith('valid-access-token');
355+
356+
// Check result contains expected user info
357+
expect(result).toEqual({
358+
user: mockSession.user,
359+
sessionId: mockClaims.sessionId,
360+
organizationId: mockClaims.organizationId,
361+
role: mockClaims.role,
362+
permissions: mockClaims.permissions,
363+
entitlements: mockClaims.entitlements,
364+
impersonator: mockSession.impersonator,
365+
accessToken: mockSession.accessToken,
366+
});
367+
});
368+
369+
it('should handle expired access tokens', async () => {
370+
// Mock session with expired access token
371+
const mockSession = {
372+
accessToken: 'expired-access-token',
373+
refreshToken: 'refresh-token',
374+
user: {
375+
id: 'user-1',
376+
377+
firstName: 'Test',
378+
lastName: 'User',
379+
emailVerified: true,
380+
profilePictureUrl: 'https://example.com/profile.jpg',
381+
object: 'user' as const,
382+
createdAt: '2023-01-01T00:00:00Z',
383+
updatedAt: '2023-01-01T00:00:00Z',
384+
lastSignInAt: '2023-01-01T00:00:00Z',
385+
externalId: null,
386+
},
387+
headers: {},
388+
};
389+
390+
// Mock claims with expired token
391+
const mockClaims = {
392+
sessionId: 'session-123',
393+
organizationId: 'org-456',
394+
role: 'admin',
395+
permissions: ['read', 'write'],
396+
entitlements: ['feature-1', 'feature-2'],
397+
exp: Date.now() / 1000 - 3600, // 1 hour ago (expired)
398+
iss: 'https://api.workos.com',
399+
};
400+
401+
getSessionFromCookie.mockResolvedValue(mockSession);
402+
getClaimsFromAccessToken.mockReturnValue(mockClaims);
403+
404+
// Spy on console.warn
405+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
406+
407+
const result = await withAuth(createMockRequest('wos-session=expired-session-data'));
408+
409+
// Should warn about expired token
410+
expect(consoleWarnSpy).toHaveBeenCalledWith('Access token expired for user');
411+
412+
// Result should still contain user info
413+
expect(result).toEqual({
414+
user: mockSession.user,
415+
sessionId: mockClaims.sessionId,
416+
organizationId: mockClaims.organizationId,
417+
role: mockClaims.role,
418+
permissions: mockClaims.permissions,
419+
entitlements: mockClaims.entitlements,
420+
impersonator: undefined,
421+
accessToken: mockSession.accessToken,
422+
});
423+
424+
consoleWarnSpy.mockRestore();
425+
});
426+
427+
it('should return NoUserInfo when no session exists', async () => {
428+
// Mock no session
429+
getSessionFromCookie.mockResolvedValue(null);
430+
431+
const result = await withAuth(createMockRequest());
432+
433+
expect(result).toEqual({
434+
user: null,
435+
});
436+
437+
// getClaimsFromAccessToken should not be called
438+
expect(getClaimsFromAccessToken).not.toHaveBeenCalled();
439+
});
440+
441+
it('should return NoUserInfo when session exists but has no access token', async () => {
442+
// Mock session with no access token - we'll add a dummy accessToken that will be ignored
443+
getSessionFromCookie.mockResolvedValue({
444+
user: {
445+
id: 'user-1',
446+
447+
firstName: 'Test',
448+
lastName: 'User',
449+
emailVerified: true,
450+
profilePictureUrl: 'https://example.com/profile.jpg',
451+
object: 'user' as const,
452+
createdAt: '2023-01-01T00:00:00Z',
453+
updatedAt: '2023-01-01T00:00:00Z',
454+
lastSignInAt: '2023-01-01T00:00:00Z',
455+
externalId: null,
456+
},
457+
refreshToken: 'refresh-token',
458+
headers: {},
459+
accessToken: '', // Empty string to meet type requirement but it will be treated as falsy
460+
});
461+
462+
const result = await withAuth(createMockRequest('wos-session=invalid-session-data'));
463+
464+
expect(result).toEqual({
465+
user: null,
466+
});
467+
468+
// getClaimsFromAccessToken should not be called
469+
expect(getClaimsFromAccessToken).not.toHaveBeenCalled();
470+
});
471+
472+
it('should warn when no cookie header includes the cookie name', async () => {
473+
// Spy on console.warn
474+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
475+
476+
getSessionFromCookie.mockResolvedValue(null);
477+
478+
await withAuth(createMockRequest('other-cookie=value'));
479+
480+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('No session cookie "wos-session" found.'));
481+
482+
consoleWarnSpy.mockRestore();
483+
});
484+
});
286485
});

src/auth.ts

+59-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { data, redirect } from '@remix-run/node';
1+
import { LoaderFunctionArgs, data, redirect } from '@remix-run/node';
22
import { getAuthorizationUrl } from './get-authorization-url.js';
3-
import { refreshSession, terminateSession } from './session.js';
3+
import { NoUserInfo, UserInfo } from './interfaces.js';
4+
import { getClaimsFromAccessToken, getSessionFromCookie, refreshSession, terminateSession } from './session.js';
5+
import { getConfig } from './config.js';
46

57
export async function getSignInUrl(returnPathname?: string) {
68
return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in' });
@@ -14,6 +16,61 @@ export async function signOut(request: Request, options?: { returnTo?: string })
1416
return await terminateSession(request, options);
1517
}
1618

19+
/**
20+
* Given a loader's args, this function will check if the user is authenticated.
21+
* If the user is authenticated, it will return their information.
22+
* If the user is not authenticated, it will return an object with user set to null.
23+
* IMPORTANT: This authkitLoader must be used in a parent/root loader
24+
* to handle session refresh and cookie management.
25+
* @param args - The loader's arguments.
26+
* @returns An object containing user information
27+
*/
28+
export async function withAuth(args: LoaderFunctionArgs): Promise<UserInfo | NoUserInfo> {
29+
const { request } = args;
30+
const cookieHeader = request.headers.get('Cookie') as string;
31+
const cookieName = getConfig('cookieName');
32+
33+
// Simple check without environment detection
34+
if (!cookieHeader || !cookieHeader.includes(cookieName)) {
35+
console.warn(
36+
`[AuthKit] No session cookie "${cookieName}" found. ` + `Make sure authkitLoader is used in a parent/root route.`,
37+
);
38+
}
39+
const session = await getSessionFromCookie(cookieHeader);
40+
41+
if (!session?.accessToken) {
42+
return {
43+
user: null,
44+
};
45+
}
46+
47+
const {
48+
sessionId,
49+
organizationId,
50+
permissions,
51+
entitlements,
52+
role,
53+
exp = 0,
54+
} = getClaimsFromAccessToken(session.accessToken);
55+
56+
if (Date.now() >= exp * 1000) {
57+
// The access token is expired. This function does not handle token refresh.
58+
// Ensure that token refresh is implemented in the parent/root loader as documented.
59+
console.warn('Access token expired for user');
60+
}
61+
62+
return {
63+
user: session.user,
64+
sessionId,
65+
organizationId,
66+
role,
67+
permissions,
68+
entitlements,
69+
impersonator: session.impersonator,
70+
accessToken: session.accessToken,
71+
};
72+
}
73+
1774
/**
1875
* Switches the current session to a different organization.
1976
* @param request - The incoming request object.

src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSignInUrl, getSignUpUrl, signOut } from './auth.js';
1+
import { getSignInUrl, getSignUpUrl, signOut, withAuth } from './auth.js';
22
import { authLoader } from './authkit-callback-route.js';
33
import { configure, getConfig } from './config.js';
44
import { authkitLoader } from './session.js';
@@ -15,4 +15,5 @@ export {
1515
configure,
1616
getConfig,
1717
getWorkOS,
18+
withAuth,
1819
};

src/interfaces.ts

+22
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ export interface AccessToken {
5050
entitlements?: string[];
5151
}
5252

53+
export interface UserInfo {
54+
user: User;
55+
sessionId: string;
56+
organizationId?: string;
57+
role?: string;
58+
permissions?: string[];
59+
entitlements?: string[];
60+
impersonator?: Impersonator;
61+
accessToken: string;
62+
}
63+
64+
export interface NoUserInfo {
65+
user: null;
66+
sessionId?: undefined;
67+
organizationId?: undefined;
68+
role?: undefined;
69+
permissions?: undefined;
70+
entitlements?: undefined;
71+
impersonator?: undefined;
72+
accessToken?: undefined;
73+
}
74+
5375
export type AuthKitLoaderOptions = {
5476
ensureSignedIn?: boolean;
5577
debug?: boolean;

src/session.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -518,16 +518,20 @@ export async function terminateSession(request: Request, { returnTo }: { returnT
518518
});
519519
}
520520

521-
function getClaimsFromAccessToken(accessToken: string) {
521+
export function getClaimsFromAccessToken(accessToken: string) {
522522
const {
523523
sid: sessionId,
524524
org_id: organizationId,
525525
role,
526526
permissions,
527527
entitlements,
528+
exp,
529+
iss,
528530
} = decodeJwt<AccessToken>(accessToken);
529531

530532
return {
533+
iss,
534+
exp,
531535
sessionId,
532536
organizationId,
533537
role,
@@ -536,7 +540,7 @@ function getClaimsFromAccessToken(accessToken: string) {
536540
};
537541
}
538542

539-
async function getSessionFromCookie(cookie: string, session?: SessionData) {
543+
export async function getSessionFromCookie(cookie: string, session?: SessionData) {
540544
const { getSession } = await getSessionStorage();
541545
if (!session) {
542546
session = await getSession(cookie);

0 commit comments

Comments
 (0)