Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions src/server/auth-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,24 @@ describe('Auth Helpers', () => {
expect(result).toBeNull();
});

it('returns null when no refresh token in session', async () => {
it('returns null when no refresh token in auth context', async () => {
mockAuthContext = {
auth: () => ({ user: { id: 'user_123' }, accessToken: 'token' }),
request: new Request('http://test.local'),
};
mockAuthkit.getSession.mockResolvedValue({ refreshToken: null });

const result = await getSessionWithRefreshToken();

expect(result).toBeNull();
});

it('returns session data with refresh token', async () => {
it('returns session data with refresh token from auth context', async () => {
const user = { id: 'user_123', email: 'test@example.com' };
const impersonator = { email: 'admin@example.com' };
mockAuthContext = {
auth: () => ({ user, accessToken: 'access_token', impersonator }),
auth: () => ({ user, accessToken: 'access_token', refreshToken: 'refresh_token', impersonator }),
request: new Request('http://test.local'),
};
mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' });

const result = await getSessionWithRefreshToken();

Expand All @@ -127,6 +125,37 @@ describe('Auth Helpers', () => {
impersonator,
});
});

it('uses middleware-refreshed token instead of stale request token', async () => {
// Simulates the case where middleware auto-refreshed the session
// (e.g., expired access token). The auth context has the NEW refresh token,
// while the original request cookie has the OLD (invalidated) one.
const user = { id: 'user_123' };
mockAuthContext = {
auth: () => ({
user,
accessToken: 'new_access_token',
refreshToken: 'new_refresh_token', // refreshed by middleware
impersonator: undefined,
}),
request: new Request('http://test.local', {
headers: { cookie: 'wos-session=old_encrypted_session' },
}),
};

const result = await getSessionWithRefreshToken();

// Should use the NEW refresh token from auth context, not the old one from request
expect(result).toEqual({
refreshToken: 'new_refresh_token',
accessToken: 'new_access_token',
user,
impersonator: undefined,
});

// Should NOT call getSession on the request (no longer needed)
expect(mockAuthkit.getSession).not.toHaveBeenCalled();
});
});

describe('refreshSession', () => {
Expand All @@ -144,10 +173,9 @@ describe('Auth Helpers', () => {
it('refreshes session and saves encrypted session', async () => {
const user = { id: 'user_123' };
mockAuthContext = {
auth: () => ({ user, accessToken: 'old_token' }),
auth: () => ({ user, accessToken: 'old_token', refreshToken: 'refresh_token' }),
request: new Request('http://test.local'),
};
mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' });
mockAuthkit.refreshSession.mockResolvedValue({
auth: { user, accessToken: 'new_token', sessionId: 'session_123' },
encryptedSession: 'encrypted_data',
Expand All @@ -171,10 +199,9 @@ describe('Auth Helpers', () => {
it('does not save session when no encrypted data', async () => {
const user = { id: 'user_123' };
mockAuthContext = {
auth: () => ({ user, accessToken: 'token' }),
auth: () => ({ user, accessToken: 'token', refreshToken: 'refresh_token' }),
request: new Request('http://test.local'),
};
mockAuthkit.getSession.mockResolvedValue({ refreshToken: 'refresh_token' });
mockAuthkit.refreshSession.mockResolvedValue({
auth: { user },
encryptedSession: null,
Expand Down
17 changes: 10 additions & 7 deletions src/server/auth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export function getRedirectUriFromContext(): string | undefined {
}

/**
* Gets the session with refresh token from the current request.
* Gets the session with refresh token from the auth context.
* Uses the middleware auth context which always has the latest refresh token,
* even if the middleware auto-refreshed during withAuth().
* Returns null if no valid session exists.
*/
export async function getSessionWithRefreshToken(): Promise<{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant async keyword

After this refactor, getSessionWithRefreshToken no longer contains any await expressions. The async keyword is now redundant — the function still behaves correctly (returning a resolved Promise), but linters with @typescript-eslint/require-await will flag this, and it may be slightly misleading to future readers who expect an I/O operation inside.

Consider removing async and returning Promise.resolve(...) explicitly, or alternatively converting the return type from Promise<...> to a synchronous return and updating all call sites (though since the public API uses await, keeping the Promise wrapper is safest).

Expand All @@ -43,16 +45,17 @@ export async function getSessionWithRefreshToken(): Promise<{
return null;
}

const ctx = getAuthKitContext();
const authkit = await getAuthkit();
const session = await authkit.getSession(ctx.request);

if (!session?.refreshToken) {
// Use the refresh token from the auth context — it's always up-to-date.
// Previously we re-read the session from the original request, but if the
// middleware auto-refreshed (e.g., expired access token), the old refresh
// token in the request cookie would already be invalidated.
const refreshToken = 'refreshToken' in auth ? auth.refreshToken : undefined;
if (!refreshToken) {
return null;
}

return {
refreshToken: session.refreshToken,
refreshToken,
accessToken: auth.accessToken,
user: auth.user,
impersonator: auth.impersonator,
Expand Down