From a3cc82af84297e3b118bb9a2046ea49d47117d0a Mon Sep 17 00:00:00 2001 From: kptdobe Date: Wed, 29 Apr 2026 12:00:15 +0200 Subject: [PATCH 1/2] fix: reject expired IMS tokens by comparing expiry in milliseconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IMS JWT `created_at` and `expires_in` fields are in milliseconds, but `now` was computed in seconds — making `expires` always ~1000x larger than `now`, so expired tokens were never rejected. Fix: use `Date.now()` (ms) for `now` to match the IMS field scale. Also update the jose mock and offline validation token fixture to use ms-scale timestamps, matching real IMS behavior. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/auth.js | 2 +- test/utils/auth.test.js | 33 ++++++++++++++++++++++++++++ test/utils/mocks/jose.js | 2 +- test/utils/offlineValidation.test.js | 4 ++-- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/utils/auth.js b/src/utils/auth.js index 5401eac2..9defa166 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -147,7 +147,7 @@ export async function getUsers(req, env) { if (type !== 'access_token') return { email: 'anonymous' }; const expires = Number(createdAt) + Number(expiresIn); - const now = Math.floor(new Date().getTime() / 1000); + const now = Date.now(); if (expires < now) return { email: 'anonymous' }; // Find the user in recent sessions diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index d279e714..41a9c322 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -60,6 +60,39 @@ describe('DA auth', () => { assert.strictEqual(users[0].email, 'anonymous'); }); + it('anonymous if token expired with realistic IMS ms-scale timestamps', async () => { + // IMS JWT fields: created_at and expires_in are in milliseconds. + // Bug: auth.js computes `now` in seconds; ms-scale `expires` is always larger, + // so expired tokens are never rejected. + // Token issued 2h ago, valid for only 1h — clearly expired. + const TWO_HOURS_MS = 2 * 60 * 60 * 1000; + const ONE_HOUR_MS = 60 * 60 * 1000; + + const { getUsers: getUsersMsExpired } = await esmock('../../src/utils/auth.js', { + jose: { + createRemoteJWKSet: () => null, + jwksCache: 'cache-key', + jwtVerify: () => ({ + payload: { + type: 'access_token', + user_id: 'user@example.com', + created_at: Date.now() - TWO_HOURS_MS, + expires_in: ONE_HOUR_MS, + }, + }), + }, + }); + + const req = new Request('https://da.live/source/cq/test', { + headers: new Headers({ Authorization: 'Bearer sometoken' }), + }); + + await withMockedFetch(async () => { + const users = await getUsersMsExpired(req, env); + assert.strictEqual(users[0].email, 'anonymous'); + }); + }); + it('authorized if email matches', async () => { await withMockedFetch(async () => { const users = await getUsers(reqs.site, env); diff --git a/test/utils/mocks/jose.js b/test/utils/mocks/jose.js index a7c5e8f5..759e5ff8 100644 --- a/test/utils/mocks/jose.js +++ b/test/utils/mocks/jose.js @@ -13,7 +13,7 @@ const jwtVerify = (token) => { // eslint-disable-next-line prefer-const let [email, created_at = 0, expires_in = 0] = token.split(':'); - created_at += Math.floor(new Date().getTime() / 1000); + created_at += new Date().getTime(); expires_in += created_at; return { payload: { diff --git a/test/utils/offlineValidation.test.js b/test/utils/offlineValidation.test.js index 7a714bf3..b5d88163 100644 --- a/test/utils/offlineValidation.test.js +++ b/test/utils/offlineValidation.test.js @@ -50,8 +50,8 @@ async function generateToken(kid, privateKey) { return new SignJWT({ user_id: 'mocked_example_com', type: 'access_token', - created_at: Date.now() / 1000, - expires_in: 60, + created_at: Date.now(), + expires_in: 60000, }) .setProtectedHeader({ alg: 'RS256', From a34d460745aaaab82d21a960163073d7f48a6b62 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Wed, 29 Apr 2026 12:25:45 +0200 Subject: [PATCH 2/2] fix: survive KV PUT failure when caching near-expiry tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens with < 60s remaining cause DA_AUTH.put() to throw a 400 (Cloudflare KV requires expiration >= 60s in the future). The unhandled error propagated through getUsers → getDaCtx → 500 response. Fix: wrap the KV PUT in setUser() with a try-catch so near-expiry tokens still authenticate the user for the current request; they just won't be cached, which is the right behaviour for an almost-expired token anyway. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/auth.js | 9 ++++++++- test/utils/auth.test.js | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/utils/auth.js b/src/utils/auth.js index 9defa166..309ef41c 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -61,7 +61,14 @@ export async function setUser(userId, expiration, reqHeaders, env) { orgs, }); - await env.DA_AUTH.put(userId, value, { expiration }); + try { + await env.DA_AUTH.put(userId, value, { expiration }); + } catch (e) { + // KV rejects expiration timestamps < 60s in the future (near-expiry tokens). + // Log and continue — user is still authenticated, just not cached. + // eslint-disable-next-line no-console + console.error('Failed to cache user in KV', e); + } return value; } diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index 41a9c322..6e33349f 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -151,6 +151,29 @@ describe('DA auth', () => { ]; assert.deepStrictEqual(expectedOrgs, userValue.orgs); }); + + it('returns user value when KV PUT fails for near-expiry token', async () => { + // Near-expiry tokens have < 60s remaining; KV rejects the expiration timestamp. + // Bug: the thrown error propagates up through getUsers -> getDaCtx -> 500. + // Fix: setUser must catch the KV PUT failure and still return the user value. + const headers = new Headers({ Authorization: 'Bearer aparker@geometrixx.info' }); + const kvPutError = new Error( + 'KV PUT failed: 400 Invalid expiration of 1777144621.' + + ' Expiration times must be at least 60 seconds in the future.', + ); + const failEnv = { + ...env, + DA_AUTH: { ...env.DA_AUTH, put: () => { throw kvPutError; } }, + }; + + let userValue; + await withMockedFetch(async () => { + const userValStr = await setUser('aparker@geometrixx.info', 100, headers, failEnv); + userValue = JSON.parse(userValStr); + }); + + assert.strictEqual(userValue.email, 'aparker@geometrixx.info'); + }); }); describe('path authorization', async () => {