sitevision-cli 1.0.0-beta.11 → 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 {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import { TextInput } from './TextInput.js';
|
|
5
5
|
import { writeDevProperties } from '../utils/project-detection.js';
|
|
6
6
|
import { setDeployPassword, deleteDeployPassword, setOAuth2ClientSecret, } from '../utils/keychain.js';
|
|
7
|
-
import { DEFAULT_REDIRECT_PORT } from '../utils/oauth2-auth.js';
|
|
7
|
+
import { DEFAULT_REDIRECT_PORT, discoverOAuth2Config, } from '../utils/oauth2-auth.js';
|
|
8
8
|
const LABELS = {
|
|
9
9
|
domain: 'Domain',
|
|
10
10
|
siteName: 'Site Name',
|
|
@@ -50,8 +50,39 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
|
|
|
50
50
|
scopes: initialProperties?.oauth2?.scopes?.join(' ') ?? '',
|
|
51
51
|
clientSecret: '',
|
|
52
52
|
}));
|
|
53
|
+
const [discoveryNote, setDiscoveryNote] = useState('');
|
|
53
54
|
const method = properties.authMethod ?? 'basic';
|
|
54
55
|
const isOAuth = method === 'oauth2';
|
|
56
|
+
// When OAuth2 is chosen, auto-fill the endpoints from the site's OpenID
|
|
57
|
+
// configuration (unauthenticated) so the user doesn't type them in.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (method !== 'oauth2' || !properties.domain)
|
|
60
|
+
return;
|
|
61
|
+
if (oauth.authorizationEndpoint && oauth.tokenEndpoint)
|
|
62
|
+
return;
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
setDiscoveryNote('Looking up OAuth2 endpoints…');
|
|
65
|
+
void discoverOAuth2Config(properties.domain, properties.useHTTPForDevDeploy).then(discovered => {
|
|
66
|
+
if (cancelled)
|
|
67
|
+
return;
|
|
68
|
+
if (discovered) {
|
|
69
|
+
setOauth(previous => ({
|
|
70
|
+
...previous,
|
|
71
|
+
authorizationEndpoint: previous.authorizationEndpoint || discovered.authorizationEndpoint,
|
|
72
|
+
tokenEndpoint: previous.tokenEndpoint || discovered.tokenEndpoint,
|
|
73
|
+
scopes: previous.scopes || (discovered.scopesSupported?.join(' ') ?? ''),
|
|
74
|
+
}));
|
|
75
|
+
setDiscoveryNote('Endpoints auto-filled from the site OpenID config.');
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
setDiscoveryNote('Could not auto-discover endpoints — enter them below.');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [method, properties.domain]);
|
|
55
86
|
const methodSteps = method === 'oauth2'
|
|
56
87
|
? [
|
|
57
88
|
'oauthClientId',
|
|
@@ -177,7 +208,7 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
|
|
|
177
208
|
return null;
|
|
178
209
|
}
|
|
179
210
|
};
|
|
180
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Setup Development Properties" }), _jsxs(Text, { children: [' ', "Step ", stepIndex + 1, " of ", steps.length, ":", ' ', currentStep ? LABELS[currentStep] : ''] })] }), isOAuth && (
|
|
211
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Setup Development Properties" }), _jsxs(Text, { children: [' ', "Step ", stepIndex + 1, " of ", steps.length, ":", ' ', currentStep ? LABELS[currentStep] : ''] })] }), isOAuth && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Whitelist this redirect URI on the OAuth2 client: http://127.0.0.1:", redirectPort, "/callback"] }), discoveryNote && _jsx(Text, { color: "yellow", children: discoveryNote })] })), _jsx(Box, { marginBottom: 1, children: steps.map((s, i) => (_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: i <= stepIndex ? 'green' : 'gray', children: i < stepIndex ? '✓' : i === stepIndex ? '●' : '○' }) }, s))) }), _jsx(Box, { borderStyle: "single", borderColor: "gray", padding: 1, children: renderInput() })] }));
|
|
181
212
|
}
|
|
182
213
|
function MethodSelect({ defaultValue, onSubmit, }) {
|
|
183
214
|
const [index, setIndex] = useState(() => Math.max(0, AUTH_METHODS.findIndex(m => m.value === defaultValue)));
|
|
@@ -6,7 +6,39 @@ export declare function createPkcePair(): {
|
|
|
6
6
|
verifier: string;
|
|
7
7
|
challenge: string;
|
|
8
8
|
};
|
|
9
|
+
export interface DiscoveredOAuth2 {
|
|
10
|
+
authorizationEndpoint: string;
|
|
11
|
+
tokenEndpoint: string;
|
|
12
|
+
scopesSupported?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
|
|
16
|
+
* authorization/token endpoints. Returns null if it isn't published (provider
|
|
17
|
+
* not enabled) or the response isn't a valid config, so callers fall back to
|
|
18
|
+
* manual entry.
|
|
19
|
+
*/
|
|
20
|
+
export declare function discoverOAuth2Config(domain: string, useHTTP?: boolean): Promise<DiscoveredOAuth2 | null>;
|
|
9
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;
|
|
10
42
|
/**
|
|
11
43
|
* Start an interactive OAuth2 login. Returns the authorize URL to open and a
|
|
12
44
|
* `complete()` that awaits the loopback redirect, exchanges the code, stores the
|
|
@@ -15,7 +47,10 @@ export declare function openBrowser(url: string): void;
|
|
|
15
47
|
*/
|
|
16
48
|
export declare function beginOAuth2Login(dev: DevProperties): {
|
|
17
49
|
authUrl: string;
|
|
18
|
-
complete: () => Promise<
|
|
50
|
+
complete: () => Promise<{
|
|
51
|
+
token?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
}>;
|
|
19
54
|
cancel: () => void;
|
|
20
55
|
} | null;
|
|
21
56
|
/**
|
|
@@ -25,3 +60,4 @@ export declare function beginOAuth2Login(dev: DevProperties): {
|
|
|
25
60
|
* driven by the Ink login screen — the access token is never persisted.
|
|
26
61
|
*/
|
|
27
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,9 +36,45 @@ 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
|
+
error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { tokens: JSON.parse(response.body.toString()) };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
error: `Token request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** OpenID configuration path (published at the issuer root once the provider is saved). */
|
|
53
|
+
const DISCOVERY_PATH = '/.well-known/openid-configuration';
|
|
54
|
+
/**
|
|
55
|
+
* Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
|
|
56
|
+
* authorization/token endpoints. Returns null if it isn't published (provider
|
|
57
|
+
* not enabled) or the response isn't a valid config, so callers fall back to
|
|
58
|
+
* manual entry.
|
|
59
|
+
*/
|
|
60
|
+
export async function discoverOAuth2Config(domain, useHTTP = false) {
|
|
61
|
+
if (!domain)
|
|
62
|
+
return null;
|
|
63
|
+
const protocol = useHTTP ? 'http' : 'https';
|
|
64
|
+
try {
|
|
65
|
+
const response = await makeRequest(`${protocol}://${domain}${DISCOVERY_PATH}`, { method: 'GET' });
|
|
39
66
|
if (response.statusCode !== 200)
|
|
40
67
|
return null;
|
|
41
|
-
|
|
68
|
+
const doc = JSON.parse(response.body.toString());
|
|
69
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint)
|
|
70
|
+
return null;
|
|
71
|
+
return {
|
|
72
|
+
authorizationEndpoint: doc.authorization_endpoint,
|
|
73
|
+
tokenEndpoint: doc.token_endpoint,
|
|
74
|
+
scopesSupported: Array.isArray(doc.scopes_supported)
|
|
75
|
+
? doc.scopes_supported
|
|
76
|
+
: undefined,
|
|
77
|
+
};
|
|
42
78
|
}
|
|
43
79
|
catch {
|
|
44
80
|
return null;
|
|
@@ -56,14 +92,37 @@ export function openBrowser(url) {
|
|
|
56
92
|
}
|
|
57
93
|
}
|
|
58
94
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* 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.
|
|
62
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
|
+
}
|
|
63
122
|
function startLoopback(port, state) {
|
|
64
123
|
let finish;
|
|
65
124
|
let settled = false;
|
|
66
|
-
const
|
|
125
|
+
const result = new Promise(resolve => {
|
|
67
126
|
finish = (value) => {
|
|
68
127
|
if (settled)
|
|
69
128
|
return;
|
|
@@ -79,19 +138,29 @@ function startLoopback(port, state) {
|
|
|
79
138
|
res.writeHead(404).end();
|
|
80
139
|
return;
|
|
81
140
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
149
|
? 'Login complete. You can close this window and return to the terminal.'
|
|
86
|
-
:
|
|
150
|
+
: `Login failed: ${outcome.error}`;
|
|
87
151
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
88
|
-
res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
|
|
89
|
-
finish(
|
|
152
|
+
res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
|
|
153
|
+
finish(outcome);
|
|
90
154
|
});
|
|
91
|
-
const timer = setTimeout(() => finish(
|
|
92
|
-
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
|
+
}));
|
|
93
159
|
server.listen(port, '127.0.0.1');
|
|
94
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
result,
|
|
162
|
+
close: (reason) => finish({ error: reason ?? 'Login cancelled.' }),
|
|
163
|
+
};
|
|
95
164
|
}
|
|
96
165
|
/**
|
|
97
166
|
* Start an interactive OAuth2 login. Returns the authorize URL to open and a
|
|
@@ -121,24 +190,28 @@ export function beginOAuth2Login(dev) {
|
|
|
121
190
|
}
|
|
122
191
|
const loopback = startLoopback(port, state);
|
|
123
192
|
const complete = async () => {
|
|
124
|
-
const
|
|
125
|
-
if (!code)
|
|
126
|
-
return
|
|
127
|
-
|
|
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, {
|
|
128
198
|
grant_type: 'authorization_code',
|
|
129
|
-
code,
|
|
199
|
+
code: redirect.code,
|
|
130
200
|
redirect_uri: redirectUri,
|
|
131
201
|
client_id: config.clientId,
|
|
132
202
|
code_verifier: verifier,
|
|
133
203
|
}, secret);
|
|
134
|
-
if (
|
|
135
|
-
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
|
+
}
|
|
136
209
|
if (tokens.refresh_token) {
|
|
137
210
|
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
138
211
|
}
|
|
139
|
-
return tokens.access_token;
|
|
212
|
+
return { token: tokens.access_token };
|
|
140
213
|
};
|
|
141
|
-
return { authUrl: url.href, complete, cancel: loopback.close };
|
|
214
|
+
return { authUrl: url.href, complete, cancel: () => loopback.close() };
|
|
142
215
|
}
|
|
143
216
|
/**
|
|
144
217
|
* Silently resolve an access token by refreshing the keychain refresh token.
|
|
@@ -155,7 +228,7 @@ export async function resolveOAuth2AccessToken(dev) {
|
|
|
155
228
|
const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
|
|
156
229
|
if (!storedRefresh)
|
|
157
230
|
return null;
|
|
158
|
-
const tokens = await postToken(config, {
|
|
231
|
+
const { tokens } = await postToken(config, {
|
|
159
232
|
grant_type: 'refresh_token',
|
|
160
233
|
refresh_token: storedRefresh,
|
|
161
234
|
client_id: config.clientId,
|