ed6cca499d
mount express-session + connect-redis at bootstrap on top of the
shared ioredis client. the full json payload is encrypted with
aes-256-gcm before it reaches redis; envelope is versioned
(v1.<iv>.<tag>.<ciphertext>, base64url) so the algorithm or key
derivation can rotate without a flag-day re-encryption.
scope is intentionally infrastructure-only — middleware mounted,
req.session available downstream, cookie set on first write,
encryption-at-rest active. populating req.session.user from
/auth/callback, /me, /auth/logout, and the absolute-timeout
interceptor land in follow-ups.
notable shape choices captured in ADR-0010 (amended here):
- encryption at rest applies to the whole payload, not just a tokens
sub-field. the session also carries pii claims (oid, tid,
preferred_username) — encrypting the envelope removes the need to
classify fields one by one and costs essentially the same.
- connect-redis v9 was rewritten for node-redis v4 and dropped
ioredis. rather than swap the whole bff to node-redis, a small
adapter shapes the six commands connect-redis actually calls
(get / set with {expiration:{type:'EX',value}} / expire / del /
mGet / scanIterator) to look like node-redis. the rest of the
bff stays on a single shared ioredis client.
session id: crypto.randomBytes(32).toString('base64url')
cookie name: __Host-portal_session in production, portal_session in
dev (the __Host- prefix mandates Secure which dev http can't
provide). httpOnly + sameSite=lax + path=/. resave:false,
saveUninitialized:false, rolling:true; cookie maxAge follows
SESSION_IDLE_TIMEOUT_SECONDS (default 1800).
env: SESSION_ENCRYPTION_KEY is mandatory (32 bytes after base64url
decode); rejected at boot via a new assertSessionEncryptionKey()
mirroring the other pre-flight validators. SESSION_IDLE_TIMEOUT_SECONDS
and SESSION_ABSOLUTE_TIMEOUT_SECONDS are optional with adr-aligned
defaults (1800 / 43200).
117 lines
4.4 KiB
TypeScript
117 lines
4.4 KiB
TypeScript
import IORedis from 'ioredis';
|
|
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
|
|
|
|
/**
|
|
* Unit-level tests that pin the **shape** of each adapted call —
|
|
* `connect-redis` v9 invokes them in a specific way and any drift
|
|
* breaks the session store silently. The behavioural round-trip is
|
|
* exercised by `connect-redis`' own integration tests once its
|
|
* `RedisStore` is wired in `session.module.spec.ts`.
|
|
*/
|
|
describe('adaptIoredisForConnectRedis', () => {
|
|
function fakeClient(): {
|
|
spy: Record<string, jest.Mock>;
|
|
client: IORedis;
|
|
} {
|
|
const spy = {
|
|
get: jest.fn().mockResolvedValue('v'),
|
|
set: jest.fn().mockResolvedValue('OK'),
|
|
expire: jest.fn().mockResolvedValue(1),
|
|
del: jest.fn().mockResolvedValue(2),
|
|
mget: jest.fn().mockResolvedValue(['v1', null, 'v3']),
|
|
scanStream: jest.fn(),
|
|
};
|
|
// The adapter only touches the 6 methods above — we deliberately
|
|
// don't construct a real `ioredis` instance to keep the unit
|
|
// pure and avoid hitting the network.
|
|
return { spy, client: spy as unknown as IORedis };
|
|
}
|
|
|
|
it('forwards get(key) unchanged', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
await adapter.get('sid');
|
|
expect(spy['get']).toHaveBeenCalledWith('sid');
|
|
});
|
|
|
|
it('translates set with EX expiration to ioredis varargs', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
await adapter.set('sid', 'value', { expiration: { type: 'EX', value: 1800 } });
|
|
expect(spy['set']).toHaveBeenCalledWith('sid', 'value', 'EX', 1800);
|
|
});
|
|
|
|
it('translates set without options to a plain ioredis set', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
await adapter.set('sid', 'value');
|
|
expect(spy['set']).toHaveBeenCalledWith('sid', 'value');
|
|
});
|
|
|
|
it('forwards expire(key, seconds) unchanged', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
await adapter.expire('sid', 1800);
|
|
expect(spy['expire']).toHaveBeenCalledWith('sid', 1800);
|
|
});
|
|
|
|
it('spreads del([keys]) into ioredis varargs', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
await adapter.del(['a', 'b', 'c']);
|
|
expect(spy['del']).toHaveBeenCalledWith('a', 'b', 'c');
|
|
});
|
|
|
|
it('short-circuits del([]) without touching the client', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
const result = await adapter.del([]);
|
|
expect(result).toBe(0);
|
|
expect(spy['del']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('spreads mGet([keys]) into ioredis varargs and returns the array', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
const result = await adapter.mGet(['a', 'b', 'c']);
|
|
expect(spy['mget']).toHaveBeenCalledWith('a', 'b', 'c');
|
|
expect(result).toEqual(['v1', null, 'v3']);
|
|
});
|
|
|
|
it('short-circuits mGet([]) without touching the client', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
expect(await adapter.mGet([])).toEqual([]);
|
|
expect(spy['mget']).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('wraps scanStream into an async iterable of key arrays', async () => {
|
|
const { spy, client } = fakeClient();
|
|
const fakeStream = makeReadable([['session:a', 'session:b'], ['session:c']]);
|
|
(spy['scanStream'] as jest.Mock).mockReturnValueOnce(fakeStream);
|
|
|
|
const adapter = adaptIoredisForConnectRedis(client);
|
|
const iterator = adapter.scanIterator({ MATCH: 'session:*', COUNT: 100 });
|
|
|
|
const collected: string[][] = [];
|
|
for await (const batch of iterator) {
|
|
collected.push(batch);
|
|
}
|
|
|
|
expect(spy['scanStream']).toHaveBeenCalledWith({ match: 'session:*', count: 100 });
|
|
expect(collected).toEqual([['session:a', 'session:b'], ['session:c']]);
|
|
});
|
|
});
|
|
|
|
function makeReadable<T>(chunks: T[]): NodeJS.ReadableStream {
|
|
// Minimal async iterable that mimics `ioredis.scanStream`'s
|
|
// for-await contract — the adapter only needs `Symbol.asyncIterator`.
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const chunk of chunks) {
|
|
yield chunk;
|
|
}
|
|
},
|
|
} as unknown as NodeJS.ReadableStream;
|
|
}
|