Skip to content

Commit a4f061a

Browse files
fix: Ensure JWE expires as expected (#2040)
1 parent 6bc280a commit a4f061a

11 files changed

+195
-116
lines changed

src/server/auth-client.test.ts

Lines changed: 85 additions & 46 deletions
Large diffs are not rendered by default.

src/server/cookies.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,47 @@ describe("encrypt/decrypt", async () => {
99

1010
it("should encrypt/decrypt a payload with the correct secret", async () => {
1111
const payload = { key: "value" };
12-
const encrypted = await encrypt(payload, secret);
12+
const maxAge = 60 * 60; // 1 hour in seconds
13+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
14+
const encrypted = await encrypt(payload, secret, expiration);
1315
const decrypted = await decrypt(encrypted, secret);
1416

15-
expect(decrypted.payload).toEqual(payload);
17+
expect(decrypted.payload).toEqual(expect.objectContaining(payload));
1618
});
1719

1820
it("should fail to decrypt a payload with the incorrect secret", async () => {
1921
const payload = { key: "value" };
20-
const encrypted = await encrypt(payload, secret);
22+
const maxAge = 60 * 60; // 1 hour in seconds
23+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
24+
const encrypted = await encrypt(payload, secret, expiration);
2125
await expect(() =>
2226
decrypt(encrypted, incorrectSecret)
2327
).rejects.toThrowError();
2428
});
2529

30+
it("should fail to decrypt when expired", async () => {
31+
const payload = { key: "value" };
32+
const expiration = Math.floor(Date.now() / 1000 - 60); // 60 seconds in the past
33+
const encrypted = await encrypt(payload, secret, expiration);
34+
await expect(() =>
35+
decrypt(encrypted, secret)
36+
).rejects.toThrowError(`"exp" claim timestamp check failed`);
37+
});
38+
2639
it("should fail to encrypt if a secret is not provided", async () => {
2740
const payload = { key: "value" };
28-
await expect(() => encrypt(payload, "")).rejects.toThrowError();
41+
const maxAge = 60 * 60; // 1 hour in seconds
42+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
43+
44+
await expect(() => encrypt(payload, "", expiration)).rejects.toThrowError();
2945
});
3046

3147
it("should fail to decrypt if a secret is not provided", async () => {
3248
const payload = { key: "value" };
33-
const encrypted = await encrypt(payload, secret);
49+
const maxAge = 60 * 60; // 1 hour in seconds
50+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
51+
52+
const encrypted = await encrypt(payload, secret, expiration);
3453
await expect(() => decrypt(encrypted, "")).rejects.toThrowError();
3554
});
3655
});

src/server/cookies.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const ENCRYPTION_INFO = "JWE CEK";
1515
export async function encrypt(
1616
payload: jose.JWTPayload,
1717
secret: string,
18+
expiration: number,
1819
additionalHeaders?: {
1920
iat: number;
2021
uat: number;
@@ -31,6 +32,7 @@ export async function encrypt(
3132

3233
const encryptedCookie = await new jose.EncryptJWT(payload)
3334
.setProtectedHeader({ enc: ENC, alg: ALG, ...additionalHeaders })
35+
.setExpirationTime(expiration)
3436
.encrypt(encryptionSecret);
3537

3638
return encryptedCookie.toString();

src/server/session/stateful-session-store.test.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ describe("Stateful Session Store", async () => {
3434
set: vi.fn(),
3535
delete: vi.fn()
3636
};
37+
const maxAge = 60 * 60; // 1 hour in seconds
38+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
3739
const encryptedCookieValue = await encrypt(
3840
{
3941
id: sessionId
4042
},
41-
secret
43+
secret,
44+
expiration,
4245
);
4346

4447
const headers = new Headers();
@@ -99,11 +102,14 @@ describe("Stateful Session Store", async () => {
99102
set: vi.fn(),
100103
delete: vi.fn()
101104
};
105+
const maxAge = 60 * 60; // 1 hour in seconds
106+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
102107
const encryptedCookieValue = await encrypt(
103108
{
104109
id: sessionId
105110
},
106-
secret
111+
secret,
112+
expiration,
107113
);
108114

109115
const headers = new Headers();
@@ -464,12 +470,14 @@ describe("Stateful Session Store", async () => {
464470
set: vi.fn(),
465471
delete: vi.fn()
466472
};
467-
473+
const maxAge = 60 * 60; // 1 hour in seconds
474+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
468475
const encryptedCookieValue = await encrypt(
469476
{
470477
id: sessionId
471478
},
472-
secret
479+
secret,
480+
expiration,
473481
);
474482
const headers = new Headers();
475483
headers.append("cookie", `__session=${encryptedCookieValue}`);
@@ -750,11 +758,14 @@ describe("Stateful Session Store", async () => {
750758
set: vi.fn(),
751759
delete: vi.fn()
752760
};
761+
const maxAge = 60 * 60; // 1 hour in seconds
762+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
753763
const encryptedCookieValue = await encrypt(
754764
{
755765
id: sessionId
756766
},
757-
secret
767+
secret,
768+
expiration,
758769
);
759770
const headers = new Headers();
760771
headers.append("cookie", `__session=${encryptedCookieValue}`);

src/server/session/stateful-session-store.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,17 @@ export class StatefulSessionStore extends AbstractSessionStore {
130130
if (!sessionId) {
131131
sessionId = generateId();
132132
}
133-
133+
134+
const maxAge = this.calculateMaxAge(session.internal.createdAt);
135+
const expiration = Date.now() / 1000 + maxAge;
134136
const jwe = await cookies.encrypt(
135137
{
136138
id: sessionId
137139
},
138-
this.secret
140+
this.secret,
141+
expiration,
139142
);
140-
const maxAge = this.calculateMaxAge(session.internal.createdAt);
141-
143+
142144
resCookies.set(this.sessionCookieName, jwe.toString(), {
143145
...this.cookieConfig,
144146
maxAge

src/server/session/stateless-session-store.test.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ describe("Stateless Session Store", async () => {
2222
createdAt: Math.floor(Date.now() / 1000)
2323
}
2424
};
25-
const encryptedCookieValue = await encrypt(session, secret);
25+
const maxAge = 60 * 60; // 1 hour in seconds
26+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
27+
const encryptedCookieValue = await encrypt(session, secret, expiration);
2628

2729
const headers = new Headers();
2830
headers.append("cookie", `__session=${encryptedCookieValue}`);
@@ -32,7 +34,7 @@ describe("Stateless Session Store", async () => {
3234
secret
3335
});
3436

35-
expect(await sessionStore.get(requestCookies)).toEqual(session);
37+
expect(await sessionStore.get(requestCookies)).toEqual(expect.objectContaining(session));
3638
});
3739

3840
it("should return null if no session cookie exists", async () => {
@@ -65,9 +67,12 @@ describe("Stateless Session Store", async () => {
6567
uat: Math.floor(Date.now() / 1000),
6668
exp: Math.floor(Date.now() / 1000)
6769
};
70+
const maxAge = 60 * 60; // 1 hour in seconds
71+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
6872
const encryptedCookieValue = await encrypt(
6973
legacySession,
7074
secret,
75+
expiration,
7176
legacyHeader
7277
);
7378

@@ -106,9 +111,12 @@ describe("Stateless Session Store", async () => {
106111
uat: Math.floor(Date.now() / 1000),
107112
exp: Math.floor(Date.now() / 1000)
108113
};
114+
const maxAge = 60 * 60; // 1 hour in seconds
115+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
109116
const encryptedCookieValue = await encrypt(
110117
legacySession,
111118
secret,
119+
expiration,
112120
legacyHeader
113121
);
114122

@@ -153,9 +161,12 @@ describe("Stateless Session Store", async () => {
153161
uat: Math.floor(Date.now() / 1000),
154162
exp: Math.floor(Date.now() / 1000)
155163
};
164+
const maxAge = 60 * 60; // 1 hour in seconds
165+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
156166
const encryptedCookieValue = await encrypt(
157167
legacySession,
158168
secret,
169+
expiration,
159170
legacyHeader
160171
);
161172

@@ -206,7 +217,9 @@ describe("Stateless Session Store", async () => {
206217
}
207218
]
208219
};
209-
const encryptedCookieValue = await encrypt(session, secret);
220+
const maxAge = 60 * 60; // 1 hour in seconds
221+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
222+
const encryptedCookieValue = await encrypt(session, secret, expiration);
210223

211224
const headers = new Headers();
212225
headers.append("cookie", `__session=${encryptedCookieValue}`);
@@ -216,7 +229,7 @@ describe("Stateless Session Store", async () => {
216229
secret
217230
});
218231

219-
expect(await sessionStore.get(requestCookies)).toEqual(session);
232+
expect(await sessionStore.get(requestCookies)).toEqual(expect.objectContaining(session));
220233
});
221234
});
222235

@@ -264,7 +277,7 @@ describe("Stateless Session Store", async () => {
264277
const cookie = responseCookies.get("__session");
265278

266279
expect(cookie).toBeDefined();
267-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
280+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
268281
expect(cookie?.path).toEqual("/");
269282
expect(cookie?.httpOnly).toEqual(true);
270283
expect(cookie?.sameSite).toEqual("lax");
@@ -306,12 +319,10 @@ describe("Stateless Session Store", async () => {
306319
const cookie = responseCookies.get("__session");
307320

308321
expect(cookie).toBeDefined();
309-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
310-
expect(cookie?.path).toEqual("/");
311-
expect(cookie?.httpOnly).toEqual(true);
312-
expect(cookie?.sameSite).toEqual("lax");
313-
expect(cookie?.maxAge).toEqual(0); // cookie should expire immediately
314-
expect(cookie?.secure).toEqual(false);
322+
323+
await expect(
324+
decrypt(cookie!.value, secret)
325+
).rejects.toThrow(`"exp" claim timestamp check failed`);
315326
});
316327

317328
it("should delete the legacy cookie if it exists", async () => {
@@ -413,7 +424,7 @@ describe("Stateless Session Store", async () => {
413424
const cookie = responseCookies.get("__session");
414425

415426
expect(cookie).toBeDefined();
416-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
427+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
417428
expect(cookie?.path).toEqual("/");
418429
expect(cookie?.httpOnly).toEqual(true);
419430
expect(cookie?.sameSite).toEqual("lax");
@@ -453,7 +464,7 @@ describe("Stateless Session Store", async () => {
453464
const cookie = responseCookies.get("__session");
454465

455466
expect(cookie).toBeDefined();
456-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
467+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
457468
expect(cookie?.path).toEqual("/");
458469
expect(cookie?.httpOnly).toEqual(true);
459470
expect(cookie?.sameSite).toEqual("lax");
@@ -492,7 +503,7 @@ describe("Stateless Session Store", async () => {
492503
const cookie = responseCookies.get("__session");
493504

494505
expect(cookie).toBeDefined();
495-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
506+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
496507
expect(cookie?.path).toEqual("/");
497508
expect(cookie?.httpOnly).toEqual(true);
498509
expect(cookie?.sameSite).toEqual("strict");
@@ -528,7 +539,7 @@ describe("Stateless Session Store", async () => {
528539
const cookie = responseCookies.get("__session");
529540

530541
expect(cookie).toBeDefined();
531-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
542+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
532543
expect(cookie?.path).toEqual("/custom-path");
533544
});
534545

@@ -563,7 +574,7 @@ describe("Stateless Session Store", async () => {
563574
const cookie = responseCookies.get("custom-session");
564575

565576
expect(cookie).toBeDefined();
566-
expect((await decrypt(cookie!.value, secret)).payload).toEqual(session);
577+
expect((await decrypt(cookie!.value, secret)).payload).toEqual(expect.objectContaining(session));
567578
expect(cookie?.path).toEqual("/");
568579
expect(cookie?.httpOnly).toEqual(true);
569580
expect(cookie?.sameSite).toEqual("lax");

src/server/session/stateless-session-store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ export class StatelessSessionStore extends AbstractSessionStore {
8787
session: SessionData
8888
) {
8989
const { connectionTokenSets, ...originalSession } = session;
90-
const jwe = await cookies.encrypt(originalSession, this.secret);
9190
const maxAge = this.calculateMaxAge(session.internal.createdAt);
91+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
92+
const jwe = await cookies.encrypt(originalSession, this.secret, expiration);
9293
const cookieValue = jwe.toString();
9394
const options: CookieOptions = {
9495
...this.cookieConfig,
@@ -141,7 +142,8 @@ export class StatelessSessionStore extends AbstractSessionStore {
141142
cookieName: string,
142143
maxAge: number
143144
) {
144-
const jwe = await cookies.encrypt(session, this.secret);
145+
const expiration = Math.floor(Date.now() / 1000 + maxAge);
146+
const jwe = await cookies.encrypt(session, this.secret, expiration);
145147

146148
const cookieValue = jwe.toString();
147149

0 commit comments

Comments
 (0)