quickmagic-cli 1.0.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/src/oauth.js ADDED
@@ -0,0 +1,169 @@
1
+ // oauth.js — OAuth PKCE S256 + DCR + loopback listener + exchange + refresh.
2
+ // H8/A2: mở http server TRƯỚC để biết port → redirect_uri http://127.0.0.1:<port>/callback.
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+ const open = require('open');
6
+ const config = require('./config');
7
+
8
+ const LOGIN_TIMEOUT_MS = 120000; // 2 phút chờ đăng nhập qua browser
9
+ const REDIRECT_PATH = '/callback';
10
+ const OAUTH_SCOPE = 'openid email offline_access';
11
+
12
+ // base64url encode (bỏ padding, thay +/ → -_).
13
+ function toBase64Url(buf) {
14
+ return buf
15
+ .toString('base64')
16
+ .replace(/\+/g, '-')
17
+ .replace(/\//g, '_')
18
+ .replace(/=/g, '');
19
+ }
20
+
21
+ // Sinh cặp PKCE verifier + challenge (S256).
22
+ function generatePkce() {
23
+ const verifier = toBase64Url(crypto.randomBytes(32));
24
+ const challenge = toBase64Url(crypto.createHash('sha256').update(verifier).digest());
25
+ return { verifier, challenge };
26
+ }
27
+
28
+ // Đọc metadata OAuth ở ROOT (KHÔNG hardcode path endpoint — đề phòng đổi/fallback).
29
+ async function discover(api_url) {
30
+ const res = await fetch(`${api_url}/.well-known/oauth-authorization-server`);
31
+ if (!res.ok) throw new Error(`không lấy được metadata OAuth (HTTP ${res.status})`);
32
+ return res.json();
33
+ }
34
+
35
+ // DCR — đăng ký client 1 lần (public client, không secret). Host-only redirect → port bất kỳ.
36
+ async function ensureClient(meta, existing_cfg) {
37
+ if (existing_cfg?.client_id) return existing_cfg.client_id;
38
+ const res = await fetch(meta.registration_endpoint, {
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({
42
+ client_name: 'Quick Magic CLI',
43
+ redirect_uris: ['http://127.0.0.1/callback'],
44
+ token_endpoint_auth_method: 'none',
45
+ grant_types: ['authorization_code', 'refresh_token'],
46
+ response_types: ['code'],
47
+ }),
48
+ });
49
+ if (!res.ok) throw new Error(`DCR thất bại (HTTP ${res.status})`);
50
+ const data = await res.json();
51
+ return data.client_id;
52
+ }
53
+
54
+ // Đổi code/refresh_token lấy token (application/x-www-form-urlencoded).
55
+ async function exchange(token_endpoint, params) {
56
+ const res = await fetch(token_endpoint, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
59
+ body: new URLSearchParams(params).toString(),
60
+ });
61
+ if (!res.ok) {
62
+ const text = await res.text().catch(() => '');
63
+ throw new Error(`token endpoint ${res.status}: ${text}`);
64
+ }
65
+ return res.json();
66
+ }
67
+
68
+ // Chuẩn hoá token response → { access_token, refresh_token, expires_at }.
69
+ function normalize(tok) {
70
+ return {
71
+ access_token: tok.access_token,
72
+ refresh_token: tok.refresh_token,
73
+ expires_at: Date.now() + (tok.expires_in || 3600) * 1000,
74
+ };
75
+ }
76
+
77
+ // Mở loopback listener TRƯỚC, trả { server, port } để biết port trước khi authorize (H8/A2).
78
+ function startLoopback() {
79
+ return new Promise((resolve) => {
80
+ const server = http.createServer();
81
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
82
+ });
83
+ }
84
+
85
+ // Chờ callback trên loopback → trả authorization code (kiểm tra state chống CSRF).
86
+ function waitForCode(server, expected_state) {
87
+ return new Promise((resolve, reject) => {
88
+ const timer = setTimeout(() => {
89
+ server.close();
90
+ reject(new Error('hết thời gian chờ đăng nhập (2 phút)'));
91
+ }, LOGIN_TIMEOUT_MS);
92
+ server.on('request', (req, res) => {
93
+ const parsed = new URL(req.url, 'http://127.0.0.1');
94
+ if (parsed.pathname !== REDIRECT_PATH) {
95
+ res.writeHead(404);
96
+ return res.end();
97
+ }
98
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
99
+ res.end('<h3>Đăng nhập Quick Magic thành công. Quay lại terminal.</h3>');
100
+ clearTimeout(timer);
101
+ server.close();
102
+ const err = parsed.searchParams.get('error');
103
+ const code = parsed.searchParams.get('code');
104
+ const got_state = parsed.searchParams.get('state');
105
+ if (err) return reject(new Error(`OAuth trả lỗi: ${err}`));
106
+ if (got_state !== expected_state) return reject(new Error('state không khớp (nghi CSRF)'));
107
+ if (!code) return reject(new Error('không nhận được authorization code'));
108
+ resolve(code);
109
+ });
110
+ server.on('error', reject);
111
+ });
112
+ }
113
+
114
+ // Luồng login: discover → DCR → PKCE → mở browser → loopback nhận code → exchange → lưu.
115
+ async function login(api_url) {
116
+ const meta = await discover(api_url);
117
+ const existing_cfg = config.load();
118
+ const client_id = await ensureClient(meta, existing_cfg);
119
+ const { verifier, challenge } = generatePkce();
120
+ const state = toBase64Url(crypto.randomBytes(16));
121
+
122
+ const { server, port } = await startLoopback(); // mở listener TRƯỚC để biết port
123
+ const redirect_uri = `http://127.0.0.1:${port}${REDIRECT_PATH}`;
124
+ const code_promise = waitForCode(server, state);
125
+
126
+ const auth_url = new URL(meta.authorization_endpoint);
127
+ auth_url.search = new URLSearchParams({
128
+ response_type: 'code',
129
+ client_id,
130
+ redirect_uri,
131
+ code_challenge: challenge,
132
+ code_challenge_method: 'S256',
133
+ scope: OAUTH_SCOPE,
134
+ state,
135
+ }).toString();
136
+
137
+ console.log('Mở trình duyệt để đăng nhập...');
138
+ console.log(`Nếu trình duyệt không tự mở, hãy dán URL sau:\n${auth_url.toString()}`);
139
+ try {
140
+ await open(auth_url.toString());
141
+ } catch {
142
+ console.log('(Không mở được trình duyệt tự động — dùng URL ở trên.)');
143
+ }
144
+
145
+ const code = await code_promise;
146
+ const tok = await exchange(meta.token_endpoint, {
147
+ grant_type: 'authorization_code',
148
+ code,
149
+ redirect_uri,
150
+ client_id,
151
+ code_verifier: verifier,
152
+ });
153
+ config.save({ api_url, client_id, ...normalize(tok) });
154
+ console.log('Đăng nhập thành công.');
155
+ }
156
+
157
+ // Refresh access token (được bọc lockfile ở api.js chống refresh song song).
158
+ async function refresh() {
159
+ const cfg = config.load();
160
+ if (!cfg?.refresh_token) throw new Error('chưa đăng nhập (thiếu refresh_token)');
161
+ const meta = await discover(cfg.api_url);
162
+ const params = { grant_type: 'refresh_token', refresh_token: cfg.refresh_token };
163
+ if (cfg.client_id) params.client_id = cfg.client_id; // chỉ gửi khi có (public client)
164
+ const tok = await exchange(meta.token_endpoint, params);
165
+ config.save({ ...cfg, ...normalize(tok) });
166
+ return cfg.api_url;
167
+ }
168
+
169
+ module.exports = { login, refresh, discover };