@friggframework/devtools 2.0.0-next.64 → 2.0.0-next.65
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/frigg-cli/auth-command/CLAUDE.md +293 -0
- package/frigg-cli/auth-command/README.md +450 -0
- package/frigg-cli/auth-command/api-key-flow.js +153 -0
- package/frigg-cli/auth-command/auth-tester.js +344 -0
- package/frigg-cli/auth-command/credential-storage.js +182 -0
- package/frigg-cli/auth-command/index.js +256 -0
- package/frigg-cli/auth-command/json-schema-form.js +67 -0
- package/frigg-cli/auth-command/module-loader.js +172 -0
- package/frigg-cli/auth-command/oauth-callback-server.js +431 -0
- package/frigg-cli/auth-command/oauth-flow.js +195 -0
- package/frigg-cli/auth-command/utils/browser.js +30 -0
- package/frigg-cli/index.js +36 -1
- package/package.json +7 -7
- package/test/mock-api.js +1 -3
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const url = require('url');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
class OAuthCallbackServer {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.port = options.port || 3333;
|
|
8
|
+
this.timeout = (options.timeout || 300) * 1000; // Convert to milliseconds
|
|
9
|
+
this.server = null;
|
|
10
|
+
this._resolveCode = null;
|
|
11
|
+
this._rejectCode = null;
|
|
12
|
+
this._timeoutId = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async start() {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
this.server = http.createServer(this.handleRequest.bind(this));
|
|
18
|
+
|
|
19
|
+
this.server.on('error', (err) => {
|
|
20
|
+
if (err.code === 'EADDRINUSE') {
|
|
21
|
+
reject(
|
|
22
|
+
new Error(
|
|
23
|
+
`Port ${this.port} is already in use.\n` +
|
|
24
|
+
`Try using a different port: frigg auth test <module> --port <different-port>`
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
} else {
|
|
28
|
+
reject(err);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.server.listen(this.port, () => {
|
|
33
|
+
console.log(
|
|
34
|
+
chalk.gray(
|
|
35
|
+
`Callback server listening on http://localhost:${this.port}`
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
handleRequest(req, res) {
|
|
44
|
+
const parsedUrl = url.parse(req.url, true);
|
|
45
|
+
|
|
46
|
+
// Handle OAuth callback (any path - to support module-specific redirects like /attio, /pipedrive)
|
|
47
|
+
if (parsedUrl.query.code) {
|
|
48
|
+
this.handleOAuthCallback(parsedUrl.query, res);
|
|
49
|
+
} else if (parsedUrl.query.error) {
|
|
50
|
+
this.handleOAuthError(parsedUrl.query, res);
|
|
51
|
+
} else if (parsedUrl.pathname === '/health') {
|
|
52
|
+
// Health check endpoint
|
|
53
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ status: 'ready' }));
|
|
55
|
+
} else {
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
57
|
+
res.end(this.getWaitingHtml());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handleOAuthCallback(query, res) {
|
|
62
|
+
const { code, state } = query;
|
|
63
|
+
|
|
64
|
+
// Send success page to browser
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
66
|
+
res.end(this.getSuccessHtml());
|
|
67
|
+
|
|
68
|
+
// Clear timeout
|
|
69
|
+
if (this._timeoutId) {
|
|
70
|
+
clearTimeout(this._timeoutId);
|
|
71
|
+
this._timeoutId = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resolve the waiting promise
|
|
75
|
+
if (this._resolveCode) {
|
|
76
|
+
this._resolveCode({ code, state });
|
|
77
|
+
this._resolveCode = null;
|
|
78
|
+
this._rejectCode = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
handleOAuthError(query, res) {
|
|
83
|
+
const { error, error_description, error_uri } = query;
|
|
84
|
+
|
|
85
|
+
// Send error page to browser
|
|
86
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
87
|
+
res.end(this.getErrorHtml(error, error_description, error_uri));
|
|
88
|
+
|
|
89
|
+
// Clear timeout
|
|
90
|
+
if (this._timeoutId) {
|
|
91
|
+
clearTimeout(this._timeoutId);
|
|
92
|
+
this._timeoutId = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reject the waiting promise
|
|
96
|
+
if (this._rejectCode) {
|
|
97
|
+
const errorMessage = error_description
|
|
98
|
+
? `OAuth error: ${error} - ${error_description}`
|
|
99
|
+
: `OAuth error: ${error}`;
|
|
100
|
+
this._rejectCode(new Error(errorMessage));
|
|
101
|
+
this._resolveCode = null;
|
|
102
|
+
this._rejectCode = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
waitForCode() {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
this._resolveCode = resolve;
|
|
109
|
+
this._rejectCode = reject;
|
|
110
|
+
|
|
111
|
+
// Set timeout
|
|
112
|
+
this._timeoutId = setTimeout(() => {
|
|
113
|
+
this._resolveCode = null;
|
|
114
|
+
this._rejectCode = null;
|
|
115
|
+
reject(
|
|
116
|
+
new Error(
|
|
117
|
+
`OAuth callback timeout after ${
|
|
118
|
+
this.timeout / 1000
|
|
119
|
+
} seconds.\n` +
|
|
120
|
+
`Make sure you completed the authorization in the browser.`
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}, this.timeout);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getSuccessHtml() {
|
|
128
|
+
return `
|
|
129
|
+
<!DOCTYPE html>
|
|
130
|
+
<html>
|
|
131
|
+
<head>
|
|
132
|
+
<title>Frigg Auth - Success</title>
|
|
133
|
+
<style>
|
|
134
|
+
:root {
|
|
135
|
+
--primary: hsl(150 45% 30%);
|
|
136
|
+
--primary-foreground: hsl(150 30% 90%);
|
|
137
|
+
--background: hsl(0 0% 100%);
|
|
138
|
+
--foreground: hsl(240 10% 3.9%);
|
|
139
|
+
--muted-foreground: hsl(240 3.8% 46.1%);
|
|
140
|
+
--border: hsl(240 5.9% 90%);
|
|
141
|
+
--radius: 0.5rem;
|
|
142
|
+
}
|
|
143
|
+
body {
|
|
144
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
145
|
+
display: flex;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
align-items: center;
|
|
148
|
+
min-height: 100vh;
|
|
149
|
+
margin: 0;
|
|
150
|
+
background: var(--background);
|
|
151
|
+
}
|
|
152
|
+
.container {
|
|
153
|
+
text-align: center;
|
|
154
|
+
background: var(--background);
|
|
155
|
+
padding: 3rem;
|
|
156
|
+
border-radius: var(--radius);
|
|
157
|
+
border: 1px solid var(--border);
|
|
158
|
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
159
|
+
max-width: 400px;
|
|
160
|
+
}
|
|
161
|
+
.icon {
|
|
162
|
+
width: 64px;
|
|
163
|
+
height: 64px;
|
|
164
|
+
margin: 0 auto 1.5rem auto;
|
|
165
|
+
background: var(--primary);
|
|
166
|
+
border-radius: 50%;
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
}
|
|
171
|
+
.icon svg {
|
|
172
|
+
width: 32px;
|
|
173
|
+
height: 32px;
|
|
174
|
+
color: var(--primary-foreground);
|
|
175
|
+
}
|
|
176
|
+
h1 {
|
|
177
|
+
color: var(--foreground);
|
|
178
|
+
margin: 0 0 0.5rem 0;
|
|
179
|
+
font-size: 1.25rem;
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
}
|
|
182
|
+
p {
|
|
183
|
+
color: var(--muted-foreground);
|
|
184
|
+
margin: 0;
|
|
185
|
+
line-height: 1.6;
|
|
186
|
+
font-size: 0.875rem;
|
|
187
|
+
}
|
|
188
|
+
.frigg-badge {
|
|
189
|
+
margin-top: 2rem;
|
|
190
|
+
padding-top: 1.5rem;
|
|
191
|
+
border-top: 1px solid var(--border);
|
|
192
|
+
font-size: 0.75rem;
|
|
193
|
+
color: var(--muted-foreground);
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<div class="container">
|
|
199
|
+
<div class="icon">
|
|
200
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
201
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
202
|
+
</svg>
|
|
203
|
+
</div>
|
|
204
|
+
<h1>Authentication Successful</h1>
|
|
205
|
+
<p>You can close this window and return to the terminal.</p>
|
|
206
|
+
<div class="frigg-badge">Powered by Frigg</div>
|
|
207
|
+
</div>
|
|
208
|
+
</body>
|
|
209
|
+
</html>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getErrorHtml(error, description, errorUri) {
|
|
213
|
+
return `
|
|
214
|
+
<!DOCTYPE html>
|
|
215
|
+
<html>
|
|
216
|
+
<head>
|
|
217
|
+
<title>Frigg Auth - Error</title>
|
|
218
|
+
<style>
|
|
219
|
+
:root {
|
|
220
|
+
--destructive: hsl(0 84.2% 60.2%);
|
|
221
|
+
--destructive-foreground: hsl(0 0% 98%);
|
|
222
|
+
--background: hsl(0 0% 100%);
|
|
223
|
+
--foreground: hsl(240 10% 3.9%);
|
|
224
|
+
--muted-foreground: hsl(240 3.8% 46.1%);
|
|
225
|
+
--border: hsl(240 5.9% 90%);
|
|
226
|
+
--primary: hsl(150 45% 30%);
|
|
227
|
+
--radius: 0.5rem;
|
|
228
|
+
}
|
|
229
|
+
body {
|
|
230
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
231
|
+
display: flex;
|
|
232
|
+
justify-content: center;
|
|
233
|
+
align-items: center;
|
|
234
|
+
min-height: 100vh;
|
|
235
|
+
margin: 0;
|
|
236
|
+
background: var(--background);
|
|
237
|
+
}
|
|
238
|
+
.container {
|
|
239
|
+
text-align: center;
|
|
240
|
+
background: var(--background);
|
|
241
|
+
padding: 3rem;
|
|
242
|
+
border-radius: var(--radius);
|
|
243
|
+
border: 1px solid var(--border);
|
|
244
|
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
245
|
+
max-width: 400px;
|
|
246
|
+
}
|
|
247
|
+
.icon {
|
|
248
|
+
width: 64px;
|
|
249
|
+
height: 64px;
|
|
250
|
+
margin: 0 auto 1.5rem auto;
|
|
251
|
+
background: var(--destructive);
|
|
252
|
+
border-radius: 50%;
|
|
253
|
+
display: flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
justify-content: center;
|
|
256
|
+
}
|
|
257
|
+
.icon svg {
|
|
258
|
+
width: 32px;
|
|
259
|
+
height: 32px;
|
|
260
|
+
color: var(--destructive-foreground);
|
|
261
|
+
}
|
|
262
|
+
h1 {
|
|
263
|
+
color: var(--foreground);
|
|
264
|
+
margin: 0 0 1rem 0;
|
|
265
|
+
font-size: 1.25rem;
|
|
266
|
+
font-weight: 600;
|
|
267
|
+
}
|
|
268
|
+
.error-code {
|
|
269
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
270
|
+
background: hsl(0 84.2% 97%);
|
|
271
|
+
color: hsl(0 72% 51%);
|
|
272
|
+
padding: 0.5rem 1rem;
|
|
273
|
+
border-radius: calc(var(--radius) - 2px);
|
|
274
|
+
display: inline-block;
|
|
275
|
+
margin-bottom: 1rem;
|
|
276
|
+
font-size: 0.875rem;
|
|
277
|
+
border: 1px solid hsl(0 84.2% 90%);
|
|
278
|
+
}
|
|
279
|
+
p {
|
|
280
|
+
color: var(--muted-foreground);
|
|
281
|
+
margin: 0 0 0.5rem 0;
|
|
282
|
+
line-height: 1.6;
|
|
283
|
+
font-size: 0.875rem;
|
|
284
|
+
}
|
|
285
|
+
a {
|
|
286
|
+
color: var(--primary);
|
|
287
|
+
text-decoration: none;
|
|
288
|
+
}
|
|
289
|
+
a:hover {
|
|
290
|
+
text-decoration: underline;
|
|
291
|
+
}
|
|
292
|
+
.frigg-badge {
|
|
293
|
+
margin-top: 2rem;
|
|
294
|
+
padding-top: 1.5rem;
|
|
295
|
+
border-top: 1px solid var(--border);
|
|
296
|
+
font-size: 0.75rem;
|
|
297
|
+
color: var(--muted-foreground);
|
|
298
|
+
}
|
|
299
|
+
</style>
|
|
300
|
+
</head>
|
|
301
|
+
<body>
|
|
302
|
+
<div class="container">
|
|
303
|
+
<div class="icon">
|
|
304
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
305
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
306
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
307
|
+
</svg>
|
|
308
|
+
</div>
|
|
309
|
+
<h1>Authentication Failed</h1>
|
|
310
|
+
<div class="error-code">${escapeHtml(error)}</div>
|
|
311
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ''}
|
|
312
|
+
${
|
|
313
|
+
errorUri
|
|
314
|
+
? `<p><a href="${escapeHtml(
|
|
315
|
+
errorUri
|
|
316
|
+
)}" target="_blank">More information →</a></p>`
|
|
317
|
+
: ''
|
|
318
|
+
}
|
|
319
|
+
<p style="margin-top: 1rem;">Check the terminal for details.</p>
|
|
320
|
+
<div class="frigg-badge">Powered by Frigg</div>
|
|
321
|
+
</div>
|
|
322
|
+
</body>
|
|
323
|
+
</html>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
getWaitingHtml() {
|
|
327
|
+
return `
|
|
328
|
+
<!DOCTYPE html>
|
|
329
|
+
<html>
|
|
330
|
+
<head>
|
|
331
|
+
<title>Frigg Auth - Waiting</title>
|
|
332
|
+
<style>
|
|
333
|
+
:root {
|
|
334
|
+
--primary: hsl(150 45% 30%);
|
|
335
|
+
--background: hsl(0 0% 100%);
|
|
336
|
+
--foreground: hsl(240 10% 3.9%);
|
|
337
|
+
--muted-foreground: hsl(240 3.8% 46.1%);
|
|
338
|
+
--border: hsl(240 5.9% 90%);
|
|
339
|
+
--radius: 0.5rem;
|
|
340
|
+
}
|
|
341
|
+
body {
|
|
342
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
343
|
+
display: flex;
|
|
344
|
+
justify-content: center;
|
|
345
|
+
align-items: center;
|
|
346
|
+
min-height: 100vh;
|
|
347
|
+
margin: 0;
|
|
348
|
+
background: var(--background);
|
|
349
|
+
}
|
|
350
|
+
.container {
|
|
351
|
+
text-align: center;
|
|
352
|
+
background: var(--background);
|
|
353
|
+
padding: 3rem;
|
|
354
|
+
border-radius: var(--radius);
|
|
355
|
+
border: 1px solid var(--border);
|
|
356
|
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
357
|
+
max-width: 400px;
|
|
358
|
+
}
|
|
359
|
+
.spinner {
|
|
360
|
+
width: 48px;
|
|
361
|
+
height: 48px;
|
|
362
|
+
margin: 0 auto 1.5rem auto;
|
|
363
|
+
border: 3px solid var(--border);
|
|
364
|
+
border-top-color: var(--primary);
|
|
365
|
+
border-radius: 50%;
|
|
366
|
+
animation: spin 1s linear infinite;
|
|
367
|
+
}
|
|
368
|
+
@keyframes spin {
|
|
369
|
+
to { transform: rotate(360deg); }
|
|
370
|
+
}
|
|
371
|
+
h1 {
|
|
372
|
+
color: var(--foreground);
|
|
373
|
+
margin: 0 0 0.5rem 0;
|
|
374
|
+
font-size: 1.25rem;
|
|
375
|
+
font-weight: 600;
|
|
376
|
+
}
|
|
377
|
+
p {
|
|
378
|
+
color: var(--muted-foreground);
|
|
379
|
+
margin: 0;
|
|
380
|
+
line-height: 1.6;
|
|
381
|
+
font-size: 0.875rem;
|
|
382
|
+
}
|
|
383
|
+
.frigg-badge {
|
|
384
|
+
margin-top: 2rem;
|
|
385
|
+
padding-top: 1.5rem;
|
|
386
|
+
border-top: 1px solid var(--border);
|
|
387
|
+
font-size: 0.75rem;
|
|
388
|
+
color: var(--muted-foreground);
|
|
389
|
+
}
|
|
390
|
+
</style>
|
|
391
|
+
</head>
|
|
392
|
+
<body>
|
|
393
|
+
<div class="container">
|
|
394
|
+
<div class="spinner"></div>
|
|
395
|
+
<h1>Waiting for Authorization</h1>
|
|
396
|
+
<p>Complete the OAuth flow in your browser to continue.</p>
|
|
397
|
+
<div class="frigg-badge">Powered by Frigg</div>
|
|
398
|
+
</div>
|
|
399
|
+
</body>
|
|
400
|
+
</html>`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async stop() {
|
|
404
|
+
// Clear any pending timeout
|
|
405
|
+
if (this._timeoutId) {
|
|
406
|
+
clearTimeout(this._timeoutId);
|
|
407
|
+
this._timeoutId = null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (this.server) {
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
this.server.close(() => {
|
|
413
|
+
this.server = null;
|
|
414
|
+
resolve();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function escapeHtml(str) {
|
|
422
|
+
if (!str) return '';
|
|
423
|
+
return String(str)
|
|
424
|
+
.replace(/&/g, '&')
|
|
425
|
+
.replace(/</g, '<')
|
|
426
|
+
.replace(/>/g, '>')
|
|
427
|
+
.replace(/"/g, '"')
|
|
428
|
+
.replace(/'/g, ''');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = { OAuthCallbackServer };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const { OAuthCallbackServer } = require('./oauth-callback-server');
|
|
2
|
+
const { openBrowser } = require('./utils/browser');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
async function runOAuthFlow(definition, ApiClass, options) {
|
|
7
|
+
const port = options.port || 3333;
|
|
8
|
+
const defaultRedirectUri = `http://localhost:${port}`;
|
|
9
|
+
const redirectUri =
|
|
10
|
+
definition.env?.redirect_uri ||
|
|
11
|
+
process.env.REDIRECT_URI ||
|
|
12
|
+
defaultRedirectUri;
|
|
13
|
+
const moduleName =
|
|
14
|
+
definition.moduleName || definition.getName?.() || 'unknown';
|
|
15
|
+
|
|
16
|
+
// 1. Generate state for CSRF protection
|
|
17
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
18
|
+
|
|
19
|
+
// 2. Create API instance with auth params (redirect_uri from definition.env is preserved)
|
|
20
|
+
const apiParams = {
|
|
21
|
+
...definition.env,
|
|
22
|
+
state,
|
|
23
|
+
};
|
|
24
|
+
if (!definition.env?.redirect_uri) {
|
|
25
|
+
apiParams.redirect_uri = redirectUri;
|
|
26
|
+
}
|
|
27
|
+
const api = new ApiClass(apiParams);
|
|
28
|
+
|
|
29
|
+
// 3. Get authorization URL
|
|
30
|
+
let authUrl;
|
|
31
|
+
if (typeof api.getAuthorizationUri === 'function') {
|
|
32
|
+
authUrl = api.getAuthorizationUri();
|
|
33
|
+
} else if (api.authorizationUri) {
|
|
34
|
+
authUrl = api.authorizationUri;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!authUrl) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Module ${moduleName} did not provide an authorization URL.\n` +
|
|
40
|
+
`Expected api.getAuthorizationUri() or api.authorizationUri property.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!authUrl.includes('state=')) {
|
|
45
|
+
const separator = authUrl.includes('?') ? '&' : '?';
|
|
46
|
+
authUrl = `${authUrl}${separator}state=${state}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(chalk.blue('\n📝 OAuth2 Authorization Flow\n'));
|
|
50
|
+
console.log(chalk.gray(`Module: ${moduleName}`));
|
|
51
|
+
console.log(chalk.gray(`Redirect URI: ${redirectUri}`));
|
|
52
|
+
|
|
53
|
+
// 4. Start callback server
|
|
54
|
+
const server = new OAuthCallbackServer({ port, timeout: options.timeout });
|
|
55
|
+
await server.start();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// 5. Open browser for authorization
|
|
59
|
+
console.log(chalk.gray('\nOpening browser for authorization...'));
|
|
60
|
+
try {
|
|
61
|
+
await openBrowser(authUrl);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.log(
|
|
64
|
+
chalk.yellow(
|
|
65
|
+
`Could not open browser automatically: ${err.message}`
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
console.log(chalk.yellow('Please open the URL manually:'));
|
|
69
|
+
console.log(chalk.cyan(`\n ${authUrl}\n`));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(chalk.gray('Waiting for OAuth callback...'));
|
|
73
|
+
console.log(
|
|
74
|
+
chalk.gray(`(Timeout: ${options.timeout || 300} seconds)\n`)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// 6. Wait for callback
|
|
78
|
+
const { code, state: returnedState } = await server.waitForCode();
|
|
79
|
+
|
|
80
|
+
// 7. Verify state (CSRF protection)
|
|
81
|
+
if (returnedState && returnedState !== state) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'OAuth state mismatch - possible CSRF attack.\n' +
|
|
84
|
+
`Expected: ${state}\n` +
|
|
85
|
+
`Received: ${returnedState}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(chalk.green('✓ Authorization code received'));
|
|
90
|
+
|
|
91
|
+
// 8. Exchange code for tokens
|
|
92
|
+
console.log(chalk.gray('Exchanging code for tokens...'));
|
|
93
|
+
|
|
94
|
+
let tokenResponse;
|
|
95
|
+
if (definition.requiredAuthMethods?.getToken) {
|
|
96
|
+
tokenResponse = await definition.requiredAuthMethods.getToken(api, {
|
|
97
|
+
code,
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
// Fallback to direct API call
|
|
101
|
+
tokenResponse = await api.getTokenFromCode(code);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(chalk.green('✓ Tokens received'));
|
|
105
|
+
|
|
106
|
+
// 9. Get entity details
|
|
107
|
+
console.log(chalk.gray('Fetching entity details...'));
|
|
108
|
+
|
|
109
|
+
let entityDetails;
|
|
110
|
+
if (definition.requiredAuthMethods?.getEntityDetails) {
|
|
111
|
+
entityDetails =
|
|
112
|
+
await definition.requiredAuthMethods.getEntityDetails(
|
|
113
|
+
api,
|
|
114
|
+
{ code, state: returnedState },
|
|
115
|
+
tokenResponse,
|
|
116
|
+
'cli-test-user'
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
// Minimal entity details if method not provided
|
|
120
|
+
entityDetails = {
|
|
121
|
+
identifiers: { externalId: 'unknown', user: 'cli-test-user' },
|
|
122
|
+
details: { name: 'Unknown' },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(chalk.green('✓ Entity details retrieved'));
|
|
127
|
+
|
|
128
|
+
if (entityDetails?.details?.name) {
|
|
129
|
+
console.log(chalk.gray(` Entity: ${entityDetails.details.name}`));
|
|
130
|
+
}
|
|
131
|
+
if (entityDetails?.identifiers?.externalId) {
|
|
132
|
+
console.log(
|
|
133
|
+
chalk.gray(
|
|
134
|
+
` External ID: ${entityDetails.identifiers.externalId}`
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 10. Collect credential details
|
|
140
|
+
let credentialDetails = {};
|
|
141
|
+
if (definition.requiredAuthMethods?.getCredentialDetails) {
|
|
142
|
+
try {
|
|
143
|
+
credentialDetails =
|
|
144
|
+
await definition.requiredAuthMethods.getCredentialDetails(
|
|
145
|
+
api,
|
|
146
|
+
'cli-test-user'
|
|
147
|
+
);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.log(
|
|
150
|
+
chalk.yellow(
|
|
151
|
+
` Warning: Could not get credential details: ${err.message}`
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 11. Return credentials object
|
|
158
|
+
return {
|
|
159
|
+
tokens: {
|
|
160
|
+
access_token: api.access_token,
|
|
161
|
+
refresh_token: api.refresh_token,
|
|
162
|
+
accessTokenExpire: api.accessTokenExpire,
|
|
163
|
+
refreshTokenExpire: api.refreshTokenExpire,
|
|
164
|
+
},
|
|
165
|
+
entity: entityDetails,
|
|
166
|
+
credential: credentialDetails,
|
|
167
|
+
apiParams: sanitizeApiParams(apiParams),
|
|
168
|
+
tokenResponse: sanitizeTokenResponse(tokenResponse),
|
|
169
|
+
obtainedAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
} finally {
|
|
172
|
+
await server.stop();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sanitizeApiParams(params) {
|
|
177
|
+
// Remove sensitive data that shouldn't be stored
|
|
178
|
+
const sanitized = { ...params };
|
|
179
|
+
delete sanitized.client_secret;
|
|
180
|
+
return sanitized;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sanitizeTokenResponse(response) {
|
|
184
|
+
if (!response) return null;
|
|
185
|
+
// Keep only metadata, not the actual tokens
|
|
186
|
+
return {
|
|
187
|
+
token_type: response.token_type,
|
|
188
|
+
expires_in: response.expires_in,
|
|
189
|
+
scope: response.scope,
|
|
190
|
+
// Include any service-specific metadata (like api_domain for Pipedrive)
|
|
191
|
+
api_domain: response.api_domain,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { runOAuthFlow };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { exec } = require('child_process');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
|
|
4
|
+
async function openBrowser(url) {
|
|
5
|
+
const platform = os.platform();
|
|
6
|
+
let command;
|
|
7
|
+
|
|
8
|
+
switch (platform) {
|
|
9
|
+
case 'darwin':
|
|
10
|
+
command = `open "${url}"`;
|
|
11
|
+
break;
|
|
12
|
+
case 'win32':
|
|
13
|
+
command = `start "" "${url}"`;
|
|
14
|
+
break;
|
|
15
|
+
default:
|
|
16
|
+
command = `xdg-open "${url}"`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
exec(command, (error) => {
|
|
21
|
+
if (error) {
|
|
22
|
+
reject(new Error(`Failed to open browser: ${error.message}`));
|
|
23
|
+
} else {
|
|
24
|
+
resolve();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { openBrowser };
|
package/frigg-cli/index.js
CHANGED
|
@@ -85,6 +85,7 @@ const { uiCommand } = require('./ui-command');
|
|
|
85
85
|
const { dbSetupCommand } = require('./db-setup-command');
|
|
86
86
|
const { doctorCommand } = require('./doctor-command');
|
|
87
87
|
const { repairCommand } = require('./repair-command');
|
|
88
|
+
const { authCommand } = require('./auth-command');
|
|
88
89
|
|
|
89
90
|
const program = new Command();
|
|
90
91
|
|
|
@@ -168,6 +169,40 @@ program
|
|
|
168
169
|
.option('-v, --verbose', 'enable verbose output')
|
|
169
170
|
.action(repairCommand);
|
|
170
171
|
|
|
172
|
+
// Auth command group for testing API module authentication
|
|
173
|
+
const authProgram = program
|
|
174
|
+
.command('auth')
|
|
175
|
+
.description('Test API module authentication');
|
|
176
|
+
|
|
177
|
+
authProgram
|
|
178
|
+
.command('test <module>')
|
|
179
|
+
.description('Test authentication for an API module')
|
|
180
|
+
.option('--api-key <key>', 'API key for API-Key authentication')
|
|
181
|
+
.option('--port <port>', 'Callback server port', '3333')
|
|
182
|
+
.option('--timeout <seconds>', 'OAuth callback timeout', '300')
|
|
183
|
+
.option('-v, --verbose', 'Enable verbose output')
|
|
184
|
+
.action(authCommand.test);
|
|
185
|
+
|
|
186
|
+
authProgram
|
|
187
|
+
.command('list')
|
|
188
|
+
.description('List saved credentials')
|
|
189
|
+
.option('--json', 'Output as JSON')
|
|
190
|
+
.action(authCommand.list);
|
|
191
|
+
|
|
192
|
+
authProgram
|
|
193
|
+
.command('get <module>')
|
|
194
|
+
.description('Get credentials for a module')
|
|
195
|
+
.option('--json', 'Output as JSON')
|
|
196
|
+
.option('--export', 'Export as environment variables')
|
|
197
|
+
.action(authCommand.get);
|
|
198
|
+
|
|
199
|
+
authProgram
|
|
200
|
+
.command('delete [module]')
|
|
201
|
+
.description('Delete saved credentials')
|
|
202
|
+
.option('--all', 'Delete all credentials')
|
|
203
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
204
|
+
.action(authCommand.delete);
|
|
205
|
+
|
|
171
206
|
program.parse(process.argv);
|
|
172
207
|
|
|
173
|
-
module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand };
|
|
208
|
+
module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand, authCommand };
|