codex-auth-automation 0.1.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.
@@ -0,0 +1,253 @@
1
+ import { chromium, Browser, BrowserContext, Page } from 'playwright';
2
+ import http from 'http';
3
+ import crypto from 'crypto';
4
+ import url from 'url';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+
8
+ export class OAuthFlowError extends Error {
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = 'OAuthFlowError';
12
+ }
13
+ }
14
+
15
+ const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
16
+ const TOKEN_URL = 'https://auth.openai.com/oauth/token';
17
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
18
+ const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'api.connectors.read', 'api.connectors.invoke'];
19
+ const REDIRECT_PORT = 1455;
20
+ const DEBUG_DIR = path.join(process.cwd(), 'debug');
21
+
22
+ function generatePkce() {
23
+ const raw = crypto.randomBytes(32);
24
+ const codeVerifier = raw.toString('base64url');
25
+ const digest = crypto.createHash('sha256').update(codeVerifier).digest();
26
+ const codeChallenge = digest.toString('base64url');
27
+ return { codeVerifier, codeChallenge };
28
+ }
29
+
30
+ function generateState() {
31
+ return crypto.randomBytes(32).toString('base64url');
32
+ }
33
+
34
+ function buildAuthUrl(codeChallenge: string, state: string): string {
35
+ const params = new URLSearchParams({
36
+ client_id: CLIENT_ID,
37
+ redirect_uri: `http://localhost:${REDIRECT_PORT}/auth/callback`,
38
+ response_type: 'code',
39
+ scope: SCOPES.join(' '),
40
+ code_challenge: codeChallenge,
41
+ code_challenge_method: 'S256',
42
+ id_token_add_organizations: 'true',
43
+ codex_cli_simplified_flow: 'true',
44
+ state,
45
+ originator: 'opencode',
46
+ });
47
+ return `${AUTHORIZE_URL}?${params.toString()}`;
48
+ }
49
+
50
+ function startCallbackServer(): Promise<{ code: string; server: http.Server }> {
51
+ return new Promise((resolve, reject) => {
52
+ const server = http.createServer((req, res) => {
53
+ const parsed = url.parse(req.url || '', true);
54
+ const code = parsed.query.code as string | undefined;
55
+
56
+ if (code) {
57
+ res.writeHead(200, { 'Content-Type': 'text/html' });
58
+ res.end('<h1>Login successful!</h1><p>Close this window.</p>');
59
+ server.close(() => resolve({ code, server }));
60
+ } else {
61
+ res.writeHead(400);
62
+ res.end('No code found.');
63
+ }
64
+ });
65
+
66
+ server.listen(REDIRECT_PORT);
67
+ server.on('error', reject);
68
+ });
69
+ }
70
+
71
+ async function exchangeCode(code: string, codeVerifier: string): Promise<Record<string, any>> {
72
+ const data = new URLSearchParams({
73
+ grant_type: 'authorization_code',
74
+ code,
75
+ redirect_uri: `http://localhost:${REDIRECT_PORT}/auth/callback`,
76
+ client_id: CLIENT_ID,
77
+ code_verifier: codeVerifier,
78
+ });
79
+
80
+ const resp = await fetch(TOKEN_URL, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
83
+ body: data.toString(),
84
+ });
85
+
86
+ if (!resp.ok) {
87
+ const text = await resp.text();
88
+ throw new OAuthFlowError(`Token exchange failed (HTTP ${resp.status}): ${text.slice(0, 300)}`);
89
+ }
90
+
91
+ return resp.json() as Promise<Record<string, any>>;
92
+ }
93
+
94
+ async function findElement(page: Page, selectors: string[], timeout = 10000) {
95
+ for (const sel of selectors) {
96
+ try {
97
+ const el = await page.waitForSelector(sel, { timeout });
98
+ if (el) return el;
99
+ } catch { /* empty */ }
100
+ }
101
+ throw new OAuthFlowError(`Element not found. Tried: ${selectors.join(', ')}. URL: ${page.url()}`);
102
+ }
103
+
104
+ async function handleConsent(page: Page) {
105
+ try {
106
+ const btn = await page.$('button:has-text("Continue"), button:has-text("Allow"), button:has-text("Authorize"), button:has-text("Accept")');
107
+ if (btn) {
108
+ const text = await btn.innerText();
109
+ console.log(` Clicking: '${text.trim()}'`);
110
+ await btn.click();
111
+ await page.waitForTimeout(2000);
112
+ }
113
+ } catch {
114
+ // No consent page
115
+ }
116
+ }
117
+
118
+ async function saveScreenshot(page: Page, name: string) {
119
+ try {
120
+ fs.mkdirSync(DEBUG_DIR, { recursive: true });
121
+ const filePath = path.join(DEBUG_DIR, `${name}_${Date.now()}.png`);
122
+ await page.screenshot({ path: filePath });
123
+ console.log(` Screenshot: ${filePath}`);
124
+ } catch {
125
+ // Screenshot failed, non-critical
126
+ }
127
+ }
128
+
129
+ export interface BrowserSignupResult {
130
+ tokens: Record<string, any>;
131
+ email: string;
132
+ }
133
+
134
+ export async function browserSignup(
135
+ email: string,
136
+ otpCode: string,
137
+ headless = true,
138
+ ): Promise<Record<string, any>> {
139
+ const { codeVerifier, codeChallenge } = generatePkce();
140
+ const state = generateState();
141
+ const authUrl = buildAuthUrl(codeChallenge, state);
142
+ const callbackPromise = startCallbackServer();
143
+
144
+ let browser: Browser | null = null;
145
+ let context: BrowserContext | null = null;
146
+ let page: Page | null = null;
147
+
148
+ try {
149
+ browser = await chromium.launch({
150
+ headless,
151
+ args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'],
152
+ });
153
+ context = await browser.newContext({
154
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
155
+ viewport: { width: 1280, height: 800 },
156
+ });
157
+ page = await context.newPage();
158
+
159
+ console.log(' [1/6] Navigating to OpenAI auth...');
160
+ await page.goto(authUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
161
+ await page.waitForTimeout(3000);
162
+
163
+ console.log(` [2/6] Entering email: ${email}...`);
164
+ const emailInput = await findElement(page, [
165
+ 'input[name="email"]',
166
+ 'input[name="username"]',
167
+ 'input[type="email"]',
168
+ 'input[placeholder*="email" i]',
169
+ ]);
170
+ await emailInput.fill(email);
171
+ await page.waitForTimeout(500);
172
+
173
+ const contBtn = await findElement(page, [
174
+ 'button[type="submit"]',
175
+ 'button:has-text("Continue")',
176
+ 'button:has-text("Next")',
177
+ ]);
178
+ await contBtn.click();
179
+ await page.waitForTimeout(3000);
180
+
181
+ console.log(' [3/6] Using one-time code login...');
182
+ const otpLink = await page.$(
183
+ 'button:has-text("one-time code"), a:has-text("one-time code"), button:has-text("Log in with a one-time code"), a:has-text("Log in with a one-time code")',
184
+ );
185
+
186
+ if (otpLink) {
187
+ await otpLink.click();
188
+ await page.waitForTimeout(3000);
189
+
190
+ const otpEmailInput = await page.$('input[name="email"], input[type="email"]');
191
+ if (otpEmailInput) {
192
+ await otpEmailInput.fill(email);
193
+ await page.waitForTimeout(300);
194
+ const otpSubmit = await page.$('button[type="submit"], button:has-text("Continue")');
195
+ if (otpSubmit) {
196
+ await otpSubmit.click();
197
+ await page.waitForTimeout(2000);
198
+ }
199
+ }
200
+
201
+ console.log(` [4/6] Entering OTP code: ${otpCode}...`);
202
+ const codeField = await findElement(page, [
203
+ 'input[name="code"]',
204
+ 'input[type="text"]',
205
+ 'input[inputmode="numeric"]',
206
+ 'input[placeholder*="ode" i]',
207
+ ], 10000);
208
+ await codeField.fill(otpCode);
209
+ await page.waitForTimeout(500);
210
+
211
+ const verifyBtn = await findElement(page, [
212
+ 'button[type="submit"]',
213
+ 'button:has-text("Continue")',
214
+ 'button:has-text("Verify")',
215
+ ]);
216
+ await verifyBtn.click();
217
+ await page.waitForTimeout(5000);
218
+ } else {
219
+ console.log(' [3/6] No OTP link found, checking page state...');
220
+ }
221
+
222
+ console.log(' [5/6] Handling consent page...');
223
+ await page.waitForTimeout(2000);
224
+ await handleConsent(page);
225
+
226
+ console.log(' [6/6] Waiting for OAuth callback...');
227
+ const { code } = await callbackPromise;
228
+ console.log(` Got OAuth code: ${code.slice(0, 20)}...`);
229
+
230
+ console.log(' Exchanging code for tokens...');
231
+ const tokens = await exchangeCode(code, codeVerifier);
232
+ return tokens;
233
+ } catch (err) {
234
+ if (page) {
235
+ await saveScreenshot(page, 'browser_signup_error');
236
+ }
237
+ if (browser) {
238
+ await browser.close();
239
+ }
240
+ throw new OAuthFlowError(`Browser signup failed: ${err instanceof Error ? err.message : String(err)}`);
241
+ }
242
+ }
243
+
244
+ export async function fullBrowserFlow(
245
+ email: string,
246
+ otpCode: string,
247
+ headless = true,
248
+ ): Promise<Record<string, any>> {
249
+ console.log('[Browser Flow] Starting OAuth flow...');
250
+ const tokens = await browserSignup(email, otpCode, headless);
251
+ console.log(`[Browser Flow] Tokens received for ${email}`);
252
+ return tokens;
253
+ }
@@ -0,0 +1,244 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+
5
+ export class TokenStoreError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = 'TokenStoreError';
9
+ }
10
+ }
11
+
12
+ export interface Account {
13
+ accessToken: string;
14
+ refreshToken: string;
15
+ idToken: string;
16
+ accountId?: string;
17
+ expiresAt: number;
18
+ email: string;
19
+ lastRefresh: string;
20
+ lastSeenAt: number;
21
+ addedAt: number;
22
+ source: string;
23
+ authInvalid: boolean;
24
+ usageCount: number;
25
+ enabled: boolean;
26
+ quota: unknown;
27
+ rateLimitHistory?: unknown[];
28
+ }
29
+
30
+ export interface StoreData {
31
+ version: number;
32
+ accounts: Account[];
33
+ activeIndex: number;
34
+ rotationIndex: number;
35
+ lastRotation: number;
36
+ }
37
+
38
+ function defaultStore(): StoreData {
39
+ return {
40
+ version: 2,
41
+ accounts: [],
42
+ activeIndex: 0,
43
+ rotationIndex: 0,
44
+ lastRotation: Date.now(),
45
+ };
46
+ }
47
+
48
+ function decodeJwt(token: string): Record<string, unknown> | null {
49
+ if (!token) return null;
50
+ try {
51
+ const parts = token.split('.');
52
+ if (parts.length !== 3) return null;
53
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
54
+ const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
55
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function getExpiryFromClaims(claims: Record<string, unknown> | null): number | null {
62
+ if (!claims) return null;
63
+ const exp = claims.exp;
64
+ if (typeof exp === 'number') return Math.floor(exp * 1000);
65
+ return null;
66
+ }
67
+
68
+ function getAccountId(claims: Record<string, unknown> | null): string | null {
69
+ if (!claims) return null;
70
+ const auth = claims['https://api.openai.com/auth'] as Record<string, unknown> | undefined;
71
+ if (auth && typeof auth.chatgpt_account_id === 'string') return auth.chatgpt_account_id;
72
+ return null;
73
+ }
74
+
75
+ export class TokenStore {
76
+ private storePath: string;
77
+ private store: StoreData;
78
+
79
+ constructor(storePath?: string) {
80
+ this.storePath = storePath || path.join(
81
+ process.env.HOME || process.env.USERPROFILE || '',
82
+ '.config',
83
+ 'opencode',
84
+ 'codex-automation-accounts.json',
85
+ );
86
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
87
+ this.store = this.load();
88
+ }
89
+
90
+ get path(): string {
91
+ return this.storePath;
92
+ }
93
+
94
+ private load(): StoreData {
95
+ if (!fs.existsSync(this.storePath)) return defaultStore();
96
+ try {
97
+ const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
98
+ if (typeof data !== 'object' || !Array.isArray(data.accounts)) return defaultStore();
99
+ return {
100
+ version: data.version || 2,
101
+ accounts: data.accounts,
102
+ activeIndex: data.activeIndex ?? 0,
103
+ rotationIndex: data.rotationIndex ?? 0,
104
+ lastRotation: data.lastRotation ?? Date.now(),
105
+ };
106
+ } catch {
107
+ return defaultStore();
108
+ }
109
+ }
110
+
111
+ save(): void {
112
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
113
+ if (fs.existsSync(this.storePath)) {
114
+ fs.copyFileSync(this.storePath, this.storePath + '.bak');
115
+ }
116
+ const tmpPath = `${this.storePath}.tmp-${process.pid}-${Date.now()}`;
117
+ fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2));
118
+ fs.renameSync(tmpPath, this.storePath);
119
+ fs.chmodSync(this.storePath, 0o600);
120
+ }
121
+
122
+ addAccount(tokens: Record<string, unknown>, email: string): number {
123
+ const nowMs = Date.now();
124
+ const nowIso = new Date().toISOString();
125
+
126
+ const accessClaims = decodeJwt(String(tokens.access_token || ''));
127
+ const idClaims = decodeJwt(String(tokens.id_token || ''));
128
+
129
+ const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : 3600;
130
+ const expiresAt = getExpiryFromClaims(accessClaims)
131
+ || getExpiryFromClaims(idClaims)
132
+ || nowMs + (expiresIn as number) * 1000;
133
+
134
+ const accountId = getAccountId(accessClaims) || getAccountId(idClaims);
135
+
136
+ const newAccount: Account = {
137
+ accessToken: String(tokens.access_token || ''),
138
+ refreshToken: String(tokens.refresh_token || ''),
139
+ idToken: String(tokens.id_token || ''),
140
+ accountId: accountId || undefined,
141
+ expiresAt,
142
+ email,
143
+ lastRefresh: nowIso,
144
+ lastSeenAt: nowMs,
145
+ addedAt: nowMs,
146
+ source: 'opencode',
147
+ authInvalid: false,
148
+ usageCount: 0,
149
+ enabled: true,
150
+ quota: null,
151
+ };
152
+
153
+ const existingIdx = this.store.accounts.findIndex((a) => a.email === email);
154
+ if (existingIdx >= 0) {
155
+ const old = this.store.accounts[existingIdx];
156
+ this.store.accounts[existingIdx] = {
157
+ ...old,
158
+ ...newAccount,
159
+ usageCount: old.usageCount,
160
+ addedAt: old.addedAt,
161
+ rateLimitHistory: old.rateLimitHistory,
162
+ };
163
+ this.save();
164
+ return existingIdx;
165
+ }
166
+
167
+ this.store.accounts.push(newAccount);
168
+ const idx = this.store.accounts.length - 1;
169
+ if (this.store.activeIndex < 0) this.store.activeIndex = idx;
170
+ this.save();
171
+ return idx;
172
+ }
173
+
174
+ getAccount(index: number): Account | null {
175
+ return this.store.accounts[index] || null;
176
+ }
177
+
178
+ getActiveAccount(): { index: number; account: Account } | null {
179
+ const idx = this.store.activeIndex;
180
+ const accounts = this.store.accounts;
181
+ if (!accounts.length) return null;
182
+
183
+ for (let offset = 0; offset < accounts.length; offset++) {
184
+ const i = (idx + offset) % accounts.length;
185
+ const acc = accounts[i];
186
+ if (acc.enabled && !acc.authInvalid) return { index: i, account: acc };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ setActive(index: number): void {
192
+ if (index >= 0 && index < this.store.accounts.length) {
193
+ this.store.activeIndex = index;
194
+ this.save();
195
+ }
196
+ }
197
+
198
+ removeAccount(index: number): void {
199
+ if (index >= 0 && index < this.store.accounts.length) {
200
+ this.store.accounts.splice(index, 1);
201
+ if (this.store.activeIndex >= this.store.accounts.length) {
202
+ this.store.activeIndex = Math.max(0, this.store.accounts.length - 1);
203
+ }
204
+ if (this.store.accounts.length > 0) this.save();
205
+ }
206
+ }
207
+
208
+ listAccounts(): Array<{ index: number; email: string; enabled: boolean; authInvalid: boolean; expiresAt: number; usageCount: number; quota: unknown }> {
209
+ return this.store.accounts.map((acc, i) => ({
210
+ index: i,
211
+ email: acc.email,
212
+ enabled: acc.enabled,
213
+ authInvalid: acc.authInvalid,
214
+ expiresAt: acc.expiresAt,
215
+ usageCount: acc.usageCount,
216
+ quota: acc.quota,
217
+ }));
218
+ }
219
+
220
+ cleanInvalid(): number {
221
+ const nowMs = Date.now();
222
+ const originalCount = this.store.accounts.length;
223
+ this.store.accounts = this.store.accounts.filter(
224
+ (acc) => !acc.authInvalid && acc.expiresAt > nowMs,
225
+ );
226
+ const removed = originalCount - this.store.accounts.length;
227
+ if (removed > 0) {
228
+ if (this.store.accounts.length > 0) {
229
+ this.store.activeIndex = Math.min(this.store.activeIndex, this.store.accounts.length - 1);
230
+ } else {
231
+ this.store.activeIndex = 0;
232
+ }
233
+ this.save();
234
+ }
235
+ return removed;
236
+ }
237
+
238
+ updateAccount(index: number, updates: Partial<Account>): void {
239
+ if (index >= 0 && index < this.store.accounts.length) {
240
+ Object.assign(this.store.accounts[index], updates);
241
+ this.save();
242
+ }
243
+ }
244
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }