Skip to content

Commit fd7cbc5

Browse files
committed
fix(oauth): retry device-code bootstrap and fail closed
1 parent 0568d43 commit fd7cbc5

5 files changed

Lines changed: 324 additions & 29 deletions

File tree

backend/internal/api/accounts_handler.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -560,13 +560,16 @@ func (h *AccountsHandler) createAuthSession(w http.ResponseWriter, r *http.Reque
560560
"authorization_url": authURL,
561561
"state": state,
562562
}
563-
if session, err := h.connector.StartDeviceAuth(r.Context(), h.client); err == nil {
564-
resp["device_code"] = session.DeviceCode
565-
resp["user_code"] = session.UserCode
566-
resp["verification_uri"] = session.VerificationURI
567-
resp["expires_in"] = session.ExpiresIn
568-
resp["interval"] = session.Interval
563+
session, err := h.connector.StartDeviceAuth(r.Context(), h.client)
564+
if err != nil {
565+
http.Error(w, fmt.Sprintf("start device auth: %v", err), http.StatusBadGateway)
566+
return
569567
}
568+
resp["device_code"] = session.DeviceCode
569+
resp["user_code"] = session.UserCode
570+
resp["verification_uri"] = session.VerificationURI
571+
resp["expires_in"] = session.ExpiresIn
572+
resp["interval"] = session.Interval
570573

571574
writeJSON(w, http.StatusOK, resp)
572575
}

backend/internal/api/accounts_handler_test.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,49 @@ func TestAccountsHandlerAuthorizeReturnsDeviceCode(t *testing.T) {
101101
}
102102
}
103103

104+
func TestAccountsHandlerAuthorizeReturnsErrorWhenDeviceAuthUnavailable(t *testing.T) {
105+
t.Parallel()
106+
107+
store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite"))
108+
if err != nil {
109+
t.Fatalf("Open returned error: %v", err)
110+
}
111+
t.Cleanup(func() { _ = store.Close() })
112+
113+
repo := accounts.NewSQLiteRepository(store.DB())
114+
deviceAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115+
http.Error(w, "temporary upstream failure", http.StatusBadGateway)
116+
}))
117+
t.Cleanup(deviceAuthServer.Close)
118+
119+
connector := auth.NewOAuthConnector(auth.Config{
120+
ClientID: "client-id",
121+
AuthorizeURL: "https://auth.example.test/oauth/authorize",
122+
TokenURL: "https://auth.example.test/oauth/token",
123+
RedirectURL: "http://localhost:8080/callback",
124+
Scopes: []string{"model.read"},
125+
DeviceAuthUserCodeURL: deviceAuthServer.URL + "/device-auth",
126+
})
127+
handler := api.NewAccountsHandler(
128+
repo,
129+
nil,
130+
connector,
131+
auth.NewStateStore(5*time.Minute),
132+
api.WithAccountsHTTPClient(deviceAuthServer.Client()),
133+
)
134+
135+
req := httptest.NewRequest(http.MethodPost, "/accounts/auth/authorize", bytes.NewBufferString(`{}`))
136+
req.Header.Set("Content-Type", "application/json")
137+
rec := httptest.NewRecorder()
138+
handler.ServeHTTP(rec, req)
139+
if rec.Code != http.StatusBadGateway {
140+
t.Fatalf("POST /accounts/auth/authorize status = %d, want %d", rec.Code, http.StatusBadGateway)
141+
}
142+
if !strings.Contains(rec.Body.String(), "start device auth:") {
143+
t.Fatalf("POST /accounts/auth/authorize body = %q, want prefixed error", rec.Body.String())
144+
}
145+
}
146+
104147
func TestAccountsHandlerCompleteDeviceAuthUsesInferredName(t *testing.T) {
105148
t.Parallel()
106149

@@ -192,14 +235,42 @@ func TestAccountsHandler(t *testing.T) {
192235

193236
repo := accounts.NewSQLiteRepository(store.DB())
194237
usageRepo := usage.NewSQLiteRepository(store.DB())
238+
deviceAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239+
writeJSON := func(value any) {
240+
w.Header().Set("Content-Type", "application/json")
241+
_ = json.NewEncoder(w).Encode(value)
242+
}
243+
switch r.URL.Path {
244+
case "/device-auth":
245+
writeJSON(map[string]any{
246+
"device_auth_id": "dev-auth-generic",
247+
"user_code": "WXYZ-1234",
248+
"expires_in": 900,
249+
"interval": 5,
250+
})
251+
return
252+
default:
253+
http.NotFound(w, r)
254+
return
255+
}
256+
}))
257+
t.Cleanup(deviceAuthServer.Close)
258+
195259
connector := auth.NewOAuthConnector(auth.Config{
196-
ClientID: "client-id",
197-
AuthorizeURL: "https://auth.example.test/oauth/authorize",
198-
TokenURL: "https://auth.example.test/oauth/token",
199-
RedirectURL: "http://localhost:8080/callback",
200-
Scopes: []string{"model.read"},
260+
ClientID: "client-id",
261+
AuthorizeURL: "https://auth.example.test/oauth/authorize",
262+
TokenURL: "https://auth.example.test/oauth/token",
263+
RedirectURL: "http://localhost:8080/callback",
264+
Scopes: []string{"model.read"},
265+
DeviceAuthUserCodeURL: deviceAuthServer.URL + "/device-auth",
201266
})
202-
handler := api.NewAccountsHandler(repo, usageRepo, connector, auth.NewStateStore(5*time.Minute))
267+
handler := api.NewAccountsHandler(
268+
repo,
269+
usageRepo,
270+
connector,
271+
auth.NewStateStore(5*time.Minute),
272+
api.WithAccountsHTTPClient(deviceAuthServer.Client()),
273+
)
203274

204275
createBody := bytes.NewBufferString(`{
205276
"provider_type":"openai-compatible",

frontend/src/features/accounts/AccountsPage.test.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,203 @@ describe("AccountsPage", () => {
158158
).toBeInTheDocument();
159159
});
160160

161+
it("does not open browser when oauth authorize response misses device code", async () => {
162+
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
163+
const url = String(input);
164+
if (
165+
url === "/ai-router/api/accounts" &&
166+
(!init?.method || init.method === "GET")
167+
) {
168+
return Promise.resolve(
169+
new Response(JSON.stringify([]), {
170+
status: 200,
171+
headers: { "Content-Type": "application/json" },
172+
}),
173+
);
174+
}
175+
if (
176+
url === "/ai-router/api/accounts/usage" &&
177+
(!init?.method || init.method === "GET")
178+
) {
179+
return Promise.resolve(
180+
new Response(JSON.stringify([]), {
181+
status: 200,
182+
headers: { "Content-Type": "application/json" },
183+
}),
184+
);
185+
}
186+
if (
187+
url === "/ai-router/api/accounts/auth/authorize" &&
188+
init?.method === "POST"
189+
) {
190+
return Promise.resolve(
191+
new Response(
192+
JSON.stringify({
193+
authorization_url: "https://auth.openai.com/codex/device",
194+
state: "state-1",
195+
}),
196+
{ status: 200, headers: { "Content-Type": "application/json" } },
197+
),
198+
);
199+
}
200+
return Promise.resolve(new Response(null, { status: 404 }));
201+
});
202+
vi.stubGlobal("fetch", fetchMock);
203+
204+
renderAccountsPage();
205+
206+
fireEvent.click(await screen.findByRole("button", { name: // }));
207+
fireEvent.click(await screen.findByText("官方账户"));
208+
const officialModal = await screen.findByRole("dialog", {
209+
name: "添加官方账户",
210+
});
211+
fireEvent.click(
212+
within(officialModal).getByRole("button", { name: "使用 ChatGPT 登录" }),
213+
);
214+
215+
await waitFor(() => {
216+
expect(fetchMock).toHaveBeenCalledWith(
217+
"/ai-router/api/accounts/auth/authorize",
218+
expect.objectContaining({ method: "POST" }),
219+
);
220+
expect(openExternalUrl).not.toHaveBeenCalled();
221+
expect(within(officialModal).queryByText("设备码")).toBeNull();
222+
expect(
223+
within(officialModal).getByRole("button", { name: "使用 ChatGPT 登录" }),
224+
).toBeInTheDocument();
225+
});
226+
});
227+
228+
it("retries oauth authorize up to 3 times and succeeds on the third try", async () => {
229+
let authorizeCalls = 0;
230+
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
231+
const url = String(input);
232+
if (
233+
url === "/ai-router/api/accounts" &&
234+
(!init?.method || init.method === "GET")
235+
) {
236+
return Promise.resolve(
237+
new Response(JSON.stringify([]), {
238+
status: 200,
239+
headers: { "Content-Type": "application/json" },
240+
}),
241+
);
242+
}
243+
if (
244+
url === "/ai-router/api/accounts/usage" &&
245+
(!init?.method || init.method === "GET")
246+
) {
247+
return Promise.resolve(
248+
new Response(JSON.stringify([]), {
249+
status: 200,
250+
headers: { "Content-Type": "application/json" },
251+
}),
252+
);
253+
}
254+
if (
255+
url === "/ai-router/api/accounts/auth/authorize" &&
256+
init?.method === "POST"
257+
) {
258+
authorizeCalls += 1;
259+
if (authorizeCalls < 3) {
260+
return Promise.resolve(new Response("temporary unavailable", { status: 502 }));
261+
}
262+
return Promise.resolve(
263+
new Response(
264+
JSON.stringify({
265+
authorization_url: "https://auth.openai.com/codex/device",
266+
state: "state-3",
267+
user_code: "IJKL-MNOP",
268+
device_code: "device-auth-id-3",
269+
verification_uri: "https://auth.openai.com/codex/device",
270+
}),
271+
{ status: 200, headers: { "Content-Type": "application/json" } },
272+
),
273+
);
274+
}
275+
return Promise.resolve(new Response(null, { status: 404 }));
276+
});
277+
vi.stubGlobal("fetch", fetchMock);
278+
279+
renderAccountsPage();
280+
281+
fireEvent.click(await screen.findByRole("button", { name: // }));
282+
fireEvent.click(await screen.findByText("官方账户"));
283+
const officialModal = await screen.findByRole("dialog", {
284+
name: "添加官方账户",
285+
});
286+
fireEvent.click(
287+
within(officialModal).getByRole("button", { name: "使用 ChatGPT 登录" }),
288+
);
289+
290+
await waitFor(() => {
291+
expect(authorizeCalls).toBe(3);
292+
expect(openExternalUrl).toHaveBeenCalledWith(
293+
"https://auth.openai.com/codex/device",
294+
);
295+
expect(within(officialModal).getByText("设备码")).toBeInTheDocument();
296+
expect(within(officialModal).getByText("IJKL-MNOP")).toBeInTheDocument();
297+
});
298+
});
299+
300+
it("stops after 3 oauth authorize retries and does not open browser", async () => {
301+
let authorizeCalls = 0;
302+
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
303+
const url = String(input);
304+
if (
305+
url === "/ai-router/api/accounts" &&
306+
(!init?.method || init.method === "GET")
307+
) {
308+
return Promise.resolve(
309+
new Response(JSON.stringify([]), {
310+
status: 200,
311+
headers: { "Content-Type": "application/json" },
312+
}),
313+
);
314+
}
315+
if (
316+
url === "/ai-router/api/accounts/usage" &&
317+
(!init?.method || init.method === "GET")
318+
) {
319+
return Promise.resolve(
320+
new Response(JSON.stringify([]), {
321+
status: 200,
322+
headers: { "Content-Type": "application/json" },
323+
}),
324+
);
325+
}
326+
if (
327+
url === "/ai-router/api/accounts/auth/authorize" &&
328+
init?.method === "POST"
329+
) {
330+
authorizeCalls += 1;
331+
return Promise.resolve(new Response("temporary unavailable", { status: 502 }));
332+
}
333+
return Promise.resolve(new Response(null, { status: 404 }));
334+
});
335+
vi.stubGlobal("fetch", fetchMock);
336+
337+
renderAccountsPage();
338+
339+
fireEvent.click(await screen.findByRole("button", { name: // }));
340+
fireEvent.click(await screen.findByText("官方账户"));
341+
const officialModal = await screen.findByRole("dialog", {
342+
name: "添加官方账户",
343+
});
344+
fireEvent.click(
345+
within(officialModal).getByRole("button", { name: "使用 ChatGPT 登录" }),
346+
);
347+
348+
await waitFor(() => {
349+
expect(authorizeCalls).toBe(3);
350+
expect(openExternalUrl).not.toHaveBeenCalled();
351+
expect(within(officialModal).queryByText("设备码")).toBeNull();
352+
expect(
353+
within(officialModal).getByRole("button", { name: "使用 ChatGPT 登录" }),
354+
).toBeInTheDocument();
355+
});
356+
});
357+
161358
it("supports official upload, third-party create, and chat test in a single dashboard", async () => {
162359
const accountList = [
163360
{

frontend/src/features/accounts/AccountsPage.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -994,29 +994,47 @@ export function AccountsPage({
994994
}
995995

996996
async function handleStartOfficialOAuth() {
997+
if (officialOAuthLaunching) {
998+
return;
999+
}
9971000
setOfficialOAuthLaunching(true);
9981001
try {
9991002
let targetURL = "https://auth.openai.com/codex/device";
1000-
let startedSession: OfficialAuthSession | null = null;
1001-
try {
1002-
const session = await startOfficialAuth();
1003-
setOfficialOAuthSession(session);
1004-
startedSession = session;
1005-
const candidate = (session.authorization_url || "").trim();
1006-
if (/^https?:\/\//i.test(candidate)) {
1007-
targetURL = candidate;
1008-
}
1009-
const verificationURI = (session.verification_uri || "").trim();
1010-
if (/^https?:\/\//i.test(verificationURI)) {
1011-
targetURL = verificationURI;
1003+
const maxRetries = 3;
1004+
let session: OfficialAuthSession | null = null;
1005+
let lastError: unknown = null;
1006+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
1007+
try {
1008+
session = await startOfficialAuth();
1009+
break;
1010+
} catch (error) {
1011+
lastError = error;
1012+
if (attempt < maxRetries) {
1013+
await new Promise<void>((resolve) => {
1014+
window.setTimeout(resolve, 300);
1015+
});
1016+
}
10121017
}
1013-
} catch {
1014-
setOfficialOAuthSession(null);
1015-
// Fallback to Codex device login URL when backend oauth metadata is unavailable.
1018+
}
1019+
if (!session) {
1020+
throw lastError instanceof Error ? lastError : new Error(t("启动 OAuth 失败"));
1021+
}
1022+
setOfficialOAuthSession(session);
1023+
const candidate = (session.authorization_url || "").trim();
1024+
if (/^https?:\/\//i.test(candidate)) {
1025+
targetURL = candidate;
1026+
}
1027+
const verificationURI = (session.verification_uri || "").trim();
1028+
if (/^https?:\/\//i.test(verificationURI)) {
1029+
targetURL = verificationURI;
10161030
}
10171031
await openExternalUrl(targetURL);
10181032
void messageApi.success(t("已打开 ChatGPT 登录页"));
1019-
startOfficialOAuthAutoPoll(startedSession);
1033+
startOfficialOAuthAutoPoll(session);
1034+
} catch (error) {
1035+
setOfficialOAuthSession(null);
1036+
const friendlyMessage = t("网络波动,暂时无法获取设备码。已自动重试 3 次,请稍后重试。");
1037+
void messageApi.error(friendlyMessage);
10201038
} finally {
10211039
setOfficialOAuthLaunching(false);
10221040
}

0 commit comments

Comments
 (0)