Skip to content

Commit 76a499a

Browse files
Merge pull request #1433 from corbitsdev/cl-10665-auth-de-dupe-shared-callbackloginsessionstorelock-h
refactor(auth): extract shared provider auth helpers (C1-C7)
2 parents 842e787 + 4192829 commit 76a499a

21 files changed

Lines changed: 940 additions & 693 deletions

‎src/auth/bearer-headers.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface BearerToken {
2+
readonly access: string;
3+
}
4+
5+
export function bearerHeaders(
6+
token: BearerToken,
7+
extra: Record<string, string> = {},
8+
): Record<string, string> {
9+
return { authorization: `Bearer ${token.access}`, ...extra };
10+
}

‎src/auth/codex/auth-headers.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { bearerHeaders } from "../bearer-headers.js";
12
import { CODEX_CLIENT_VERSION, CODEX_ORIGINATOR } from "./constants.js";
23

34
export function codexAuthHeadersForToken(
@@ -7,11 +8,10 @@ export function codexAuthHeadersForToken(
78
},
89
commandName: string,
910
): Record<string, string> {
10-
const headers: Record<string, string> = {
11-
authorization: `Bearer ${token.access}`,
11+
const headers = bearerHeaders(token, {
1212
originator: CODEX_ORIGINATOR,
1313
"user-agent": `${commandName} (${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION})`,
14-
};
14+
});
1515
// An empty account id carries no identity — sending it as a header value
1616
// would label the request with a meaningless id. Only a non-empty id rides.
1717
if (token.accountId) headers["chatgpt-account-id"] = token.accountId;

‎src/auth/codex/callback-server.ts‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { startCallbackServer, type CallbackServer } from "@corbits/oauth-core";
2-
31
import {
4-
authorizationDoneHtml,
5-
callbackPageHtml,
6-
type CallbackPageCopy,
7-
} from "../callback-page.js";
2+
startProviderCallbackServer,
3+
type CallbackServer,
4+
} from "../provider-callback-server.js";
5+
6+
import type { CallbackPageCopy } from "../callback-page.js";
87
import {
98
CODEX_CALLBACK_HOST,
109
CODEX_CALLBACK_PATH,
@@ -19,13 +18,15 @@ export async function startCodexCallbackServer(
1918
expectedState: string,
2019
copy: CallbackPageCopy,
2120
): Promise<CodexCallbackServer> {
22-
return startCallbackServer(expectedState, {
23-
port: CODEX_CALLBACK_PORT,
24-
// Codex's registered redirect_uri uses localhost (not 127.0.0.1).
25-
host: CODEX_CALLBACK_HOST,
26-
path: CODEX_CALLBACK_PATH,
27-
doneHtml: authorizationDoneHtml("Codex", copy),
28-
failedHtml: (reason) =>
29-
callbackPageHtml({ subject: "Codex", error: reason }, copy),
30-
});
21+
return startProviderCallbackServer(
22+
"Codex",
23+
{
24+
port: CODEX_CALLBACK_PORT,
25+
// Codex's registered redirect_uri uses localhost (not 127.0.0.1).
26+
host: CODEX_CALLBACK_HOST,
27+
path: CODEX_CALLBACK_PATH,
28+
},
29+
expectedState,
30+
copy,
31+
);
3132
}

‎src/auth/codex/login.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import {
2-
buildAuthorizeUrl,
3-
startOAuthLogin,
4-
type OAuthLoginHandle,
5-
type StartOAuthLoginOptions,
6-
} from "@corbits/oauth-core";
7-
import {
8-
codexOAuthConfig,
92
exchangeCodexCode,
3+
codexOAuthConfig,
104
type CodexTokens,
115
} from "@corbits/codex-provider";
6+
import type {
7+
OAuthLoginHandle,
8+
StartOAuthLoginOptions,
9+
} from "@corbits/oauth-core";
10+
import { startProviderLogin } from "../provider-login.js";
1211

1312
import type { CallbackPageCopy } from "../callback-page.js";
1413
import { saveCodexProfile } from "../../config/oauth-stores.js";
@@ -25,10 +24,9 @@ export async function startCodexLogin(
2524
opts: StartCodexLoginOptions,
2625
): Promise<CodexLoginHandle> {
2726
const { home, copy, ...loginOpts } = opts;
28-
return startOAuthLogin(loginOpts, {
27+
return startProviderLogin<CodexTokens>(loginOpts, {
28+
oauthConfig: codexOAuthConfig,
2929
startCallbackServer: (state) => startCodexCallbackServer(state, copy),
30-
buildAuthorizeUrl: (pkce, state) =>
31-
buildAuthorizeUrl(codexOAuthConfig, pkce, state),
3230
exchangeCode: async (code, verifier, now) =>
3331
withDefaultCodexExpiry(await exchangeCodexCode(code, verifier, now), now),
3432
saveProfile: (profile) => saveCodexProfile(profile, home),

‎src/auth/codex/refresh-lock.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,30 @@ import {
99
import { tmpdir } from "node:os";
1010
import { join } from "node:path";
1111
import { describe, expect, test } from "bun:test";
12-
import {
13-
CodexRefreshLockTimeoutError,
14-
withCodexRefreshLock,
15-
} from "./refresh-lock.js";
12+
import { CodexRefreshLockTimeoutError } from "./refresh-lock.js";
13+
14+
import { withMockedModule } from "../../../testkit/mock-module.js";
15+
16+
// Non-ENOENT read and stat failures must surface raw instead of being swallowed.
17+
// Flags gate each failure to its own test; undefined keeps every call on the real module.
18+
let failingReadPath: string | undefined;
19+
let failingStatPath: string | undefined;
20+
await withMockedModule(
21+
import.meta.resolve("node:fs/promises"),
22+
(real: typeof import("node:fs/promises")) => ({
23+
...real,
24+
readFile: async (...args: Parameters<typeof real.readFile>) => {
25+
if (args[0] === failingReadPath) throw new Error("lock read failed");
26+
return real.readFile(...args);
27+
},
28+
stat: async (...args: Parameters<typeof real.stat>) => {
29+
if (args[0] === failingStatPath) throw new Error("lock stat failed");
30+
return real.stat(...args);
31+
},
32+
}),
33+
);
34+
35+
const { withCodexRefreshLock } = await import("./refresh-lock.js");
1636

1737
async function tempDir(): Promise<string> {
1838
return mkdtemp(join(tmpdir(), "cl8628-lock-"));
@@ -214,4 +234,50 @@ describe("codex refresh lock", () => {
214234
await rm(dir, { recursive: true, force: true });
215235
}
216236
});
237+
238+
test("a non-ENOENT read failure while contending propagates raw", async () => {
239+
const dir = await tempDir();
240+
try {
241+
const lock = join(dir, "refresh.lock");
242+
await writeFile(lock, "");
243+
failingReadPath = lock;
244+
try {
245+
// The unified lock re-reads the holder content while contending; a
246+
// permission/disk failure there must surface, not read as "no lock".
247+
await expect(
248+
withCodexRefreshLock(lock, async () => "never", {
249+
timeoutMs: 1_000,
250+
retryMs: 10,
251+
}),
252+
).rejects.toThrow("lock read failed");
253+
} finally {
254+
failingReadPath = undefined;
255+
}
256+
} finally {
257+
await rm(dir, { recursive: true, force: true });
258+
}
259+
});
260+
261+
test("a non-ENOENT stat failure while deciding staleness propagates raw", async () => {
262+
const dir = await tempDir();
263+
try {
264+
const lock = join(dir, "refresh.lock");
265+
// Untagged content routes the staleness decision through mtime stat;
266+
// a non-ENOENT stat failure must surface, not read as "not stale".
267+
await writeFile(lock, "");
268+
failingStatPath = lock;
269+
try {
270+
await expect(
271+
withCodexRefreshLock(lock, async () => "never", {
272+
timeoutMs: 1_000,
273+
retryMs: 10,
274+
}),
275+
).rejects.toThrow("lock stat failed");
276+
} finally {
277+
failingStatPath = undefined;
278+
}
279+
} finally {
280+
await rm(dir, { recursive: true, force: true });
281+
}
282+
});
217283
});

‎src/auth/codex/refresh-lock.ts‎

Lines changed: 11 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
2-
import { randomUUID } from "node:crypto";
3-
import { dirname } from "node:path";
1+
import { withFileLock } from "../file-lock.js";
42

53
// Serializes Codex OAuth refresh-and-persist sections across processes that
64
// share one credential store. Same-process callers chain on an in-memory
7-
// tail; cross-process callers contend on an exclusive lock file with
8-
// crashed-holder takeover via PID liveness; the inner TokenSession dedups
9-
// overlapping refreshes. At most one refresh grant is in flight, so a second
10-
// refresher sees the persisted result instead of racing refresh-token
11-
// rotation and revoking its sibling.
5+
// tail; cross-process callers contend on an exclusive lock file with a
6+
// crashed-holder takeover via PID liveness. At most one refresh grant is in
7+
// flight, so a second refresher sees the persisted result instead of racing
8+
// refresh-token rotation and revoking its sibling.
129
//
1310
// Accepted limits: PID liveness only means something on one host in one PID
1411
// namespace (ESRCH would steal a live holder's lock, a recycled PID stalls
1512
// recovery), and one lock path per store (two spellings contend on two
1613
// files). Same-host headless runs satisfy both.
17-
const tails = new Map<string, Promise<void>>();
1814

1915
// Stale horizon for legacy locks that carry no holder PID (foreign writers,
2016
// pre-tagging locks). Kept far above any legitimate hold and contender
@@ -54,160 +50,17 @@ export class CodexRefreshLockTimeoutError extends Error {
5450
}
5551
}
5652

57-
// Holder tag: the creating PID (for crashed-holder liveness) plus a unique
58-
// token, so only the creator removes the file — a belated release after a
59-
// stale-takeover steal would otherwise hand two holders overlapping grants.
60-
function holderTag(): string {
61-
return `${String(process.pid)}:${randomUUID()}`;
62-
}
63-
64-
function holderPid(content: string): number | null {
65-
const pid = Number(content.split(":")[0]?.trim());
66-
return Number.isInteger(pid) && pid > 0 ? pid : null;
67-
}
68-
69-
// kill(pid, 0) liveness: EPERM is alive (other user), ESRCH/EINVAL dead,
70-
// anything else treated as alive so a confused signal check never steals a
71-
// live holder's lock. Only meaningful within one PID namespace.
72-
function isPidAlive(pid: number): boolean {
73-
try {
74-
process.kill(pid, 0);
75-
return true;
76-
} catch (err) {
77-
const code = (err as NodeJS.ErrnoException)?.code;
78-
return code !== "ESRCH" && code !== "EINVAL";
79-
}
80-
}
81-
82-
async function readLockContent(lockPath: string): Promise<string | null> {
83-
try {
84-
return await readFile(lockPath, "utf8");
85-
} catch {
86-
return null;
87-
}
88-
}
89-
90-
// Safe to take over: a tagged holder with a dead PID, or an untagged lock
91-
// older than the stale horizon.
92-
async function isLockStale(
93-
lockPath: string,
94-
staleMs: number,
95-
content: string | null,
96-
): Promise<boolean> {
97-
if (content === null) return false;
98-
const pid = holderPid(content);
99-
if (pid !== null) return !isPidAlive(pid);
100-
try {
101-
const info = await stat(lockPath);
102-
return Date.now() - info.mtimeMs > staleMs;
103-
} catch {
104-
// The lock vanished between read and stat: not ours to take over.
105-
return false;
106-
}
107-
}
108-
109-
async function acquireLockFile(
110-
lockPath: string,
111-
timeoutMs: number,
112-
retryMs: number,
113-
staleMs: number,
114-
deadline: number,
115-
): Promise<string> {
116-
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
117-
for (;;) {
118-
const tag = holderTag();
119-
try {
120-
const handle = await open(lockPath, "wx", 0o600);
121-
await handle.writeFile(tag, "utf8");
122-
await handle.close();
123-
return tag;
124-
} catch (err) {
125-
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err;
126-
}
127-
// A crashed holder never releases: unlink and take over. Re-check the
128-
// content first so a concurrent winner's fresh lock is not unlinked.
129-
const content = await readLockContent(lockPath);
130-
if (content !== null && (await isLockStale(lockPath, staleMs, content))) {
131-
if ((await readLockContent(lockPath)) === content) {
132-
await unlink(lockPath).catch(() => undefined);
133-
}
134-
continue;
135-
}
136-
if (Date.now() >= deadline)
137-
throw new CodexRefreshLockTimeoutError(lockPath, timeoutMs);
138-
await new Promise((resolve) => setTimeout(resolve, retryMs));
139-
}
140-
}
141-
142-
// Bound the in-memory queue wait by the same deadline as file contention,
143-
// or a backed-up queue stalls far longer than timeoutMs promises.
144-
async function waitForTail(
145-
previous: Promise<void>,
146-
lockPath: string,
147-
timeoutMs: number,
148-
deadline: number,
149-
): Promise<void> {
150-
let timer: ReturnType<typeof setTimeout> | undefined;
151-
try {
152-
await Promise.race([
153-
previous.catch(() => undefined),
154-
new Promise<never>((_, reject) => {
155-
const remaining = deadline - Date.now();
156-
if (remaining <= 0) {
157-
reject(new CodexRefreshLockTimeoutError(lockPath, timeoutMs));
158-
return;
159-
}
160-
timer = setTimeout(() => {
161-
reject(new CodexRefreshLockTimeoutError(lockPath, timeoutMs));
162-
}, remaining);
163-
}),
164-
]);
165-
} finally {
166-
clearTimeout(timer);
167-
}
168-
}
169-
170-
/**
171-
* Runs `work` while holding the refresh lock at `lockPath`. Same-process
172-
* callers queue in memory, other processes on the lock file; one deadline
173-
* covers both, so the call holds or throws within ~timeoutMs. The creating
174-
* holder removes the file. Callers must pass one canonical path per store.
175-
*/
17653
export async function withCodexRefreshLock<T>(
17754
lockPath: string,
17855
work: () => Promise<T>,
17956
options: CodexRefreshLockOptions = {},
18057
): Promise<T> {
18158
const timeoutMs = options.timeoutMs ?? 30_000;
182-
const retryMs = options.retryMs ?? 25;
183-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
184-
const deadline = Date.now() + timeoutMs;
185-
const previous = tails.get(lockPath) ?? Promise.resolve();
186-
let releaseTail!: () => void;
187-
const current = new Promise<void>((resolve) => {
188-
releaseTail = resolve;
59+
return withFileLock(lockPath, work, {
60+
timeoutMs,
61+
retryMs: options.retryMs ?? 25,
62+
staleMs: options.staleMs ?? DEFAULT_STALE_MS,
63+
timeoutError: (lockPath: string, timeoutMs: number) =>
64+
new CodexRefreshLockTimeoutError(lockPath, timeoutMs),
18965
});
190-
tails.set(lockPath, current);
191-
try {
192-
await waitForTail(previous, lockPath, timeoutMs, deadline);
193-
const tag = await acquireLockFile(
194-
lockPath,
195-
timeoutMs,
196-
retryMs,
197-
staleMs,
198-
deadline,
199-
);
200-
try {
201-
return await work();
202-
} finally {
203-
// Fenced release: remove the file only while it still carries our
204-
// tag, or a stale-takeover steal would hand two holders grants.
205-
if ((await readLockContent(lockPath)) === tag) {
206-
await unlink(lockPath).catch(() => undefined);
207-
}
208-
}
209-
} finally {
210-
if (tails.get(lockPath) === current) tails.delete(lockPath);
211-
releaseTail();
212-
}
21366
}

0 commit comments

Comments
 (0)