@enrichlayer/el-linear 1.4.0 → 1.5.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.
- package/README.md +54 -0
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/init/index.js +22 -0
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +272 -0
- package/dist/commands/profile.js +6 -0
- package/dist/main.js +1 -1
- package/dist/utils/auth.js +8 -0
- package/dist/utils/graphql-service.d.ts +16 -1
- package/dist/utils/graphql-service.js +19 -7
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,6 +122,60 @@ The active profile is selected by, in priority:
|
|
|
122
122
|
The legacy fallback means **existing single-profile users see no
|
|
123
123
|
behavior change** — profiles are purely opt-in.
|
|
124
124
|
|
|
125
|
+
## Migrating from v1.0–1.3
|
|
126
|
+
|
|
127
|
+
Versions 1.0–1.3 stored everything in the single-file layout
|
|
128
|
+
(`~/.config/el-linear/{token,config.json}`). 1.4 introduced **named
|
|
129
|
+
profiles** (`~/.config/el-linear/profiles/<name>/{token,config.json}`) and
|
|
130
|
+
the legacy single-file layout still works as a fallback.
|
|
131
|
+
|
|
132
|
+
Some upgrade paths leave the legacy `config.json` on disk (with all your
|
|
133
|
+
member aliases and brand rules) but no usable token, in which case every
|
|
134
|
+
command fails with a generic `No API token found` error. 1.5 detects this
|
|
135
|
+
state and prints a one-line stderr hint *before* the auth error fires:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
el-linear: legacy config detected at ~/.config/el-linear/config.json
|
|
139
|
+
but no token. Migrate with:
|
|
140
|
+
|
|
141
|
+
el-linear profile migrate-legacy [--name <profile>]
|
|
142
|
+
|
|
143
|
+
Or suppress this hint with EL_LINEAR_SKIP_MIGRATION_HINT=1.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run the suggested command to copy your legacy config into a named profile
|
|
147
|
+
in one step:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# Default target name is "default"; pass --name to choose another.
|
|
151
|
+
el-linear profile migrate-legacy
|
|
152
|
+
|
|
153
|
+
# CI / scripted: read the token from a file, skip all prompts.
|
|
154
|
+
el-linear profile migrate-legacy \
|
|
155
|
+
--name work \
|
|
156
|
+
--token-from /path/to/token.txt \
|
|
157
|
+
--yes
|
|
158
|
+
|
|
159
|
+
# Pick the token up from an env var instead.
|
|
160
|
+
EL_LINEAR_TOKEN=lin_api_xxx el-linear profile migrate-legacy --yes
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The migration is **idempotent** — re-running with the same inputs is a no-op.
|
|
164
|
+
If the destination profile already has a different `config.json` or token,
|
|
165
|
+
the command refuses unless you pass `--force` (and confirms before
|
|
166
|
+
overwriting unless you also pass `--yes`).
|
|
167
|
+
|
|
168
|
+
The legacy `~/.config/el-linear/config.json` is **never deleted** — you keep
|
|
169
|
+
a rollback path. Once you've verified the new profile works, you can remove
|
|
170
|
+
the legacy file by hand at your leisure.
|
|
171
|
+
|
|
172
|
+
If you've decided to stay on the legacy single-file layout intentionally,
|
|
173
|
+
suppress the hint with:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
export EL_LINEAR_SKIP_MIGRATION_HINT=1
|
|
177
|
+
```
|
|
178
|
+
|
|
125
179
|
## Configuration
|
|
126
180
|
|
|
127
181
|
el-linear reads `~/.config/el-linear/config.json` on startup. All keys are
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localhost HTTP listener that captures the OAuth redirect.
|
|
3
|
+
*
|
|
4
|
+
* Linear redirects the user's browser to
|
|
5
|
+
* http://localhost:<port>/oauth/callback?code=…&state=…
|
|
6
|
+
* after they approve the app. We spin a one-shot HTTP server, parse the
|
|
7
|
+
* callback, validate `state`, return `{code, state}`, then close.
|
|
8
|
+
*
|
|
9
|
+
* Hardening:
|
|
10
|
+
* - Only listens on `127.0.0.1` (NOT `0.0.0.0`) so other hosts on the
|
|
11
|
+
* network can't race to grab the code.
|
|
12
|
+
* - Rejects requests with a missing/wrong `state` parameter.
|
|
13
|
+
* - Has a timeout (default 5 minutes) so a user who closes the browser
|
|
14
|
+
* tab doesn't leave the CLI hanging forever.
|
|
15
|
+
* - Sends a friendly success/error HTML page back so the user knows what
|
|
16
|
+
* to do next ("you can close this tab").
|
|
17
|
+
*/
|
|
18
|
+
import { type Server } from "node:http";
|
|
19
|
+
import { type CallbackParams } from "./oauth-client.js";
|
|
20
|
+
export declare const DEFAULT_CALLBACK_PATH = "/oauth/callback";
|
|
21
|
+
export declare const DEFAULT_LISTEN_HOST = "127.0.0.1";
|
|
22
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
23
|
+
export interface CallbackOptions {
|
|
24
|
+
port: number;
|
|
25
|
+
expectedState: string;
|
|
26
|
+
host?: string;
|
|
27
|
+
callbackPath?: string;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Spin up a one-shot HTTP server on `127.0.0.1:<port>`, accept the OAuth
|
|
32
|
+
* callback, validate state, and resolve with `{code, state}`.
|
|
33
|
+
*
|
|
34
|
+
* Always closes the server before resolving / rejecting.
|
|
35
|
+
*
|
|
36
|
+
* Test seam: `serverFactory` lets tests inject a mock server so we don't
|
|
37
|
+
* have to bind to a real port (and avoid flakiness from port collisions in
|
|
38
|
+
* CI).
|
|
39
|
+
*/
|
|
40
|
+
export declare function runLocalhostCallback(options: CallbackOptions, serverFactory?: () => Server): Promise<CallbackParams>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localhost HTTP listener that captures the OAuth redirect.
|
|
3
|
+
*
|
|
4
|
+
* Linear redirects the user's browser to
|
|
5
|
+
* http://localhost:<port>/oauth/callback?code=…&state=…
|
|
6
|
+
* after they approve the app. We spin a one-shot HTTP server, parse the
|
|
7
|
+
* callback, validate `state`, return `{code, state}`, then close.
|
|
8
|
+
*
|
|
9
|
+
* Hardening:
|
|
10
|
+
* - Only listens on `127.0.0.1` (NOT `0.0.0.0`) so other hosts on the
|
|
11
|
+
* network can't race to grab the code.
|
|
12
|
+
* - Rejects requests with a missing/wrong `state` parameter.
|
|
13
|
+
* - Has a timeout (default 5 minutes) so a user who closes the browser
|
|
14
|
+
* tab doesn't leave the CLI hanging forever.
|
|
15
|
+
* - Sends a friendly success/error HTML page back so the user knows what
|
|
16
|
+
* to do next ("you can close this tab").
|
|
17
|
+
*/
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { parseCallbackUrl } from "./oauth-client.js";
|
|
20
|
+
export const DEFAULT_CALLBACK_PATH = "/oauth/callback";
|
|
21
|
+
export const DEFAULT_LISTEN_HOST = "127.0.0.1";
|
|
22
|
+
export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
23
|
+
const SUCCESS_HTML = `<!doctype html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head><meta charset="utf-8"><title>el-linear · authorized</title>
|
|
26
|
+
<style>body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 16px;color:#1a1a1a}h1{font-size:18px;margin:0 0 12px}p{margin:8px 0}.ok{color:#0a7e35}</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<h1 class="ok">el-linear · authorization complete</h1>
|
|
30
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
31
|
+
</body>
|
|
32
|
+
</html>`;
|
|
33
|
+
function errorHtml(message) {
|
|
34
|
+
const safe = message.replace(/[<>&]/g, "");
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head><meta charset="utf-8"><title>el-linear · authorization error</title>
|
|
38
|
+
<style>body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 16px;color:#1a1a1a}h1{font-size:18px;margin:0 0 12px}p{margin:8px 0}.bad{color:#a30000}code{background:#f4f4f4;padding:2px 4px;border-radius:3px}</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<h1 class="bad">el-linear · authorization error</h1>
|
|
42
|
+
<p>${safe}</p>
|
|
43
|
+
<p>You can close this tab. Re-run <code>el-linear init oauth</code> to retry.</p>
|
|
44
|
+
</body>
|
|
45
|
+
</html>`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Spin up a one-shot HTTP server on `127.0.0.1:<port>`, accept the OAuth
|
|
49
|
+
* callback, validate state, and resolve with `{code, state}`.
|
|
50
|
+
*
|
|
51
|
+
* Always closes the server before resolving / rejecting.
|
|
52
|
+
*
|
|
53
|
+
* Test seam: `serverFactory` lets tests inject a mock server so we don't
|
|
54
|
+
* have to bind to a real port (and avoid flakiness from port collisions in
|
|
55
|
+
* CI).
|
|
56
|
+
*/
|
|
57
|
+
export async function runLocalhostCallback(options, serverFactory = () => createServer()) {
|
|
58
|
+
const host = options.host ?? DEFAULT_LISTEN_HOST;
|
|
59
|
+
const callbackPath = options.callbackPath ?? DEFAULT_CALLBACK_PATH;
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
const server = serverFactory();
|
|
62
|
+
let timeoutHandle = null;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const settle = (run, finalize = { closeServer: true }) => {
|
|
65
|
+
if (timeoutHandle) {
|
|
66
|
+
clearTimeout(timeoutHandle);
|
|
67
|
+
timeoutHandle = null;
|
|
68
|
+
}
|
|
69
|
+
if (finalize.closeServer) {
|
|
70
|
+
server.close(() => run());
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
run();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
server.on("request", (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const incoming = req;
|
|
79
|
+
const rawUrl = incoming.url ?? "";
|
|
80
|
+
// Only respond on the configured callback path; all other
|
|
81
|
+
// requests get a 404 so a stray `/favicon.ico` poke doesn't
|
|
82
|
+
// trigger a parse error.
|
|
83
|
+
const pathname = new URL(rawUrl, "http://localhost").pathname;
|
|
84
|
+
if (pathname !== callbackPath) {
|
|
85
|
+
res.statusCode = 404;
|
|
86
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
87
|
+
res.end("Not found.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = parseCallbackUrl(rawUrl);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
res.statusCode = 400;
|
|
97
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
98
|
+
res.end(errorHtml(message));
|
|
99
|
+
settle(() => reject(new Error(message)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (parsed.state !== options.expectedState) {
|
|
103
|
+
const message = "State mismatch — the OAuth callback's `state` parameter doesn't match what we sent. This could indicate a CSRF attempt; aborting.";
|
|
104
|
+
res.statusCode = 400;
|
|
105
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
106
|
+
res.end(errorHtml(message));
|
|
107
|
+
settle(() => reject(new Error(message)));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
res.statusCode = 200;
|
|
111
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
112
|
+
res.end(SUCCESS_HTML);
|
|
113
|
+
settle(() => resolve(parsed));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
try {
|
|
118
|
+
res.statusCode = 500;
|
|
119
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
120
|
+
res.end(`internal error: ${message}`);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Connection already closed — nothing to do.
|
|
124
|
+
}
|
|
125
|
+
settle(() => reject(err instanceof Error ? err : new Error(message)));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
server.on("error", (err) => {
|
|
129
|
+
const code = err.code ?? "UNKNOWN";
|
|
130
|
+
const friendly = code === "EADDRINUSE"
|
|
131
|
+
? `Port ${options.port} is already in use. Pick another with --port <n>.`
|
|
132
|
+
: `Could not start localhost listener on ${host}:${options.port} (${code}: ${err.message}).`;
|
|
133
|
+
settle(() => reject(new Error(friendly)), { closeServer: false });
|
|
134
|
+
});
|
|
135
|
+
timeoutHandle = setTimeout(() => {
|
|
136
|
+
settle(() => reject(new Error(`OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s. Re-run \`el-linear init oauth\`.`)));
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
// Don't keep the event loop alive solely on the timeout.
|
|
139
|
+
timeoutHandle.unref?.();
|
|
140
|
+
server.listen(options.port, host);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Default scope set picked when the user doesn't customise. */
|
|
2
|
+
export declare const DEFAULT_SCOPES: readonly OAuthScope[];
|
|
3
|
+
/** All scopes Linear advertises. Keep in sync with their docs. */
|
|
4
|
+
export declare const ALL_SCOPES: readonly ["read", "write", "issues:create", "comments:create", "timeSchedule:write", "admin", "app:assignable", "app:mentionable"];
|
|
5
|
+
export type OAuthScope = (typeof ALL_SCOPES)[number];
|
|
6
|
+
export declare const SCOPE_DESCRIPTIONS: Record<OAuthScope, string>;
|
|
7
|
+
export declare const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
|
|
8
|
+
export declare const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
9
|
+
export declare const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
|
|
10
|
+
export interface PkcePair {
|
|
11
|
+
verifier: string;
|
|
12
|
+
challenge: string;
|
|
13
|
+
method: "S256";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate a PKCE verifier + S256 challenge.
|
|
17
|
+
*
|
|
18
|
+
* Per RFC 7636 §4.1, verifier length is between 43 and 128 chars after
|
|
19
|
+
* base64url encoding. We pick 32 raw bytes which yields a 43-char verifier —
|
|
20
|
+
* the floor, but cryptographically plenty (256 bits of entropy).
|
|
21
|
+
*/
|
|
22
|
+
export declare function generatePkce(): PkcePair;
|
|
23
|
+
/** Cryptographically random `state` parameter. 16 bytes → 22-char b64url. */
|
|
24
|
+
export declare function generateState(): string;
|
|
25
|
+
export interface AuthorizeUrlInput {
|
|
26
|
+
clientId: string;
|
|
27
|
+
redirectUri: string;
|
|
28
|
+
scopes: readonly string[];
|
|
29
|
+
state: string;
|
|
30
|
+
codeChallenge: string;
|
|
31
|
+
/** "consent" forces the consent screen even for previously-authorized users. */
|
|
32
|
+
prompt?: "consent";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the URL to send the user to. Linear's authorize endpoint accepts
|
|
36
|
+
* scope as a comma-separated list.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildAuthorizeUrl(input: AuthorizeUrlInput): string;
|
|
39
|
+
export interface CallbackParams {
|
|
40
|
+
code: string;
|
|
41
|
+
state: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse a callback URL like
|
|
45
|
+
* http://localhost:8765/oauth/callback?code=…&state=…
|
|
46
|
+
*
|
|
47
|
+
* Throws on missing parameters or on `?error=…` responses (Linear sends
|
|
48
|
+
* `error=access_denied` if the user cancels).
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseCallbackUrl(rawUrl: string): CallbackParams;
|
|
51
|
+
/**
|
|
52
|
+
* Validate scope strings against Linear's known set. Returns the validated
|
|
53
|
+
* tuple; throws on any unknown scope (typo guard).
|
|
54
|
+
*/
|
|
55
|
+
export declare function validateScopes(scopes: readonly string[]): OAuthScope[];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 PKCE primitives for Linear.
|
|
3
|
+
*
|
|
4
|
+
* Linear's OAuth flow follows the standard authorization-code-with-PKCE shape:
|
|
5
|
+
*
|
|
6
|
+
* 1. Generate a random `code_verifier` and its SHA-256 `code_challenge`.
|
|
7
|
+
* 2. Send the user to /oauth/authorize with `code_challenge` + `state`.
|
|
8
|
+
* 3. Linear redirects back to our `redirect_uri` with `?code=…&state=…`.
|
|
9
|
+
* 4. We exchange `code` + `code_verifier` for an access token.
|
|
10
|
+
*
|
|
11
|
+
* Notes specific to Linear (per https://linear.app/developers/oauth-2-0-authentication):
|
|
12
|
+
* - Scopes are joined with commas (`scope=read,write,issues:create`),
|
|
13
|
+
* not spaces. Defensive: we encode the comma so URL parsers don't treat
|
|
14
|
+
* it as a multi-value separator.
|
|
15
|
+
* - `client_secret` is required by Linear's token endpoint even with PKCE
|
|
16
|
+
* for confidential apps. Public/native apps configured without a secret
|
|
17
|
+
* can omit it; we let the caller decide.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
/** Default scope set picked when the user doesn't customise. */
|
|
21
|
+
export const DEFAULT_SCOPES = [
|
|
22
|
+
"read",
|
|
23
|
+
"write",
|
|
24
|
+
"issues:create",
|
|
25
|
+
"comments:create",
|
|
26
|
+
];
|
|
27
|
+
/** All scopes Linear advertises. Keep in sync with their docs. */
|
|
28
|
+
export const ALL_SCOPES = [
|
|
29
|
+
"read",
|
|
30
|
+
"write",
|
|
31
|
+
"issues:create",
|
|
32
|
+
"comments:create",
|
|
33
|
+
"timeSchedule:write",
|
|
34
|
+
"admin",
|
|
35
|
+
"app:assignable",
|
|
36
|
+
"app:mentionable",
|
|
37
|
+
];
|
|
38
|
+
export const SCOPE_DESCRIPTIONS = {
|
|
39
|
+
read: "Read access to the user's account.",
|
|
40
|
+
write: "Write access. Without admin, also requires issues:create / comments:create for those mutations.",
|
|
41
|
+
"issues:create": "Allow creating new issues and their attachments.",
|
|
42
|
+
"comments:create": "Allow creating new issue comments.",
|
|
43
|
+
"timeSchedule:write": "Manage on-call schedules.",
|
|
44
|
+
admin: "Full administrative permissions.",
|
|
45
|
+
"app:assignable": "Mark this app as assignable to issues (Agents API).",
|
|
46
|
+
"app:mentionable": "Mark this app as mentionable from comments (Agents API).",
|
|
47
|
+
};
|
|
48
|
+
export const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
|
|
49
|
+
export const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
50
|
+
export const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
|
|
51
|
+
/**
|
|
52
|
+
* Encode bytes as base64url per RFC 4648 §5: replace `+` and `/`, strip `=`.
|
|
53
|
+
* `Buffer.toString("base64url")` is available in Node 22+ (we require 22+).
|
|
54
|
+
*/
|
|
55
|
+
function toBase64Url(buf) {
|
|
56
|
+
return buf.toString("base64url");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Generate a PKCE verifier + S256 challenge.
|
|
60
|
+
*
|
|
61
|
+
* Per RFC 7636 §4.1, verifier length is between 43 and 128 chars after
|
|
62
|
+
* base64url encoding. We pick 32 raw bytes which yields a 43-char verifier —
|
|
63
|
+
* the floor, but cryptographically plenty (256 bits of entropy).
|
|
64
|
+
*/
|
|
65
|
+
export function generatePkce() {
|
|
66
|
+
const verifier = toBase64Url(randomBytes(32));
|
|
67
|
+
const challenge = toBase64Url(createHash("sha256").update(verifier).digest());
|
|
68
|
+
return { verifier, challenge, method: "S256" };
|
|
69
|
+
}
|
|
70
|
+
/** Cryptographically random `state` parameter. 16 bytes → 22-char b64url. */
|
|
71
|
+
export function generateState() {
|
|
72
|
+
return toBase64Url(randomBytes(16));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build the URL to send the user to. Linear's authorize endpoint accepts
|
|
76
|
+
* scope as a comma-separated list.
|
|
77
|
+
*/
|
|
78
|
+
export function buildAuthorizeUrl(input) {
|
|
79
|
+
const url = new URL(LINEAR_AUTHORIZE_URL);
|
|
80
|
+
url.searchParams.set("client_id", input.clientId);
|
|
81
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
82
|
+
url.searchParams.set("response_type", "code");
|
|
83
|
+
// Linear's docs are explicit: scopes are joined with `,`. URLSearchParams
|
|
84
|
+
// will URL-encode the comma to %2C, which Linear accepts.
|
|
85
|
+
url.searchParams.set("scope", input.scopes.join(","));
|
|
86
|
+
url.searchParams.set("state", input.state);
|
|
87
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
88
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
89
|
+
if (input.prompt) {
|
|
90
|
+
url.searchParams.set("prompt", input.prompt);
|
|
91
|
+
}
|
|
92
|
+
return url.toString();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse a callback URL like
|
|
96
|
+
* http://localhost:8765/oauth/callback?code=…&state=…
|
|
97
|
+
*
|
|
98
|
+
* Throws on missing parameters or on `?error=…` responses (Linear sends
|
|
99
|
+
* `error=access_denied` if the user cancels).
|
|
100
|
+
*/
|
|
101
|
+
export function parseCallbackUrl(rawUrl) {
|
|
102
|
+
const url = new URL(rawUrl, "http://localhost");
|
|
103
|
+
const error = url.searchParams.get("error");
|
|
104
|
+
if (error) {
|
|
105
|
+
const description = url.searchParams.get("error_description") ?? "no description provided";
|
|
106
|
+
throw new Error(`OAuth error: ${error} (${description})`);
|
|
107
|
+
}
|
|
108
|
+
const code = url.searchParams.get("code");
|
|
109
|
+
const state = url.searchParams.get("state");
|
|
110
|
+
if (!code)
|
|
111
|
+
throw new Error("OAuth callback missing `code` parameter");
|
|
112
|
+
if (!state)
|
|
113
|
+
throw new Error("OAuth callback missing `state` parameter");
|
|
114
|
+
return { code, state };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate scope strings against Linear's known set. Returns the validated
|
|
118
|
+
* tuple; throws on any unknown scope (typo guard).
|
|
119
|
+
*/
|
|
120
|
+
export function validateScopes(scopes) {
|
|
121
|
+
const known = new Set(ALL_SCOPES);
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const s of scopes) {
|
|
124
|
+
const trimmed = s.trim();
|
|
125
|
+
if (!known.has(trimmed)) {
|
|
126
|
+
throw new Error(`Unknown OAuth scope: "${trimmed}". Allowed: ${ALL_SCOPES.join(", ")}.`);
|
|
127
|
+
}
|
|
128
|
+
out.push(trimmed);
|
|
129
|
+
}
|
|
130
|
+
if (out.length === 0) {
|
|
131
|
+
throw new Error("At least one OAuth scope is required.");
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function atomicWrite(targetPath: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal: atomic write helper used by `oauth-storage.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The wizard already has an `atomicWrite` in `commands/init/shared.ts`, but
|
|
5
|
+
* importing wizard internals from non-wizard code creates a cycle (the
|
|
6
|
+
* wizard depends on `auth/`, and `auth/` would depend back on the wizard).
|
|
7
|
+
* Duplicating the 12-line helper here keeps the dependency graph clean.
|
|
8
|
+
*
|
|
9
|
+
* Behaviour matches `commands/init/shared.ts#atomicWrite`: write to a sibling
|
|
10
|
+
* tmp file then `rename`. On POSIX same-filesystem, rename is atomic. The
|
|
11
|
+
* tmp suffix uses crypto-random bytes so concurrent writers don't collide.
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
export async function atomicWrite(targetPath, data, mode = 0o644) {
|
|
16
|
+
const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
|
|
17
|
+
try {
|
|
18
|
+
await fs.writeFile(tmpPath, data, { encoding: "utf8", mode });
|
|
19
|
+
// fs.writeFile only honours `mode` when the file is newly created.
|
|
20
|
+
// Tmp paths are always new, but be explicit to make this airtight if
|
|
21
|
+
// the random suffix ever collides with a stale tmp.
|
|
22
|
+
await fs.chmod(tmpPath, mode);
|
|
23
|
+
await fs.rename(tmpPath, targetPath);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless OAuth fallback: print the authorize URL, ask the user to open
|
|
3
|
+
* it in any browser, then paste the redirected URL (or just the `code`
|
|
4
|
+
* fragment) back into the terminal.
|
|
5
|
+
*
|
|
6
|
+
* This is the escape hatch for environments where:
|
|
7
|
+
* - The CLI can't open a browser (SSH session, remote shell).
|
|
8
|
+
* - The user explicitly passes `--no-browser`.
|
|
9
|
+
* - The localhost listener fails (port in use, firewall, restricted
|
|
10
|
+
* container).
|
|
11
|
+
*
|
|
12
|
+
* Linear's OAuth app config requires a redirect URI matching the
|
|
13
|
+
* `redirect_uri` we sent. If the user is somewhere they can't run a
|
|
14
|
+
* localhost listener, they can still paste the full callback URL —
|
|
15
|
+
* Linear's redirect happens client-side in the browser regardless of
|
|
16
|
+
* whether the URL is reachable.
|
|
17
|
+
*/
|
|
18
|
+
import { type CallbackParams } from "./oauth-client.js";
|
|
19
|
+
export interface PromptForPastedCodeOptions {
|
|
20
|
+
expectedState: string;
|
|
21
|
+
/** Test seam — defaults to @inquirer/prompts `input`. */
|
|
22
|
+
prompt?: (opts: {
|
|
23
|
+
message: string;
|
|
24
|
+
validate?: (value: string) => boolean | string;
|
|
25
|
+
}) => Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ask the user to paste either:
|
|
29
|
+
* - The full callback URL (we extract code+state ourselves), OR
|
|
30
|
+
* - Just the `code` value (we accept the user's word on state).
|
|
31
|
+
*
|
|
32
|
+
* For the URL form, state is validated against `expectedState`.
|
|
33
|
+
*
|
|
34
|
+
* Returns `{code, state}`. When the user pasted only a code, `state` is
|
|
35
|
+
* the expected value (the user has implicitly trusted it; we have no
|
|
36
|
+
* other check available).
|
|
37
|
+
*/
|
|
38
|
+
export declare function promptForPastedCode(options: PromptForPastedCodeOptions): Promise<CallbackParams>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless OAuth fallback: print the authorize URL, ask the user to open
|
|
3
|
+
* it in any browser, then paste the redirected URL (or just the `code`
|
|
4
|
+
* fragment) back into the terminal.
|
|
5
|
+
*
|
|
6
|
+
* This is the escape hatch for environments where:
|
|
7
|
+
* - The CLI can't open a browser (SSH session, remote shell).
|
|
8
|
+
* - The user explicitly passes `--no-browser`.
|
|
9
|
+
* - The localhost listener fails (port in use, firewall, restricted
|
|
10
|
+
* container).
|
|
11
|
+
*
|
|
12
|
+
* Linear's OAuth app config requires a redirect URI matching the
|
|
13
|
+
* `redirect_uri` we sent. If the user is somewhere they can't run a
|
|
14
|
+
* localhost listener, they can still paste the full callback URL —
|
|
15
|
+
* Linear's redirect happens client-side in the browser regardless of
|
|
16
|
+
* whether the URL is reachable.
|
|
17
|
+
*/
|
|
18
|
+
import { input } from "@inquirer/prompts";
|
|
19
|
+
import { parseCallbackUrl } from "./oauth-client.js";
|
|
20
|
+
/**
|
|
21
|
+
* Ask the user to paste either:
|
|
22
|
+
* - The full callback URL (we extract code+state ourselves), OR
|
|
23
|
+
* - Just the `code` value (we accept the user's word on state).
|
|
24
|
+
*
|
|
25
|
+
* For the URL form, state is validated against `expectedState`.
|
|
26
|
+
*
|
|
27
|
+
* Returns `{code, state}`. When the user pasted only a code, `state` is
|
|
28
|
+
* the expected value (the user has implicitly trusted it; we have no
|
|
29
|
+
* other check available).
|
|
30
|
+
*/
|
|
31
|
+
export async function promptForPastedCode(options) {
|
|
32
|
+
const ask = options.prompt ?? ((o) => input(o));
|
|
33
|
+
const raw = (await ask({
|
|
34
|
+
message: "Paste the full callback URL (or just the `code` value):",
|
|
35
|
+
validate: (value) => value.trim().length > 0 || "Cannot be empty",
|
|
36
|
+
})).trim();
|
|
37
|
+
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
38
|
+
const parsed = parseCallbackUrl(raw);
|
|
39
|
+
if (parsed.state !== options.expectedState) {
|
|
40
|
+
throw new Error("State mismatch — the pasted URL's `state` doesn't match the value we sent. Re-run `el-linear init oauth`.");
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
// Bare code path. Reject anything that looks like it has a query
|
|
45
|
+
// string but isn't a URL (the user partially copied something).
|
|
46
|
+
if (raw.includes("=") || raw.includes("?")) {
|
|
47
|
+
throw new Error("Pasted value looks malformed. Paste either the full callback URL or just the `code` value (alphanumeric + dashes).");
|
|
48
|
+
}
|
|
49
|
+
return { code: raw, state: options.expectedState };
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const OAUTH_STATE_VERSION = 1;
|
|
2
|
+
export declare const OAUTH_STATE_FILENAME = "oauth.json";
|
|
3
|
+
/**
|
|
4
|
+
* Persisted OAuth state. Mirrors what we got back from Linear's token
|
|
5
|
+
* endpoint plus the bits we need to refresh / re-authorize.
|
|
6
|
+
*/
|
|
7
|
+
export interface OAuthState {
|
|
8
|
+
v: typeof OAUTH_STATE_VERSION;
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientSecret?: string;
|
|
11
|
+
registeredRedirectUri: string;
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken?: string;
|
|
14
|
+
tokenType: string;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
/** Unix epoch milliseconds; computed at write time from `expires_in`. */
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
/** When we last fetched a token (for diagnostics). */
|
|
19
|
+
obtainedAt: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the path to the active profile's `oauth.json`. Mirrors
|
|
23
|
+
* `activePaths()` in `commands/init/shared.ts` so OAuth state lands in the
|
|
24
|
+
* same directory as the profile's `config.json` and `token`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function oauthStatePath(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Read the active profile's OAuth state, or `null` if none has been written.
|
|
29
|
+
* Returns `null` (not throw) on JSON parse errors so callers can fall back
|
|
30
|
+
* to personal-token auth without spamming users with repair instructions —
|
|
31
|
+
* the `init oauth` command is responsible for repair.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readOAuthState(): Promise<OAuthState | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Write the active profile's OAuth state atomically with mode 0600.
|
|
36
|
+
*
|
|
37
|
+
* IMPORTANT: uses the same write-tmp + rename pattern as `writeToken` so a
|
|
38
|
+
* pre-existing 0644 file gets its mode reset. Tokens leaking via group/other
|
|
39
|
+
* read is the failure mode we want to make impossible.
|
|
40
|
+
*/
|
|
41
|
+
export declare function writeOAuthState(state: OAuthState): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Delete the active profile's OAuth state. No-op if the file is already gone.
|
|
44
|
+
*/
|
|
45
|
+
export declare function clearOAuthState(): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Return `true` when the access token is still valid for at least
|
|
48
|
+
* `skewMs` milliseconds. Default 60s skew protects against clock drift +
|
|
49
|
+
* network latency.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isAccessTokenFresh(state: OAuthState, skewMs?: number): boolean;
|