Skip to content

Commit 19f7502

Browse files
committed
Merge xAI OAuth code completion fix
2 parents 7a0dda6 + 7263571 commit 19f7502

11 files changed

Lines changed: 260 additions & 29 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rerouted",
3-
"version": "0.4.6",
3+
"version": "0.4.7",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"homepage": "https://rerouted.dev",

scripts/capture-ui.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,55 @@ app.whenReady().then(async () => {
773773
})()
774774
`);
775775

776+
await win.webContents.executeJavaScript(`
777+
(async () => {
778+
document.querySelector("[data-provider-back]")?.click();
779+
document.querySelector('[data-provider-key="xai"]')?.click();
780+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
781+
document.getElementById("btn-add-provider")?.click();
782+
await new Promise((resolve) => setTimeout(resolve, 500));
783+
const panel = document.querySelector("#add-panel .action-panel");
784+
const input = panel?.querySelector("#paste-code-oauth");
785+
const details = panel?.querySelector("details");
786+
if (panel?.querySelector("[data-panel-heading]")?.textContent.trim() !== "xAI") {
787+
throw new Error("xAI OAuth add panel did not render");
788+
}
789+
if (!input || details?.contains(input)) {
790+
throw new Error("xAI authorization code input was hidden under troubleshooting");
791+
}
792+
if (!input.previousElementSibling?.textContent.includes("Authorization code")) {
793+
throw new Error("xAI authorization code input was not clearly labeled");
794+
}
795+
if (!panel.querySelector("#oauth-status-line")?.textContent.includes("authorization code")) {
796+
throw new Error("xAI OAuth status did not request the displayed code");
797+
}
798+
return true;
799+
})()
800+
`);
801+
await capture(
802+
"app-providers-xai-oauth-add.png",
803+
"#add-panel .action-panel",
804+
"#add-panel .action-panel"
805+
);
806+
807+
await win.webContents.executeJavaScript(`
808+
(async () => {
809+
const input = document.querySelector("#paste-code-oauth");
810+
const finish = document.querySelector("#btn-done-oauth");
811+
if (!input || !finish) throw new Error("xAI OAuth completion controls disappeared");
812+
input.value = "captured-xai-code";
813+
finish.click();
814+
await new Promise((resolve) => setTimeout(resolve, 100));
815+
if (document.querySelector("#add-panel .action-panel")) {
816+
throw new Error("xAI OAuth panel did not complete cleanly");
817+
}
818+
document.querySelector("[data-provider-back]")?.click();
819+
document.querySelector('[data-provider-key="chatgpt"]')?.click();
820+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
821+
return true;
822+
})()
823+
`);
824+
776825
await win.webContents.executeJavaScript(`
777826
(async () => {
778827
const opener = document.getElementById("btn-add-provider");

src/lib/oauth.js

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,18 @@ function normalizeAuthCode(raw) {
120120
) {
121121
s = s.slice(1, -1).trim();
122122
}
123-
// full URL with ?code=
124-
try {
125-
if (/^https?:\/\//i.test(s) || s.includes("code=")) {
123+
// Full callback URL or query string with ?code=.
124+
const callbackLike = /^https?:\/\//i.test(s) || s.includes("code=");
125+
if (callbackLike) {
126+
try {
126127
const u = new URL(s.includes("://") ? s : `http://local/?${s.replace(/^\?/, "")}`);
127128
const code = u.searchParams.get("code") || "";
128129
const state = u.searchParams.get("state") || "";
129130
if (code) return { code: decodeURIComponent(code), state: state || "" };
131+
return { code: "", state };
132+
} catch {
133+
return { code: "", state: "" };
130134
}
131-
} catch {
132-
/* fall through */
133135
}
134136
// Claude: code#state
135137
if (s.includes("#")) {
@@ -139,6 +141,22 @@ function normalizeAuthCode(raw) {
139141
return { code: s, state: "" };
140142
}
141143

144+
function isStandaloneAuthCode(raw) {
145+
let s = String(raw || "").trim();
146+
if (
147+
(s.startsWith('"') && s.endsWith('"')) ||
148+
(s.startsWith("'") && s.endsWith("'"))
149+
) {
150+
s = s.slice(1, -1).trim();
151+
}
152+
return !!s && !/^https?:\/\//i.test(s) && !s.includes("code=") && !s.includes("#");
153+
}
154+
155+
function isCallbackLikeInput(raw) {
156+
const s = String(raw || "").trim().replace(/^(?:"|')|(?:"|')$/g, "");
157+
return /^https?:\/\//i.test(s) || s.includes("code=");
158+
}
159+
142160
function callbackPage(result) {
143161
const safeUrl = escapeHtml(result.fullUrl);
144162
if (result.providerError) {
@@ -482,6 +500,8 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) {
482500

483501
let code = session?.code;
484502
let returnedState = session?.callbackState || "";
503+
let acceptsActivePkceSession = false;
504+
let callbackInputMissingCode = false;
485505
if (pasteCode) {
486506
const n = normalizeAuthCode(pasteCode);
487507
logger.oauth("paste code normalized", {
@@ -491,25 +511,34 @@ async function completeOAuth(type, { pasteCode, fetchImpl = fetch } = {}) {
491511
});
492512
code = n.code || code;
493513
// A manual value must carry its own state; never combine a pasted code
494-
// with state captured from an earlier callback.
514+
// with state captured from an earlier callback. xAI is the exception: its
515+
// browser flow displays a standalone code that remains bound to this PKCE
516+
// session through the verifier used during token exchange.
495517
returnedState = n.state || "";
518+
acceptsActivePkceSession =
519+
type === "xai" && !!n.code && !n.state && isStandaloneAuthCode(pasteCode);
520+
callbackInputMissingCode = !n.code && isCallbackLikeInput(pasteCode);
496521
}
497522
if (!code) {
498-
const msg = session?.error
499-
? `OAuth error: ${session.error}`
500-
: "No authorization code yet. Finish login in the browser (paste the code if shown), then click I'm done.";
523+
const msg = callbackInputMissingCode
524+
? type === "xai"
525+
? "That URL does not contain an authorization code. Paste the code xAI shows you, or the full callback URL after authorization."
526+
: "That URL does not contain an authorization code. Paste the full callback URL after authorization."
527+
: session?.error
528+
? `OAuth error: ${session.error}`
529+
: "No authorization code yet. Finish login in the browser, then click Finish connection.";
501530
logger.error(msg);
502531
throw new Error(msg);
503532
}
504-
if (!statesMatch(session.state, returnedState)) {
533+
if (!statesMatch(session.state, returnedState) && !acceptsActivePkceSession) {
505534
logger.warn(`OAuth state validation failed for ${type}`);
506535
throw new Error("OAuth state mismatch. Start the connection again and paste the full callback URL.");
507536
}
508537

509538
const t = type === "codex" ? "chatgpt" : type;
510539
logger.oauth(`exchanging tokens type=${t}`, {
511540
codeLen: code.length,
512-
stateValidated: true,
541+
authorizationBinding: acceptsActivePkceSession ? "active-pkce-session" : "callback-state",
513542
redirectUri: session?.redirectUri,
514543
});
515544
let tokens;

src/renderer/app.js

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { accountDisplayName, accountIdentityLabel, maskAccountEmail } =
66
const { compactNumber: fmtNum } = window.ReroutedNumberFormat;
77
const { createLatestRequestGate, guardSensitiveRender } = window.ReroutedRendererLockState;
88
const { buildProviderCatalog } = window.ReroutedProviderCatalog;
9+
const { oauthPrompt } = window.ReroutedOAuthPrompt;
910
const $ = (sel, el = document) => el.querySelector(sel);
1011
const view = $("#view");
1112
const nav = $("#nav");
@@ -291,7 +292,6 @@ function wireSecrets(root = view) {
291292
});
292293
}
293294

294-
const PASTE_CODE_PLACEHOLDER = "Paste the full callback URL if prompted";
295295
const OAUTH_RISK_NOTICE =
296296
"Notice: This provider's subscription or OAuth session is not officially licensed for proxy or router use. Using it this way may result in account restrictions or bans. Proceed at your own risk.";
297297

@@ -450,15 +450,17 @@ function renderOauthProviders() {
450450
view.querySelectorAll(".tile").forEach((btn) => {
451451
btn.onclick = async () => {
452452
const type = btn.dataset.type;
453+
const prompt = oauthPrompt(type);
453454
const panel = $("#oauth-panel");
454-
panel.innerHTML = `<div class="card"><div class="card-title">Signing in to ${esc(type)}…</div>
455-
<div class="card-sub">Complete login in your browser, then click Finish connection.</div>
455+
panel.innerHTML = `<div class="card"><div class="card-title">Signing in to ${esc(providerLabel(type))}…</div>
456+
<div class="card-sub">${esc(prompt.instruction)}</div>
456457
${oauthRiskNotice()}
457458
<div class="btn-row" style="margin-top:10px">
458-
<button type="button" class="btn btn-secondary btn-sm" id="btn-reopen">Open browser again</button>
459+
<button type="button" class="btn btn-secondary btn-sm" id="btn-reopen">Restart sign-in</button>
459460
<button type="button" class="btn btn-primary btn-sm" id="btn-done">Finish connection</button>
460461
</div>
461-
<input class="input" id="paste-code" placeholder="${PASTE_CODE_PLACEHOLDER}" style="margin-top:10px" />
462+
<div class="oauth-paste-entry"><label class="label" for="paste-code">${esc(prompt.fieldLabel)}</label>
463+
<input class="input" id="paste-code" placeholder="${esc(prompt.placeholder)}" autocomplete="off" /></div>
462464
</div>`;
463465
await api.invoke("app:oauth-start", type);
464466
$("#btn-reopen").onclick = () => api.invoke("app:oauth-start", type);
@@ -1067,21 +1069,21 @@ function startOauthFlow({ type, providerId, onDone, opener }) {
10671069
box.className = "action-panel";
10681070
box.setAttribute("role", "region");
10691071
box.setAttribute("aria-labelledby", "oauth-panel-title");
1070-
const claudeHint =
1071-
type === "claude"
1072-
? "After authorizing, paste the full localhost callback URL if the browser cannot return automatically."
1073-
: "Most providers return automatically. Paste the full callback URL only if automatic return fails.";
1074-
box.innerHTML = `<div class="action-panel-head"><div class="eyebrow">${providerId ? "Reconnect" : "New account"}</div><div class="action-panel-title" id="oauth-panel-title" data-panel-heading tabindex="-1">${esc(providerLabel(type))}</div><div class="action-panel-sub">Opening a secure browser session. ${esc(claudeHint)}</div></div>
1072+
const prompt = oauthPrompt(type);
1073+
const pasteField = `<div class="oauth-paste-entry"><label class="label" for="paste-code-oauth">${esc(prompt.fieldLabel)}</label>
1074+
<input class="input" id="paste-code-oauth" placeholder="${esc(prompt.placeholder)}" autocomplete="off" /></div>`;
1075+
box.innerHTML = `<div class="action-panel-head"><div class="eyebrow">${providerId ? "Reconnect" : "New account"}</div><div class="action-panel-title" id="oauth-panel-title" data-panel-heading tabindex="-1">${esc(providerLabel(type))}</div><div class="action-panel-sub">Opening a secure browser session. ${esc(prompt.instruction)}</div></div>
10751076
${oauthRiskNotice()}
10761077
<div class="gateway-state"><span class="status-node"></span><span id="oauth-status-line">Starting OAuth…</span></div>
1078+
${prompt.primaryPaste ? pasteField : ""}
10771079
<details class="disclosure" style="margin:10px 0 0">
10781080
<summary>Having trouble?</summary>
10791081
<div class="disclosure-body"><div class="label">Authorization URL</div><div class="auth-url-box" id="oauth-url-display">Starting…</div>
1080-
<input class="input" id="paste-code-oauth" placeholder="${type === "claude" ? "Paste full localhost callback URL" : PASTE_CODE_PLACEHOLDER}" autocomplete="off" /></div>
1082+
${prompt.primaryPaste ? "" : pasteField}</div>
10811083
</details>
10821084
<div class="btn-row">
10831085
<button type="button" class="btn btn-secondary btn-sm" id="btn-copy-oauth-url">Copy URL</button>
1084-
<button type="button" class="btn btn-secondary btn-sm" id="btn-reopen-oauth">Open browser</button>
1086+
<button type="button" class="btn btn-secondary btn-sm" id="btn-reopen-oauth">Restart sign-in</button>
10851087
<button type="button" class="btn btn-secondary btn-sm" id="btn-oauth-logs">Logs</button>
10861088
</div>
10871089
<div class="btn-row">
@@ -1132,9 +1134,11 @@ function startOauthFlow({ type, providerId, onDone, opener }) {
11321134
}
11331135
lastAuthUrl = r.authUrl || "";
11341136
disp.textContent = lastAuthUrl;
1135-
status.textContent = r.needsPaste
1136-
? `Redirect: ${r.redirectUri || "—"} · after Authorize paste the full localhost callback URL`
1137-
: `Redirect: ${r.redirectUri || "—"} · waiting for browser callback`;
1137+
status.textContent = prompt.primaryPaste
1138+
? prompt.status
1139+
: r.needsPaste
1140+
? `Redirect: ${r.redirectUri || "—"} · ${prompt.status}`
1141+
: `Redirect: ${r.redirectUri || "—"} · waiting for browser callback`;
11381142
}
11391143
start().catch((e) => {
11401144
if (!panelSession.closed) toast(e.message || "OAuth start failed");

src/renderer/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<script src="number-format.js"></script>
6363
<script src="lock-state.js"></script>
6464
<script src="provider-catalog.js"></script>
65+
<script src="oauth-prompt.js"></script>
6566
<script src="app.js"></script>
6667
</body>
6768
</html>

src/renderer/oauth-prompt.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use strict";
2+
3+
(function exposeOAuthPrompt(root) {
4+
function oauthPrompt(type) {
5+
if (type === "xai") {
6+
return {
7+
instruction:
8+
"After authorizing, paste the code xAI shows you. A full callback URL also works.",
9+
status: "Paste the authorization code xAI shows you below.",
10+
fieldLabel: "Authorization code or callback URL",
11+
placeholder: "Paste the code shown by xAI",
12+
primaryPaste: true,
13+
};
14+
}
15+
if (type === "claude") {
16+
return {
17+
instruction:
18+
"After authorizing, paste the full localhost callback URL if the browser cannot return automatically.",
19+
status: "After authorizing, paste the full localhost callback URL if needed.",
20+
fieldLabel: "Callback URL",
21+
placeholder: "Paste full localhost callback URL",
22+
primaryPaste: false,
23+
};
24+
}
25+
return {
26+
instruction:
27+
"Most providers return automatically. Paste the full callback URL only if automatic return fails.",
28+
status: "Waiting for the browser callback.",
29+
fieldLabel: "Callback URL",
30+
placeholder: "Paste the full callback URL if prompted",
31+
primaryPaste: false,
32+
};
33+
}
34+
35+
const api = { oauthPrompt };
36+
if (typeof module !== "undefined" && module.exports) module.exports = api;
37+
if (root) root.ReroutedOAuthPrompt = api;
38+
})(typeof window !== "undefined" ? window : null);

src/renderer/styles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,10 @@ summary:focus-visible,
10441044
color: var(--muted);
10451045
}
10461046

1047+
.oauth-paste-entry {
1048+
margin-top: 10px;
1049+
}
1050+
10471051
.panel-cancel {
10481052
width: 100%;
10491053
margin-top: 7px;

tests/gateway.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,10 @@ describe("oauth code normalize", () => {
11181118
assert.equal(local.code, "abc");
11191119
assert.equal(local.state, "xyz");
11201120
assert.equal(normalizeAuthCode(" plain ").code, "plain");
1121+
assert.deepEqual(normalizeAuthCode("https://auth.x.ai/oauth2/authorize?state=st"), {
1122+
code: "",
1123+
state: "st",
1124+
});
11211125
});
11221126
});
11231127

tests/oauth-prompt.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use strict";
2+
3+
const assert = require("node:assert/strict");
4+
const { describe, it } = require("node:test");
5+
const { oauthPrompt } = require("../src/renderer/oauth-prompt");
6+
7+
describe("OAuth renderer prompts", () => {
8+
it("treats xAI's displayed code as the primary completion input", () => {
9+
const prompt = oauthPrompt("xai");
10+
11+
assert.equal(prompt.primaryPaste, true);
12+
assert.match(prompt.instruction, /paste the code xAI shows you/i);
13+
assert.match(prompt.fieldLabel, /authorization code/i);
14+
assert.match(prompt.placeholder, /code shown by xAI/i);
15+
});
16+
17+
it("keeps callback URL guidance for providers that return through the browser", () => {
18+
assert.equal(oauthPrompt("claude").primaryPaste, false);
19+
assert.match(oauthPrompt("claude").placeholder, /callback URL/i);
20+
assert.equal(oauthPrompt("chatgpt").primaryPaste, false);
21+
});
22+
});

0 commit comments

Comments
 (0)