Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for logging out of all sessions #70

Closed
wants to merge 15 commits into from
Closed
18 changes: 14 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,25 @@ generator client {
}

model Session {
id String @id
sid String @unique
id String @id //Record id
sid String @unique //Session id (which can be configured to be same as record id, using dbRecordIdIsSessionId option)
uid String? //Optional user id / name
// * Non-unique; a given user may have multiple sessions - for multiple browsers, devices, etc.
// * Auto-populated by set(), if the session argument passed to set() includes a uid property.
// * Required to delete all sessions for a given user via destroyUsersSessions().
// * Enables functions within this package, such as destroyUsersSessions(), to make user-based queries.
data String
expiresAt DateTime
}

model OtherSession {
id String @id
sid String @unique
id String @id //Record id
sid String @unique //Session id (which can be configured to be same as record id, using dbRecordIdIsSessionId option)
uid String? //Optional user id / name
// * Non-unique; a given user may have multiple sessions - for multiple browsers, devices, etc.
// * Auto-populated by set(), if the session argument passed to set() includes a uid property.
// * Required to delete all sessions for a given user via destroyUsersSessions().
// * Enables functions within this package, such as destroyUsersSessions(), to make user-based queries.
data String
expiresAt DateTime
}
14 changes: 11 additions & 3 deletions src/@types/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
export interface IPrismaSession {
id: string; //record id
sid: string; //session id (which can be configured to be same as record id, using dbRecordIdIsSessionId option)
uid?: string | null; //Optional user id / name
// * Non-unique; a given user may have multiple sessions - for multiple browsers, devices, etc.
// * Auto-populated by set(), if the session argument passed to set() includes a uid property.
// * Required to delete all sessions for a given user via destroyUsersSessions().
// * Enables functions within this package, such as destroyUsersSessions(), to make user-based queries.
data: string | null;
expiresAt: Date;
id: string;
sid: string;
}

interface ICreatePrismaSession extends IPrismaSession {
export interface ICreatePrismaSession extends IPrismaSession {
data: string;
}

Expand All @@ -27,15 +32,18 @@ interface IFindManyArgs {
};
where?: {
sid?: string;
uid?: string;
};
}

interface ICreateArgs {
data: ICreatePrismaSession;
uid?: string | null;
}

interface IUpdateArgs {
data: Partial<ICreatePrismaSession>;
uid?: string | null;
where: { sid: string };
}

Expand Down
99 changes: 99 additions & 0 deletions src/lib/prisma-session-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ declare module 'express-session' {
interface SessionData {
sample?: boolean;
unrealizable?: string;
uid?: string; //Optional user id / name
// * Non-unique; a given user may have multiple sessions - for multiple browsers, devices, etc.
// * Auto-populated by set(), if the session argument passed to set() includes a uid property.
// * Required to delete all sessions for a given user via destroyUsersSessions().
// * Enables functions within this package, such as destroyUsersSessions(), to make user-based queries.
data?: string;
}
}
Expand Down Expand Up @@ -191,6 +196,86 @@ describe('PrismaSessionStore', () => {
});
});

describe('.destroyUsersSessions()', () => {
it('should delete all sessions for user associated with session sid-0 (user id: uid-0)', async () => {
const [store, { findUniqueMock, deleteManyMock }] = freshStore();

const sid0: string = 'sid-0';
const uid0: string = 'uid-0';
const session0Data: string = `{"sample": false, "uid": "${uid0}}"}`;

findUniqueMock.mockResolvedValue({
sid: sid0,
uid: uid0,
data: session0Data,
});

await store.destroyUsersSessions(sid0);

expect(findUniqueMock).toHaveBeenCalledWith(
expect.objectContaining({ where: { sid: sid0 } })
);
expect(deleteManyMock).toHaveBeenCalledWith(
expect.objectContaining({ where: { uid: uid0 } })
);

expect(deleteManyMock).toBeCalledTimes(1);
});

it('should fail gracefully when attempting to delete non-existent item', async () => {
const [store, { findUniqueMock }] = freshStore();

findUniqueMock.mockRejectedValue('Could not delete items'); //When attempting to delete non-existent item, fails on finding it

const deletePromise = store.destroyUsersSessions('sid-0');

await expect(deletePromise).resolves.toBe(undefined);
});

it('should delete an array of sids', async () => {
const [store, { deleteManyMock, findUniqueMock }] = freshStore();

const sid0: string = 'sid-0';
const uid0: string = 'uid-0';
const session0Data: string = `{"sample": false, "uid": "${uid0}}"}`;

//XXX: This value should actually change, with each array value in the
//destroyUsersSessions([]) call below... How to accomplish this with Jest?
findUniqueMock.mockResolvedValue({
sid: sid0,
uid: uid0,
data: session0Data,
});

await store.destroyUsersSessions(['sid-0', 'sid-1', 'sid-2']);

expect(deleteManyMock).toHaveBeenCalledTimes(1); //XXX Depending on the user ids associated with the array above, this value could change.
});

it('should pass errors to callback', async () => {
const [store, { findUniqueMock, deleteManyMock }] = freshStore();

//Session provided doesn't exist
const callback1 = jest.fn();
findUniqueMock.mockRejectedValue('Session doesnt exist error');
await store.destroyUsersSessions('sid-0', callback1);
expect(callback1).toHaveBeenCalledWith('Session doesnt exist error');

//Session provided doesn't have an associated user id
const callback2 = jest.fn();
const sid0: string = 'sid-0';
const session0Data: string = `{"sample": false}`;
findUniqueMock.mockResolvedValue({
sid: sid0,
data: session0Data,
});
await store.destroyUsersSessions('sid-0', callback2);
expect(callback2).toHaveBeenCalledWith(
new Error('No user id found for provided session id: sid-0')
);
});
});

describe('.touch()', () => {
it('should update a given entry', async () => {
const [store, { updateMock, findUniqueMock }] = freshStore();
Expand Down Expand Up @@ -267,6 +352,20 @@ describe('PrismaSessionStore', () => {
});
});

it('should create a new session if none exists, when optional uid arg is supplied', async () => {
const [store, { createMock }] = freshStore();

await store.set('sid-0', { cookie: {}, sample: true, uid: 'uid-0' });
expect(createMock).toHaveBeenCalledWith({
data: expect.objectContaining({
data: '{"cookie":{},"sample":true,"uid":"uid-0"}',
id: 'sid-0',
sid: 'sid-0',
uid: 'uid-0',
}),
});
});

it('should update any existing sessions', async () => {
const [store, { updateMock, findUniqueMock }] = freshStore();

Expand Down
83 changes: 78 additions & 5 deletions src/lib/prisma-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { SessionData, Store } from 'express-session';
import { dedent } from 'ts-dedent';
import type { PartialDeep } from 'type-fest';

import type { IOptions, IPrisma, ISessions } from '../@types';
import type {
IOptions,
IPrisma,
ISessions,
ICreatePrismaSession,
} from '../@types';

import { ManagedLogger } from './logger';
import { createExpiration, defer, getTTL } from './utils';
Expand Down Expand Up @@ -220,7 +225,68 @@ export class PrismaSessionStore<M extends string = 'session'> extends Store {
this.logger.warn(
`Attempt to destroy non-existent session:${String(sid)} ${String(e)}`
);
if (callback) defer(callback, e);
if (callback) return defer(callback, e);
kleydon marked this conversation as resolved.
Show resolved Hide resolved
}

if (callback) defer(callback);
};

/**
* Destroy all session(s) for the user(s) associated with the provided `sid`(s)'.
* Prerequisite:
* All sessions in the store have been created using set() with a
* `session` argument containing the `uid` (user id / name) property.
*
* @param sid a single or multiple id(s) to remove data for
* @param callback a callback notifying that the session(s) have
* been destroyed or that an error occurred
*/
public readonly destroyUsersSessions = async (
sid: string | string[],
callback?: (err?: unknown) => void
) => {
if (!(await this.validateConnection())) return callback?.();

try {
//Find all unique user ids for the sessions specified by the sid argument
const sids = Array.isArray(sid) ? sid : [sid];
const uids: { [key: string]: any } = {};
for (const id of sids) {
const s = await this.prisma[this.sessionModelName].findUnique({
where: { sid: id },
});
if (!s) {
const errMsg: string = `No session found for provided session id: ${String(
sid
)}`;
this.logger.warn(errMsg);
throw Error(errMsg);
} else {
const uid = s.uid;
if (!uid) {
const errMsg: string = `No user id found for provided session id: ${String(
sid
)}`;
this.logger.warn(errMsg);
throw Error(errMsg);
} else if (!uids[uid]) {
uids[uid] = true;
}
}
}
//Delete all sessions corresponding to the found user ids
await Promise.all(
Object.keys(uids).map(async (uid) =>
this.prisma[this.sessionModelName].deleteMany({ where: { uid } })
)
);
} catch (e: unknown) {
this.logger.warn(
`Error while destroying user's sessions for session id ${String(
sid
)}: ${String(e)}`
);
if (callback) return defer(callback, e);
kleydon marked this conversation as resolved.
Show resolved Hide resolved
}

if (callback) defer(callback);
Expand Down Expand Up @@ -361,6 +427,8 @@ export class PrismaSessionStore<M extends string = 'session'> extends Store {
rounding: this.options.roundTTL,
});

const uid: string | undefined = session.uid; //user id

let sessionString;
try {
sessionString = this.serializer.stringify(session);
Expand All @@ -377,21 +445,26 @@ export class PrismaSessionStore<M extends string = 'session'> extends Store {
})
.catch(() => null);

const data = {
let baseData: ICreatePrismaSession = {
sid,
expiresAt,
data: sessionString,
id: this.dbRecordIdIsSessionId ? sid : this.dbRecordIdFunction(sid),
};
if (uid) {
baseData = { ...baseData, uid };
}

if (existingSession !== null) {
const updateData: Partial<ICreatePrismaSession> = baseData;
await this.prisma[this.sessionModelName].update({
data,
data: updateData,
where: { sid },
});
} else {
const createData: ICreatePrismaSession = { ...baseData };
await this.prisma[this.sessionModelName].create({
data: { ...data, data: sessionString },
data: { ...createData, data: sessionString },
});
}

Expand Down