Skip to content

Commit 0d7fd98

Browse files
committed
feat(codex-auth): add manual redirect-URL/code paste for headless account add (#183)
1 parent a626a5b commit 0d7fd98

4 files changed

Lines changed: 218 additions & 3 deletions

File tree

devlog/_plan/260722_issue_bug_sweep/022_patch_o2_codex_auth_manual_code.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- 소스 RCA: `004_rca_o_oauth_persistence_manual_code.md` O-2 절 (리뷰어 검증 완료)
44
- 위험도: 높음/C4 (인증 코드를 받는 신규 API 표면) — 020(Anthropic 지속성)과 별도 위협 모델이라 분리된 단위.
55
- 선행 조건: 없음.
6+
- **구현 완료 (2026-07-22)**: auth-api.ts:766 `POST /api/codex-auth/login/code`(pending flowId 결속 + 4096자 제한 + `submitManualLoginCode("chatgpt")` 위임, 자체 교환 없음); AddCodexAccountModal.tsx 수동 붙여넣기 입력(autoComplete off, 제출/취소/만료/unmount 시 클리어, Enter 제출); 붙여넣은 값 무로깅, chatgpt는 isPublicOAuthProvider 제외 유지, raw-import 403 게이트(:141) 불변; 기존 prov.paste* i18n 재사용. 검증: codex-auth-api+oauth-manual-code 72 pass, `bun x tsc --noEmit` exit 0. 커밋: WP-impl-5.
67

78
## 목표와 비목표
89

gui/src/components/AddCodexAccountModal.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export default function AddCodexAccountModal({
1616
const [error, setError] = useState("");
1717
const [authUrl, setAuthUrl] = useState("");
1818
const [copied, setCopied] = useState(false);
19+
const [manualCode, setManualCode] = useState("");
20+
const [manualCodeBusy, setManualCodeBusy] = useState(false);
1921

2022
const aliveRef = useRef(true);
2123
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -41,7 +43,13 @@ export default function AddCodexAccountModal({
4143
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
4244
}, []);
4345

46+
const clearManualCode = useCallback(() => {
47+
setManualCode("");
48+
setManualCodeBusy(false);
49+
}, []);
50+
4451
const cancelLogin = useCallback(async () => {
52+
clearManualCode();
4553
const flowId = flowRef.current;
4654
flowRef.current = null;
4755
setAuthUrl("");
@@ -54,9 +62,10 @@ export default function AddCodexAccountModal({
5462
headers: { "Content-Type": "application/json" },
5563
body: JSON.stringify({ flowId }),
5664
}).catch(() => {});
57-
}, [apiBase, stopPolling]);
65+
}, [apiBase, clearManualCode, stopPolling]);
5866

5967
useEffect(() => () => {
68+
clearManualCode();
6069
aliveRef.current = false;
6170
loginAbortRef.current?.abort();
6271
loginAbortRef.current = null;
@@ -72,14 +81,16 @@ export default function AddCodexAccountModal({
7281
body: JSON.stringify({ flowId }),
7382
}).catch(() => {});
7483
}
75-
}, [apiBase]);
84+
}, [apiBase, clearManualCode]);
7685

7786
const closeModal = useCallback(() => {
7887
if (step === "oauth-waiting") void cancelLogin();
7988
onCloseRef.current();
8089
}, [step, cancelLogin]);
8190

8291
const startOAuth = useCallback(async (requestedId?: string) => {
92+
clearManualCode();
93+
flowRef.current = null;
8394
const controller = new AbortController();
8495
loginAbortRef.current?.abort();
8596
loginAbortRef.current = controller;
@@ -117,12 +128,14 @@ export default function AddCodexAccountModal({
117128
const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string };
118129
if (st.status === "done") {
119130
stopPolling();
131+
clearManualCode();
120132
flowRef.current = null;
121133
if (!aliveRef.current) return;
122134
onAddedRef.current();
123135
onCloseRef.current();
124136
} else if (st.status === "error" || st.status === "expired") {
125137
stopPolling();
138+
clearManualCode();
126139
flowRef.current = null;
127140
if (aliveRef.current) {
128141
if (!reauthAccountId) setStep("pick");
@@ -133,6 +146,7 @@ export default function AddCodexAccountModal({
133146
}, 2000);
134147
timeoutRef.current = setTimeout(() => {
135148
if (pollRef.current) {
149+
clearManualCode();
136150
void cancelLogin();
137151
if (aliveRef.current) {
138152
if (!reauthAccountId) setStep("pick");
@@ -145,7 +159,7 @@ export default function AddCodexAccountModal({
145159
} catch (e) {
146160
if (aliveRef.current && !(e instanceof Error && e.name === "AbortError")) setError(String(e));
147161
}
148-
}, [apiBase, cancelLogin, reauthAccountId, stopPolling, t]);
162+
}, [apiBase, cancelLogin, clearManualCode, reauthAccountId, stopPolling, t]);
149163

150164
useEffect(() => {
151165
if (!reauthAccountId) {
@@ -179,6 +193,32 @@ export default function AddCodexAccountModal({
179193
}
180194
};
181195

196+
const submitManualCode = useCallback(async () => {
197+
const flowId = flowRef.current;
198+
const input = manualCode.trim();
199+
if (!flowId || !input || manualCodeBusy) return;
200+
setManualCodeBusy(true);
201+
setManualCode("");
202+
try {
203+
const resp = await fetch(`${apiBase}/api/codex-auth/login/code`, {
204+
method: "POST",
205+
headers: { "Content-Type": "application/json" },
206+
body: JSON.stringify({ flowId, input }),
207+
});
208+
const data = await resp.json().catch(() => ({})) as { error?: string };
209+
if (!aliveRef.current) return;
210+
if (!resp.ok) {
211+
setError(t("prov.pasteFail", { error: data.error ?? resp.statusText }));
212+
return;
213+
}
214+
setError("");
215+
} catch {
216+
if (aliveRef.current) setError(t("modal.networkError"));
217+
} finally {
218+
if (aliveRef.current) setManualCodeBusy(false);
219+
}
220+
}, [apiBase, manualCode, manualCodeBusy, t]);
221+
182222
// Focus-trap: focus first interactive element on mount, restore on unmount.
183223
useEffect(() => {
184224
previousFocusRef.current = document.activeElement as HTMLElement | null;
@@ -244,6 +284,37 @@ export default function AddCodexAccountModal({
244284
<button className="btn btn-ghost" onClick={copyLoginLink} disabled={!authUrl} style={{ width: "100%", justifyContent: "center", marginTop: 12 }}>
245285
<IconLink width={14} /> {copied ? t("codexAuth.loginLinkCopied") : t("codexAuth.copyLoginLink")}
246286
</button>
287+
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 12 }}>
288+
<div className="muted text-label">{t("prov.pasteRedirectHint")}</div>
289+
<div style={{ display: "flex", gap: 8 }}>
290+
<input
291+
type="text"
292+
autoComplete="off"
293+
spellCheck={false}
294+
value={manualCode}
295+
onChange={e => setManualCode(e.target.value)}
296+
onKeyDown={e => {
297+
if (e.key === "Enter") {
298+
e.preventDefault();
299+
void submitManualCode();
300+
}
301+
}}
302+
placeholder={t("prov.pasteRedirect")}
303+
aria-label={t("prov.pasteRedirect")}
304+
disabled={manualCodeBusy}
305+
className="input text-label"
306+
style={{ flex: 1 }}
307+
/>
308+
<button
309+
className="btn btn-ghost"
310+
type="button"
311+
disabled={manualCodeBusy || !manualCode.trim() || !flowRef.current}
312+
onClick={() => void submitManualCode()}
313+
>
314+
{manualCodeBusy ? t("prov.pasteSubmitting") : t("prov.pasteSubmit")}
315+
</button>
316+
</div>
317+
</div>
247318
{error && <div className="notice notice-err" style={{ marginTop: 12 }}>{error}</div>}
248319
<div style={{ textAlign: "center", padding: "24px 0" }}>
249320
<span className="spin" style={{ width: 24, height: 24 }} />

src/codex/auth-api.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,24 @@ export async function handleCodexAuthAPI(
763763
}
764764
}
765765

766+
if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
767+
const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
768+
const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
769+
const input = typeof body.input === "string" ? body.input : "";
770+
if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
771+
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
772+
773+
// Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
774+
const { submitManualLoginCode } = await import("../oauth");
775+
const flow = codexAuthLoginState.get(flowId);
776+
if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
777+
if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
778+
779+
const result = submitManualLoginCode("chatgpt", input);
780+
if (!result.ok) return jsonResponse({ error: result.error }, 400);
781+
return jsonResponse({ ok: true }, 202);
782+
}
783+
766784
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
767785
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
768786
const { cancelLoginFlow } = await import("../oauth");

tests/codex-auth-api.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,131 @@ describe("codex-auth API", () => {
806806
expect(data).toMatchObject({ status: "error", error: "Login cancelled" });
807807
});
808808

809+
describe("POST /api/codex-auth/login/code", () => {
810+
async function startPendingFlow() {
811+
const oauth = await import("../src/oauth");
812+
const openUrlMod = await import("../src/lib/open-url");
813+
const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" });
814+
const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({
815+
done: false,
816+
loggedIn: false,
817+
} as ReturnType<typeof oauth.getLoginStatus>);
818+
const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {});
819+
const req = new Request("http://localhost/api/codex-auth/login", {
820+
method: "POST",
821+
headers: { "Content-Type": "application/json" },
822+
body: "{}",
823+
});
824+
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
825+
const data = await resp!.json() as { flowId: string };
826+
return {
827+
flowId: data.flowId,
828+
oauth,
829+
async cleanup() {
830+
const cancelReq = new Request("http://localhost/api/codex-auth/login/cancel", {
831+
method: "POST",
832+
headers: { "Content-Type": "application/json" },
833+
body: JSON.stringify({ flowId: data.flowId }),
834+
});
835+
await handleCodexAuthAPI(cancelReq, new URL(cancelReq.url), makeConfig());
836+
startSpy.mockRestore();
837+
statusSpy.mockRestore();
838+
openSpy.mockRestore();
839+
},
840+
};
841+
}
842+
843+
function codeRequest(body: Record<string, unknown>): Request {
844+
return new Request("http://localhost/api/codex-auth/login/code", {
845+
method: "POST",
846+
headers: { "Content-Type": "application/json" },
847+
body: JSON.stringify(body),
848+
});
849+
}
850+
851+
test("accepts a manual code only for the pending flow without reflecting it", async () => {
852+
const flow = await startPendingFlow();
853+
const pasted = "http://localhost:1455/auth/callback?code=secret-code&state=expected";
854+
const submitSpy = spyOn(flow.oauth, "submitManualLoginCode").mockReturnValue({ ok: true });
855+
try {
856+
const req = codeRequest({ flowId: flow.flowId, input: pasted });
857+
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
858+
expect(resp!.status).toBe(202);
859+
const responseText = await resp!.text();
860+
expect(JSON.parse(responseText)).toEqual({ ok: true });
861+
expect(submitSpy).toHaveBeenCalledTimes(1);
862+
expect(submitSpy).toHaveBeenCalledWith("chatgpt", pasted);
863+
expect(responseText).not.toContain(pasted);
864+
} finally {
865+
submitSpy.mockRestore();
866+
await flow.cleanup();
867+
}
868+
});
869+
870+
test("rejects missing and unknown flow ids before submitting", async () => {
871+
const oauth = await import("../src/oauth");
872+
const submitSpy = spyOn(oauth, "submitManualLoginCode");
873+
try {
874+
for (const body of [{ input: "code" }, { flowId: "", input: "code" }, { flowId: "unknown", input: "code" }]) {
875+
const req = codeRequest(body);
876+
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
877+
expect(resp!.status).toBe(400);
878+
}
879+
expect(submitSpy).not.toHaveBeenCalled();
880+
} finally {
881+
submitSpy.mockRestore();
882+
}
883+
});
884+
885+
test("rejects expired or mismatched non-pending flow ids", async () => {
886+
const flow = await startPendingFlow();
887+
const submitSpy = spyOn(flow.oauth, "submitManualLoginCode");
888+
await flow.cleanup();
889+
try {
890+
for (const flowId of [flow.flowId, `${flow.flowId}-mismatch`]) {
891+
const req = codeRequest({ flowId, input: "code" });
892+
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
893+
expect(resp!.status).toBe(400);
894+
}
895+
expect(submitSpy).not.toHaveBeenCalled();
896+
} finally {
897+
submitSpy.mockRestore();
898+
}
899+
});
900+
901+
test("rejects empty input and no shared login in progress", async () => {
902+
const flow = await startPendingFlow();
903+
try {
904+
for (const input of ["", " ", "raw-code"]) {
905+
const req = codeRequest({ flowId: flow.flowId, input });
906+
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
907+
expect(resp!.status).toBe(400);
908+
}
909+
} finally {
910+
await flow.cleanup();
911+
}
912+
});
913+
914+
test("rejects input over 4096 characters and allows the 4096 boundary to reach shared validation", async () => {
915+
const flow = await startPendingFlow();
916+
const submitSpy = spyOn(flow.oauth, "submitManualLoginCode").mockReturnValue({ ok: true });
917+
try {
918+
const oversizedReq = codeRequest({ flowId: flow.flowId, input: "x".repeat(4097) });
919+
const oversizedResp = await handleCodexAuthAPI(oversizedReq, new URL(oversizedReq.url), makeConfig());
920+
expect(oversizedResp!.status).toBe(400);
921+
expect(submitSpy).not.toHaveBeenCalled();
922+
923+
const boundaryReq = codeRequest({ flowId: flow.flowId, input: "x".repeat(4096) });
924+
const boundaryResp = await handleCodexAuthAPI(boundaryReq, new URL(boundaryReq.url), makeConfig());
925+
expect(boundaryResp!.status).toBe(202);
926+
expect(submitSpy).toHaveBeenCalledTimes(1);
927+
} finally {
928+
submitSpy.mockRestore();
929+
await flow.cleanup();
930+
}
931+
});
932+
});
933+
809934
test("GET /api/codex-auth/login-status recovers done when a persisted account exists", async () => {
810935
saveCodexAccountCredential("pool-login-recovery", {
811936
accessToken: "tok",

0 commit comments

Comments
 (0)