@hostinger/mcp 1.52.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/.env.example +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +36 -0
- package/.github/workflows/build-release.yaml +163 -0
- package/CODEOWNERS +1 -0
- package/Dockerfile +15 -0
- package/LICENSE +21 -0
- package/README.md +4343 -0
- package/build.js +60 -0
- package/gemini-extension.json +25 -0
- package/glama.json +7 -0
- package/package.json +59 -0
- package/server.json +29 -0
- package/skills/headless/SKILL.md +55 -0
- package/skills/headless/entry/bootstrap.mjs +109 -0
- package/skills/headless/entry/skill.md +79 -0
- package/skills/headless/references/DEPLOYMENT.md +34 -0
- package/skills/headless/references/SETUP.md +34 -0
- package/skills/headless/references/STORE.md +52 -0
- package/skills/headless/references/WORDPRESS.md +51 -0
- package/src/core/oauth-error.html +90 -0
- package/src/core/oauth-success.html +77 -0
- package/src/core/oauth.js +406 -0
- package/src/core/oauth.ts +374 -0
- package/src/core/runtime.js +2673 -0
- package/src/core/runtime.ts +2724 -0
- package/src/core/tools/agency-hosting.js +1535 -0
- package/src/core/tools/agency-hosting.ts +1550 -0
- package/src/core/tools/all.js +15361 -0
- package/src/core/tools/all.ts +15376 -0
- package/src/core/tools/billing.js +310 -0
- package/src/core/tools/billing.ts +325 -0
- package/src/core/tools/dns.js +393 -0
- package/src/core/tools/dns.ts +408 -0
- package/src/core/tools/domains.js +1435 -0
- package/src/core/tools/domains.ts +1450 -0
- package/src/core/tools/ecommerce.js +1426 -0
- package/src/core/tools/ecommerce.ts +1441 -0
- package/src/core/tools/horizons.js +84 -0
- package/src/core/tools/horizons.ts +99 -0
- package/src/core/tools/hosting.js +2479 -0
- package/src/core/tools/hosting.ts +2494 -0
- package/src/core/tools/mail.js +1641 -0
- package/src/core/tools/mail.ts +1656 -0
- package/src/core/tools/reach.js +2270 -0
- package/src/core/tools/reach.ts +2285 -0
- package/src/core/tools/vps.js +2248 -0
- package/src/core/tools/vps.ts +2263 -0
- package/src/core/tools/wordpress.js +1570 -0
- package/src/core/tools/wordpress.ts +1585 -0
- package/src/servers/agency-hosting.js +6 -0
- package/src/servers/agency-hosting.ts +6 -0
- package/src/servers/all.js +6 -0
- package/src/servers/all.ts +6 -0
- package/src/servers/billing.js +6 -0
- package/src/servers/billing.ts +6 -0
- package/src/servers/dns.js +6 -0
- package/src/servers/dns.ts +6 -0
- package/src/servers/domains.js +6 -0
- package/src/servers/domains.ts +6 -0
- package/src/servers/ecommerce.js +6 -0
- package/src/servers/ecommerce.ts +6 -0
- package/src/servers/horizons.js +6 -0
- package/src/servers/horizons.ts +6 -0
- package/src/servers/hosting.js +6 -0
- package/src/servers/hosting.ts +6 -0
- package/src/servers/mail.js +6 -0
- package/src/servers/mail.ts +6 -0
- package/src/servers/reach.js +6 -0
- package/src/servers/reach.ts +6 -0
- package/src/servers/vps.js +6 -0
- package/src/servers/vps.ts +6 -0
- package/src/servers/wordpress.js +6 -0
- package/src/servers/wordpress.ts +6 -0
- package/tsconfig.json +15 -0
- package/types.d.ts +8836 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Authentication failed</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--primary: #673DE6;
|
|
13
|
+
--primary-hover: #5025D1;
|
|
14
|
+
--deep: #2F1C6A;
|
|
15
|
+
--bg: #F4F5FF;
|
|
16
|
+
--lavender: #b6b2ff;
|
|
17
|
+
}
|
|
18
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
19
|
+
html, body { height: 100%; margin: 0; }
|
|
20
|
+
body {
|
|
21
|
+
font-family: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--deep);
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
padding: 24px;
|
|
28
|
+
}
|
|
29
|
+
.card {
|
|
30
|
+
background: #ffffff;
|
|
31
|
+
border-radius: 16px;
|
|
32
|
+
box-shadow: 0 10px 40px rgba(31, 19, 70, 0.08);
|
|
33
|
+
padding: 48px 56px;
|
|
34
|
+
max-width: 480px;
|
|
35
|
+
width: 100%;
|
|
36
|
+
text-align: center;
|
|
37
|
+
}
|
|
38
|
+
.icon {
|
|
39
|
+
width: 72px;
|
|
40
|
+
height: 72px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: #ffffff;
|
|
43
|
+
color: var(--primary);
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
margin-bottom: 24px;
|
|
48
|
+
border: 2px solid var(--primary);
|
|
49
|
+
}
|
|
50
|
+
.icon svg { width: 36px; height: 36px; }
|
|
51
|
+
h1 {
|
|
52
|
+
font-size: 28px;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
letter-spacing: -0.01em;
|
|
55
|
+
margin: 0 0 12px;
|
|
56
|
+
color: var(--deep);
|
|
57
|
+
}
|
|
58
|
+
p {
|
|
59
|
+
font-size: 16px;
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
margin: 0 0 16px;
|
|
62
|
+
color: var(--deep);
|
|
63
|
+
opacity: 0.72;
|
|
64
|
+
}
|
|
65
|
+
.error-detail {
|
|
66
|
+
display: inline-block;
|
|
67
|
+
margin-top: 8px;
|
|
68
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Code", monospace;
|
|
69
|
+
font-size: 14px;
|
|
70
|
+
color: var(--deep);
|
|
71
|
+
background: var(--bg);
|
|
72
|
+
padding: 8px 14px;
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
border: 1px solid var(--lavender);
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<main class="card" role="alert" aria-live="assertive">
|
|
80
|
+
<div class="icon" aria-hidden="true">
|
|
81
|
+
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
82
|
+
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
83
|
+
</svg>
|
|
84
|
+
</div>
|
|
85
|
+
<h1>Authentication failed</h1>
|
|
86
|
+
<p>You can close this tab.</p>
|
|
87
|
+
<code class="error-detail">{{error}}</code>
|
|
88
|
+
</main>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Authentication successful</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--primary: #673DE6;
|
|
13
|
+
--primary-hover: #5025D1;
|
|
14
|
+
--deep: #2F1C6A;
|
|
15
|
+
--bg: #F4F5FF;
|
|
16
|
+
--lavender: #b6b2ff;
|
|
17
|
+
}
|
|
18
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
19
|
+
html, body { height: 100%; margin: 0; }
|
|
20
|
+
body {
|
|
21
|
+
font-family: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--deep);
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
padding: 24px;
|
|
28
|
+
}
|
|
29
|
+
.card {
|
|
30
|
+
background: #ffffff;
|
|
31
|
+
border-radius: 16px;
|
|
32
|
+
box-shadow: 0 10px 40px rgba(31, 19, 70, 0.08);
|
|
33
|
+
padding: 48px 56px;
|
|
34
|
+
max-width: 480px;
|
|
35
|
+
width: 100%;
|
|
36
|
+
text-align: center;
|
|
37
|
+
}
|
|
38
|
+
.icon {
|
|
39
|
+
width: 72px;
|
|
40
|
+
height: 72px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: var(--primary);
|
|
43
|
+
color: #ffffff;
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
margin-bottom: 24px;
|
|
48
|
+
}
|
|
49
|
+
.icon svg { width: 36px; height: 36px; }
|
|
50
|
+
h1 {
|
|
51
|
+
font-size: 28px;
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
letter-spacing: -0.01em;
|
|
54
|
+
margin: 0 0 12px;
|
|
55
|
+
color: var(--deep);
|
|
56
|
+
}
|
|
57
|
+
p {
|
|
58
|
+
font-size: 16px;
|
|
59
|
+
line-height: 1.5;
|
|
60
|
+
margin: 0;
|
|
61
|
+
color: var(--deep);
|
|
62
|
+
opacity: 0.72;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
<main class="card" role="status" aria-live="polite">
|
|
68
|
+
<div class="icon" aria-hidden="true">
|
|
69
|
+
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
70
|
+
<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
71
|
+
</svg>
|
|
72
|
+
</div>
|
|
73
|
+
<h1>Authentication successful</h1>
|
|
74
|
+
<p>You can close this tab and return to your application.</p>
|
|
75
|
+
</main>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "crypto";
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { exec } from "child_process";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import axios from "axios";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const SUCCESS_HTML = readFileSync(path.join(__dirname, "oauth-success.html"), "utf8");
|
|
13
|
+
const ERROR_HTML_TEMPLATE = readFileSync(path.join(__dirname, "oauth-error.html"), "utf8");
|
|
14
|
+
|
|
15
|
+
function escapeHtml(s) {
|
|
16
|
+
return String(s).replace(/[&<>"']/g, (ch) => ({
|
|
17
|
+
"&": "&",
|
|
18
|
+
"<": "<",
|
|
19
|
+
">": ">",
|
|
20
|
+
'"': """,
|
|
21
|
+
"'": "'",
|
|
22
|
+
})[ch]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ISSUER = "https://auth.hostinger.com";
|
|
26
|
+
const REGISTER_PATH = "/api/external/v1/oauth-server/register";
|
|
27
|
+
const AUTHORIZE_PATH = "/api/external/v1/oauth-server/authorize";
|
|
28
|
+
const TOKEN_PATH = "/api/external/v1/oauth-server/token";
|
|
29
|
+
const REVOKE_PATH = "/api/external/v1/oauth-server/token/revoke";
|
|
30
|
+
const CLIENT_NAME = "hostinger-mcp";
|
|
31
|
+
const CALLBACK_PATH = "/oauth/callback";
|
|
32
|
+
const CREDENTIALS_DIR_NAME = "hostinger-mcp";
|
|
33
|
+
const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
34
|
+
const EXPIRY_BUFFER_SECONDS = 60;
|
|
35
|
+
|
|
36
|
+
export class OAuthRefreshError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "OAuthRefreshError";
|
|
40
|
+
this.code = "OAUTH_INVALID_GRANT";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the bearer token from the environment. HOSTINGER_API_TOKEN is the
|
|
46
|
+
* preferred name; API_TOKEN and APITOKEN are kept as backwards-compatible
|
|
47
|
+
* aliases (API_TOKEN is deprecated and will be removed in a future version).
|
|
48
|
+
* Empty values fall through, matching the previous `||` behavior.
|
|
49
|
+
*/
|
|
50
|
+
export function getEnvToken() {
|
|
51
|
+
return (
|
|
52
|
+
process.env["HOSTINGER_API_TOKEN"] ||
|
|
53
|
+
process.env["API_TOKEN"] ||
|
|
54
|
+
process.env["APITOKEN"]
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class OAuthProvider {
|
|
59
|
+
constructor(issuerBaseUrl) {
|
|
60
|
+
this.issuer = (
|
|
61
|
+
issuerBaseUrl ||
|
|
62
|
+
process.env["OAUTH_ISSUER"] ||
|
|
63
|
+
DEFAULT_ISSUER
|
|
64
|
+
).replace(/\/$/, "");
|
|
65
|
+
this._loginInProgress = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getAccessToken() {
|
|
69
|
+
const envToken = getEnvToken();
|
|
70
|
+
if (envToken) {
|
|
71
|
+
return envToken;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!this._loginInProgress) {
|
|
75
|
+
this._loginInProgress = this._resolveToken().finally(() => {
|
|
76
|
+
this._loginInProgress = null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return await this._loginInProgress;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async _resolveToken() {
|
|
83
|
+
const creds = await this._load();
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
creds &&
|
|
87
|
+
creds.access_token &&
|
|
88
|
+
creds.expires_at &&
|
|
89
|
+
Date.now() < creds.expires_at
|
|
90
|
+
) {
|
|
91
|
+
return creds.access_token;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (creds && creds.refresh_token && creds.client_id) {
|
|
95
|
+
try {
|
|
96
|
+
return await this._refresh(creds);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.code !== "OAUTH_INVALID_GRANT") {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return await this._login();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async login() {
|
|
108
|
+
if (this._loginInProgress) {
|
|
109
|
+
return await this._loginInProgress;
|
|
110
|
+
}
|
|
111
|
+
this._loginInProgress = this._login().finally(() => {
|
|
112
|
+
this._loginInProgress = null;
|
|
113
|
+
});
|
|
114
|
+
return await this._loginInProgress;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Force a fresh token, bypassing the cached-token fast path. Called by the
|
|
119
|
+
* runtime when the API rejects an apparently-valid token with 401. Tries the
|
|
120
|
+
* refresh grant first; if the refresh token is also dead (4xx), falls through
|
|
121
|
+
* to the full browser login flow.
|
|
122
|
+
*/
|
|
123
|
+
async reauthenticate() {
|
|
124
|
+
if (!this._loginInProgress) {
|
|
125
|
+
this._loginInProgress = this._reauthenticate().finally(() => {
|
|
126
|
+
this._loginInProgress = null;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return await this._loginInProgress;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async _reauthenticate() {
|
|
133
|
+
const creds = await this._load();
|
|
134
|
+
if (creds && creds.refresh_token && creds.client_id) {
|
|
135
|
+
try {
|
|
136
|
+
return await this._refresh(creds);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code !== "OAUTH_INVALID_GRANT") {
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return await this._login();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async logout() {
|
|
147
|
+
const creds = await this._load();
|
|
148
|
+
if (!creds) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (creds.access_token && creds.client_id) {
|
|
152
|
+
try {
|
|
153
|
+
const params = new URLSearchParams();
|
|
154
|
+
params.set("token", creds.access_token);
|
|
155
|
+
params.set("client_id", creds.client_id);
|
|
156
|
+
await axios.post(`${this.issuer}${REVOKE_PATH}`, params.toString(), {
|
|
157
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
158
|
+
validateStatus: () => true,
|
|
159
|
+
});
|
|
160
|
+
} catch (_) {}
|
|
161
|
+
}
|
|
162
|
+
await this._save({ client_id: creds.client_id });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async _login() {
|
|
166
|
+
let creds = (await this._load()) || {};
|
|
167
|
+
|
|
168
|
+
const port = await this._getFreePort();
|
|
169
|
+
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
170
|
+
|
|
171
|
+
if (!creds.client_id) {
|
|
172
|
+
creds.client_id = await this._register(redirectUri);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { verifier, challenge } = this._generatePKCE();
|
|
176
|
+
const state = this._generateState();
|
|
177
|
+
|
|
178
|
+
const callbackPromise = this._listenForCallback(state, port);
|
|
179
|
+
|
|
180
|
+
const authorizeUrl = new URL(`${this.issuer}${AUTHORIZE_PATH}`);
|
|
181
|
+
authorizeUrl.searchParams.set("client_id", creds.client_id);
|
|
182
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
183
|
+
authorizeUrl.searchParams.set("state", state);
|
|
184
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
185
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
186
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
187
|
+
|
|
188
|
+
this._openBrowser(authorizeUrl.toString());
|
|
189
|
+
|
|
190
|
+
const { code } = await callbackPromise;
|
|
191
|
+
|
|
192
|
+
const params = new URLSearchParams();
|
|
193
|
+
params.set("grant_type", "authorization_code");
|
|
194
|
+
params.set("code", code);
|
|
195
|
+
params.set("code_verifier", verifier);
|
|
196
|
+
params.set("redirect_uri", redirectUri);
|
|
197
|
+
params.set("client_id", creds.client_id);
|
|
198
|
+
|
|
199
|
+
const resp = await axios.post(
|
|
200
|
+
`${this.issuer}${TOKEN_PATH}`,
|
|
201
|
+
params.toString(),
|
|
202
|
+
{
|
|
203
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
204
|
+
validateStatus: () => true,
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (resp.status >= 400) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Token exchange failed (${resp.status}): ${JSON.stringify(resp.data)}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const tokens = resp.data;
|
|
215
|
+
const newCreds = {
|
|
216
|
+
client_id: creds.client_id,
|
|
217
|
+
access_token: tokens.access_token,
|
|
218
|
+
refresh_token: tokens.refresh_token,
|
|
219
|
+
expires_at:
|
|
220
|
+
Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
|
|
221
|
+
};
|
|
222
|
+
await this._save(newCreds);
|
|
223
|
+
return tokens.access_token;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async _refresh(creds) {
|
|
227
|
+
const params = new URLSearchParams();
|
|
228
|
+
params.set("grant_type", "refresh_token");
|
|
229
|
+
params.set("refresh_token", creds.refresh_token);
|
|
230
|
+
params.set("client_id", creds.client_id);
|
|
231
|
+
|
|
232
|
+
const resp = await axios.post(
|
|
233
|
+
`${this.issuer}${TOKEN_PATH}`,
|
|
234
|
+
params.toString(),
|
|
235
|
+
{
|
|
236
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
237
|
+
validateStatus: () => true,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (resp.status >= 400 && resp.status < 500) {
|
|
242
|
+
throw new OAuthRefreshError(
|
|
243
|
+
`Refresh token rejected (${resp.status}): ${JSON.stringify(resp.data)}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (resp.status >= 500) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Token refresh failed (${resp.status}): ${JSON.stringify(resp.data)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const tokens = resp.data;
|
|
254
|
+
const newCreds = {
|
|
255
|
+
client_id: creds.client_id,
|
|
256
|
+
access_token: tokens.access_token,
|
|
257
|
+
refresh_token: tokens.refresh_token || creds.refresh_token,
|
|
258
|
+
expires_at:
|
|
259
|
+
Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
|
|
260
|
+
};
|
|
261
|
+
await this._save(newCreds);
|
|
262
|
+
return tokens.access_token;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _register(redirectUri) {
|
|
266
|
+
const resp = await axios.post(
|
|
267
|
+
`${this.issuer}${REGISTER_PATH}`,
|
|
268
|
+
{
|
|
269
|
+
client_name: CLIENT_NAME,
|
|
270
|
+
redirect_uris: [redirectUri],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
validateStatus: () => true,
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (resp.status >= 400 || !resp.data || !resp.data.client_id) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Client registration failed (${resp.status}): ${JSON.stringify(resp.data)}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return resp.data.client_id;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_generatePKCE() {
|
|
287
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
288
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
289
|
+
return { verifier, challenge };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_generateState() {
|
|
293
|
+
return randomBytes(16).toString("hex");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_listenForCallback(expectedState, port) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
const server = createServer((req, res) => {
|
|
299
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
300
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
301
|
+
res.writeHead(404);
|
|
302
|
+
res.end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const code = url.searchParams.get("code");
|
|
307
|
+
const state = url.searchParams.get("state");
|
|
308
|
+
const error = url.searchParams.get("error");
|
|
309
|
+
|
|
310
|
+
const body = error
|
|
311
|
+
? ERROR_HTML_TEMPLATE.replace("{{error}}", escapeHtml(error))
|
|
312
|
+
: SUCCESS_HTML;
|
|
313
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
314
|
+
res.end(body);
|
|
315
|
+
|
|
316
|
+
setImmediate(() => server.close());
|
|
317
|
+
|
|
318
|
+
if (error) {
|
|
319
|
+
return reject(
|
|
320
|
+
new Error(`OAuth error from authorization server: ${error}`),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (!state || state !== expectedState) {
|
|
324
|
+
return reject(new Error("OAuth state mismatch"));
|
|
325
|
+
}
|
|
326
|
+
if (!code) {
|
|
327
|
+
return reject(new Error("No authorization code received"));
|
|
328
|
+
}
|
|
329
|
+
resolve({ code, port });
|
|
330
|
+
});
|
|
331
|
+
server.on("error", reject);
|
|
332
|
+
server.listen(port, "127.0.0.1");
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_getFreePort() {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const srv = createServer();
|
|
339
|
+
srv.unref();
|
|
340
|
+
srv.on("error", reject);
|
|
341
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
342
|
+
const addr = srv.address();
|
|
343
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
344
|
+
srv.close(() => resolve(port));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_credentialsPath() {
|
|
350
|
+
if (process.platform === "win32") {
|
|
351
|
+
const base = process.env["APPDATA"] || os.homedir();
|
|
352
|
+
return path.join(base, CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
|
|
353
|
+
}
|
|
354
|
+
return path.join(
|
|
355
|
+
os.homedir(),
|
|
356
|
+
".config",
|
|
357
|
+
CREDENTIALS_DIR_NAME,
|
|
358
|
+
CREDENTIALS_FILE_NAME,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async _load() {
|
|
363
|
+
const p = this._credentialsPath();
|
|
364
|
+
if (!existsSync(p)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const raw = await readFile(p, "utf8");
|
|
369
|
+
return JSON.parse(raw);
|
|
370
|
+
} catch (_) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async _save(creds) {
|
|
376
|
+
const p = this._credentialsPath();
|
|
377
|
+
await mkdir(path.dirname(p), { recursive: true });
|
|
378
|
+
const writeOpts =
|
|
379
|
+
process.platform === "win32"
|
|
380
|
+
? { encoding: "utf8" }
|
|
381
|
+
: { encoding: "utf8", mode: 0o600 };
|
|
382
|
+
await writeFile(p, JSON.stringify(creds, null, 2), writeOpts);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_openBrowser(url) {
|
|
386
|
+
process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
|
|
387
|
+
process.stderr.write(
|
|
388
|
+
"[OAuth] If the browser does not open, copy the URL above into one manually.\n\n",
|
|
389
|
+
);
|
|
390
|
+
let cmd;
|
|
391
|
+
if (process.platform === "darwin") {
|
|
392
|
+
cmd = `open "${url}"`;
|
|
393
|
+
} else if (process.platform === "win32") {
|
|
394
|
+
cmd = `start "" "${url}"`;
|
|
395
|
+
} else {
|
|
396
|
+
cmd = `xdg-open "${url}"`;
|
|
397
|
+
}
|
|
398
|
+
exec(cmd, (err) => {
|
|
399
|
+
if (err) {
|
|
400
|
+
process.stderr.write(
|
|
401
|
+
`[OAuth] Could not auto-launch browser: ${err.message}\n`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|