Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

## [Unreleased]

### 2026-04-03
- **Fix**: 複数ターミナルで同一プロファイルを使用時、refresh token rotation によりセッションが無効化される問題を修正 — トークンリフレッシュ前に config を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## [0.9.0] - 2026-03-25

### 2026-03-25
Expand Down
16 changes: 16 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class GdbClient {
private clientId?: string;
private clientSecret?: string;
private onTokenRefresh?: (token: string, refreshToken?: string) => void;
private onBeforeRefresh?: () => { token?: string; refreshToken?: string };
private verbose: boolean;
private dryRun: boolean;
private refreshPromise?: Promise<boolean>;
Expand All @@ -30,6 +31,7 @@ export class GdbClient {
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.onTokenRefresh = options.onTokenRefresh;
this.onBeforeRefresh = options.onBeforeRefresh;
this.verbose = options.verbose ?? false;
this.dryRun = options.dryRun ?? false;
}
Expand Down Expand Up @@ -187,6 +189,20 @@ export class GdbClient {
}

private async doRefresh(): Promise<boolean> {
// Re-read config to pick up tokens saved by another process
if (this.onBeforeRefresh) {
const latest = this.onBeforeRefresh();
if (latest.token && latest.token !== this.token) {
// Another process already refreshed — use the new token
this.token = latest.token;
if (latest.refreshToken) this.refreshToken = latest.refreshToken;
return true;
}
if (latest.refreshToken) {
this.refreshToken = latest.refreshToken;
}
}

// Try refreshToken first
if (this.refreshToken) {
try {
Expand Down
6 changes: 6 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export function createClient(cmd: Command): GdbClient {
if (refreshToken) cfg.refreshToken = refreshToken;
saveConfig(cfg, opts.profile);
},
onBeforeRefresh: usingCliToken
? undefined
: () => {
const cfg = loadConfig(opts.profile);
return { token: cfg.token, refreshToken: cfg.refreshToken };
},
verbose: opts.verbose,
dryRun: opts.dryRun,
});
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ClientOptions {
clientId?: string;
clientSecret?: string;
onTokenRefresh?: (token: string, refreshToken?: string) => void;
onBeforeRefresh?: () => { token?: string; refreshToken?: string };
verbose?: boolean;
dryRun?: boolean;
}
Expand Down
77 changes: 77 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,83 @@ describe("GdbClient", () => {
});
});

describe("onBeforeRefresh", () => {
it("uses token from config when another process already refreshed", async () => {
const onRefresh = vi.fn();
const client = new GdbClient({
baseUrl: "http://localhost:3000",
token: "expired-token",
refreshToken: "old-refresh",
onTokenRefresh: onRefresh,
onBeforeRefresh: () => ({
token: "fresh-token-from-config",
refreshToken: "fresh-refresh-from-config",
}),
});

let callCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
callCount++;
if (callCount === 1) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify([{ id: "Room:001" }]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});

const result = await client.get("/entities");
expect(result.data).toEqual([{ id: "Room:001" }]);
// Should NOT have called /auth/refresh — used config token directly
expect(onRefresh).not.toHaveBeenCalled();
// Only 2 calls: original 401 + retry with fresh token
expect(callCount).toBe(2);
});

it("uses latest refreshToken from config when token is the same", async () => {
const onRefresh = vi.fn();
const client = new GdbClient({
baseUrl: "http://localhost:3000",
token: "expired-token",
refreshToken: "stale-refresh",
onTokenRefresh: onRefresh,
onBeforeRefresh: () => ({
token: "expired-token",
refreshToken: "latest-refresh-from-config",
}),
});

vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/auth/refresh")) {
return new Response(
JSON.stringify({ accessToken: "new-token", refreshToken: "new-refresh" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
});

// Will fail because retry also returns 401, but we can verify the refresh used latest token
await expect(client.get("/entities")).rejects.toThrow(GdbClientError);
expect(onRefresh).toHaveBeenCalledWith("new-token", "new-refresh");
// Verify /auth/refresh was called with the latest refreshToken
const refreshCall = vi.mocked(fetch).mock.calls.find(
(call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/auth/refresh"),
);
expect(refreshCall).toBeDefined();
const body = JSON.parse(refreshCall![1]!.body as string);
expect(body.refreshToken).toBe("latest-refresh-from-config");
});
});

describe("concurrent refresh deduplication", () => {
it("reuses the same refresh promise for concurrent 401 retries", async () => {
const onRefresh = vi.fn();
Expand Down
Loading