Skip to content

Commit 713d42a

Browse files
committed
fix(shared): dispatch redis TTL and Lua calls on the detected client
`setWithTTL` tried the ioredis positional form and fell back to the options form only if the first call threw. node-redis does not throw on it: it accepts `set(key, value, 'PX', ms)`, ignores the trailing arguments, and stores the key with no expiry. The fallback therefore never ran, and every caller of this helper silently lost its TTL on node-redis — including the oauth2 authorization-code, PAR and device-code stores, where the whole point of the write is that it expires quickly. `incr-store` had the same problem in its `eval` call. That one at least failed loudly, because Redis rejects a script invoked with no keys. Adds `detectDialect` and `evalScript` to the shared redis helpers so there is one answer to "which convention does this client want", and routes both call sites through it. `incr` now also reports a driver failure through the binding package's `wrap`, so callers get their own error class and a `code` to branch on instead of a bare driver reply. The fake clients in the unit suites now declare which driver they imitate (ioredis exposes `status`), since that is what the code keys on, and the shared suite asserts both argument forms rather than just one.
1 parent 713d9e7 commit 713d42a

10 files changed

Lines changed: 152 additions & 38 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@exortek/oauth2': patch
3+
'@exortek/opaque': patch
4+
'@exortek/challenge': patch
5+
'@exortek/magic-link': patch
6+
---
7+
8+
Fix Redis TTL and Lua handling so both supported clients behave the same.
9+
10+
The internal helper that writes a key with an expiry tried the `ioredis`
11+
argument form and fell back only if it threw. node-redis does not throw on
12+
that form — it accepts the call and stores the key with **no expiry at all**,
13+
so the fallback never ran and the TTL was silently dropped. Anything given a
14+
lifetime through this path never expired on node-redis: OAuth 2 authorization
15+
codes, PAR request URIs and device codes among them.
16+
17+
The shared counter behind `challenge` and `magic-link` rate limiting had the
18+
same problem in its Lua call, where it surfaced as a failure rather than
19+
silence, and let the driver's own error escape instead of the package's.
20+
21+
Both now dispatch on the detected client, and counter failures are reported as
22+
the calling package's error type with a `code` you can branch on.

packages/challenge/tests/stores/redis.integration.test.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,8 @@ import { forEachRedisDriver } from '@exortek/shared/test-helpers/redis-drivers';
1818

1919
import { redisStore } from '../../src/stores/redis.js';
2020

21-
// The shared counter store issues its Lua call in the ioredis positional form
22-
// for every client, so the whole suite fails on node-redis. Fixed in the
23-
// follow-up commit.
24-
const WRONG_EVAL_FORM = { todo: { 'node-redis': 'incr-store dialect branch — fixed in a follow-up' } };
25-
2621
forEachRedisDriver('challenge redis store', ({ test, client, ns }) => {
27-
test('incr counts up from one and reports a future expiry', WRONG_EVAL_FORM, async () => {
22+
test('incr counts up from one and reports a future expiry', async () => {
2823
const store = redisStore(client(), { keyPrefix: ns('a') });
2924

3025
const first = await store.incr('user-1', 60_000);
@@ -35,7 +30,7 @@ forEachRedisDriver('challenge redis store', ({ test, client, ns }) => {
3530
assert.equal(second.count, 2, 'the counter must be shared across calls');
3631
});
3732

38-
test('separate keys keep independent counters', WRONG_EVAL_FORM, async () => {
33+
test('separate keys keep independent counters', async () => {
3934
const store = redisStore(client(), { keyPrefix: ns('b') });
4035

4136
await store.incr('user-a', 60_000);
@@ -45,7 +40,7 @@ forEachRedisDriver('challenge redis store', ({ test, client, ns }) => {
4540
assert.equal(other.count, 1, 'a different key must start its own counter');
4641
});
4742

48-
test('the TTL is bound on the first increment', WRONG_EVAL_FORM, async () => {
43+
test('the TTL is bound on the first increment', async () => {
4944
const store = redisStore(client(), { keyPrefix: ns('c') });
5045

5146
const { expiresAt } = await store.incr('user-ttl', 5_000);

packages/challenge/tests/stores/redis.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ test('incr: falls back to ttlMs when pttl is missing / non-positive', async () =
7070
test('keyPrefix: prepended before every call', async () => {
7171
let seenKey;
7272
const client = {
73+
// ioredis-shaped: positional EVAL args.
74+
status: 'ready',
7375
async eval(_s, _n, key) {
7476
seenKey = key;
7577
return [1, 1000];

packages/magic-link/tests/stores/redis.integration.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function newRecord(id, email, extras = {}) {
4444
};
4545
}
4646

47-
forEachRedisDriver('magic-link redis store', ({ test, client, ns }) => {
47+
forEachRedisDriver('magic-link redis store', ({ test, client, ns, raw }) => {
4848
test('put + getById + consume atomicity', WRONG_EVAL_FORM, async () => {
4949
const store = redisStore(client(), { keyPrefix: ns('a') });
5050
await store.put(newRecord('id1', 'a@x.com'));
@@ -70,7 +70,7 @@ forEachRedisDriver('magic-link redis store', ({ test, client, ns }) => {
7070
);
7171
});
7272

73-
test('incrRate increments and PEXPIRE binds a TTL', WRONG_EVAL_FORM, async () => {
73+
test('incrRate increments and PEXPIRE binds a TTL', async () => {
7474
const keyPrefix = ns('c');
7575
const store = redisStore(client(), { keyPrefix });
7676

@@ -80,7 +80,7 @@ forEachRedisDriver('magic-link redis store', ({ test, client, ns }) => {
8080
assert.equal(b.count, 2);
8181

8282
// Real Redis PTTL reports the remaining ms — must be > 0 and ≤ 60_000.
83-
const pttl = await client().pttl(`${keyPrefix}rate:u@x.com`);
83+
const pttl = await raw.pttl(`${keyPrefix}rate:u@x.com`);
8484
assert.ok(pttl > 0 && pttl <= 60_000, `expected a bounded TTL, got ${pttl}`);
8585
});
8686

packages/oauth2/tests/stores/redis.integration.test.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@ import { forEachRedisDriver } from '@exortek/shared/test-helpers/redis-drivers';
1111

1212
import { createRedisAuthCodeStore, createRedisRefreshStore, createRedisDeviceStore } from '../../src/server/index.js';
1313

14-
// The shared `setWithTTL` helper tries the ioredis positional form first and
15-
// falls back on throw — but node-redis does not throw, it accepts the call and
16-
// silently stores the key with no expiry. Short-lived grant material therefore
17-
// persists indefinitely. Fixed in the follow-up commit.
18-
const TTL_DROPPED = { todo: { 'node-redis': 'setWithTTL dialect branch — fixed in a follow-up' } };
19-
2014
forEachRedisDriver('oauth2 server stores', ({ test, client, ns, raw }) => {
2115
test('auth-code single-use consume', async () => {
2216
const store = createRedisAuthCodeStore(client(), { keyPrefix: ns('a') });
@@ -26,7 +20,7 @@ forEachRedisDriver('oauth2 server stores', ({ test, client, ns, raw }) => {
2620
assert.equal(await store.consume('c1'), undefined, 'a code must not be redeemable twice');
2721
});
2822

29-
test('an authorization code carries its expiry into Redis', TTL_DROPPED, async () => {
23+
test('an authorization code carries its expiry into Redis', async () => {
3024
const keyPrefix = ns('b');
3125
const store = createRedisAuthCodeStore(client(), { keyPrefix });
3226
await store.save('c-ttl', { clientId: 'app' }, 60_000);

packages/opaque/tests/stores/redis.integration.test.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,14 @@ import { forEachRedisDriver } from '@exortek/shared/test-helpers/redis-drivers';
1313

1414
import { redisStore } from '../../src/stores/redis.js';
1515

16-
// The shared `setWithTTL` helper tries the ioredis positional form first and
17-
// falls back on throw — but node-redis does not throw, it accepts the call and
18-
// silently stores the key with no expiry. So the fallback never runs and the
19-
// TTL is dropped. Fixed in the follow-up commit.
20-
const TTL_DROPPED = { todo: { 'node-redis': 'setWithTTL dialect branch — fixed in a follow-up' } };
21-
2216
forEachRedisDriver('opaque redis store', ({ test, client, ns }) => {
2317
test('set + get round-trip', async () => {
2418
const store = redisStore(client(), { keyPrefix: ns('a') });
2519
await store.set('hash1', { userId: 'usr_1' });
2620
assert.deepEqual(await store.get('hash1'), { userId: 'usr_1' });
2721
});
2822

29-
test('entries expire via native Redis TTL', TTL_DROPPED, async () => {
23+
test('entries expire via native Redis TTL', async () => {
3024
const store = redisStore(client(), { keyPrefix: ns('b') });
3125
await store.set('hash1', { a: 1 }, { expiresIn: 50 });
3226
assert.deepEqual(await store.get('hash1'), { a: 1 });

packages/opaque/tests/stores/redis.test.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ function fakeClient() {
1515
const kv = new Map();
1616
return {
1717
kv,
18+
// `status` is part of ioredis's surface and is how the store recognises
19+
// the positional argument convention this fake implements.
20+
status: 'ready',
1821
async get(k) {
1922
const entry = kv.get(k);
2023
if (!entry) return null;
@@ -35,9 +38,10 @@ function fakeClient() {
3538
}
3639

3740
/**
38-
* Mimics node-redis v4's object-options `set` signature — throws on
39-
* the ioredis-style `set(k, v, 'PX', ms)` call so `setWithTTL`'s
40-
* try/catch actually falls through to the object-options form.
41+
* Mimics node-redis's object-options `set` signature. It exposes none of
42+
* ioredis's surface, so the store detects it as node-redis and passes the
43+
* options form. It throws on a positional call to make a regression loud —
44+
* the real client would accept it and silently drop the TTL.
4145
*/
4246
function fakeNodeRedisV4Client() {
4347
const kv = new Map();

packages/shared/src/incr-store.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { isFunction, isInteger, isUndefined } from './predicates.js';
1313
import { assertRedisClient } from './redis-guard.js';
14+
import { evalScript } from './redis-helpers.js';
1415

1516
// Memory
1617

@@ -157,7 +158,20 @@ export function createRedisIncrStore(client, options = {}, wrap) {
157158

158159
return {
159160
async incr(key, ttlMs) {
160-
const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
161+
let raw;
162+
try {
163+
raw = await evalScript(client, INCR_SCRIPT, [k(key)], [String(Math.max(1, Math.ceil(ttlMs)))]);
164+
} catch (err) {
165+
// Surface driver failures as the binding package's own error rather
166+
// than letting a raw `ErrorReply` escape — callers branch on
167+
// `err instanceof <Pkg>Error` and on `err.code`, and a driver error
168+
// satisfies neither.
169+
const message = `incr-store.incr: EVAL failed — ${err instanceof Error ? err.message : String(err)}`;
170+
if (wrap) {
171+
wrap(message, { cause: err });
172+
}
173+
throw err;
174+
}
161175
const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
162176
const count = Number(arr[0]);
163177
const pttl = Number(arr[1]);

packages/shared/src/redis-helpers.js

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,57 @@
1313

1414
import { isFunction, isString } from './predicates.js';
1515

16+
/**
17+
* Which calling convention a client expects for the commands whose
18+
* signatures differ between drivers — Lua (`eval`) and `SET` with a TTL.
19+
*
20+
* Most helpers here can dispatch on a method name (`mget` vs `mGet`), but
21+
* these two share a name and differ only in how arguments are passed, so
22+
* they need an explicit answer.
23+
*
24+
* A real `ioredis` instance reports `constructor.name === 'EventEmitter'`
25+
* — it extends EventEmitter and no class name survives to the instance —
26+
* so a constructor-name probe alone never matches it. Detect on the API
27+
* surface instead (`scanStream` / the `status` string), neither of which
28+
* node-redis has, and keep the name check as a fallback for wrapped
29+
* clients. Anything unrecognised is treated as node-redis, whose
30+
* options-object form is also what `@upstash/redis` accepts.
31+
*
32+
* @param {any} client
33+
* @returns {'ioredis' | 'node-redis'}
34+
*/
35+
export function detectDialect(client) {
36+
if (client && (isFunction(client.scanStream) || isString(client.status))) {
37+
return 'ioredis';
38+
}
39+
const name = (client && client.constructor && client.constructor.name) || '';
40+
if (name === 'Redis' || name === 'Cluster') {
41+
return 'ioredis';
42+
}
43+
return 'node-redis';
44+
}
45+
46+
/**
47+
* Run a Lua script, passing its keys and arguments the way `client`
48+
* expects.
49+
*
50+
* Getting this wrong is silent on node-redis: handed the ioredis
51+
* positional form it does not throw, it sends `EVAL <script> 0` and the
52+
* script runs against an empty `KEYS`/`ARGV`.
53+
*
54+
* @param {any} client
55+
* @param {string} script
56+
* @param {string[]} keys
57+
* @param {string[]} args
58+
* @param {'ioredis' | 'node-redis'} [dialect]
59+
* @returns {Promise<any>}
60+
*/
61+
export function evalScript(client, script, keys, args, dialect = detectDialect(client)) {
62+
return dialect === 'ioredis'
63+
? client.eval(script, keys.length, ...keys, ...args)
64+
: client.eval(script, { keys, arguments: args });
65+
}
66+
1667
/**
1768
* @param {object} client A Redis-compatible client instance.
1869
* @returns {{
@@ -26,7 +77,11 @@ import { isFunction, isString } from './predicates.js';
2677
* }}
2778
*/
2879
export function createRedisHelpers(client) {
80+
const dialect = detectDialect(client);
81+
2982
return {
83+
dialect,
84+
3085
async mget(keys) {
3186
if (keys.length === 0) {
3287
return [];
@@ -72,9 +127,13 @@ export function createRedisHelpers(client) {
72127

73128
async setWithTTL(key, value, ttlMs) {
74129
const px = Math.max(1, Math.ceil(ttlMs));
75-
try {
130+
// Must dispatch on the dialect, not on a try/catch: node-redis accepts
131+
// the ioredis positional form without complaint and simply stores the
132+
// key with no expiry, so a catch-based fallback never fires and the TTL
133+
// is silently dropped.
134+
if (dialect === 'ioredis') {
76135
await client.set(key, value, 'PX', px);
77-
} catch {
136+
} else {
78137
await client.set(key, value, { PX: px });
79138
}
80139
},

packages/shared/tests/incr-store.test.js

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ describe('createRedisIncrStore', () => {
8484
assert.throws(() => createRedisIncrStore({ get: () => {} }), /eval/);
8585
});
8686

87-
test('incr calls eval with the Lua script and returns parsed result', async () => {
87+
test('incr passes node-redis its options form and returns the parsed result', async () => {
8888
let capturedArgs;
89+
// No ioredis surface, so the store treats it as node-redis.
8990
const fakeClient = {
9091
eval: async (...args) => {
9192
capturedArgs = args;
@@ -97,12 +98,41 @@ describe('createRedisIncrStore', () => {
9798

9899
assert.equal(res.count, 1);
99100
assert.ok(res.expiresAt > Date.now() - 100);
100-
// verify the key was prefixed
101+
assert.deepEqual(capturedArgs[1], { keys: ['test:mykey'], arguments: ['10000'] });
102+
});
103+
104+
test('incr passes ioredis its positional form', async () => {
105+
let capturedArgs;
106+
const fakeClient = {
107+
// `status` is part of ioredis's surface and selects the positional form.
108+
status: 'ready',
109+
eval: async (...args) => {
110+
capturedArgs = args;
111+
return [1, 5000];
112+
},
113+
};
114+
const store = createRedisIncrStore(fakeClient, { keyPrefix: 'test:' });
115+
await store.incr('mykey', 10_000);
116+
117+
assert.equal(capturedArgs[1], 1, 'numKeys');
101118
assert.equal(capturedArgs[2], 'test:mykey');
102-
// verify ttlMs was passed as string
103119
assert.equal(capturedArgs[3], '10000');
104120
});
105121

122+
test("a driver failure surfaces through the caller's wrap, not as a raw reply", async () => {
123+
class FakeReply extends Error {}
124+
const fakeClient = {
125+
eval: async () => {
126+
throw new FakeReply('ERR something went wrong');
127+
},
128+
};
129+
const store = createRedisIncrStore(fakeClient, {}, msg => {
130+
throw new RangeError(`WRAPPED: ${msg}`);
131+
});
132+
133+
await assert.rejects(() => store.incr('k', 1000), /WRAPPED: incr-store.incr: EVAL failed/);
134+
});
135+
106136
test('handles Upstash string responses', async () => {
107137
const fakeClient = {
108138
eval: async () => ['3', '4500'],
@@ -122,16 +152,16 @@ describe('createRedisIncrStore', () => {
122152
});
123153

124154
test('default keyPrefix is empty string', async () => {
125-
let capturedKey;
155+
let capturedKeys;
126156
const fakeClient = {
127-
eval: async (...args) => {
128-
capturedKey = args[2];
157+
eval: async (_script, options) => {
158+
capturedKeys = options.keys;
129159
return [1, 5000];
130160
},
131161
};
132162
const store = createRedisIncrStore(fakeClient);
133163
await store.incr('raw', 1000);
134-
assert.equal(capturedKey, 'raw');
164+
assert.deepEqual(capturedKeys, ['raw']);
135165
});
136166

137167
test('custom wrap callback for client validation', () => {

0 commit comments

Comments
 (0)