Skip to content

Commit 4930149

Browse files
authored
fix: 複数ターミナル使用時の refresh token rotation 競合を解消 (#93)
* fix: 複数ターミナル使用時の refresh token rotation 競合を解消 複数ターミナルで同一プロファイルを使用している場合、一方のプロセスが refresh token rotation でトークンを更新すると、他方のプロセスが保持する 古い refreshToken が無効化されセッション切れとなっていた。 トークンリフレッシュ前に config ファイルを再読み込みする onBeforeRefresh コールバックを追加し、別プロセスが既にリフレッシュ済みの 場合はそのトークンを直接採用する。 * fix: CHANGELOG エントリに PR 番号を追加
1 parent 3578338 commit 4930149

5 files changed

Lines changed: 103 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
## [Unreleased]
99

10+
### 2026-04-03
11+
- **Fix**: 複数ターミナルで同一プロファイルを使用時、refresh token rotation によりセッションが無効化される問題を修正 — トークンリフレッシュ前に config を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する (#93)
12+
1013
## [0.9.0] - 2026-03-25
1114

1215
### 2026-03-25

src/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class GdbClient {
1717
private clientId?: string;
1818
private clientSecret?: string;
1919
private onTokenRefresh?: (token: string, refreshToken?: string) => void;
20+
private onBeforeRefresh?: () => { token?: string; refreshToken?: string };
2021
private verbose: boolean;
2122
private dryRun: boolean;
2223
private refreshPromise?: Promise<boolean>;
@@ -30,6 +31,7 @@ export class GdbClient {
3031
this.clientId = options.clientId;
3132
this.clientSecret = options.clientSecret;
3233
this.onTokenRefresh = options.onTokenRefresh;
34+
this.onBeforeRefresh = options.onBeforeRefresh;
3335
this.verbose = options.verbose ?? false;
3436
this.dryRun = options.dryRun ?? false;
3537
}
@@ -187,6 +189,20 @@ export class GdbClient {
187189
}
188190

189191
private async doRefresh(): Promise<boolean> {
192+
// Re-read config to pick up tokens saved by another process
193+
if (this.onBeforeRefresh) {
194+
const latest = this.onBeforeRefresh();
195+
if (latest.token && latest.token !== this.token) {
196+
// Another process already refreshed — use the new token
197+
this.token = latest.token;
198+
if (latest.refreshToken) this.refreshToken = latest.refreshToken;
199+
return true;
200+
}
201+
if (latest.refreshToken) {
202+
this.refreshToken = latest.refreshToken;
203+
}
204+
}
205+
190206
// Try refreshToken first
191207
if (this.refreshToken) {
192208
try {

src/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export function createClient(cmd: Command): GdbClient {
5252
if (refreshToken) cfg.refreshToken = refreshToken;
5353
saveConfig(cfg, opts.profile);
5454
},
55+
onBeforeRefresh: usingCliToken
56+
? undefined
57+
: () => {
58+
const cfg = loadConfig(opts.profile);
59+
return { token: cfg.token, refreshToken: cfg.refreshToken };
60+
},
5561
verbose: opts.verbose,
5662
dryRun: opts.dryRun,
5763
});

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface ClientOptions {
3838
clientId?: string;
3939
clientSecret?: string;
4040
onTokenRefresh?: (token: string, refreshToken?: string) => void;
41+
onBeforeRefresh?: () => { token?: string; refreshToken?: string };
4142
verbose?: boolean;
4243
dryRun?: boolean;
4344
}

tests/client.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,83 @@ describe("GdbClient", () => {
966966
});
967967
});
968968

969+
describe("onBeforeRefresh", () => {
970+
it("uses token from config when another process already refreshed", async () => {
971+
const onRefresh = vi.fn();
972+
const client = new GdbClient({
973+
baseUrl: "http://localhost:3000",
974+
token: "expired-token",
975+
refreshToken: "old-refresh",
976+
onTokenRefresh: onRefresh,
977+
onBeforeRefresh: () => ({
978+
token: "fresh-token-from-config",
979+
refreshToken: "fresh-refresh-from-config",
980+
}),
981+
});
982+
983+
let callCount = 0;
984+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
985+
callCount++;
986+
if (callCount === 1) {
987+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
988+
status: 401,
989+
headers: { "Content-Type": "application/json" },
990+
});
991+
}
992+
return new Response(JSON.stringify([{ id: "Room:001" }]), {
993+
status: 200,
994+
headers: { "Content-Type": "application/json" },
995+
});
996+
});
997+
998+
const result = await client.get("/entities");
999+
expect(result.data).toEqual([{ id: "Room:001" }]);
1000+
// Should NOT have called /auth/refresh — used config token directly
1001+
expect(onRefresh).not.toHaveBeenCalled();
1002+
// Only 2 calls: original 401 + retry with fresh token
1003+
expect(callCount).toBe(2);
1004+
});
1005+
1006+
it("uses latest refreshToken from config when token is the same", async () => {
1007+
const onRefresh = vi.fn();
1008+
const client = new GdbClient({
1009+
baseUrl: "http://localhost:3000",
1010+
token: "expired-token",
1011+
refreshToken: "stale-refresh",
1012+
onTokenRefresh: onRefresh,
1013+
onBeforeRefresh: () => ({
1014+
token: "expired-token",
1015+
refreshToken: "latest-refresh-from-config",
1016+
}),
1017+
});
1018+
1019+
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
1020+
const urlStr = typeof url === "string" ? url : url.toString();
1021+
if (urlStr.includes("/auth/refresh")) {
1022+
return new Response(
1023+
JSON.stringify({ accessToken: "new-token", refreshToken: "new-refresh" }),
1024+
{ status: 200, headers: { "Content-Type": "application/json" } },
1025+
);
1026+
}
1027+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
1028+
status: 401,
1029+
headers: { "Content-Type": "application/json" },
1030+
});
1031+
});
1032+
1033+
// Will fail because retry also returns 401, but we can verify the refresh used latest token
1034+
await expect(client.get("/entities")).rejects.toThrow(GdbClientError);
1035+
expect(onRefresh).toHaveBeenCalledWith("new-token", "new-refresh");
1036+
// Verify /auth/refresh was called with the latest refreshToken
1037+
const refreshCall = vi.mocked(fetch).mock.calls.find(
1038+
(call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/auth/refresh"),
1039+
);
1040+
expect(refreshCall).toBeDefined();
1041+
const body = JSON.parse(refreshCall![1]!.body as string);
1042+
expect(body.refreshToken).toBe("latest-refresh-from-config");
1043+
});
1044+
});
1045+
9691046
describe("concurrent refresh deduplication", () => {
9701047
it("reuses the same refresh promise for concurrent 401 retries", async () => {
9711048
const onRefresh = vi.fn();

0 commit comments

Comments
 (0)