From 3a9ed94b7ab15c75bb1a4c7af33e8355ca5a2c9b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 7 Jan 2025 08:44:59 +0100 Subject: [PATCH] Extend session on every request, as long as it is not expired already --- app/auth/auth-helper.server.ts | 35 +++++++++++++++++++++------- app/auth/session-context.ts | 4 ++++ app/auth/session.server.ts | 6 ++--- app/root.tsx | 9 ++++++- test/auth/auth-helper.server.test.ts | 33 ++++++++++++++++++++++++-- 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/app/auth/auth-helper.server.ts b/app/auth/auth-helper.server.ts index f2114b2..10cf049 100644 --- a/app/auth/auth-helper.server.ts +++ b/app/auth/auth-helper.server.ts @@ -1,8 +1,8 @@ -import type { SessionStorage } from 'react-router'; +import type { Session, SessionStorage } from 'react-router'; import { redirect } from 'react-router'; import type { Authenticator } from 'remix-auth'; import { CREDENTIALS_STRATEGY } from './auth.server'; -import type { SessionData } from './session-context'; +import type { SessionData, ShlinkSessionData } from './session-context'; /** * Wraps a SessionStorage and Authenticator to perform common authentication and session checking/commiting/destroying @@ -11,13 +11,13 @@ import type { SessionData } from './session-context'; export class AuthHelper { constructor( private readonly authenticator: Authenticator, - private readonly sessionStorage: SessionStorage<{ sessionData: SessionData }>, + private readonly sessionStorage: SessionStorage, ) {} async login(request: Request): Promise { const [sessionData, session] = await Promise.all([ this.authenticator.authenticate(CREDENTIALS_STRATEGY, request), - this.sessionStorage.getSession(request.headers.get('cookie')), + this.sessionFromRequest(request), ]); session.set('sessionData', sessionData); @@ -30,7 +30,7 @@ export class AuthHelper { } async logout(request: Request): Promise { - const session = await this.sessionStorage.getSession(request.headers.get('cookie')); + const session = await this.sessionFromRequest(request); return redirect('/login', { headers: { 'Set-Cookie': await this.sessionStorage.destroySession(session) }, }); @@ -39,9 +39,7 @@ export class AuthHelper { async getSession(request: Request): Promise; async getSession(request: Request, redirectTo: string): Promise; async getSession(request: Request, redirectTo?: string): Promise { - const session = await this.sessionStorage.getSession(request.headers.get('cookie')); - const sessionData = session.get('sessionData'); - + const [sessionData] = await this.sessionAndData(request); if (redirectTo && !sessionData) { throw redirect(redirectTo); } @@ -49,6 +47,27 @@ export class AuthHelper { return sessionData; } + /** + * Refresh an active session expiration, to avoid expiring cookies for users which are active in the app + */ + async refreshSessionExpiration(request: Request): Promise { + const [sessionData, session] = await this.sessionAndData(request); + if (sessionData) { + return await this.sessionStorage.commitSession(session); + } + + return undefined; + } + + private sessionFromRequest(request: Request): Promise> { + return this.sessionStorage.getSession(request.headers.get('cookie')); + } + + private async sessionAndData(request: Request): Promise<[SessionData | undefined, Session]> { + const session = await this.sessionFromRequest(request); + return [session.get('sessionData'), session]; + } + async isAuthenticated(request: Request): Promise { const sessionData = await this.getSession(request); return !!sessionData; diff --git a/app/auth/session-context.ts b/app/auth/session-context.ts index 2cf922a..e28a813 100644 --- a/app/auth/session-context.ts +++ b/app/auth/session-context.ts @@ -3,6 +3,10 @@ import { createContext, useContext } from 'react'; export type SessionData = { userId: string; displayName: string | null; +}; + +export type ShlinkSessionData = { + sessionData: SessionData; [key: string]: unknown; }; diff --git a/app/auth/session.server.ts b/app/auth/session.server.ts index b5d12ad..eececbf 100644 --- a/app/auth/session.server.ts +++ b/app/auth/session.server.ts @@ -1,8 +1,8 @@ import { createCookieSessionStorage } from 'react-router'; import { env, isProd } from '../utils/env.server'; -import type { SessionData } from './session-context'; +import type { ShlinkSessionData } from './session-context'; -export const createSessionStorage = () => createCookieSessionStorage<{ sessionData: SessionData }>({ +export const createSessionStorage = () => createCookieSessionStorage({ cookie: { name: 'shlink_dashboard_session', httpOnly: true, @@ -13,5 +13,3 @@ export const createSessionStorage = () => createCookieSessionStorage<{ sessionDa secure: isProd(), }, }); - -export type SessionStorage = ReturnType; diff --git a/app/root.tsx b/app/root.tsx index 992ced1..03efa89 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -2,6 +2,7 @@ import type { Theme } from '@shlinkio/shlink-frontend-kit'; import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { useEffect, useState } from 'react'; import type { LoaderFunctionArgs } from 'react-router'; +import { data } from 'react-router'; import { Links, Meta, Outlet, Scripts, useLoaderData } from 'react-router'; import { AuthHelper } from './auth/auth-helper.server'; import { SessionProvider } from './auth/session-context'; @@ -24,8 +25,14 @@ export async function loader( ); const settings = sessionData && (await settingsService.userSettings(sessionData.userId)); + const sessionCookie = await authHelper.refreshSessionExpiration(request); - return { sessionData, settings }; + return data( + { sessionData, settings }, + sessionCookie ? { + headers: { 'Set-Cookie': sessionCookie }, + } : undefined, + ); } export default function App() { diff --git a/test/auth/auth-helper.server.test.ts b/test/auth/auth-helper.server.test.ts index efbe167..8d159ac 100644 --- a/test/auth/auth-helper.server.test.ts +++ b/test/auth/auth-helper.server.test.ts @@ -2,13 +2,15 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { SessionStorage } from 'react-router'; import type { Authenticator } from 'remix-auth'; import { AuthHelper } from '../../app/auth/auth-helper.server'; -import type { SessionData } from '../../app/auth/session-context'; +import type { SessionData, ShlinkSessionData } from '../../app/auth/session-context'; describe('AuthHelper', () => { const authenticate = vi.fn(); const authenticator: Authenticator = fromPartial({ authenticate }); - const defaultSessionData = fromPartial({ displayName: 'foo' }); + const defaultSessionData = fromPartial({ + sessionData: { displayName: 'foo' }, + }); const getSessionData = vi.fn().mockReturnValue(defaultSessionData); const getSession = vi.fn().mockResolvedValue({ get: getSessionData, set: vi.fn() }); const commitSession = vi.fn(); @@ -98,4 +100,31 @@ describe('AuthHelper', () => { expect(authenticate).not.toHaveBeenCalled(); }); }); + + describe('refreshSessionExpiration', () => { + it('sets no cookie when there is no session', async () => { + const authHelper = setUp(); + const request = buildRequest(); + + getSessionData.mockReturnValue(undefined); + + const cookie = await authHelper.refreshSessionExpiration(request); + + expect(cookie).toBeUndefined(); + expect(commitSession).not.toHaveBeenCalled(); + }); + + it('sets cookie and commits session when it is not expired', async () => { + const authHelper = setUp(); + const request = buildRequest(); + + getSessionData.mockReturnValue(defaultSessionData); + commitSession.mockResolvedValue('the-cookie-value'); + + const cookie = await authHelper.refreshSessionExpiration(request); + + expect(cookie).toEqual('the-cookie-value'); + expect(commitSession).toHaveBeenCalled(); + }); + }); });