Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/lib/sessionValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment thread
heskew marked this conversation as resolved.

if (typeof session.update === 'function') {
await session.update(session);
Comment thread
heskew marked this conversation as resolved.
Expand Down
49 changes: 49 additions & 0 deletions test/lib/sessionValidator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
heskew marked this conversation as resolved.

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({
Expand Down
Loading