Skip to content

Commit 607caac

Browse files
authored
Merge commit from fork
* remove accessToken and sealedSession, add getAccessToken helper method * update README to document changes and add section about advanced witHAuth usage * add tests * remove unused includeAccessToken property in interface
1 parent e96748e commit 607caac

File tree

4 files changed

+169
-26
lines changed

4 files changed

+169
-26
lines changed

README.md

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export const loader = (args: LoaderFunctionArgs) => authkitLoader(args);
146146

147147
export function App() {
148148
// Retrieves the user from the session or returns `null` if no user is signed in
149-
// Other supported values include `sessionId`, `accessToken`, `organizationId`,
149+
// Other supported values include `sessionId`, `organizationId`,
150150
// `role`, `permissions`, `entitlements`, `featureFlags`, and `impersonator`.
151151
const { user, signInUrl, signUpUrl } = useLoaderData<typeof loader>();
152152

@@ -230,32 +230,122 @@ export async function action({ request }: ActionFunctionArgs) {
230230

231231
### Get the access token
232232

233-
Sometimes it is useful to obtain the access token directly, for instance to make API requests to another service.
233+
Access tokens are available through the `getAccessToken()` function within your loader. This design encourages server-side token usage while making the security implications explicit.
234234

235235
```tsx
236236
import { data, type LoaderFunctionArgs } from 'react-router';
237237
import { authkitLoader } from '@workos-inc/authkit-react-router';
238238

239239
export const loader = (args: LoaderFunctionArgs) =>
240-
authkitLoader(args, async ({ auth }) => {
241-
const { accessToken } = auth;
242-
243-
if (!accessToken) {
244-
// Not signed in
240+
authkitLoader(args, async ({ auth, getAccessToken }) => {
241+
if (!auth.user) {
242+
// Not signed in - getAccessToken() would return null
243+
return data({ data: null });
245244
}
246245

246+
// Explicitly call the function to get the access token
247+
const accessToken = getAccessToken();
248+
247249
const serviceData = await fetch('/api/path', {
248250
headers: {
249251
Authorization: `Bearer ${accessToken}`,
250252
},
251253
});
252254

253255
return data({
254-
data: serviceData,
256+
data: await serviceData.json(),
255257
});
256258
});
257259
```
258260

261+
#### Security Considerations
262+
263+
By default, access tokens are not included in the data sent to React components. This helps prevent unintentional token exposure in:
264+
- Browser developer tools
265+
- HTML source code
266+
- Client-side logs or error reporting
267+
268+
If you need to expose the access token to client-side code, you can explicitly return it from your loader:
269+
270+
```tsx
271+
export const loader = (args: LoaderFunctionArgs) =>
272+
authkitLoader(args, async ({ auth, getAccessToken }) => {
273+
const accessToken = getAccessToken();
274+
275+
return {
276+
// Only expose to client if absolutely necessary
277+
accessToken,
278+
userData: await fetchUserData(accessToken)
279+
};
280+
}, { ensureSignedIn: true });
281+
```
282+
283+
**Note:** Only expose access tokens to the client when necessary for your use case (e.g., making direct API calls from the browser). Consider alternatives like:
284+
- Making API calls server-side in your loaders
285+
- Creating proxy endpoints in your application
286+
- Using separate client-specific tokens with limited scope
287+
288+
#### Using with `ensureSignedIn`
289+
290+
When using the `ensureSignedIn` option, you can be confident that `getAccessToken()` will always return a valid token:
291+
292+
```tsx
293+
export const loader = (args: LoaderFunctionArgs) =>
294+
authkitLoader(args, async ({ auth, getAccessToken }) => {
295+
// With ensureSignedIn: true, the user is guaranteed to be authenticated
296+
const accessToken = getAccessToken();
297+
298+
// Use the token for your API calls
299+
const data = await fetchProtectedData(accessToken);
300+
301+
return { data };
302+
}, { ensureSignedIn: true });
303+
```
304+
305+
### Using withAuth for low-level access
306+
307+
For advanced use cases, the `withAuth` function provides direct access to authentication data, including the access token. Unlike `authkitLoader`, this function:
308+
309+
- Does not handle automatic token refresh
310+
- Does not manage cookies or session updates
311+
- Returns the access token directly as a property
312+
- Requires manual redirect handling for unauthenticated users
313+
314+
```tsx
315+
import { withAuth } from '@workos-inc/authkit-react-router';
316+
import { redirect, type LoaderFunctionArgs } from 'react-router';
317+
318+
export const loader = async (args: LoaderFunctionArgs) => {
319+
const auth = await withAuth(args);
320+
321+
if (!auth.user) {
322+
// Manual redirect - withAuth doesn't handle this automatically
323+
throw redirect('/sign-in');
324+
}
325+
326+
// Access token is directly available as a property
327+
const { accessToken, user, sessionId } = auth;
328+
329+
// Use the token for server-side operations
330+
const apiData = await fetch('https://api.example.com/data', {
331+
headers: { Authorization: `Bearer ${accessToken}` }
332+
});
333+
334+
// Be careful what you return - accessToken will be exposed if included
335+
return {
336+
user,
337+
apiData: await apiData.json(),
338+
// accessToken, // ⚠️ Only include if client-side access is necessary
339+
};
340+
};
341+
```
342+
343+
**When to use `withAuth` vs `authkitLoader`:**
344+
345+
- Use `authkitLoader` for most cases - it handles token refresh, cookies, and provides safer defaults
346+
- Use `withAuth` when you need more control or are building custom authentication flows
347+
- `withAuth` is useful for API routes or middleware where you don't need the full loader functionality
348+
259349
### Debugging
260350

261351
To enable debug logs, pass in the debug flag when using `authkitLoader`.

src/interfaces.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,27 +108,23 @@ export type AuthKitLoaderOptions = {
108108
export interface AuthorizedData {
109109
user: User;
110110
sessionId: string;
111-
accessToken: string;
112111
organizationId: string | null;
113112
role: string | null;
114113
permissions: string[];
115114
entitlements: string[];
116115
featureFlags: string[];
117116
impersonator: Impersonator | null;
118-
sealedSession: string;
119117
}
120118

121119
export interface UnauthorizedData {
122120
user: null;
123121
sessionId: null;
124-
accessToken: null;
125122
organizationId: null;
126123
role: null;
127124
permissions: null;
128125
entitlements: null;
129126
featureFlags: null;
130127
impersonator: null;
131-
sealedSession: null;
132128
}
133129

134130
/**

src/session.spec.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,13 @@ describe('session', () => {
277277

278278
expect(data).toEqual({
279279
user: null,
280-
accessToken: null,
281280
impersonator: null,
282281
organizationId: null,
283282
permissions: null,
284283
entitlements: null,
285284
featureFlags: null,
286285
role: null,
287286
sessionId: null,
288-
sealedSession: null,
289287
});
290288
});
291289

@@ -397,15 +395,13 @@ describe('session', () => {
397395

398396
expect(data).toEqual({
399397
user: mockSessionData.user,
400-
accessToken: mockSessionData.accessToken,
401398
impersonator: null,
402399
organizationId: 'org-123',
403400
permissions: ['read', 'write'],
404401
entitlements: ['premium'],
405402
featureFlags: ['flag-1', 'flag-2'],
406403
role: 'admin',
407404
sessionId: 'test-session-id',
408-
sealedSession: 'encrypted-jwt',
409405
});
410406
});
411407

@@ -422,7 +418,6 @@ describe('session', () => {
422418
customData: 'test-value',
423419
metadata: { key: 'value' },
424420
user: mockSessionData.user,
425-
accessToken: mockSessionData.accessToken,
426421
sessionId: 'test-session-id',
427422
}),
428423
);
@@ -469,6 +464,53 @@ describe('session', () => {
469464
expect(response.headers.get('X-Redirect-Reason')).toBe('test');
470465
}
471466
});
467+
468+
it('should provide getAccessToken function to custom loader', async () => {
469+
const customLoader = jest.fn().mockImplementation(({ getAccessToken }) => {
470+
const token = getAccessToken();
471+
return { retrievedToken: token };
472+
});
473+
474+
const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader);
475+
476+
// Verify the loader was called with getAccessToken function
477+
expect(customLoader).toHaveBeenCalledWith(
478+
expect.objectContaining({
479+
auth: expect.objectContaining({
480+
user: mockSessionData.user,
481+
}),
482+
getAccessToken: expect.any(Function),
483+
}),
484+
);
485+
486+
// Verify the token was retrieved correctly
487+
expect(data).toEqual(
488+
expect.objectContaining({
489+
retrievedToken: mockSessionData.accessToken,
490+
user: mockSessionData.user,
491+
}),
492+
);
493+
});
494+
495+
it('should return null from getAccessToken for unauthenticated users', async () => {
496+
// Mock no session
497+
unsealData.mockResolvedValue(null);
498+
499+
const customLoader = jest.fn().mockImplementation(({ getAccessToken }) => {
500+
const token = getAccessToken();
501+
return { retrievedToken: token };
502+
});
503+
504+
const { data } = await authkitLoader(createLoaderArgs(createMockRequest()), customLoader);
505+
506+
// Verify getAccessToken returned null
507+
expect(data).toEqual(
508+
expect.objectContaining({
509+
retrievedToken: null,
510+
user: null,
511+
}),
512+
);
513+
});
472514
});
473515

474516
describe('session refresh', () => {
@@ -539,7 +581,6 @@ describe('session', () => {
539581
// Verify the response contains the new token data
540582
expect(data).toEqual(
541583
expect.objectContaining({
542-
accessToken: 'new.valid.token',
543584
sessionId: 'new-session-id',
544585
organizationId: 'org-123',
545586
role: 'user',

src/session.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,12 @@ type LoaderValue<Data> = Response | TypedResponse<Data> | NonNullable<Data> | nu
164164
type LoaderReturnValue<Data> = Promise<LoaderValue<Data>> | LoaderValue<Data>;
165165

166166
type AuthLoader<Data> = (
167-
args: LoaderFunctionArgs & { auth: AuthorizedData | UnauthorizedData },
167+
args: LoaderFunctionArgs & { auth: AuthorizedData | UnauthorizedData; getAccessToken: () => string | null },
168168
) => LoaderReturnValue<Data>;
169169

170-
type AuthorizedAuthLoader<Data> = (args: LoaderFunctionArgs & { auth: AuthorizedData }) => LoaderReturnValue<Data>;
170+
type AuthorizedAuthLoader<Data> = (
171+
args: LoaderFunctionArgs & { auth: AuthorizedData; getAccessToken: () => string },
172+
) => LoaderReturnValue<Data>;
171173

172174
/**
173175
* This loader handles authentication state, session management, and access token refreshing
@@ -322,15 +324,13 @@ export async function authkitLoader<Data = unknown>(
322324

323325
const auth: UnauthorizedData = {
324326
user: null,
325-
accessToken: null,
326327
impersonator: null,
327328
organizationId: null,
328329
permissions: null,
329330
entitlements: null,
330331
featureFlags: null,
331332
role: null,
332333
sessionId: null,
333-
sealedSession: null,
334334
};
335335

336336
return await handleAuthLoader(loader, loaderArgs, auth);
@@ -346,7 +346,6 @@ export async function authkitLoader<Data = unknown>(
346346
featureFlags = [],
347347
} = getClaimsFromAccessToken(session.accessToken);
348348

349-
const cookieSession = await getSession(request.headers.get('Cookie'));
350349
const { impersonator = null } = session;
351350

352351
// checking for 'headers' in session determines if the session was refreshed or not
@@ -362,14 +361,12 @@ export async function authkitLoader<Data = unknown>(
362361
const auth: AuthorizedData = {
363362
user: session.user,
364363
sessionId,
365-
accessToken: session.accessToken,
366364
organizationId,
367365
role,
368366
permissions,
369367
entitlements,
370368
featureFlags,
371369
impersonator,
372-
sealedSession: cookieSession.get('jwt'),
373370
};
374371

375372
return await handleAuthLoader(loader, loaderArgs, auth, session);
@@ -420,7 +417,26 @@ async function handleAuthLoader(
420417

421418
// If there's a custom loader, get the resulting data and return it with our
422419
// auth data plus session cookie header
423-
const loaderResult = await loader({ ...args, auth: auth as AuthorizedData });
420+
let loaderResult;
421+
422+
if (auth.user) {
423+
// Authorized case
424+
const getAccessToken = () => {
425+
if (!session?.accessToken) {
426+
throw new Error('No access token available');
427+
}
428+
return session.accessToken;
429+
};
430+
loaderResult = await (loader as AuthorizedAuthLoader<unknown>)({
431+
...args,
432+
auth: auth as AuthorizedData,
433+
getAccessToken,
434+
});
435+
} else {
436+
// Unauthorized case
437+
const getAccessToken = () => null;
438+
loaderResult = await (loader as AuthLoader<unknown>)({ ...args, auth, getAccessToken });
439+
}
424440

425441
if (isResponse(loaderResult)) {
426442
// If the result is a redirect, return it unedited

0 commit comments

Comments
 (0)