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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ Three new options were added apart from the work that was already done by [memor
- `startInterval()` and `stopInterval()` methods to start/clear the automatic check for expired.
- `prune()` that you can use to manually remove only the expired entries from the store.
- `shutdown()` that can be used to stop any intervals and disconnect from prisma.
- `destroyUsersSessions(sid, callback)` to destroy all sessions for user associated with `sid` (presuming sessions have been created with `data` containing a `uid` user id/name property).

## Author

Expand Down
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
}
15 changes: 12 additions & 3 deletions src/@types/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
export interface IPrismaSession {
id: string; // record id
sid: string; // session id (which can be configured to be same as record id, using dbRecordIdIsSessionId option)
// 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 deletenpm install -g tslint all sessions for a given user via destroyUsersSessions().
// * Enables functions within this package, such as destroyUsersSessions(), to make user-based queries.
uid?: string | null;
data: string | null;
expiresAt: Date;
id: string;
sid: string;
}

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

Expand All @@ -27,15 +33,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
111 changes: 110 additions & 1 deletion src/lib/prisma-session-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ declare module 'express-session' {
interface SessionData {
sample?: boolean;
unrealizable?: 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.
*/
uid?: string;
data?: string;
}
}
Expand Down Expand Up @@ -191,6 +200,88 @@ describe('PrismaSessionStore', () => {
});
});

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

const callback = jest.fn();

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

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

await store.destroyUsersSessions(sid0, callback);

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.mockResolvedValue(null);

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 = 'sid-0';
const uid0 = 'uid-0';
const session0Data = `{"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 }] = 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 = 'sid-0';
const session0Data = `{"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 +358,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 Expand Up @@ -536,6 +641,7 @@ describe('PrismaSessionStore', () => {
await invalidPrisma.set('', {});
await invalidPrisma.touch('', {});
await invalidPrisma.destroy('');
await invalidPrisma.destroyUsersSessions('');
await invalidPrisma.all();
await invalidPrisma.ids();
await invalidPrisma.length();
Expand All @@ -546,6 +652,7 @@ describe('PrismaSessionStore', () => {
await invalidPrismaKeys.set('', {});
await invalidPrismaKeys.touch('', {});
await invalidPrismaKeys.destroy('');
await invalidPrismaKeys.destroyUsersSessions('');
await invalidPrismaKeys.all();
await invalidPrismaKeys.ids();
await invalidPrismaKeys.length();
Expand All @@ -557,6 +664,7 @@ describe('PrismaSessionStore', () => {
await invalidPrisma.set('', {}, callback);
await invalidPrisma.touch('', {}, callback);
await invalidPrisma.destroy('', callback);
await invalidPrisma.destroyUsersSessions('', callback);
await invalidPrisma.all(callback);
await invalidPrisma.ids(callback);
await invalidPrisma.length(callback);
Expand All @@ -566,12 +674,13 @@ describe('PrismaSessionStore', () => {
await invalidPrismaKeys.set('', {}, callback);
await invalidPrismaKeys.touch('', {}, callback);
await invalidPrismaKeys.destroy('', callback);
await invalidPrismaKeys.destroyUsersSessions('', callback);
await invalidPrismaKeys.all(callback);
await invalidPrismaKeys.ids(callback);
await invalidPrismaKeys.length(callback);
await invalidPrismaKeys.clear(callback);

expect(callback).toHaveBeenCalledTimes(16);
expect(callback).toHaveBeenCalledTimes(18);
});
});

Expand Down
92 changes: 90 additions & 2 deletions src/lib/prisma-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,32 @@ 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 {
ICreatePrismaSession,
IOptions,
IPrisma,
ISessions,
} from '../@types';

import { ManagedLogger } from './logger';
import { createExpiration, defer, getTTL } from './utils';

declare module 'express-session' {
// tslint:disable-next-line: naming-convention
interface SessionData {
/**
* 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.
*/
uid?: string;
data?: string;
}
}

/**
* An `express-session` store used in the `express-session` options
* to hook up prisma as a session store
Expand Down Expand Up @@ -221,6 +242,70 @@ export class PrismaSessionStore<M extends string = 'session'> extends Store {
`Attempt to destroy non-existent session:${String(sid)} ${String(e)}`
);
if (callback) defer(callback, e);

return;
}

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]: boolean } = {};
for (const id of sids) {
const s = await this.prisma[this.sessionModelName].findUnique({
where: { sid: id },
});
if (s === null) {
const errMsg = `No session found for provided session id: ${String(
sid
)}`;
this.logger.warn(errMsg);
throw Error(errMsg);
}
const uid = s.uid;
if (typeof uid !== 'string' || uid === '') {
const errMsg = `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) defer(callback, e);

return;
}

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

const uid = session.uid; // user id

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

const data = {
const data: ICreatePrismaSession = {
...(uid ? { uid } : {}),
sid,
expiresAt,
data: sessionString,
Expand Down