berget 2.1.1 → 2.2.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.
@@ -3,11 +3,39 @@ import {
3
3
  saveAuthToken,
4
4
  clearAuthToken,
5
5
  apiClient,
6
+ API_BASE_URL,
6
7
  } from '../client'
7
8
  // We'll use dynamic import for 'open' to support ESM modules in CommonJS
8
9
  import chalk from 'chalk'
9
10
  import { handleError } from '../utils/error-handler'
10
11
  import { COMMAND_GROUPS, SUBCOMMANDS } from '../constants/command-structure'
12
+ import * as http from 'http'
13
+ import * as crypto from 'crypto'
14
+ import * as url from 'url'
15
+
16
+ // Keycloak configuration based on environment
17
+ const isStageMode = process.argv.includes('--stage')
18
+ const isLocalMode = process.argv.includes('--local')
19
+ const KEYCLOAK_URL = (isStageMode || isLocalMode)
20
+ ? 'https://keycloak.stage.berget.ai'
21
+ : 'https://keycloak.berget.ai'
22
+ const KEYCLOAK_REALM = 'berget'
23
+ const KEYCLOAK_CLIENT_ID = 'berget-code'
24
+ const CALLBACK_PORT = 8787
25
+
26
+ /**
27
+ * Generate a random string for PKCE code_verifier
28
+ */
29
+ function generateCodeVerifier(): string {
30
+ return crypto.randomBytes(32).toString('base64url')
31
+ }
32
+
33
+ /**
34
+ * Generate code_challenge from code_verifier using S256 method
35
+ */
36
+ function generateCodeChallenge(verifier: string): string {
37
+ return crypto.createHash('sha256').update(verifier).digest('base64url')
38
+ }
11
39
 
12
40
  /**
13
41
  * Service for authentication operations
@@ -34,7 +62,9 @@ export class AuthService {
34
62
 
35
63
  public async whoami(): Promise<any> {
36
64
  try {
37
- const { data: profile, error } = await this.client.GET('/v1/users/me')
65
+ // Create fresh client to ensure we have the latest token
66
+ const client = createAuthenticatedClient()
67
+ const { data: profile, error } = await client.GET('/v1/users/me')
38
68
  if (error) {
39
69
  return null
40
70
  }
@@ -51,209 +81,314 @@ export class AuthService {
51
81
 
52
82
  console.log(chalk.blue('Initiating login process...'))
53
83
 
54
- // Step 1: Initiate device authorization
55
- const { data: deviceData, error: deviceError } = await apiClient.POST(
56
- '/v1/auth/device',
57
- {},
58
- )
84
+ // Generate PKCE code verifier and challenge
85
+ const codeVerifier = generateCodeVerifier()
86
+ const codeChallenge = generateCodeChallenge(codeVerifier)
87
+ const state = crypto.randomBytes(16).toString('hex')
59
88
 
60
- if (deviceError || !deviceData) {
61
- throw new Error(
62
- deviceError
63
- ? JSON.stringify(deviceError)
64
- : 'Failed to get device authorization data',
65
- )
66
- }
89
+ const redirectUri = `http://localhost:${CALLBACK_PORT}/callback`
67
90
 
68
- // Type assertion for deviceData
69
- const typedDeviceData = deviceData as {
70
- verification_url?: string
71
- user_code?: string
72
- device_code?: string
73
- expires_in?: number
74
- interval?: number
75
- }
76
-
77
- // Display information to user
78
- console.log(chalk.cyan('\nTo complete login:'))
79
- console.log(
80
- chalk.cyan(
81
- `1. Open this URL: ${chalk.bold(
82
- typedDeviceData.verification_url ||
83
- 'https://keycloak.berget.ai/device',
84
- )}`,
85
- ),
91
+ // Build authorization URL
92
+ const authUrl = new URL(
93
+ `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`,
86
94
  )
87
- if (!typedDeviceData.verification_url)
88
- console.log(
89
- chalk.cyan(
90
- `2. Enter this code: ${chalk.bold(
91
- typedDeviceData.user_code || '',
92
- )}\n`,
93
- ),
94
- )
95
+ authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID)
96
+ authUrl.searchParams.set('response_type', 'code')
97
+ authUrl.searchParams.set('redirect_uri', redirectUri)
98
+ authUrl.searchParams.set('scope', 'openid email profile')
99
+ authUrl.searchParams.set('state', state)
100
+ authUrl.searchParams.set('code_challenge', codeChallenge)
101
+ authUrl.searchParams.set('code_challenge_method', 'S256')
95
102
 
96
- // Try to open browser automatically
97
- try {
98
- if (typedDeviceData.verification_url) {
99
- // Use dynamic import for the 'open' package
100
- const open = await import('open').then((m) => m.default)
101
- await open(typedDeviceData.verification_url)
102
- console.log(
103
- chalk.dim(
104
- "Browser opened automatically. If it didn't open, please use the URL above.",
105
- ),
106
- )
107
- }
108
- } catch (error) {
109
- console.log(
110
- chalk.yellow(
111
- 'Could not open browser automatically. Please open the URL manually.',
112
- ),
113
- )
114
- }
103
+ // Create a promise that resolves when we receive the callback
104
+ const authResult = await new Promise<{
105
+ success: boolean
106
+ code?: string
107
+ error?: string
108
+ }>((resolve) => {
109
+ const server = http.createServer(async (req, res) => {
110
+ const parsedUrl = url.parse(req.url || '', true)
115
111
 
116
- console.log(chalk.dim('\nWaiting for authentication to complete...'))
112
+ if (parsedUrl.pathname === '/callback') {
113
+ const receivedState = parsedUrl.query.state as string
114
+ const code = parsedUrl.query.code as string
115
+ const error = parsedUrl.query.error as string
117
116
 
118
- // Step 2: Poll for completion
119
- const startTime = Date.now()
120
- const expiresIn =
121
- typedDeviceData.expires_in !== undefined
122
- ? typedDeviceData.expires_in
123
- : 900
124
- const expiresAt = startTime + expiresIn * 1000
125
- let pollInterval = (typedDeviceData.interval || 5) * 1000
117
+ const errorPage = (title: string, message: string) => `
118
+ <!DOCTYPE html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="UTF-8">
122
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
123
+ <title>Berget - Authentication Failed</title>
124
+ <style>
125
+ * { margin: 0; padding: 0; box-sizing: border-box; }
126
+ body {
127
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
128
+ display: flex;
129
+ justify-content: center;
130
+ align-items: center;
131
+ min-height: 100vh;
132
+ background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
133
+ color: #fff;
134
+ }
135
+ .container {
136
+ text-align: center;
137
+ padding: 3rem;
138
+ max-width: 400px;
139
+ }
140
+ .icon {
141
+ width: 80px;
142
+ height: 80px;
143
+ background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
144
+ border-radius: 50%;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ margin: 0 auto 1.5rem;
149
+ box-shadow: 0 4px 20px rgba(248, 113, 113, 0.3);
150
+ }
151
+ .icon svg {
152
+ width: 40px;
153
+ height: 40px;
154
+ stroke: #fff;
155
+ stroke-width: 3;
156
+ }
157
+ h1 {
158
+ font-size: 1.5rem;
159
+ font-weight: 600;
160
+ margin-bottom: 0.75rem;
161
+ color: #fff;
162
+ }
163
+ p {
164
+ color: #94a3b8;
165
+ font-size: 0.95rem;
166
+ line-height: 1.5;
167
+ }
168
+ .brand {
169
+ margin-top: 2rem;
170
+ opacity: 0.5;
171
+ font-size: 0.8rem;
172
+ letter-spacing: 0.05em;
173
+ }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <div class="container">
178
+ <div class="icon">
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
180
+ <line x1="18" y1="6" x2="6" y2="18"></line>
181
+ <line x1="6" y1="6" x2="18" y2="18"></line>
182
+ </svg>
183
+ </div>
184
+ <h1>${title}</h1>
185
+ <p>${message}</p>
186
+ <div class="brand">BERGET</div>
187
+ </div>
188
+ </body>
189
+ </html>
190
+ `
126
191
 
127
- const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
128
- let spinnerIdx = 0
192
+ if (error) {
193
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
194
+ res.end(errorPage('Authentication Failed', String(parsedUrl.query.error_description || error)))
195
+ server.close()
196
+ resolve({ success: false, error })
197
+ return
198
+ }
129
199
 
130
- while (Date.now() < expiresAt) {
131
- // Wait for the polling interval
132
- await new Promise((resolve) => setTimeout(resolve, pollInterval))
200
+ if (receivedState !== state) {
201
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
202
+ res.end(errorPage('Authentication Failed', 'Invalid state parameter. Please try again.'))
203
+ server.close()
204
+ resolve({ success: false, error: 'Invalid state parameter' })
205
+ return
206
+ }
133
207
 
134
- // Update spinner
135
- process.stdout.write(
136
- `\r${chalk.blue(spinner[spinnerIdx])} Waiting for authentication...`,
137
- )
138
- spinnerIdx = (spinnerIdx + 1) % spinner.length
139
-
140
- // Check if authentication is complete
141
- const deviceCode = typedDeviceData.device_code || ''
142
- const { data: tokenData, error: tokenError } = await apiClient.POST(
143
- '/v1/auth/device/token',
144
- {
145
- body: {
146
- device_code: deviceCode,
147
- },
148
- },
149
- )
208
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
209
+ res.end(`
210
+ <!DOCTYPE html>
211
+ <html lang="en">
212
+ <head>
213
+ <meta charset="UTF-8">
214
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
215
+ <title>Berget - Authentication Successful</title>
216
+ <style>
217
+ * { margin: 0; padding: 0; box-sizing: border-box; }
218
+ body {
219
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
220
+ display: flex;
221
+ justify-content: center;
222
+ align-items: center;
223
+ min-height: 100vh;
224
+ background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
225
+ color: #fff;
226
+ }
227
+ .container {
228
+ text-align: center;
229
+ padding: 3rem;
230
+ max-width: 400px;
231
+ }
232
+ .icon {
233
+ width: 80px;
234
+ height: 80px;
235
+ background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
236
+ border-radius: 50%;
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ margin: 0 auto 1.5rem;
241
+ box-shadow: 0 4px 20px rgba(74, 222, 128, 0.3);
242
+ }
243
+ .icon svg {
244
+ width: 40px;
245
+ height: 40px;
246
+ stroke: #fff;
247
+ stroke-width: 3;
248
+ }
249
+ h1 {
250
+ font-size: 1.5rem;
251
+ font-weight: 600;
252
+ margin-bottom: 0.75rem;
253
+ color: #fff;
254
+ }
255
+ p {
256
+ color: #94a3b8;
257
+ font-size: 0.95rem;
258
+ line-height: 1.5;
259
+ }
260
+ .brand {
261
+ margin-top: 2rem;
262
+ opacity: 0.5;
263
+ font-size: 0.8rem;
264
+ letter-spacing: 0.05em;
265
+ }
266
+ </style>
267
+ </head>
268
+ <body>
269
+ <div class="container">
270
+ <div class="icon">
271
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
272
+ <polyline points="20 6 9 17 4 12"></polyline>
273
+ </svg>
274
+ </div>
275
+ <h1>Authentication Successful</h1>
276
+ <p>You can close this window and return to your terminal.</p>
277
+ <div class="brand">BERGET</div>
278
+ </div>
279
+ </body>
280
+ </html>
281
+ `)
282
+ server.close()
283
+ resolve({ success: true, code })
284
+ }
285
+ })
150
286
 
151
- if (tokenError) {
152
- // Parse the error to get status and other details
153
- const errorObj =
154
- typeof tokenError === 'string' ? JSON.parse(tokenError) : tokenError
155
-
156
- const status = errorObj.status || 0
157
- const errorCode = errorObj.code || ''
158
-
159
- if (status === 401 || errorCode === 'AUTHORIZATION_PENDING') {
160
- // Still waiting for user to complete authorization
161
- continue
162
- } else if (status === 429) {
163
- // Slow down
164
- pollInterval *= 2
165
- continue
166
- } else if (status === 400) {
167
- // Error or expired
168
- if (errorCode === 'EXPIRED_TOKEN') {
169
- console.log(
170
- chalk.red('\n\nAuthentication timed out. Please try again.'),
171
- )
172
- } else if (errorCode !== 'AUTHORIZATION_PENDING') {
173
- // Only show error if it's not the expected "still waiting" error
174
- const errorMessage = errorObj.message || JSON.stringify(errorObj)
175
- console.log(chalk.red(`\n\nError: ${errorMessage}`))
176
- return false
177
- } else {
178
- // If it's AUTHORIZATION_PENDING, continue polling
179
- continue
180
- }
181
- return false
182
- } else {
183
- // For any other error, log it but continue polling
184
- // This makes the flow more resilient to temporary issues
185
- if (process.env.DEBUG) {
186
- console.log(
187
- chalk.yellow(`\n\nReceived error: ${JSON.stringify(errorObj)}`),
188
- )
189
- console.log(
190
- chalk.yellow('Continuing to wait for authentication...'),
191
- )
192
- process.stdout.write(
193
- `\r${chalk.blue(
194
- spinner[spinnerIdx],
195
- )} Waiting for authentication...`,
196
- )
197
- }
198
- continue
287
+ server.listen(CALLBACK_PORT, () => {
288
+ if (process.argv.includes('--debug')) {
289
+ console.log(
290
+ chalk.dim(`Callback server listening on port ${CALLBACK_PORT}`),
291
+ )
199
292
  }
200
- } else if (tokenData) {
201
- // Type assertion for tokenData
202
- const typedTokenData = tokenData as {
203
- token?: string
204
- refresh_token?: string
205
- expires_in?: number
206
- refresh_expires_in?: number
207
- user?: {
208
- id?: string
209
- email?: string
210
- name?: string
211
- }
293
+ })
294
+
295
+ // Set timeout for the server
296
+ setTimeout(() => {
297
+ server.close()
298
+ resolve({ success: false, error: 'Authentication timed out' })
299
+ }, 5 * 60 * 1000) // 5 minute timeout
300
+
301
+ // Open browser
302
+ ;(async () => {
303
+ try {
304
+ const open = await import('open').then((m) => m.default)
305
+ await open(authUrl.toString())
306
+ console.log(chalk.dim('Browser opened for authentication...'))
307
+ } catch {
308
+ console.log(chalk.cyan('\nPlease open this URL in your browser:'))
309
+ console.log(chalk.bold(authUrl.toString()))
212
310
  }
311
+ })()
312
+ })
213
313
 
214
- if (typedTokenData.token) {
215
- // Success!
216
- saveAuthToken(
217
- typedTokenData.token,
218
- typedTokenData.refresh_token || '',
219
- typedTokenData.expires_in || 3600,
220
- )
314
+ if (!authResult.success || !authResult.code) {
315
+ console.log(
316
+ chalk.red(`\nAuthentication failed: ${authResult.error || 'Unknown error'}`),
317
+ )
318
+ return false
319
+ }
221
320
 
222
- if (process.argv.includes('--debug')) {
223
- console.log(chalk.yellow('DEBUG: Token data received:'))
224
- console.log(
225
- chalk.yellow(
226
- JSON.stringify(
227
- {
228
- expires_in: typedTokenData.expires_in,
229
- refresh_expires_in: typedTokenData.refresh_expires_in,
230
- },
231
- null,
232
- 2,
233
- ),
234
- ),
235
- )
236
- }
321
+ // Exchange authorization code for tokens
322
+ console.log(chalk.dim('Exchanging authorization code for tokens...'))
237
323
 
238
- process.stdout.write('\r' + ' '.repeat(50) + '\r') // Clear the spinner line
239
- console.log(chalk.green('✓ Successfully logged in to Berget'))
324
+ const tokenUrl = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`
325
+ const tokenResponse = await fetch(tokenUrl, {
326
+ method: 'POST',
327
+ headers: {
328
+ 'Content-Type': 'application/x-www-form-urlencoded',
329
+ },
330
+ body: new URLSearchParams({
331
+ grant_type: 'authorization_code',
332
+ client_id: KEYCLOAK_CLIENT_ID,
333
+ code: authResult.code,
334
+ redirect_uri: redirectUri,
335
+ code_verifier: codeVerifier,
336
+ }).toString(),
337
+ })
240
338
 
241
- if (typedTokenData.user) {
242
- const user = typedTokenData.user
243
- console.log(
244
- chalk.green(
245
- `Logged in as ${user.name || user.email || 'User'}`,
246
- ),
247
- )
248
- }
339
+ if (!tokenResponse.ok) {
340
+ const errorText = await tokenResponse.text()
341
+ console.log(chalk.red(`\nFailed to exchange code for tokens: ${errorText}`))
342
+ return false
343
+ }
249
344
 
250
- return true
251
- }
345
+ const tokenData = (await tokenResponse.json()) as {
346
+ access_token: string
347
+ refresh_token: string
348
+ expires_in: number
349
+ refresh_expires_in?: number
350
+ }
351
+
352
+ // Save tokens
353
+ saveAuthToken(
354
+ tokenData.access_token,
355
+ tokenData.refresh_token,
356
+ tokenData.expires_in,
357
+ )
358
+
359
+ if (process.argv.includes('--debug')) {
360
+ console.log(chalk.yellow('DEBUG: Token data received:'))
361
+ console.log(
362
+ chalk.yellow(
363
+ JSON.stringify(
364
+ {
365
+ expires_in: tokenData.expires_in,
366
+ refresh_expires_in: tokenData.refresh_expires_in,
367
+ },
368
+ null,
369
+ 2,
370
+ ),
371
+ ),
372
+ )
373
+ }
374
+
375
+ console.log(chalk.green('\n✓ Successfully logged in to Berget'))
376
+
377
+ // Try to get user info
378
+ try {
379
+ const profile = await this.whoami()
380
+ if (profile?.email) {
381
+ console.log(chalk.green(`Logged in as ${profile.name || profile.email}`))
252
382
  }
383
+ } catch {
384
+ // Ignore errors fetching profile
253
385
  }
254
386
 
255
- console.log(chalk.red('\n\nAuthentication timed out. Please try again.'))
256
- return false
387
+ console.log(chalk.cyan('\nNext steps:'))
388
+ console.log(chalk.cyan(' • Create an API key: berget api-keys create'))
389
+ console.log(chalk.cyan(' • Setup OpenCode: berget code init'))
390
+
391
+ return true
257
392
  } catch (error) {
258
393
  handleError('Login failed', error)
259
394
  return false
@@ -93,21 +93,27 @@ export class TokenManager {
93
93
 
94
94
  /**
95
95
  * Check if the access token is expired
96
- * @returns true if expired or about to expire (within 5 minutes), false otherwise
96
+ * @returns true if expired or about to expire (within 10% of lifetime or 30 seconds), false otherwise
97
97
  */
98
98
  public isTokenExpired(): boolean {
99
99
  if (!this.tokenData || !this.tokenData.expires_at) return true
100
100
 
101
101
  try {
102
- // Consider token expired if it's within 10 minutes of expiration
103
- // Using a larger buffer to be more proactive about refreshing
104
- const expirationBuffer = 10 * 60 * 1000 // 10 minutes in milliseconds
105
- const isExpired =
106
- Date.now() + expirationBuffer >= this.tokenData.expires_at
102
+ const now = Date.now()
103
+ const expiresAt = this.tokenData.expires_at
104
+ const timeUntilExpiry = expiresAt - now
105
+
106
+ // Use 10% of remaining lifetime or 30 seconds, whichever is smaller
107
+ // This ensures we don't refresh tokens that were just issued
108
+ const minBuffer = 30 * 1000 // 30 seconds minimum
109
+ const percentBuffer = timeUntilExpiry * 0.1 // 10% of lifetime
110
+ const expirationBuffer = Math.min(minBuffer, percentBuffer)
111
+
112
+ const isExpired = now + expirationBuffer >= expiresAt
107
113
 
108
114
  if (isExpired) {
109
115
  logger.debug(
110
- `Token expired or expiring soon. Current time: ${new Date().toISOString()}, Expiry: ${new Date(this.tokenData.expires_at).toISOString()}`,
116
+ `Token expired or expiring soon. Current time: ${new Date().toISOString()}, Expiry: ${new Date(expiresAt).toISOString()}`,
111
117
  )
112
118
  }
113
119