linear-sdk-cli 0.2.4 → 0.3.1
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/CHANGELOG.md +36 -16
- package/MIGRATING.md +7 -6
- package/README.md +120 -31
- package/package.json +5 -3
- package/skills/linear-sdk-cli/SKILL.md +43 -8
- package/skills/linear-sdk-cli/references/api.md +1 -1
- package/skills/linear-sdk-cli/references/attachment.md +1 -1
- package/skills/linear-sdk-cli/references/auth.md +26 -7
- package/skills/linear-sdk-cli/references/commands.md +1 -1
- package/skills/linear-sdk-cli/references/comment.md +1 -1
- package/skills/linear-sdk-cli/references/completion.md +1 -1
- package/skills/linear-sdk-cli/references/config.md +5 -1
- package/skills/linear-sdk-cli/references/cycle.md +1 -1
- package/skills/linear-sdk-cli/references/document.md +1 -1
- package/skills/linear-sdk-cli/references/favorite.md +1 -1
- package/skills/linear-sdk-cli/references/initiative-update.md +1 -1
- package/skills/linear-sdk-cli/references/initiative.md +1 -1
- package/skills/linear-sdk-cli/references/issue.md +1 -1
- package/skills/linear-sdk-cli/references/label.md +1 -1
- package/skills/linear-sdk-cli/references/milestone.md +1 -1
- package/skills/linear-sdk-cli/references/notification.md +1 -1
- package/skills/linear-sdk-cli/references/open.md +1 -1
- package/skills/linear-sdk-cli/references/organization.md +1 -1
- package/skills/linear-sdk-cli/references/project-update.md +1 -1
- package/skills/linear-sdk-cli/references/project.md +1 -1
- package/skills/linear-sdk-cli/references/roadmap.md +1 -1
- package/skills/linear-sdk-cli/references/schema.md +1 -1
- package/skills/linear-sdk-cli/references/state.md +1 -1
- package/skills/linear-sdk-cli/references/team.md +1 -1
- package/skills/linear-sdk-cli/references/user.md +1 -1
- package/skills/linear-sdk-cli/references/webhook.md +1 -1
- package/skills/linear-sdk-cli/references/whoami.md +1 -1
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +3 -1
- package/src/client.ts +57 -2
- package/src/commands/meta.ts +240 -65
- package/src/config.ts +319 -16
- package/src/context.ts +2 -0
- package/src/index.ts +19 -0
- package/src/lib/introspect.ts +1 -0
- package/src/lib/options.ts +1 -0
- package/src/lib/output-shapes.ts +21 -2
- package/src/oauth.ts +582 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { CliError, type ErrorCode } from "./lib/errors.js";
|
|
4
|
+
|
|
5
|
+
export const LINEAR_AUTHORIZE_ENDPOINT = "https://linear.app/oauth/authorize";
|
|
6
|
+
export const LINEAR_TOKEN_ENDPOINT = "https://api.linear.app/oauth/token";
|
|
7
|
+
export const LINEAR_REVOKE_ENDPOINT = "https://api.linear.app/oauth/revoke";
|
|
8
|
+
export const DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000;
|
|
9
|
+
export const DEFAULT_OAUTH_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
10
|
+
export const DEFAULT_OAUTH_SCOPES = ["read", "write"] as const;
|
|
11
|
+
|
|
12
|
+
type OAuthFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
export interface ClientCredentialsTokenProviderOptions {
|
|
15
|
+
clientId: string;
|
|
16
|
+
clientSecret: string;
|
|
17
|
+
scopes: readonly string[];
|
|
18
|
+
refreshSkewMs?: number;
|
|
19
|
+
tokenEndpoint?: string;
|
|
20
|
+
fetch?: OAuthFetch;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OAuthAccessToken {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
expiresIn: number;
|
|
28
|
+
scope: string;
|
|
29
|
+
tokenType: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OAuthWorkspaceIdentity {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
urlKey: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OAuthUserIdentity {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
email: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Entire human OAuth session. It is serialized only into the OS keyring. */
|
|
45
|
+
export interface OAuthUserCredential {
|
|
46
|
+
version: 1;
|
|
47
|
+
kind: "oauth-user";
|
|
48
|
+
actor: "user";
|
|
49
|
+
accessToken: string;
|
|
50
|
+
refreshToken: string;
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
scopes: string[];
|
|
53
|
+
tokenType: "Bearer";
|
|
54
|
+
clientId: string;
|
|
55
|
+
workspace: OAuthWorkspaceIdentity;
|
|
56
|
+
user: OAuthUserIdentity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface GetAccessTokenOptions {
|
|
60
|
+
forceRefresh?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A stable, deliberately non-secret OAuth failure. */
|
|
64
|
+
export class OAuthTokenError extends CliError {
|
|
65
|
+
readonly status?: number;
|
|
66
|
+
|
|
67
|
+
constructor(message: string, status?: number, code: ErrorCode = "auth") {
|
|
68
|
+
super(message, code);
|
|
69
|
+
this.name = "OAuthTokenError";
|
|
70
|
+
this.status = status;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function base64url(value: Uint8Array): string {
|
|
75
|
+
return Buffer.from(value).toString("base64url");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PkceRequest {
|
|
79
|
+
verifier: string;
|
|
80
|
+
challenge: string;
|
|
81
|
+
state: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** RFC 7636 verifier/challenge plus an independent CSRF nonce. */
|
|
85
|
+
export function createPkceRequest(): PkceRequest {
|
|
86
|
+
const verifier = base64url(randomBytes(32));
|
|
87
|
+
return {
|
|
88
|
+
verifier,
|
|
89
|
+
challenge: base64url(createHash("sha256").update(verifier).digest()),
|
|
90
|
+
state: base64url(randomBytes(32)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildAuthorizationUrl(options: {
|
|
95
|
+
clientId: string;
|
|
96
|
+
redirectUri: string;
|
|
97
|
+
scopes: readonly string[];
|
|
98
|
+
challenge: string;
|
|
99
|
+
state: string;
|
|
100
|
+
}): string {
|
|
101
|
+
if (!options.clientId.trim()) {
|
|
102
|
+
throw new OAuthTokenError(
|
|
103
|
+
"Browser OAuth is not configured. Set LINEAR_OAUTH_CLIENT_ID to a Linear OAuth application client ID.",
|
|
104
|
+
undefined,
|
|
105
|
+
"usage",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const url = new URL(LINEAR_AUTHORIZE_ENDPOINT);
|
|
109
|
+
url.search = new URLSearchParams({
|
|
110
|
+
client_id: options.clientId,
|
|
111
|
+
redirect_uri: validateLoopbackRedirectUri(options.redirectUri).toString(),
|
|
112
|
+
response_type: "code",
|
|
113
|
+
scope: [...new Set(options.scopes)].join(","),
|
|
114
|
+
actor: "user",
|
|
115
|
+
state: options.state,
|
|
116
|
+
code_challenge: options.challenge,
|
|
117
|
+
code_challenge_method: "S256",
|
|
118
|
+
prompt: "consent",
|
|
119
|
+
}).toString();
|
|
120
|
+
return url.toString();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateLoopbackRedirectUri(value: string): URL {
|
|
124
|
+
let url: URL;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL(value);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new OAuthTokenError("OAuth redirect URI is invalid.", undefined, "usage");
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
url.protocol !== "http:" ||
|
|
132
|
+
(url.hostname !== "127.0.0.1" && url.hostname !== "[::1]" && url.hostname !== "::1") ||
|
|
133
|
+
!url.port ||
|
|
134
|
+
url.username ||
|
|
135
|
+
url.password ||
|
|
136
|
+
url.search ||
|
|
137
|
+
url.hash
|
|
138
|
+
) {
|
|
139
|
+
throw new OAuthTokenError(
|
|
140
|
+
"OAuth redirect URI must be an HTTP loopback URL with an explicit port and no query or fragment.",
|
|
141
|
+
undefined,
|
|
142
|
+
"usage",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return url;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface LoopbackCallback {
|
|
149
|
+
redirectUri: string;
|
|
150
|
+
wait: Promise<string>;
|
|
151
|
+
close(): Promise<void>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function safeStateEqual(expected: string, received: string): boolean {
|
|
155
|
+
const a = Buffer.from(expected);
|
|
156
|
+
const b = Buffer.from(received);
|
|
157
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function callbackPage(ok: boolean): string {
|
|
161
|
+
const title = ok ? "Linear authentication complete" : "Linear authentication failed";
|
|
162
|
+
const body = ok
|
|
163
|
+
? "You can close this window and return to the terminal."
|
|
164
|
+
: "Return to the terminal for a safe error message, then retry.";
|
|
165
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title><h1>${title}</h1><p>${body}</p>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bind the registered loopback URI before opening the browser and await one valid callback. */
|
|
169
|
+
export async function startLoopbackCallback(options: {
|
|
170
|
+
redirectUri: string;
|
|
171
|
+
state: string;
|
|
172
|
+
timeoutMs?: number;
|
|
173
|
+
}): Promise<LoopbackCallback> {
|
|
174
|
+
const url = validateLoopbackRedirectUri(options.redirectUri);
|
|
175
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_OAUTH_CALLBACK_TIMEOUT_MS;
|
|
176
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
177
|
+
throw new OAuthTokenError(
|
|
178
|
+
"OAuth callback timeout must be greater than zero.",
|
|
179
|
+
undefined,
|
|
180
|
+
"usage",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let settled = false;
|
|
185
|
+
let resolveWait!: (code: string) => void;
|
|
186
|
+
let rejectWait!: (error: Error) => void;
|
|
187
|
+
const wait = new Promise<string>((resolve, reject) => {
|
|
188
|
+
resolveWait = resolve;
|
|
189
|
+
rejectWait = reject;
|
|
190
|
+
});
|
|
191
|
+
const settle = (error?: Error, code?: string): void => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
if (timer) clearTimeout(timer);
|
|
195
|
+
if (error) rejectWait(error);
|
|
196
|
+
else resolveWait(code!);
|
|
197
|
+
server.close();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const server: Server = createServer((request, response) => {
|
|
201
|
+
const requestUrl = new URL(request.url ?? "/", url.origin);
|
|
202
|
+
if (request.method !== "GET" || requestUrl.pathname !== url.pathname) {
|
|
203
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
204
|
+
response.end("Not found\n");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const receivedState = requestUrl.searchParams.get("state") ?? "";
|
|
208
|
+
const denied = requestUrl.searchParams.get("error");
|
|
209
|
+
const code = requestUrl.searchParams.get("code");
|
|
210
|
+
let error: Error | undefined;
|
|
211
|
+
if (!safeStateEqual(options.state, receivedState)) {
|
|
212
|
+
error = new OAuthTokenError(
|
|
213
|
+
"Linear OAuth callback state did not match; authentication was cancelled.",
|
|
214
|
+
);
|
|
215
|
+
} else if (denied) {
|
|
216
|
+
error = new OAuthTokenError("Linear OAuth consent was denied or cancelled.");
|
|
217
|
+
} else if (!code || requestUrl.searchParams.has("access_token")) {
|
|
218
|
+
error = new OAuthTokenError("Linear OAuth callback was malformed.");
|
|
219
|
+
}
|
|
220
|
+
response.writeHead(error ? 400 : 200, {
|
|
221
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
222
|
+
"Cache-Control": "no-store",
|
|
223
|
+
});
|
|
224
|
+
response.end(callbackPage(!error));
|
|
225
|
+
settle(error, code ?? undefined);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await new Promise<void>((resolve, reject) => {
|
|
229
|
+
server.once("error", () =>
|
|
230
|
+
reject(
|
|
231
|
+
new OAuthTokenError(
|
|
232
|
+
"Could not start the Linear OAuth loopback callback listener.",
|
|
233
|
+
undefined,
|
|
234
|
+
"runtime",
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
server.listen(Number(url.port), url.hostname.replace(/^\[|\]$/g, ""), resolve);
|
|
239
|
+
});
|
|
240
|
+
const timer = setTimeout(
|
|
241
|
+
() => settle(new OAuthTokenError("Timed out waiting for the Linear OAuth callback.")),
|
|
242
|
+
timeoutMs,
|
|
243
|
+
);
|
|
244
|
+
timer.unref?.();
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
redirectUri: url.toString(),
|
|
248
|
+
wait,
|
|
249
|
+
close: () =>
|
|
250
|
+
new Promise<void>((resolve) => {
|
|
251
|
+
if (timer) clearTimeout(timer);
|
|
252
|
+
if (!server.listening) return resolve();
|
|
253
|
+
server.close(() => resolve());
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ParsedTokenResponse {
|
|
259
|
+
accessToken: string;
|
|
260
|
+
refreshToken?: string;
|
|
261
|
+
expiresIn: number;
|
|
262
|
+
scope: string;
|
|
263
|
+
scopes: string[];
|
|
264
|
+
tokenType: "Bearer";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseTokenResponse(value: unknown, requireRefreshToken = false): ParsedTokenResponse {
|
|
268
|
+
if (!value || typeof value !== "object") {
|
|
269
|
+
throw new OAuthTokenError("Linear OAuth token exchange returned an invalid response.");
|
|
270
|
+
}
|
|
271
|
+
const response = value as Record<string, unknown>;
|
|
272
|
+
const accessToken = response.access_token;
|
|
273
|
+
const refreshToken = response.refresh_token;
|
|
274
|
+
const expiresIn = response.expires_in;
|
|
275
|
+
const rawScope = response.scope;
|
|
276
|
+
const tokenType = response.token_type;
|
|
277
|
+
const scopes = Array.isArray(rawScope)
|
|
278
|
+
? rawScope.filter((scope): scope is string => typeof scope === "string" && !!scope)
|
|
279
|
+
: typeof rawScope === "string"
|
|
280
|
+
? rawScope.split(/[ ,]+/).filter(Boolean)
|
|
281
|
+
: [];
|
|
282
|
+
if (
|
|
283
|
+
typeof accessToken !== "string" ||
|
|
284
|
+
!accessToken ||
|
|
285
|
+
(requireRefreshToken && (typeof refreshToken !== "string" || !refreshToken)) ||
|
|
286
|
+
typeof expiresIn !== "number" ||
|
|
287
|
+
!Number.isFinite(expiresIn) ||
|
|
288
|
+
expiresIn <= 0 ||
|
|
289
|
+
scopes.length === 0 ||
|
|
290
|
+
typeof tokenType !== "string" ||
|
|
291
|
+
tokenType.toLowerCase() !== "bearer"
|
|
292
|
+
) {
|
|
293
|
+
throw new OAuthTokenError("Linear OAuth token exchange returned an invalid response.");
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
accessToken,
|
|
297
|
+
refreshToken: typeof refreshToken === "string" ? refreshToken : undefined,
|
|
298
|
+
expiresIn,
|
|
299
|
+
scope: scopes.join(" "),
|
|
300
|
+
scopes,
|
|
301
|
+
tokenType: "Bearer",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function postTokenForm(
|
|
306
|
+
body: URLSearchParams,
|
|
307
|
+
options: { endpoint?: string; fetch?: OAuthFetch } = {},
|
|
308
|
+
): Promise<ParsedTokenResponse> {
|
|
309
|
+
let response: Response;
|
|
310
|
+
try {
|
|
311
|
+
response = await (options.fetch ?? globalThis.fetch)(
|
|
312
|
+
options.endpoint ?? LINEAR_TOKEN_ENDPOINT,
|
|
313
|
+
{
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
316
|
+
body,
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
} catch {
|
|
320
|
+
throw new OAuthTokenError(
|
|
321
|
+
"Unable to reach Linear's OAuth token endpoint.",
|
|
322
|
+
undefined,
|
|
323
|
+
"network",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (!response.ok) {
|
|
327
|
+
throw new OAuthTokenError(
|
|
328
|
+
`Linear OAuth token exchange failed (HTTP ${response.status}).`,
|
|
329
|
+
response.status,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
let value: unknown;
|
|
333
|
+
try {
|
|
334
|
+
value = await response.json();
|
|
335
|
+
} catch {
|
|
336
|
+
throw new OAuthTokenError(
|
|
337
|
+
"Linear OAuth token exchange returned malformed JSON.",
|
|
338
|
+
response.status,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return parseTokenResponse(value, true);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function exchangeAuthorizationCode(options: {
|
|
345
|
+
code: string;
|
|
346
|
+
verifier: string;
|
|
347
|
+
clientId: string;
|
|
348
|
+
redirectUri: string;
|
|
349
|
+
fetch?: OAuthFetch;
|
|
350
|
+
tokenEndpoint?: string;
|
|
351
|
+
now?: () => number;
|
|
352
|
+
}): Promise<
|
|
353
|
+
Pick<OAuthUserCredential, "accessToken" | "refreshToken" | "expiresAt" | "scopes" | "tokenType">
|
|
354
|
+
> {
|
|
355
|
+
const token = await postTokenForm(
|
|
356
|
+
new URLSearchParams({
|
|
357
|
+
grant_type: "authorization_code",
|
|
358
|
+
code: options.code,
|
|
359
|
+
redirect_uri: validateLoopbackRedirectUri(options.redirectUri).toString(),
|
|
360
|
+
client_id: options.clientId,
|
|
361
|
+
code_verifier: options.verifier,
|
|
362
|
+
}),
|
|
363
|
+
{ endpoint: options.tokenEndpoint, fetch: options.fetch },
|
|
364
|
+
);
|
|
365
|
+
return {
|
|
366
|
+
accessToken: token.accessToken,
|
|
367
|
+
refreshToken: token.refreshToken!,
|
|
368
|
+
expiresAt: (options.now ?? Date.now)() + token.expiresIn * 1000,
|
|
369
|
+
scopes: token.scopes,
|
|
370
|
+
tokenType: "Bearer",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface OAuthUserTokenProviderOptions {
|
|
375
|
+
credential: OAuthUserCredential;
|
|
376
|
+
persist: (previousRefreshToken: string, next: OAuthUserCredential) => OAuthUserCredential;
|
|
377
|
+
refreshSkewMs?: number;
|
|
378
|
+
tokenEndpoint?: string;
|
|
379
|
+
fetch?: OAuthFetch;
|
|
380
|
+
now?: () => number;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Refreshes a PKCE user session and atomically hands rotation to the keyring store. */
|
|
384
|
+
export class OAuthUserTokenProvider {
|
|
385
|
+
#credential: OAuthUserCredential;
|
|
386
|
+
readonly #persist: OAuthUserTokenProviderOptions["persist"];
|
|
387
|
+
readonly #refreshSkewMs: number;
|
|
388
|
+
readonly #tokenEndpoint?: string;
|
|
389
|
+
readonly #fetch?: OAuthFetch;
|
|
390
|
+
readonly #now: () => number;
|
|
391
|
+
#pending?: Promise<OAuthUserCredential>;
|
|
392
|
+
|
|
393
|
+
constructor(options: OAuthUserTokenProviderOptions) {
|
|
394
|
+
this.#credential = options.credential;
|
|
395
|
+
this.#persist = options.persist;
|
|
396
|
+
this.#refreshSkewMs = options.refreshSkewMs ?? DEFAULT_OAUTH_REFRESH_SKEW_MS;
|
|
397
|
+
this.#tokenEndpoint = options.tokenEndpoint;
|
|
398
|
+
this.#fetch = options.fetch;
|
|
399
|
+
this.#now = options.now ?? Date.now;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
get credential(): OAuthUserCredential {
|
|
403
|
+
return this.#credential;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async getAccessToken(options: GetAccessTokenOptions = {}): Promise<string> {
|
|
407
|
+
if (!options.forceRefresh && this.#now() < this.#credential.expiresAt - this.#refreshSkewMs) {
|
|
408
|
+
return this.#credential.accessToken;
|
|
409
|
+
}
|
|
410
|
+
if (!this.#pending) this.#pending = this.#refresh();
|
|
411
|
+
try {
|
|
412
|
+
this.#credential = await this.#pending;
|
|
413
|
+
return this.#credential.accessToken;
|
|
414
|
+
} finally {
|
|
415
|
+
this.#pending = undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async #refresh(): Promise<OAuthUserCredential> {
|
|
420
|
+
const previous = this.#credential;
|
|
421
|
+
const token = await postTokenForm(
|
|
422
|
+
new URLSearchParams({
|
|
423
|
+
grant_type: "refresh_token",
|
|
424
|
+
refresh_token: previous.refreshToken,
|
|
425
|
+
client_id: previous.clientId,
|
|
426
|
+
}),
|
|
427
|
+
{ endpoint: this.#tokenEndpoint, fetch: this.#fetch },
|
|
428
|
+
);
|
|
429
|
+
const next: OAuthUserCredential = {
|
|
430
|
+
...previous,
|
|
431
|
+
accessToken: token.accessToken,
|
|
432
|
+
refreshToken: token.refreshToken!,
|
|
433
|
+
expiresAt: this.#now() + token.expiresIn * 1000,
|
|
434
|
+
scopes: token.scopes,
|
|
435
|
+
tokenType: "Bearer",
|
|
436
|
+
};
|
|
437
|
+
try {
|
|
438
|
+
return this.#persist(previous.refreshToken, next);
|
|
439
|
+
} catch {
|
|
440
|
+
throw new OAuthTokenError(
|
|
441
|
+
"Linear OAuth refreshed, but the rotated credential could not be saved. The previous refresh token was retained for recovery.",
|
|
442
|
+
undefined,
|
|
443
|
+
"runtime",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function revokeOAuthToken(options: {
|
|
450
|
+
token: string;
|
|
451
|
+
tokenTypeHint: "access_token" | "refresh_token";
|
|
452
|
+
endpoint?: string;
|
|
453
|
+
fetch?: OAuthFetch;
|
|
454
|
+
}): Promise<"revoked" | "already-revoked"> {
|
|
455
|
+
let response: Response;
|
|
456
|
+
try {
|
|
457
|
+
response = await (options.fetch ?? globalThis.fetch)(
|
|
458
|
+
options.endpoint ?? LINEAR_REVOKE_ENDPOINT,
|
|
459
|
+
{
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
462
|
+
body: new URLSearchParams({ token: options.token, token_type_hint: options.tokenTypeHint }),
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
} catch {
|
|
466
|
+
throw new OAuthTokenError(
|
|
467
|
+
"Unable to reach Linear's OAuth revocation endpoint.",
|
|
468
|
+
undefined,
|
|
469
|
+
"network",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (response.ok) return "revoked";
|
|
473
|
+
if (response.status === 400 || response.status === 401) return "already-revoked";
|
|
474
|
+
throw new OAuthTokenError(
|
|
475
|
+
`Linear OAuth revocation failed (HTTP ${response.status}).`,
|
|
476
|
+
response.status,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Hosted app actor lifecycle: client secret stays with the host and tokens stay in memory. */
|
|
481
|
+
export class ClientCredentialsTokenProvider {
|
|
482
|
+
readonly #clientId: string;
|
|
483
|
+
readonly #clientSecret: string;
|
|
484
|
+
readonly #scopes: readonly string[];
|
|
485
|
+
readonly #refreshSkewMs: number;
|
|
486
|
+
readonly #tokenEndpoint: string;
|
|
487
|
+
readonly #fetch: OAuthFetch;
|
|
488
|
+
readonly #now: () => number;
|
|
489
|
+
#cached?: OAuthAccessToken;
|
|
490
|
+
#pending?: Promise<OAuthAccessToken>;
|
|
491
|
+
|
|
492
|
+
constructor(options: ClientCredentialsTokenProviderOptions) {
|
|
493
|
+
if (!options.clientId.trim()) throw new OAuthTokenError("OAuth client ID is required.");
|
|
494
|
+
if (!options.clientSecret) throw new OAuthTokenError("OAuth client secret is required.");
|
|
495
|
+
if (options.scopes.length === 0 || options.scopes.some((scope) => !scope.trim())) {
|
|
496
|
+
throw new OAuthTokenError("At least one non-empty OAuth scope is required.");
|
|
497
|
+
}
|
|
498
|
+
const refreshSkewMs = options.refreshSkewMs ?? DEFAULT_OAUTH_REFRESH_SKEW_MS;
|
|
499
|
+
if (!Number.isFinite(refreshSkewMs) || refreshSkewMs < 0) {
|
|
500
|
+
throw new OAuthTokenError("OAuth refresh skew must be a non-negative finite number.");
|
|
501
|
+
}
|
|
502
|
+
this.#clientId = options.clientId;
|
|
503
|
+
this.#clientSecret = options.clientSecret;
|
|
504
|
+
this.#scopes = [...new Set(options.scopes)];
|
|
505
|
+
this.#refreshSkewMs = refreshSkewMs;
|
|
506
|
+
this.#tokenEndpoint = options.tokenEndpoint ?? LINEAR_TOKEN_ENDPOINT;
|
|
507
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
508
|
+
this.#now = options.now ?? Date.now;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async getAccessToken(options: GetAccessTokenOptions = {}): Promise<OAuthAccessToken> {
|
|
512
|
+
if (!options.forceRefresh && this.#cached && !this.#shouldRefresh(this.#cached)) {
|
|
513
|
+
return this.#cached;
|
|
514
|
+
}
|
|
515
|
+
if (this.#pending) return this.#pending;
|
|
516
|
+
const pending = this.#exchange();
|
|
517
|
+
this.#pending = pending;
|
|
518
|
+
try {
|
|
519
|
+
const token = await pending;
|
|
520
|
+
this.#cached = token;
|
|
521
|
+
return token;
|
|
522
|
+
} finally {
|
|
523
|
+
if (this.#pending === pending) this.#pending = undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
invalidate(): void {
|
|
528
|
+
this.#cached = undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#shouldRefresh(token: OAuthAccessToken): boolean {
|
|
532
|
+
const lifetimeMs = token.expiresIn * 1000;
|
|
533
|
+
return this.#now() >= token.expiresAt - Math.min(this.#refreshSkewMs, lifetimeMs / 2);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async #exchange(): Promise<OAuthAccessToken> {
|
|
537
|
+
const authorization = Buffer.from(`${this.#clientId}:${this.#clientSecret}`).toString("base64");
|
|
538
|
+
let response: Response;
|
|
539
|
+
try {
|
|
540
|
+
response = await this.#fetch(this.#tokenEndpoint, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: {
|
|
543
|
+
Authorization: `Basic ${authorization}`,
|
|
544
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
545
|
+
},
|
|
546
|
+
body: new URLSearchParams({
|
|
547
|
+
grant_type: "client_credentials",
|
|
548
|
+
scope: this.#scopes.join(","),
|
|
549
|
+
}),
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
throw new OAuthTokenError(
|
|
553
|
+
"Unable to reach Linear's OAuth token endpoint.",
|
|
554
|
+
undefined,
|
|
555
|
+
"network",
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (!response.ok) {
|
|
559
|
+
throw new OAuthTokenError(
|
|
560
|
+
`Linear OAuth token exchange failed (HTTP ${response.status}).`,
|
|
561
|
+
response.status,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
let value: unknown;
|
|
565
|
+
try {
|
|
566
|
+
value = await response.json();
|
|
567
|
+
} catch {
|
|
568
|
+
throw new OAuthTokenError(
|
|
569
|
+
"Linear OAuth token exchange returned malformed JSON.",
|
|
570
|
+
response.status,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const token = parseTokenResponse(value);
|
|
574
|
+
return {
|
|
575
|
+
accessToken: token.accessToken,
|
|
576
|
+
expiresAt: this.#now() + token.expiresIn * 1000,
|
|
577
|
+
expiresIn: token.expiresIn,
|
|
578
|
+
scope: token.scope,
|
|
579
|
+
tokenType: token.tokenType,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
}
|