passwd-sso-cli 0.4.4 → 0.4.6
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/dist/commands/login.d.ts +6 -2
- package/dist/commands/login.js +69 -16
- package/dist/index.js +4 -2
- package/dist/lib/api-client.d.ts +3 -3
- package/dist/lib/api-client.js +40 -42
- package/dist/lib/config.d.ts +21 -6
- package/dist/lib/config.js +51 -44
- package/dist/lib/oauth.d.ts +60 -0
- package/dist/lib/oauth.js +321 -0
- package/package.json +1 -4
package/dist/commands/login.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `passwd-sso login` —
|
|
2
|
+
* `passwd-sso login` — Authenticate via OAuth 2.1 PKCE or manual token paste.
|
|
3
3
|
*/
|
|
4
|
-
export
|
|
4
|
+
export interface LoginOptions {
|
|
5
|
+
useToken?: boolean;
|
|
6
|
+
server?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function loginCommand(opts?: LoginOptions): Promise<void>;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `passwd-sso login` —
|
|
2
|
+
* `passwd-sso login` — Authenticate via OAuth 2.1 PKCE or manual token paste.
|
|
3
3
|
*/
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
-
import { loadConfig, saveConfig,
|
|
5
|
+
import { loadConfig, saveConfig, loadCredentials, saveCredentials } from "../lib/config.js";
|
|
6
6
|
import { setTokenCache } from "../lib/api-client.js";
|
|
7
|
+
import { runOAuthFlow, revokeTokenRequest, validateServerUrl } from "../lib/oauth.js";
|
|
7
8
|
import * as output from "../lib/output.js";
|
|
8
9
|
async function prompt(question) {
|
|
9
10
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -14,32 +15,84 @@ async function prompt(question) {
|
|
|
14
15
|
});
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
|
-
export async function loginCommand() {
|
|
18
|
+
export async function loginCommand(opts = {}) {
|
|
18
19
|
const config = loadConfig();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Resolve server URL
|
|
21
|
+
const serverInput = opts.server ?? await prompt(`Server URL${config.serverUrl ? ` [${config.serverUrl}]` : ""}: `);
|
|
22
|
+
if (serverInput) {
|
|
23
|
+
config.serverUrl = serverInput.replace(/\/$/, "");
|
|
22
24
|
}
|
|
23
25
|
if (!config.serverUrl) {
|
|
24
26
|
output.error("Server URL is required.");
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
29
|
+
try {
|
|
30
|
+
validateServerUrl(config.serverUrl);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
output.error(err instanceof Error ? err.message : "Invalid server URL");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
saveConfig(config);
|
|
37
|
+
// Revoke previous session before starting new login
|
|
38
|
+
await revokeExistingSession(config.serverUrl);
|
|
39
|
+
if (opts.useToken) {
|
|
40
|
+
// Manual token paste fallback (CI / headless without callback)
|
|
41
|
+
await manualTokenLogin(config.serverUrl);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// OAuth 2.1 Authorization Code + PKCE (default)
|
|
45
|
+
await oauthLogin(config.serverUrl);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Best-effort revocation of the previous session's tokens. */
|
|
49
|
+
async function revokeExistingSession(serverUrl) {
|
|
50
|
+
const creds = loadCredentials();
|
|
51
|
+
if (!creds || !creds.refreshToken || !creds.clientId)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
// Revoking the refresh token also revokes all access tokens in the family
|
|
55
|
+
await revokeTokenRequest(serverUrl, creds.refreshToken, creds.clientId, "refresh_token");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Best-effort — failure here should not block login
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function oauthLogin(serverUrl) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await runOAuthFlow(serverUrl);
|
|
64
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
|
|
65
|
+
saveCredentials({
|
|
66
|
+
accessToken: result.accessToken,
|
|
67
|
+
refreshToken: result.refreshToken,
|
|
68
|
+
clientId: result.clientId,
|
|
69
|
+
expiresAt,
|
|
70
|
+
});
|
|
71
|
+
setTokenCache(result.accessToken, expiresAt, result.refreshToken, result.clientId);
|
|
72
|
+
output.success(`Logged in to ${serverUrl}`);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
output.error(err instanceof Error ? err.message : "OAuth login failed");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function manualTokenLogin(serverUrl) {
|
|
27
79
|
output.info("Open your browser and go to the token page to generate a CLI token.");
|
|
28
|
-
output.info(` ${
|
|
80
|
+
output.info(` ${serverUrl}/dashboard/settings/developer`);
|
|
29
81
|
console.log();
|
|
30
82
|
const token = await prompt("Paste your token: ");
|
|
31
83
|
if (!token) {
|
|
32
84
|
output.error("Token is required.");
|
|
33
85
|
return;
|
|
34
86
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
output.
|
|
87
|
+
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
|
88
|
+
saveCredentials({
|
|
89
|
+
accessToken: token,
|
|
90
|
+
refreshToken: "",
|
|
91
|
+
clientId: "",
|
|
92
|
+
expiresAt,
|
|
93
|
+
});
|
|
94
|
+
setTokenCache(token, expiresAt);
|
|
95
|
+
output.warn("Manual token will not auto-refresh. Use `passwd-sso login` for persistent sessions.");
|
|
96
|
+
output.success(`Logged in to ${serverUrl}`);
|
|
44
97
|
}
|
|
45
98
|
//# sourceMappingURL=login.js.map
|
package/dist/index.js
CHANGED
|
@@ -40,8 +40,10 @@ program
|
|
|
40
40
|
});
|
|
41
41
|
program
|
|
42
42
|
.command("login")
|
|
43
|
-
.description("
|
|
44
|
-
.
|
|
43
|
+
.description("Authenticate via browser (OAuth 2.1 PKCE) or paste a token")
|
|
44
|
+
.option("--token", "Use manual token paste instead of browser OAuth")
|
|
45
|
+
.option("-s, --server <url>", "Server URL")
|
|
46
|
+
.action((opts) => loginCommand({ useToken: opts.token, server: opts.server }));
|
|
45
47
|
program
|
|
46
48
|
.command("status")
|
|
47
49
|
.description("Show connection and vault status")
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* API client for the CLI tool.
|
|
3
3
|
*
|
|
4
4
|
* Uses native Node.js fetch with Bearer token authentication.
|
|
5
|
-
* Automatically refreshes expired tokens.
|
|
5
|
+
* Automatically refreshes expired tokens via OAuth 2.1 refresh_token grant.
|
|
6
6
|
*/
|
|
7
7
|
export declare function setInsecure(enabled: boolean): void;
|
|
8
|
-
export declare function getToken():
|
|
9
|
-
export declare function setTokenCache(token: string, expiresAt?: string): void;
|
|
8
|
+
export declare function getToken(): string | null;
|
|
9
|
+
export declare function setTokenCache(token: string, expiresAt?: string, refreshToken?: string, clientId?: string): void;
|
|
10
10
|
export declare function clearTokenCache(): void;
|
|
11
11
|
export interface ApiResponse<T = unknown> {
|
|
12
12
|
ok: boolean;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -2,32 +2,49 @@
|
|
|
2
2
|
* API client for the CLI tool.
|
|
3
3
|
*
|
|
4
4
|
* Uses native Node.js fetch with Bearer token authentication.
|
|
5
|
-
* Automatically refreshes expired tokens.
|
|
5
|
+
* Automatically refreshes expired tokens via OAuth 2.1 refresh_token grant.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { loadCredentials, saveCredentials, loadConfig } from "./config.js";
|
|
8
|
+
import { refreshTokenGrant } from "./oauth.js";
|
|
8
9
|
let cachedToken = null;
|
|
9
10
|
let cachedExpiresAt = null;
|
|
11
|
+
let cachedRefreshToken = null;
|
|
12
|
+
let cachedClientId = null;
|
|
10
13
|
export function setInsecure(enabled) {
|
|
11
14
|
if (enabled) {
|
|
12
15
|
process.stderr.write("WARNING: TLS certificate verification is disabled. Your credentials may be intercepted.\n");
|
|
13
16
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
14
17
|
}
|
|
15
18
|
}
|
|
16
|
-
export
|
|
19
|
+
export function getToken() {
|
|
17
20
|
if (cachedToken)
|
|
18
21
|
return cachedToken;
|
|
19
|
-
|
|
22
|
+
const creds = loadCredentials();
|
|
23
|
+
if (!creds)
|
|
24
|
+
return null;
|
|
25
|
+
cachedToken = creds.accessToken;
|
|
26
|
+
cachedExpiresAt = new Date(creds.expiresAt).getTime();
|
|
27
|
+
cachedRefreshToken = creds.refreshToken || null;
|
|
28
|
+
cachedClientId = creds.clientId || null;
|
|
20
29
|
return cachedToken;
|
|
21
30
|
}
|
|
22
|
-
export function setTokenCache(token, expiresAt) {
|
|
31
|
+
export function setTokenCache(token, expiresAt, refreshToken, clientId) {
|
|
23
32
|
cachedToken = token;
|
|
24
33
|
if (expiresAt) {
|
|
25
34
|
cachedExpiresAt = new Date(expiresAt).getTime();
|
|
26
35
|
}
|
|
36
|
+
if (refreshToken !== undefined) {
|
|
37
|
+
cachedRefreshToken = refreshToken || null;
|
|
38
|
+
}
|
|
39
|
+
if (clientId !== undefined) {
|
|
40
|
+
cachedClientId = clientId || null;
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
43
|
export function clearTokenCache() {
|
|
29
44
|
cachedToken = null;
|
|
30
45
|
cachedExpiresAt = null;
|
|
46
|
+
cachedRefreshToken = null;
|
|
47
|
+
cachedClientId = null;
|
|
31
48
|
}
|
|
32
49
|
function getBaseUrl() {
|
|
33
50
|
const config = loadConfig();
|
|
@@ -36,46 +53,28 @@ function getBaseUrl() {
|
|
|
36
53
|
}
|
|
37
54
|
return config.serverUrl.replace(/\/$/, "");
|
|
38
55
|
}
|
|
39
|
-
/** Refresh buffer: refresh 2 minutes before expiry */
|
|
40
56
|
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
|
|
41
57
|
function isTokenExpiringSoon() {
|
|
42
|
-
if (!cachedExpiresAt) {
|
|
43
|
-
// Load from config if not cached
|
|
44
|
-
const config = loadConfig();
|
|
45
|
-
if (config.tokenExpiresAt) {
|
|
46
|
-
cachedExpiresAt = new Date(config.tokenExpiresAt).getTime();
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
58
|
if (!cachedExpiresAt)
|
|
50
59
|
return false;
|
|
51
60
|
return Date.now() >= cachedExpiresAt - REFRESH_BUFFER_MS;
|
|
52
61
|
}
|
|
53
62
|
async function refreshToken() {
|
|
54
|
-
|
|
55
|
-
if (!token)
|
|
63
|
+
if (!cachedRefreshToken || !cachedClientId)
|
|
56
64
|
return false;
|
|
57
65
|
const baseUrl = getBaseUrl();
|
|
58
66
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
headers: {
|
|
62
|
-
Authorization: `Bearer ${token}`,
|
|
63
|
-
"Content-Type": "application/json",
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
if (!res.ok)
|
|
67
|
-
return false;
|
|
68
|
-
const data = (await res.json());
|
|
69
|
-
if (typeof data.token !== "string" || !data.token)
|
|
67
|
+
const result = await refreshTokenGrant(baseUrl, cachedRefreshToken, cachedClientId);
|
|
68
|
+
if (!result || !result.refreshToken)
|
|
70
69
|
return false;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
|
|
71
|
+
saveCredentials({
|
|
72
|
+
accessToken: result.accessToken,
|
|
73
|
+
refreshToken: result.refreshToken,
|
|
74
|
+
clientId: cachedClientId,
|
|
75
|
+
expiresAt,
|
|
76
|
+
});
|
|
77
|
+
setTokenCache(result.accessToken, expiresAt, result.refreshToken, cachedClientId);
|
|
79
78
|
return true;
|
|
80
79
|
}
|
|
81
80
|
catch {
|
|
@@ -83,15 +82,14 @@ async function refreshToken() {
|
|
|
83
82
|
}
|
|
84
83
|
}
|
|
85
84
|
export async function apiRequest(path, options = {}) {
|
|
86
|
-
let token =
|
|
85
|
+
let token = getToken();
|
|
87
86
|
if (!token) {
|
|
88
87
|
throw new Error("Not logged in. Run `passwd-sso login` first.");
|
|
89
88
|
}
|
|
90
|
-
// Proactively refresh if token is expiring soon
|
|
91
89
|
if (isTokenExpiringSoon()) {
|
|
92
90
|
const refreshed = await refreshToken();
|
|
93
91
|
if (refreshed) {
|
|
94
|
-
token =
|
|
92
|
+
token = getToken();
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
const baseUrl = getBaseUrl();
|
|
@@ -109,11 +107,10 @@ export async function apiRequest(path, options = {}) {
|
|
|
109
107
|
fetchOpts.body = JSON.stringify(body);
|
|
110
108
|
}
|
|
111
109
|
let res = await fetch(url, fetchOpts);
|
|
112
|
-
// Auto-refresh on 401
|
|
113
110
|
if (res.status === 401) {
|
|
114
111
|
const refreshed = await refreshToken();
|
|
115
112
|
if (refreshed) {
|
|
116
|
-
const newToken =
|
|
113
|
+
const newToken = getToken();
|
|
117
114
|
fetchOpts.headers = {
|
|
118
115
|
...fetchOpts.headers,
|
|
119
116
|
Authorization: `Bearer ${newToken}`,
|
|
@@ -125,15 +122,16 @@ export async function apiRequest(path, options = {}) {
|
|
|
125
122
|
return { ok: res.ok, status: res.status, data };
|
|
126
123
|
}
|
|
127
124
|
// ─── Background Token Refresh Timer ─────────────────────────
|
|
128
|
-
|
|
129
|
-
const BG_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
125
|
+
const BG_REFRESH_INTERVAL_MS = 50 * 60 * 1000;
|
|
130
126
|
let bgRefreshTimer = null;
|
|
131
127
|
export function startBackgroundRefresh() {
|
|
132
128
|
stopBackgroundRefresh();
|
|
129
|
+
// Skip timer entirely for manual --token login (no refresh token)
|
|
130
|
+
if (!cachedRefreshToken)
|
|
131
|
+
return;
|
|
133
132
|
bgRefreshTimer = setInterval(() => {
|
|
134
133
|
void refreshToken();
|
|
135
134
|
}, BG_REFRESH_INTERVAL_MS);
|
|
136
|
-
// Don't keep the process alive just for the timer
|
|
137
135
|
bgRefreshTimer.unref();
|
|
138
136
|
}
|
|
139
137
|
export function stopBackgroundRefresh() {
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration management for the CLI tool.
|
|
3
3
|
*
|
|
4
|
-
* Config file:
|
|
5
|
-
* Credentials:
|
|
4
|
+
* Config file: $XDG_CONFIG_HOME/passwd-sso/config.json
|
|
5
|
+
* Credentials: $XDG_DATA_HOME/passwd-sso/credentials (mode 0o600, JSON)
|
|
6
6
|
*
|
|
7
7
|
* Legacy ~/.passwd-sso/ is auto-migrated on first access.
|
|
8
8
|
*/
|
|
9
9
|
export interface CliConfig {
|
|
10
10
|
serverUrl: string;
|
|
11
11
|
locale: string;
|
|
12
|
-
tokenExpiresAt?: string;
|
|
13
12
|
}
|
|
14
13
|
export declare function loadConfig(): CliConfig;
|
|
15
14
|
export declare function saveConfig(config: CliConfig): void;
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export interface StoredCredentials {
|
|
16
|
+
accessToken: string;
|
|
17
|
+
refreshToken: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
expiresAt: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Write credentials to the data directory using O_NOFOLLOW to prevent
|
|
23
|
+
* symlink attacks. File is created with mode 0o600 (owner read/write only).
|
|
24
|
+
*/
|
|
25
|
+
export declare function saveCredentials(creds: StoredCredentials): void;
|
|
26
|
+
/**
|
|
27
|
+
* Load stored credentials. Returns null if the file does not exist,
|
|
28
|
+
* cannot be parsed as JSON, or is missing required fields (e.g. legacy
|
|
29
|
+
* plaintext token format written by older CLI versions).
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadCredentials(): StoredCredentials | null;
|
|
32
|
+
/** Remove the stored credentials file. Silently ignores missing-file errors. */
|
|
33
|
+
export declare function deleteCredentials(): void;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration management for the CLI tool.
|
|
3
3
|
*
|
|
4
|
-
* Config file:
|
|
5
|
-
* Credentials:
|
|
4
|
+
* Config file: $XDG_CONFIG_HOME/passwd-sso/config.json
|
|
5
|
+
* Credentials: $XDG_DATA_HOME/passwd-sso/credentials (mode 0o600, JSON)
|
|
6
6
|
*
|
|
7
7
|
* Legacy ~/.passwd-sso/ is auto-migrated on first access.
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, openSync, writeSync, closeSync, constants as fsConstants } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, openSync, writeSync, closeSync, unlinkSync, constants as fsConstants, } from "node:fs";
|
|
10
10
|
import { getConfigDir, getDataDir, getConfigFilePath, getCredentialsFilePath, } from "./paths.js";
|
|
11
11
|
import { migrateIfNeeded } from "./migrate.js";
|
|
12
|
-
const KEYCHAIN_SERVICE = "passwd-sso-cli";
|
|
13
|
-
const KEYCHAIN_ACCOUNT = "bearer-token";
|
|
14
12
|
const DEFAULT_CONFIG = {
|
|
15
13
|
serverUrl: "",
|
|
16
14
|
locale: "en",
|
|
@@ -40,28 +38,15 @@ export function loadConfig() {
|
|
|
40
38
|
}
|
|
41
39
|
export function saveConfig(config) {
|
|
42
40
|
ensureConfigDir();
|
|
43
|
-
writeFileSync(getConfigFilePath(), JSON.stringify(config, null, 2), {
|
|
41
|
+
writeFileSync(getConfigFilePath(), JSON.stringify(config, null, 2), {
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
});
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const mod = await import("keytar");
|
|
51
|
-
// Dynamic import may wrap the CJS module in { default: ... }
|
|
52
|
-
return (mod.default ?? mod);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
export async function saveToken(token) {
|
|
59
|
-
const kt = await tryKeytar();
|
|
60
|
-
if (kt) {
|
|
61
|
-
await kt.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, token);
|
|
62
|
-
return "keychain";
|
|
63
|
-
}
|
|
64
|
-
// File fallback — use O_NOFOLLOW to prevent symlink attacks (TOCTOU-safe)
|
|
45
|
+
/**
|
|
46
|
+
* Write credentials to the data directory using O_NOFOLLOW to prevent
|
|
47
|
+
* symlink attacks. File is created with mode 0o600 (owner read/write only).
|
|
48
|
+
*/
|
|
49
|
+
export function saveCredentials(creds) {
|
|
65
50
|
ensureDataDir();
|
|
66
51
|
const dataDir = getDataDir();
|
|
67
52
|
const stat = lstatSync(dataDir);
|
|
@@ -69,38 +54,60 @@ export async function saveToken(token) {
|
|
|
69
54
|
throw new Error("Data directory is a symlink — refusing to write credentials.");
|
|
70
55
|
}
|
|
71
56
|
const credPath = getCredentialsFilePath();
|
|
72
|
-
const fd = openSync(credPath, fsConstants.O_WRONLY |
|
|
57
|
+
const fd = openSync(credPath, fsConstants.O_WRONLY |
|
|
58
|
+
fsConstants.O_CREAT |
|
|
59
|
+
fsConstants.O_TRUNC |
|
|
60
|
+
(fsConstants.O_NOFOLLOW ?? 0), 0o600);
|
|
73
61
|
try {
|
|
74
|
-
writeSync(fd,
|
|
62
|
+
writeSync(fd, JSON.stringify(creds)); // codeql[js/network-data-written-to-file] OAuth tokens are intentionally persisted for session continuity
|
|
75
63
|
}
|
|
76
64
|
finally {
|
|
77
65
|
closeSync(fd);
|
|
78
66
|
}
|
|
79
|
-
return "file";
|
|
80
67
|
}
|
|
81
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Load stored credentials. Returns null if the file does not exist,
|
|
70
|
+
* cannot be parsed as JSON, or is missing required fields (e.g. legacy
|
|
71
|
+
* plaintext token format written by older CLI versions).
|
|
72
|
+
*/
|
|
73
|
+
export function loadCredentials() {
|
|
82
74
|
migrateIfNeeded();
|
|
83
|
-
const kt = await tryKeytar();
|
|
84
|
-
if (kt) {
|
|
85
|
-
const token = await kt.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
86
|
-
if (token)
|
|
87
|
-
return token;
|
|
88
|
-
}
|
|
89
|
-
// File fallback
|
|
90
75
|
try {
|
|
91
|
-
|
|
76
|
+
const raw = readFileSync(getCredentialsFilePath(), "utf-8").trim();
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Legacy plaintext token — prompt user to re-login
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (parsed === null ||
|
|
86
|
+
typeof parsed !== "object" ||
|
|
87
|
+
Array.isArray(parsed)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const obj = parsed;
|
|
91
|
+
if (typeof obj.accessToken !== "string" ||
|
|
92
|
+
typeof obj.refreshToken !== "string" ||
|
|
93
|
+
typeof obj.clientId !== "string" ||
|
|
94
|
+
typeof obj.expiresAt !== "string") {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
accessToken: obj.accessToken,
|
|
99
|
+
refreshToken: obj.refreshToken,
|
|
100
|
+
clientId: obj.clientId,
|
|
101
|
+
expiresAt: obj.expiresAt,
|
|
102
|
+
};
|
|
92
103
|
}
|
|
93
104
|
catch {
|
|
94
105
|
return null;
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (kt) {
|
|
100
|
-
await kt.deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
101
|
-
}
|
|
108
|
+
/** Remove the stored credentials file. Silently ignores missing-file errors. */
|
|
109
|
+
export function deleteCredentials() {
|
|
102
110
|
try {
|
|
103
|
-
const { unlinkSync } = await import("node:fs");
|
|
104
111
|
unlinkSync(getCredentialsFilePath());
|
|
105
112
|
}
|
|
106
113
|
catch {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 Authorization Code + PKCE client for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Uses only Node.js built-in modules (node:crypto, node:http, node:child_process).
|
|
5
|
+
* Implements DCR (RFC 7591) for client registration against the MCP OAuth endpoints.
|
|
6
|
+
*/
|
|
7
|
+
export interface OAuthResult {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
refreshToken: string;
|
|
10
|
+
expiresIn: number;
|
|
11
|
+
scope: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TokenResponse {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
refreshToken: string;
|
|
17
|
+
expiresIn: number;
|
|
18
|
+
scope: string;
|
|
19
|
+
}
|
|
20
|
+
interface CallbackResult {
|
|
21
|
+
code: string;
|
|
22
|
+
state: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function generateCodeVerifier(): string;
|
|
25
|
+
export declare function computeS256Challenge(verifier: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Start a local HTTP server on port 0 (OS-assigned) that waits for the
|
|
28
|
+
* OAuth redirect callback. Returns the assigned port and a promise that
|
|
29
|
+
* resolves with the authorization code.
|
|
30
|
+
*
|
|
31
|
+
* Verifies the state parameter using constant-time comparison (RFC 9700 §2.1.2).
|
|
32
|
+
*/
|
|
33
|
+
export declare function startCallbackServer(expectedState: string): Promise<{
|
|
34
|
+
port: number;
|
|
35
|
+
waitForCallback: () => Promise<CallbackResult>;
|
|
36
|
+
}>;
|
|
37
|
+
/** Register a new public OAuth client via Dynamic Client Registration (RFC 7591). */
|
|
38
|
+
export declare function registerClient(serverUrl: string, redirectUri: string): Promise<{
|
|
39
|
+
clientId: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Exchange an authorization code for access + refresh tokens. */
|
|
42
|
+
export declare function exchangeCode(serverUrl: string, params: {
|
|
43
|
+
code: string;
|
|
44
|
+
redirectUri: string;
|
|
45
|
+
clientId: string;
|
|
46
|
+
codeVerifier: string;
|
|
47
|
+
}): Promise<OAuthResult>;
|
|
48
|
+
/** Exchange a refresh token for a new access + refresh token pair. */
|
|
49
|
+
export declare function refreshTokenGrant(serverUrl: string, refreshToken: string, clientId: string): Promise<TokenResponse | null>;
|
|
50
|
+
/** Revoke a token via the server's revocation endpoint. Best-effort — does not throw. */
|
|
51
|
+
export declare function revokeTokenRequest(serverUrl: string, token: string, clientId: string, tokenTypeHint?: "access_token" | "refresh_token"): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Attempt to open the URL in the system browser.
|
|
54
|
+
* Returns false when running in a headless environment (no display server).
|
|
55
|
+
*/
|
|
56
|
+
export declare function openBrowser(url: string): boolean;
|
|
57
|
+
/** Reject non-HTTPS URLs except for loopback development. */
|
|
58
|
+
export declare function validateServerUrl(url: string): void;
|
|
59
|
+
export declare function runOAuthFlow(serverUrl: string): Promise<OAuthResult>;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 Authorization Code + PKCE client for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Uses only Node.js built-in modules (node:crypto, node:http, node:child_process).
|
|
5
|
+
* Implements DCR (RFC 7591) for client registration against the MCP OAuth endpoints.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { createServer, } from "node:http";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
const CALLBACK_TIMEOUT_MS = 120_000; // 2 minutes
|
|
11
|
+
const CLI_CLIENT_NAME = "passwd-sso-cli";
|
|
12
|
+
const CLI_SCOPES = "credentials:list credentials:use vault:status vault:unlock-data passwords:read passwords:write";
|
|
13
|
+
const MCP_TOKEN_ENDPOINT = "/api/mcp/token";
|
|
14
|
+
// ─── PKCE helpers ─────────────────────────────────────────────────────────────
|
|
15
|
+
export function generateCodeVerifier() {
|
|
16
|
+
return randomBytes(32).toString("base64url");
|
|
17
|
+
}
|
|
18
|
+
export function computeS256Challenge(verifier) {
|
|
19
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
20
|
+
}
|
|
21
|
+
// ─── HTML templates ──────────────────────────────────────────────────────────
|
|
22
|
+
function escapeHtml(s) {
|
|
23
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
24
|
+
}
|
|
25
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head><meta charset="utf-8"><title>Login successful</title>
|
|
28
|
+
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
29
|
+
.box{text-align:center;max-width:400px}</style></head>
|
|
30
|
+
<body><div class="box">
|
|
31
|
+
<h2>Login successful</h2>
|
|
32
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
33
|
+
</div></body></html>`;
|
|
34
|
+
function errorHtml(msg) {
|
|
35
|
+
return `<!DOCTYPE html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head><meta charset="utf-8"><title>Login failed</title>
|
|
38
|
+
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
39
|
+
.box{text-align:center;max-width:400px}</style></head>
|
|
40
|
+
<body><div class="box">
|
|
41
|
+
<h2>Login failed</h2>
|
|
42
|
+
<p>${escapeHtml(msg)}</p>
|
|
43
|
+
<p>Please return to the terminal.</p>
|
|
44
|
+
</div></body></html>`;
|
|
45
|
+
}
|
|
46
|
+
// ─── Loopback callback server ─────────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Start a local HTTP server on port 0 (OS-assigned) that waits for the
|
|
49
|
+
* OAuth redirect callback. Returns the assigned port and a promise that
|
|
50
|
+
* resolves with the authorization code.
|
|
51
|
+
*
|
|
52
|
+
* Verifies the state parameter using constant-time comparison (RFC 9700 §2.1.2).
|
|
53
|
+
*/
|
|
54
|
+
export async function startCallbackServer(expectedState) {
|
|
55
|
+
let resolve;
|
|
56
|
+
let reject;
|
|
57
|
+
const callbackPromise = new Promise((res, rej) => {
|
|
58
|
+
resolve = res;
|
|
59
|
+
reject = rej;
|
|
60
|
+
});
|
|
61
|
+
// Prevent unhandled rejection — errors are consumed via waitForCallback()
|
|
62
|
+
callbackPromise.catch(() => { });
|
|
63
|
+
const server = createServer((req, res) => {
|
|
64
|
+
const addr = server.address();
|
|
65
|
+
const p = typeof addr === "object" && addr ? addr.port : 0;
|
|
66
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${p}`);
|
|
67
|
+
if (url.pathname !== "/callback") {
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end("Not found");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const code = url.searchParams.get("code");
|
|
73
|
+
const state = url.searchParams.get("state");
|
|
74
|
+
const error = url.searchParams.get("error");
|
|
75
|
+
if (error) {
|
|
76
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
77
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
78
|
+
res.end(errorHtml(`Authorization error: ${desc}`));
|
|
79
|
+
reject(new Error(`OAuth error: ${desc}`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!code || !state) {
|
|
83
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
84
|
+
res.end(errorHtml("Missing code or state parameter."));
|
|
85
|
+
reject(new Error("Callback missing code or state parameter"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const expectedBuf = Buffer.from(expectedState, "utf-8");
|
|
89
|
+
const receivedBuf = Buffer.from(state, "utf-8");
|
|
90
|
+
const stateValid = expectedBuf.length === receivedBuf.length &&
|
|
91
|
+
timingSafeEqual(expectedBuf, receivedBuf);
|
|
92
|
+
if (!stateValid) {
|
|
93
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
94
|
+
res.end(errorHtml("State mismatch — possible CSRF attack."));
|
|
95
|
+
reject(new Error("OAuth state mismatch"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
99
|
+
res.end(SUCCESS_HTML);
|
|
100
|
+
resolve({ code, state });
|
|
101
|
+
});
|
|
102
|
+
// Bind to port 0 so the OS assigns a free ephemeral port — no TOCTOU race
|
|
103
|
+
const port = await new Promise((res, rej) => {
|
|
104
|
+
server.listen(0, "127.0.0.1", () => {
|
|
105
|
+
const addr = server.address();
|
|
106
|
+
if (addr && typeof addr !== "string") {
|
|
107
|
+
res(addr.port);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
server.close();
|
|
111
|
+
rej(new Error("Failed to determine callback server port"));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
server.on("error", rej);
|
|
115
|
+
});
|
|
116
|
+
const waitForCallback = () => {
|
|
117
|
+
return new Promise((res, rej) => {
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
server.close();
|
|
120
|
+
rej(new Error(`Timed out waiting for OAuth callback after ${CALLBACK_TIMEOUT_MS / 1000}s`));
|
|
121
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
122
|
+
callbackPromise
|
|
123
|
+
.then((result) => { clearTimeout(timer); server.close(); res(result); })
|
|
124
|
+
.catch((err) => { clearTimeout(timer); server.close(); rej(err); });
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
return { port, waitForCallback };
|
|
128
|
+
}
|
|
129
|
+
// ─── DCR client registration ──────────────────────────────────────────────────
|
|
130
|
+
/** Register a new public OAuth client via Dynamic Client Registration (RFC 7591). */
|
|
131
|
+
export async function registerClient(serverUrl, redirectUri) {
|
|
132
|
+
const response = await fetch(`${serverUrl}/api/mcp/register`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
client_name: CLI_CLIENT_NAME,
|
|
137
|
+
redirect_uris: [redirectUri],
|
|
138
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
139
|
+
response_types: ["code"],
|
|
140
|
+
token_endpoint_auth_method: "none",
|
|
141
|
+
scope: CLI_SCOPES,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const text = await response.text().catch(() => "");
|
|
146
|
+
throw new Error(`DCR registration failed (${response.status}): ${text}`);
|
|
147
|
+
}
|
|
148
|
+
const data = (await response.json());
|
|
149
|
+
const clientId = data.client_id;
|
|
150
|
+
if (typeof clientId !== "string" || !clientId) {
|
|
151
|
+
throw new Error("DCR response missing client_id");
|
|
152
|
+
}
|
|
153
|
+
const registeredUris = data.redirect_uris;
|
|
154
|
+
if (Array.isArray(registeredUris) && !registeredUris.includes(redirectUri)) {
|
|
155
|
+
throw new Error("DCR: server registered unexpected redirect_uri");
|
|
156
|
+
}
|
|
157
|
+
return { clientId };
|
|
158
|
+
}
|
|
159
|
+
// ─── Token endpoint helpers ──────────────────────────────────────────────────
|
|
160
|
+
/** Parse an OAuth token endpoint response into a structured result. */
|
|
161
|
+
function parseTokenResponse(data) {
|
|
162
|
+
const accessToken = data.access_token;
|
|
163
|
+
const refreshToken = data.refresh_token;
|
|
164
|
+
const expiresIn = data.expires_in;
|
|
165
|
+
const scope = data.scope;
|
|
166
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
167
|
+
throw new Error("Token response missing access_token");
|
|
168
|
+
}
|
|
169
|
+
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
170
|
+
throw new Error("Token response missing refresh_token");
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
accessToken,
|
|
174
|
+
refreshToken,
|
|
175
|
+
expiresIn: typeof expiresIn === "number" ? expiresIn : 3600,
|
|
176
|
+
scope: typeof scope === "string" ? scope : CLI_SCOPES,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Exchange an authorization code for access + refresh tokens. */
|
|
180
|
+
export async function exchangeCode(serverUrl, params) {
|
|
181
|
+
const response = await fetch(`${serverUrl}${MCP_TOKEN_ENDPOINT}`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body: new URLSearchParams({
|
|
185
|
+
grant_type: "authorization_code",
|
|
186
|
+
code: params.code,
|
|
187
|
+
redirect_uri: params.redirectUri,
|
|
188
|
+
client_id: params.clientId,
|
|
189
|
+
code_verifier: params.codeVerifier,
|
|
190
|
+
}).toString(),
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const text = await response.text().catch(() => "");
|
|
194
|
+
throw new Error(`Token exchange failed (${response.status}): ${text}`);
|
|
195
|
+
}
|
|
196
|
+
const data = (await response.json());
|
|
197
|
+
const token = parseTokenResponse(data);
|
|
198
|
+
return { ...token, clientId: params.clientId };
|
|
199
|
+
}
|
|
200
|
+
/** Exchange a refresh token for a new access + refresh token pair. */
|
|
201
|
+
export async function refreshTokenGrant(serverUrl, refreshToken, clientId) {
|
|
202
|
+
const response = await fetch(`${serverUrl}${MCP_TOKEN_ENDPOINT}`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
205
|
+
body: new URLSearchParams({
|
|
206
|
+
grant_type: "refresh_token",
|
|
207
|
+
refresh_token: refreshToken,
|
|
208
|
+
client_id: clientId,
|
|
209
|
+
}).toString(),
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
return null;
|
|
213
|
+
const data = (await response.json());
|
|
214
|
+
return parseTokenResponse(data);
|
|
215
|
+
}
|
|
216
|
+
// ─── Token revocation (RFC 7009) ─────────────────────────────────────────────
|
|
217
|
+
/** Revoke a token via the server's revocation endpoint. Best-effort — does not throw. */
|
|
218
|
+
export async function revokeTokenRequest(serverUrl, token, clientId, tokenTypeHint) {
|
|
219
|
+
const params = { token, client_id: clientId };
|
|
220
|
+
if (tokenTypeHint)
|
|
221
|
+
params.token_type_hint = tokenTypeHint;
|
|
222
|
+
try {
|
|
223
|
+
await fetch(`${serverUrl}/api/mcp/revoke`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
226
|
+
body: new URLSearchParams(params).toString(),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Best-effort — network errors are silently ignored
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ─── Browser launcher ─────────────────────────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* Attempt to open the URL in the system browser.
|
|
236
|
+
* Returns false when running in a headless environment (no display server).
|
|
237
|
+
*/
|
|
238
|
+
export function openBrowser(url) {
|
|
239
|
+
const platform = process.platform;
|
|
240
|
+
if (platform === "linux") {
|
|
241
|
+
const hasDisplay = process.env.DISPLAY ||
|
|
242
|
+
process.env.WAYLAND_DISPLAY ||
|
|
243
|
+
process.env.TERM_PROGRAM;
|
|
244
|
+
if (!hasDisplay)
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
let cmd;
|
|
248
|
+
let args;
|
|
249
|
+
if (platform === "darwin") {
|
|
250
|
+
cmd = "open";
|
|
251
|
+
args = [url];
|
|
252
|
+
}
|
|
253
|
+
else if (platform === "win32") {
|
|
254
|
+
cmd = "cmd";
|
|
255
|
+
args = ["/c", "start", "", url];
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
cmd = "xdg-open";
|
|
259
|
+
args = [url];
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ─── URL validation ───────────────────────────────────────────────────────────
|
|
270
|
+
/** Reject non-HTTPS URLs except for loopback development. */
|
|
271
|
+
export function validateServerUrl(url) {
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = new URL(url);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new Error(`Invalid server URL: ${url}`);
|
|
278
|
+
}
|
|
279
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
280
|
+
throw new Error(`Unsupported protocol: ${parsed.protocol} (only https and http are allowed)`);
|
|
281
|
+
}
|
|
282
|
+
if (parsed.protocol === "http:") {
|
|
283
|
+
const isLoopback = parsed.hostname === "localhost" ||
|
|
284
|
+
parsed.hostname === "127.0.0.1" ||
|
|
285
|
+
parsed.hostname === "[::1]";
|
|
286
|
+
if (!isLoopback) {
|
|
287
|
+
throw new Error("Server URL must use HTTPS (http is only allowed for localhost/127.0.0.1/::1)");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ─── Main OAuth flow ──────────────────────────────────────────────────────────
|
|
292
|
+
export async function runOAuthFlow(serverUrl) {
|
|
293
|
+
validateServerUrl(serverUrl);
|
|
294
|
+
const codeVerifier = generateCodeVerifier();
|
|
295
|
+
const codeChallenge = computeS256Challenge(codeVerifier);
|
|
296
|
+
const state = randomBytes(16).toString("hex");
|
|
297
|
+
// Start callback server on OS-assigned port (no TOCTOU)
|
|
298
|
+
const { port, waitForCallback } = await startCallbackServer(state);
|
|
299
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
300
|
+
const { clientId } = await registerClient(serverUrl, redirectUri);
|
|
301
|
+
const authUrl = new URL(`${serverUrl}/api/mcp/authorize`);
|
|
302
|
+
authUrl.searchParams.set("response_type", "code");
|
|
303
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
304
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
305
|
+
authUrl.searchParams.set("scope", CLI_SCOPES);
|
|
306
|
+
authUrl.searchParams.set("state", state);
|
|
307
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
308
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
309
|
+
const urlString = authUrl.toString();
|
|
310
|
+
const opened = openBrowser(urlString);
|
|
311
|
+
if (opened) {
|
|
312
|
+
process.stderr.write("Opening browser for authentication...\n");
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
process.stderr.write("Cannot open browser. Please visit:\n " + urlString + "\n");
|
|
316
|
+
process.stderr.write("Waiting for authorization... (press Ctrl+C to cancel)\n");
|
|
317
|
+
}
|
|
318
|
+
const { code } = await waitForCallback();
|
|
319
|
+
return exchangeCode(serverUrl, { code, redirectUri, clientId, codeVerifier });
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=oauth.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passwd-sso-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "CLI for passwd-sso password manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,9 +44,6 @@
|
|
|
44
44
|
"otpauth": "^9.4.0",
|
|
45
45
|
"zod": "^4.3.6"
|
|
46
46
|
},
|
|
47
|
-
"optionalDependencies": {
|
|
48
|
-
"keytar": "^7.9.0"
|
|
49
|
-
},
|
|
50
47
|
"devDependencies": {
|
|
51
48
|
"@types/node": "^22.15.3",
|
|
52
49
|
"tsx": "^4.19.4",
|