Skip to content

Commit 23cf326

Browse files
committed
fix(auth): redact device-code logs
1 parent f047ee8 commit 23cf326

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

lib/auth/device-code.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AUTHORIZE_URL, CLIENT_ID, exchangeAuthorizationCode } from "./auth.js";
55

66
const DEFAULT_DEVICE_CODE_MAX_WAIT_MS = 15 * 60 * 1000;
77
const DEFAULT_DEVICE_CODE_INTERVAL_SECONDS = 5;
8+
const DEVICE_CODE_LOG_BODY_LIMIT = 120;
89

910
export interface DeviceCodeSession {
1011
verificationUrl: string;
@@ -62,6 +63,21 @@ function getErrorMessage(status: number, bodyText: string, fallback: string): st
6263
return `${fallback} (${status}): ${trimmed}`;
6364
}
6465

66+
// Auth-server error bodies can echo identifiers like device_auth_id, so logs keep
67+
// only a short prefix while the user-facing message still gets the full response.
68+
function getRedactedErrorBody(bodyText: string): string {
69+
const trimmed = bodyText.trim();
70+
if (!trimmed) {
71+
return "<empty>";
72+
}
73+
74+
if (trimmed.length <= DEVICE_CODE_LOG_BODY_LIMIT) {
75+
return trimmed;
76+
}
77+
78+
return `${trimmed.slice(0, DEVICE_CODE_LOG_BODY_LIMIT)}...`;
79+
}
80+
6581
function parseIntervalSeconds(value: unknown): number {
6682
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
6783
return Math.max(1, Math.floor(value));
@@ -154,7 +170,9 @@ export async function createDeviceCodeSession(options?: {
154170
bodyText,
155171
"Device code login could not be started",
156172
);
157-
logError(`device-code usercode request failed: ${response.status} ${bodyText}`);
173+
logError(
174+
`device-code usercode request failed: ${response.status} ${getRedactedErrorBody(bodyText)}`,
175+
);
158176
return {
159177
type: "failed",
160178
failure: {
@@ -251,7 +269,9 @@ export async function completeDeviceCodeSession(
251269
}
252270

253271
const bodyText = await response.text().catch(() => "");
254-
logError(`device-code token poll failed: ${response.status} ${bodyText}`);
272+
logError(
273+
`device-code token poll failed: ${response.status} ${getRedactedErrorBody(bodyText)}`,
274+
);
255275
return {
256276
type: "failed",
257277
reason: "http_error",

lib/auth/login-runner.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export function resolveAccountSelection(tokens: TokenSuccess): AccountSelectionR
104104
};
105105
}
106106

107+
/**
108+
* Persists login results through the shared storage transaction so overlapping
109+
* login retries serialize their read-modify-write cycle instead of racing stale
110+
* snapshots. `withAccountStorageTransaction` also routes the final rename
111+
* through the Windows lock retry path in `lib/storage.ts`; see
112+
* `test/login-runner.test.ts` for the concurrent persist regression coverage.
113+
*/
107114
export async function persistAccountPool(
108115
results: TokenSuccessWithAccount[],
109116
replaceAll: boolean = false,

test/device-code.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
createDeviceCodeSession,
2222
} from "../lib/auth/device-code.js";
2323
import { exchangeAuthorizationCode } from "../lib/auth/auth.js";
24+
import { logError } from "../lib/logger.js";
2425

2526
describe("device-code auth", () => {
2627
beforeEach(() => {
@@ -72,6 +73,20 @@ describe("device-code auth", () => {
7273
}
7374
});
7475

76+
it("redacts auth-server bodies before logging usercode failures", async () => {
77+
const sensitiveBody = `device_auth_id=secret-${"x".repeat(160)}`;
78+
globalThis.fetch = vi.fn(async () => new Response(sensitiveBody, { status: 500 })) as typeof fetch;
79+
80+
const result = await createDeviceCodeSession();
81+
82+
expect(result.type).toBe("failed");
83+
expect(vi.mocked(logError)).toHaveBeenCalledTimes(1);
84+
const [message] = vi.mocked(logError).mock.calls[0] ?? [];
85+
expect(message).toContain("device-code usercode request failed: 500");
86+
expect(message).not.toContain(sensitiveBody);
87+
expect(message).toContain(sensitiveBody.slice(0, 120));
88+
});
89+
7590
it("polls until an authorization code is issued and exchanges it", async () => {
7691
globalThis.fetch = vi
7792
.fn()
@@ -120,4 +135,23 @@ describe("device-code auth", () => {
120135
message: "Device code authorization timed out after 15 minutes",
121136
});
122137
});
138+
139+
it("redacts auth-server bodies before logging poll failures", async () => {
140+
const sensitiveBody = `authorization_context=secret-${"y".repeat(160)}`;
141+
globalThis.fetch = vi.fn(async () => new Response(sensitiveBody, { status: 500 })) as typeof fetch;
142+
143+
const result = await completeDeviceCodeSession({
144+
verificationUrl: "https://auth.openai.com/codex/device",
145+
userCode: "ABCD-EFGH",
146+
deviceAuthId: "device-auth-1",
147+
intervalSeconds: 1,
148+
});
149+
150+
expect(result.type).toBe("failed");
151+
expect(vi.mocked(logError)).toHaveBeenCalledTimes(1);
152+
const [message] = vi.mocked(logError).mock.calls[0] ?? [];
153+
expect(message).toContain("device-code token poll failed: 500");
154+
expect(message).not.toContain(sensitiveBody);
155+
expect(message).toContain(sensitiveBody.slice(0, 120));
156+
});
123157
});

test/login-runner.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { promises as fs } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
persistAccountPool,
7+
type TokenSuccessWithAccount,
8+
} from "../lib/auth/login-runner.js";
9+
import { loadAccounts, setStoragePathDirect } from "../lib/storage.js";
10+
11+
function createTokenResult(
12+
accountId: string,
13+
refreshToken: string,
14+
): TokenSuccessWithAccount {
15+
return {
16+
type: "success",
17+
access: `access-${accountId}`,
18+
refresh: refreshToken,
19+
expires: Date.now() + 60_000,
20+
accountIdOverride: accountId,
21+
accountIdSource: "manual",
22+
accountLabel: accountId,
23+
};
24+
}
25+
26+
describe("login-runner persistAccountPool", () => {
27+
let testDir: string;
28+
let storagePath: string;
29+
30+
beforeEach(async () => {
31+
testDir = join(
32+
tmpdir(),
33+
`login-runner-${Date.now()}-${Math.random().toString(36).slice(2)}`,
34+
);
35+
storagePath = join(testDir, "openai-codex-accounts.json");
36+
await fs.mkdir(testDir, { recursive: true });
37+
setStoragePathDirect(storagePath);
38+
});
39+
40+
afterEach(async () => {
41+
setStoragePathDirect(null);
42+
vi.restoreAllMocks();
43+
await fs.rm(testDir, { recursive: true, force: true });
44+
});
45+
46+
it("serializes overlapping login persists without losing accounts", async () => {
47+
const originalRename = fs.rename.bind(fs);
48+
let firstRenameReleased = false;
49+
let resolveFirstRename: (() => void) | undefined;
50+
const firstRenameBlocked = new Promise<void>((resolve) => {
51+
resolveFirstRename = resolve;
52+
});
53+
let resolveFirstRenameStarted: (() => void) | undefined;
54+
const firstRenameStarted = new Promise<void>((resolve) => {
55+
resolveFirstRenameStarted = resolve;
56+
});
57+
let renameCount = 0;
58+
59+
const renameSpy = vi
60+
.spyOn(fs, "rename")
61+
.mockImplementation(async (sourcePath, destinationPath) => {
62+
renameCount += 1;
63+
if (renameCount === 1 && !firstRenameReleased) {
64+
resolveFirstRenameStarted?.();
65+
await firstRenameBlocked;
66+
firstRenameReleased = true;
67+
}
68+
return originalRename(sourcePath, destinationPath);
69+
});
70+
71+
try {
72+
const firstPersist = persistAccountPool(
73+
[createTokenResult("acct-a", "refresh-a")],
74+
false,
75+
);
76+
await firstRenameStarted;
77+
78+
const secondPersist = persistAccountPool(
79+
[createTokenResult("acct-b", "refresh-b")],
80+
false,
81+
);
82+
83+
resolveFirstRename?.();
84+
await Promise.all([firstPersist, secondPersist]);
85+
86+
expect(renameSpy).toHaveBeenCalledTimes(2);
87+
const loaded = await loadAccounts();
88+
expect(loaded?.accounts).toHaveLength(2);
89+
expect(
90+
new Set(loaded?.accounts.map((account) => account.accountId)),
91+
).toEqual(new Set(["acct-a", "acct-b"]));
92+
} finally {
93+
resolveFirstRename?.();
94+
}
95+
});
96+
});

0 commit comments

Comments
 (0)