-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlinear-oauth.ts
More file actions
295 lines (279 loc) · 10.9 KB
/
Copy pathlinear-oauth.ts
File metadata and controls
295 lines (279 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/**
* MIT No Attribution
*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as crypto from 'crypto';
import { CliError } from './errors';
/**
* Linear OAuth endpoint URLs. Fixed across all workspaces.
*/
export const LINEAR_AUTHORIZE_ENDPOINT = 'https://linear.app/oauth/authorize';
export const LINEAR_TOKEN_ENDPOINT = 'https://api.linear.app/oauth/token';
/**
* Scopes for the agent install. `actor=app` is incompatible with `admin`,
* so we deliberately exclude it. `app:assignable` + `app:mentionable` are
* required for an Agent app install (Phase 2.0b spike, 2026-05-18).
*/
export const LINEAR_OAUTH_SCOPES = [
'read',
'write',
'app:assignable',
'app:mentionable',
] as const;
/**
* Linear OAuth token response shape (RFC 6749 §5.1 + Linear's extensions).
* Verified via direct curl 2026-05-19 — Linear returns `scope` as a
* space-separated string for apps created after Dec 2023, with
* `lin_oauth_…` access tokens and `lin_refresh_…` refresh tokens.
*/
export interface LinearTokenResponse {
readonly access_token: string;
readonly token_type: string;
readonly expires_in: number;
readonly refresh_token?: string;
readonly scope: string;
}
/**
* Persisted form of a Linear OAuth credential. Stored as the JSON
* `SecretString` of `bgagent-linear-oauth-<slug>` in Secrets Manager.
*
* `expires_at` is computed at write time as ISO-8601, so consumers can
* compare against `new Date()` without depending on Linear's
* `expires_in` (relative to issuance) being correct on the wall clock.
*
* `client_id` and `client_secret` are co-located so Lambda-side refresh
* can hit Linear's `/oauth/token` without needing additional environment
* variables — one secret per workspace contains everything the runtime
* needs to renew the access token autonomously.
*/
export interface StoredLinearOauthToken {
readonly access_token: string;
readonly refresh_token: string;
/** ISO-8601 timestamp; if `now >= expires_at - threshold`, refresh first. */
readonly expires_at: string;
/** Space-separated scope string Linear returned (e.g. "read write app:..."). */
readonly scope: string;
/** Linear OAuth app Client ID — needed for refresh. */
readonly client_id: string;
/** Linear OAuth app Client Secret — needed for refresh. */
readonly client_secret: string;
/** Linear organization UUID; webhook payloads carry this. */
readonly workspace_id: string;
/** Linear urlKey; matches the suffix on the secret name. */
readonly workspace_slug: string;
/** ISO-8601 timestamp of the original install (does NOT change on refresh). */
readonly installed_at: string;
/** ISO-8601 timestamp of the most recent refresh write (or first install). */
readonly updated_at: string;
/** Cognito sub of the admin who ran `bgagent linear setup`. Audit only. */
readonly installed_by_platform_user_id: string;
/**
* Per-workspace Linear webhook signing secret (`lin_wh_…`).
*
* Linear generates a fresh signing secret per webhook subscription, and
* webhook subscriptions are workspace-scoped — so a single stack-wide
* signing secret can't verify events from multiple workspaces. The
* webhook receiver looks this up by orgId at verify time.
*
* Optional for back-compat: tokens written before the per-workspace
* signing flow won't have it, and the receiver falls back to the
* stack-wide `LINEAR_WEBHOOK_SECRET_ARN` for those installs.
*/
readonly webhook_signing_secret?: string;
}
/**
* Common prefix for all per-workspace Linear OAuth secrets. The full
* secret name is `${LINEAR_OAUTH_SECRET_PREFIX}<slug>`. Use this when
* scanning Secrets Manager for every workspace install (e.g. the CLI's
* `list-projects` command queries every workspace it can find).
*/
export const LINEAR_OAUTH_SECRET_PREFIX = 'bgagent-linear-oauth-';
/**
* Build the secret name for a given Linear workspace slug. Matches the
* naming convention encoded in the runtime's IAM policy resource pattern,
* so changes here MUST be matched by the IAM resource pattern in CDK.
*/
export function linearOauthSecretName(workspaceSlug: string): string {
return `${LINEAR_OAUTH_SECRET_PREFIX}${workspaceSlug}`;
}
/**
* Compute when an access token should be considered "stale and needs
* refresh." We refresh if there's <60s left on the access token —
* gives Lambda invocations a clean buffer to make the upstream call
* without racing the actual expiry.
*/
const REFRESH_THRESHOLD_SECONDS = 60;
export function isAccessTokenExpiring(
expiresAt: string,
thresholdSeconds: number = REFRESH_THRESHOLD_SECONDS,
): boolean {
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) {
// Treat malformed expires_at as expired — better to over-refresh than
// proceed with a token that may have rotated under us.
return true;
}
return Date.now() + thresholdSeconds * 1000 >= expiry;
}
/**
* PKCE pair: a random `code_verifier` and the SHA-256 base64url digest
* (`code_challenge`). Linear supports both `S256` and `plain`; we always
* use `S256` because the wire-format cost is identical and stronger.
*
* Returned `code_verifier` MUST be sent on the token-exchange POST to
* complete PKCE. Without it, Linear rejects with `invalid_grant`.
*/
export function generatePkce(): { codeVerifier: string; codeChallenge: string } {
const VERIFIER_BYTES = 32;
const verifierBytes = crypto.randomBytes(VERIFIER_BYTES);
const codeVerifier = verifierBytes.toString('base64url');
const challengeBytes = crypto.createHash('sha256').update(codeVerifier).digest();
const codeChallenge = challengeBytes.toString('base64url');
return { codeVerifier, codeChallenge };
}
/**
* Build the Linear authorization URL the CLI opens in the browser.
* `actorApp: true` adds `actor=app` (the Agent install variant).
*/
export function buildAuthorizationUrl(opts: {
clientId: string;
redirectUri: string;
state: string;
codeChallenge: string;
scopes?: readonly string[];
actorApp?: boolean;
}): string {
const params = new URLSearchParams({
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
response_type: 'code',
// RFC 6749 §3.3: scope is a space-separated list. Linear rejects
// comma-separated scopes with "Invalid redirect_uri" — the error
// is misleading; verified by 2.0b smoke test 2026-05-19.
scope: (opts.scopes ?? LINEAR_OAUTH_SCOPES).join(' '),
state: opts.state,
code_challenge: opts.codeChallenge,
code_challenge_method: 'S256',
});
if (opts.actorApp ?? true) {
params.set('actor', 'app');
}
return `${LINEAR_AUTHORIZE_ENDPOINT}?${params.toString()}`;
}
/**
* Exchange an authorization `code` for an access + refresh token by
* POSTing to Linear's `/oauth/token` endpoint. Mirrors the curl shape
* verified by the 2026-05-19 manual smoke test.
*
* Throws CliError with Linear's error_description on failure (the most
* common cause of failure is `invalid_grant` from a reused/expired
* code or `redirect_uri_mismatch`).
*/
export async function exchangeAuthorizationCode(args: {
code: string;
codeVerifier: string;
redirectUri: string;
clientId: string;
clientSecret: string;
fetchImpl?: typeof fetch;
}): Promise<LinearTokenResponse> {
const fetchImpl = args.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
code_verifier: args.codeVerifier,
redirect_uri: args.redirectUri,
client_id: args.clientId,
client_secret: args.clientSecret,
});
const response = await fetchImpl(LINEAR_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return parseTokenResponse(response, 'authorization_code exchange');
}
/**
* Refresh an expiring access token. Linear's refresh tokens are
* long-lived (no documented TTL) but rotate every refresh call —
* always persist `refresh_token` from the response back to storage.
*/
export async function refreshAccessToken(args: {
refreshToken: string;
clientId: string;
clientSecret: string;
fetchImpl?: typeof fetch;
}): Promise<LinearTokenResponse> {
const fetchImpl = args.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
client_id: args.clientId,
client_secret: args.clientSecret,
});
const response = await fetchImpl(LINEAR_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return parseTokenResponse(response, 'refresh_token grant');
}
async function parseTokenResponse(
response: Response,
contextLabel: string,
): Promise<LinearTokenResponse> {
let body: unknown;
try {
body = await response.json();
} catch (_err) {
throw new CliError(
`Linear /oauth/token returned non-JSON during ${contextLabel}: HTTP ${response.status}`,
);
}
if (!response.ok) {
const obj = body as { error?: string; error_description?: string };
throw new CliError(
`Linear /oauth/token rejected ${contextLabel}: HTTP ${response.status} `
+ `${obj.error ?? 'unknown_error'}: ${obj.error_description ?? '(no description)'}`,
);
}
if (!isLinearTokenResponse(body)) {
throw new CliError(
`Linear /oauth/token returned an unexpected shape for ${contextLabel}: `
+ `${JSON.stringify(body).slice(0, 200)}`,
);
}
return body;
}
function isLinearTokenResponse(value: unknown): value is LinearTokenResponse {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.access_token === 'string'
&& typeof obj.token_type === 'string'
&& typeof obj.expires_in === 'number'
&& typeof obj.scope === 'string'
);
}
/**
* Compute the `expires_at` ISO timestamp from `expires_in` (seconds).
* Centralised so the CLI's initial-install path and the Lambda-side
* refresh path agree on the timestamp shape.
*/
export function computeExpiresAt(expiresInSeconds: number, now: Date = new Date()): string {
return new Date(now.getTime() + expiresInSeconds * 1000).toISOString();
}