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
|
|
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<
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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('&', '&')
|
|
119
|
+
.replaceAll('<', '<')
|
|
120
|
+
.replaceAll('>', '>');
|
|
121
|
+
}
|
|
94
122
|
function startLoopback(port, state) {
|
|
95
123
|
let finish;
|
|
96
124
|
let settled = false;
|
|
97
|
-
const
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
:
|
|
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(
|
|
152
|
+
res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
|
|
153
|
+
finish(outcome);
|
|
121
154
|
});
|
|
122
|
-
const timer = setTimeout(() => finish(
|
|
123
|
-
server.on('error',
|
|
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 {
|
|
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
|
|
156
|
-
if (!code)
|
|
157
|
-
return
|
|
158
|
-
|
|
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 (
|
|
166
|
-
return
|
|
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,
|