sitevision-cli 1.0.0-beta.12 → 1.0.0-beta.13

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.
@@ -28,13 +28,13 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
28
28
  setAuthUrl(session.authUrl);
29
29
  openBrowser(session.authUrl);
30
30
  setPhase('awaiting');
31
- const token = await session.complete();
31
+ const { token, error } = await session.complete();
32
32
  cancelOAuthRef.current = null;
33
33
  if (token) {
34
34
  onComplete({ accessToken: token });
35
35
  }
36
36
  else {
37
- onError('OAuth2 login failed, timed out, or was rejected.');
37
+ onError(error ?? 'OAuth2 login failed.');
38
38
  }
39
39
  }
40
40
  else {
@@ -19,6 +19,26 @@ export interface DiscoveredOAuth2 {
19
19
  */
20
20
  export declare function discoverOAuth2Config(domain: string, useHTTP?: boolean): Promise<DiscoveredOAuth2 | null>;
21
21
  export declare function openBrowser(url: string): void;
22
+ /**
23
+ * Serve the loopback redirect once. Returns the awaited code and a `close()`
24
+ * that shuts the server down (freeing the port) if the login is cancelled — so
25
+ * a retry doesn't hit an EADDRINUSE on the fixed redirect port.
26
+ */
27
+ interface LoopbackResult {
28
+ code?: string;
29
+ error?: string;
30
+ }
31
+ /**
32
+ * Turn a redirect's query params into a result, prioritizing the provider's own
33
+ * error (the most useful reason) over a generic "no code". Exported for testing.
34
+ */
35
+ export declare function classifyRedirect(params: {
36
+ expectedState: string;
37
+ state: string | null;
38
+ error: string | null;
39
+ errorDescription: string | null;
40
+ code: string | null;
41
+ }): LoopbackResult;
22
42
  /**
23
43
  * Start an interactive OAuth2 login. Returns the authorize URL to open and a
24
44
  * `complete()` that awaits the loopback redirect, exchanges the code, stores the
@@ -27,7 +47,10 @@ export declare function openBrowser(url: string): void;
27
47
  */
28
48
  export declare function beginOAuth2Login(dev: DevProperties): {
29
49
  authUrl: string;
30
- complete: () => Promise<string | null>;
50
+ complete: () => Promise<{
51
+ token?: string;
52
+ error?: string;
53
+ }>;
31
54
  cancel: () => void;
32
55
  } | null;
33
56
  /**
@@ -37,3 +60,4 @@ export declare function beginOAuth2Login(dev: DevProperties): {
37
60
  * driven by the Ink login screen — the access token is never persisted.
38
61
  */
39
62
  export declare function resolveOAuth2AccessToken(dev: DevProperties): Promise<string | null>;
63
+ export {};
@@ -1,7 +1,7 @@
1
1
  import http from 'http';
2
2
  import crypto from 'crypto';
3
3
  import { spawn } from 'child_process';
4
- import { makeRequest } from './sitevision-api.js';
4
+ import { makeRequest, summarizeErrorBody } from './sitevision-api.js';
5
5
  import { getOAuth2RefreshToken, setOAuth2RefreshToken, deleteOAuth2RefreshToken, getOAuth2ClientSecret, } from './keychain.js';
6
6
  /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
7
7
  export const DEFAULT_REDIRECT_PORT = 8137;
@@ -36,12 +36,17 @@ async function postToken(config, params, secret) {
36
36
  // client_secret_basic when confidential; public+PKCE clients omit it.
37
37
  auth: secret ? { username: config.clientId, password: secret } : undefined,
38
38
  });
39
- if (response.statusCode !== 200)
40
- return null;
41
- return JSON.parse(response.body.toString());
39
+ if (response.statusCode !== 200) {
40
+ return {
41
+ error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
42
+ };
43
+ }
44
+ return { tokens: JSON.parse(response.body.toString()) };
42
45
  }
43
- catch {
44
- return null;
46
+ catch (error) {
47
+ return {
48
+ error: `Token request failed: ${error instanceof Error ? error.message : String(error)}`,
49
+ };
45
50
  }
46
51
  }
47
52
  /** OpenID configuration path (published at the issuer root once the provider is saved). */
@@ -87,14 +92,37 @@ export function openBrowser(url) {
87
92
  }
88
93
  }
89
94
  /**
90
- * Serve the loopback redirect once. Returns the awaited code and a `close()`
91
- * that shuts the server down (freeing the port) if the login is cancelled — so
92
- * a retry doesn't hit an EADDRINUSE on the fixed redirect port.
95
+ * Turn a redirect's query params into a result, prioritizing the provider's own
96
+ * error (the most useful reason) over a generic "no code". Exported for testing.
93
97
  */
98
+ export function classifyRedirect(params) {
99
+ if (params.error) {
100
+ return {
101
+ error: `The OAuth2 provider rejected the login: ${params.errorDescription
102
+ ? `${params.error} — ${params.errorDescription}`
103
+ : params.error}`,
104
+ };
105
+ }
106
+ if (params.state !== params.expectedState) {
107
+ return {
108
+ error: 'State mismatch — the login response did not match this request (a stale browser tab, or the wrong window).',
109
+ };
110
+ }
111
+ if (params.code) {
112
+ return { code: params.code };
113
+ }
114
+ return { error: 'No authorization code was returned by the provider.' };
115
+ }
116
+ function escapeHtml(text) {
117
+ return text
118
+ .replaceAll('&', '&amp;')
119
+ .replaceAll('<', '&lt;')
120
+ .replaceAll('>', '&gt;');
121
+ }
94
122
  function startLoopback(port, state) {
95
123
  let finish;
96
124
  let settled = false;
97
- const code = new Promise(resolve => {
125
+ const result = new Promise(resolve => {
98
126
  finish = (value) => {
99
127
  if (settled)
100
128
  return;
@@ -110,19 +138,29 @@ function startLoopback(port, state) {
110
138
  res.writeHead(404).end();
111
139
  return;
112
140
  }
113
- const ok = url.searchParams.get('state') === state;
114
- const authCode = url.searchParams.get('code');
115
- const message = ok && authCode
141
+ const outcome = classifyRedirect({
142
+ expectedState: state,
143
+ state: url.searchParams.get('state'),
144
+ error: url.searchParams.get('error'),
145
+ errorDescription: url.searchParams.get('error_description'),
146
+ code: url.searchParams.get('code'),
147
+ });
148
+ const message = outcome.code
116
149
  ? 'Login complete. You can close this window and return to the terminal.'
117
- : 'Login failed. Check the terminal.';
150
+ : `Login failed: ${outcome.error}`;
118
151
  res.writeHead(200, { 'Content-Type': 'text/html' });
119
- res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
120
- finish(ok ? authCode : null);
152
+ res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
153
+ finish(outcome);
121
154
  });
122
- const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
123
- server.on('error', () => finish(null));
155
+ const timer = setTimeout(() => finish({ error: 'Timed out waiting for the login to complete.' }), LOGIN_TIMEOUT_MS);
156
+ server.on('error', error => finish({
157
+ error: `Local login server error: ${error instanceof Error ? error.message : String(error)}`,
158
+ }));
124
159
  server.listen(port, '127.0.0.1');
125
- return { code, close: () => finish(null) };
160
+ return {
161
+ result,
162
+ close: (reason) => finish({ error: reason ?? 'Login cancelled.' }),
163
+ };
126
164
  }
127
165
  /**
128
166
  * Start an interactive OAuth2 login. Returns the authorize URL to open and a
@@ -152,24 +190,28 @@ export function beginOAuth2Login(dev) {
152
190
  }
153
191
  const loopback = startLoopback(port, state);
154
192
  const complete = async () => {
155
- const code = await loopback.code;
156
- if (!code)
157
- return null;
158
- const tokens = await postToken(config, {
193
+ const redirect = await loopback.result;
194
+ if (redirect.error || !redirect.code) {
195
+ return { error: redirect.error ?? 'Login failed.' };
196
+ }
197
+ const { tokens, error } = await postToken(config, {
159
198
  grant_type: 'authorization_code',
160
- code,
199
+ code: redirect.code,
161
200
  redirect_uri: redirectUri,
162
201
  client_id: config.clientId,
163
202
  code_verifier: verifier,
164
203
  }, secret);
165
- if (!tokens?.access_token)
166
- return null;
204
+ if (error)
205
+ return { error };
206
+ if (!tokens?.access_token) {
207
+ return { error: 'The token endpoint did not return an access token.' };
208
+ }
167
209
  if (tokens.refresh_token) {
168
210
  setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
169
211
  }
170
- return tokens.access_token;
212
+ return { token: tokens.access_token };
171
213
  };
172
- return { authUrl: url.href, complete, cancel: loopback.close };
214
+ return { authUrl: url.href, complete, cancel: () => loopback.close() };
173
215
  }
174
216
  /**
175
217
  * Silently resolve an access token by refreshing the keychain refresh token.
@@ -186,7 +228,7 @@ export async function resolveOAuth2AccessToken(dev) {
186
228
  const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
187
229
  if (!storedRefresh)
188
230
  return null;
189
- const tokens = await postToken(config, {
231
+ const { tokens } = await postToken(config, {
190
232
  grant_type: 'refresh_token',
191
233
  refresh_token: storedRefresh,
192
234
  client_id: config.clientId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"