@globisnv/cypress-core 2.1.3 → 2.2.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/README.md +3 -3
- package/dist/ci/sso-login.cy.d.ts +0 -0
- package/dist/ci/sso-login.cy.js +8 -0
- package/dist/commands/FOB_commands.d.ts +1 -1
- package/dist/commands/FOB_commands.js +4 -4
- package/dist/commands/GLO_commands.d.ts +0 -9
- package/dist/commands/GLO_commands.js +101 -24
- package/dist/commands/POR_commands.js +1 -1
- package/dist/config.js +13 -1
- package/dist/ssoSessionFile.d.ts +2 -0
- package/dist/ssoSessionFile.js +43 -0
- package/dist/totp.d.ts +2 -0
- package/dist/totp.js +54 -0
- package/package.json +1 -1
- package/template/cypress.env.json.example +3 -2
package/README.md
CHANGED
|
@@ -221,10 +221,10 @@ Set via Cypress env (`config.env`, `CYPRESS_*`, or `--env`) unless noted.
|
|
|
221
221
|
| Key | Purpose |
|
|
222
222
|
| --- | --- |
|
|
223
223
|
| `globisUrl` | Base solution URL. `GLJ/GLS/GLT_login` rewrite the `/solution/<name>` segment off this. |
|
|
224
|
-
| `portalUrl`, `formBuilderUrl`, `
|
|
224
|
+
| `portalUrl`, `formBuilderUrl`, `ssoUrl` | Entry URLs for the portal, form builder and Okta login flows (`ssoUrl` was `oktaUrl`). With `loginType: sso` the provider follows from it: Okta when it contains `okta`, otherwise Keycloak (default `https://auth.globis.cloud`). |
|
|
225
225
|
| `username`, `username2` | Default and alternate login users. |
|
|
226
|
-
| `password`, `password2`, `api_key` | Secrets — kept node-side, never exposed to the browser. |
|
|
227
|
-
| `loginType
|
|
226
|
+
| `password`, `password2`, `api_key`, `otpSecret` | Secrets — kept node-side, never exposed to the browser. `otpSecret` is the base32 TOTP secret for Keycloak users with OTP. |
|
|
227
|
+
| `loginType` (`default`/`servoy`/`sso`), `ownerName`, `formBuilderStandalone`, `saveData` | Login/flow switches read by the commands. |
|
|
228
228
|
| `BUILD_NUMBER` | Used by `getOrCreateUuid()` to build a stable `CYP<build>` test-data prefix. |
|
|
229
229
|
| `extraParams` | Object of Jenkins-passed extras; `cypressWebsocketLoader` and `cypressWebsocketDebug` (`'true'`) live here. |
|
|
230
230
|
| `AMP_REMOTE_WRITE_URL`, `AMP_REGION` | Node `process.env` — Prometheus remote-write target and region. |
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// CI pre-step: logs in once so parallel runners on other machines can take over the SSO session (env ssoSessionFile).
|
|
3
|
+
// Run it with: cypress run --spec node_modules/@globisnv/cypress-core/dist/ci/sso-login.cy.js
|
|
4
|
+
describe('SSO login once', () => {
|
|
5
|
+
it('logs in and stores the IdP session for the other runners', () => {
|
|
6
|
+
cy.GLO_login();
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -29,7 +29,7 @@ declare global {
|
|
|
29
29
|
* @param {string} [company] - Optional parameter representing the company name.
|
|
30
30
|
* @param {boolean} [formBuilderStandalone] - Optional check if test uses standalone formbuilder or not.
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
FOB_ssoLogin(company?: string, formBuilderStandalone?: boolean): void;
|
|
33
33
|
/**
|
|
34
34
|
* Command to add a new custom view.
|
|
35
35
|
*
|
|
@@ -8,8 +8,8 @@ Cypress.Commands.add('FOB_login', (company, formBuilderStandalone) => {
|
|
|
8
8
|
case 'servoy':
|
|
9
9
|
cy.POR_servoyLogin(company, formBuilderStandalone);
|
|
10
10
|
break;
|
|
11
|
-
case '
|
|
12
|
-
cy.
|
|
11
|
+
case 'sso':
|
|
12
|
+
cy.FOB_ssoLogin(company, formBuilderStandalone);
|
|
13
13
|
break;
|
|
14
14
|
case 'default':
|
|
15
15
|
cy.FOB_defaultLogin(company, formBuilderStandalone);
|
|
@@ -66,14 +66,14 @@ Cypress.Commands.add('FOB_defaultLogin', (company, formBuilderStandalone) => {
|
|
|
66
66
|
}
|
|
67
67
|
cy.get(`[data-cy="formbuilder${formBuilderStandalone ? '_standalone' : ''}.navbar_top"]`, { timeout: 120000 }).should('exist');
|
|
68
68
|
});
|
|
69
|
-
Cypress.Commands.add('
|
|
69
|
+
Cypress.Commands.add('FOB_ssoLogin', (company, formBuilderStandalone) => {
|
|
70
70
|
// Change the Url if necessary
|
|
71
71
|
const url = Cypress.expose('formBuilderUrl');
|
|
72
72
|
cy.visit(url);
|
|
73
73
|
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for okta redirect page load
|
|
74
74
|
cy.wait(2000);
|
|
75
75
|
cy.task('log', 'Redirect to okta url');
|
|
76
|
-
cy.origin(Cypress.expose('
|
|
76
|
+
cy.origin(Cypress.expose('ssoUrl'), () => {
|
|
77
77
|
cy.get('.o-form-input')
|
|
78
78
|
.find('[autocomplete="username"]')
|
|
79
79
|
.type(Cypress.expose('username'));
|
|
@@ -65,15 +65,6 @@ declare global {
|
|
|
65
65
|
* If provided, the specified company will be selected during login.
|
|
66
66
|
*/
|
|
67
67
|
GLO_defaultloginExtraUser(usernameENV: string, passwordENV: string, company?: string): void;
|
|
68
|
-
/**
|
|
69
|
-
* Command to perform a login using Okta authentication.
|
|
70
|
-
*
|
|
71
|
-
* @param {string} [company] - Optional parameter representing the company name.
|
|
72
|
-
* If provided, it selects the specified company after login.
|
|
73
|
-
* @param {urlTypeType} [urlType] - This optional parameter is used to change the url solution parameter from 'online' to something else, e.g. 'pos'.
|
|
74
|
-
* @param {skipCheckFlag} [skipNavCheck] - This optional parameter is used to skip the navbar validation. Only use this in rare cases
|
|
75
|
-
*/
|
|
76
|
-
GLO_oktaLogin(company?: string, urlType?: urlTypeType, skipNavCheck?: skipCheckFlag): void;
|
|
77
68
|
/**
|
|
78
69
|
* Command to perform a logout.
|
|
79
70
|
*/
|
|
@@ -88,14 +88,14 @@ Cypress.Commands.add('GLO_login', (company, urlType) => {
|
|
|
88
88
|
case 'servoy':
|
|
89
89
|
cy.GLO_servoyLogin(company);
|
|
90
90
|
break;
|
|
91
|
-
case '
|
|
92
|
-
|
|
91
|
+
case 'sso':
|
|
92
|
+
ssoLogin(company, urlType);
|
|
93
93
|
break;
|
|
94
94
|
case 'default':
|
|
95
95
|
cy.GLO_defaultlogin(company, undefined, undefined, urlType);
|
|
96
96
|
break;
|
|
97
97
|
default:
|
|
98
|
-
throw new Error('Incorrect type for GLO_login. Available types: servoy,
|
|
98
|
+
throw new Error('Incorrect type for GLO_login. Available types: servoy, sso, default');
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
Cypress.Commands.add('GLO_defaultlogin', (company, usernameENV = 'username', passwordENV = 'password', urlType) => {
|
|
@@ -164,8 +164,12 @@ Cypress.Commands.add('GLO_defaultloginExtraUser', (usernameENV, passwordENV, com
|
|
|
164
164
|
}
|
|
165
165
|
cy.get('[data-cy="ng$main_form2.navbar_top"]', { timeout: 120000 }).should('exist');
|
|
166
166
|
});
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
// loginType sso: the URL of the identity provider, Globis Online's own Keycloak is the default
|
|
168
|
+
const ssoUrl = () => Cypress.expose('ssoUrl') || 'https://auth.globis.cloud';
|
|
169
|
+
// The provider follows from the URL. ponytail: add a provider here when a third one is supported
|
|
170
|
+
const ssoProvider = () => (new URL(ssoUrl()).hostname.includes('okta') ? 'okta' : 'keycloak');
|
|
171
|
+
const waitForWebSocketAfterSso = (idp = ssoUrl()) => {
|
|
172
|
+
cy.url({ timeout: 60000 }).should('not.include', new URL(idp).hostname);
|
|
169
173
|
cy.window({ timeout: 60000 }).should((win) => {
|
|
170
174
|
const w = win;
|
|
171
175
|
if (!w.wsMessages)
|
|
@@ -177,20 +181,8 @@ const waitForWebSocketAfterOkta = () => {
|
|
|
177
181
|
cy.task('log', `WebSocket spy confirmed connected, ${win.wsMessages?.messages.length} messages captured so far`);
|
|
178
182
|
});
|
|
179
183
|
};
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let url = Cypress.expose('globisUrl');
|
|
183
|
-
if (urlType) {
|
|
184
|
-
const target = globisUrlTypes.includes(urlType) ? 'globis_online' : 'online';
|
|
185
|
-
url = url.replace(target, urlType);
|
|
186
|
-
}
|
|
187
|
-
// Stateless Okta login redirects server side before any Servoy client boots, so there is no
|
|
188
|
-
// websocket to wait for on the first visit — the connection is only asserted after the Okta
|
|
189
|
-
// round trip (see waitForWebSocketAfterOkta). The spy itself is attached globally on every
|
|
190
|
-
// AUT page load (see websocketDetector.ts), which covers this server-side redirect too.
|
|
191
|
-
cy.visit(url);
|
|
192
|
-
cy.task('log', 'Redirect to okta url');
|
|
193
|
-
cy.origin(Cypress.expose('oktaUrl'), () => {
|
|
184
|
+
const oktaIdpLogin = () => {
|
|
185
|
+
cy.origin(ssoUrl(), () => {
|
|
194
186
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
195
187
|
.find('[autocomplete="username"]')
|
|
196
188
|
.clear()
|
|
@@ -205,11 +197,96 @@ Cypress.Commands.add('GLO_oktaLogin', (company, urlType, skipvNavCheck) => {
|
|
|
205
197
|
cy.task('log', 'Filled in password');
|
|
206
198
|
cy.get('[type="submit"]').click();
|
|
207
199
|
});
|
|
200
|
+
};
|
|
201
|
+
// Keycloak: username page, password page, then a one-time code page when the user has OTP (otpSecret is set)
|
|
202
|
+
const keycloakIdpLogin = () => {
|
|
203
|
+
// The code is made outside cy.origin: a task that waits for a free OTP window times out after 4s inside it.
|
|
204
|
+
// It is claimed right before the IdP pages, so it is still fresh when typed.
|
|
205
|
+
cy.env(['otpSecret']).then(({ otpSecret }) => {
|
|
206
|
+
if (otpSecret)
|
|
207
|
+
cy.task('totp', otpSecret, { log: false, timeout: 200000 }).then((code) => keycloakOrigin(code));
|
|
208
|
+
else
|
|
209
|
+
keycloakOrigin(undefined);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const keycloakOrigin = (code) => {
|
|
213
|
+
cy.origin(ssoUrl(), { args: { code } }, ({ code }) => {
|
|
214
|
+
cy.get('input[name="username"]', { timeout: 60000 })
|
|
215
|
+
.clear()
|
|
216
|
+
.type(Cypress.expose('username'));
|
|
217
|
+
cy.task('log', 'Filled in username');
|
|
218
|
+
cy.get('#kc-login').click();
|
|
219
|
+
cy.env(['password']).then(({ password }) => {
|
|
220
|
+
cy.get('input[name="password"]', { timeout: 20000 }).type(password, { log: false });
|
|
221
|
+
cy.task('log', 'Filled in password');
|
|
222
|
+
cy.get('#kc-login').click();
|
|
223
|
+
if (code) {
|
|
224
|
+
cy.get('input[name="otp"]', { timeout: 20000 }).should('be.visible');
|
|
225
|
+
cy.get('input[name="otp"]').type(code, { log: false });
|
|
226
|
+
cy.task('log', 'Filled in one-time code');
|
|
227
|
+
cy.get('#kc-login').click();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
// One IdP login per run (cy.session, also across specs): every later call only revisits the app,
|
|
233
|
+
// which the IdP signs in silently, so no password/OTP is asked again.
|
|
234
|
+
// ponytail: no `validate` — an expired IdP session shows up as a login page, add a validate if that happens
|
|
235
|
+
const ssoLogin = (company, urlType, skipvNavCheck) => {
|
|
236
|
+
const provider = ssoProvider();
|
|
237
|
+
// Change the Url if necessary
|
|
238
|
+
let url = Cypress.expose('globisUrl');
|
|
239
|
+
if (urlType) {
|
|
240
|
+
const target = globisUrlTypes.includes(urlType) ? 'globis_online' : 'online';
|
|
241
|
+
url = url.replace(target, urlType);
|
|
242
|
+
}
|
|
243
|
+
cy.session(['sso', provider, Cypress.expose('username'), ssoUrl(), url], () => {
|
|
244
|
+
const file = Cypress.expose('ssoSessionFile');
|
|
245
|
+
const idpHost = new URL(ssoUrl()).hostname;
|
|
246
|
+
const isIdpCookie = (c) => idpHost === c.domain.replace(/^\./, '') || idpHost.endsWith(`.${c.domain.replace(/^\./, '')}`);
|
|
247
|
+
const login = () => {
|
|
248
|
+
// Stateless SSO login redirects server side before any Servoy client boots, so there is no
|
|
249
|
+
// websocket to wait for on the first visit — the connection is only asserted after the IdP
|
|
250
|
+
// round trip (see waitForWebSocketAfterSso). The spy itself is attached globally on every
|
|
251
|
+
// AUT page load (see websocketDetector.ts), which covers this server-side redirect too.
|
|
252
|
+
cy.visit(url);
|
|
253
|
+
cy.task('log', `Redirect to ${provider} url`);
|
|
254
|
+
if (provider === 'okta')
|
|
255
|
+
oktaIdpLogin();
|
|
256
|
+
else
|
|
257
|
+
keycloakIdpLogin();
|
|
258
|
+
// Let the app finish its login callback, then keep only the IdP cookies: the app's own client session
|
|
259
|
+
// does not survive a restore, so every call starts a fresh app client that the IdP signs in silently.
|
|
260
|
+
if (cypressWebsocketLoader)
|
|
261
|
+
waitForWebSocketAfterSso(ssoUrl());
|
|
262
|
+
else
|
|
263
|
+
cy.url({ timeout: 60000 }).should('not.include', idpHost);
|
|
264
|
+
cy.getAllCookies({ log: false }).then((all) => {
|
|
265
|
+
for (const c of all)
|
|
266
|
+
if (!isIdpCookie(c))
|
|
267
|
+
cy.clearCookie(c.name, { domain: c.domain, log: false });
|
|
268
|
+
if (file)
|
|
269
|
+
cy.task('ssoSessionWrite', { file, cookies: all.filter(isIdpCookie) }, { log: false });
|
|
270
|
+
});
|
|
271
|
+
cy.clearAllSessionStorage({ log: false });
|
|
272
|
+
};
|
|
273
|
+
// ssoSessionFile: another machine already logged in (CI pre-step), so take over its IdP cookies, no password/OTP here
|
|
274
|
+
if (!file)
|
|
275
|
+
return login();
|
|
276
|
+
cy.task('ssoSessionRead', file, { log: false }).then((saved) => {
|
|
277
|
+
if (!saved)
|
|
278
|
+
return login();
|
|
279
|
+
for (const c of saved) {
|
|
280
|
+
cy.setCookie(c.name, c.value, { domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expiry: c.expiry, log: false });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}, { cacheAcrossSpecs: true });
|
|
284
|
+
cy.visit(url);
|
|
208
285
|
if (company) {
|
|
209
286
|
cy.GLO_selectCompany(company);
|
|
210
287
|
}
|
|
211
288
|
if (cypressWebsocketLoader)
|
|
212
|
-
|
|
289
|
+
waitForWebSocketAfterSso(ssoUrl());
|
|
213
290
|
cy.task('log', 'Start navbar check');
|
|
214
291
|
if (!skipvNavCheck) {
|
|
215
292
|
const navbarSelectors = {
|
|
@@ -228,7 +305,7 @@ Cypress.Commands.add('GLO_oktaLogin', (company, urlType, skipvNavCheck) => {
|
|
|
228
305
|
cy.GLO_checkLoadingIndicator();
|
|
229
306
|
}
|
|
230
307
|
cy.task('log', 'End navbar check');
|
|
231
|
-
}
|
|
308
|
+
};
|
|
232
309
|
Cypress.Commands.add('GLO_logout', () => {
|
|
233
310
|
cy.document({ log: false }).then((doc) => {
|
|
234
311
|
cy.GLO_getElement(['ng$main_form2', 'navbar_top']).find('.lucide-LogOut').click();
|
|
@@ -1974,7 +2051,7 @@ Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
|
1974
2051
|
});
|
|
1975
2052
|
}
|
|
1976
2053
|
if (altUser) {
|
|
1977
|
-
cy.origin(
|
|
2054
|
+
cy.origin(ssoUrl(), () => {
|
|
1978
2055
|
cy.get('.okta-container', { timeout: 120000 });
|
|
1979
2056
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
1980
2057
|
.find('[autocomplete="username"]')
|
|
@@ -1993,7 +2070,7 @@ Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
|
1993
2070
|
cy.GLO_log(`Logged in as ${Cypress.expose('username2')}`);
|
|
1994
2071
|
}
|
|
1995
2072
|
else {
|
|
1996
|
-
cy.origin(
|
|
2073
|
+
cy.origin(ssoUrl(), () => {
|
|
1997
2074
|
cy.get('.okta-container', { timeout: 120000 });
|
|
1998
2075
|
cy.get('.o-form-input', { timeout: 60000 })
|
|
1999
2076
|
.find('[autocomplete="username"]')
|
|
@@ -2012,7 +2089,7 @@ Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
|
|
|
2012
2089
|
cy.GLO_log(`Logged in as ${Cypress.expose('username')}`);
|
|
2013
2090
|
}
|
|
2014
2091
|
if (cypressWebsocketLoader)
|
|
2015
|
-
|
|
2092
|
+
waitForWebSocketAfterSso();
|
|
2016
2093
|
cy.get('[data-cy$="ng$main_form2.navbar_top"]', { timeout: 120000 }).should('exist');
|
|
2017
2094
|
if (company)
|
|
2018
2095
|
cy.GLO_checkAndSelectCompany(company);
|
|
@@ -14,7 +14,7 @@ Cypress.Commands.add('POR_portalLogin', () => {
|
|
|
14
14
|
cy.visit(Cypress.expose('portalUrl'));
|
|
15
15
|
// biome-ignore lint/plugin/noUnnecessaryWaiting: wait for portal login redirect
|
|
16
16
|
cy.wait(2000);
|
|
17
|
-
cy.origin(Cypress.expose('
|
|
17
|
+
cy.origin(Cypress.expose('ssoUrl'), () => {
|
|
18
18
|
cy.get('.o-form-input')
|
|
19
19
|
.find('[autocomplete="username"]')
|
|
20
20
|
.type(Cypress.expose('username'));
|
package/dist/config.js
CHANGED
|
@@ -45,7 +45,7 @@ export function defineGlobisConfig(overrides = {}) {
|
|
|
45
45
|
...overrides.e2e,
|
|
46
46
|
setupNodeEvents(on, config) {
|
|
47
47
|
// Sensitive keys never get exposed to the browser via config.expose.
|
|
48
|
-
const sensitiveEnvKeys = new Set(['password', 'password2', 'api_key', 'QLIKURLS', 'S3_KEY', 'S3_SECRET']);
|
|
48
|
+
const sensitiveEnvKeys = new Set(['password', 'password2', 'api_key', 'QLIKURLS', 'S3_KEY', 'S3_SECRET', 'otpSecret']);
|
|
49
49
|
for (const [key, val] of Object.entries(config.env)) {
|
|
50
50
|
if (!sensitiveEnvKeys.has(key))
|
|
51
51
|
config.expose[key] = val;
|
|
@@ -92,6 +92,18 @@ export function defineGlobisConfig(overrides = {}) {
|
|
|
92
92
|
});
|
|
93
93
|
return result;
|
|
94
94
|
},
|
|
95
|
+
// IdP session handed from one machine to the others (CI stashes the file), see ssoLogin in GLO_commands
|
|
96
|
+
async ssoSessionRead(file) {
|
|
97
|
+
return (await import('./ssoSessionFile.js')).readSsoSession(file);
|
|
98
|
+
},
|
|
99
|
+
async ssoSessionWrite({ file, cookies }) {
|
|
100
|
+
(await import('./ssoSessionFile.js')).writeSsoSession(file, cookies);
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
async totp(secret) {
|
|
104
|
+
const { nextTotp } = await import('./totp.js');
|
|
105
|
+
return nextTotp(secret);
|
|
106
|
+
},
|
|
95
107
|
...overrides.extraTasks
|
|
96
108
|
});
|
|
97
109
|
on('before:browser:launch', (browser, launchOptions) => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_AGE_MS = 8 * 3600 * 1000;
|
|
4
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
5
|
+
// The file name comes from an env value, so its real location (parent symlinks resolved) must stay inside the
|
|
6
|
+
// project (cwd). The file itself is opened with O_NOFOLLOW so it can not be a symlink either.
|
|
7
|
+
const safePath = (file) => {
|
|
8
|
+
const resolved = path.resolve(file);
|
|
9
|
+
const parent = fs.realpathSync(path.dirname(resolved));
|
|
10
|
+
const target = path.join(parent, path.basename(resolved));
|
|
11
|
+
const relative = path.relative(fs.realpathSync(process.cwd()), target);
|
|
12
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
13
|
+
throw new Error(`ssoSessionFile must be a file inside the project, got: ${file}`);
|
|
14
|
+
// no O_NOFOLLOW (Windows): reject a symlinked file up front instead
|
|
15
|
+
if (!NOFOLLOW && fs.lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
16
|
+
throw new Error('ssoSessionFile must not be a symlink');
|
|
17
|
+
return target;
|
|
18
|
+
};
|
|
19
|
+
export const readSsoSession = (file) => {
|
|
20
|
+
const target = safePath(file);
|
|
21
|
+
try {
|
|
22
|
+
const fd = fs.openSync(target, fs.constants.O_RDONLY | NOFOLLOW);
|
|
23
|
+
try {
|
|
24
|
+
const saved = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
25
|
+
return Date.now() - saved.savedAt < MAX_AGE_MS ? saved.cookies : null;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
fs.closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export const writeSsoSession = (file, cookies) => {
|
|
36
|
+
const fd = fs.openSync(safePath(file), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | NOFOLLOW, 0o600);
|
|
37
|
+
try {
|
|
38
|
+
fs.writeSync(fd, JSON.stringify({ savedAt: Date.now(), cookies }));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
};
|
package/dist/totp.d.ts
ADDED
package/dist/totp.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// RFC 6238: HMAC-SHA1, 6 digits, 30 second period (Keycloak defaults, identical in every realm)
|
|
6
|
+
const PERIOD_MS = 30000;
|
|
7
|
+
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
8
|
+
const base32Decode = (secret) => {
|
|
9
|
+
const bytes = [];
|
|
10
|
+
let bits = 0;
|
|
11
|
+
let value = 0;
|
|
12
|
+
for (const char of secret.replace(/[\s=]/g, '').toUpperCase()) {
|
|
13
|
+
const index = BASE32.indexOf(char);
|
|
14
|
+
if (index < 0)
|
|
15
|
+
throw new Error('otpSecret is not valid base32');
|
|
16
|
+
value = (value << 5) | index;
|
|
17
|
+
bits += 5;
|
|
18
|
+
if (bits >= 8) {
|
|
19
|
+
bytes.push((value >>> (bits - 8)) & 255);
|
|
20
|
+
bits -= 8;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return Buffer.from(bytes);
|
|
24
|
+
};
|
|
25
|
+
export const totp = (secret, timeMs = Date.now()) => {
|
|
26
|
+
const counter = Buffer.alloc(8);
|
|
27
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(timeMs / PERIOD_MS)));
|
|
28
|
+
const hash = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
|
29
|
+
const offset = hash[hash.length - 1] & 15;
|
|
30
|
+
const code = (hash.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
|
31
|
+
return String(code).padStart(6, '0');
|
|
32
|
+
};
|
|
33
|
+
// Keycloak refuses a code twice. Parallel runners are separate processes, so a window is claimed with an
|
|
34
|
+
// exclusive marker file in the temp dir (atomic): a runner that finds its window taken waits for the next one.
|
|
35
|
+
// ponytail: same machine only, runners on other machines (CI agents) still need staggering
|
|
36
|
+
const claimWindow = (secret, window) => {
|
|
37
|
+
const id = createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(join(tmpdir(), `globis-otp-${id}-${window}`), '', { flag: 'wx' });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export const nextTotp = async (secret) => {
|
|
47
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
48
|
+
const window = Math.floor(Date.now() / PERIOD_MS);
|
|
49
|
+
if (claimWindow(secret, window))
|
|
50
|
+
return totp(secret);
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, (window + 1) * PERIOD_MS - Date.now() + 500));
|
|
52
|
+
}
|
|
53
|
+
throw new Error('No free OTP window after 6 tries, too many runners share this user');
|
|
54
|
+
};
|
package/package.json
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// Delete these comments
|
|
3
3
|
|
|
4
4
|
{
|
|
5
|
-
"loginType": "<default/servoy/
|
|
5
|
+
"loginType": "<default/servoy/sso>",
|
|
6
6
|
"username": "<enter your username here>",
|
|
7
7
|
"password": "<enter your password here>",
|
|
8
8
|
"username2": "<optional - alternate login user, used by tests that switch users>",
|
|
9
9
|
"password2": "<optional - password for username2>",
|
|
10
10
|
|
|
11
11
|
"globisUrl": "<enter your globisUrl here>",
|
|
12
|
-
"
|
|
12
|
+
"ssoUrl": "<optional - IdP URL, only used when loginType is sso: Okta when it contains okta, otherwise Keycloak. Default https://auth.globis.cloud>",
|
|
13
|
+
"otpSecret": "<optional - base32 TOTP secret, only used when loginType is sso with Keycloak and the user has OTP>",
|
|
13
14
|
"ownerName": "<optional - only used for a local Servoy instance>",
|
|
14
15
|
|
|
15
16
|
"api_key": "<optional - only used when a command needs an API key>",
|