@meetopenbot/linear 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.ts DELETED
@@ -1,572 +0,0 @@
1
- /**
2
- * Linear OAuth 2.0 (authorization code + PKCE).
3
- *
4
- * Two callback modes:
5
- * - Loopback: temporary localhost server (local runtimes).
6
- * - Webhook: redirect to `https://<host>/api/webhooks/linear` (cloud/remote).
7
- */
8
-
9
- import { createHash, randomBytes } from "node:crypto";
10
- import { createServer, type Server } from "node:http";
11
- import type { Storage } from "@meetopenbot/plugin-sdk";
12
- import { GO_BACK_TO_OPENBOT_URL } from "./config.js";
13
- import {
14
- clearPendingOAuthSession,
15
- loadPendingOAuthSession,
16
- savePendingOAuthSession,
17
- type PendingOAuthSession,
18
- } from "./oauth-pending.js";
19
-
20
- export const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
21
- export const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
22
- export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
23
- export const OAUTH_WEBHOOK_PROVIDER = "linear";
24
-
25
- const LOOPBACK_CALLBACK_PATH = "/oauth/callback";
26
- const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
27
- const PENDING_SESSION_MS = 10 * 60 * 1000;
28
-
29
- let activeServer: Server | null = null;
30
-
31
- const webhookOAuthCompletions = new Map<
32
- string,
33
- (tokens: OAuthTokens | null) => void
34
- >();
35
-
36
- export interface OAuthTokens {
37
- accessToken: string;
38
- refreshToken?: string;
39
- /** Epoch ms when the access token expires, if Linear reported expiry. */
40
- expiresAt?: number;
41
- scope?: string;
42
- }
43
-
44
- export interface StartOAuthFlowArgs {
45
- clientId: string;
46
- clientSecret?: string;
47
- port: number;
48
- scopes: string;
49
- /** Called once the code has been exchanged successfully. */
50
- onSuccess: (tokens: OAuthTokens) => Promise<void>;
51
- onError?: (error: Error) => void;
52
- /** How long the callback server stays alive, in ms. */
53
- timeoutMs?: number;
54
- }
55
-
56
- export interface OAuthFlowHandle {
57
- authorizeUrl: string;
58
- redirectUri: string;
59
- /** Resolves with tokens on success, null on timeout/cancel. */
60
- completion: Promise<OAuthTokens | null>;
61
- cancel: () => void;
62
- }
63
-
64
- export interface StartWebhookOAuthFlowArgs {
65
- storage: Storage;
66
- clientId: string;
67
- clientSecret?: string;
68
- scopes: string;
69
- webhookBaseUrl: string;
70
- timeoutMs?: number;
71
- }
72
-
73
- function base64url(buffer: Buffer): string {
74
- return buffer
75
- .toString("base64")
76
- .replace(/\+/g, "-")
77
- .replace(/\//g, "_")
78
- .replace(/=+$/, "");
79
- }
80
-
81
- export function oauthHtmlPage(title: string, body: string, ok: boolean): string {
82
- return `<!doctype html>
83
- <html>
84
- <head>
85
- <meta charset="utf-8" />
86
- <meta name="viewport" content="width=device-width, initial-scale=1" />
87
- <title>${title}</title>
88
- <link rel="preconnect" href="https://fonts.googleapis.com" />
89
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
90
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
91
- <style>
92
- body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
93
- .card { text-align: center; padding: 40px 48px; border-radius: 14px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
94
- .icon { height: 28px; margin-bottom: 18px; font-size: 28px; font-weight: 500; line-height: 28px; }
95
- .success { color: #4ade80; }
96
- h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
97
- p { color: #9b9ba3; margin: 0; line-height: 1.5; }
98
- .back-link { margin: 20px 0 0; font-size: 14px; }
99
- .back-link a { color: #ededef; text-decoration: none; }
100
- .back-link a:hover { text-decoration: underline; }
101
- </style>
102
- </head>
103
- <body>
104
- <div class="card">
105
- <div class="icon${ok ? " success" : ""}">${ok ? "✓" : "⚠"}</div>
106
- <h1>${title}</h1>
107
- <p>${body}</p>
108
- <p class="back-link">Go back to <a href="${GO_BACK_TO_OPENBOT_URL}">OpenBot</a></p>
109
- </div>
110
- </body>
111
- </html>`;
112
- }
113
-
114
- export function buildOAuthRedirectUri(webhookBaseUrl: string): string {
115
- const base = webhookBaseUrl.replace(/\/$/, "");
116
- return `${base}/api/webhooks/${OAUTH_WEBHOOK_PROVIDER}`;
117
- }
118
-
119
- function generatePkce() {
120
- const state = base64url(randomBytes(24));
121
- const codeVerifier = base64url(randomBytes(48));
122
- const codeChallenge = base64url(
123
- createHash("sha256").update(codeVerifier).digest(),
124
- );
125
- return { state, codeVerifier, codeChallenge };
126
- }
127
-
128
- export function buildAuthorizeUrl(args: {
129
- clientId: string;
130
- redirectUri: string;
131
- scopes: string;
132
- state: string;
133
- codeChallenge: string;
134
- }): string {
135
- return (
136
- `${LINEAR_AUTHORIZE_URL}?` +
137
- new URLSearchParams({
138
- client_id: args.clientId,
139
- redirect_uri: args.redirectUri,
140
- response_type: "code",
141
- scope: args.scopes,
142
- state: args.state,
143
- prompt: "consent",
144
- code_challenge: args.codeChallenge,
145
- code_challenge_method: "S256",
146
- }).toString()
147
- );
148
- }
149
-
150
- export async function exchangeAuthorizationCode(args: {
151
- code: string;
152
- redirectUri: string;
153
- clientId: string;
154
- clientSecret?: string;
155
- codeVerifier: string;
156
- }): Promise<OAuthTokens> {
157
- const body = new URLSearchParams({
158
- grant_type: "authorization_code",
159
- code: args.code,
160
- redirect_uri: args.redirectUri,
161
- client_id: args.clientId,
162
- code_verifier: args.codeVerifier,
163
- });
164
- if (args.clientSecret) body.set("client_secret", args.clientSecret);
165
-
166
- const response = await fetch(LINEAR_TOKEN_URL, {
167
- method: "POST",
168
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
169
- body,
170
- });
171
- const payload = (await response.json().catch(() => ({}))) as Record<
172
- string,
173
- unknown
174
- >;
175
- if (!response.ok || typeof payload.access_token !== "string") {
176
- const detail =
177
- typeof payload.error_description === "string"
178
- ? payload.error_description
179
- : JSON.stringify(payload);
180
- throw new Error(
181
- `Linear token exchange failed (${response.status}): ${detail}`,
182
- );
183
- }
184
- return normalizeTokenResponse(payload);
185
- }
186
-
187
- export async function refreshAccessToken(args: {
188
- refreshToken: string;
189
- clientId: string;
190
- clientSecret?: string;
191
- }): Promise<OAuthTokens> {
192
- const body = new URLSearchParams({
193
- grant_type: "refresh_token",
194
- refresh_token: args.refreshToken,
195
- client_id: args.clientId,
196
- });
197
- if (args.clientSecret) body.set("client_secret", args.clientSecret);
198
-
199
- const response = await fetch(LINEAR_TOKEN_URL, {
200
- method: "POST",
201
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
202
- body,
203
- });
204
- const payload = (await response.json().catch(() => ({}))) as Record<
205
- string,
206
- unknown
207
- >;
208
- if (!response.ok || typeof payload.access_token !== "string") {
209
- const detail =
210
- typeof payload.error_description === "string"
211
- ? payload.error_description
212
- : JSON.stringify(payload);
213
- throw new Error(
214
- `Linear token refresh failed (${response.status}): ${detail}`,
215
- );
216
- }
217
- return normalizeTokenResponse(payload);
218
- }
219
-
220
- function normalizeTokenResponse(
221
- payload: Record<string, unknown>,
222
- ): OAuthTokens {
223
- return {
224
- accessToken: payload.access_token as string,
225
- refreshToken:
226
- typeof payload.refresh_token === "string"
227
- ? payload.refresh_token
228
- : undefined,
229
- expiresAt:
230
- typeof payload.expires_in === "number"
231
- ? Date.now() + payload.expires_in * 1000
232
- : undefined,
233
- scope: Array.isArray(payload.scope)
234
- ? payload.scope.join(",")
235
- : (payload.scope as string | undefined),
236
- };
237
- }
238
-
239
- function registerWebhookOAuthCompletion(
240
- state: string,
241
- timeoutMs: number,
242
- ): Promise<OAuthTokens | null> {
243
- return new Promise((resolve) => {
244
- const existing = webhookOAuthCompletions.get(state);
245
- if (existing) {
246
- webhookOAuthCompletions.delete(state);
247
- }
248
-
249
- const timeout = setTimeout(() => {
250
- webhookOAuthCompletions.delete(state);
251
- resolve(null);
252
- }, timeoutMs);
253
- timeout.unref?.();
254
-
255
- webhookOAuthCompletions.set(state, (tokens) => {
256
- clearTimeout(timeout);
257
- resolve(tokens);
258
- });
259
- });
260
- }
261
-
262
- function settleWebhookOAuthCompletion(
263
- state: string,
264
- tokens: OAuthTokens | null,
265
- ): void {
266
- const resolve = webhookOAuthCompletions.get(state);
267
- if (!resolve) return;
268
- webhookOAuthCompletions.delete(state);
269
- resolve(tokens);
270
- }
271
-
272
- export async function startWebhookOAuthFlow(
273
- args: StartWebhookOAuthFlowArgs,
274
- ): Promise<OAuthFlowHandle> {
275
- const { state, codeVerifier, codeChallenge } = generatePkce();
276
- const redirectUri = buildOAuthRedirectUri(args.webhookBaseUrl);
277
- const pending: PendingOAuthSession = {
278
- state,
279
- codeVerifier,
280
- clientId: args.clientId,
281
- clientSecret: args.clientSecret,
282
- redirectUri,
283
- expiresAt: Date.now() + PENDING_SESSION_MS,
284
- };
285
-
286
- await savePendingOAuthSession(args.storage, pending);
287
-
288
- const authorizeUrl = buildAuthorizeUrl({
289
- clientId: args.clientId,
290
- redirectUri,
291
- scopes: args.scopes,
292
- state,
293
- codeChallenge,
294
- });
295
-
296
- const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
297
- const completion = registerWebhookOAuthCompletion(state, timeoutMs);
298
-
299
- return {
300
- authorizeUrl,
301
- redirectUri,
302
- completion,
303
- cancel: () => {
304
- settleWebhookOAuthCompletion(state, null);
305
- void clearPendingOAuthSession(args.storage);
306
- },
307
- };
308
- }
309
-
310
- export type WebhookOAuthCallbackResult =
311
- | {
312
- kind: "oauth";
313
- status: number;
314
- html: string;
315
- tokens: OAuthTokens | null;
316
- state?: string;
317
- }
318
- | { kind: "ignore" };
319
-
320
- export async function handleWebhookOAuthCallback(args: {
321
- storage: Storage;
322
- query: Record<string, unknown>;
323
- onSuccess: (tokens: OAuthTokens) => Promise<void>;
324
- }): Promise<WebhookOAuthCallbackResult> {
325
- const code = queryParam(args.query, "code");
326
- const state = queryParam(args.query, "state");
327
- const oauthError = queryParam(args.query, "error");
328
-
329
- if (!code && !state && !oauthError) {
330
- return { kind: "ignore" };
331
- }
332
-
333
- if (oauthError) {
334
- if (state) settleWebhookOAuthCompletion(state, null);
335
- await clearPendingOAuthSession(args.storage);
336
- return {
337
- kind: "oauth",
338
- status: 400,
339
- html: oauthHtmlPage(
340
- "Connection failed",
341
- `Linear returned: ${oauthError}. Return to OpenBot and try again.`,
342
- false,
343
- ),
344
- tokens: null,
345
- state,
346
- };
347
- }
348
-
349
- const pending = await loadPendingOAuthSession(args.storage);
350
- if (!pending || !code || !state || pending.state !== state) {
351
- return {
352
- kind: "oauth",
353
- status: 400,
354
- html: oauthHtmlPage(
355
- "Connection failed",
356
- "Invalid callback (missing code or state mismatch). Return to OpenBot and try again.",
357
- false,
358
- ),
359
- tokens: null,
360
- state,
361
- };
362
- }
363
-
364
- try {
365
- const tokens = await exchangeAuthorizationCode({
366
- code,
367
- redirectUri: pending.redirectUri,
368
- clientId: pending.clientId,
369
- clientSecret: pending.clientSecret,
370
- codeVerifier: pending.codeVerifier,
371
- });
372
- await args.onSuccess(tokens);
373
- await clearPendingOAuthSession(args.storage);
374
- settleWebhookOAuthCompletion(state, tokens);
375
- return {
376
- kind: "oauth",
377
- status: 200,
378
- html: oauthHtmlPage(
379
- "Connected to Linear",
380
- "You can close this tab and return to OpenBot.",
381
- true,
382
- ),
383
- tokens,
384
- state,
385
- };
386
- } catch (error) {
387
- const message = error instanceof Error ? error.message : String(error);
388
- await clearPendingOAuthSession(args.storage);
389
- settleWebhookOAuthCompletion(state, null);
390
- return {
391
- kind: "oauth",
392
- status: 500,
393
- html: oauthHtmlPage(
394
- "Connection failed",
395
- `${message}. Return to OpenBot and try again.`,
396
- false,
397
- ),
398
- tokens: null,
399
- state,
400
- };
401
- }
402
- }
403
-
404
- function queryParam(
405
- query: Record<string, unknown>,
406
- key: string,
407
- ): string | undefined {
408
- const value = query[key];
409
- if (typeof value === "string" && value.trim()) return value.trim();
410
- if (Array.isArray(value) && typeof value[0] === "string") {
411
- return value[0].trim();
412
- }
413
- return undefined;
414
- }
415
-
416
- export function startOAuthFlow(args: StartOAuthFlowArgs): OAuthFlowHandle {
417
- if (activeServer) {
418
- activeServer.close();
419
- activeServer = null;
420
- }
421
-
422
- const { state, codeVerifier, codeChallenge } = generatePkce();
423
- const redirectUri = `http://localhost:${args.port}${LOOPBACK_CALLBACK_PATH}`;
424
- const authorizeUrl = buildAuthorizeUrl({
425
- clientId: args.clientId,
426
- redirectUri,
427
- scopes: args.scopes,
428
- state,
429
- codeChallenge,
430
- });
431
-
432
- let settle: (tokens: OAuthTokens | null) => void;
433
- const completion = new Promise<OAuthTokens | null>((resolve) => {
434
- settle = resolve;
435
- });
436
-
437
- const server = createServer(async (req, res) => {
438
- const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
439
- if (url.pathname !== LOOPBACK_CALLBACK_PATH) {
440
- res.writeHead(404).end();
441
- return;
442
- }
443
-
444
- const finish = (status: number, title: string, body: string, ok: boolean) => {
445
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
446
- res.end(oauthHtmlPage(title, body, ok));
447
- };
448
-
449
- const error = url.searchParams.get("error");
450
- if (error) {
451
- finish(
452
- 400,
453
- "Connection failed",
454
- `Linear returned: ${error}. Return to OpenBot and try again.`,
455
- false,
456
- );
457
- cleanup();
458
- args.onError?.(new Error(`Linear authorization failed: ${error}`));
459
- settle(null);
460
- return;
461
- }
462
-
463
- const code = url.searchParams.get("code");
464
- if (!code || url.searchParams.get("state") !== state) {
465
- finish(
466
- 400,
467
- "Connection failed",
468
- "Invalid callback (missing code or state mismatch). Return to OpenBot and try again.",
469
- false,
470
- );
471
- return;
472
- }
473
-
474
- try {
475
- const tokens = await exchangeAuthorizationCode({
476
- code,
477
- redirectUri,
478
- clientId: args.clientId,
479
- clientSecret: args.clientSecret,
480
- codeVerifier,
481
- });
482
- await args.onSuccess(tokens);
483
- finish(
484
- 200,
485
- "Connected to Linear",
486
- "You can close this tab and return to OpenBot.",
487
- true,
488
- );
489
- cleanup();
490
- settle(tokens);
491
- } catch (error) {
492
- const message = error instanceof Error ? error.message : String(error);
493
- finish(
494
- 500,
495
- "Connection failed",
496
- `${message}. Return to OpenBot and try again.`,
497
- false,
498
- );
499
- cleanup();
500
- args.onError?.(error instanceof Error ? error : new Error(message));
501
- settle(null);
502
- }
503
- });
504
-
505
- const timeout = setTimeout(() => {
506
- cleanup();
507
- settle(null);
508
- }, args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
509
- timeout.unref?.();
510
-
511
- function cleanup() {
512
- clearTimeout(timeout);
513
- if (activeServer === server) activeServer = null;
514
- server.close();
515
- }
516
-
517
- server.on("error", (error) => {
518
- cleanup();
519
- args.onError?.(error);
520
- settle(null);
521
- });
522
-
523
- server.listen(args.port);
524
- activeServer = server;
525
-
526
- return {
527
- authorizeUrl,
528
- redirectUri,
529
- completion,
530
- cancel: () => {
531
- cleanup();
532
- settle(null);
533
- },
534
- };
535
- }
536
-
537
- export async function fetchViewer(accessToken: string) {
538
- const response = await fetch(LINEAR_GRAPHQL_URL, {
539
- method: "POST",
540
- headers: {
541
- "Content-Type": "application/json",
542
- Authorization: `Bearer ${accessToken}`,
543
- },
544
- body: JSON.stringify({
545
- query: `query { viewer { id name displayName email } organization { id name urlKey } }`,
546
- }),
547
- });
548
-
549
- if (!response.ok) {
550
- const body = await response.text().catch(() => "");
551
- throw new Error(
552
- `Linear API request failed (${response.status}): ${body.slice(0, 500)}`,
553
- );
554
- }
555
-
556
- const payload = (await response.json()) as {
557
- data?: {
558
- viewer: { id: string; name: string; displayName: string; email: string };
559
- organization: { id: string; name: string; urlKey: string };
560
- };
561
- errors?: Array<{ message: string }>;
562
- };
563
-
564
- if (payload.errors?.length) {
565
- throw new Error(payload.errors.map((e) => e.message).join("; "));
566
- }
567
- if (!payload.data) {
568
- throw new Error("Linear API returned no data.");
569
- }
570
-
571
- return payload.data;
572
- }