Skip to content

Commit 5a65065

Browse files
authored
feat(node): add KV session wrapper (#1054)
* feat(node): add kv session wrapper utility * fix(node): address kv session wrapper comments
1 parent a40488c commit 5a65065

5 files changed

Lines changed: 164 additions & 37 deletions

File tree

packages/node/README.md

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -83,38 +83,14 @@ But the cookie size is limited, so you may need to use external storage like Red
8383

8484
```typescript
8585
// storage.ts
86-
import { CookieStorage } from '@logto/node';
87-
88-
class RedisSessionWrapper implements SessionWrapper {
89-
private currentSessionId?: string;
90-
91-
constructor(private readonly redis: Redis) {}
92-
93-
async wrap(data: unknown, _key: string): Promise<string> {
94-
// Reuse existing session ID if available, only generate new one for first-time users.
95-
// This is important for environments where cookies cannot be updated (e.g. React Server Components),
96-
// as the session ID in the cookie must remain stable while the data in Redis can be updated.
97-
const sessionId = this.currentSessionId ?? randomUUID();
98-
this.currentSessionId = sessionId;
99-
await this.redis.set(`logto_session_${sessionId}`, JSON.stringify(data));
100-
return sessionId;
101-
}
102-
103-
async unwrap(value: string, _key: string): Promise<SessionData> {
104-
if (!value) {
105-
return {};
106-
}
107-
108-
// Store the session ID for potential reuse in wrap()
109-
this.currentSessionId = value;
110-
const data = await this.redis.get(`logto_session_${value}`);
111-
return data ? JSON.parse(data) : {};
112-
}
113-
}
86+
import { CookieStorage, createKVSessionWrapper } from '@logto/node';
11487

115-
export const storage = new CookieStorage({
88+
export const createStorage = () => new CookieStorage({
11689
cookieKey: `<logto_app_xxx>`,
117-
sessionWrapper: new RedisSessionWrapper(redis),
90+
sessionWrapper: createKVSessionWrapper({
91+
get: (key) => redis.get(key),
92+
set: (key, value, ttl) => redis.set(key, value, 'EX', ttl),
93+
}),
11894
isSecure: false, // Set to true if you are using HTTPS
11995
getCookie: (name) => {
12096
// Example usage, get cookie from the request, depends on your framework

packages/node/src/utils/cookie-storage.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,32 @@ describe('CookieStorage', () => {
114114
const cookie = await storage.config.getCookie('logtoCookies');
115115
expect(cookie).toBe(JSON.stringify({ [PersistKey.AccessToken]: 'test-token' }));
116116
});
117+
118+
it('should pass current session value to custom sessionWrapper when saving', async () => {
119+
const mockSessionWrapper = {
120+
wrap: vi.fn(
121+
async (_data: unknown, _key: string, currentValue?: string) => currentValue ?? ''
122+
),
123+
unwrap: vi.fn(async () => ({ [PersistKey.AccessToken]: 'test-token' })),
124+
};
125+
const storage = new TestCookieStorage({
126+
...createCookieConfig(encryptionKey, {
127+
logtoCookies: 'existing-session',
128+
}),
129+
sessionWrapper: mockSessionWrapper,
130+
});
131+
132+
await storage.init();
133+
await storage.setItem(PersistKey.AccessToken, 'updated-token');
134+
135+
expect(mockSessionWrapper.unwrap).toHaveBeenCalledWith('existing-session', encryptionKey);
136+
expect(mockSessionWrapper.wrap).toHaveBeenCalledWith(
137+
{ [PersistKey.AccessToken]: 'updated-token' },
138+
encryptionKey,
139+
'existing-session'
140+
);
141+
await expect(storage.config.getCookie('logtoCookies')).resolves.toBe('existing-session');
142+
});
117143
});
118144

119145
describe('CookieStorage concurrency', () => {

packages/node/src/utils/cookie-storage.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type CookieConfigBase = {
1919
};
2020

2121
export type SessionWrapper = {
22-
wrap: (data: SessionData, key: string) => Promise<string>;
22+
wrap: (data: SessionData, key: string, currentValue?: string) => Promise<string>;
2323
unwrap: (value: string, key: string) => Promise<SessionData>;
2424
};
2525

@@ -61,6 +61,7 @@ export class CookieStorage implements Storage<PersistKey> {
6161
}
6262

6363
protected sessionData: SessionData = {};
64+
protected sessionValue = '';
6465
protected saveQueue = new PromiseQueue();
6566

6667
/**
@@ -85,10 +86,9 @@ export class CookieStorage implements Storage<PersistKey> {
8586

8687
async init() {
8788
const { encryptionKey = '' } = this.config;
88-
this.sessionData = await this.sessionWrapper.unwrap(
89-
(await this.config.getCookie(this.cookieKey)) ?? '',
90-
encryptionKey
91-
);
89+
const sessionValue = (await this.config.getCookie(this.cookieKey)) ?? '';
90+
this.sessionValue = sessionValue;
91+
this.sessionData = await this.sessionWrapper.unwrap(sessionValue, encryptionKey);
9292
}
9393

9494
async getItem(key: PersistKey): Promise<Nullable<string>> {
@@ -117,7 +117,8 @@ export class CookieStorage implements Storage<PersistKey> {
117117

118118
protected async write(data = this.sessionData) {
119119
const { encryptionKey = '' } = this.config;
120-
const value = await this.sessionWrapper.wrap(data, encryptionKey);
120+
const value = await this.sessionWrapper.wrap(data, encryptionKey, this.sessionValue);
121+
this.sessionValue = value;
121122
await this.config.setCookie(this.cookieKey, value, this.cookieOptions);
122123
}
123124
}

packages/node/src/utils/session.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { PersistKey } from '@logto/client';
22

3-
import { unwrapSession, wrapSession } from './session.js';
3+
import { createKVSessionWrapper, unwrapSession, wrapSession } from './session.js';
44

55
const secret = 'secret';
66

@@ -23,4 +23,73 @@ describe('session', () => {
2323
const session = await unwrapSession(cookie, secret);
2424
expect(session[PersistKey.IdToken]).toEqual('idToken');
2525
});
26+
27+
it('should be able to wrap and unwrap with kv wrapper', async () => {
28+
const store = new Map<string, string>();
29+
const sessionWrapper = createKVSessionWrapper({
30+
get: async (key) => store.get(key),
31+
set: async (key, value) => {
32+
store.set(key, value);
33+
},
34+
});
35+
36+
const cookieValue = await sessionWrapper.wrap({ [PersistKey.IdToken]: 'idToken' }, '');
37+
const session = await sessionWrapper.unwrap(cookieValue, '');
38+
39+
expect(session[PersistKey.IdToken]).toEqual('idToken');
40+
});
41+
42+
it('should reuse the current kv session id and pass options to the adapter', async () => {
43+
const store = new Map<string, string>();
44+
const set = vi.fn(async (key: string, value: string, ttl?: number) => {
45+
store.set(key, value);
46+
});
47+
const sessionWrapper = createKVSessionWrapper(
48+
{
49+
get: async (key) => store.get(key) ?? null,
50+
set,
51+
},
52+
{
53+
keyPrefix: 'custom_prefix_',
54+
ttl: 123,
55+
}
56+
);
57+
const existingSessionId = 'session-id';
58+
store.set(
59+
`custom_prefix_${existingSessionId}`,
60+
JSON.stringify({ [PersistKey.IdToken]: 'idToken' })
61+
);
62+
63+
const session = await sessionWrapper.unwrap(existingSessionId, '');
64+
const cookieValue = await sessionWrapper.wrap(
65+
{ ...session, [PersistKey.AccessToken]: 'accessToken' },
66+
'',
67+
existingSessionId
68+
);
69+
70+
expect(cookieValue).toBe(existingSessionId);
71+
expect(set).toHaveBeenCalledWith(
72+
`custom_prefix_${existingSessionId}`,
73+
JSON.stringify({
74+
[PersistKey.IdToken]: 'idToken',
75+
[PersistKey.AccessToken]: 'accessToken',
76+
}),
77+
123
78+
);
79+
expect(JSON.parse(store.get(`custom_prefix_${existingSessionId}`) ?? '')).toEqual({
80+
[PersistKey.IdToken]: 'idToken',
81+
[PersistKey.AccessToken]: 'accessToken',
82+
});
83+
});
84+
85+
it.each(['null', '1', '[]'])('should ignore invalid kv session data: %s', async (value) => {
86+
const sessionWrapper = createKVSessionWrapper({
87+
get: async () => value,
88+
set: async () => {
89+
await Promise.resolve();
90+
},
91+
});
92+
93+
await expect(sessionWrapper.unwrap('session-id', '')).resolves.toEqual({});
94+
});
2695
});

packages/node/src/utils/session.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ export type Session = SessionData & {
1212
getValues?: () => Promise<string>;
1313
};
1414

15+
// eslint-disable-next-line @typescript-eslint/ban-types
16+
type NullableKVValue = string | null | undefined;
17+
18+
export type KVAdapter = {
19+
get: (key: string) => Promise<NullableKVValue>;
20+
set: (key: string, value: string, ttlSeconds?: number) => Promise<void>;
21+
};
22+
23+
export type KVSessionWrapperOptions = {
24+
keyPrefix?: string;
25+
ttl?: number;
26+
};
27+
1528
async function getKeyFromPassword(password: string, crypto: Crypto): Promise<string> {
1629
const encoder = new TextEncoder();
1730
const data = encoder.encode(password);
@@ -99,3 +112,45 @@ export const wrapSession = async (session: SessionData, secret: string): Promise
99112
const { ciphertext, iv } = await encrypt(JSON.stringify(session), secret, crypto);
100113
return `${ciphertext}.${iv}`;
101114
};
115+
116+
const isSessionData = (data: unknown): data is SessionData =>
117+
typeof data === 'object' && data !== null && !Array.isArray(data);
118+
119+
export const createKVSessionWrapper = (
120+
kv: KVAdapter,
121+
options: KVSessionWrapperOptions = {}
122+
): {
123+
wrap: (data: SessionData, key: string, currentValue?: string) => Promise<string>;
124+
unwrap: (value: string, key: string) => Promise<SessionData>;
125+
} => {
126+
const prefix = options.keyPrefix ?? 'logto_session_';
127+
const ttl = options.ttl ?? 14 * 24 * 3600;
128+
129+
return {
130+
async wrap(data: SessionData, _key: string, currentValue?: string): Promise<string> {
131+
const sessionId =
132+
currentValue === undefined || currentValue === '' ? crypto.randomUUID() : currentValue;
133+
await kv.set(`${prefix}${sessionId}`, JSON.stringify(data), ttl);
134+
return sessionId;
135+
},
136+
async unwrap(value: string, _key: string): Promise<SessionData> {
137+
if (!value) {
138+
return {};
139+
}
140+
141+
const data = await kv.get(`${prefix}${value}`);
142+
143+
if (!data) {
144+
return {};
145+
}
146+
147+
try {
148+
// eslint-disable-next-line no-restricted-syntax
149+
const sessionData = JSON.parse(data) as unknown;
150+
return isSessionData(sessionData) ? sessionData : {};
151+
} catch {
152+
return {};
153+
}
154+
},
155+
};
156+
};

0 commit comments

Comments
 (0)