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.
@@ -1,4 +1,8 @@
1
1
  /**
2
- * `passwd-sso login` — Configure server URL and Bearer token.
2
+ * `passwd-sso login` — Authenticate via OAuth 2.1 PKCE or manual token paste.
3
3
  */
4
- export declare function loginCommand(): Promise<void>;
4
+ export interface LoginOptions {
5
+ useToken?: boolean;
6
+ server?: string;
7
+ }
8
+ export declare function loginCommand(opts?: LoginOptions): Promise<void>;
@@ -1,9 +1,10 @@
1
1
  /**
2
- * `passwd-sso login` — Configure server URL and Bearer token.
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, saveToken } from "../lib/config.js";
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
- const serverUrl = await prompt(`Server URL${config.serverUrl ? ` [${config.serverUrl}]` : ""}: `);
20
- if (serverUrl) {
21
- config.serverUrl = serverUrl.replace(/\/$/, "");
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(` ${config.serverUrl}/dashboard/settings`);
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
- // Approximate token expiry (15 min from now)
36
- config.tokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
37
- saveConfig(config);
38
- const storage = await saveToken(token);
39
- setTokenCache(token, config.tokenExpiresAt);
40
- if (storage === "file") {
41
- output.warn("Token saved to file (plaintext). Consider installing keytar for OS keychain storage.");
42
- }
43
- output.success(`Logged in to ${config.serverUrl}`);
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("Configure server URL and authentication token")
44
- .action(loginCommand);
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")
@@ -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(): Promise<string | null>;
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;
@@ -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 { loadToken, saveToken, loadConfig, saveConfig } from "./config.js";
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 async function getToken() {
19
+ export function getToken() {
17
20
  if (cachedToken)
18
21
  return cachedToken;
19
- cachedToken = await loadToken();
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
- const token = await getToken();
55
- if (!token)
63
+ if (!cachedRefreshToken || !cachedClientId)
56
64
  return false;
57
65
  const baseUrl = getBaseUrl();
58
66
  try {
59
- const res = await fetch(`${baseUrl}/api/extension/token/refresh`, {
60
- method: "POST",
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
- if (typeof data.expiresAt !== "string" || isNaN(new Date(data.expiresAt).getTime()))
72
- return false;
73
- await saveToken(data.token);
74
- setTokenCache(data.token, data.expiresAt);
75
- // Persist expiresAt in config
76
- const config = loadConfig();
77
- config.tokenExpiresAt = data.expiresAt;
78
- saveConfig(config);
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 = await getToken();
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 = await getToken();
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 = await getToken();
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
- /** Interval: refresh every 10 minutes (well within 15-min TTL) */
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() {
@@ -1,18 +1,33 @@
1
1
  /**
2
2
  * Configuration management for the CLI tool.
3
3
  *
4
- * Config file: $XDG_CONFIG_HOME/passwd-sso/config.json
5
- * Credentials: OS keychain (via keytar) or $XDG_DATA_HOME/passwd-sso/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 declare function saveToken(token: string): Promise<"keychain" | "file">;
17
- export declare function loadToken(): Promise<string | null>;
18
- export declare function deleteToken(): Promise<void>;
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;
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * Configuration management for the CLI tool.
3
3
  *
4
- * Config file: $XDG_CONFIG_HOME/passwd-sso/config.json
5
- * Credentials: OS keychain (via keytar) or $XDG_DATA_HOME/passwd-sso/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), { mode: 0o600 });
41
+ writeFileSync(getConfigFilePath(), JSON.stringify(config, null, 2), {
42
+ mode: 0o600,
43
+ });
44
44
  }
45
- // ─── Credential Storage ───────────────────────────────────────
46
- async function tryKeytar() {
47
- if (process.env.PSSO_NO_KEYCHAIN === "1")
48
- return null;
49
- try {
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 | fsConstants.O_CREAT | fsConstants.O_TRUNC | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
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, token);
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
- export async function loadToken() {
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
- return readFileSync(getCredentialsFilePath(), "utf-8").trim();
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
- export async function deleteToken() {
98
- const kt = await tryKeytar();
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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.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",