Skip to content

Commit

Permalink
Merge pull request #334 from acelaya-forks/feature/extend-session
Browse files Browse the repository at this point in the history
Extend session on every request, as long as it is not expired already
  • Loading branch information
acelaya authored Jan 7, 2025
2 parents f6f48ca + 3a9ed94 commit fd29720
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 15 deletions.
35 changes: 27 additions & 8 deletions app/auth/auth-helper.server.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,13 +11,13 @@ import type { SessionData } from './session-context';
export class AuthHelper {
constructor(
private readonly authenticator: Authenticator<SessionData>,
private readonly sessionStorage: SessionStorage<{ sessionData: SessionData }>,
private readonly sessionStorage: SessionStorage<ShlinkSessionData>,
) {}

async login(request: Request): Promise<Response> {
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);

Expand All @@ -30,7 +30,7 @@ export class AuthHelper {
}

async logout(request: Request): Promise<Response> {
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) },
});
Expand All @@ -39,16 +39,35 @@ export class AuthHelper {
async getSession(request: Request): Promise<SessionData | undefined>;
async getSession(request: Request, redirectTo: string): Promise<SessionData>;
async getSession(request: Request, redirectTo?: string): Promise<SessionData | undefined> {
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);
}

return sessionData;
}

/**
* Refresh an active session expiration, to avoid expiring cookies for users which are active in the app
*/
async refreshSessionExpiration(request: Request): Promise<string | undefined> {
const [sessionData, session] = await this.sessionAndData(request);
if (sessionData) {
return await this.sessionStorage.commitSession(session);
}

return undefined;
}

private sessionFromRequest(request: Request): Promise<Session<ShlinkSessionData>> {
return this.sessionStorage.getSession(request.headers.get('cookie'));
}

private async sessionAndData(request: Request): Promise<[SessionData | undefined, Session<ShlinkSessionData>]> {
const session = await this.sessionFromRequest(request);
return [session.get('sessionData'), session];
}

async isAuthenticated(request: Request): Promise<boolean> {
const sessionData = await this.getSession(request);
return !!sessionData;
Expand Down
4 changes: 4 additions & 0 deletions app/auth/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
6 changes: 2 additions & 4 deletions app/auth/session.server.ts
Original file line number Diff line number Diff line change
@@ -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<ShlinkSessionData>({
cookie: {
name: 'shlink_dashboard_session',
httpOnly: true,
Expand All @@ -13,5 +13,3 @@ export const createSessionStorage = () => createCookieSessionStorage<{ sessionDa
secure: isProd(),
},
});

export type SessionStorage = ReturnType<typeof createSessionStorage>;
9 changes: 8 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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() {
Expand Down
33 changes: 31 additions & 2 deletions test/auth/auth-helper.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionData> = fromPartial({ authenticate });

const defaultSessionData = fromPartial<SessionData>({ displayName: 'foo' });
const defaultSessionData = fromPartial<ShlinkSessionData>({
sessionData: { displayName: 'foo' },
});
const getSessionData = vi.fn().mockReturnValue(defaultSessionData);
const getSession = vi.fn().mockResolvedValue({ get: getSessionData, set: vi.fn() });
const commitSession = vi.fn();
Expand Down Expand Up @@ -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();
});
});
});

0 comments on commit fd29720

Please sign in to comment.