logwork-cli 0.0.0-stage → 1.0.0
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.
- package/LICENSE +11 -0
- package/README.md +287 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +101 -0
- package/commands/auth.mjs +227 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +504 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +347 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +793 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +72 -4
- package/setup.sh +22 -0
package/lib/api-auth.mjs
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { createTerminalCredentialProvider } from './terminal-auth-prompts.mjs';
|
|
4
|
+
import { redactText } from './auth-redaction.mjs';
|
|
5
|
+
import { fetchWithPolicy } from './http.mjs';
|
|
6
|
+
|
|
7
|
+
export async function authenticateWithApi({
|
|
8
|
+
...options
|
|
9
|
+
} = {}) {
|
|
10
|
+
const session = await authenticateWithApiSession(options);
|
|
11
|
+
return session.accessToken;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function authenticateWithApiSession({
|
|
15
|
+
apiBase = CONFIG.apiBase,
|
|
16
|
+
keycloakAuthUrl = CONFIG.keycloakAuthUrl,
|
|
17
|
+
keycloakTokenUrl = CONFIG.keycloakTokenUrl,
|
|
18
|
+
keycloakClientId = CONFIG.keycloakClientId,
|
|
19
|
+
keycloakRedirectUri = CONFIG.keycloakRedirectUri,
|
|
20
|
+
keycloakScope = CONFIG.keycloakScope,
|
|
21
|
+
keycloakResponseMode = CONFIG.keycloakResponseMode,
|
|
22
|
+
keycloakResponseType = CONFIG.keycloakResponseType,
|
|
23
|
+
keycloakState = randomUUID(),
|
|
24
|
+
keycloakNonce = randomUUID(),
|
|
25
|
+
signinKeycloakPath = CONFIG.signinKeycloakPath,
|
|
26
|
+
maxApiAuthSteps = CONFIG.maxApiAuthSteps,
|
|
27
|
+
credentialProvider = createTerminalCredentialProvider(),
|
|
28
|
+
fetchImpl = fetch,
|
|
29
|
+
diagnosticsRecorder = null
|
|
30
|
+
} = {}) {
|
|
31
|
+
const protocolContext = createAuthProtocolContext({
|
|
32
|
+
apiBase,
|
|
33
|
+
keycloakAuthUrl,
|
|
34
|
+
keycloakTokenUrl,
|
|
35
|
+
keycloakRedirectUri
|
|
36
|
+
});
|
|
37
|
+
const session = createHttpSession({
|
|
38
|
+
fetchImpl,
|
|
39
|
+
allowedOrigins: protocolContext.allowedOrigins,
|
|
40
|
+
authorizationRedirectUri: keycloakRedirectUri
|
|
41
|
+
});
|
|
42
|
+
const authUrl = buildKeycloakAuthorizeUrl({
|
|
43
|
+
keycloakAuthUrl,
|
|
44
|
+
clientId: keycloakClientId,
|
|
45
|
+
redirectUri: keycloakRedirectUri,
|
|
46
|
+
scope: keycloakScope,
|
|
47
|
+
responseMode: keycloakResponseMode,
|
|
48
|
+
responseType: keycloakResponseType,
|
|
49
|
+
state: keycloakState,
|
|
50
|
+
nonce: keycloakNonce
|
|
51
|
+
});
|
|
52
|
+
recordAuthEvent(diagnosticsRecorder, 'authorize_request', {
|
|
53
|
+
...pageDiagnostics({
|
|
54
|
+
url: authUrl,
|
|
55
|
+
status: 'pending',
|
|
56
|
+
body: ''
|
|
57
|
+
})
|
|
58
|
+
});
|
|
59
|
+
const loginPage = await session.request(authUrl);
|
|
60
|
+
recordAuthEvent(diagnosticsRecorder, 'authorize_response', pageDiagnostics(loginPage));
|
|
61
|
+
|
|
62
|
+
let current = loginPage;
|
|
63
|
+
const authContext = readAuthContext(authUrl);
|
|
64
|
+
let apiAuthSteps = 0;
|
|
65
|
+
while (!current.redirectCode) {
|
|
66
|
+
if (apiAuthSteps >= maxApiAuthSteps) {
|
|
67
|
+
throw safeApiAuthError(formatStepLimitDiagnostics(current, maxApiAuthSteps));
|
|
68
|
+
}
|
|
69
|
+
apiAuthSteps += 1;
|
|
70
|
+
|
|
71
|
+
const form = selectNextKeycloakForm(current.body, current.url, {
|
|
72
|
+
preferDevice: false
|
|
73
|
+
});
|
|
74
|
+
if (!form) {
|
|
75
|
+
throw safeApiAuthError(formatMissingFormDiagnostics(current));
|
|
76
|
+
}
|
|
77
|
+
assertAllowedFormAction(form, protocolContext);
|
|
78
|
+
|
|
79
|
+
recordAuthEvent(diagnosticsRecorder, 'keycloak_form_detected', {
|
|
80
|
+
step: apiAuthSteps,
|
|
81
|
+
kind: form.kind,
|
|
82
|
+
deviceOptions: form.devices.length,
|
|
83
|
+
...pageDiagnostics(current)
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (form.kind === 'password-update') {
|
|
87
|
+
if (typeof credentialProvider.requestPasswordUpdate !== 'function') {
|
|
88
|
+
throw safeApiAuthError(
|
|
89
|
+
'Keycloak requires a password update. Re-run `logwork login` in an interactive terminal.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const passwords = await credentialProvider.requestPasswordUpdate();
|
|
93
|
+
current = await submitPasswordUpdateForm(session, form, passwords);
|
|
94
|
+
recordAuthEvent(diagnosticsRecorder, 'password_update_submit_result', pageDiagnostics(current));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (form.kind === 'credentials') {
|
|
99
|
+
const credentials = await credentialProvider.requestCredentials();
|
|
100
|
+
current = await submitCredentialsForm(session, form, credentials);
|
|
101
|
+
recordAuthEvent(diagnosticsRecorder, 'credentials_submit_result', pageDiagnostics(current));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (form.kind === 'device') {
|
|
106
|
+
const selectedDevice = await credentialProvider.requestDeviceSelection(form.devices);
|
|
107
|
+
if (!selectedDevice) {
|
|
108
|
+
throw safeApiAuthError('Keycloak requires device selection, but no device option was found.');
|
|
109
|
+
}
|
|
110
|
+
current = await submitDeviceForm(session, form, selectedDevice);
|
|
111
|
+
recordAuthEvent(diagnosticsRecorder, 'device_submit_result', pageDiagnostics(current));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (form.kind === 'otp') {
|
|
116
|
+
let selectedDevice = null;
|
|
117
|
+
if (form.devices.length > 0) {
|
|
118
|
+
selectedDevice = form.devices.length === 1
|
|
119
|
+
? form.devices[0]
|
|
120
|
+
: await credentialProvider.requestDeviceSelection(form.devices);
|
|
121
|
+
}
|
|
122
|
+
const otp = await credentialProvider.requestOtp();
|
|
123
|
+
current = await submitOtpForm(session, form, otp, selectedDevice);
|
|
124
|
+
recordAuthEvent(diagnosticsRecorder, 'otp_submit_result', {
|
|
125
|
+
otpFormHasDevices: form.devices.length > 0,
|
|
126
|
+
selectedDeviceSubmitted: Boolean(selectedDevice),
|
|
127
|
+
...pageDiagnostics(current)
|
|
128
|
+
});
|
|
129
|
+
if (!current.redirectCode) {
|
|
130
|
+
const nextForm = selectNextKeycloakForm(current.body, current.url, {
|
|
131
|
+
preferDevice: false
|
|
132
|
+
});
|
|
133
|
+
if (nextForm?.kind === 'otp') {
|
|
134
|
+
recordAuthEvent(diagnosticsRecorder, 'otp_reprompt', {
|
|
135
|
+
otpFormHasDevices: form.devices.length > 0,
|
|
136
|
+
selectedDeviceSubmitted: Boolean(selectedDevice),
|
|
137
|
+
...pageDiagnostics(current)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const code = current.redirectCode;
|
|
146
|
+
const redirectUri = authContext.redirectUri || current.redirectUri;
|
|
147
|
+
if (!code || !redirectUri) {
|
|
148
|
+
throw safeApiAuthError('Keycloak did not return an authorization code and redirect URI.');
|
|
149
|
+
}
|
|
150
|
+
assertExpectedAuthorizationRedirect(current, {
|
|
151
|
+
expectedRedirectUri: authContext.redirectUri,
|
|
152
|
+
expectedState: authContext.state
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
recordAuthEvent(diagnosticsRecorder, 'authorization_code_received', {
|
|
156
|
+
...pageDiagnostics(current),
|
|
157
|
+
redirectUri
|
|
158
|
+
});
|
|
159
|
+
const keycloakToken = await exchangeAuthorizationCode(session, {
|
|
160
|
+
keycloakTokenUrl,
|
|
161
|
+
code,
|
|
162
|
+
clientId: authContext.clientId || 'localhost',
|
|
163
|
+
redirectUri,
|
|
164
|
+
expectedNonce: authContext.nonce,
|
|
165
|
+
expectedKeycloakOrigin: protocolContext.keycloakOrigin,
|
|
166
|
+
browserOrigin: protocolContext.redirectOrigin,
|
|
167
|
+
diagnosticsRecorder
|
|
168
|
+
});
|
|
169
|
+
const finalToken = await exchangeSigninKeycloakSession(session, {
|
|
170
|
+
apiBase,
|
|
171
|
+
signinKeycloakPath,
|
|
172
|
+
accessToken: keycloakToken,
|
|
173
|
+
browserOrigin: protocolContext.redirectOrigin,
|
|
174
|
+
diagnosticsRecorder
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return finalToken;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function buildKeycloakAuthorizeUrl({
|
|
181
|
+
keycloakAuthUrl = CONFIG.keycloakAuthUrl,
|
|
182
|
+
clientId = CONFIG.keycloakClientId,
|
|
183
|
+
redirectUri = CONFIG.keycloakRedirectUri,
|
|
184
|
+
scope = CONFIG.keycloakScope,
|
|
185
|
+
responseMode = CONFIG.keycloakResponseMode,
|
|
186
|
+
responseType = CONFIG.keycloakResponseType,
|
|
187
|
+
state = randomUUID(),
|
|
188
|
+
nonce = randomUUID()
|
|
189
|
+
} = {}) {
|
|
190
|
+
const url = new URL(keycloakAuthUrl);
|
|
191
|
+
url.searchParams.set('client_id', clientId);
|
|
192
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
193
|
+
url.searchParams.set('state', state);
|
|
194
|
+
url.searchParams.set('response_mode', responseMode);
|
|
195
|
+
url.searchParams.set('response_type', responseType);
|
|
196
|
+
url.searchParams.set('scope', scope);
|
|
197
|
+
url.searchParams.set('nonce', nonce);
|
|
198
|
+
return url.toString();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createHttpSession({
|
|
202
|
+
fetchImpl = fetch,
|
|
203
|
+
allowedHosts = [],
|
|
204
|
+
allowedOrigins = [],
|
|
205
|
+
authorizationRedirectUri = null
|
|
206
|
+
} = {}) {
|
|
207
|
+
const jar = new CookieJar();
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
jar,
|
|
211
|
+
async request(url, options = {}) {
|
|
212
|
+
return requestWithRedirects({
|
|
213
|
+
fetchImpl,
|
|
214
|
+
jar,
|
|
215
|
+
url,
|
|
216
|
+
options,
|
|
217
|
+
allowedHosts,
|
|
218
|
+
allowedOrigins,
|
|
219
|
+
authorizationRedirectUri
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function requestWithRedirects({
|
|
226
|
+
fetchImpl,
|
|
227
|
+
jar,
|
|
228
|
+
url,
|
|
229
|
+
options = {},
|
|
230
|
+
maxRedirects = 10,
|
|
231
|
+
allowedHosts = [],
|
|
232
|
+
allowedOrigins = [],
|
|
233
|
+
authorizationRedirectUri = null,
|
|
234
|
+
timeoutMs = CONFIG.httpTimeoutMs,
|
|
235
|
+
retryDelayMs = CONFIG.httpRetryDelayMs,
|
|
236
|
+
retries = CONFIG.httpReadRetries
|
|
237
|
+
}) {
|
|
238
|
+
let currentUrl = new URL(url).toString();
|
|
239
|
+
let method = options.method || 'GET';
|
|
240
|
+
let body = options.body;
|
|
241
|
+
const headers = new Headers(options.headers || {});
|
|
242
|
+
const allowedHostSet = new Set(allowedHosts.filter(Boolean).map((host) => String(host).toLowerCase()));
|
|
243
|
+
const allowedOriginSet = new Set(allowedOrigins.filter(Boolean).map((origin) => String(origin).toLowerCase()));
|
|
244
|
+
|
|
245
|
+
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
|
246
|
+
assertSecureUrl(currentUrl, 'Auth request');
|
|
247
|
+
const cookie = jar.header(currentUrl);
|
|
248
|
+
if (cookie) {
|
|
249
|
+
headers.set('Cookie', cookie);
|
|
250
|
+
}
|
|
251
|
+
const response = await fetchWithPolicy(currentUrl, {
|
|
252
|
+
...options,
|
|
253
|
+
fetchImpl,
|
|
254
|
+
timeoutMs,
|
|
255
|
+
retryDelayMs,
|
|
256
|
+
retries,
|
|
257
|
+
idempotent: isIdempotentMethod(method),
|
|
258
|
+
method,
|
|
259
|
+
body,
|
|
260
|
+
headers,
|
|
261
|
+
redirect: 'manual'
|
|
262
|
+
});
|
|
263
|
+
jar.store(currentUrl, response.headers);
|
|
264
|
+
|
|
265
|
+
if (isRedirect(response.status)) {
|
|
266
|
+
const location = response.headers.get('location');
|
|
267
|
+
if (!location) {
|
|
268
|
+
throw safeApiAuthError('Redirect response did not include a location header.');
|
|
269
|
+
}
|
|
270
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
271
|
+
assertAllowedRedirect(nextUrl, {
|
|
272
|
+
allowedHosts: allowedHostSet,
|
|
273
|
+
allowedOrigins: allowedOriginSet,
|
|
274
|
+
authorizationRedirectUri
|
|
275
|
+
});
|
|
276
|
+
const redirectCode = readAuthorizationCode(nextUrl);
|
|
277
|
+
if (redirectCode) {
|
|
278
|
+
return {
|
|
279
|
+
url: nextUrl,
|
|
280
|
+
status: response.status,
|
|
281
|
+
headers: response.headers,
|
|
282
|
+
body: '',
|
|
283
|
+
redirectCode,
|
|
284
|
+
redirectUri: stripHash(nextUrl)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
currentUrl = nextUrl;
|
|
288
|
+
method = 'GET';
|
|
289
|
+
body = undefined;
|
|
290
|
+
headers.delete('Content-Type');
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
throw safeApiAuthError(`API auth request failed with HTTP ${response.status}.`);
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
url: currentUrl,
|
|
300
|
+
status: response.status,
|
|
301
|
+
headers: response.headers,
|
|
302
|
+
body: text
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
throw safeApiAuthError('Too many redirects during API auth.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function findKeycloakAuthUrl(html, baseUrl) {
|
|
310
|
+
const matches = String(html || '').match(/https:\/\/keycloak\.vinova\.sg\/auth\/realms\/resource\/protocol\/openid-connect\/auth[^"' <]+/i);
|
|
311
|
+
if (matches?.[0]) {
|
|
312
|
+
return decodeHtml(matches[0]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const href = findAttributeValue(html, 'href', /keycloak\.vinova\.sg\/auth\/realms\/resource\/protocol\/openid-connect\/auth/i);
|
|
316
|
+
if (href) {
|
|
317
|
+
return new URL(decodeHtml(href), baseUrl).toString();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function selectNextKeycloakForm(html, baseUrl, { preferDevice = false } = {}) {
|
|
324
|
+
const forms = parseHtmlForms(html, baseUrl);
|
|
325
|
+
const passwordUpdate = forms.find((form) => form.kind === 'password-update');
|
|
326
|
+
const credentials = forms.find((form) => form.kind === 'credentials');
|
|
327
|
+
const otp = forms.find((form) => form.kind === 'otp');
|
|
328
|
+
const device = forms.find((form) => form.kind === 'device' || form.devices.length > 0);
|
|
329
|
+
|
|
330
|
+
if (passwordUpdate) {
|
|
331
|
+
return passwordUpdate;
|
|
332
|
+
}
|
|
333
|
+
if (credentials) {
|
|
334
|
+
return credentials;
|
|
335
|
+
}
|
|
336
|
+
if (otp) {
|
|
337
|
+
return otp;
|
|
338
|
+
}
|
|
339
|
+
if (preferDevice && device) {
|
|
340
|
+
return {
|
|
341
|
+
...device,
|
|
342
|
+
kind: 'device'
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (device) {
|
|
346
|
+
return device;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return forms.find((form) => form.kind === 'credentials')
|
|
350
|
+
|| null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function parseHtmlForms(html, baseUrl) {
|
|
354
|
+
const forms = [];
|
|
355
|
+
const source = String(html || '');
|
|
356
|
+
const regex = /<form\b([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
357
|
+
let match;
|
|
358
|
+
while ((match = regex.exec(source))) {
|
|
359
|
+
const attrs = match[1];
|
|
360
|
+
const inner = match[2];
|
|
361
|
+
const action = resolveActionUrl(decodeHtml(readAttr(attrs, 'action') || ''), baseUrl);
|
|
362
|
+
const method = (readAttr(attrs, 'method') || 'GET').toUpperCase();
|
|
363
|
+
const fields = readInputFields(inner);
|
|
364
|
+
const devices = readDeviceOptions(inner);
|
|
365
|
+
const kind = classifyForm(fields, devices);
|
|
366
|
+
forms.push({ action, method, fields, devices, kind });
|
|
367
|
+
}
|
|
368
|
+
return forms;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function submitCredentialsForm(session, form, { email, password }) {
|
|
372
|
+
const body = bodyFromForm(form);
|
|
373
|
+
body.set(usernameFieldName(form) || 'username', email);
|
|
374
|
+
body.set(passwordFieldName(form) || 'password', password);
|
|
375
|
+
return postForm(session, form.action, body);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function submitPasswordUpdateForm(session, form, {
|
|
379
|
+
newPassword,
|
|
380
|
+
confirmPassword
|
|
381
|
+
}) {
|
|
382
|
+
const fieldNames = passwordUpdateFieldNames(form);
|
|
383
|
+
if (!fieldNames) {
|
|
384
|
+
throw safeApiAuthError('Keycloak password update form did not contain the expected password fields.');
|
|
385
|
+
}
|
|
386
|
+
if (!newPassword || newPassword !== confirmPassword) {
|
|
387
|
+
throw safeApiAuthError('New Resource Optimiser passwords must be non-empty and match.');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const body = bodyFromForm(form);
|
|
391
|
+
body.set(fieldNames.newPassword, newPassword);
|
|
392
|
+
body.set(fieldNames.confirmPassword, confirmPassword);
|
|
393
|
+
return postForm(session, form.action, body);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function submitDeviceForm(session, form, device) {
|
|
397
|
+
const body = bodyFromForm(form);
|
|
398
|
+
body.set(device.name, device.value);
|
|
399
|
+
return postForm(session, form.action, body);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function submitOtpForm(session, form, otp, selectedDevice = null) {
|
|
403
|
+
const body = bodyFromForm(form);
|
|
404
|
+
if (selectedDevice?.name && selectedDevice.value !== undefined) {
|
|
405
|
+
body.set(selectedDevice.name, selectedDevice.value);
|
|
406
|
+
}
|
|
407
|
+
body.set(otpFieldName(form) || 'otp', otp);
|
|
408
|
+
return postForm(session, form.action, body);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function exchangeAuthorizationCode(session, {
|
|
412
|
+
keycloakTokenUrl,
|
|
413
|
+
code,
|
|
414
|
+
clientId,
|
|
415
|
+
redirectUri,
|
|
416
|
+
expectedNonce = null,
|
|
417
|
+
expectedKeycloakOrigin = null,
|
|
418
|
+
browserOrigin = originFromUrl(redirectUri),
|
|
419
|
+
diagnosticsRecorder = null
|
|
420
|
+
}) {
|
|
421
|
+
assertExpectedOrigin(keycloakTokenUrl, expectedKeycloakOrigin, 'Keycloak token endpoint');
|
|
422
|
+
assertSecureUrl(keycloakTokenUrl, 'Keycloak token endpoint');
|
|
423
|
+
const body = new URLSearchParams({
|
|
424
|
+
code,
|
|
425
|
+
grant_type: 'authorization_code',
|
|
426
|
+
client_id: clientId,
|
|
427
|
+
redirect_uri: redirectUri
|
|
428
|
+
});
|
|
429
|
+
const response = await session.request(keycloakTokenUrl, {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
headers: {
|
|
432
|
+
Accept: '*/*',
|
|
433
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
434
|
+
Origin: browserOrigin,
|
|
435
|
+
Referer: `${browserOrigin}/`
|
|
436
|
+
},
|
|
437
|
+
body
|
|
438
|
+
});
|
|
439
|
+
let data;
|
|
440
|
+
try {
|
|
441
|
+
data = safeJson(response.body);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
recordAuthEvent(diagnosticsRecorder, 'keycloak_token_response', {
|
|
444
|
+
status: response.status || 'unknown',
|
|
445
|
+
jsonKeys: [],
|
|
446
|
+
error: error.message
|
|
447
|
+
});
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
recordAuthEvent(diagnosticsRecorder, 'keycloak_token_response', {
|
|
451
|
+
status: response.status || 'unknown',
|
|
452
|
+
jsonKeys: collectJsonKeyPaths(data)
|
|
453
|
+
});
|
|
454
|
+
const token = data.access_token || data.accessToken;
|
|
455
|
+
if (!token) {
|
|
456
|
+
throw safeApiAuthError('Keycloak token endpoint did not return an access token.');
|
|
457
|
+
}
|
|
458
|
+
assertExpectedTokenNonce(token, expectedNonce);
|
|
459
|
+
return token;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function exchangeSigninKeycloak(session, {
|
|
463
|
+
...options
|
|
464
|
+
}) {
|
|
465
|
+
const finalSession = await exchangeSigninKeycloakSession(session, options);
|
|
466
|
+
return finalSession.accessToken;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function exchangeSigninKeycloakSession(session, {
|
|
470
|
+
apiBase,
|
|
471
|
+
signinKeycloakPath,
|
|
472
|
+
accessToken,
|
|
473
|
+
browserOrigin = 'https://app.resourceoptimiser.com',
|
|
474
|
+
diagnosticsRecorder = null
|
|
475
|
+
}) {
|
|
476
|
+
assertSecureUrl(apiBase, 'Resource Optimiser API endpoint');
|
|
477
|
+
const response = await session.request(`${apiBase}${signinKeycloakPath}`, {
|
|
478
|
+
method: 'POST',
|
|
479
|
+
headers: {
|
|
480
|
+
Accept: 'application/json, text/plain, */*',
|
|
481
|
+
'Content-Type': 'application/json',
|
|
482
|
+
Origin: browserOrigin,
|
|
483
|
+
Referer: `${browserOrigin}/`
|
|
484
|
+
},
|
|
485
|
+
body: JSON.stringify({ accessToken })
|
|
486
|
+
});
|
|
487
|
+
let data;
|
|
488
|
+
try {
|
|
489
|
+
data = safeJson(response.body);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
recordAuthEvent(diagnosticsRecorder, 'signin_keycloak_response', {
|
|
492
|
+
status: response.status || 'unknown',
|
|
493
|
+
jsonKeys: [],
|
|
494
|
+
error: error.message
|
|
495
|
+
});
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
recordAuthEvent(diagnosticsRecorder, 'signin_keycloak_response', {
|
|
499
|
+
status: response.status || 'unknown',
|
|
500
|
+
jsonKeys: collectJsonKeyPaths(data)
|
|
501
|
+
});
|
|
502
|
+
const finalSession = extractRoSession(data, response.headers);
|
|
503
|
+
if (!finalSession?.accessToken) {
|
|
504
|
+
throw safeApiAuthError(formatSigninKeycloakDiagnostics(response, data));
|
|
505
|
+
}
|
|
506
|
+
return finalSession;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function extractRoToken(data, headers) {
|
|
510
|
+
return extractRoSession(data, headers)?.accessToken || null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function extractRoSession(data, headers) {
|
|
514
|
+
const accessToken = extractRoAccessToken(data, headers);
|
|
515
|
+
if (!accessToken) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
accessToken,
|
|
520
|
+
refreshToken: extractRoRefreshToken(data)
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function extractRoAccessToken(data, headers) {
|
|
525
|
+
const candidates = [
|
|
526
|
+
readBearerHeader(headers),
|
|
527
|
+
...collectJwtCandidatesByKey(data, isAccessTokenKey)
|
|
528
|
+
].filter(Boolean);
|
|
529
|
+
return candidates.find(isResourceOptimiserJwt) || null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function extractRoRefreshToken(data) {
|
|
533
|
+
const candidates = collectJwtCandidatesByKey(data, isRefreshTokenKey).filter(Boolean);
|
|
534
|
+
return candidates.find(isResourceOptimiserJwt) || null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function readAuthContext(rawUrl) {
|
|
538
|
+
let url;
|
|
539
|
+
try {
|
|
540
|
+
url = new URL(rawUrl);
|
|
541
|
+
} catch {
|
|
542
|
+
return {};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
clientId: url.searchParams.get('client_id'),
|
|
546
|
+
redirectUri: url.searchParams.get('redirect_uri'),
|
|
547
|
+
state: url.searchParams.get('state'),
|
|
548
|
+
nonce: url.searchParams.get('nonce')
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function createAuthProtocolContext({
|
|
553
|
+
apiBase,
|
|
554
|
+
keycloakAuthUrl,
|
|
555
|
+
keycloakTokenUrl,
|
|
556
|
+
keycloakRedirectUri
|
|
557
|
+
}) {
|
|
558
|
+
const apiBaseUrl = assertSecureUrl(apiBase, 'Resource Optimiser API endpoint');
|
|
559
|
+
const keycloakAuth = assertSecureUrl(keycloakAuthUrl, 'Keycloak auth endpoint');
|
|
560
|
+
const keycloakToken = assertSecureUrl(keycloakTokenUrl, 'Keycloak token endpoint');
|
|
561
|
+
const redirectUri = assertSecureUrl(keycloakRedirectUri, 'Resource Optimiser redirect URI');
|
|
562
|
+
if (keycloakAuth.origin !== keycloakToken.origin) {
|
|
563
|
+
throw safeApiAuthError('Keycloak token endpoint origin did not match the authorization endpoint origin.');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
allowedOrigins: [
|
|
568
|
+
apiBaseUrl.origin,
|
|
569
|
+
keycloakAuth.origin,
|
|
570
|
+
keycloakToken.origin,
|
|
571
|
+
redirectUri.origin
|
|
572
|
+
],
|
|
573
|
+
keycloakOrigin: keycloakAuth.origin,
|
|
574
|
+
redirectOrigin: redirectUri.origin,
|
|
575
|
+
redirectUri: keycloakRedirectUri
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function assertAllowedFormAction(form, context) {
|
|
580
|
+
const actionUrl = assertSecureUrl(form.action, 'Keycloak form action');
|
|
581
|
+
if (actionUrl.origin !== context.keycloakOrigin) {
|
|
582
|
+
throw safeApiAuthError(`Blocked Keycloak form submission to unexpected origin: ${actionUrl.origin || 'unknown-origin'}.`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function assertAllowedRedirect(rawUrl, { allowedHosts, allowedOrigins, authorizationRedirectUri }) {
|
|
587
|
+
const url = assertSecureUrl(rawUrl, 'Auth redirect');
|
|
588
|
+
const origin = url.origin.toLowerCase();
|
|
589
|
+
const host = url.host.toLowerCase();
|
|
590
|
+
const originAllowed = allowedOrigins?.size
|
|
591
|
+
? allowedOrigins.has(origin)
|
|
592
|
+
: allowedHosts.has(host);
|
|
593
|
+
if (!originAllowed) {
|
|
594
|
+
throw safeApiAuthError(`Blocked auth redirect to unexpected origin: ${url.origin || 'unknown-origin'}.`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (readAuthorizationCode(rawUrl) && !sameRedirectTarget(rawUrl, authorizationRedirectUri)) {
|
|
598
|
+
throw safeApiAuthError('Blocked authorization code redirect to unexpected Resource Optimiser callback.');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function assertExpectedAuthorizationRedirect(response, {
|
|
603
|
+
expectedRedirectUri,
|
|
604
|
+
expectedState
|
|
605
|
+
}) {
|
|
606
|
+
if (!sameRedirectTarget(response.url, expectedRedirectUri)) {
|
|
607
|
+
throw safeApiAuthError('Authorization code redirect URI did not match the requested callback.');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const state = readAuthorizationState(response.url);
|
|
611
|
+
if (!expectedState || state !== expectedState) {
|
|
612
|
+
throw safeApiAuthError('Authorization response state did not match the requested state.');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function assertExpectedTokenNonce(token, expectedNonce) {
|
|
617
|
+
if (!expectedNonce) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const payload = decodeJwtPayload(token);
|
|
622
|
+
if (!payload || payload.nonce === undefined || payload.nonce === null) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (String(payload.nonce) !== String(expectedNonce)) {
|
|
627
|
+
throw safeApiAuthError('Keycloak token nonce did not match the requested nonce.');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function assertExpectedOrigin(rawUrl, expectedOrigin, label) {
|
|
632
|
+
if (!expectedOrigin) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const url = assertSecureUrl(rawUrl, label);
|
|
637
|
+
if (url.origin !== expectedOrigin) {
|
|
638
|
+
throw safeApiAuthError(`${label} origin did not match the expected authorization origin.`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function assertSecureUrl(rawUrl, label) {
|
|
643
|
+
let url;
|
|
644
|
+
try {
|
|
645
|
+
url = new URL(rawUrl);
|
|
646
|
+
} catch {
|
|
647
|
+
throw safeApiAuthError(`${label} must be a valid URL.`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
651
|
+
throw safeApiAuthError(`${label} must use HTTP or HTTPS.`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (url.protocol === 'http:' && !isLocalHttpHost(url.hostname)) {
|
|
655
|
+
throw safeApiAuthError(`${label} must use HTTPS unless it points to localhost or loopback.`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return url;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function originFromUrl(rawUrl) {
|
|
662
|
+
return assertSecureUrl(rawUrl, 'Origin URL').origin;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function postForm(session, action, body) {
|
|
666
|
+
return session.request(action, {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: {
|
|
669
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
670
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
671
|
+
},
|
|
672
|
+
body
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function recordAuthEvent(recorder, name, details = {}) {
|
|
677
|
+
try {
|
|
678
|
+
recorder?.event?.(name, details);
|
|
679
|
+
} catch {
|
|
680
|
+
// Diagnostics must never break auth.
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function pageDiagnostics(response = {}) {
|
|
685
|
+
const url = safeUrlParts(response.url);
|
|
686
|
+
const forms = parseHtmlForms(response.body, response.url);
|
|
687
|
+
const kinds = forms.map((form) => form.kind).filter(Boolean);
|
|
688
|
+
const title = readTitle(response.body);
|
|
689
|
+
const bodyHint = readBodyHint(response.body);
|
|
690
|
+
return {
|
|
691
|
+
host: url.host,
|
|
692
|
+
path: url.path,
|
|
693
|
+
status: response.status || 'unknown',
|
|
694
|
+
forms: forms.length,
|
|
695
|
+
formKinds: kinds,
|
|
696
|
+
title: title || undefined,
|
|
697
|
+
hint: bodyHint || undefined,
|
|
698
|
+
redirectCodeReceived: Boolean(response.redirectCode)
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function formatMissingFormDiagnostics(response) {
|
|
703
|
+
const url = safeUrlParts(response.url);
|
|
704
|
+
const forms = parseHtmlForms(response.body, response.url);
|
|
705
|
+
const kinds = forms.map((form) => form.kind).filter(Boolean);
|
|
706
|
+
const formSummary = summarizeFormShapes(forms);
|
|
707
|
+
const title = readTitle(response.body);
|
|
708
|
+
const bodyHint = readBodyHint(response.body);
|
|
709
|
+
return [
|
|
710
|
+
'Unable to find a supported Keycloak login, password update, device, or 2FA form.',
|
|
711
|
+
`Current page: ${url.host}${url.path}`,
|
|
712
|
+
`Status: ${response.status || 'unknown'}`,
|
|
713
|
+
`Forms: ${forms.length}${kinds.length ? ` (${kinds.join(', ')})` : ''}`,
|
|
714
|
+
formSummary ? `Fields: ${formSummary}` : null,
|
|
715
|
+
title ? `Title: ${title}` : null,
|
|
716
|
+
bodyHint ? `Hint: ${bodyHint}` : null,
|
|
717
|
+
'Retry with `logwork login`. If this keeps failing, inspect sanitized JSON key diagnostics and Keycloak form shape.'
|
|
718
|
+
].filter(Boolean).join(' ');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function formatStepLimitDiagnostics(response, maxApiAuthSteps) {
|
|
722
|
+
const url = safeUrlParts(response.url);
|
|
723
|
+
const forms = parseHtmlForms(response.body, response.url);
|
|
724
|
+
const kinds = forms.map((form) => form.kind).filter(Boolean);
|
|
725
|
+
const formSummary = summarizeFormShapes(forms);
|
|
726
|
+
return [
|
|
727
|
+
`API-only auth stopped after ${maxApiAuthSteps} Keycloak form steps to avoid an infinite loop.`,
|
|
728
|
+
`Current page: ${url.host}${url.path}`,
|
|
729
|
+
`Status: ${response.status || 'unknown'}`,
|
|
730
|
+
`Forms: ${forms.length}${kinds.length ? ` (${kinds.join(', ')})` : ''}`,
|
|
731
|
+
formSummary ? `Fields: ${formSummary}` : null,
|
|
732
|
+
'Retry with `logwork login`. If this keeps failing, inspect sanitized diagnostics and Keycloak form shape.'
|
|
733
|
+
].filter(Boolean).join(' ');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function formatSigninKeycloakDiagnostics(response, data) {
|
|
737
|
+
const keys = collectJsonKeyPaths(data);
|
|
738
|
+
return [
|
|
739
|
+
'Resource Optimiser signinKeyCloak did not return a usable API token.',
|
|
740
|
+
`Status: ${response.status || 'unknown'}`,
|
|
741
|
+
`JSON keys: ${keys.length ? keys.join(', ') : '(none)'}`,
|
|
742
|
+
'Retry with `logwork login`. If this keeps failing, inspect sanitized JSON key diagnostics from the error.'
|
|
743
|
+
].join(' ');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function safeUrlParts(rawUrl) {
|
|
747
|
+
try {
|
|
748
|
+
const url = new URL(rawUrl);
|
|
749
|
+
return {
|
|
750
|
+
host: url.host,
|
|
751
|
+
path: url.pathname
|
|
752
|
+
};
|
|
753
|
+
} catch {
|
|
754
|
+
return {
|
|
755
|
+
host: 'unknown-host',
|
|
756
|
+
path: '/unknown-path'
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function readTitle(html) {
|
|
762
|
+
const match = String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
763
|
+
return match ? stripTags(decodeHtml(match[1])).slice(0, 120) : null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function readBodyHint(html) {
|
|
767
|
+
const bodyMatch = String(html || '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
768
|
+
const body = String(bodyMatch ? bodyMatch[1] : html)
|
|
769
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
|
|
770
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
|
|
771
|
+
const hint = stripTags(decodeHtml(body)).slice(0, 160);
|
|
772
|
+
return hint || null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function resolveActionUrl(action, baseUrl) {
|
|
776
|
+
if (!action) {
|
|
777
|
+
return baseUrl ? new URL(baseUrl).toString() : '';
|
|
778
|
+
}
|
|
779
|
+
if (!baseUrl) {
|
|
780
|
+
return action;
|
|
781
|
+
}
|
|
782
|
+
return new URL(action, baseUrl).toString();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function bodyFromForm(form) {
|
|
786
|
+
const body = new URLSearchParams();
|
|
787
|
+
for (const field of form.fields) {
|
|
788
|
+
if (field.name && field.value !== undefined) {
|
|
789
|
+
body.set(field.name, field.value);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return body;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function readBearerHeader(headers) {
|
|
796
|
+
const value = headers?.get?.('authorization') || headers?.get?.('Authorization') || '';
|
|
797
|
+
const match = String(value).match(/^Bearer\s+(.+)$/i);
|
|
798
|
+
return match?.[1]?.trim() || null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function collectJwtCandidates(value, seen = new Set()) {
|
|
802
|
+
if (typeof value === 'string') {
|
|
803
|
+
return value.split('.').length >= 3 ? [value] : [];
|
|
804
|
+
}
|
|
805
|
+
if (!value || typeof value !== 'object' || seen.has(value)) {
|
|
806
|
+
return [];
|
|
807
|
+
}
|
|
808
|
+
seen.add(value);
|
|
809
|
+
|
|
810
|
+
const candidates = [];
|
|
811
|
+
if (Array.isArray(value)) {
|
|
812
|
+
for (const item of value) {
|
|
813
|
+
candidates.push(...collectJwtCandidates(item, seen));
|
|
814
|
+
}
|
|
815
|
+
return candidates;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
for (const item of Object.values(value)) {
|
|
819
|
+
candidates.push(...collectJwtCandidates(item, seen));
|
|
820
|
+
}
|
|
821
|
+
return candidates;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function collectJwtCandidatesByKey(value, keyMatcher, seen = new Set()) {
|
|
825
|
+
if (!value || typeof value !== 'object' || seen.has(value)) {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
seen.add(value);
|
|
829
|
+
|
|
830
|
+
const candidates = [];
|
|
831
|
+
if (Array.isArray(value)) {
|
|
832
|
+
for (const item of value) {
|
|
833
|
+
candidates.push(...collectJwtCandidatesByKey(item, keyMatcher, seen));
|
|
834
|
+
}
|
|
835
|
+
return candidates;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
for (const [key, item] of Object.entries(value)) {
|
|
839
|
+
if (typeof item === 'string' && keyMatcher(key) && item.split('.').length >= 3) {
|
|
840
|
+
candidates.push(item);
|
|
841
|
+
}
|
|
842
|
+
candidates.push(...collectJwtCandidatesByKey(item, keyMatcher, seen));
|
|
843
|
+
}
|
|
844
|
+
return candidates;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function isAccessTokenKey(key) {
|
|
848
|
+
return /^(access[_-]?token|token|jwt)$/i.test(String(key || ''));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function isRefreshTokenKey(key) {
|
|
852
|
+
return /^refresh[_-]?token$/i.test(String(key || ''));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function collectJsonKeyPaths(value, path = '', seen = new Set(), output = []) {
|
|
856
|
+
if (!value || typeof value !== 'object' || seen.has(value)) {
|
|
857
|
+
return output;
|
|
858
|
+
}
|
|
859
|
+
seen.add(value);
|
|
860
|
+
|
|
861
|
+
if (Array.isArray(value)) {
|
|
862
|
+
value.slice(0, 5).forEach((item, index) => {
|
|
863
|
+
collectJsonKeyPaths(item, `${path}[${index}]`, seen, output);
|
|
864
|
+
});
|
|
865
|
+
return output;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const key of Object.keys(value)) {
|
|
869
|
+
const nextPath = path ? `${path}.${key}` : key;
|
|
870
|
+
output.push(nextPath);
|
|
871
|
+
if (output.length >= 40) {
|
|
872
|
+
return output;
|
|
873
|
+
}
|
|
874
|
+
collectJsonKeyPaths(value[key], nextPath, seen, output);
|
|
875
|
+
}
|
|
876
|
+
return output;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function isResourceOptimiserJwt(token) {
|
|
880
|
+
const payload = decodeJwtPayload(token);
|
|
881
|
+
if (!payload) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
if (isKeycloakJwtPayload(payload)) {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
return Boolean(
|
|
888
|
+
payload.id !== undefined
|
|
889
|
+
|| typeof payload.email === 'string'
|
|
890
|
+
|| typeof payload.db_url === 'string'
|
|
891
|
+
|| typeof payload.dbUrl === 'string'
|
|
892
|
+
|| typeof payload.user_id === 'number'
|
|
893
|
+
|| typeof payload.userId === 'number'
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function isKeycloakJwtPayload(payload) {
|
|
898
|
+
const issuer = String(payload.iss || '').toLowerCase();
|
|
899
|
+
return issuer.includes('keycloak')
|
|
900
|
+
|| payload.azp === 'localhost'
|
|
901
|
+
|| payload.realm_access !== undefined
|
|
902
|
+
|| payload.resource_access !== undefined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function decodeJwtPayload(token) {
|
|
906
|
+
const [, payload] = String(token || '').split('.');
|
|
907
|
+
if (!payload) {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function classifyForm(fields, devices) {
|
|
918
|
+
if (passwordUpdateFieldNames({ fields })) {
|
|
919
|
+
return 'password-update';
|
|
920
|
+
}
|
|
921
|
+
if (passwordFieldName({ fields })) {
|
|
922
|
+
return 'credentials';
|
|
923
|
+
}
|
|
924
|
+
if (otpFieldName({ fields })) {
|
|
925
|
+
return 'otp';
|
|
926
|
+
}
|
|
927
|
+
if (devices.length > 0) {
|
|
928
|
+
return 'device';
|
|
929
|
+
}
|
|
930
|
+
return 'unknown';
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function readInputFields(html) {
|
|
934
|
+
const fields = [];
|
|
935
|
+
const regex = /<input\b([^>]*)>/gi;
|
|
936
|
+
let match;
|
|
937
|
+
while ((match = regex.exec(String(html || '')))) {
|
|
938
|
+
const attrs = match[1];
|
|
939
|
+
fields.push({
|
|
940
|
+
name: decodeHtml(readAttr(attrs, 'name') || ''),
|
|
941
|
+
id: decodeHtml(readAttr(attrs, 'id') || ''),
|
|
942
|
+
type: decodeHtml(readAttr(attrs, 'type') || 'text'),
|
|
943
|
+
value: decodeHtml(readAttr(attrs, 'value') || ''),
|
|
944
|
+
autocomplete: decodeHtml(readAttr(attrs, 'autocomplete') || ''),
|
|
945
|
+
inputmode: decodeHtml(readAttr(attrs, 'inputmode') || ''),
|
|
946
|
+
placeholder: decodeHtml(readAttr(attrs, 'placeholder') || ''),
|
|
947
|
+
ariaLabel: decodeHtml(readAttr(attrs, 'aria-label') || '')
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
return fields.filter((field) => field.name);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function otpFieldName(form) {
|
|
954
|
+
return form.fields.find(isOtpInputField)?.name;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function usernameFieldName(form) {
|
|
958
|
+
return fieldName(form, ['username', 'email', 'login', 'user', 'userid', 'user_id']) ||
|
|
959
|
+
form.fields.find((field) => isTextInputField(field) && fieldHint(field, /user|email|login|account|identifier/))?.name ||
|
|
960
|
+
form.fields.find((field) => isTextInputField(field) && !isOtpInputField(field))?.name;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function passwordFieldName(form) {
|
|
964
|
+
return fieldName(form, ['password', 'passwd', 'pass', 'pwd']) ||
|
|
965
|
+
form.fields.find((field) => String(field.type || '').toLowerCase() === 'password')?.name;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function passwordUpdateFieldNames(form) {
|
|
969
|
+
const passwordFields = form.fields.filter(
|
|
970
|
+
(field) => String(field.type || '').toLowerCase() === 'password'
|
|
971
|
+
);
|
|
972
|
+
if (passwordFields.length < 2) {
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const newPassword = fieldName(form, [
|
|
977
|
+
'password-new',
|
|
978
|
+
'new-password',
|
|
979
|
+
'newPassword',
|
|
980
|
+
'passwordNew'
|
|
981
|
+
]) || passwordFields.find((field) => fieldHint(field, /(?:new.*password|password.*new)/i))?.name;
|
|
982
|
+
const confirmPassword = fieldName(form, [
|
|
983
|
+
'password-confirm',
|
|
984
|
+
'confirm-password',
|
|
985
|
+
'confirmPassword',
|
|
986
|
+
'passwordConfirm',
|
|
987
|
+
'password-repeat',
|
|
988
|
+
'repeat-password'
|
|
989
|
+
]) || passwordFields.find((field) => fieldHint(field, /confirm|repeat/i))?.name;
|
|
990
|
+
|
|
991
|
+
if (newPassword && confirmPassword && newPassword !== confirmPassword) {
|
|
992
|
+
return { newPassword, confirmPassword };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return {
|
|
996
|
+
newPassword: passwordFields[0].name,
|
|
997
|
+
confirmPassword: passwordFields[1].name
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function fieldName(form, candidates) {
|
|
1002
|
+
const lower = new Set(candidates.map((candidate) => candidate.toLowerCase()));
|
|
1003
|
+
return form.fields.find((field) => lower.has(String(field.name || '').toLowerCase()))?.name;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function isTextInputField(field) {
|
|
1007
|
+
const type = String(field.type || 'text').toLowerCase();
|
|
1008
|
+
return ['text', 'email', 'search', 'tel', 'url', ''].includes(type);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function isOtpInputField(field) {
|
|
1012
|
+
const name = String(field.name || '').toLowerCase();
|
|
1013
|
+
const type = String(field.type || 'text').toLowerCase();
|
|
1014
|
+
if (['hidden', 'radio', 'checkbox', 'submit', 'button'].includes(type)) {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (/^(otp|totp|mfa|mfa_code|two_factor|twofactor|authenticator_code|authenticatorCode)$/i.test(name)) {
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (/(otp|totp|mfa|2fa|two[_-]?factor|authenticator|verification)/i.test(name)) {
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (name !== 'code') {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return fieldHint(field, /otp|totp|one-time|one time|verification|authenticator|2fa|mfa|sms|pin|numeric|number/);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function fieldHint(field, pattern) {
|
|
1034
|
+
const hint = [
|
|
1035
|
+
field.id,
|
|
1036
|
+
field.autocomplete,
|
|
1037
|
+
field.inputmode,
|
|
1038
|
+
field.placeholder,
|
|
1039
|
+
field.ariaLabel
|
|
1040
|
+
].map((value) => String(value || '').toLowerCase()).join(' ');
|
|
1041
|
+
|
|
1042
|
+
return pattern.test(hint);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function readDeviceOptions(html) {
|
|
1046
|
+
const devices = [];
|
|
1047
|
+
const radioRegex = /<input\b([^>]*type=["']?radio["']?[^>]*)>/gi;
|
|
1048
|
+
let radioMatch;
|
|
1049
|
+
while ((radioMatch = radioRegex.exec(String(html || '')))) {
|
|
1050
|
+
const attrs = radioMatch[1];
|
|
1051
|
+
const name = decodeHtml(readAttr(attrs, 'name') || '');
|
|
1052
|
+
const value = decodeHtml(readAttr(attrs, 'value') || '');
|
|
1053
|
+
const id = decodeHtml(readAttr(attrs, 'id') || '');
|
|
1054
|
+
if (!name || !value) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
devices.push({
|
|
1058
|
+
type: 'radio',
|
|
1059
|
+
name,
|
|
1060
|
+
value,
|
|
1061
|
+
label: readLabelFor(html, id) || readWrappingLabelForInput(html, attrs) || value
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const selectRegex = /<select\b([^>]*)>([\s\S]*?)<\/select>/gi;
|
|
1066
|
+
let selectMatch;
|
|
1067
|
+
while ((selectMatch = selectRegex.exec(String(html || '')))) {
|
|
1068
|
+
const name = decodeHtml(readAttr(selectMatch[1], 'name') || '');
|
|
1069
|
+
if (!name) {
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const optionRegex = /<option\b([^>]*)>([\s\S]*?)<\/option>/gi;
|
|
1073
|
+
let optionMatch;
|
|
1074
|
+
while ((optionMatch = optionRegex.exec(selectMatch[2]))) {
|
|
1075
|
+
const value = decodeHtml(readAttr(optionMatch[1], 'value') || '');
|
|
1076
|
+
const label = stripTags(decodeHtml(optionMatch[2]));
|
|
1077
|
+
if (value) {
|
|
1078
|
+
devices.push({ type: 'select', name, value, label: label || value });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return devices;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function readLabelFor(html, id) {
|
|
1087
|
+
if (!id) {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1091
|
+
const match = String(html || '').match(new RegExp(`<label\\b[^>]*for=["']${escapedId}["'][^>]*>([\\s\\S]*?)<\\/label>`, 'i'));
|
|
1092
|
+
return match ? stripTags(decodeHtml(match[1])) : null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function readWrappingLabelForInput(html, inputAttrs) {
|
|
1096
|
+
const inputValue = readAttr(inputAttrs, 'value');
|
|
1097
|
+
const labels = String(html || '').match(/<label\b[^>]*>[\s\S]*?<\/label>/gi) || [];
|
|
1098
|
+
for (const label of labels) {
|
|
1099
|
+
const inputMatch = label.match(/<input\b([^>]*)>/i);
|
|
1100
|
+
if (!inputMatch) {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
const labelInputValue = readAttr(inputMatch[1], 'value');
|
|
1104
|
+
const labelInputName = readAttr(inputMatch[1], 'name');
|
|
1105
|
+
if (
|
|
1106
|
+
inputValue !== null &&
|
|
1107
|
+
labelInputValue === inputValue &&
|
|
1108
|
+
labelInputName === readAttr(inputAttrs, 'name')
|
|
1109
|
+
) {
|
|
1110
|
+
return stripTags(decodeHtml(label));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function summarizeFormShapes(forms) {
|
|
1117
|
+
return forms
|
|
1118
|
+
.slice(0, 3)
|
|
1119
|
+
.map((form, index) => {
|
|
1120
|
+
const fields = form.fields
|
|
1121
|
+
.slice(0, 8)
|
|
1122
|
+
.map((field) => `${field.name || '(unnamed)'}:${field.type || 'text'}`)
|
|
1123
|
+
.join(',');
|
|
1124
|
+
const devices = form.devices.length ? ` devices=${form.devices.length}` : '';
|
|
1125
|
+
return `form${index + 1}{kind=${form.kind || 'unknown'} fields=[${fields}]${devices}}`;
|
|
1126
|
+
})
|
|
1127
|
+
.join(' ');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function readAttr(attrs, name) {
|
|
1131
|
+
const match = String(attrs || '').match(new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
|
1132
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function findAttributeValue(html, attr, pattern) {
|
|
1136
|
+
const regex = new RegExp(`${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'gi');
|
|
1137
|
+
let match;
|
|
1138
|
+
while ((match = regex.exec(String(html || '')))) {
|
|
1139
|
+
const value = match[1] ?? match[2] ?? match[3] ?? '';
|
|
1140
|
+
if (pattern.test(value)) {
|
|
1141
|
+
return value;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function readAuthorizationCode(rawUrl) {
|
|
1148
|
+
const url = new URL(rawUrl);
|
|
1149
|
+
return url.searchParams.get('code') || new URLSearchParams(url.hash.replace(/^#/, '')).get('code');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function readAuthorizationState(rawUrl) {
|
|
1153
|
+
const url = new URL(rawUrl);
|
|
1154
|
+
return url.searchParams.get('state') || new URLSearchParams(url.hash.replace(/^#/, '')).get('state');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function sameRedirectTarget(rawUrl, expectedRawUrl) {
|
|
1158
|
+
if (!expectedRawUrl) {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
try {
|
|
1163
|
+
const url = new URL(rawUrl);
|
|
1164
|
+
const expected = new URL(expectedRawUrl);
|
|
1165
|
+
return url.origin === expected.origin && url.pathname === expected.pathname;
|
|
1166
|
+
} catch {
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function isLocalHttpHost(hostname) {
|
|
1172
|
+
const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
1173
|
+
return host === 'localhost' ||
|
|
1174
|
+
host === '127.0.0.1' ||
|
|
1175
|
+
host === '::1' ||
|
|
1176
|
+
host.startsWith('127.');
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function stripHash(rawUrl) {
|
|
1180
|
+
const url = new URL(rawUrl);
|
|
1181
|
+
url.hash = '';
|
|
1182
|
+
return url.toString();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function isRedirect(status) {
|
|
1186
|
+
return [301, 302, 303, 307, 308].includes(status);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function isIdempotentMethod(method) {
|
|
1190
|
+
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase());
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function safeJson(text) {
|
|
1194
|
+
try {
|
|
1195
|
+
return JSON.parse(text);
|
|
1196
|
+
} catch {
|
|
1197
|
+
throw safeApiAuthError('Auth endpoint returned non-JSON response.');
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function safeApiAuthError(message) {
|
|
1202
|
+
const error = new Error(redactText(message));
|
|
1203
|
+
error.code = 'API_AUTH_FAILED';
|
|
1204
|
+
return error;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function decodeHtml(value) {
|
|
1208
|
+
return String(value || '')
|
|
1209
|
+
.replace(/&/g, '&')
|
|
1210
|
+
.replace(/"/g, '"')
|
|
1211
|
+
.replace(/'/g, "'")
|
|
1212
|
+
.replace(/</g, '<')
|
|
1213
|
+
.replace(/>/g, '>');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function stripTags(value) {
|
|
1217
|
+
return String(value || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
class CookieJar {
|
|
1221
|
+
constructor() {
|
|
1222
|
+
this.cookies = new Map();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
store(rawUrl, headers) {
|
|
1226
|
+
const url = new URL(rawUrl);
|
|
1227
|
+
const values = getSetCookieValues(headers);
|
|
1228
|
+
for (const value of values) {
|
|
1229
|
+
const [pair] = value.split(';');
|
|
1230
|
+
const [name, cookieValue] = pair.split('=');
|
|
1231
|
+
if (name && cookieValue !== undefined) {
|
|
1232
|
+
this.cookies.set(`${url.hostname}:${name.trim()}`, `${name.trim()}=${cookieValue.trim()}`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
header(rawUrl) {
|
|
1238
|
+
const url = new URL(rawUrl);
|
|
1239
|
+
return [...this.cookies.entries()]
|
|
1240
|
+
.filter(([key]) => key.startsWith(`${url.hostname}:`))
|
|
1241
|
+
.map(([, value]) => value)
|
|
1242
|
+
.join('; ');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function getSetCookieValues(headers) {
|
|
1247
|
+
if (typeof headers.getSetCookie === 'function') {
|
|
1248
|
+
return headers.getSetCookie();
|
|
1249
|
+
}
|
|
1250
|
+
const value = headers.get?.('set-cookie');
|
|
1251
|
+
return value ? splitSetCookie(value) : [];
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function splitSetCookie(value) {
|
|
1255
|
+
return String(value)
|
|
1256
|
+
.split(/,(?=\s*[^;,\s]+=)/)
|
|
1257
|
+
.map((item) => item.trim())
|
|
1258
|
+
.filter(Boolean);
|
|
1259
|
+
}
|