Skip to content

Commit 2e94690

Browse files
committed
Enabled two-stage login
1 parent b3ac2ec commit 2e94690

File tree

5 files changed

+199
-45
lines changed

5 files changed

+199
-45
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,11 @@ This channel contains values polled from SENEC App-API.
303303
Placeholder for the next version (at the beginning of the line):
304304
### **WORK IN PROGRESS**
305305
-->
306+
307+
### **WORK IN PROGRESS**
308+
- Senec changed login (again). We can now also work with senec asking for username/email first and password second.
309+
- Dependency updates
310+
306311
### 2.3.0 (2026-02-17)
307312
- Measurements for today and yesterday are also available by the hour
308313
- Measurements for month and previous month are also available by day

io-package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@
158158
],
159159
"globalDependencies": [
160160
{
161-
"admin": ">=7.6.17"
161+
"admin": ">=7.6.20"
162162
}
163163
]
164164
},

main.js

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
const crypto = require("crypto");
44
const { URL, URLSearchParams } = require("url");
55
const axios = require("axios");
6-
const axiosApi = axios.create({
7-
timeout: 10000,
8-
});
9-
axios.defaults.headers.post["Content-Type"] = "application/json";
6+
const tough = require("tough-cookie");
7+
const CookieJar = tough.CookieJar;
8+
const { wrapper } = require("axios-cookiejar-support");
9+
const jar = new CookieJar();
10+
const api_client = wrapper(axios.create({ withCredentials: true, timeout: 10000 }));
11+
api_client.defaults.headers.post["Content-Type"] = "application/json";
1012
const https = require("https");
1113
// rejectUnauthorized needs to be false due to the local machine's certificate cannot be checked properly
1214
const agent = new https.Agent({
@@ -24,13 +26,16 @@ const LAST_UPDATED = "last updated";
2426
// API Endpoints
2527
const HOST_SYSTEMS = "https://senec-app-systems-proxy.prod.senec.dev";
2628
const HOST_MEASUREMENTS = "https://senec-app-measurements-proxy.prod.senec.dev";
29+
const SSO_BASE_URL = "https://sso.senec.com/realms/senec/protocol/openid-connect";
30+
const SSO_AUTH_URL = `${SSO_BASE_URL}/auth`;
31+
const SSO_TOKEN_URL = `${SSO_BASE_URL}/token`;
2732

2833
const CONFIG = {
29-
authUrl: "https://sso.senec.com/realms/senec/protocol/openid-connect/auth",
30-
tokenUrl: "https://sso.senec.com/realms/senec/protocol/openid-connect/token",
34+
authUrl: SSO_AUTH_URL,
35+
tokenUrl: SSO_TOKEN_URL,
3136
clientId: "endcustomer-app-frontend",
3237
redirectUri: "senec-app-auth://keycloak.prod",
33-
scope: "roles meinsenec openid",
38+
scope: "roles profile meinsenec",
3439
};
3540

3641
const apiKnownSystems = new Set();
@@ -473,7 +478,7 @@ class Senec extends utils.Adapter {
473478
const codeVerifier = generateCodeVerifier();
474479
const codeChallenge = generateCodeChallenge(codeVerifier);
475480

476-
const pageRes = await axiosApi.get(
481+
let pageRes = await api_client.get(
477482
`${CONFIG.authUrl}?${new URLSearchParams({
478483
response_type: "code",
479484
client_id: CONFIG.clientId,
@@ -482,26 +487,63 @@ class Senec extends utils.Adapter {
482487
code_challenge: codeChallenge,
483488
code_challenge_method: "S256",
484489
}).toString()}`,
490+
{ jar }, // attach cookie jar
485491
);
486492

487-
const actionUrl = extractFormAction(pageRes.data);
493+
let actionUrl = extractFormAction(pageRes.data);
488494
if (!actionUrl) {
489495
throw new Error("Login-Formular URL nicht gefunden.");
490496
}
491497

492-
const formData = new URLSearchParams();
493-
formData.append("username", this.config.api_mail);
494-
formData.append("password", this.config.api_pwd);
495-
formData.append("credentialId", "");
498+
let loginRes;
499+
if (hasUsernameAndPassword(pageRes.data)) {
500+
// worked until 20260228
501+
const formData = new URLSearchParams();
502+
formData.append("username", this.config.api_mail);
503+
formData.append("password", this.config.api_pwd);
504+
formData.append("credentialId", "");
505+
506+
loginRes = await api_client.post(actionUrl, formData, {
507+
headers: {
508+
"Content-Type": "application/x-www-form-urlencoded",
509+
},
510+
maxRedirects: 0,
511+
validateStatus: (s) => s >= 200 && s < 400,
512+
jar,
513+
});
514+
} else {
515+
if (!hasUsername(pageRes.data)) {
516+
throw new Error("Expected: Login-Form with username. Got something else.");
517+
}
518+
let formData = new URLSearchParams();
519+
formData.append("credentialId", "");
520+
formData.append("username", this.config.api_mail);
521+
loginRes = await api_client.post(actionUrl, formData, {
522+
headers: {
523+
"Content-Type": "application/x-www-form-urlencoded",
524+
},
525+
maxRedirects: 0,
526+
validateStatus: (s) => s >= 200 && s < 400,
527+
jar,
528+
});
496529

497-
const loginRes = await axiosApi.post(actionUrl, formData, {
498-
headers: {
499-
"Content-Type": "application/x-www-form-urlencoded",
500-
Cookie: formatCookies(pageRes.headers),
501-
},
502-
maxRedirects: 0,
503-
validateStatus: (s) => s >= 200 && s < 400,
504-
});
530+
if (!hasPassword(loginRes.data)) {
531+
throw new Error("Expected: Login-Form with password. Got something else.");
532+
}
533+
actionUrl = extractFormAction(loginRes.data);
534+
formData = new URLSearchParams();
535+
//formData.append("credentialId", "");
536+
formData.append("username", this.config.api_mail);
537+
formData.append("password", this.config.api_pwd);
538+
loginRes = await api_client.post(actionUrl, formData, {
539+
headers: {
540+
"Content-Type": "application/x-www-form-urlencoded",
541+
},
542+
maxRedirects: 0,
543+
validateStatus: (s) => s >= 200 && s < 400,
544+
jar,
545+
});
546+
}
505547

506548
const redirectLocation = loginRes.headers["location"];
507549
if (!redirectLocation) {
@@ -518,7 +560,7 @@ class Senec extends utils.Adapter {
518560
throw new Error("Authorization code not found in redirect.");
519561
}
520562

521-
const tokenRes = await axiosApi.post(
563+
const tokenRes = await api_client.post(
522564
CONFIG.tokenUrl,
523565
new URLSearchParams({
524566
grant_type: "authorization_code",
@@ -570,7 +612,7 @@ class Senec extends utils.Adapter {
570612
for (const anlagenId of apiKnownSystems) {
571613
this.log.info(`🔄 Polling API data for system ${anlagenId}...`);
572614
// get Dashboard data
573-
const dashRes = await axiosApi.get(`${HOST_MEASUREMENTS}/v1/systems/${anlagenId}/dashboard`, {
615+
const dashRes = await api_client.get(`${HOST_MEASUREMENTS}/v1/systems/${anlagenId}/dashboard`, {
574616
headers: { Authorization: `Bearer ${token}` },
575617
});
576618
this.log.debug(`DashRes${JSON.stringify(dashRes.data)}`);
@@ -653,7 +695,7 @@ class Senec extends utils.Adapter {
653695
async pollSystems(token) {
654696
this.log.debug("🔄 Reading available systems from API ...");
655697
// get Systems
656-
const sysRes = await axiosApi.get(`${HOST_SYSTEMS}/v1/systems`, {
698+
const sysRes = await api_client.get(`${HOST_SYSTEMS}/v1/systems`, {
657699
headers: { Authorization: `Bearer ${token}` },
658700
});
659701
if (!sysRes.data || !sysRes.data[0]) {
@@ -743,7 +785,7 @@ class Senec extends utils.Adapter {
743785
}
744786
const url = `${HOST_MEASUREMENTS}/v1/systems/${anlagenId}/measurements?resolution=${resolution}&from=${start}&to=${end}`;
745787
this.log.debug(`🔄 Polling measurements for ${url}`);
746-
const measurements = await axiosApi.get(url, {
788+
const measurements = await api_client.get(url, {
747789
headers: { Authorization: `Bearer ${token}` },
748790
});
749791
if (!measurements.data.timeSeries || measurements.data.timeSeries.length === 0) {
@@ -791,7 +833,7 @@ class Senec extends utils.Adapter {
791833
}
792834
const url = `${HOST_MEASUREMENTS}/v1/systems/${anlagenId}/measurements?resolution=${resolution}&from=${start}&to=${end}`;
793835
this.log.debug(`🔄 Polling measurements for ${url}`);
794-
const measurements = await axiosApi.get(url, {
836+
const measurements = await api_client.get(url, {
795837
headers: { Authorization: `Bearer ${token}` },
796838
});
797839
await this.doSumMeasurements(measurements.data, anlagenId, pfx, period);
@@ -835,7 +877,7 @@ class Senec extends utils.Adapter {
835877
}
836878
const url = `${HOST_MEASUREMENTS}/v1/systems/${anlagenId}/measurements?resolution=${resolution}&from=${start}&to=${end}`;
837879
this.log.debug(`🔄 Polling measurements for ${url}`);
838-
const measurements = await axiosApi.get(url, {
880+
const measurements = await api_client.get(url, {
839881
headers: { Authorization: `Bearer ${token}` },
840882
});
841883
await this.doSumMeasurements(measurements.data, anlagenId, pfx, period);
@@ -1415,15 +1457,21 @@ function base64UrlEncode(buffer) {
14151457
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
14161458
}
14171459

1418-
function formatCookies(headers) {
1419-
const setCookie = headers["set-cookie"];
1420-
if (!setCookie) {
1421-
return "";
1422-
}
1423-
return Array.isArray(setCookie) ? setCookie.map((c) => c.split(";")[0]).join("; ") : setCookie.split(";")[0];
1424-
}
1425-
14261460
function extractFormAction(html) {
14271461
const match = html.match(/<form[^>]*action="([^"]+)"[^>]*>/i);
14281462
return match && match[1] ? match[1].replace(/&amp;/g, "&") : null;
14291463
}
1464+
1465+
function hasUsername(html) {
1466+
return html.match(/<input\b(?![^>]*\bvalue\s*=)[^>]*\b(?:name|id)\s*=\s*["']?(?:username|user|email)["']?[^>]*>/i);
1467+
}
1468+
1469+
function hasPassword(html) {
1470+
return html.match(
1471+
/<input\b(?=[^>]*\btype\s*=\s*["']?password["']?)(?=[^>]*\b(?:name|id)\s*=\s*["']?password["']?)[^>]*>/i,
1472+
);
1473+
}
1474+
1475+
function hasUsernameAndPassword(html) {
1476+
return hasUsername(html) && hasPassword(html);
1477+
}

0 commit comments

Comments
 (0)