@plosson/agentio 0.3.1 → 0.3.2
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/package.json +1 -1
- package/src/auth/github-oauth.ts +41 -118
- package/src/auth/jira-oauth.ts +42 -104
- package/src/auth/oauth-server.ts +149 -0
- package/src/auth/oauth.ts +25 -98
- package/src/commands/config.ts +1 -15
- package/src/commands/discourse.ts +7 -30
- package/src/commands/gchat.ts +7 -28
- package/src/commands/github.ts +7 -28
- package/src/commands/slack.ts +7 -28
- package/src/commands/sql.ts +7 -28
- package/src/commands/status.ts +1 -1
- package/src/commands/telegram.ts +12 -33
- package/src/commands/update.ts +1 -15
- package/src/config/config-manager.ts +1 -1
- package/src/services/discourse/client.ts +2 -10
- package/src/services/gchat/client.ts +4 -6
- package/src/services/gmail/client.ts +35 -20
- package/src/services/jira/client.ts +2 -10
- package/src/services/slack/client.ts +2 -9
- package/src/types/telegram.ts +4 -4
- package/src/utils/client-factory.ts +53 -0
- package/src/utils/errors.ts +12 -0
- package/src/utils/obscure.ts +13 -0
package/package.json
CHANGED
package/src/auth/github-oauth.ts
CHANGED
|
@@ -1,30 +1,9 @@
|
|
|
1
|
-
import { createServer, type Server } from 'http';
|
|
2
1
|
import { URL } from 'url';
|
|
3
2
|
import { GITHUB_OAUTH_CONFIG } from '../config/credentials';
|
|
3
|
+
import { findAvailablePort, startOAuthCallbackServer, launchBrowser } from './oauth-server';
|
|
4
4
|
|
|
5
5
|
const GITHUB_SCOPES = ['repo'];
|
|
6
6
|
|
|
7
|
-
const PORT_RANGE_START = 3000;
|
|
8
|
-
const PORT_RANGE_END = 3010;
|
|
9
|
-
|
|
10
|
-
async function findAvailablePort(): Promise<number> {
|
|
11
|
-
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
12
|
-
try {
|
|
13
|
-
await new Promise<void>((resolve, reject) => {
|
|
14
|
-
const server = createServer();
|
|
15
|
-
server.listen(port, () => {
|
|
16
|
-
server.close(() => resolve());
|
|
17
|
-
});
|
|
18
|
-
server.on('error', reject);
|
|
19
|
-
});
|
|
20
|
-
return port;
|
|
21
|
-
} catch {
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
7
|
export interface GitHubOAuthResult {
|
|
29
8
|
accessToken: string;
|
|
30
9
|
}
|
|
@@ -32,110 +11,54 @@ export interface GitHubOAuthResult {
|
|
|
32
11
|
export async function performGitHubOAuthFlow(): Promise<GitHubOAuthResult> {
|
|
33
12
|
const port = await findAvailablePort();
|
|
34
13
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
14
|
+
const state = Math.random().toString(36).substring(7);
|
|
35
15
|
|
|
36
16
|
const authUrl = new URL('https://github.com/login/oauth/authorize');
|
|
37
17
|
authUrl.searchParams.set('client_id', GITHUB_OAUTH_CONFIG.clientId);
|
|
38
18
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
39
19
|
authUrl.searchParams.set('scope', GITHUB_SCOPES.join(' '));
|
|
40
|
-
authUrl.searchParams.set('state',
|
|
41
|
-
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
let server: Server;
|
|
44
|
-
|
|
45
|
-
const timeout = setTimeout(() => {
|
|
46
|
-
server?.close();
|
|
47
|
-
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
48
|
-
}, 5 * 60 * 1000);
|
|
49
|
-
|
|
50
|
-
server = createServer(async (req, res) => {
|
|
51
|
-
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
52
|
-
|
|
53
|
-
if (url.pathname !== '/callback') {
|
|
54
|
-
res.writeHead(404);
|
|
55
|
-
res.end('Not found');
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const code = url.searchParams.get('code');
|
|
60
|
-
const error = url.searchParams.get('error');
|
|
61
|
-
const errorDescription = url.searchParams.get('error_description');
|
|
62
|
-
|
|
63
|
-
if (error) {
|
|
64
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
65
|
-
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
66
|
-
clearTimeout(timeout);
|
|
67
|
-
server.close();
|
|
68
|
-
reject(new Error(`GitHub OAuth error: ${error} - ${errorDescription || 'Unknown error'}`));
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
20
|
+
authUrl.searchParams.set('state', state);
|
|
71
21
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
// Exchange code for access token
|
|
83
|
-
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
|
84
|
-
method: 'POST',
|
|
85
|
-
headers: {
|
|
86
|
-
Accept: 'application/json',
|
|
87
|
-
'Content-Type': 'application/json',
|
|
88
|
-
},
|
|
89
|
-
body: JSON.stringify({
|
|
90
|
-
client_id: GITHUB_OAUTH_CONFIG.clientId,
|
|
91
|
-
client_secret: GITHUB_OAUTH_CONFIG.clientSecret,
|
|
92
|
-
code,
|
|
93
|
-
redirect_uri: redirectUri,
|
|
94
|
-
}),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const tokenData = await tokenResponse.json() as {
|
|
98
|
-
access_token?: string;
|
|
99
|
-
error?: string;
|
|
100
|
-
error_description?: string;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (tokenData.error || !tokenData.access_token) {
|
|
104
|
-
throw new Error(tokenData.error_description || tokenData.error || 'Failed to get access token');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
108
|
-
res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
109
|
-
|
|
110
|
-
clearTimeout(timeout);
|
|
111
|
-
server.close();
|
|
22
|
+
// Start callback server and browser in parallel
|
|
23
|
+
const callbackPromise = startOAuthCallbackServer({
|
|
24
|
+
port,
|
|
25
|
+
serviceName: 'GitHub',
|
|
26
|
+
expectedState: state,
|
|
27
|
+
});
|
|
112
28
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
29
|
+
console.error(`\nOpening browser for GitHub authorization...`);
|
|
30
|
+
console.error(`If browser doesn't open, visit:\n${authUrl.toString()}\n`);
|
|
31
|
+
launchBrowser(authUrl.toString());
|
|
32
|
+
|
|
33
|
+
// Wait for the callback with the authorization code
|
|
34
|
+
const { code } = await callbackPromise;
|
|
35
|
+
|
|
36
|
+
// Exchange code for access token
|
|
37
|
+
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: 'application/json',
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
client_id: GITHUB_OAUTH_CONFIG.clientId,
|
|
45
|
+
client_secret: GITHUB_OAUTH_CONFIG.clientSecret,
|
|
46
|
+
code,
|
|
47
|
+
redirect_uri: redirectUri,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
124
50
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
51
|
+
const tokenData = await tokenResponse.json() as {
|
|
52
|
+
access_token?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
error_description?: string;
|
|
55
|
+
};
|
|
128
56
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Bun.spawn([open, authUrl.toString()], { stdout: 'ignore', stderr: 'ignore' });
|
|
133
|
-
});
|
|
57
|
+
if (tokenData.error || !tokenData.access_token) {
|
|
58
|
+
throw new Error(tokenData.error_description || tokenData.error || 'Failed to get access token');
|
|
59
|
+
}
|
|
134
60
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
reject(err);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
61
|
+
return {
|
|
62
|
+
accessToken: tokenData.access_token,
|
|
63
|
+
};
|
|
141
64
|
}
|
package/src/auth/jira-oauth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createServer, type Server } from 'http';
|
|
2
1
|
import { URL } from 'url';
|
|
3
2
|
import { JIRA_OAUTH_CONFIG } from '../config/credentials';
|
|
3
|
+
import { startOAuthCallbackServer, launchBrowser } from './oauth-server';
|
|
4
4
|
|
|
5
5
|
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
|
|
6
6
|
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
|
|
@@ -114,8 +114,8 @@ export async function performJiraOAuthFlow(
|
|
|
114
114
|
selectSite?: (sites: AtlassianSite[]) => Promise<AtlassianSite>
|
|
115
115
|
): Promise<JiraOAuthResult> {
|
|
116
116
|
const redirectUri = `http://localhost:${OAUTH_PORT}/callback`;
|
|
117
|
-
|
|
118
117
|
const state = Math.random().toString(36).substring(2);
|
|
118
|
+
|
|
119
119
|
const authUrl = new URL(ATLASSIAN_AUTH_URL);
|
|
120
120
|
authUrl.searchParams.set('audience', 'api.atlassian.com');
|
|
121
121
|
authUrl.searchParams.set('client_id', JIRA_OAUTH_CONFIG.clientId);
|
|
@@ -125,107 +125,45 @@ export async function performJiraOAuthFlow(
|
|
|
125
125
|
authUrl.searchParams.set('response_type', 'code');
|
|
126
126
|
authUrl.searchParams.set('prompt', 'consent');
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
134
|
-
}, 5 * 60 * 1000);
|
|
135
|
-
|
|
136
|
-
server = createServer(async (req, res) => {
|
|
137
|
-
const url = new URL(req.url || '', `http://localhost:${OAUTH_PORT}`);
|
|
138
|
-
|
|
139
|
-
if (url.pathname !== '/callback') {
|
|
140
|
-
res.writeHead(404);
|
|
141
|
-
res.end('Not found');
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const code = url.searchParams.get('code');
|
|
146
|
-
const error = url.searchParams.get('error');
|
|
147
|
-
const returnedState = url.searchParams.get('state');
|
|
148
|
-
|
|
149
|
-
if (error) {
|
|
150
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
151
|
-
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
152
|
-
clearTimeout(timeout);
|
|
153
|
-
server.close();
|
|
154
|
-
reject(new Error(`OAuth error: ${error}`));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (returnedState !== state) {
|
|
159
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
160
|
-
res.end('<html><body><h1>State Mismatch</h1><p>You can close this window.</p></body></html>');
|
|
161
|
-
clearTimeout(timeout);
|
|
162
|
-
server.close();
|
|
163
|
-
reject(new Error('OAuth state mismatch - possible CSRF attack'));
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (!code) {
|
|
168
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
169
|
-
res.end('<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>');
|
|
170
|
-
clearTimeout(timeout);
|
|
171
|
-
server.close();
|
|
172
|
-
reject(new Error('Missing authorization code in OAuth callback'));
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
178
|
-
res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
179
|
-
|
|
180
|
-
clearTimeout(timeout);
|
|
181
|
-
server.close();
|
|
182
|
-
|
|
183
|
-
// Exchange code for tokens
|
|
184
|
-
const tokens = await exchangeCodeForTokens(code, JIRA_OAUTH_CONFIG.clientId, JIRA_OAUTH_CONFIG.clientSecret, redirectUri);
|
|
185
|
-
|
|
186
|
-
// Get accessible resources to find cloud ID
|
|
187
|
-
const sites = await getAccessibleResources(tokens.accessToken);
|
|
188
|
-
|
|
189
|
-
if (sites.length === 0) {
|
|
190
|
-
throw new Error('No accessible Jira sites found. Make sure your app has the correct permissions.');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Let user select site if multiple, otherwise use the first one
|
|
194
|
-
let selectedSite: AtlassianSite;
|
|
195
|
-
if (sites.length === 1) {
|
|
196
|
-
selectedSite = sites[0];
|
|
197
|
-
} else if (selectSite) {
|
|
198
|
-
selectedSite = await selectSite(sites);
|
|
199
|
-
} else {
|
|
200
|
-
selectedSite = sites[0];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
resolve({
|
|
204
|
-
accessToken: tokens.accessToken,
|
|
205
|
-
refreshToken: tokens.refreshToken,
|
|
206
|
-
expiryDate: Date.now() + tokens.expiresIn * 1000,
|
|
207
|
-
cloudId: selectedSite.id,
|
|
208
|
-
siteUrl: selectedSite.url,
|
|
209
|
-
});
|
|
210
|
-
} catch (err) {
|
|
211
|
-
reject(err);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
server.listen(OAUTH_PORT, () => {
|
|
216
|
-
console.error(`\nOpening browser for Atlassian authorization...`);
|
|
217
|
-
console.error(`If browser doesn't open, visit:\n${authUrl.toString()}\n`);
|
|
218
|
-
|
|
219
|
-
// Open browser
|
|
220
|
-
const open = process.platform === 'darwin' ? 'open' :
|
|
221
|
-
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
222
|
-
Bun.spawn([open, authUrl.toString()], { stdout: 'ignore', stderr: 'ignore' });
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
server.on('error', (err) => {
|
|
226
|
-
clearTimeout(timeout);
|
|
227
|
-
server?.close();
|
|
228
|
-
reject(err);
|
|
229
|
-
});
|
|
128
|
+
// Start callback server and browser in parallel
|
|
129
|
+
const callbackPromise = startOAuthCallbackServer({
|
|
130
|
+
port: OAUTH_PORT,
|
|
131
|
+
serviceName: 'Atlassian',
|
|
132
|
+
expectedState: state,
|
|
230
133
|
});
|
|
134
|
+
|
|
135
|
+
console.error(`\nOpening browser for Atlassian authorization...`);
|
|
136
|
+
console.error(`If browser doesn't open, visit:\n${authUrl.toString()}\n`);
|
|
137
|
+
launchBrowser(authUrl.toString());
|
|
138
|
+
|
|
139
|
+
// Wait for the callback with the authorization code
|
|
140
|
+
const { code } = await callbackPromise;
|
|
141
|
+
|
|
142
|
+
// Exchange code for tokens
|
|
143
|
+
const tokens = await exchangeCodeForTokens(code, JIRA_OAUTH_CONFIG.clientId, JIRA_OAUTH_CONFIG.clientSecret, redirectUri);
|
|
144
|
+
|
|
145
|
+
// Get accessible resources to find cloud ID
|
|
146
|
+
const sites = await getAccessibleResources(tokens.accessToken);
|
|
147
|
+
|
|
148
|
+
if (sites.length === 0) {
|
|
149
|
+
throw new Error('No accessible Jira sites found. Make sure your app has the correct permissions.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Let user select site if multiple, otherwise use the first one
|
|
153
|
+
let selectedSite: AtlassianSite;
|
|
154
|
+
if (sites.length === 1) {
|
|
155
|
+
selectedSite = sites[0];
|
|
156
|
+
} else if (selectSite) {
|
|
157
|
+
selectedSite = await selectSite(sites);
|
|
158
|
+
} else {
|
|
159
|
+
selectedSite = sites[0];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
accessToken: tokens.accessToken,
|
|
164
|
+
refreshToken: tokens.refreshToken,
|
|
165
|
+
expiryDate: Date.now() + tokens.expiresIn * 1000,
|
|
166
|
+
cloudId: selectedSite.id,
|
|
167
|
+
siteUrl: selectedSite.url,
|
|
168
|
+
};
|
|
231
169
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
|
|
4
|
+
const PORT_RANGE_START = 3000;
|
|
5
|
+
const PORT_RANGE_END = 3010;
|
|
6
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Find an available port in the range 3000-3010.
|
|
10
|
+
*/
|
|
11
|
+
export async function findAvailablePort(): Promise<number> {
|
|
12
|
+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
13
|
+
try {
|
|
14
|
+
await new Promise<void>((resolve, reject) => {
|
|
15
|
+
const server = createServer();
|
|
16
|
+
server.listen(port, () => {
|
|
17
|
+
server.close(() => resolve());
|
|
18
|
+
});
|
|
19
|
+
server.on('error', reject);
|
|
20
|
+
});
|
|
21
|
+
return port;
|
|
22
|
+
} catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Launch the default browser to open a URL.
|
|
31
|
+
*/
|
|
32
|
+
export function launchBrowser(url: string): void {
|
|
33
|
+
const open = process.platform === 'darwin' ? 'open' :
|
|
34
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
35
|
+
Bun.spawn([open, url], { stdout: 'ignore', stderr: 'ignore' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* HTML templates for OAuth callback responses.
|
|
40
|
+
*/
|
|
41
|
+
export const OAuthHtml = {
|
|
42
|
+
success: '<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>',
|
|
43
|
+
failed: '<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>',
|
|
44
|
+
missingCode: '<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>',
|
|
45
|
+
stateMismatch: '<html><body><h1>State Mismatch</h1><p>You can close this window.</p></body></html>',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface OAuthCallbackResult {
|
|
49
|
+
code: string;
|
|
50
|
+
state?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OAuthServerConfig {
|
|
54
|
+
port: number;
|
|
55
|
+
serviceName: string;
|
|
56
|
+
expectedState?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start an OAuth callback server that listens for the authorization code.
|
|
61
|
+
*
|
|
62
|
+
* @param config Configuration for the server
|
|
63
|
+
* @returns Promise that resolves with the authorization code
|
|
64
|
+
*/
|
|
65
|
+
export function startOAuthCallbackServer(
|
|
66
|
+
config: OAuthServerConfig
|
|
67
|
+
): Promise<OAuthCallbackResult> {
|
|
68
|
+
const { port, serviceName, expectedState } = config;
|
|
69
|
+
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
let server: Server;
|
|
72
|
+
|
|
73
|
+
const timeout = setTimeout(() => {
|
|
74
|
+
server?.close();
|
|
75
|
+
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
76
|
+
}, TIMEOUT_MS);
|
|
77
|
+
|
|
78
|
+
const handleCallback = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
79
|
+
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
80
|
+
|
|
81
|
+
if (url.pathname !== '/callback') {
|
|
82
|
+
res.writeHead(404);
|
|
83
|
+
res.end('Not found');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const code = url.searchParams.get('code');
|
|
88
|
+
const error = url.searchParams.get('error');
|
|
89
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
90
|
+
const returnedState = url.searchParams.get('state');
|
|
91
|
+
|
|
92
|
+
if (error) {
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
94
|
+
res.end(OAuthHtml.failed);
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
server.close();
|
|
97
|
+
const errorMsg = errorDescription ? `${error} - ${errorDescription}` : error;
|
|
98
|
+
reject(new Error(`${serviceName} OAuth error: ${errorMsg}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (expectedState && returnedState !== expectedState) {
|
|
103
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
104
|
+
res.end(OAuthHtml.stateMismatch);
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
server.close();
|
|
107
|
+
reject(new Error('OAuth state mismatch - possible CSRF attack'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!code) {
|
|
112
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
113
|
+
res.end(OAuthHtml.missingCode);
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
server.close();
|
|
116
|
+
reject(new Error('Missing authorization code in OAuth callback'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
121
|
+
res.end(OAuthHtml.success);
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
server.close();
|
|
124
|
+
resolve({ code, state: returnedState || undefined });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
server = createServer((req, res) => {
|
|
128
|
+
handleCallback(req, res).catch((err) => {
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(500);
|
|
131
|
+
res.end('Internal server error');
|
|
132
|
+
}
|
|
133
|
+
clearTimeout(timeout);
|
|
134
|
+
server?.close();
|
|
135
|
+
reject(err);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
server.listen(port, () => {
|
|
140
|
+
// Server is ready for callback
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
server.on('error', (err) => {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
server?.close();
|
|
146
|
+
reject(err);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/auth/oauth.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { createServer, type Server } from 'http';
|
|
2
|
-
import { URL } from 'url';
|
|
3
1
|
import { google } from 'googleapis';
|
|
4
2
|
import { GOOGLE_OAUTH_CONFIG } from '../config/credentials';
|
|
3
|
+
import { findAvailablePort, startOAuthCallbackServer, launchBrowser } from './oauth-server';
|
|
5
4
|
import type { OAuthTokens } from '../types/tokens';
|
|
6
5
|
|
|
7
6
|
const GMAIL_SCOPES = [
|
|
@@ -18,26 +17,10 @@ const GCHAT_SCOPES = [
|
|
|
18
17
|
'https://www.googleapis.com/auth/userinfo.email', // get user email for profile naming
|
|
19
18
|
];
|
|
20
19
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
26
|
-
try {
|
|
27
|
-
await new Promise<void>((resolve, reject) => {
|
|
28
|
-
const server = createServer();
|
|
29
|
-
server.listen(port, () => {
|
|
30
|
-
server.close(() => resolve());
|
|
31
|
-
});
|
|
32
|
-
server.on('error', reject);
|
|
33
|
-
});
|
|
34
|
-
return port;
|
|
35
|
-
} catch {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
|
40
|
-
}
|
|
20
|
+
const SCOPES: Record<'gmail' | 'gchat', string[]> = {
|
|
21
|
+
gmail: GMAIL_SCOPES,
|
|
22
|
+
gchat: GCHAT_SCOPES,
|
|
23
|
+
};
|
|
41
24
|
|
|
42
25
|
export async function performOAuthFlow(
|
|
43
26
|
service: 'gmail' | 'gchat'
|
|
@@ -51,7 +34,7 @@ export async function performOAuthFlow(
|
|
|
51
34
|
redirectUri
|
|
52
35
|
);
|
|
53
36
|
|
|
54
|
-
const scopes = service
|
|
37
|
+
const scopes = SCOPES[service];
|
|
55
38
|
|
|
56
39
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
57
40
|
access_type: 'offline',
|
|
@@ -59,83 +42,27 @@ export async function performOAuthFlow(
|
|
|
59
42
|
prompt: 'consent',
|
|
60
43
|
});
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
68
|
-
}, 5 * 60 * 1000);
|
|
69
|
-
|
|
70
|
-
server = createServer(async (req, res) => {
|
|
71
|
-
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
72
|
-
|
|
73
|
-
if (url.pathname !== '/callback') {
|
|
74
|
-
res.writeHead(404);
|
|
75
|
-
res.end('Not found');
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const code = url.searchParams.get('code');
|
|
80
|
-
const error = url.searchParams.get('error');
|
|
81
|
-
|
|
82
|
-
if (error) {
|
|
83
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
84
|
-
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
85
|
-
clearTimeout(timeout);
|
|
86
|
-
server.close();
|
|
87
|
-
reject(new Error(`OAuth error: ${error}`));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (!code) {
|
|
92
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
93
|
-
res.end('<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>');
|
|
94
|
-
clearTimeout(timeout);
|
|
95
|
-
server.close();
|
|
96
|
-
reject(new Error('Missing authorization code in OAuth callback'));
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
const { tokens } = await oauth2Client.getToken(code);
|
|
102
|
-
|
|
103
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
104
|
-
res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
105
|
-
|
|
106
|
-
clearTimeout(timeout);
|
|
107
|
-
server.close();
|
|
45
|
+
// Start callback server and browser in parallel
|
|
46
|
+
const callbackPromise = startOAuthCallbackServer({
|
|
47
|
+
port,
|
|
48
|
+
serviceName: 'Google',
|
|
49
|
+
});
|
|
108
50
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
expiry_date: tokens.expiry_date || undefined,
|
|
113
|
-
token_type: tokens.token_type || 'Bearer',
|
|
114
|
-
scope: tokens.scope || undefined,
|
|
115
|
-
});
|
|
116
|
-
} catch (err) {
|
|
117
|
-
res.writeHead(500);
|
|
118
|
-
res.end('Failed to exchange authorization code');
|
|
119
|
-
clearTimeout(timeout);
|
|
120
|
-
server.close();
|
|
121
|
-
reject(err);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
51
|
+
console.error(`\nOpening browser for authorization...`);
|
|
52
|
+
console.error(`If browser doesn't open, visit:\n${authUrl}\n`);
|
|
53
|
+
launchBrowser(authUrl);
|
|
124
54
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
console.error(`If browser doesn't open, visit:\n${authUrl}\n`);
|
|
55
|
+
// Wait for the callback with the authorization code
|
|
56
|
+
const { code } = await callbackPromise;
|
|
128
57
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
132
|
-
Bun.spawn([open, authUrl], { stdout: 'ignore', stderr: 'ignore' });
|
|
133
|
-
});
|
|
58
|
+
// Exchange code for tokens using Google's OAuth client
|
|
59
|
+
const { tokens } = await oauth2Client.getToken(code);
|
|
134
60
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
61
|
+
return {
|
|
62
|
+
access_token: tokens.access_token!,
|
|
63
|
+
refresh_token: tokens.refresh_token || undefined,
|
|
64
|
+
expiry_date: tokens.expiry_date || undefined,
|
|
65
|
+
token_type: tokens.token_type || 'Bearer',
|
|
66
|
+
scope: tokens.scope || undefined,
|
|
67
|
+
};
|
|
141
68
|
}
|
package/src/commands/config.ts
CHANGED
|
@@ -3,27 +3,13 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypt
|
|
|
3
3
|
import { readFile, writeFile } from 'fs/promises';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
|
-
import { createInterface } from 'readline';
|
|
7
6
|
import { loadConfig, saveConfig, setEnv, unsetEnv, listEnv } from '../config/config-manager';
|
|
8
7
|
import { getAllCredentials, setAllCredentials } from '../auth/token-store';
|
|
9
8
|
import { CliError, handleError } from '../utils/errors';
|
|
9
|
+
import { confirm } from '../utils/stdin';
|
|
10
10
|
import type { Config } from '../types/config';
|
|
11
11
|
import type { StoredCredentials } from '../types/tokens';
|
|
12
12
|
|
|
13
|
-
async function confirm(message: string): Promise<boolean> {
|
|
14
|
-
const rl = createInterface({
|
|
15
|
-
input: process.stdin,
|
|
16
|
-
output: process.stderr,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
return new Promise((resolve) => {
|
|
20
|
-
rl.question(`${message} [y/N] `, (answer) => {
|
|
21
|
-
rl.close();
|
|
22
|
-
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
13
|
const ALGORITHM = 'aes-256-gcm';
|
|
28
14
|
|
|
29
15
|
interface ExportedData {
|