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
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 を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する (#93)

## [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