clauderipple 0.3.1 → 0.4.1
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/CHANGELOG.md +41 -0
- package/README.ko.md +24 -16
- package/README.md +26 -17
- package/dist/app/package.json +4 -0
- package/dist/cli/src/codex.js +26 -2
- package/dist/cli/src/index.js +14 -7
- package/dist/router/src/admin.js +74 -4
- package/dist/router/src/index.js +9 -2
- package/dist/router/src/ingress/server.js +77 -3
- package/dist/router/src/locked-file.js +80 -0
- package/dist/router/src/providers/anthropic-accounts.js +9 -74
- package/dist/router/src/providers/chatgpt/accounts.js +438 -0
- package/dist/router/src/providers/chatgpt/auth.js +43 -87
- package/dist/router/src/providers/chatgpt/index.js +483 -84
- package/dist/router/src/proxy.js +36 -6
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +128 -5
- package/dist/ui/i18n.js +4 -4
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +54 -3
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// ChatGPT subscription
|
|
1
|
+
// ChatGPT subscription sign-in for the Codex backend: the OAuth flow, token identity, and the two
|
|
2
|
+
// places a login can come from. Which accounts exist and which one answers is accounts.ts.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
4
|
+
// own `clauderipple login` (OAuth PKCE), stored with the other accounts. We refresh these.
|
|
5
|
+
// <home>/chatgpt-auth.json is the single-login file from before there were several.
|
|
5
6
|
// borrow-codex ~/.codex/auth.json written by the Codex CLI. Read-only: refresh tokens rotate, and
|
|
6
7
|
// refreshing someone else's grant would break their login. If it expires, the user runs
|
|
7
8
|
// Codex once or logs in with us.
|
|
@@ -49,6 +50,34 @@ function expiryFromToken(token) {
|
|
|
49
50
|
const exp = decodeJwt(token)?.exp;
|
|
50
51
|
return typeof exp === "number" ? exp * 1000 : 0;
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Who a grant belongs to, from its JWT claims: the id token first (it carries the email), then the
|
|
55
|
+
* access token. The workspace is `https://api.openai.com/auth`.chatgpt_account_id; the email is a
|
|
56
|
+
* top-level claim of the id token or `https://api.openai.com/profile`.email of the access token.
|
|
57
|
+
*/
|
|
58
|
+
export function identityFromTokens(tokens) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const token of [tokens.idToken, tokens.accessToken]) {
|
|
61
|
+
const claims = token ? decodeJwt(token) : null;
|
|
62
|
+
if (!claims)
|
|
63
|
+
continue;
|
|
64
|
+
const auth = claims["https://api.openai.com/auth"];
|
|
65
|
+
const profile = claims["https://api.openai.com/profile"];
|
|
66
|
+
const accountId = typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : typeof claims.chatgpt_account_id === "string" ? claims.chatgpt_account_id : undefined;
|
|
67
|
+
const email = typeof claims.email === "string" ? claims.email : typeof profile?.email === "string" ? profile.email : undefined;
|
|
68
|
+
const planType = typeof auth?.chatgpt_plan_type === "string" ? auth.chatgpt_plan_type : undefined;
|
|
69
|
+
if (!out.accountId && accountId)
|
|
70
|
+
out.accountId = accountId;
|
|
71
|
+
if (!out.email && email)
|
|
72
|
+
out.email = email.toLowerCase();
|
|
73
|
+
if (!out.planType && planType)
|
|
74
|
+
out.planType = planType;
|
|
75
|
+
}
|
|
76
|
+
const exp = expiryFromToken(tokens.accessToken);
|
|
77
|
+
if (exp)
|
|
78
|
+
out.expiresAt = exp;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
52
81
|
export function readOwn(home) {
|
|
53
82
|
try {
|
|
54
83
|
const j = JSON.parse(fs.readFileSync(ownAuthPath(home), "utf8"));
|
|
@@ -82,77 +111,14 @@ export function readBorrowed() {
|
|
|
82
111
|
return null;
|
|
83
112
|
}
|
|
84
113
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
method: "POST",
|
|
94
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
95
|
-
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: t.refreshToken, client_id: OAUTH.clientId }),
|
|
96
|
-
});
|
|
97
|
-
if (!res.ok)
|
|
98
|
-
throw new Error(`token refresh failed: HTTP ${res.status}`);
|
|
99
|
-
const j = (await res.json());
|
|
100
|
-
const next = {
|
|
101
|
-
accessToken: j.access_token,
|
|
102
|
-
refreshToken: j.refresh_token ?? t.refreshToken,
|
|
103
|
-
...(j.id_token ? { idToken: j.id_token } : {}),
|
|
104
|
-
accountId: accountIdFromToken(j.access_token) ?? t.accountId,
|
|
105
|
-
expiresAt: j.expires_in ? Date.now() + j.expires_in * 1000 : expiryFromToken(j.access_token),
|
|
106
|
-
source: "own",
|
|
107
|
-
};
|
|
108
|
-
writeOwn(home, next);
|
|
109
|
-
return next;
|
|
110
|
-
}
|
|
111
|
-
export class CredentialStore {
|
|
112
|
-
cached = null;
|
|
113
|
-
home;
|
|
114
|
-
mode;
|
|
115
|
-
constructor(home, mode = "auto") {
|
|
116
|
-
this.home = home;
|
|
117
|
-
this.mode = mode;
|
|
118
|
-
}
|
|
119
|
-
/** Valid tokens or an Error describing what the user must do. Never throws. */
|
|
120
|
-
async get() {
|
|
121
|
-
const skew = 5 * 60 * 1000;
|
|
122
|
-
let t = this.cached;
|
|
123
|
-
if (!t || Date.now() > t.expiresAt - skew) {
|
|
124
|
-
t = this.mode === "borrow-codex" ? readBorrowed() : (readOwn(this.home) ?? (this.mode === "own" ? null : readBorrowed()));
|
|
125
|
-
if (!t)
|
|
126
|
-
return new Error("no ChatGPT credentials: run `clauderipple login`, or sign in to the Codex CLI once");
|
|
127
|
-
if (Date.now() > t.expiresAt - skew) {
|
|
128
|
-
if (t.source === "own") {
|
|
129
|
-
try {
|
|
130
|
-
t = await refreshOwn(this.home, t);
|
|
131
|
-
}
|
|
132
|
-
catch (e) {
|
|
133
|
-
return new Error(`ChatGPT login expired and refresh failed (${e.message}); run \`clauderipple login\``);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
return new Error("borrowed Codex CLI login has expired; run `codex` once to refresh it, or `clauderipple login` for a login of our own");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
this.cached = t;
|
|
141
|
-
}
|
|
142
|
-
return t;
|
|
143
|
-
}
|
|
144
|
-
invalidate() {
|
|
145
|
-
this.cached = null;
|
|
146
|
-
}
|
|
147
|
-
describe() {
|
|
148
|
-
const own = readOwn(this.home);
|
|
149
|
-
const bor = readBorrowed();
|
|
150
|
-
const fmt = (t) => (t ? `${t.source} (expires ${new Date(t.expiresAt).toISOString().slice(0, 16)}Z)` : "none");
|
|
151
|
-
return `own=${fmt(own)} borrow-codex=${fmt(bor)} mode=${this.mode}`;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/** Interactive OAuth PKCE login. Opens the browser; the user signs in themselves. Resolves when tokens are stored. */
|
|
155
|
-
export async function login(home, openBrowser) {
|
|
114
|
+
/**
|
|
115
|
+
* Interactive OAuth PKCE login. Opens the browser; the user signs in themselves. Resolves with the
|
|
116
|
+
* grant; storing it is the caller's (accounts.ts `saveChatGptAccount`).
|
|
117
|
+
*
|
|
118
|
+
* `prompt=login` makes OpenAI ask who is signing in instead of silently reusing the browser's
|
|
119
|
+
* ChatGPT session — without it, "add another account" hands back the account already added.
|
|
120
|
+
*/
|
|
121
|
+
export async function login(openBrowser) {
|
|
156
122
|
const verifier = b64url(crypto.randomBytes(32));
|
|
157
123
|
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
158
124
|
const state = crypto.randomBytes(16).toString("hex");
|
|
@@ -165,8 +131,10 @@ export async function login(home, openBrowser) {
|
|
|
165
131
|
code_challenge: challenge,
|
|
166
132
|
code_challenge_method: "S256",
|
|
167
133
|
state,
|
|
134
|
+
id_token_add_organizations: "true",
|
|
168
135
|
codex_cli_simplified_flow: "true",
|
|
169
136
|
originator: "codex_cli_rs",
|
|
137
|
+
prompt: "login",
|
|
170
138
|
}).toString();
|
|
171
139
|
const code = await new Promise((resolve, reject) => {
|
|
172
140
|
const server = http.createServer((req, res) => {
|
|
@@ -204,23 +172,11 @@ export async function login(home, openBrowser) {
|
|
|
204
172
|
const accountId = accountIdFromToken(j.access_token) ?? (j.id_token ? accountIdFromToken(j.id_token) : null);
|
|
205
173
|
if (!accountId)
|
|
206
174
|
throw new Error("token has no chatgpt_account_id claim");
|
|
207
|
-
|
|
175
|
+
return {
|
|
208
176
|
accessToken: j.access_token,
|
|
209
177
|
...(j.refresh_token ? { refreshToken: j.refresh_token } : {}),
|
|
210
178
|
...(j.id_token ? { idToken: j.id_token } : {}),
|
|
211
179
|
accountId,
|
|
212
180
|
expiresAt: j.expires_in ? Date.now() + j.expires_in * 1000 : expiryFromToken(j.access_token),
|
|
213
|
-
source: "own",
|
|
214
181
|
};
|
|
215
|
-
writeOwn(home, t);
|
|
216
|
-
return t;
|
|
217
|
-
}
|
|
218
|
-
export function logout(home) {
|
|
219
|
-
try {
|
|
220
|
-
fs.unlinkSync(ownAuthPath(home));
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
182
|
}
|