Skip to content

Commit

Permalink
Extend session on every request, as long as it is not expired already
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Jan 7, 2025
1 parent f6f48ca commit d3de054
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 15 deletions.
37 changes: 29 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,37 @@ 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<Record<string, string>> {
const [sessionData, session] = await this.sessionAndData(request);
if (sessionData) {
return {
'Set-Cookie': await this.sessionStorage.commitSession(session),
};
}

return {};
}

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>;
4 changes: 3 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,9 @@ export async function loader(
);

const settings = sessionData && (await settingsService.userSettings(sessionData.userId));
const headers = await authHelper.refreshSessionExpiration(request);

Check warning on line 28 in app/root.tsx

View check run for this annotation

Codecov / codecov/patch

app/root.tsx#L28

Added line #L28 was not covered by tests

return { sessionData, settings };
return data({ sessionData, settings }, { headers });

Check warning on line 30 in app/root.tsx

View check run for this annotation

Codecov / codecov/patch

app/root.tsx#L30

Added line #L30 was not covered by tests
}

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 headers = await authHelper.refreshSessionExpiration(request);

expect(headers).toEqual({});
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 headers = await authHelper.refreshSessionExpiration(request);

expect(headers['Set-Cookie']).toEqual('the-cookie-value');
expect(commitSession).toHaveBeenCalled();
});
});
});

0 comments on commit d3de054

Please sign in to comment.