|
| 1 | +import { createServer } from 'node:http' |
| 2 | +import { randomBytes } from 'node:crypto' |
1 | 3 | import { ApiClient, linkedProject } from '../api.js' |
2 | | -import { info, die, printJson, promptPassword } from '../util.js' |
| 4 | +import { info, die, printJson, promptPassword, openUrl } from '../util.js' |
3 | 5 |
|
4 | | -export async function login(opts: { email?: string; password?: string; apiUrl?: string }): Promise<void> { |
| 6 | +export async function login(opts: { email?: string; password?: string; apiUrl?: string; oauth?: string }): Promise<void> { |
| 7 | + if (opts.oauth) return loginOauth(opts.oauth, opts) |
5 | 8 | const api = await ApiClient.load() |
6 | 9 | if (opts.apiUrl) api.setApiUrl(opts.apiUrl) |
7 | | - if (!opts.email) die('--email is required') |
| 10 | + if (!opts.email) die('--email is required (or use --oauth <github|google>)') |
8 | 11 | const password = opts.password ?? process.env.INSTA_PASSWORD ?? (await promptPassword()) |
9 | 12 | const res = await api.request('POST', '/auth/login', { email: opts.email, password }, { auth: false }) |
10 | 13 | api.setSession(res, res.user) |
11 | 14 | await api.persist() |
12 | 15 | info(`logged in as ${res.user.email ?? res.user.id} @ ${api.apiUrl}`) |
13 | 16 | } |
14 | 17 |
|
| 18 | +// Browser OAuth (GitHub/Google) via a loopback listener. We open the platform's CLI-OAuth bridge, |
| 19 | +// which runs Better Auth's social flow and bounces the resulting session token back to us. |
| 20 | +export async function loginOauth(provider: string, opts: { apiUrl?: string }): Promise<void> { |
| 21 | + if (provider !== 'github' && provider !== 'google') die('provider must be github or google') |
| 22 | + const api = await ApiClient.load() |
| 23 | + if (opts.apiUrl) api.setApiUrl(opts.apiUrl) |
| 24 | + const token = await browserOauth(api.apiUrl, provider) |
| 25 | + api.setSession({ accessToken: token, refreshToken: token }) |
| 26 | + const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') |
| 27 | + api.setSession({ accessToken: token, refreshToken: token }, me.user) |
| 28 | + await api.persist() |
| 29 | + info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`) |
| 30 | +} |
| 31 | + |
| 32 | +// Start a loopback server, open the browser at the platform bridge, and await the token. |
| 33 | +function browserOauth(apiUrl: string, provider: string): Promise<string> { |
| 34 | + return new Promise<string>((resolve, reject) => { |
| 35 | + const state = randomBytes(16).toString('hex') |
| 36 | + let timer: NodeJS.Timeout |
| 37 | + const server = createServer((req, res) => { |
| 38 | + const url = new URL(req.url ?? '/', 'http://127.0.0.1') |
| 39 | + if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return } |
| 40 | + const token = url.searchParams.get('token') |
| 41 | + const err = url.searchParams.get('error') |
| 42 | + const ok = !!token && !err && url.searchParams.get('state') === state |
| 43 | + res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html' }) |
| 44 | + res.end(`<!doctype html><meta charset=utf-8><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h2>InstaCloud</h2><p>${ok ? '✓ Login complete — you can close this tab.' : '✗ Login failed' + (err ? ` (${err})` : '')}</p></body>`) |
| 45 | + clearTimeout(timer) |
| 46 | + server.close() |
| 47 | + if (err) return reject(new Error(`oauth failed: ${err}`)) |
| 48 | + if (!token) return reject(new Error('no token returned')) |
| 49 | + if (url.searchParams.get('state') !== state) return reject(new Error('state mismatch — aborting')) |
| 50 | + resolve(token) |
| 51 | + }) |
| 52 | + server.on('error', reject) |
| 53 | + server.listen(0, '127.0.0.1', () => { |
| 54 | + const addr = server.address() |
| 55 | + const port = typeof addr === 'object' && addr ? addr.port : 0 |
| 56 | + const redirect = `http://127.0.0.1:${port}/callback` |
| 57 | + const authorizeUrl = `${apiUrl}/auth/cli/authorize?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(redirect)}&state=${state}` |
| 58 | + info(`opening browser to authorize with ${provider}…`) |
| 59 | + if (!openUrl(authorizeUrl)) info(`open this URL to continue:\n ${authorizeUrl}`) |
| 60 | + timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')) }, 120_000) |
| 61 | + }) |
| 62 | + }) |
| 63 | +} |
| 64 | + |
15 | 65 | export async function logout(): Promise<void> { |
16 | 66 | const api = await ApiClient.load() |
17 | 67 | if (api.config.refreshToken) { |
|
0 commit comments