@link-assistant/agent 0.26.15 → 0.27.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.
@@ -0,0 +1,804 @@
1
+ import crypto from 'crypto';
2
+ import * as http from 'node:http';
3
+ import { Auth } from './index';
4
+ import type { AuthPlugin, AuthResult } from './plugins';
5
+ import { Log } from '../util/log';
6
+
7
+ /**
8
+ * Subscription / account logins OpenCode offers for xAI, Poe, DigitalOcean
9
+ * and GitLab
10
+ * (https://github.com/anomalyco/opencode, packages/core/src/plugin/provider),
11
+ * ported to the agent's AuthPlugin shape (#334). Each provider also keeps a
12
+ * plain API-key method.
13
+ */
14
+ export namespace ProviderLogins {
15
+ const log = Log.create({ service: 'provider-logins' });
16
+
17
+ type Fetch = typeof fetch;
18
+ type Sleep = (ms: number) => Promise<void>;
19
+ const sleep: Sleep = (ms) =>
20
+ new Promise((resolve) => setTimeout(resolve, ms));
21
+
22
+ // No authorize(): the CLI falls back to its masked API-key prompt.
23
+ const apiKeyMethod: AuthPlugin['methods'][number] = {
24
+ label: 'Manually enter API Key',
25
+ type: 'api',
26
+ };
27
+
28
+ function positiveSeconds(value: unknown, fallback: number) {
29
+ const seconds = Number(value);
30
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
31
+ }
32
+
33
+ // ---------------------------------------------------------------- xAI ----
34
+
35
+ export const XAI_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
36
+ export const XAI_ISSUER = 'https://auth.x.ai/oauth2';
37
+ const XAI_SCOPE =
38
+ 'openid profile email offline_access grok-cli:access api:access';
39
+ const XAI_DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
40
+ const FORM = {
41
+ 'Content-Type': 'application/x-www-form-urlencoded',
42
+ Accept: 'application/json',
43
+ };
44
+
45
+ export interface XAIToken {
46
+ access_token: string;
47
+ refresh_token?: string;
48
+ expires_in?: number;
49
+ }
50
+
51
+ export interface XAIDevice {
52
+ device_code: string;
53
+ user_code: string;
54
+ verification_uri: string;
55
+ verification_uri_complete?: string;
56
+ expires_in?: number;
57
+ interval?: number;
58
+ }
59
+
60
+ /** Expiry from `expires_in`, else from the JWT `exp` claim, else 1h. */
61
+ export function tokenExpiration(token: XAIToken, now = Date.now()) {
62
+ if (token.expires_in) {
63
+ return now + positiveSeconds(token.expires_in, 3600) * 1000;
64
+ }
65
+ const payload = token.access_token.split('.')[1];
66
+ try {
67
+ const exp = payload
68
+ ? JSON.parse(Buffer.from(payload, 'base64url').toString()).exp
69
+ : undefined;
70
+ if (typeof exp === 'number') return exp * 1000;
71
+ } catch {
72
+ // not a JWT
73
+ }
74
+ return now + 3600 * 1000;
75
+ }
76
+
77
+ /**
78
+ * Poll the device token endpoint until the user approves, following the
79
+ * RFC 8628 `authorization_pending` / `slow_down` replies.
80
+ */
81
+ export async function pollXAIDevice(
82
+ device: XAIDevice,
83
+ options: { fetchImpl?: Fetch; sleep?: Sleep; now?: () => number } = {}
84
+ ): Promise<XAIToken> {
85
+ const fetchImpl = options.fetchImpl ?? fetch;
86
+ const wait = options.sleep ?? sleep;
87
+ const now = options.now ?? Date.now;
88
+ const expires = now() + positiveSeconds(device.expires_in, 300) * 1000;
89
+ let interval = Math.max(positiveSeconds(device.interval, 5) * 1000, 1000);
90
+ const margin = 3000;
91
+ while (now() < expires) {
92
+ const response = await fetchImpl(`${XAI_ISSUER}/token`, {
93
+ method: 'POST',
94
+ headers: FORM,
95
+ body: new URLSearchParams({
96
+ grant_type: XAI_DEVICE_GRANT,
97
+ client_id: XAI_CLIENT_ID,
98
+ device_code: device.device_code,
99
+ }).toString(),
100
+ });
101
+ if (response.ok) return (await response.json()) as XAIToken;
102
+ const body = (await response.json().catch(() => ({}))) as {
103
+ error?: string;
104
+ error_description?: string;
105
+ };
106
+ if (body.error === 'authorization_pending') {
107
+ await wait(interval + margin);
108
+ continue;
109
+ }
110
+ if (body.error === 'slow_down') {
111
+ interval += 5000;
112
+ await wait(interval + margin);
113
+ continue;
114
+ }
115
+ if (
116
+ body.error === 'access_denied' ||
117
+ body.error === 'authorization_denied'
118
+ ) {
119
+ throw new Error('xAI device authorization was denied');
120
+ }
121
+ if (body.error === 'expired_token') {
122
+ throw new Error('xAI device code expired - please re-run login');
123
+ }
124
+ const detail = body.error_description ?? body.error;
125
+ throw new Error(
126
+ `xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ''}`
127
+ );
128
+ }
129
+ throw new Error('xAI device authorization timed out');
130
+ }
131
+
132
+ export async function refreshXAI(
133
+ refresh: string,
134
+ fetchImpl: Fetch = fetch
135
+ ): Promise<Extract<Auth.Info, { type: 'oauth' }>> {
136
+ const response = await fetchImpl(`${XAI_ISSUER}/token`, {
137
+ method: 'POST',
138
+ headers: FORM,
139
+ body: new URLSearchParams({
140
+ grant_type: 'refresh_token',
141
+ refresh_token: refresh,
142
+ client_id: XAI_CLIENT_ID,
143
+ }).toString(),
144
+ });
145
+ if (!response.ok) {
146
+ throw new Error(`xAI token refresh failed (${response.status})`);
147
+ }
148
+ const token = (await response.json()) as XAIToken;
149
+ return {
150
+ type: 'oauth',
151
+ access: token.access_token,
152
+ refresh: token.refresh_token ?? refresh,
153
+ expires: tokenExpiration(token),
154
+ };
155
+ }
156
+
157
+ export const XAIPlugin: AuthPlugin = {
158
+ provider: 'xai',
159
+ methods: [
160
+ {
161
+ label: 'SuperGrok Subscription',
162
+ type: 'oauth',
163
+ async authorize() {
164
+ const response = await fetch(`${XAI_ISSUER}/device/code`, {
165
+ method: 'POST',
166
+ headers: FORM,
167
+ body: new URLSearchParams({
168
+ client_id: XAI_CLIENT_ID,
169
+ scope: XAI_SCOPE,
170
+ referrer: 'link-assistant-agent',
171
+ }).toString(),
172
+ });
173
+ if (!response.ok) {
174
+ log.error(() => ({
175
+ message: 'xai device code request failed',
176
+ status: response.status,
177
+ }));
178
+ return { type: 'failed' as const };
179
+ }
180
+ const device = (await response.json()) as XAIDevice;
181
+ return {
182
+ url: device.verification_uri_complete ?? device.verification_uri,
183
+ instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`,
184
+ method: 'auto' as const,
185
+ async callback(): Promise<AuthResult> {
186
+ try {
187
+ const token = await pollXAIDevice(device);
188
+ if (!token.refresh_token) {
189
+ throw new Error(
190
+ 'xAI token response is missing refresh_token'
191
+ );
192
+ }
193
+ return {
194
+ type: 'success',
195
+ provider: 'xai',
196
+ access: token.access_token,
197
+ refresh: token.refresh_token,
198
+ expires: tokenExpiration(token),
199
+ };
200
+ } catch (error) {
201
+ log.error(() => ({
202
+ message: 'xai login failed',
203
+ error: String(error),
204
+ }));
205
+ return { type: 'failed' };
206
+ }
207
+ },
208
+ };
209
+ },
210
+ },
211
+ apiKeyMethod,
212
+ ],
213
+ async loader(getAuth) {
214
+ let auth = await getAuth();
215
+ if (auth?.type === 'api') return { apiKey: auth.key };
216
+ if (auth?.type !== 'oauth') return {};
217
+ if (auth.expires <= Date.now() + 60_000) {
218
+ auth = await refreshXAI(auth.refresh);
219
+ await Auth.set('xai', auth);
220
+ }
221
+ return { apiKey: auth.access };
222
+ },
223
+ };
224
+
225
+ // ---------------------------------------------------------------- Poe ----
226
+
227
+ export const POE_CLIENT_ID = 'client_728290227fc048cc9262091a1ea197ea';
228
+ export const POE_ISSUER = 'https://poe.com';
229
+ const NO_EXPIRY = 8_640_000_000_000_000;
230
+
231
+ /** Validate the loopback redirect; returns an error message or undefined. */
232
+ export function poeCallbackError(params: URLSearchParams, state: string) {
233
+ if (params.get('state') !== state) return 'Invalid OAuth state';
234
+ if (params.has('iss') && params.get('iss') !== POE_ISSUER) {
235
+ return 'Invalid OAuth issuer';
236
+ }
237
+ if (params.has('error')) {
238
+ const detail =
239
+ params.get('error_description') ||
240
+ params.get('error') ||
241
+ 'Authorization denied';
242
+ return detail.includes(state) ? 'Poe authorization failed' : detail;
243
+ }
244
+ return params.get('code')?.trim()
245
+ ? undefined
246
+ : 'Missing authorization code';
247
+ }
248
+
249
+ /** Exchange the code for a Poe-issued API key (`apikey:create` scope). */
250
+ export async function exchangePoeCode(
251
+ input: { code: string; redirect: string; verifier: string },
252
+ fetchImpl: Fetch = fetch,
253
+ now = Date.now()
254
+ ): Promise<{ key: string; expires: number }> {
255
+ const response = await fetchImpl('https://api.poe.com/token', {
256
+ method: 'POST',
257
+ headers: FORM,
258
+ body: new URLSearchParams({
259
+ grant_type: 'authorization_code',
260
+ client_id: POE_CLIENT_ID,
261
+ code: input.code,
262
+ redirect_uri: input.redirect,
263
+ code_verifier: input.verifier,
264
+ }).toString(),
265
+ });
266
+ if (!response.ok) {
267
+ const body = (await response.json().catch(() => ({}))) as {
268
+ error?: string;
269
+ error_description?: string;
270
+ };
271
+ const detail = body.error_description || body.error;
272
+ const leaks =
273
+ detail && [input.code, input.verifier].some((s) => detail.includes(s));
274
+ throw new Error(
275
+ detail && !leaks
276
+ ? `Poe token exchange failed: ${detail}`
277
+ : `Poe token exchange failed (${response.status})`
278
+ );
279
+ }
280
+ const token = (await response.json()) as {
281
+ api_key?: string;
282
+ api_key_expires_in?: number | null;
283
+ };
284
+ const key = token.api_key?.trim();
285
+ if (!key || /\s/.test(key)) throw new Error('Invalid Poe token response');
286
+ const expires =
287
+ token.api_key_expires_in == null
288
+ ? NO_EXPIRY
289
+ : now + token.api_key_expires_in * 1000;
290
+ return { key, expires };
291
+ }
292
+
293
+ function page(title: string, message: string, script = '') {
294
+ const escape = (s: string) =>
295
+ s.replace(
296
+ /[&<>"']/g,
297
+ (c) =>
298
+ ({
299
+ '&': '&amp;',
300
+ '<': '&lt;',
301
+ '>': '&gt;',
302
+ '"': '&quot;',
303
+ "'": '&#39;',
304
+ })[c]!
305
+ );
306
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escape(title)}</title></head><body><h1 id="h">${escape(title)}</h1><p id="m">${escape(message)}</p>${script ? `<script>${script}</script>` : ''}</body></html>`;
307
+ }
308
+
309
+ async function listen(server: http.Server, port: number, host: string) {
310
+ await new Promise<void>((resolve, reject) => {
311
+ server.once('error', reject);
312
+ server.listen(port, host, () => {
313
+ server.off('error', reject);
314
+ resolve();
315
+ });
316
+ });
317
+ const address = server.address();
318
+ if (!address || typeof address === 'string') {
319
+ throw new Error('Missing OAuth callback port');
320
+ }
321
+ return address.port;
322
+ }
323
+
324
+ export const PoePlugin: AuthPlugin = {
325
+ provider: 'poe',
326
+ methods: [
327
+ {
328
+ label: 'Login with Poe (browser)',
329
+ type: 'oauth',
330
+ async authorize() {
331
+ const verifier = crypto.randomBytes(32).toString('base64url');
332
+ const challenge = crypto
333
+ .createHash('sha256')
334
+ .update(verifier)
335
+ .digest('base64url');
336
+ const state = crypto.randomBytes(32).toString('base64url');
337
+ let settle!: (value: { code: string } | Error) => void;
338
+ const received = new Promise<{ code: string } | Error>(
339
+ (resolve) => (settle = resolve)
340
+ );
341
+ const server = http.createServer((request, response) => {
342
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
343
+ if (request.method !== 'GET' || url.pathname !== '/callback') {
344
+ response.writeHead(404).end();
345
+ return;
346
+ }
347
+ const error = poeCallbackError(url.searchParams, state);
348
+ response
349
+ .writeHead(error ? 400 : 200, { 'Content-Type': 'text/html' })
350
+ .end(
351
+ error
352
+ ? page('Authorization failed', error)
353
+ : page(
354
+ 'Authorization successful',
355
+ 'You can close this window and return to the terminal.'
356
+ )
357
+ );
358
+ settle(
359
+ error
360
+ ? new Error(error)
361
+ : { code: url.searchParams.get('code')!.trim() }
362
+ );
363
+ });
364
+ const port = await listen(server, 0, '127.0.0.1');
365
+ const redirect = `http://127.0.0.1:${port}/callback`;
366
+ return {
367
+ url: `${POE_ISSUER}/oauth/authorize?${new URLSearchParams({
368
+ response_type: 'code',
369
+ client_id: POE_CLIENT_ID,
370
+ redirect_uri: redirect,
371
+ scope: 'apikey:create',
372
+ code_challenge: challenge,
373
+ code_challenge_method: 'S256',
374
+ state,
375
+ })}`,
376
+ instructions:
377
+ 'Complete authorization in your browser. This window will close automatically.',
378
+ method: 'auto' as const,
379
+ async callback(): Promise<AuthResult> {
380
+ try {
381
+ const result = await received;
382
+ if (result instanceof Error) throw result;
383
+ const { key } = await exchangePoeCode({
384
+ code: result.code,
385
+ redirect,
386
+ verifier,
387
+ });
388
+ // Poe returns an API key, stored like a manually entered one.
389
+ return { type: 'success', provider: 'poe', key };
390
+ } catch (error) {
391
+ log.error(() => ({
392
+ message: 'poe login failed',
393
+ error: String(error),
394
+ }));
395
+ return { type: 'failed' };
396
+ } finally {
397
+ server.close();
398
+ server.closeAllConnections();
399
+ }
400
+ },
401
+ };
402
+ },
403
+ },
404
+ apiKeyMethod,
405
+ ],
406
+ };
407
+
408
+ // -------------------------------------------------------- DigitalOcean ----
409
+
410
+ export const DIGITALOCEAN_CLIENT_ID =
411
+ 'b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82';
412
+ const DIGITALOCEAN_AUTHORIZE =
413
+ 'https://cloud.digitalocean.com/v1/oauth/authorize';
414
+ /** The redirect URI registered for the client is fixed to this port. */
415
+ export const DIGITALOCEAN_CALLBACK_PORT = 1456;
416
+ const CALLBACK_PATH = '/auth/callback';
417
+ // Also written literally in the bootstrap page script below.
418
+ const TOKEN_PATH = '/auth/token';
419
+
420
+ /**
421
+ * DigitalOcean uses the implicit grant: the token arrives in the URL
422
+ * fragment, so a small page posts it back to the loopback server.
423
+ */
424
+ export function digitalOceanBootstrapPage() {
425
+ const script = `var p=new URLSearchParams(location.hash.slice(1));var b={};p.forEach(function(v,k){b[k]=v});new URLSearchParams(location.search).forEach(function(v,k){if(!(k in b))b[k]=v});fetch('/auth/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(function(r){return r.json()}).then(function(j){document.getElementById('h').textContent=j.ok?'Authorization successful':'Authorization failed';document.getElementById('m').textContent=j.ok?'You can close this window.':(j.error||'')}).catch(function(e){document.getElementById('m').textContent=String(e)})`;
426
+ return page(
427
+ 'Finishing sign-in',
428
+ 'Completing your DigitalOcean authorization.',
429
+ script
430
+ );
431
+ }
432
+
433
+ /** Parse the posted fragment. Returns the token or an error message. */
434
+ export function parseDigitalOceanToken(
435
+ raw: string,
436
+ state: string
437
+ ): { access: string; expiresIn: number } | { error: string } {
438
+ let body: Record<string, string | undefined> = {};
439
+ try {
440
+ body = JSON.parse(raw);
441
+ } catch {
442
+ // handled below
443
+ }
444
+ const error = body.error_description || body.error;
445
+ if (error) return { error };
446
+ if (!body.access_token) return { error: 'Missing access token' };
447
+ if (body.state !== state) return { error: 'Invalid OAuth state' };
448
+ const expires = Number.parseInt(body.expires_in ?? '0', 10);
449
+ return {
450
+ access: body.access_token,
451
+ expiresIn:
452
+ Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
453
+ };
454
+ }
455
+
456
+ export const DigitalOceanPlugin: AuthPlugin = {
457
+ provider: 'digitalocean',
458
+ methods: [
459
+ {
460
+ label: 'Login with DigitalOcean',
461
+ type: 'oauth',
462
+ async authorize() {
463
+ const state = crypto.randomBytes(32).toString('base64url');
464
+ let settle!: (
465
+ value: { access: string; expiresIn: number } | Error
466
+ ) => void;
467
+ const received = new Promise<
468
+ { access: string; expiresIn: number } | Error
469
+ >((resolve) => (settle = resolve));
470
+ const server = http.createServer((request, response) => {
471
+ const url = new URL(request.url ?? '/', 'http://localhost');
472
+ if (request.method === 'GET' && url.pathname === CALLBACK_PATH) {
473
+ response
474
+ .writeHead(200, { 'Content-Type': 'text/html' })
475
+ .end(digitalOceanBootstrapPage());
476
+ return;
477
+ }
478
+ if (request.method === 'POST' && url.pathname === TOKEN_PATH) {
479
+ const chunks: Buffer[] = [];
480
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
481
+ request.on('end', () => {
482
+ const parsed = parseDigitalOceanToken(
483
+ Buffer.concat(chunks).toString('utf8'),
484
+ state
485
+ );
486
+ const failed = 'error' in parsed;
487
+ response
488
+ .writeHead(failed ? 400 : 200, {
489
+ 'Content-Type': 'application/json',
490
+ })
491
+ .end(
492
+ JSON.stringify(
493
+ failed ? { error: parsed.error } : { ok: true }
494
+ )
495
+ );
496
+ settle(failed ? new Error(parsed.error) : parsed);
497
+ });
498
+ return;
499
+ }
500
+ response.writeHead(404).end('Not found');
501
+ });
502
+ try {
503
+ await listen(server, DIGITALOCEAN_CALLBACK_PORT, 'localhost');
504
+ } catch (error: any) {
505
+ log.error(() => ({
506
+ message:
507
+ error?.code === 'EADDRINUSE'
508
+ ? `DigitalOcean login needs local port ${DIGITALOCEAN_CALLBACK_PORT}, but it is already in use`
509
+ : 'digitalocean callback server failed',
510
+ error: String(error),
511
+ }));
512
+ return { type: 'failed' as const };
513
+ }
514
+ const redirect = `http://localhost:${DIGITALOCEAN_CALLBACK_PORT}${CALLBACK_PATH}`;
515
+ return {
516
+ url: `${DIGITALOCEAN_AUTHORIZE}?${new URLSearchParams({
517
+ response_type: 'token',
518
+ client_id: DIGITALOCEAN_CLIENT_ID,
519
+ redirect_uri: redirect,
520
+ scope: 'genai:read inference:query',
521
+ state,
522
+ })}`,
523
+ instructions:
524
+ 'Complete authorization in your browser. This window will close automatically.',
525
+ method: 'auto' as const,
526
+ async callback(): Promise<AuthResult> {
527
+ try {
528
+ const result = await received;
529
+ if (result instanceof Error) throw result;
530
+ return {
531
+ type: 'success',
532
+ provider: 'digitalocean',
533
+ access: result.access,
534
+ refresh: '',
535
+ expires: Date.now() + result.expiresIn * 1000,
536
+ };
537
+ } catch (error) {
538
+ log.error(() => ({
539
+ message: 'digitalocean login failed',
540
+ error: String(error),
541
+ }));
542
+ return { type: 'failed' };
543
+ } finally {
544
+ server.close();
545
+ server.closeAllConnections();
546
+ }
547
+ },
548
+ };
549
+ },
550
+ },
551
+ apiKeyMethod,
552
+ ],
553
+ async loader(getAuth) {
554
+ const auth = await getAuth();
555
+ if (auth?.type === 'api') return { apiKey: auth.key };
556
+ if (auth?.type !== 'oauth') return {};
557
+ if (auth.expires && auth.expires <= Date.now()) {
558
+ log.warn(() => ({
559
+ message:
560
+ 'DigitalOcean login expired; run `agent auth login digitalocean` again',
561
+ }));
562
+ return {};
563
+ }
564
+ return { apiKey: auth.access };
565
+ },
566
+ };
567
+
568
+ // ------------------------------------------------------------- GitLab ----
569
+
570
+ /**
571
+ * OpenCode's public GitLab OAuth application, registered on gitlab.com with
572
+ * the redirect URI below. Self-managed instances need their own application
573
+ * (`GITLAB_OAUTH_CLIENT_ID`).
574
+ */
575
+ export const GITLAB_CLIENT_ID =
576
+ 'fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7';
577
+ /** GitLab matches redirect_uri exactly, so the port cannot be ephemeral. */
578
+ export const GITLAB_REDIRECT_URI = 'http://127.0.0.1:8080/callback';
579
+ const GITLAB_SCOPE = 'api';
580
+
581
+ export function gitlabInstanceUrl(
582
+ input?: string,
583
+ env: Record<string, string | undefined> = process.env
584
+ ) {
585
+ const url = new URL(
586
+ input?.trim() || env['GITLAB_INSTANCE_URL'] || 'https://gitlab.com'
587
+ );
588
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
589
+ throw new Error('GitLab instance URL must use http(s)');
590
+ }
591
+ // Keep the path so instances under a relative URL root work.
592
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
593
+ }
594
+
595
+ export function gitlabClientID(
596
+ env: Record<string, string | undefined> = process.env
597
+ ) {
598
+ return env['GITLAB_OAUTH_CLIENT_ID']?.trim() || GITLAB_CLIENT_ID;
599
+ }
600
+
601
+ export async function exchangeGitLabToken(
602
+ instanceUrl: string,
603
+ body: Record<string, string>,
604
+ fetchImpl: Fetch = fetch,
605
+ now = Date.now()
606
+ ): Promise<Extract<Auth.Info, { type: 'oauth' }>> {
607
+ const response = await fetchImpl(`${instanceUrl}/oauth/token`, {
608
+ method: 'POST',
609
+ headers: FORM,
610
+ body: new URLSearchParams({
611
+ ...body,
612
+ client_id: gitlabClientID(),
613
+ redirect_uri: GITLAB_REDIRECT_URI,
614
+ }).toString(),
615
+ });
616
+ if (!response.ok) {
617
+ const error = (await response.json().catch(() => ({}))) as {
618
+ error?: string;
619
+ error_description?: string;
620
+ };
621
+ const detail =
622
+ error.error_description || error.error || `HTTP ${response.status}`;
623
+ throw new Error(
624
+ error.error === 'invalid_grant' || error.error === 'invalid_client'
625
+ ? `GitLab token exchange failed: ${detail}. Start a fresh login; a custom GITLAB_OAUTH_CLIENT_ID application must register ${GITLAB_REDIRECT_URI}, grant the \`api\` scope and not be confidential.`
626
+ : `GitLab token exchange failed: ${detail}`
627
+ );
628
+ }
629
+ const token = (await response.json()) as XAIToken;
630
+ if (!token.access_token || !token.refresh_token) {
631
+ throw new Error('Invalid GitLab token response');
632
+ }
633
+ return {
634
+ type: 'oauth',
635
+ access: token.access_token,
636
+ refresh: token.refresh_token,
637
+ expires: now + positiveSeconds(token.expires_in, 7200) * 1000,
638
+ enterpriseUrl: instanceUrl,
639
+ };
640
+ }
641
+
642
+ // GitLab rotates the refresh token on every use: share one refresh.
643
+ const gitlabRefreshing = new Map<
644
+ string,
645
+ Promise<Extract<Auth.Info, { type: 'oauth' }>>
646
+ >();
647
+
648
+ export function refreshGitLab(
649
+ auth: Extract<Auth.Info, { type: 'oauth' }>,
650
+ fetchImpl: Fetch = fetch
651
+ ) {
652
+ const instanceUrl = auth.enterpriseUrl || gitlabInstanceUrl();
653
+ const key = `${instanceUrl}\0${auth.refresh}`;
654
+ let pending = gitlabRefreshing.get(key);
655
+ if (!pending) {
656
+ pending = exchangeGitLabToken(
657
+ instanceUrl,
658
+ { grant_type: 'refresh_token', refresh_token: auth.refresh },
659
+ fetchImpl
660
+ ).finally(() => {
661
+ setTimeout(() => gitlabRefreshing.delete(key), 60_000).unref?.();
662
+ });
663
+ gitlabRefreshing.set(key, pending);
664
+ }
665
+ return pending;
666
+ }
667
+
668
+ export const GitLabPlugin: AuthPlugin = {
669
+ provider: 'gitlab',
670
+ methods: [
671
+ {
672
+ label: 'Login with GitLab (OAuth)',
673
+ type: 'oauth',
674
+ prompts: [
675
+ {
676
+ type: 'text',
677
+ key: 'instanceUrl',
678
+ message: 'GitLab instance URL',
679
+ placeholder: 'https://gitlab.com',
680
+ validate: (value) =>
681
+ !value || /^https?:\/\/\S+$/.test(value.trim())
682
+ ? undefined
683
+ : 'Enter an http(s) URL',
684
+ },
685
+ ],
686
+ async authorize(inputs) {
687
+ const instanceUrl = gitlabInstanceUrl(inputs['instanceUrl']);
688
+ const custom = Boolean(process.env['GITLAB_OAUTH_CLIENT_ID']?.trim());
689
+ if (!custom && new URL(instanceUrl).host !== 'gitlab.com') {
690
+ log.error(() => ({
691
+ message: `The bundled GitLab OAuth application only exists on gitlab.com. Register an OAuth application on ${instanceUrl} with redirect URI ${GITLAB_REDIRECT_URI} and the \`api\` scope (not confidential), then set GITLAB_OAUTH_CLIENT_ID. A personal access token in GITLAB_TOKEN also works.`,
692
+ }));
693
+ return { type: 'failed' as const };
694
+ }
695
+ const verifier = crypto.randomBytes(32).toString('base64url');
696
+ const challenge = crypto
697
+ .createHash('sha256')
698
+ .update(verifier)
699
+ .digest('base64url');
700
+ const state = crypto.randomBytes(32).toString('base64url');
701
+ let settle!: (value: { code: string } | Error) => void;
702
+ const received = new Promise<{ code: string } | Error>(
703
+ (resolve) => (settle = resolve)
704
+ );
705
+ const server = http.createServer((request, response) => {
706
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
707
+ if (request.method !== 'GET' || url.pathname !== '/callback') {
708
+ response.writeHead(404).end();
709
+ return;
710
+ }
711
+ const params = url.searchParams;
712
+ const error =
713
+ params.get('state') !== state
714
+ ? 'Invalid OAuth state'
715
+ : params.get('error_description') ||
716
+ params.get('error') ||
717
+ (params.get('code')
718
+ ? undefined
719
+ : 'Missing authorization code');
720
+ response
721
+ .writeHead(error ? 400 : 200, { 'Content-Type': 'text/html' })
722
+ .end(
723
+ error
724
+ ? page('Authorization failed', error)
725
+ : page(
726
+ 'Authorization successful',
727
+ 'You can close this window and return to the terminal.'
728
+ )
729
+ );
730
+ settle(error ? new Error(error) : { code: params.get('code')! });
731
+ });
732
+ try {
733
+ await listen(server, 8080, '127.0.0.1');
734
+ } catch (error: any) {
735
+ log.error(() => ({
736
+ message:
737
+ error?.code === 'EADDRINUSE'
738
+ ? 'GitLab login needs local port 8080, but it is already in use'
739
+ : 'gitlab callback server failed',
740
+ error: String(error),
741
+ }));
742
+ return { type: 'failed' as const };
743
+ }
744
+ return {
745
+ url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
746
+ client_id: gitlabClientID(),
747
+ redirect_uri: GITLAB_REDIRECT_URI,
748
+ response_type: 'code',
749
+ state,
750
+ scope: GITLAB_SCOPE,
751
+ code_challenge: challenge,
752
+ code_challenge_method: 'S256',
753
+ })}`,
754
+ instructions:
755
+ 'Complete authorization in your browser. This window will close automatically.',
756
+ method: 'auto' as const,
757
+ async callback(): Promise<AuthResult> {
758
+ try {
759
+ const result = await received;
760
+ if (result instanceof Error) throw result;
761
+ const auth = await exchangeGitLabToken(instanceUrl, {
762
+ grant_type: 'authorization_code',
763
+ code: result.code,
764
+ code_verifier: verifier,
765
+ });
766
+ return {
767
+ type: 'success',
768
+ provider: 'gitlab',
769
+ access: auth.access,
770
+ refresh: auth.refresh,
771
+ expires: auth.expires,
772
+ enterpriseUrl: instanceUrl,
773
+ };
774
+ } catch (error) {
775
+ log.error(() => ({
776
+ message: 'gitlab login failed',
777
+ error: String(error),
778
+ }));
779
+ return { type: 'failed' };
780
+ } finally {
781
+ server.close();
782
+ server.closeAllConnections();
783
+ }
784
+ },
785
+ };
786
+ },
787
+ },
788
+ apiKeyMethod,
789
+ ],
790
+ async loader(getAuth) {
791
+ let auth = await getAuth();
792
+ if (auth?.type === 'api') return { apiKey: auth.key };
793
+ if (auth?.type !== 'oauth') return {};
794
+ if (auth.expires <= Date.now() + 60_000) {
795
+ auth = await refreshGitLab(auth);
796
+ await Auth.set('gitlab', auth);
797
+ }
798
+ return {
799
+ apiKey: auth.access,
800
+ options: { instanceUrl: auth.enterpriseUrl || gitlabInstanceUrl() },
801
+ };
802
+ },
803
+ };
804
+ }