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.
@@ -1,7 +1,8 @@
1
- // ChatGPT subscription credentials for the Codex backend.
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
- // Two sources:
4
- // own <home>/chatgpt-auth.json written by `clauderipple login` (OAuth PKCE). We refresh it.
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
- function writeOwn(home, t) {
86
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
87
- fs.writeFileSync(ownAuthPath(home), JSON.stringify(t, null, 2), { mode: 0o600 });
88
- }
89
- export async function refreshOwn(home, t) {
90
- if (!t.refreshToken)
91
- throw new Error("no refresh token; run `clauderipple login`");
92
- const res = await fetch(OAUTH.tokenUrl, {
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
- const t = {
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
  }