sitevision-cli 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -6,6 +6,15 @@ function deployAccount(domain, username) {
6
6
  function signingAccount(username) {
7
7
  return `signing:${username}`;
8
8
  }
9
+ function oauthRefreshAccount(domain, clientId) {
10
+ return `oauth2-refresh:${clientId}@${domain}`;
11
+ }
12
+ function oauthSecretAccount(domain, clientId) {
13
+ return `oauth2-secret:${clientId}@${domain}`;
14
+ }
15
+ function sessionCookieAccount(domain, username) {
16
+ return `session:${username}@${domain}`;
17
+ }
9
18
  function safeGet(account) {
10
19
  try {
11
20
  return new Entry(SERVICE, account).getPassword();
@@ -61,3 +70,48 @@ export function deleteSigningPassword(username) {
61
70
  return;
62
71
  safeDelete(signingAccount(username));
63
72
  }
73
+ export function getOAuth2RefreshToken(domain, clientId) {
74
+ if (!domain || !clientId)
75
+ return null;
76
+ return safeGet(oauthRefreshAccount(domain, clientId));
77
+ }
78
+ export function setOAuth2RefreshToken(domain, clientId, token) {
79
+ if (!domain || !clientId || !token)
80
+ return false;
81
+ return safeSet(oauthRefreshAccount(domain, clientId), token);
82
+ }
83
+ export function deleteOAuth2RefreshToken(domain, clientId) {
84
+ if (!domain || !clientId)
85
+ return;
86
+ safeDelete(oauthRefreshAccount(domain, clientId));
87
+ }
88
+ export function getOAuth2ClientSecret(domain, clientId) {
89
+ if (!domain || !clientId)
90
+ return null;
91
+ return safeGet(oauthSecretAccount(domain, clientId));
92
+ }
93
+ export function setOAuth2ClientSecret(domain, clientId, secret) {
94
+ if (!domain || !clientId || !secret)
95
+ return false;
96
+ return safeSet(oauthSecretAccount(domain, clientId), secret);
97
+ }
98
+ export function deleteOAuth2ClientSecret(domain, clientId) {
99
+ if (!domain || !clientId)
100
+ return;
101
+ safeDelete(oauthSecretAccount(domain, clientId));
102
+ }
103
+ export function getSessionCookie(domain, username) {
104
+ if (!domain || !username)
105
+ return null;
106
+ return safeGet(sessionCookieAccount(domain, username));
107
+ }
108
+ export function setSessionCookie(domain, username, cookie) {
109
+ if (!domain || !username || !cookie)
110
+ return false;
111
+ return safeSet(sessionCookieAccount(domain, username), cookie);
112
+ }
113
+ export function deleteSessionCookie(domain, username) {
114
+ if (!domain || !username)
115
+ return;
116
+ safeDelete(sessionCookieAccount(domain, username));
117
+ }
@@ -0,0 +1,20 @@
1
+ import type { DevProperties } from '../types/index.js';
2
+ /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
3
+ export declare const DEFAULT_REDIRECT_PORT = 8137;
4
+ /** RFC 7636 S256 pair. Exported for testing the challenge derivation. */
5
+ export declare function createPkcePair(): {
6
+ verifier: string;
7
+ challenge: string;
8
+ };
9
+ /**
10
+ * Return a usable OAuth2 access token, or null if one can't be obtained.
11
+ *
12
+ * Order: silent refresh from the keychain refresh token, then (when
13
+ * `interactive`) a browser login. The access token is never persisted; the
14
+ * refresh token is stored in the keychain for next time. Pass
15
+ * `interactive: false` from contexts that can't own the terminal (the Ink
16
+ * menu) to get refresh-only resolution with no browser.
17
+ */
18
+ export declare function resolveOAuth2AccessToken(dev: DevProperties, options?: {
19
+ interactive?: boolean;
20
+ }): Promise<string | null>;
@@ -0,0 +1,160 @@
1
+ import http from 'http';
2
+ import crypto from 'crypto';
3
+ import { spawn } from 'child_process';
4
+ import { makeRequest } from './sitevision-api.js';
5
+ import { getOAuth2RefreshToken, setOAuth2RefreshToken, deleteOAuth2RefreshToken, getOAuth2ClientSecret, } from './keychain.js';
6
+ /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
7
+ export const DEFAULT_REDIRECT_PORT = 8137;
8
+ /** Seconds to wait for the user to finish logging in before giving up. */
9
+ const LOGIN_TIMEOUT_MS = 300_000;
10
+ function base64url(buffer) {
11
+ return buffer
12
+ .toString('base64')
13
+ .replaceAll('+', '-')
14
+ .replaceAll('/', '_')
15
+ .replaceAll('=', '');
16
+ }
17
+ /** RFC 7636 S256 pair. Exported for testing the challenge derivation. */
18
+ export function createPkcePair() {
19
+ const verifier = base64url(crypto.randomBytes(32));
20
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
21
+ return { verifier, challenge };
22
+ }
23
+ function hasOAuth2Config(config) {
24
+ return Boolean(config?.authorizationEndpoint && config.tokenEndpoint && config.clientId);
25
+ }
26
+ async function postToken(config, params, secret) {
27
+ const body = Buffer.from(new URLSearchParams(params).toString());
28
+ try {
29
+ const response = await makeRequest(config.tokenEndpoint, {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/x-www-form-urlencoded',
33
+ 'Content-Length': String(body.length),
34
+ },
35
+ body,
36
+ // client_secret_basic when confidential; public+PKCE clients omit it.
37
+ auth: secret ? { username: config.clientId, password: secret } : undefined,
38
+ });
39
+ if (response.statusCode !== 200)
40
+ return null;
41
+ return JSON.parse(response.body.toString());
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function openBrowser(url) {
48
+ const isWin = process.platform === 'win32';
49
+ const cmd = process.platform === 'darwin' ? 'open' : isWin ? 'cmd' : 'xdg-open';
50
+ const args = isWin ? ['/c', 'start', '', url] : [url];
51
+ try {
52
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
53
+ }
54
+ catch {
55
+ // Fall back to the printed URL.
56
+ }
57
+ }
58
+ /** Serve the loopback redirect once, resolving the authorization code. */
59
+ function waitForCode(port, state) {
60
+ return new Promise(resolve => {
61
+ let settled = false;
62
+ const finish = (code) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ clearTimeout(timer);
67
+ server.close();
68
+ resolve(code);
69
+ };
70
+ const server = http.createServer((req, res) => {
71
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
72
+ if (url.pathname !== '/callback') {
73
+ res.writeHead(404).end();
74
+ return;
75
+ }
76
+ const ok = url.searchParams.get('state') === state;
77
+ const code = url.searchParams.get('code');
78
+ const message = ok && code
79
+ ? 'Login complete. You can close this window and return to the terminal.'
80
+ : 'Login failed. Check the terminal.';
81
+ res.writeHead(200, { 'Content-Type': 'text/html' });
82
+ res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
83
+ finish(ok ? code : null);
84
+ });
85
+ const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
86
+ server.on('error', () => finish(null));
87
+ server.listen(port, '127.0.0.1');
88
+ });
89
+ }
90
+ async function interactiveLogin(config, secret) {
91
+ const port = config.redirectPort ?? DEFAULT_REDIRECT_PORT;
92
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
93
+ const { verifier, challenge } = createPkcePair();
94
+ const state = base64url(crypto.randomBytes(16));
95
+ const authUrl = new URL(config.authorizationEndpoint);
96
+ authUrl.searchParams.set('response_type', 'code');
97
+ authUrl.searchParams.set('client_id', config.clientId);
98
+ authUrl.searchParams.set('redirect_uri', redirectUri);
99
+ authUrl.searchParams.set('state', state);
100
+ authUrl.searchParams.set('code_challenge', challenge);
101
+ authUrl.searchParams.set('code_challenge_method', 'S256');
102
+ if (config.scopes?.length) {
103
+ authUrl.searchParams.set('scope', config.scopes.join(' '));
104
+ }
105
+ const codePromise = waitForCode(port, state);
106
+ openBrowser(authUrl.href);
107
+ console.log(`\nOpening browser to log in. If it doesn't open, visit:\n${authUrl.href}\n`);
108
+ const code = await codePromise;
109
+ if (!code)
110
+ return null;
111
+ return postToken(config, {
112
+ grant_type: 'authorization_code',
113
+ code,
114
+ redirect_uri: redirectUri,
115
+ client_id: config.clientId,
116
+ code_verifier: verifier,
117
+ }, secret);
118
+ }
119
+ /**
120
+ * Return a usable OAuth2 access token, or null if one can't be obtained.
121
+ *
122
+ * Order: silent refresh from the keychain refresh token, then (when
123
+ * `interactive`) a browser login. The access token is never persisted; the
124
+ * refresh token is stored in the keychain for next time. Pass
125
+ * `interactive: false` from contexts that can't own the terminal (the Ink
126
+ * menu) to get refresh-only resolution with no browser.
127
+ */
128
+ export async function resolveOAuth2AccessToken(dev, options = {}) {
129
+ const { interactive = true } = options;
130
+ const config = dev.oauth2;
131
+ if (!hasOAuth2Config(config))
132
+ return null;
133
+ const { domain } = dev;
134
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
135
+ const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
136
+ if (storedRefresh) {
137
+ const tokens = await postToken(config, {
138
+ grant_type: 'refresh_token',
139
+ refresh_token: storedRefresh,
140
+ client_id: config.clientId,
141
+ }, secret);
142
+ if (tokens?.access_token) {
143
+ if (tokens.refresh_token) {
144
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
145
+ }
146
+ return tokens.access_token;
147
+ }
148
+ // Stale/expired refresh token — drop it and log in fresh.
149
+ deleteOAuth2RefreshToken(domain, config.clientId);
150
+ }
151
+ if (!interactive || !process.stdin.isTTY)
152
+ return null;
153
+ const tokens = await interactiveLogin(config, secret);
154
+ if (!tokens?.access_token)
155
+ return null;
156
+ if (tokens.refresh_token) {
157
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
158
+ }
159
+ return tokens.access_token;
160
+ }
@@ -3,6 +3,11 @@
3
3
  * Pressing Enter (empty answer) returns `defaultYes` (default: false).
4
4
  */
5
5
  export declare function promptYesNo(prompt: string, defaultYes?: boolean): Promise<boolean>;
6
+ /**
7
+ * Wait for the user to press Enter (or Ctrl+C). Used to hand control to an
8
+ * external browser and resume once the user says they're done.
9
+ */
10
+ export declare function promptEnter(prompt: string): Promise<void>;
6
11
  /**
7
12
  * Prompt for password input with masked display
8
13
  */
@@ -29,6 +29,34 @@ export function promptYesNo(prompt, defaultYes = false) {
29
29
  stdin.on('data', onData);
30
30
  });
31
31
  }
32
+ /**
33
+ * Wait for the user to press Enter (or Ctrl+C). Used to hand control to an
34
+ * external browser and resume once the user says they're done.
35
+ */
36
+ export function promptEnter(prompt) {
37
+ return new Promise(resolve => {
38
+ process.stdout.write(prompt);
39
+ const stdin = process.stdin;
40
+ stdin.setRawMode(true);
41
+ stdin.resume();
42
+ stdin.setEncoding('utf8');
43
+ const onData = (data) => {
44
+ const char = data[0] || '';
45
+ const charCode = char.charCodeAt(0);
46
+ if (charCode === 3) {
47
+ process.exit();
48
+ }
49
+ if (char === '' || charCode === 13 || charCode === 10) {
50
+ stdin.setRawMode(false);
51
+ stdin.removeListener('data', onData);
52
+ stdin.pause();
53
+ process.stdout.write('\n');
54
+ resolve();
55
+ }
56
+ };
57
+ stdin.on('data', onData);
58
+ });
59
+ }
32
60
  /**
33
61
  * Prompt for password input with masked display
34
62
  */
@@ -98,8 +98,9 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
98
98
  */
99
99
  export declare function readDevProperties(projectRoot: string): DevProperties | null;
100
100
  /**
101
- * Write dev properties to file. The `password` field is never persisted —
102
- * it is held in the OS keychain instead.
101
+ * Write dev properties to file. Secrets are never persisted — `password`,
102
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
103
+ * runtime instead.
103
104
  */
104
105
  export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
105
106
  /**
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getDeployPassword, setDeployPassword } from './keychain.js';
3
+ import { getDeployPassword, setDeployPassword, getSessionCookie, } from './keychain.js';
4
4
  import { parseJsonc } from './jsonc.js';
5
5
  // =============================================================================
6
6
  // LOCALIZED TEXT
@@ -244,6 +244,25 @@ export function detectProject(cwd = process.cwd()) {
244
244
  }
245
245
  }
246
246
  }
247
+ // Resolve an OAuth2 access token: env var > keychain refresh.
248
+ // The env var is the manual/CI path; the interactive login stores a
249
+ // refresh token in the keychain and mints access tokens from it.
250
+ if (devProperties.authMethod === 'oauth2') {
251
+ const envToken = process.env['SITEVISION_ACCESS_TOKEN'];
252
+ if (envToken) {
253
+ devProperties.accessToken = envToken;
254
+ }
255
+ }
256
+ // Resolve a session cookie: env var > keychain (captured at login).
257
+ if (devProperties.authMethod === 'cookie' &&
258
+ devProperties.domain &&
259
+ devProperties.username) {
260
+ const envCookie = process.env['SITEVISION_SESSION_COOKIE'];
261
+ devProperties.sessionCookie =
262
+ envCookie ??
263
+ getSessionCookie(devProperties.domain, devProperties.username) ??
264
+ undefined;
265
+ }
247
266
  }
248
267
  catch {
249
268
  // Invalid dev properties file
@@ -323,13 +342,14 @@ export function readDevProperties(projectRoot) {
323
342
  }
324
343
  }
325
344
  /**
326
- * Write dev properties to file. The `password` field is never persisted —
327
- * it is held in the OS keychain instead.
345
+ * Write dev properties to file. Secrets are never persisted — `password`,
346
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
347
+ * runtime instead.
328
348
  */
329
349
  export function writeDevProperties(projectRoot, properties) {
330
350
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
331
351
  getDefaultDevPropertiesPath(projectRoot);
332
- const { password: _password, ...persisted } = properties;
352
+ const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, ...persisted } = properties;
333
353
  fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
334
354
  }
335
355
  export function readSvcConfig(projectRoot) {
@@ -0,0 +1,9 @@
1
+ import type { DevProperties } from '../types/index.js';
2
+ /**
3
+ * Return a usable session cookie, or null. Order: keychain (a prior capture),
4
+ * then (when `interactive`) a browser login. Pass `interactive: false` from the
5
+ * Ink menu, which can't own the terminal for the "press Enter" handoff.
6
+ */
7
+ export declare function resolveSessionCookie(dev: DevProperties, options?: {
8
+ interactive?: boolean;
9
+ }): Promise<string | null>;
@@ -0,0 +1,96 @@
1
+ import { getSessionCookie, setSessionCookie } from './keychain.js';
2
+ import { promptEnter } from './password-prompt.js';
3
+ function bareDomain(domain) {
4
+ return domain.replace(/^\./, '');
5
+ }
6
+ /** Related if either host is the other or a subdomain of it (both directions). */
7
+ function domainRelated(a, b) {
8
+ const x = bareDomain(a);
9
+ const y = bareDomain(b);
10
+ return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
11
+ }
12
+ /** Read every cookie in the browser jar (httponly and secure included). */
13
+ async function readAllCookies(browser, page) {
14
+ // puppeteer >= 22 exposes the whole jar directly.
15
+ if (typeof browser.cookies === 'function') {
16
+ try {
17
+ return (await browser.cookies());
18
+ }
19
+ catch {
20
+ // Fall through to CDP.
21
+ }
22
+ }
23
+ const client = await page.createCDPSession();
24
+ const { cookies } = await client.send('Network.getAllCookies');
25
+ return cookies;
26
+ }
27
+ /**
28
+ * Open a real browser at the login URL, let the user complete SSO, then read
29
+ * the session cookies via CDP — which returns httponly, secure cookies that
30
+ * page JavaScript can't see. Returns a `Cookie:` header value, or null.
31
+ */
32
+ async function captureViaBrowser(loginUrl, siteDomain) {
33
+ let puppeteer;
34
+ try {
35
+ ({ default: puppeteer } = await import('puppeteer-core'));
36
+ }
37
+ catch {
38
+ console.log('\x1b[31mpuppeteer-core is not installed. Run `npm i puppeteer-core`, or pass --cookie / set SITEVISION_SESSION_COOKIE.\x1b[0m');
39
+ return null;
40
+ }
41
+ let browser;
42
+ try {
43
+ browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
44
+ const page = await browser.newPage();
45
+ await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
46
+ // A SAML redirect may abort the initial navigation — that's fine.
47
+ });
48
+ await promptEnter('\nLog in in the browser this tool opened, then press Enter here to capture the session: ');
49
+ const all = await readAllCookies(browser, page);
50
+ const sessions = all.filter(c => c.name === 'JSESSIONID');
51
+ if (sessions.length === 0) {
52
+ const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
53
+ console.log(`\x1b[31mNo JSESSIONID among ${all.length} cookies.\x1b[0m`);
54
+ console.log(`Cookie domains seen: ${domains.join(', ') || '(none — was the login done in the browser this tool opened?)'}`);
55
+ console.log('If those are only your IdP and not the Sitevision site, open a Sitevision page/editor in that same browser (so it issues a session), then run this again.');
56
+ return null;
57
+ }
58
+ // Prefer the JSESSIONID on the deploy host; else take the only/first one.
59
+ const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
60
+ const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
61
+ console.log(`\x1b[32mCaptured session on ${bareDomain(chosen.domain)} (${cookies.length} cookies).\x1b[0m`);
62
+ return cookies.map(c => `${c.name}=${c.value}`).join('; ');
63
+ }
64
+ catch (error) {
65
+ console.log(`\x1b[31mBrowser login failed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
66
+ return null;
67
+ }
68
+ finally {
69
+ if (browser) {
70
+ await browser.close().catch(() => {
71
+ // Best-effort close.
72
+ });
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * Return a usable session cookie, or null. Order: keychain (a prior capture),
78
+ * then (when `interactive`) a browser login. Pass `interactive: false` from the
79
+ * Ink menu, which can't own the terminal for the "press Enter" handoff.
80
+ */
81
+ export async function resolveSessionCookie(dev, options = {}) {
82
+ const { interactive = true } = options;
83
+ const { domain, username } = dev;
84
+ if (!domain || !username)
85
+ return null;
86
+ const stored = getSessionCookie(domain, username);
87
+ if (stored)
88
+ return stored;
89
+ if (!interactive || !process.stdin.isTTY)
90
+ return null;
91
+ const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
92
+ const cookie = await captureViaBrowser(loginUrl, domain);
93
+ if (cookie)
94
+ setSessionCookie(domain, username, cookie);
95
+ return cookie;
96
+ }
@@ -13,6 +13,27 @@ import type { SigningCredentials, DeployConfig, ProductionDeployConfig, SigningR
13
13
  * Create Basic Auth header value
14
14
  */
15
15
  declare function createBasicAuth(username: string, password: string): string;
16
+ type RequestAuth = {
17
+ username: string;
18
+ password: string;
19
+ } | {
20
+ token: string;
21
+ } | {
22
+ cookie: string;
23
+ };
24
+ type AuthKind = 'basic' | 'bearer' | 'cookie';
25
+ /** Single source of the 401 message, worded for the auth kind actually used. */
26
+ declare function unauthorizedMessage(kind: AuthKind): string;
27
+ /** Pick cookie > bearer > basic based on what the deploy config carries. */
28
+ declare function configAuth(config: {
29
+ username: string;
30
+ password?: string;
31
+ accessToken?: string;
32
+ sessionCookie?: string;
33
+ }): {
34
+ auth: RequestAuth;
35
+ kind: AuthKind;
36
+ };
16
37
  /**
17
38
  * Make an HTTP/HTTPS request
18
39
  */
@@ -20,10 +41,7 @@ export declare function makeRequest(url: string, options: {
20
41
  method: string;
21
42
  headers?: Record<string, string>;
22
43
  body?: Buffer;
23
- auth?: {
24
- username: string;
25
- password: string;
26
- };
44
+ auth?: RequestAuth;
27
45
  timeoutMs?: number;
28
46
  }): Promise<{
29
47
  statusCode: number;
@@ -45,6 +63,12 @@ export declare function summarizeErrorBody(body: Buffer, headers: Record<string,
45
63
  * the signing endpoint returns an error page with HTTP 200.
46
64
  */
47
65
  export declare function looksLikeZip(body: Buffer): boolean;
66
+ /**
67
+ * A stale Sitevision session usually answers with a redirect to the login page
68
+ * or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
69
+ * auth can drop the dead session and re-login instead of showing a generic error.
70
+ */
71
+ export declare function looksLikeAuthExpired(statusCode: number, body: Buffer, headers: Record<string, string>): boolean;
48
72
  /**
49
73
  * Sign an app via developer.sitevision.se
50
74
  *
@@ -85,4 +109,4 @@ export declare function createAddon(config: DeployConfig, appType: SimpleAppType
85
109
  * @param appType - The app type (web, widget, rest)
86
110
  */
87
111
  export declare function activateApp(executableId: string, config: DeployConfig, _appType: SimpleAppType): Promise<ActivationResponse>;
88
- export { createBasicAuth };
112
+ export { createBasicAuth, configAuth, unauthorizedMessage };