Skip to content

Commit 835ea53

Browse files
authored
Fix touchAfter handling (#17)
- This was not working because `expires` is a field in `Item` but only `Item.sess` is available to the `touch` function
1 parent 8780bec commit 835ea53

File tree

3 files changed

+129
-75
lines changed

3 files changed

+129
-75
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ Disclaimer: perform your own pricing calculation, monitor your costs during and
169169

170170
# Running Examples
171171

172-
## [express](./examples/express)
172+
## express
173+
174+
Source: [./examples/express.ts](./examples/express.ts)
173175

174176
1. Create DynamoDB Table using AWS Console or any other method
175177
1. AWS CLI Example: ```aws dynamodb create-table --table-name dynamodb-session-store-test --attribute-definitions AttributeName=id,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST```
@@ -184,7 +186,9 @@ Disclaimer: perform your own pricing calculation, monitor your costs during and
184186
3. Load `http://localhost:3001/login` in a browser
185187
4. Observe that a cookie is returned and does not change
186188

187-
## [cross-account](./examples/cross-account)
189+
## cross-account
190+
191+
Source: [./examples/cross-account.ts](./examples/cross-account.ts)
188192

189193
This example has the DynamoDB in one account and the express app using an IAM role from another account to access the DynamoDB Table using temporary credentials from an STS AssumeRole call (neatly encapsulated by the AWS SDK for JS v3).
190194

@@ -194,7 +198,9 @@ This example is more involved than the others as it requires setting up an IAM r
194198

195199
![Session Store with DynamoDB Table in Another Account](https://github.com/pwrdrvr/dynamodb-session-store/assets/5617868/dbc8d07b-b2f3-42c8-96c9-2476007ed24c)
196200

197-
## [express with dynamodb-connect module - for comparison](./examples/other)
201+
## express with dynamodb-connect module - for comparison
202+
203+
Source: [./examples/other.ts](./examples/other.ts)
198204

199205
1. Create DynamoDB Table using AWS Console or any other method
200206
1. AWS CLI Example: ```aws dynamodb create-table --table-name connect-dynamodb-test --attribute-definitions AttributeName=id,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST```

src/dynamodb-store.mock.spec.ts

Lines changed: 79 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,66 @@ describe('mock AWS API', () => {
168168
});
169169

170170
describe('ttl', () => {
171-
it('does not update the TTL if the session is not close to expiration', (done) => {
171+
it('does not update the TTL if the session was recently modified', (done) => {
172+
void (async () => {
173+
dynamoClient
174+
.onAnyCommand()
175+
.callsFake((input) => {
176+
console.log('dynamoClient.onAnyCommand', input);
177+
throw new Error('unexpected call');
178+
})
179+
.rejects()
180+
.on(dynamodb.DescribeTableCommand, {
181+
TableName: tableName,
182+
})
183+
.resolves({
184+
Table: {
185+
TableName: tableName,
186+
},
187+
})
188+
.on(dynamodb.CreateTableCommand, {
189+
TableName: tableName,
190+
})
191+
.rejects();
192+
ddbMock.onAnyCommand().callsFake((input) => {
193+
console.log('ddbMock.onAnyCommand', input);
194+
throw new Error('unexpected call');
195+
});
196+
197+
const store = await DynamoDBStore.create({
198+
tableName,
199+
createTableOptions: {},
200+
});
201+
202+
expect(store.tableName).toBe(tableName);
203+
expect(dynamoClient.calls().length).toBe(1);
204+
expect(ddbMock.calls().length).toBe(0);
205+
206+
store.touch(
207+
'123',
208+
{
209+
// @ts-expect-error we know we have a cookie field
210+
user: 'test',
211+
lastModified: new Date().toISOString(),
212+
cookie: {
213+
originalMaxAge: 1000 * (14 * 24) * 60 * 60,
214+
expires: new Date(Date.now() + 1000 * (14 * 24 - 0.9) * 60 * 60),
215+
},
216+
},
217+
(err) => {
218+
expect(err).toBeNull();
219+
220+
// Nothing should have happened
221+
expect(dynamoClient.calls().length).toBe(1);
222+
expect(ddbMock.calls().length).toBe(0);
223+
224+
done();
225+
},
226+
);
227+
})();
228+
});
229+
230+
it('does update the TTL if the session was last modified more than touchAfter seconds ago', (done) => {
172231
void (async () => {
173232
dynamoClient
174233
.onAnyCommand()
@@ -196,28 +255,19 @@ describe('mock AWS API', () => {
196255
throw new Error('unexpected call');
197256
})
198257
.on(
199-
GetCommand,
258+
UpdateCommand,
200259
{
201-
TableName: tableName,
202-
Key: {
203-
id: {
204-
S: 'sess:123',
205-
},
206-
},
260+
TableName: 'sessions-test',
261+
Key: { id: 'session#123' },
262+
UpdateExpression: 'set expires = :e, sess.lastModified = :lm',
263+
// ExpressionAttributeValues: { ':e': 2898182909 },
264+
ReturnValues: 'UPDATED_NEW',
207265
},
208266
false,
209267
)
210-
.resolves({
211-
Item: {
212-
id: {
213-
S: 'sess:123',
214-
},
215-
expires: {
216-
N: '1598420000',
217-
},
218-
data: {
219-
B: 'eyJ1c2VyIjoiYWRtaW4ifQ==',
220-
},
268+
.resolvesOnce({
269+
Attributes: {
270+
expires: 2898182909,
221271
},
222272
});
223273

@@ -233,27 +283,30 @@ describe('mock AWS API', () => {
233283
store.touch(
234284
'123',
235285
{
236-
user: 'test',
237286
// @ts-expect-error we know we have a cookie field
287+
user: 'test',
288+
expires: Math.floor((Date.now() + 1000 * (14 * 24 - 1.1) * 60 * 60) / 1000),
289+
lastModified: '2021-08-01T00:00:00.000Z',
238290
cookie: {
239-
expires: new Date(Date.now() + 1000 * (14 * 24 - 0.9) * 60 * 60),
291+
maxAge: 1000 * (14 * 24 - 4) * 60 * 60,
292+
originalMaxAge: 1000 * (14 * 24) * 60 * 60,
293+
expires: new Date(Date.now() + 1000 * (14 * 24 - 4) * 60 * 60),
240294
},
241-
expires: Math.floor(Date.now() + 1000 * (14 * 24 - 0.9) * 60 * 60),
242295
},
243296
(err) => {
244297
expect(err).toBeNull();
245298

246-
// Nothing should have happened
299+
// We should have written to the DB
247300
expect(dynamoClient.calls().length).toBe(1);
248-
expect(ddbMock.calls().length).toBe(0);
301+
expect(ddbMock.calls().length).toBe(1);
249302

250303
done();
251304
},
252305
);
253306
})();
254307
});
255308

256-
it('does update the TTL if the session was last touched more than touchAfter seconds ago', (done) => {
309+
it('does update the TTL if the session has no lastModified field', (done) => {
257310
void (async () => {
258311
dynamoClient
259312
.onAnyCommand()
@@ -280,37 +333,12 @@ describe('mock AWS API', () => {
280333
console.log('ddbMock.onAnyCommand', input);
281334
throw new Error('unexpected call');
282335
})
283-
.on(
284-
GetCommand,
285-
{
286-
TableName: tableName,
287-
Key: {
288-
id: {
289-
S: 'sess:123',
290-
},
291-
},
292-
},
293-
false,
294-
)
295-
.resolves({
296-
Item: {
297-
id: {
298-
S: 'sess:123',
299-
},
300-
expires: {
301-
N: '1598420000',
302-
},
303-
data: {
304-
B: 'eyJ1c2VyIjoiYWRtaW4ifQ==',
305-
},
306-
},
307-
})
308336
.on(
309337
UpdateCommand,
310338
{
311339
TableName: 'sessions-test',
312340
Key: { id: 'session#123' },
313-
UpdateExpression: 'set expires = :e',
341+
UpdateExpression: 'set expires = :e, sess.lastModified = :lm',
314342
// ExpressionAttributeValues: { ':e': 2898182909 },
315343
ReturnValues: 'UPDATED_NEW',
316344
},

src/dynamodb-store.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,8 @@ export class DynamoDBStore extends session.Store {
469469
...(session.cookie
470470
? { cookie: { ...JSON.parse(JSON.stringify(session.cookie)) } }
471471
: {}),
472+
// Add last-modified if touchAfter is set
473+
...(this.touchAfter > 0 ? { lastModified: new Date().toISOString() } : {}),
472474
},
473475
},
474476
});
@@ -497,21 +499,21 @@ export class DynamoDBStore extends session.Store {
497499
/**
498500
* Session data
499501
*/
500-
session: session.SessionData,
502+
session: session.SessionData & { lastModified?: string },
501503
/**
502504
* Callback to return an error if the session TTL was not updated
503505
*/
504506
callback?: (err?: unknown) => void,
505507
): void {
506508
void (async () => {
507509
try {
508-
// @ts-expect-error expires may exist
509-
const expiresTimeSecs = session.expires ? session.expires : 0;
510+
// The `expires` field from the DB `Item` is not available here
511+
// when a session is loaded from the store
512+
// We have to use a `lastModified` field within the user-visible session
510513
const currentTimeSecs = Math.floor(Date.now() / 1000);
511-
512-
// Compute how much time has passed since this session was last touched
513-
const timePassedSecs =
514-
currentTimeSecs + session.cookie.originalMaxAge / 1000 - expiresTimeSecs;
514+
const lastModifiedSecs = session.lastModified
515+
? Math.floor(new Date(session.lastModified).getTime() / 1000)
516+
: 0;
515517

516518
// Update the TTL only if touchAfter
517519
// seconds have passed since the TTL was last updated
@@ -521,21 +523,33 @@ export class DynamoDBStore extends session.Store {
521523
? Math.floor(0.1 * (session.cookie.originalMaxAge / 1000))
522524
: this._touchAfter;
523525

524-
if (timePassedSecs > touchAfterSecsCapped) {
525-
const newExpires = this.newExpireSecondsSinceEpochUTC(session);
526-
527-
await this._ddbDocClient.update({
528-
TableName: this._tableName,
529-
Key: {
530-
[this._hashKey]: `${this._prefix}${sid}`,
531-
},
532-
UpdateExpression: 'set expires = :e',
533-
ExpressionAttributeValues: {
534-
':e': newExpires,
535-
},
536-
ReturnValues: 'UPDATED_NEW',
537-
});
526+
const timeElapsed = currentTimeSecs - lastModifiedSecs;
527+
if (timeElapsed < touchAfterSecsCapped) {
528+
debug(`Skip touching session=${sid}`);
529+
if (callback) {
530+
callback(null);
531+
}
532+
return;
538533
}
534+
535+
// We are going to touch the session, update the lastModified
536+
session.lastModified = new Date().toISOString();
537+
538+
const newExpires = this.newExpireSecondsSinceEpochUTC(session);
539+
540+
await this._ddbDocClient.update({
541+
TableName: this._tableName,
542+
Key: {
543+
[this._hashKey]: `${this._prefix}${sid}`,
544+
},
545+
UpdateExpression: 'set expires = :e, sess.lastModified = :lm',
546+
ExpressionAttributeValues: {
547+
':e': newExpires,
548+
':lm': session.lastModified,
549+
},
550+
ReturnValues: 'UPDATED_NEW',
551+
});
552+
539553
if (callback) {
540554
callback(null);
541555
}
@@ -588,4 +602,10 @@ export class DynamoDBStore extends session.Store {
588602
: +new Date() + 60 * 60 * 24 * 1000;
589603
return Math.floor(expires / 1000);
590604
}
605+
606+
private getTTLSeconds(sess: session.SessionData) {
607+
return sess && sess.cookie && sess.cookie.expires
608+
? Math.ceil((Number(new Date(sess.cookie.expires)) - Date.now()) / 1000)
609+
: this._touchAfter;
610+
}
591611
}

0 commit comments

Comments
 (0)