diff --git a/src/lib/sessionValidator.ts b/src/lib/sessionValidator.ts index 3d9879d..14a57e3 100644 --- a/src/lib/sessionValidator.ts +++ b/src/lib/sessionValidator.ts @@ -84,9 +84,22 @@ export async function validateAndRefreshSession( return { valid: false, error: 'Token validation failed - token may have been revoked' }; } - // Update last validated timestamp in session - oauthMetadata.lastValidated = now; - session.oauth = oauthMetadata; + // session.oauth is a Harper tracked object: its properties are read-only and + // spread copies nothing, so rebuild it explicitly (mirrors the refresh path + // below) instead of mutating in place, which throws on the frozen record. + session.oauth = { + provider: oauthMetadata.provider, + providerConfigId: oauthMetadata.providerConfigId, + providerType: oauthMetadata.providerType, + accessToken: oauthMetadata.accessToken, + refreshToken: oauthMetadata.refreshToken, + expiresAt: oauthMetadata.expiresAt, + refreshThreshold: oauthMetadata.refreshThreshold, + scope: oauthMetadata.scope, + tokenType: oauthMetadata.tokenType, + lastRefreshed: oauthMetadata.lastRefreshed, + lastValidated: now, + }; if (typeof session.update === 'function') { await session.update(session); diff --git a/test/lib/sessionValidator.test.js b/test/lib/sessionValidator.test.js index e532815..5435395 100644 --- a/test/lib/sessionValidator.test.js +++ b/test/lib/sessionValidator.test.js @@ -403,6 +403,55 @@ test('should perform periodic validation for non-expiring tokens', async () => { assert.ok(session.oauth.lastValidated > Date.now() - 100, 'lastValidated timestamp should be updated'); }); +test('should update lastValidated on a read-only tracked session.oauth without throwing', async () => { + let validationCalled = false; + const provider = createMockProvider({ + config: { + ...createMockProvider().config, + validateToken: async () => { + validationCalled = true; + return true; + }, + tokenValidationInterval: 1000, + }, + }); + + // A read-only, non-enumerable session.oauth reproduces Harper's v5 tracked object: + // in-place assignment throws and spread copies nothing. + const lastRefreshed = Date.now() - 5000; + const trackedFields = { + provider: 'github', + providerConfigId: 'github', + providerType: 'github', + accessToken: 'github_token', + refreshToken: undefined, + scope: 'repo read:org', + tokenType: 'bearer', + lastRefreshed, + lastValidated: Date.now() - 2000, // 2s ago, past the interval + }; + const trackedOAuth = {}; + for (const [key, value] of Object.entries(trackedFields)) { + Object.defineProperty(trackedOAuth, key, { value, writable: false, enumerable: false, configurable: false }); + } + Object.freeze(trackedOAuth); + + const session = createMockSession({ oauth: trackedOAuth }); + + const result = await validateAndRefreshSession({ session }, provider); + + assert.strictEqual(result.valid, true); + assert.strictEqual(validationCalled, true, 'validateToken should have been called'); + assert.ok(session.oauth.lastValidated > Date.now() - 100, 'lastValidated should advance (rebuilt, not mutated)'); + assert.strictEqual(session.oauth.provider, 'github', 'provider preserved'); + assert.strictEqual(session.oauth.providerConfigId, 'github', 'providerConfigId preserved'); + assert.strictEqual(session.oauth.providerType, 'github', 'providerType preserved'); + assert.strictEqual(session.oauth.accessToken, 'github_token', 'accessToken preserved'); + assert.strictEqual(session.oauth.scope, 'repo read:org', 'scope preserved'); + assert.strictEqual(session.oauth.tokenType, 'bearer', 'tokenType preserved'); + assert.strictEqual(session.oauth.lastRefreshed, lastRefreshed, 'lastRefreshed preserved'); +}); + test('should skip validation when interval has not passed', async () => { let validationCalled = false; const provider = createMockProvider({