Skip to content

Commit bd07e65

Browse files
authored
Add saveSession for manual auth flows (#52)
1 parent 6f23982 commit bd07e65

File tree

3 files changed

+159
-31
lines changed

3 files changed

+159
-31
lines changed

src/index.ts

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

77
export {
@@ -14,6 +14,7 @@ export {
1414
getSignUpUrl,
1515
getWorkOS,
1616
refreshSession,
17+
saveSession,
1718
signOut,
1819
switchToOrganization,
1920
};

src/session.spec.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getSessionStorage as getSessionStorageMock,
88
} from './sessionStorage.js';
99
import { Session } from './interfaces.js';
10-
import { authkitLoader, encryptSession, terminateSession, refreshSession } from './session.js';
10+
import { authkitLoader, encryptSession, terminateSession, refreshSession, saveSession } from './session.js';
1111
import { assertIsResponse } from './test-utils/test-helpers.js';
1212
import { getWorkOS } from './workos.js';
1313
import { getConfig } from './config.js';
@@ -818,4 +818,96 @@ describe('session', () => {
818818
);
819819
});
820820
});
821+
822+
describe('saveSession', () => {
823+
const sessionData = {
824+
accessToken: 'new.valid.token',
825+
refreshToken: 'new.refresh.token',
826+
user: {
827+
object: 'user',
828+
id: 'user-1',
829+
830+
emailVerified: true,
831+
profilePictureUrl: null,
832+
firstName: 'Test',
833+
lastName: 'User',
834+
lastSignInAt: '2021-01-01T00:00:00Z',
835+
createdAt: '2021-01-01T00:00:00Z',
836+
updatedAt: '2021-01-01T00:00:00Z',
837+
externalId: null,
838+
locale: null,
839+
metadata: {},
840+
} satisfies User,
841+
impersonator: undefined,
842+
headers: {},
843+
} satisfies Session;
844+
845+
const createMockRequest = (cookie = 'test-cookie', url = 'http://example.com./some-path') =>
846+
new Request(url, {
847+
headers: new Headers({
848+
Cookie: cookie,
849+
}),
850+
});
851+
852+
let getSession: jest.Mock;
853+
let destroySession: jest.Mock;
854+
let commitSession: jest.Mock;
855+
let mockSession: ReactRouterSession;
856+
857+
beforeEach(() => {
858+
getSession = jest.fn();
859+
destroySession = jest.fn().mockResolvedValue('destroyed-session-cookie');
860+
commitSession = jest.fn().mockResolvedValue('new-session-cookie');
861+
862+
mockSession = createMockSession({
863+
has: jest.fn().mockReturnValue(true),
864+
get: jest.fn().mockReturnValue('encrypted-jwt'),
865+
set: jest.fn(),
866+
});
867+
868+
getSessionStorage.mockResolvedValue({
869+
cookieName: 'wos-cookie',
870+
getSession,
871+
destroySession,
872+
commitSession,
873+
});
874+
875+
getSession.mockResolvedValue(mockSession);
876+
877+
const validSessionData = {
878+
accessToken: 'valid.token',
879+
refreshToken: 'refresh.token',
880+
user: {
881+
id: 'user-1',
882+
883+
firstName: 'Test',
884+
lastName: 'User',
885+
object: 'user',
886+
},
887+
impersonator: null,
888+
};
889+
unsealData.mockResolvedValue(validSessionData);
890+
sealData.mockResolvedValue('new-encrypted-jwt');
891+
892+
authenticateWithRefreshToken.mockResolvedValue(sessionData);
893+
894+
// Mock JWT decoding
895+
(jose.decodeJwt as jest.Mock).mockReturnValue({
896+
sid: 'new-session-id',
897+
org_id: 'org-123',
898+
role: 'user',
899+
roles: ['user'],
900+
permissions: ['read'],
901+
entitlements: ['basic'],
902+
feature_flags: ['flag-1'],
903+
});
904+
});
905+
906+
it('should save the session to the cookie', async () => {
907+
await saveSession(sessionData, createMockRequest());
908+
expect(getSessionStorage).toHaveBeenCalled();
909+
expect(commitSession).toHaveBeenCalledWith(mockSession);
910+
expect(mockSession.set).toHaveBeenCalledWith('jwt', 'new-encrypted-jwt');
911+
});
912+
});
821913
});

src/session.ts

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
1616
import { getConfig } from './config.js';
1717
import { configureSessionStorage, getSessionStorage } from './sessionStorage.js';
1818
import { isDataWithResponseInit, isJsonResponse, isRedirect, isResponse } from './utils.js';
19+
import type { AuthenticationResponse } from '@workos-inc/node';
1920

2021
// must be a type since this is a subtype of response
2122
// interfaces must conform to the types they extend
@@ -37,37 +38,24 @@ export class SessionRefreshError extends Error {
3738
* @param options - Optional configuration options
3839
* @returns A promise that resolves to the new session object
3940
*/
40-
export async function refreshSession(request: Request, { organizationId }: { organizationId?: string } = {}) {
41-
const { getSession, commitSession } = await getSessionStorage();
42-
const session = await getSessionFromCookie(request.headers.get('Cookie') as string);
43-
41+
export async function refreshSession(request: Request, options: { organizationId?: string } = {}) {
42+
const { organizationId } = options;
43+
const { getSession } = await getSessionStorage();
44+
const cookie = request.headers.get('Cookie');
45+
const session = cookie ? await getSessionFromCookie(cookie) : null;
4446
if (!session) {
4547
throw redirect(await getAuthorizationUrl());
4648
}
4749

4850
try {
49-
const { accessToken, refreshToken, user, impersonator } =
50-
await getWorkOS().userManagement.authenticateWithRefreshToken({
51-
clientId: getConfig('clientId'),
52-
refreshToken: session.refreshToken,
53-
organizationId,
54-
});
55-
56-
const newSession = {
57-
accessToken,
58-
refreshToken,
59-
user,
60-
impersonator,
61-
headers: {} as Record<string, string>,
62-
};
63-
64-
const cookieSession = await getSession(request.headers.get('Cookie'));
65-
cookieSession.set('jwt', await encryptSession(newSession));
66-
const cookie = await commitSession(cookieSession);
67-
68-
newSession.headers = {
69-
'Set-Cookie': cookie,
70-
};
51+
const refreshResult = await getWorkOS().userManagement.authenticateWithRefreshToken({
52+
clientId: getConfig('clientId'),
53+
refreshToken: session.refreshToken,
54+
organizationId,
55+
});
56+
const { headers } = await saveSession(refreshResult, request);
57+
const cookieSession = await getSession(cookie);
58+
const { accessToken, user, impersonator } = refreshResult;
7159

7260
const {
7361
sessionId,
@@ -91,7 +79,7 @@ export async function refreshSession(request: Request, { organizationId }: { org
9179
featureFlags,
9280
impersonator: impersonator ?? null,
9381
sealedSession: cookieSession.get('jwt'),
94-
headers: newSession.headers,
82+
headers,
9583
};
9684
} catch (error) {
9785
throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, {
@@ -100,7 +88,54 @@ export async function refreshSession(request: Request, { organizationId }: { org
10088
}
10189
}
10290

103-
async function updateSession(request: Request, debug: boolean) {
91+
/**
92+
* Saves a WorkOS session to a cookie for use with AuthKit.
93+
*
94+
* This function is intended for advanced use cases where you need to manually
95+
* manage sessions, such as custom authentication flows (email verification,
96+
* etc.) that don't use the standard AuthKit authentication flow.
97+
*
98+
* @param sessionOrResponse The WorkOS session or AuthenticationResponse
99+
* containing access token, refresh token, and user information.
100+
* @param request A Request object, used to determine cookie settings.
101+
*
102+
* @example
103+
* import { saveSession } from '@workos-inc/authkit-react-router';
104+
*
105+
* async function handleEmailVerification(req: Request) {
106+
* const { code } = await req.json();
107+
* const authResponse = await workos.userManagement.authenticateWithEmailVerification({
108+
* clientId: process.env.WORKOS_CLIENT_ID,
109+
* code,
110+
* });
111+
*
112+
* await saveSession(authResponse, req);
113+
* }
114+
*/
115+
export async function saveSession(
116+
sessionOrResponse: Session | AuthenticationResponse,
117+
request: Request,
118+
): Promise<Session> {
119+
const { getSession, commitSession } = await getSessionStorage();
120+
const { accessToken, refreshToken, user, impersonator } = sessionOrResponse;
121+
const newSession: Session = {
122+
accessToken,
123+
refreshToken,
124+
user,
125+
impersonator,
126+
headers: {},
127+
};
128+
const cookieSession = await getSession(request.headers.get('Cookie'));
129+
cookieSession.set('jwt', await encryptSession(newSession));
130+
const cookie = await commitSession(cookieSession);
131+
newSession.headers = {
132+
'Set-Cookie': cookie,
133+
};
134+
135+
return newSession;
136+
}
137+
138+
async function updateSession(request: Request, debug: boolean): Promise<Session | null> {
104139
const session = await getSessionFromCookie(request.headers.get('Cookie') as string);
105140
const { commitSession, getSession } = await getSessionStorage();
106141

@@ -158,7 +193,7 @@ async function updateSession(request: Request, debug: boolean) {
158193
}
159194
}
160195

161-
export async function encryptSession(session: Session) {
196+
export async function encryptSession(session: Session | AuthenticationResponse) {
162197
return sealData(session, {
163198
password: getConfig('cookiePassword'),
164199
ttl: 0,

0 commit comments

Comments
 (0)