Skip to content

Commit 2ea91ac

Browse files
committed
Tighten entitlement and refresh-token identity matching
1 parent c9a89d7 commit 2ea91ac

File tree

4 files changed

+115
-18
lines changed

4 files changed

+115
-18
lines changed

lib/entitlement-cache.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,31 +50,27 @@ function normalizeEntitlementEmail(email: string | undefined): string | undefine
5050
/**
5151
* Derives a stable cache key for an entitlement account reference.
5252
*
53-
* Produces one of six deterministic keys:
53+
* Produces one of five deterministic keys:
5454
* - `account:<trimmed accountId>::email:<lowercased trimmed email>` when both are present,
5555
* - `email:<lowercased trimmed email>` when only `email` is present,
5656
* - `account:<trimmed accountId>::idx:<non-negative integer>` when `accountId` is present without email,
5757
* - `account:<trimmed accountId>` when only `accountId` is present and no index is available,
58-
* - `refresh:<trimmed refreshToken>` when no accountId/email exists but a refresh token is available,
5958
* - `idx:<non-negative integer>` otherwise (index defaults to 0).
6059
*
61-
* This function is pure and concurrency-safe; it performs no I/O and is not affected by Windows filesystem semantics. It does not redact secrets or tokens — values are only trimmed and, for emails, lowercased.
60+
* This function is pure and concurrency-safe; it performs no I/O and is not affected by Windows filesystem semantics. It never serializes refresh tokens or other secrets into the returned key.
6261
*
6362
* @param ref - Reference identifying an account (may include `accountId`, `email`, or `index`)
64-
* @returns A deterministic string key prefixed with `account:`, `email:`, `refresh:`, or `idx:` as described above
63+
* @returns A deterministic string key prefixed with `account:`, `email:`, or `idx:` as described above
6564
*/
6665
export function resolveEntitlementAccountKey(ref: EntitlementAccountRef): string {
6766
const accountId = typeof ref.accountId === "string" ? ref.accountId.trim() : "";
6867
const hasIndex = Number.isFinite(ref.index);
6968
const index = hasIndex ? Math.max(0, Math.floor(ref.index ?? 0)) : 0;
7069
const email = normalizeEntitlementEmail(ref.email);
71-
const refreshToken =
72-
typeof ref.refreshToken === "string" ? ref.refreshToken.trim() : "";
7370
if (accountId && email) return `account:${accountId}::email:${email}`;
7471
if (email) return `email:${email}`;
7572
if (accountId && hasIndex) return `account:${accountId}::idx:${index}`;
7673
if (accountId) return `account:${accountId}`;
77-
if (refreshToken) return `refresh:${refreshToken}`;
7874
return `idx:${index}`;
7975
}
8076

lib/storage.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,51 @@ function findSafeEmailMatchIndex<T extends AccountLike>(
11311131
);
11321132
}
11331133

1134+
function findCompatibleRefreshTokenMatchIndex<T extends AccountLike>(
1135+
accounts: readonly T[],
1136+
candidateRef: AccountIdentityRef,
1137+
): number | undefined {
1138+
if (!candidateRef.refreshToken) return undefined;
1139+
let matchingIndex: number | undefined;
1140+
let matchingAccount: T | null = null;
1141+
1142+
for (let i = 0; i < accounts.length; i += 1) {
1143+
const account = accounts[i];
1144+
if (!account) continue;
1145+
const ref = toAccountIdentityRef(account);
1146+
if (ref.refreshToken !== candidateRef.refreshToken) continue;
1147+
if (
1148+
(candidateRef.accountId &&
1149+
ref.accountId &&
1150+
ref.accountId !== candidateRef.accountId) ||
1151+
(candidateRef.emailKey &&
1152+
ref.emailKey &&
1153+
ref.emailKey !== candidateRef.emailKey)
1154+
) {
1155+
return undefined;
1156+
}
1157+
if (
1158+
matchingIndex !== undefined &&
1159+
!candidateRef.accountId &&
1160+
!candidateRef.emailKey
1161+
) {
1162+
return undefined;
1163+
}
1164+
if (matchingIndex === undefined || matchingAccount === null) {
1165+
matchingIndex = i;
1166+
matchingAccount = account;
1167+
continue;
1168+
}
1169+
const newest = selectNewestAccount(matchingAccount, account);
1170+
if (newest === account) {
1171+
matchingIndex = i;
1172+
matchingAccount = account;
1173+
}
1174+
}
1175+
1176+
return matchingIndex;
1177+
}
1178+
11341179
function findUniqueAccountIdMatchIndex<T extends AccountLike>(
11351180
accounts: readonly T[],
11361181
candidateRef: AccountIdentityRef,
@@ -1179,20 +1224,20 @@ export function findMatchingAccountIndex<
11791224
): number | undefined {
11801225
const candidateRef = toAccountIdentityRef(candidate);
11811226

1182-
if (candidateRef.refreshToken) {
1183-
const byRefresh = findNewestMatchingIndex(
1184-
accounts,
1185-
(ref) => ref.refreshToken === candidateRef.refreshToken,
1186-
);
1187-
if (byRefresh !== undefined) return byRefresh;
1188-
}
1189-
11901227
const byComposite = findCompositeAccountMatchIndex(accounts, candidateRef);
11911228
if (byComposite !== undefined) return byComposite;
11921229

11931230
const byEmail = findSafeEmailMatchIndex(accounts, candidateRef);
11941231
if (byEmail !== undefined) return byEmail;
11951232

1233+
if (candidateRef.refreshToken) {
1234+
const byRefresh = findCompatibleRefreshTokenMatchIndex(
1235+
accounts,
1236+
candidateRef,
1237+
);
1238+
if (byRefresh !== undefined) return byRefresh;
1239+
}
1240+
11961241
return findUniqueAccountIdMatchIndex(accounts, candidateRef, options);
11971242
}
11981243

test/entitlement-cache.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,19 @@ describe("entitlement cache", () => {
148148
expect(
149149
resolveEntitlementAccountKey({ email: " Person@Example.com " }),
150150
).toBe("email:person@example.com");
151+
expect(resolveEntitlementAccountKey({ index: Number.NaN })).toBe("idx:0");
152+
});
153+
154+
it("never serializes refresh tokens into entitlement keys", () => {
155+
expect(
156+
resolveEntitlementAccountKey({
157+
refreshToken: " refresh-token ",
158+
index: 4,
159+
}),
160+
).toBe("idx:4");
151161
expect(
152162
resolveEntitlementAccountKey({ refreshToken: " refresh-token " }),
153-
).toBe("refresh:refresh-token");
154-
expect(resolveEntitlementAccountKey({ index: Number.NaN })).toBe("idx:0");
163+
).toBe("idx:0");
155164
});
156165

157166
it("ignores invalid mark/clear/isBlocked inputs", () => {

test/storage.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("storage", () => {
151151
expect(deduped[0]?.lastUsed).toBe(now);
152152
});
153153

154-
it("prefers refresh token matches over composite and email matches", () => {
154+
it("prefers composite and email matches over refresh token matches", () => {
155155
const accounts = [
156156
{
157157
accountId: "workspace-a",
@@ -176,9 +176,56 @@ describe("storage", () => {
176176
refreshToken: "token-refresh",
177177
});
178178

179+
expect(matchIndex).toBe(1);
180+
});
181+
182+
it("uses a unique refresh token match when no safer identifier exists", () => {
183+
const accounts = [
184+
{
185+
accountId: "workspace-a",
186+
email: "alpha@example.com",
187+
refreshToken: "token-refresh",
188+
},
189+
{
190+
accountId: "workspace-b",
191+
email: "match@example.com",
192+
refreshToken: "token-composite",
193+
},
194+
];
195+
196+
const matchIndex = findMatchingAccountIndex(accounts, {
197+
refreshToken: "token-refresh",
198+
});
199+
179200
expect(matchIndex).toBe(0);
180201
});
181202

203+
it("falls back to composite matching when refresh tokens are ambiguous", () => {
204+
const accounts = [
205+
{
206+
accountId: "workspace-a",
207+
email: "alpha@example.com",
208+
refreshToken: "shared-refresh",
209+
lastUsed: 100,
210+
},
211+
{
212+
accountId: "workspace-b",
213+
email: "match@example.com",
214+
refreshToken: "shared-refresh",
215+
lastUsed: 200,
216+
},
217+
];
218+
219+
const matchIndex = findMatchingAccountIndex(accounts, {
220+
accountId: "workspace-b",
221+
email: "match@example.com",
222+
refreshToken: "shared-refresh",
223+
});
224+
225+
expect(matchIndex).toBe(1);
226+
expect(deduplicateAccounts(accounts)).toHaveLength(2);
227+
});
228+
182229
it("prefers composite accountId plus email matches over safe-email fallbacks", () => {
183230
const accounts = [
184231
{

0 commit comments

Comments
 (0)