@giovannijecha/jecode 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -15
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +1 -1
- package/dist/controller.js +3 -0
- package/dist/credential-commands.js +32 -4
- package/dist/credential-safety.js +2 -1
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +91 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/provider-commands.js +18 -30
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +114 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -4
- package/dist/providers/openai.js +2 -2
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +11 -3
- package/dist/tui/app-workflows.js +5 -0
- package/dist/tui/feedback.js +10 -6
- package/dist/tui/session-view.js +10 -4
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Interactive ChatGPT account management for the OpenAI Codex provider.
|
|
2
|
+
import { openAICodexAccount } from "./accounts.js";
|
|
3
|
+
import { headlessEnvironment, openExternal } from "./external-browser.js";
|
|
4
|
+
import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
|
|
5
|
+
import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
|
|
6
|
+
import { heading } from "./tui/picker.js";
|
|
7
|
+
const DEFAULT_DEPENDENCIES = {
|
|
8
|
+
beginBrowser: beginBrowserLogin,
|
|
9
|
+
beginDevice: beginDeviceLogin,
|
|
10
|
+
openUrl: openExternal,
|
|
11
|
+
headless: headlessEnvironment,
|
|
12
|
+
};
|
|
13
|
+
export async function openAIAccountCommand(session, host, dependencies = DEFAULT_DEPENDENCIES) {
|
|
14
|
+
if (host.choose === undefined)
|
|
15
|
+
return false;
|
|
16
|
+
const connected = openAICodexAccount() !== undefined;
|
|
17
|
+
const actions = connected
|
|
18
|
+
? [
|
|
19
|
+
{ label: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
|
|
20
|
+
{ label: "sign out", hint: "remove the saved account", key: "s" },
|
|
21
|
+
]
|
|
22
|
+
: [{ label: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
|
|
23
|
+
const action = await host.choose({
|
|
24
|
+
title: heading("ChatGPT", openAIAccountHint(), session.palette),
|
|
25
|
+
options: actions,
|
|
26
|
+
index: 0,
|
|
27
|
+
});
|
|
28
|
+
if (action === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
if (connected && actions[action]?.key === "s") {
|
|
31
|
+
const result = await removeOpenAIAccount(host.signal);
|
|
32
|
+
host.emit({
|
|
33
|
+
kind: "notice",
|
|
34
|
+
text: result.revokeFailed ? "ChatGPT disconnected · remote sign-out could not be confirmed" : "ChatGPT disconnected",
|
|
35
|
+
tone: result.revokeFailed ? "warn" : "info",
|
|
36
|
+
});
|
|
37
|
+
return result.removed;
|
|
38
|
+
}
|
|
39
|
+
return connectOpenAIAccount(session, host, dependencies);
|
|
40
|
+
}
|
|
41
|
+
export async function ensureOpenAIAccount(session, host, dependencies = DEFAULT_DEPENDENCIES) {
|
|
42
|
+
if (openAICodexAccount() !== undefined)
|
|
43
|
+
return true;
|
|
44
|
+
return connectOpenAIAccount(session, host, dependencies);
|
|
45
|
+
}
|
|
46
|
+
async function connectOpenAIAccount(session, host, dependencies) {
|
|
47
|
+
if (host.choose === undefined)
|
|
48
|
+
return false;
|
|
49
|
+
const methods = [
|
|
50
|
+
{ label: "sign in with browser", hint: "desktop terminal", key: "b" },
|
|
51
|
+
{ label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
|
|
52
|
+
];
|
|
53
|
+
const choice = await host.choose({
|
|
54
|
+
title: heading("connect ChatGPT", "OpenAI OAuth", session.palette),
|
|
55
|
+
description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
|
|
56
|
+
options: methods,
|
|
57
|
+
index: dependencies.headless() ? 1 : 0,
|
|
58
|
+
});
|
|
59
|
+
if (choice === undefined)
|
|
60
|
+
return false;
|
|
61
|
+
host.status?.("Starting ChatGPT sign-in");
|
|
62
|
+
let login;
|
|
63
|
+
try {
|
|
64
|
+
login = choice === 1
|
|
65
|
+
? await dependencies.beginDevice(host.signal)
|
|
66
|
+
: await dependencies.beginBrowser();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
host.status?.(undefined);
|
|
70
|
+
}
|
|
71
|
+
return waitForLogin(login, session, host, dependencies.openUrl);
|
|
72
|
+
}
|
|
73
|
+
async function waitForLogin(login, session, host, openUrl) {
|
|
74
|
+
if (host.choose === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
const local = new AbortController();
|
|
77
|
+
const signal = host.signal === undefined
|
|
78
|
+
? local.signal
|
|
79
|
+
: AbortSignal.any([host.signal, local.signal]);
|
|
80
|
+
const completion = login.complete(signal).then((account) => ({ kind: "account", account }), (error) => ({ kind: "error", error: error }));
|
|
81
|
+
await openUrl(login.url);
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
const selection = host.choose(waitingPicker(login, session));
|
|
85
|
+
const outcome = await Promise.race([
|
|
86
|
+
completion,
|
|
87
|
+
selection.then((index) => ({ kind: "selection", index })),
|
|
88
|
+
]);
|
|
89
|
+
if (outcome.kind === "account") {
|
|
90
|
+
host.dismiss?.();
|
|
91
|
+
await saveOpenAIAccount(outcome.account, host.signal);
|
|
92
|
+
host.emit({ kind: "notice", text: "ChatGPT connected", tone: "info" });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (outcome.kind === "error") {
|
|
96
|
+
host.dismiss?.();
|
|
97
|
+
throw outcome.error;
|
|
98
|
+
}
|
|
99
|
+
if (outcome.index === 0) {
|
|
100
|
+
await openUrl(login.url);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
local.abort(new Error("ChatGPT sign-in cancelled"));
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await login.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function waitingPicker(login, session) {
|
|
112
|
+
const detail = login.code === undefined
|
|
113
|
+
? "Finish sign-in in your browser. Jecode will continue automatically."
|
|
114
|
+
: `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
|
|
115
|
+
return {
|
|
116
|
+
title: heading("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
|
|
117
|
+
description: detail,
|
|
118
|
+
options: [
|
|
119
|
+
{ label: "open browser again", key: "o" },
|
|
120
|
+
{ label: "cancel sign-in", key: "c" },
|
|
121
|
+
],
|
|
122
|
+
index: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The live ChatGPT account: persistence, proactive refresh, and logout.
|
|
2
|
+
import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
|
|
3
|
+
import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
|
|
4
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
5
|
+
let refreshing;
|
|
6
|
+
export function openAIAccountHint() {
|
|
7
|
+
const account = openAICodexAccount();
|
|
8
|
+
if (account === undefined)
|
|
9
|
+
return "not connected";
|
|
10
|
+
const identity = account.email ?? "connected";
|
|
11
|
+
return account.plan === undefined ? identity : `${identity} · ${account.plan}`;
|
|
12
|
+
}
|
|
13
|
+
export async function saveOpenAIAccount(account, signal) {
|
|
14
|
+
await updateOpenAICodexAccount(async () => account, signal);
|
|
15
|
+
}
|
|
16
|
+
export async function removeOpenAIAccount(signal) {
|
|
17
|
+
let account;
|
|
18
|
+
await updateOpenAICodexAccount(async (current) => {
|
|
19
|
+
account = current;
|
|
20
|
+
return undefined;
|
|
21
|
+
}, signal);
|
|
22
|
+
if (account === undefined)
|
|
23
|
+
return { removed: false, revokeFailed: false };
|
|
24
|
+
let revokeFailed = false;
|
|
25
|
+
try {
|
|
26
|
+
await revokeOpenAITokens(account, signal);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
revokeFailed = true;
|
|
30
|
+
}
|
|
31
|
+
return { removed: true, revokeFailed };
|
|
32
|
+
}
|
|
33
|
+
export async function openAIAuthorization(forceToken, signal, onStatus) {
|
|
34
|
+
const account = openAICodexAccount();
|
|
35
|
+
if (account === undefined)
|
|
36
|
+
throw new Error("ChatGPT account is not connected");
|
|
37
|
+
const mustRefresh = forceToken !== undefined || expiresSoon(account);
|
|
38
|
+
const ready = mustRefresh
|
|
39
|
+
? await refreshAccount(forceToken, signal, onStatus)
|
|
40
|
+
: account;
|
|
41
|
+
return { accessToken: ready.accessToken, accountId: ready.accountId };
|
|
42
|
+
}
|
|
43
|
+
async function refreshAccount(forceToken, signal, onStatus) {
|
|
44
|
+
if (refreshing !== undefined)
|
|
45
|
+
return abortable(refreshing, signal);
|
|
46
|
+
onStatus?.("Refreshing ChatGPT sign-in");
|
|
47
|
+
const task = updateOpenAICodexAccount(async (current) => {
|
|
48
|
+
if (current === undefined)
|
|
49
|
+
throw new Error("ChatGPT account is not connected");
|
|
50
|
+
if (forceToken !== undefined && current.accessToken !== forceToken)
|
|
51
|
+
return current;
|
|
52
|
+
if (forceToken === undefined && !expiresSoon(current))
|
|
53
|
+
return current;
|
|
54
|
+
return refreshOpenAITokens(current, signal);
|
|
55
|
+
}, signal).then((account) => {
|
|
56
|
+
if (account === undefined)
|
|
57
|
+
throw new Error("ChatGPT account is not connected");
|
|
58
|
+
return account;
|
|
59
|
+
});
|
|
60
|
+
refreshing = task;
|
|
61
|
+
try {
|
|
62
|
+
return await abortable(task, signal);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (refreshing === task)
|
|
66
|
+
refreshing = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function expiresSoon(account) {
|
|
70
|
+
return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
|
|
71
|
+
}
|
|
72
|
+
function abortable(promise, signal) {
|
|
73
|
+
if (signal === undefined)
|
|
74
|
+
return promise;
|
|
75
|
+
if (signal.aborted)
|
|
76
|
+
return Promise.reject(abortReason(signal));
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const onAbort = () => reject(abortReason(signal));
|
|
79
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
80
|
+
promise.then((value) => {
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
resolve(value);
|
|
83
|
+
}, (error) => {
|
|
84
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
|
+
reject(error);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function abortReason(signal) {
|
|
90
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
91
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Loopback callback used by the browser OAuth flow.
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
const CALLBACK_PORTS = [1455, 1457];
|
|
6
|
+
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
7
|
+
export async function openAICallback(state) {
|
|
8
|
+
let resolveCode = () => { };
|
|
9
|
+
let rejectCode = () => { };
|
|
10
|
+
const code = new Promise((resolve, reject) => {
|
|
11
|
+
resolveCode = resolve;
|
|
12
|
+
rejectCode = reject;
|
|
13
|
+
});
|
|
14
|
+
// A bad callback can arrive before the TUI starts awaiting `complete()`.
|
|
15
|
+
// Mark the rejection handled here while preserving it for the real waiter.
|
|
16
|
+
void code.catch(() => undefined);
|
|
17
|
+
let response;
|
|
18
|
+
let finished = false;
|
|
19
|
+
const handler = (request, outgoing) => {
|
|
20
|
+
if (finished) {
|
|
21
|
+
outgoing.writeHead(409).end("Sign-in already completed.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (request.method !== "GET" || request.url === undefined || request.url.length > 4_096) {
|
|
25
|
+
outgoing.writeHead(400).end("Invalid sign-in callback.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const incoming = new URL(request.url, "http://localhost");
|
|
29
|
+
if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
|
|
30
|
+
outgoing.writeHead(404).end("Not found.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const receivedState = incoming.searchParams.get("state");
|
|
34
|
+
// A stale browser tab or another local client can reach the fixed callback
|
|
35
|
+
// port. Reject that one request without letting it terminate this login.
|
|
36
|
+
if (!sameState(state, receivedState)) {
|
|
37
|
+
outgoing.writeHead(400, { "cache-control": "no-store" }).end("Invalid sign-in state.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
finished = true;
|
|
41
|
+
response = outgoing;
|
|
42
|
+
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
43
|
+
const authorizationCode = incoming.searchParams.get("code");
|
|
44
|
+
if (authError !== null) {
|
|
45
|
+
rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
|
|
46
|
+
}
|
|
47
|
+
else if (authorizationCode === null || authorizationCode === "") {
|
|
48
|
+
rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
resolveCode(authorizationCode);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const listening = await firstAvailableServer(handler);
|
|
55
|
+
return {
|
|
56
|
+
port: listening.port,
|
|
57
|
+
code,
|
|
58
|
+
async respond(success) {
|
|
59
|
+
if (response === undefined || response.writableEnded)
|
|
60
|
+
return;
|
|
61
|
+
const flushed = new Promise((resolve) => {
|
|
62
|
+
response?.once("finish", resolve);
|
|
63
|
+
response?.once("close", resolve);
|
|
64
|
+
});
|
|
65
|
+
response.writeHead(success ? 200 : 400, {
|
|
66
|
+
"cache-control": "no-store",
|
|
67
|
+
connection: "close",
|
|
68
|
+
"content-security-policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
69
|
+
"content-type": "text/html; charset=utf-8",
|
|
70
|
+
"referrer-policy": "no-referrer",
|
|
71
|
+
"x-content-type-options": "nosniff",
|
|
72
|
+
});
|
|
73
|
+
response.end(resultPage(success));
|
|
74
|
+
await flushed;
|
|
75
|
+
},
|
|
76
|
+
close: () => closeServer(listening.server),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function firstAvailableServer(handler) {
|
|
80
|
+
for (const port of CALLBACK_PORTS) {
|
|
81
|
+
const server = createServer(handler);
|
|
82
|
+
server.maxHeadersCount = 40;
|
|
83
|
+
server.headersTimeout = 5_000;
|
|
84
|
+
server.keepAliveTimeout = 500;
|
|
85
|
+
server.requestTimeout = 5_000;
|
|
86
|
+
try {
|
|
87
|
+
await listen(server, port);
|
|
88
|
+
return { server, port };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await closeServer(server);
|
|
92
|
+
if (error.code !== "EADDRINUSE")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new Error("ChatGPT sign-in could not open callback ports 1455 or 1457");
|
|
97
|
+
}
|
|
98
|
+
function listen(server, port) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const onError = (error) => reject(error);
|
|
101
|
+
server.once("error", onError);
|
|
102
|
+
server.listen(port, "127.0.0.1", () => {
|
|
103
|
+
server.off("error", onError);
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function closeServer(server) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
if (!server.listening) {
|
|
111
|
+
resolve();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let settled = false;
|
|
115
|
+
let fallback;
|
|
116
|
+
const finish = () => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
if (fallback !== undefined)
|
|
121
|
+
clearTimeout(fallback);
|
|
122
|
+
resolve();
|
|
123
|
+
};
|
|
124
|
+
fallback = setTimeout(() => {
|
|
125
|
+
server.closeAllConnections();
|
|
126
|
+
finish();
|
|
127
|
+
}, 500);
|
|
128
|
+
server.close(finish);
|
|
129
|
+
server.closeIdleConnections();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function resultPage(success) {
|
|
133
|
+
const title = success ? "Signed in to Jecode" : "Jecode sign-in failed";
|
|
134
|
+
const status = success ? "Authentication complete" : "Authentication stopped";
|
|
135
|
+
const detail = success
|
|
136
|
+
? "Return to your terminal. Jecode will continue automatically."
|
|
137
|
+
: "Return to your terminal to see what stopped the connection.";
|
|
138
|
+
const state = success ? "success" : "failure";
|
|
139
|
+
return `<!doctype html>
|
|
140
|
+
<html lang="en">
|
|
141
|
+
<head>
|
|
142
|
+
<meta charset="utf-8">
|
|
143
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
144
|
+
<meta name="color-scheme" content="dark">
|
|
145
|
+
<title>${title}</title>
|
|
146
|
+
<style>
|
|
147
|
+
:root{--night:#000;--steel:#669bd2;--steel-soft:#8db4dd;--bright:#ebeff4;--danger:#e87070}
|
|
148
|
+
*{box-sizing:border-box}
|
|
149
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--night);color:var(--steel-soft);font-family:"Segoe UI",system-ui,sans-serif}
|
|
150
|
+
main{width:min(34rem,calc(100vw - 3rem));padding:3rem 1.5rem;text-align:center}
|
|
151
|
+
img{display:block;width:clamp(7.5rem,20vw,10rem);height:auto;margin:0 auto 1.75rem;filter:drop-shadow(0 1.25rem 2rem rgba(102,155,210,.16))}
|
|
152
|
+
.rail{width:min(18rem,70vw);height:1px;margin:0 auto 1.5rem;background:linear-gradient(90deg,transparent,var(--steel),transparent)}
|
|
153
|
+
.status{margin:0 0 .75rem;color:var(--steel);font:600 .72rem/1.2 ui-monospace,"Cascadia Mono",monospace;letter-spacing:.14em;text-transform:uppercase}
|
|
154
|
+
h1{margin:0;color:var(--steel);font-size:clamp(2rem,6vw,3.25rem);font-weight:720;letter-spacing:-.04em;line-height:1.05}
|
|
155
|
+
p:last-of-type{max-width:30rem;margin:1.25rem auto 0;color:var(--steel-soft);font-size:1.05rem;line-height:1.6}
|
|
156
|
+
.failure h1,.failure .status{color:var(--danger)}
|
|
157
|
+
@media (prefers-reduced-motion:no-preference){main{animation:arrive .45s ease-out both}@keyframes arrive{from{opacity:0;transform:translateY(.6rem)}to{opacity:1;transform:none}}}
|
|
158
|
+
</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<main class="${state}">
|
|
162
|
+
<img src="${mascotDataUri()}" alt="Jeco, the Jecode gecko">
|
|
163
|
+
<div class="rail" aria-hidden="true"></div>
|
|
164
|
+
<p class="status">${status}</p>
|
|
165
|
+
<h1>${title}</h1>
|
|
166
|
+
<p>${detail}</p>
|
|
167
|
+
</main>
|
|
168
|
+
<script>history.replaceState(null,"","/auth/complete")</script>
|
|
169
|
+
</body>
|
|
170
|
+
</html>`;
|
|
171
|
+
}
|
|
172
|
+
let mascot;
|
|
173
|
+
function mascotDataUri() {
|
|
174
|
+
if (mascot === undefined) {
|
|
175
|
+
const file = new URL("../docs/assets/brand/jeco-256.png", import.meta.url);
|
|
176
|
+
mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
|
|
177
|
+
}
|
|
178
|
+
return mascot;
|
|
179
|
+
}
|
|
180
|
+
function sameState(expected, received) {
|
|
181
|
+
if (received === null)
|
|
182
|
+
return false;
|
|
183
|
+
const left = Buffer.from(expected);
|
|
184
|
+
const right = Buffer.from(received);
|
|
185
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
186
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Validate OAuth token responses and extract the ChatGPT account claims.
|
|
2
|
+
const CLAIMS = "https://api.openai.com/auth";
|
|
3
|
+
export function openAITokenReply(value, previousRefresh) {
|
|
4
|
+
if (!record(value))
|
|
5
|
+
throw new Error("OpenAI sign-in returned an invalid token response");
|
|
6
|
+
const accessToken = required(value["access_token"], "access token");
|
|
7
|
+
const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
|
|
8
|
+
const expiresIn = value["expires_in"];
|
|
9
|
+
if (refreshToken === undefined)
|
|
10
|
+
throw new Error("OpenAI sign-in did not return a refresh token");
|
|
11
|
+
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
12
|
+
throw new Error("OpenAI sign-in did not return a valid token lifetime");
|
|
13
|
+
}
|
|
14
|
+
const idToken = optional(value["id_token"]);
|
|
15
|
+
return {
|
|
16
|
+
accessToken,
|
|
17
|
+
refreshToken,
|
|
18
|
+
expiresAt: Date.now() + Math.floor(expiresIn * 1_000),
|
|
19
|
+
...(idToken === undefined ? {} : { idToken }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function openAIAccountFromTokens(token) {
|
|
23
|
+
const access = jwt(token.accessToken);
|
|
24
|
+
const identity = token.idToken === undefined ? undefined : jwt(token.idToken);
|
|
25
|
+
const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
|
|
26
|
+
const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
|
|
27
|
+
if (accountId === undefined)
|
|
28
|
+
throw new Error("OpenAI sign-in did not identify a ChatGPT account");
|
|
29
|
+
const email = optional(identity?.["email"]);
|
|
30
|
+
const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
|
|
31
|
+
return {
|
|
32
|
+
accessToken: token.accessToken,
|
|
33
|
+
refreshToken: token.refreshToken,
|
|
34
|
+
expiresAt: token.expiresAt,
|
|
35
|
+
accountId,
|
|
36
|
+
...(email === undefined ? {} : { email }),
|
|
37
|
+
...(plan === undefined ? {} : { plan }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function jwt(token) {
|
|
41
|
+
const part = token.split(".")[1];
|
|
42
|
+
if (part === undefined)
|
|
43
|
+
throw new Error("OpenAI sign-in returned an unreadable token");
|
|
44
|
+
try {
|
|
45
|
+
const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
46
|
+
if (!record(value))
|
|
47
|
+
throw new Error("invalid payload");
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error("OpenAI sign-in returned an unreadable token");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function required(value, label) {
|
|
55
|
+
const found = optional(value);
|
|
56
|
+
if (found === undefined)
|
|
57
|
+
throw new Error(`OpenAI sign-in returned no ${label}`);
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
function optional(value) {
|
|
61
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
function record(value) {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// OpenAI's ChatGPT OAuth protocol, implemented with Node primitives only.
|
|
2
|
+
//
|
|
3
|
+
// This module owns the authority handshake. It does not know about the TUI or
|
|
4
|
+
// persist anything; callers decide when an explicitly completed login becomes
|
|
5
|
+
// an account on disk.
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { oauthRequest } from "./oauth-http.js";
|
|
8
|
+
import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
|
|
9
|
+
import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
|
|
10
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
|
+
const AUTHORITY = "https://auth.openai.com";
|
|
12
|
+
const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
|
|
13
|
+
const TOKEN = `${AUTHORITY}/oauth/token`;
|
|
14
|
+
const REVOKE = `${AUTHORITY}/oauth/revoke`;
|
|
15
|
+
const DEVICE_CODE = `${AUTHORITY}/api/accounts/deviceauth/usercode`;
|
|
16
|
+
const DEVICE_POLL = `${AUTHORITY}/api/accounts/deviceauth/token`;
|
|
17
|
+
const DEVICE_VERIFY = `${AUTHORITY}/codex/device`;
|
|
18
|
+
const DEVICE_REDIRECT = `${AUTHORITY}/deviceauth/callback`;
|
|
19
|
+
const LOGIN_LIMIT_MS = 15 * 60_000;
|
|
20
|
+
export async function beginBrowserLogin() {
|
|
21
|
+
const verifier = randomBytes(64).toString("base64url");
|
|
22
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
23
|
+
const state = randomBytes(32).toString("base64url");
|
|
24
|
+
const callback = await openAICallback(state);
|
|
25
|
+
const redirectUri = `http://localhost:${callback.port}${OPENAI_CALLBACK_PATH}`;
|
|
26
|
+
const authorize = new URL(AUTHORIZE);
|
|
27
|
+
authorize.search = new URLSearchParams({
|
|
28
|
+
response_type: "code",
|
|
29
|
+
client_id: CLIENT_ID,
|
|
30
|
+
redirect_uri: redirectUri,
|
|
31
|
+
scope: "openid profile email offline_access",
|
|
32
|
+
code_challenge: challenge,
|
|
33
|
+
code_challenge_method: "S256",
|
|
34
|
+
state,
|
|
35
|
+
id_token_add_organizations: "true",
|
|
36
|
+
codex_cli_simplified_flow: "true",
|
|
37
|
+
originator: "jecode",
|
|
38
|
+
}).toString();
|
|
39
|
+
return {
|
|
40
|
+
url: authorize.href,
|
|
41
|
+
async complete(signal) {
|
|
42
|
+
try {
|
|
43
|
+
const code = await abortable(callback.code, signal);
|
|
44
|
+
const account = openAIAccountFromTokens(await exchange({
|
|
45
|
+
authorizationCode: code,
|
|
46
|
+
verifier,
|
|
47
|
+
redirectUri,
|
|
48
|
+
}, signal));
|
|
49
|
+
await callback.respond(true);
|
|
50
|
+
return account;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await callback.respond(false);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await callback.close();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
close: callback.close,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function beginDeviceLogin(signal) {
|
|
64
|
+
const start = await oauthRequest(DEVICE_CODE, { contentType: "application/json", value: { client_id: CLIENT_ID } }, signal);
|
|
65
|
+
const value = record(start.value) ? start.value : {};
|
|
66
|
+
const deviceAuthId = required(value["device_auth_id"], "device authorization id");
|
|
67
|
+
const code = required(value["user_code"] ?? value["usercode"], "device code");
|
|
68
|
+
const interval = intervalSeconds(value["interval"]);
|
|
69
|
+
return {
|
|
70
|
+
url: DEVICE_VERIFY,
|
|
71
|
+
code,
|
|
72
|
+
async complete(waitSignal) {
|
|
73
|
+
const combined = combine(signal, waitSignal);
|
|
74
|
+
const pending = await pollDevice(deviceAuthId, code, interval, combined);
|
|
75
|
+
return openAIAccountFromTokens(await exchange(pending, combined));
|
|
76
|
+
},
|
|
77
|
+
close: async () => { },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function refreshOpenAITokens(account, signal) {
|
|
81
|
+
const response = await oauthRequest(TOKEN, {
|
|
82
|
+
contentType: "application/json",
|
|
83
|
+
value: {
|
|
84
|
+
client_id: CLIENT_ID,
|
|
85
|
+
grant_type: "refresh_token",
|
|
86
|
+
refresh_token: account.refreshToken,
|
|
87
|
+
},
|
|
88
|
+
}, signal);
|
|
89
|
+
const token = openAITokenReply(response.value, account.refreshToken);
|
|
90
|
+
const refreshed = openAIAccountFromTokens(token);
|
|
91
|
+
if (refreshed.accountId !== account.accountId) {
|
|
92
|
+
throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...refreshed,
|
|
96
|
+
...(refreshed.email === undefined && account.email !== undefined ? { email: account.email } : {}),
|
|
97
|
+
...(refreshed.plan === undefined && account.plan !== undefined ? { plan: account.plan } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function revokeOpenAITokens(account, signal) {
|
|
101
|
+
await oauthRequest(REVOKE, {
|
|
102
|
+
contentType: "application/json",
|
|
103
|
+
value: {
|
|
104
|
+
token: account.refreshToken,
|
|
105
|
+
token_type_hint: "refresh_token",
|
|
106
|
+
client_id: CLIENT_ID,
|
|
107
|
+
},
|
|
108
|
+
}, signal);
|
|
109
|
+
}
|
|
110
|
+
async function exchange(code, signal) {
|
|
111
|
+
const response = await oauthRequest(TOKEN, {
|
|
112
|
+
contentType: "application/x-www-form-urlencoded",
|
|
113
|
+
value: new URLSearchParams({
|
|
114
|
+
grant_type: "authorization_code",
|
|
115
|
+
client_id: CLIENT_ID,
|
|
116
|
+
code: code.authorizationCode,
|
|
117
|
+
code_verifier: code.verifier,
|
|
118
|
+
redirect_uri: code.redirectUri,
|
|
119
|
+
}),
|
|
120
|
+
}, signal);
|
|
121
|
+
return openAITokenReply(response.value);
|
|
122
|
+
}
|
|
123
|
+
async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
124
|
+
const started = Date.now();
|
|
125
|
+
while (Date.now() - started < LOGIN_LIMIT_MS) {
|
|
126
|
+
const response = await oauthRequest(DEVICE_POLL, {
|
|
127
|
+
contentType: "application/json",
|
|
128
|
+
value: { device_auth_id: deviceAuthId, user_code: userCode },
|
|
129
|
+
}, signal, [200, 403, 404]);
|
|
130
|
+
if (response.status === 200) {
|
|
131
|
+
const value = record(response.value) ? response.value : {};
|
|
132
|
+
return {
|
|
133
|
+
authorizationCode: required(value["authorization_code"], "authorization code"),
|
|
134
|
+
verifier: required(value["code_verifier"], "code verifier"),
|
|
135
|
+
redirectUri: DEVICE_REDIRECT,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
await sleep(interval * 1_000, signal);
|
|
139
|
+
}
|
|
140
|
+
throw new Error("ChatGPT device sign-in timed out after 15 minutes");
|
|
141
|
+
}
|
|
142
|
+
function intervalSeconds(value) {
|
|
143
|
+
const parsed = typeof value === "string" ? Number(value.trim()) : value;
|
|
144
|
+
return typeof parsed === "number" && Number.isFinite(parsed)
|
|
145
|
+
? Math.max(1, Math.min(30, Math.floor(parsed)))
|
|
146
|
+
: 5;
|
|
147
|
+
}
|
|
148
|
+
function required(value, label) {
|
|
149
|
+
const found = optional(value);
|
|
150
|
+
if (found === undefined)
|
|
151
|
+
throw new Error(`OpenAI sign-in returned no ${label}`);
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
function optional(value) {
|
|
155
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
156
|
+
}
|
|
157
|
+
function record(value) {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
function combine(left, right) {
|
|
161
|
+
if (left === undefined)
|
|
162
|
+
return right;
|
|
163
|
+
if (right === undefined)
|
|
164
|
+
return left;
|
|
165
|
+
return AbortSignal.any([left, right]);
|
|
166
|
+
}
|
|
167
|
+
function abortable(promise, signal) {
|
|
168
|
+
if (signal === undefined)
|
|
169
|
+
return promise;
|
|
170
|
+
if (signal.aborted)
|
|
171
|
+
return Promise.reject(abortReason(signal));
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const onAbort = () => reject(abortReason(signal));
|
|
174
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
promise.then((value) => {
|
|
176
|
+
signal.removeEventListener("abort", onAbort);
|
|
177
|
+
resolve(value);
|
|
178
|
+
}, (error) => {
|
|
179
|
+
signal.removeEventListener("abort", onAbort);
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function sleep(ms, signal) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
if (signal?.aborted === true) {
|
|
187
|
+
reject(abortReason(signal));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
resolve();
|
|
193
|
+
}, ms);
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
|
|
197
|
+
};
|
|
198
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function abortReason(signal) {
|
|
202
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
203
|
+
}
|