Skip to content

Commit dae7e61

Browse files
authored
Add switchToOrganization and refreshSession helpers to library (#11)
* put exports inline * put exports inline * add refreshSession method * add switchToOrganization method * target es2022 * add tests * fix formatting * fix typo * add doc comments * pass along organizationId in getAuthorizationUrl call
1 parent 743ff09 commit dae7e61

File tree

7 files changed

+518
-32
lines changed

7 files changed

+518
-32
lines changed

src/auth.spec.ts

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
1-
import { getSignInUrl, getSignUpUrl, signOut } from './auth.js';
1+
import type { User } from '@workos-inc/node';
2+
import { data, redirect } from 'react-router';
3+
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
24
import * as authorizationUrl from './get-authorization-url.js';
35
import * as session from './session.js';
6+
import { assertIsResponse } from './test-utils/test-helpers.js';
47

58
const terminateSession = jest.mocked(session.terminateSession);
9+
const refreshSession = jest.mocked(session.refreshSession);
610

711
jest.mock('./session', () => ({
812
terminateSession: jest.fn().mockResolvedValue(new Response()),
13+
refreshSession: jest.fn(),
914
}));
1015

16+
// Mock redirect and data from react-router
17+
jest.mock('react-router', () => {
18+
const originalModule = jest.requireActual('react-router');
19+
return {
20+
...originalModule,
21+
redirect: jest.fn().mockImplementation((to, init) => {
22+
const response = new Response(null, {
23+
status: 302,
24+
headers: { Location: to, ...(init?.headers || {}) },
25+
});
26+
return response;
27+
}),
28+
data: jest.fn().mockImplementation((value, init) => ({
29+
data: value,
30+
init,
31+
})),
32+
};
33+
});
34+
1135
describe('auth', () => {
1236
beforeEach(() => {
1337
jest.spyOn(authorizationUrl, 'getAuthorizationUrl');
38+
jest.clearAllMocks();
1439
});
1540

1641
describe('getSignInUrl', () => {
@@ -39,4 +64,216 @@ describe('auth', () => {
3964
expect(terminateSession).toHaveBeenCalledWith(request);
4065
});
4166
});
67+
68+
describe('switchToOrganization', () => {
69+
const request = new Request('https://example.com');
70+
const organizationId = 'org_123456';
71+
72+
// Create a mock user that matches the User type
73+
const mockUser = {
74+
id: 'user-1',
75+
76+
emailVerified: true,
77+
firstName: 'Test',
78+
lastName: 'User',
79+
profilePictureUrl: 'https://example.com/avatar.jpg',
80+
object: 'user',
81+
createdAt: '2021-01-01T00:00:00Z',
82+
updatedAt: '2021-01-01T00:00:00Z',
83+
lastSignInAt: '2021-01-01T00:00:00Z',
84+
externalId: null,
85+
} as User;
86+
87+
// Mock the return type of refreshSession
88+
const mockAuthResponse = {
89+
user: mockUser,
90+
sessionId: 'session-123',
91+
accessToken: 'new-access-token',
92+
organizationId: 'org_123456' as string | undefined,
93+
role: 'admin' as string | undefined,
94+
permissions: ['read', 'write'] as string[] | undefined,
95+
entitlements: ['premium'] as string[] | undefined,
96+
impersonator: null,
97+
sealedSession: 'sealed-session-data',
98+
headers: {
99+
'Set-Cookie': 'new-cookie-value',
100+
},
101+
};
102+
103+
beforeEach(() => {
104+
refreshSession.mockResolvedValue(mockAuthResponse);
105+
});
106+
107+
it('should call refreshSession with the correct params', async () => {
108+
await switchToOrganization(request, organizationId);
109+
110+
expect(refreshSession).toHaveBeenCalledWith(request, { organizationId });
111+
});
112+
113+
it('should return data with success and auth when no returnTo is provided', async () => {
114+
const result = await switchToOrganization(request, organizationId);
115+
116+
expect(data).toHaveBeenCalledWith(
117+
{ success: true, auth: mockAuthResponse },
118+
{
119+
headers: {
120+
'Set-Cookie': 'new-cookie-value',
121+
},
122+
},
123+
);
124+
expect(result).toEqual({
125+
data: { success: true, auth: mockAuthResponse },
126+
init: {
127+
headers: {
128+
'Set-Cookie': 'new-cookie-value',
129+
},
130+
},
131+
});
132+
});
133+
134+
it('should redirect to returnTo when provided', async () => {
135+
const returnTo = '/dashboard';
136+
const result = await switchToOrganization(request, organizationId, { returnTo });
137+
138+
expect(redirect).toHaveBeenCalledWith(returnTo, {
139+
headers: {
140+
'Set-Cookie': 'new-cookie-value',
141+
},
142+
});
143+
144+
assertIsResponse(result);
145+
expect(result.status).toBe(302);
146+
expect(result.headers.get('Location')).toBe(returnTo);
147+
expect(result.headers.get('Set-Cookie')).toBe('new-cookie-value');
148+
});
149+
150+
it('should handle case when refreshSession throws a redirect', async () => {
151+
const redirectResponse = new Response(null, {
152+
status: 302,
153+
headers: { Location: '/login' },
154+
});
155+
refreshSession.mockRejectedValueOnce(redirectResponse);
156+
157+
try {
158+
await switchToOrganization(request, organizationId);
159+
fail('Expected redirect response to be thrown');
160+
} catch (response) {
161+
assertIsResponse(response);
162+
expect(response.status).toBe(302);
163+
expect(response.headers.get('Location')).toBe('/login');
164+
}
165+
});
166+
167+
it('should redirect to authorization URL for SSO_required errors', async () => {
168+
const authUrl = 'https://api.workos.com/sso/authorize';
169+
const errorWithSSOCause = new Error('SSO Required', {
170+
cause: { error: 'sso_required' },
171+
});
172+
173+
refreshSession.mockRejectedValueOnce(errorWithSSOCause);
174+
(authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl);
175+
176+
const result = await switchToOrganization(request, organizationId);
177+
178+
expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled();
179+
expect(redirect).toHaveBeenCalledWith(authUrl);
180+
181+
assertIsResponse(result);
182+
expect(result.status).toBe(302);
183+
expect(result.headers.get('Location')).toBe(authUrl);
184+
});
185+
186+
it('should handle mfa_enrollment errors', async () => {
187+
const authUrl = 'https://api.workos.com/sso/authorize';
188+
const errorWithMFACause = new Error('MFA Enrollment Required', {
189+
cause: { error: 'mfa_enrollment' },
190+
});
191+
192+
refreshSession.mockRejectedValueOnce(errorWithMFACause);
193+
(authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl);
194+
195+
const result = await switchToOrganization(request, organizationId);
196+
197+
expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled();
198+
expect(redirect).toHaveBeenCalledWith(authUrl);
199+
200+
assertIsResponse(result);
201+
expect(result.status).toBe(302);
202+
expect(result.headers.get('Location')).toBe(authUrl);
203+
});
204+
205+
it('should return error data for Error instances', async () => {
206+
const error = new Error('Invalid organization');
207+
refreshSession.mockRejectedValueOnce(error);
208+
209+
const result = await switchToOrganization(request, organizationId);
210+
211+
expect(data).toHaveBeenCalledWith(
212+
{
213+
success: false,
214+
error: 'Invalid organization',
215+
},
216+
{ status: 400 },
217+
);
218+
expect(result).toEqual({
219+
data: {
220+
success: false,
221+
error: 'Invalid organization',
222+
},
223+
init: { status: 400 },
224+
});
225+
});
226+
227+
it('should return error data for non-Error objects', async () => {
228+
const error = 'String error message';
229+
refreshSession.mockRejectedValueOnce(error);
230+
231+
await switchToOrganization(request, organizationId);
232+
233+
expect(data).toHaveBeenCalledWith(
234+
{
235+
success: false,
236+
error: 'String error message',
237+
},
238+
{ status: 400 },
239+
);
240+
});
241+
242+
it('should handle when Set-Cookie header is missing', async () => {
243+
// Create a mock without the Set-Cookie header
244+
const mockResponseWithoutCookie = {
245+
...mockAuthResponse,
246+
headers: {},
247+
};
248+
refreshSession.mockResolvedValueOnce(mockResponseWithoutCookie);
249+
250+
await switchToOrganization(request, organizationId);
251+
252+
expect(data).toHaveBeenCalledWith(
253+
{ success: true, auth: mockResponseWithoutCookie },
254+
{
255+
headers: {
256+
'Set-Cookie': '',
257+
},
258+
},
259+
);
260+
});
261+
262+
it('should handle when returnTo is provided but Set-Cookie header is missing', async () => {
263+
// Create a mock without the Set-Cookie header
264+
const mockResponseWithoutCookie = {
265+
...mockAuthResponse,
266+
headers: {},
267+
};
268+
refreshSession.mockResolvedValueOnce(mockResponseWithoutCookie);
269+
270+
await switchToOrganization(request, organizationId, { returnTo: '/dashboard' });
271+
272+
expect(redirect).toHaveBeenCalledWith('/dashboard', {
273+
headers: {
274+
'Set-Cookie': '',
275+
},
276+
});
277+
});
278+
});
42279
});

src/auth.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,69 @@
1+
import { data, redirect } from 'react-router';
12
import { getAuthorizationUrl } from './get-authorization-url.js';
2-
import { terminateSession } from './session.js';
3+
import { refreshSession, terminateSession } from './session.js';
34

4-
async function getSignInUrl(returnPathname?: string) {
5+
export async function getSignInUrl(returnPathname?: string) {
56
return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in' });
67
}
78

8-
async function getSignUpUrl(returnPathname?: string) {
9+
export async function getSignUpUrl(returnPathname?: string) {
910
return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up' });
1011
}
1112

12-
async function signOut(request: Request) {
13+
export async function signOut(request: Request) {
1314
return await terminateSession(request);
1415
}
1516

16-
export { getSignInUrl, getSignUpUrl, signOut };
17+
/**
18+
* Switches the current session to a different organization.
19+
* @param request - The incoming request object.
20+
* @param organizationId - The ID of the organization to switch to.
21+
* @param options - Optional parameters.
22+
* @returns A redirect response to the specified returnTo URL or a data response with the updated auth data.
23+
*/
24+
export async function switchToOrganization(
25+
request: Request,
26+
organizationId: string,
27+
{ returnTo }: { returnTo?: string } = {},
28+
) {
29+
try {
30+
const auth = await refreshSession(request, { organizationId });
31+
32+
// if returnTo is provided, redirect to there
33+
if (returnTo) {
34+
return redirect(returnTo, {
35+
headers: {
36+
'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '',
37+
},
38+
});
39+
}
40+
41+
// otherwise return the updated auth data
42+
return data(
43+
{ success: true, auth },
44+
{
45+
headers: {
46+
'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '',
47+
},
48+
},
49+
);
50+
} catch (error) {
51+
if (error instanceof Response && error.status === 302) {
52+
throw error;
53+
}
54+
55+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
56+
const errorCause: any = error instanceof Error ? error.cause : null;
57+
if (errorCause?.error === 'sso_required' || errorCause?.error === 'mfa_enrollment') {
58+
return redirect(await getAuthorizationUrl({ organizationId }));
59+
}
60+
61+
return data(
62+
{
63+
success: false,
64+
error: error instanceof Error ? error.message : String(error),
65+
},
66+
{ status: 400 },
67+
);
68+
}
69+
}

src/get-authorization-url.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { getConfig } from './config.js';
2-
import { GetAuthURLOptions } from './interfaces.js';
32
import { getWorkOS } from './workos.js';
43

5-
async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
6-
const { returnPathname, screenHint } = options;
4+
interface GetAuthURLOptions {
5+
screenHint?: 'sign-up' | 'sign-in';
6+
returnPathname?: string;
7+
organizationId?: string;
8+
redirectUri?: string;
9+
loginHint?: string;
10+
}
11+
12+
export async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
13+
const { returnPathname, screenHint, organizationId, redirectUri, loginHint } = options;
714

815
return getWorkOS().userManagement.getAuthorizationUrl({
916
provider: 'authkit',
1017
clientId: getConfig('clientId'),
11-
redirectUri: getConfig('redirectUri'),
18+
redirectUri: redirectUri || getConfig('redirectUri'),
1219
state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined,
1320
screenHint,
21+
organizationId,
22+
loginHint,
1423
});
1524
}
16-
17-
export { getAuthorizationUrl };

src/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import { getSignInUrl, getSignUpUrl, signOut } from './auth.js';
1+
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
22
import { authLoader } from './authkit-callback-route.js';
33
import { configure, getConfig } from './config.js';
4-
import { authkitLoader } from './session.js';
4+
import { authkitLoader, refreshSession } from './session.js';
55
import { getWorkOS } from './workos.js';
66

77
export {
88
authLoader,
9-
//
109
authkitLoader,
11-
//
12-
getSignInUrl,
13-
getSignUpUrl,
14-
signOut,
1510
configure,
1611
getConfig,
12+
getSignInUrl,
13+
getSignUpUrl,
1714
getWorkOS,
15+
refreshSession,
16+
signOut,
17+
switchToOrganization,
1818
};

0 commit comments

Comments
 (0)