iobroker.vw-connect
Version:
Adapter for VW Connect
1,222 lines (1,159 loc) • 336 kB
JavaScript
// @ts-nocheck
"use strict";
/*
* Created with @iobroker/create-adapter v1.17.0
*/
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require("@iobroker/adapter-core");
const request = require("request");
const qs = require("qs");
const crypto = require("crypto");
const { Crypto } = require("@peculiar/webcrypto");
const { v4: uuidv4 } = require("uuid");
const traverse = require("traverse");
const geohash = require("ngeohash");
const { extractKeys } = require("./lib/extractKeys");
const { EuDataActClient, normalizeDataset: normalizeEuDataActDataset } = require("./lib/euDataAct");
const tibber = require("./lib/tibber");
const axios = require("axios").default;
const Json2iob = require("json2iob");
const mqtt = require("mqtt");
const uuid = require("uuid");
const { checkin_proto } = require("@eneris/push-receiver/dist/protos");
const Long = require("long");
class VwWeconnect extends utils.Adapter {
/**
* @param {Partial<ioBroker.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: "vw-connect",
});
this.on("ready", this.onReady.bind(this));
// this.on("objectChange", this.onObjectChange.bind(this));
this.on("stateChange", this.onStateChange.bind(this));
// this.on("message", this.onMessage.bind(this));
this.on("unload", this.onUnload.bind(this));
this.extractKeys = extractKeys;
this.json2iob = new Json2iob(this);
this.jar = request.jar();
this.userAgent = "iobroker v";
this.skodaUserAgent = "MySkoda/Android/8.11.0/260220003";
this.androidPackageName = "com.volkswagen.weconnect";
this.refreshTokenInterval = null;
this.vwrefreshTokenInterval = null;
this.updateInterval = null;
this.euDataActInterval = null;
this.tibberInterval = null;
this.fupdateInterval = null;
this.refreshTokenTimeout = null;
this.homeRegion = {};
this.homeRegionSetter = {};
this.secondAccessToken = null;
this.ignoredPaths = {};
this.vinArray = [];
this.etags = {};
this.hasRemoteLock = false;
this.isFirstLocation = true;
this.lastTripCheck = 0;
this.firstStart = true;
this.blockTrip = {};
this.statesArray = [
{
url: "$homeregion/fs-car/bs/departuretimer/v1/$type/$country/vehicles/$vin/timer",
path: "timer",
element: "timer",
},
{
url: "$homeregion/fs-car/bs/climatisation/v1/$type/$country/vehicles/$vin/climater",
path: "climater",
element: "climater",
},
{
url: "$homeregion/fs-car/bs/cf/v1/$type/$country/vehicles/$vin/position",
path: "position",
element: "storedPositionResponse",
element2: "position",
element3: "findCarResponse",
element4: "Position",
},
{
url: "$homeregion/fs-car/bs/tripstatistics/v1/$type/$country/vehicles/$vin/tripdata/$tripType?type=list",
path: "tripdata",
element: "tripDataList",
},
{
url: "$homeregion/fs-car/bs/vsr/v1/$type/$country/vehicles/$vin/status",
path: "status",
element: "StoredVehicleDataResponse",
element2: "vehicleData",
},
{
url: "$homeregion/fs-car/bs/batterycharge/v1/$type/$country/vehicles/$vin/charger",
path: "charger",
element: "charger",
},
{
url: "$homeregion/fs-car/bs/rs/v1/$type/$country/vehicles/$vin/status",
path: "remoteStandheizung",
element: "statusResponse",
},
{
url: "$homeregion/fs-car/bs/dwap/v1/$type/$country/vehicles/$vin/history",
path: "history",
},
];
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
// Initialize your adapter here
this.setState("info.connection", false, true);
if (!this.config.password) {
this.log.warn("Please enter password");
return;
}
this.userAgent += this.version;
// Reset the connection indicator during startup
this.type = "VW";
this.country = "DE";
this.clientId = "9496332b-ea03-4091-a224-8c746b885068%40apps_vw-dilab_com";
this.xclientId = "38761134-34d0-41f3-9a73-c4be88d7d337";
this.scope = "openid%20profile%20mbb%20email%20cars%20birthdate%20badge%20address%20vin";
this.redirect = "carnet%3A%2F%2Fidentity-kit%2Flogin";
this.xrequest = "de.volkswagen.carnet.eu.eremote";
this.responseType = "id_token%20token%20code";
this.xappversion = "5.1.2";
this.xappname = "eRemote";
if (this.config.type === "vw") {
this.log.info("WeConnect App is disabled switch to ID/Volkswagen App");
this.config.type = "id";
}
if (this.config.type === "skoda") {
this.type = "Skoda";
this.country = "CZ";
this.clientId = "f9a2359a-b776-46d9-bd0c-db1904343117@apps_vw-dilab_com";
this.xclientId = "afb0473b-6d82-42b8-bfea-cead338c46ef";
this.scope = "openid mbb profile";
this.redirect = "skodaconnect://oidc.login/";
this.xrequest = "cz.skodaauto.connect";
this.responseType = "code%20id_token";
this.xappversion = "3.2.6";
this.xappname = "cz.skodaauto.connect";
}
if (this.config.type === "skodae") {
this.type = "Skoda";
this.country = "CZ";
this.clientId = "7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com";
this.xclientId = "afb0473b-6d82-42b8-bfea-cead338c46ef";
this.scope =
"address badge birthdate cars driversLicense dealers email mileage mbb nationalIdentifier openid phone profession profile vin";
this.redirect = "myskoda%3A%2F%2Fredirect%2Flogin%2F";
this.xrequest = "cz.skodaauto.connect";
this.responseType = "code";
this.xappversion = "8.0.0";
this.xappname = "cz.skodaauto.connect";
this.xbrand = "skoda";
}
if (this.config.type === "seat") {
this.type = "Seat";
this.country = "ES";
this.clientId = "99a5b77d-bd88-4d53-b4e5-a539c60694a3@apps_vw-dilab_com";
this.scope = "openid profile nickname birthdate phone mbb cars address nationalIdentifier nationality profession email";
this.redirect = "seat://oauth-callback";
this.responseType = "code";
this.xappversion = "2.16.0";
this.xappname = "MySeat";
}
if (this.config.type === "seatcupra") {
this.type = "Seat";
this.clientId = "3c756d46-f1ba-4d78-9f9a-cff0d5292d51@apps_vw-dilab_com";
this.scope = "openid profile nickname birthdate phone mbb cars address nationalIdentifier nationality profession badge driversLicense";
this.redirect = "cupra://oauth-callback";
this.responseType = "code";
this.xappversion = "2.16.0";
this.xappname = "MyCupra";
}
if (this.config.type === "vwv2") {
this.log.info("WeConnect App is disabled switch to ID/Volkswagen App");
this.config.type = "id";
this.type = "VW";
this.country = "DE";
this.clientId = "9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com";
this.xclientId = "89312f5d-b853-4965-a471-b0859ee468af";
this.scope = "openid profile mbb cars birthdate nickname address phone";
this.redirect = "carnet://identity-kit/login";
this.xrequest = "de.volkswagen.car-net.eu.e-remote";
this.responseType = "id_token%20token%20code";
this.xappversion = "5.6.7";
this.xappname = "We Connect";
}
if (this.config.type === "id") {
this.type = "Id";
this.country = "DE";
this.clientId = "a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com";
this.xclientId = "";
this.scope = "openid profile badge cars dealers birthdate vin";
this.redirect = "weconnect://authenticated";
this.xrequest = "com.volkswagen.weconnect";
this.responseType = "code id_token token";
this.xappversion = "3.51.1";
this.xappname = "Volkswagen";
this.xbrand = "volkswagen";
this.userAgent = "Volkswagen/3.51.1-android/14";
}
if (this.config.type === "audi") {
this.log.info("Login in with audi as audietron");
this.config.type = "audietron";
// this.type = "Audi";
// this.country = "DE";
// this.clientId = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com";
// this.xclientId = "77869e21-e30a-4a92-b016-48ab7d3db1d8";
// this.scope =
// "address profile badge birthdate birthplace nationalIdentifier nationality profession email vin phone nickname name picture mbb gallery openid";
// this.redirect = "myaudi:///";
// this.xrequest = "de.myaudi.mobile.assistant";
// this.responseType = "token%20id_token";
// // this.responseType = "code";
// this.xappversion = "3.22.0";
// this.xappname = "myAudi";
}
if (this.config.type === "audietron") {
this.type = "Audi";
this.country = "DE";
this.clientId = "f4d0934f-32bf-4ce4-b3c4-699a7049ad26@apps_vw-dilab_com";
this.scope =
"address badge birthdate birthplace email gallery mbb name nationalIdentifier nationality nickname phone picture profession profile vin openid";
this.redirect = "myaudi:///";
this.responseType = "code";
this.xappversion = "4.14.1";
this.xappname = "myAudi";
this.xclientId = "59edf286-a9ca-4d34-9421-68da00f72dc8";
}
if (this.config.type === "audidata") {
this.type = "Audi";
this.country = "DE";
this.clientId = "ec6198b1-b31e-41ec-9a69-95d42d6497ed@apps_vw-dilab_com";
this.scope = "openid profile address email phone";
this.redirect = "acpp://de.audi.connectplugandplay/oauth2redirect/identitykit";
this.responseType = "code";
}
if (this.config.type === "go") {
this.type = "";
this.country = "";
this.clientId = "ac42b0fa-3b11-48a0-a941-43a399e7ef84@apps_vw-dilab_com";
this.xclientId = "";
this.scope = "openid%20profile%20address%20email%20phone";
this.redirect = "vwconnect%3A%2F%2Fde.volkswagen.vwconnect%2Foauth2redirect%2Fidentitykit";
this.xrequest = "";
this.responseType = "code";
this.xappversion = "";
this.xappname = "";
}
if (this.config.type === "seatelli") {
this.type = "";
this.country = "";
this.clientId = "d940d794-5945-48a3-84b1-44222c387800@apps_vw-dilab_com";
this.xclientId = "";
this.scope = "openid profile";
this.redirect = "Seat-elli-hub://opid";
this.xrequest = "";
this.responseType = "code";
this.xappversion = "";
this.xappname = "";
}
if (this.config.type === "skodapower") {
this.type = "";
this.country = "";
this.clientId = "b84ba8a1-7925-43c9-9963-022587faaac5@apps_vw-dilab_com";
this.xclientId = "";
this.scope = "openid profile";
this.redirect = "skoda-hub://opid";
this.xrequest = "";
this.responseType = "code";
this.xappversion = "";
this.xappname = "";
}
if (!this.config.interval || this.config.interval < 0.5) {
this.log.info("Interval of 0 is not allowed reset to 1");
this.config.interval = 1;
}
// if (this.config.type === "skodae") {
// // this.log.info("Parking Postion is temporary disabled for Skoda E");
// if (this.config.interval < 10) {
// this.log.info("Interval under 10min is temporary not allowed for Skoda E reset to 10min");
// this.config.interval = 10;
// }
// }
this.tripTypes = [];
if (this.config.tripShortTerm == true) {
this.tripTypes.push("shortTerm");
}
if (this.config.tripLongTerm == true) {
this.tripTypes.push("longTerm");
}
if (this.config.tripCyclic == true) {
this.tripTypes.push("cyclic");
}
// VW Group accounts: the EU Data Act portal at
// eu-data-act.drivesomethinggreater.com serves continuous 15-min datasets
// for ALL group brands (VW, Audi, Skoda, Seat, Cupra, Bentley, VW
// Commercial Vehicles). Each brand uses its own OIDC client_id (verified
// live from the portal's brand selector — see BRAND_CLIENT_IDS in
// lib/euDataAct.js), but the rest of the flow is identical: same
// credentials as the brand's mobile app, same proxy_api endpoints.
//
// Map adapter type -> EU Data Act brand key. For types where the user's
// VW-Group account is the same one that authenticates the brand-specific
// app, the portal accepts those credentials. Audi/Skoda/Cupra/Seat are
// OPTIONAL just like the VW path: the legacy brand login below remains
// the primary source.
const euDataActBrand = {
id: "VOLKSWAGEN_PASSENGER_CARS",
audietron: "AUDI",
audidata: "AUDI",
skodae: "SKODA",
skoda: "SKODA",
seatcupra: "CUPRA",
seat: "SEAT",
}[this.config.type];
if (euDataActBrand) {
this.runEuDataAct(euDataActBrand).catch((err) => {
const msg = (err && err.message) || String(err);
this.log.info("EU Data Act portal not available: " + msg);
this.log.info(
"EU Data Act is the 15-min portal data source. To enable it, " +
"log in once at https://eu-data-act.drivesomethinggreater.com/, link " +
"your vehicle and configure a continuous 15-minute data request.",
);
// Recovery for type=id: it's the only data source, so a transient
// failure (network blip, portal 5xx) shouldn't leave the adapter
// dead until a manual restart. Schedule a restart in 30 min.
// Credential / account errors (wrong password, account locked,
// not entitled) do NOT trigger restart — those don't self-heal,
// the user must fix the config.
if (this.config.type === "id") {
if (/login failed|password_invalid|email_invalid|account.*(locked|disabled)|not entitled/i.test(msg)) {
this.log.error(
"VW ID: EU Data Act login refused. Adapter staying down until " +
"credentials are corrected. Update user/password in the adapter " +
"settings, then restart manually.",
);
this.setState("info.connection", false, true);
return;
}
this.log.warn(
`VW ID: EU Data Act setup failed (${msg}). Will restart adapter in 30 min.`,
);
this.restartTimeout && clearTimeout(this.restartTimeout);
this.restartTimeout = setTimeout(() => {
this.log.info("Restart adapter to retry EU Data Act");
this.restart();
}, 30 * 60 * 1000);
}
});
// For non-id brands we fall through to this.login() below; for
// type=id the early return after this block skips it entirely.
}
// Tibber Data API as an additional optional source. Triggered for ANY
// VW-Group brand whose user has linked their car in a Tibber account
// and configured an OAuth2 client here. Runs parallel to the legacy /
// EU-Data-Act flows and writes to <vin>.statustibber.*.
if (this.config.tibberClientId && this.config.tibberClientSecret) {
this.runTibber().catch((err) => {
const msg = (err && err.message) || String(err);
this.log.warn(`Tibber bridge not available: ${msg}`);
});
}
// VW retired the classic VW-ID OAuth client (a24fba63-...). The IdP
// returns 403 with an Auth0 "tenant misconfiguration" error page on
// the authorize endpoint as of 2026-06-01 — same behaviour for the
// weconnect:// redirect on both identity.vwgroup.io and the BFF host.
// Other brand clients (Audi cc29b87a, Skoda 3ea88bf9, Seat/Cupra
// f85e5b69, VW PC 9b58543e for the EU Data Act portal) are unaffected.
//
// For type=id we skip the classic login entirely — the EU Data Act
// path above is the only working data source. Re-enable this block
// (delete the early return) if VW ever brings the client back.
if (this.config.type === "id") {
this.log.info(
"Classic VW ID login (a24fba63 OAuth client) was retired by VW. " +
"The adapter now relies exclusively on the EU Data Act portal " +
"for VW ID vehicles. See README -> 'EU Data Act portal' for setup.",
);
this.subscribeStates("*");
return;
}
// Cupra / SEAT: the OLA backend (ola.prod.code.seat.cloud.vwgroup.com)
// started enforcing Firebase App Check with the Play Integrity provider
// around June 2026. Every request now needs an X-Firebase-AppCheck
// header generated on a real Android device with the signed APK; we
// can't produce that from a Node.js adapter. The classic login itself
// would still complete, but every subsequent /v1/vehicles/{vin}/...
// call returns 403 'Forbidden device detected, missing-device-token'.
// Skip the classic login for these brands and let the EU Data Act +
// Tibber paths (both still working) cover their telemetry. Re-enable
// by deleting this early return if VW ever reverts the change or we
// find a token bridge we can host.
if (this.config.type === "seatcupra" || this.config.type === "seat") {
this.log.info(
"Cupra/SEAT OLA backend now requires Firebase App Check (Play Integrity). " +
"Adapter cannot generate that token from Node.js. Classic login is " +
"skipped — use the EU Data Act portal (config.type sees brand=CUPRA/SEAT) " +
"and/or the Tibber Data API for telemetry. See README.",
);
this.subscribeStates("*");
return;
}
this.login()
.then(() => {
this.log.info("Login successful");
this.setState("info.connection", true, true);
this.extendObject("refresh", {
type: "state",
common: {
name: "Refresh All States",
type: "boolean",
role: "button",
write: true,
},
native: {},
});
this.getPersonalData().then(() => {
this.getVehicles()
.then(() => {
if (this.config.type !== "go") {
this.vinArray.forEach((vin) => {
if (this.config.type === "id" || this.config.type === "audietron") {
this.getHomeRegion(vin);
this.getIdStatus(vin).catch(() => {
this.log.error("get id status Failed");
});
} else if (this.config.type === "seatcupra" || this.config.type === "seat") {
this.getSeatCupraStatus(vin);
} else if (this.config.type === "audidata") {
this.getAudiDataStatus(vin).catch(() => {
this.log.error("get audi data status Failed");
});
} else if (this.config.type === "skodae") {
this.getSkodaEStatus(vin);
} else {
this.getHomeRegion(vin)
.catch(() => {
this.log.debug("get home region Failed " + vin);
})
.finally(() => {
this.getVehicleData(vin).catch(() => {
this.log.error("get vehicle data Failed");
});
this.getVehicleRights(vin).catch(() => {
this.log.error("get vehicle rights Failed");
});
this.requestStatusUpdate(vin)
.finally(() => {
this.statesArray.forEach((state) => {
if (state.path == "tripdata") {
this.tripTypes.forEach((tripType) => {
this.getVehicleStatus(
vin,
state.url,
state.path,
state.element,
state.element2,
state.element3,
state.element4,
tripType,
).catch(() => {
this.log.debug("error while getting " + state.url);
});
});
} else {
this.getVehicleStatus(
vin,
state.url,
state.path,
state.element,
state.element2,
state.element3,
state.element4,
).catch(() => {
this.log.debug("error while getting " + state.url);
});
}
});
})
.catch(() => {
this.log.error("status update Failed " + vin);
});
})
.catch(() => {
this.log.error("Error getting home region");
});
}
});
}
if (this.config.type !== "skodae" && this.config.type !== "seatcupra" && this.config.type !== "seat") {
this.updateStatus();
}
this.updateInterval && clearInterval(this.updateInterval);
this.updateInterval = setInterval(() => {
this.updateStatus();
}, this.config.interval * 60 * 1000);
if (this.config.type !== "id" && this.config.type !== "skodae" && this.config.type !== "audietron") {
if (this.config.forceinterval > 0) {
this.fupdateInterval = setInterval(() => {
if (this.config.type === "go") {
this.getVehicles();
return;
}
this.vinArray.forEach((vin) => {
this.requestStatusUpdate(vin).catch(() => {
this.log.error("force status update Failed");
});
});
}, this.config.forceinterval * 60 * 1000);
}
}
if (this.config.type === "seatelli" || this.config.type === "skodapower") {
this.getElliData(this.config.type).catch(() => {
this.log.error("get elli Failed");
});
}
})
.catch(() => {
this.log.error("Get Vehicles Failed");
});
});
})
.catch(() => {
// For type=id we treat the classic flow as best-effort: if EU Data
// Act came up successfully we already have data flowing under
// <vin>.statuseudata, so a 30-min restart loop would only kill that.
if (this.config.type === "id" && this.euDataAct && this.euDataAct._loggedIn) {
this.log.warn(
"Classic VW login failed, but EU Data Act portal is connected — " +
"continuing with EU Data Act as the only data source. Live API features " +
"(remote climatisation, force refresh) won't work until the classic login recovers.",
);
return;
}
this.log.error("Login Failed");
this.log.error("Restart Adapter in 30min");
setTimeout(() => {
this.log.error("Restart adapter");
this.restart();
}, 30 * 60 * 1000);
});
this.subscribeStates("*");
}
/**
* Audi (myAudi) OAuth 2.0 Device Code Flow.
*
* Umgeht Play Integrity / x-assertion komplett — der Token wird direkt
* gegen identity.vwgroup.io ausgestellt und vom BFF (app-api.live-my.audi.com,
* emea.bff.cariad.digital) ohne Assertion-Header akzeptiert.
*
* Endpoint und Flow verifiziert via myAudi 5.4.1 APK
* (.../smali_classes12/technology/cariad/cat/idk/deviceflow/*).
*
* Da der User-Login im Identity-Server über UI-Cookies/CSRF läuft, simulieren
* wir den Browser-Pfad mit dem vorhandenen 2-stufigen signin-service-Flow
* (login/identifier -> login/authenticate) plus dem zusätzlichen
* "device confirmation"-POST (allow). Funktioniert vollständig per axios/request.
*/
async loginAudiDeviceFlow() {
const CLIENT_ID = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com";
const SCOPE =
"openid profile email address phone vin badge mbb cars dealers " +
"birthdate name nickname picture profession nationalIdentifier nationality";
const DEVICE_AUTH_URL = "https://identity.vwgroup.io/oidc/v1/device_authorization";
const IDP_TOKEN_URL = "https://identity.vwgroup.io/oidc/v1/token";
// Audi-CLient-Id für Device-Flow überschreibt audietron-Default damit Tokens
// im Refresh denselben client_id benutzen.
this.clientId = CLIENT_ID;
const userAgent = this.userAgent || "myAudi-Android/5.4.1 (Build 800343956) Android/14";
this.log.info("Audi: starte Device-Code-Flow (kein x-assertion erforderlich)");
// Schritt 1: device_authorization initiieren
const initResp = await axios({
method: "post",
url: DEVICE_AUTH_URL,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": userAgent,
Accept: "application/json",
},
data: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }).toString(),
});
const init = initResp.data;
if (!init || !init.device_code || !init.user_code) {
throw new Error("device_authorization Antwort ungültig: " + JSON.stringify(initResp.data));
}
this.log.debug(
"device_code=" + init.device_code + " user_code=" + init.user_code +
" verification=" + init.verification_uri_complete,
);
// Schritt 2: signin-service-Flow durchlaufen (User+Pass aus Adapter-Konfig)
const cookieJar = request.jar();
const followChain = async (startUrl) => {
let url = startUrl;
for (let i = 0; i < 20; i++) {
const r = await new Promise((res, rej) =>
request(
{ url, jar: cookieJar, headers: { "User-Agent": userAgent }, gzip: true, followRedirect: false },
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
if (r.resp.statusCode >= 300 && r.resp.statusCode < 400 && r.resp.headers.location) {
let next = r.resp.headers.location;
if (next.startsWith("/")) next = new URL(url).origin + next;
url = next;
continue;
}
if (r.resp.statusCode >= 400) {
throw new Error("HTTP " + r.resp.statusCode + " bei " + url.substring(0, 80));
}
return { url, body: r.body, status: r.resp.statusCode };
}
throw new Error("too many redirects");
};
// Verification-URL öffnen -> bekommt das login/identifier Form
const verif = await followChain(init.verification_uri_complete);
if (!/emailPasswordForm/i.test(verif.body)) {
throw new Error("Login-Form auf " + verif.url + " nicht gefunden");
}
const idForm = this.extractHidden(verif.body);
idForm.email = this.config.user;
if (!this.config.user || !this.config.password) {
throw new Error("Audi-Credentials (user/password) nicht gesetzt");
}
// POST /login/identifier
const idResp = await new Promise((res, rej) =>
request(
{
method: "POST",
url: "https://identity.vwgroup.io/signin-service/v1/" + CLIENT_ID + "/login/identifier",
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent },
form: idForm,
jar: cookieJar,
gzip: true,
followAllRedirects: true,
},
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
const passwordPage = idResp.body;
const csrf = passwordPage.split("csrf_token: '")[1] && passwordPage.split("csrf_token: '")[1].split("'")[0];
const hmac = passwordPage.split('"hmac":"')[1] && passwordPage.split('"hmac":"')[1].split('"')[0];
const relayState =
passwordPage.split('"relayState":"')[1] && passwordPage.split('"relayState":"')[1].split('"')[0];
if (!csrf || !hmac || !relayState) {
throw new Error("CSRF/HMAC/RelayState konnten nicht aus Password-Page extrahiert werden");
}
// POST /login/authenticate
const authResp = await new Promise((res, rej) =>
request(
{
method: "POST",
url: "https://identity.vwgroup.io/signin-service/v1/" + CLIENT_ID + "/login/authenticate",
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent },
form: {
_csrf: csrf,
email: this.config.user,
password: this.config.password,
hmac,
relayState,
},
jar: cookieJar,
gzip: true,
followRedirect: false,
},
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
if (authResp.resp.statusCode !== 302 || !authResp.resp.headers.location) {
throw new Error(
"login/authenticate fehlgeschlagen — Status " +
authResp.resp.statusCode +
" — vermutlich falsche Credentials",
);
}
let nextUrl = authResp.resp.headers.location;
if (nextUrl.startsWith("/")) nextUrl = "https://identity.vwgroup.io" + nextUrl;
// Folge bis zur Code-Confirmation-Seite (.../device/<client_id>/<user_code>/success?...)
const confirm = await followChain(nextUrl);
if (!/device.*success|codeConfirmation/i.test(confirm.url) && !/codeConfirmation|client_identity_name/i.test(confirm.body)) {
throw new Error("Code-Confirmation-Seite nicht erreicht. Aktuell: " + confirm.url);
}
// Form-Daten der Confirmation extrahieren (action + csrf)
const formActionMatch = confirm.body.match(/<form[^>]+action=["']([^"']+)["']/i);
if (!formActionMatch) {
throw new Error("Kein Form-Action auf Confirmation-Seite gefunden");
}
let allowAction = formActionMatch[1];
if (allowAction.startsWith("/")) allowAction = "https://identity.vwgroup.io" + allowAction;
const csrf2 =
confirm.body.match(/<input[^>]*name=["']_csrf["'][^>]*value=["']([^"']+)["']/i) || [];
if (!csrf2[1]) {
throw new Error("Kein _csrf in Confirmation-Form gefunden");
}
const clientIdentityName =
(confirm.body.match(/name=["']client_identity_name["'][^>]*value=["']([^"']+)["']/i) || [])[1] ||
"myAudi App";
// POST allow -> Server registriert die Zustimmung; danach gibt das Polling den Token zurück
const allowResp = await new Promise((res, rej) =>
request(
{
method: "POST",
url: allowAction,
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent },
form: {
_csrf: csrf2[1],
client_identity_name: clientIdentityName,
allow: "",
},
jar: cookieJar,
gzip: true,
followRedirect: false,
},
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
if (allowResp.resp.statusCode >= 400) {
throw new Error("Confirmation POST allow fehlgeschlagen: " + allowResp.resp.statusCode);
}
// Schritt 3: Polling am IDP-Token-Endpoint
const tokenBody = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: init.device_code,
client_id: CLIENT_ID,
}).toString();
const intervalMs = (init.interval || 1) * 1000;
const deadline = Date.now() + (init.expires_in || 300) * 1000;
let tokens = null;
while (Date.now() < deadline) {
let pollResp;
try {
pollResp = await axios({
method: "post",
url: IDP_TOKEN_URL,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": userAgent,
Accept: "application/json",
},
data: tokenBody,
validateStatus: () => true,
});
} catch (e) {
throw new Error("Polling-Fehler: " + e.message, { cause: e });
}
if (pollResp.status === 200 && pollResp.data && pollResp.data.access_token) {
tokens = pollResp.data;
break;
}
const err = pollResp.data && pollResp.data.error;
if (err === "authorization_pending") {
await new Promise((r) => setTimeout(r, intervalMs));
continue;
}
if (err === "slow_down") {
await new Promise((r) => setTimeout(r, intervalMs * 2));
continue;
}
throw new Error("Token-Polling fehlgeschlagen: " + JSON.stringify(pollResp.data));
}
if (!tokens) {
throw new Error("Timeout beim Token-Polling");
}
this.config.atoken = tokens.access_token;
this.config.rtoken = tokens.refresh_token;
if (this.refreshTokenInterval) clearInterval(this.refreshTokenInterval);
// expires_in ist üblicherweise 3599s; 0.9*60min = 54min als Refresh-Intervall
this.refreshTokenInterval = setInterval(() => {
this.refreshAudiDeviceFlowToken().catch((e) => {
this.log.error("Audi token refresh failed: " + (e && e.message ? e.message : e));
});
}, 0.9 * 60 * 60 * 1000);
this.log.info("Audi Device-Flow Login erfolgreich");
}
/**
* Refresh des Audi-Tokens (Device-Code-Flow). Geht direkt gegen den BFF
* (emea.bff.cariad.digital/auth/v1/idk/oidc/token) — der BFF akzeptiert
* Refresh ohne client_secret und ohne x-assertion. Der IDP-Token-Endpoint
* würde 401 invalid_client / missing client_secret werfen.
*/
async refreshAudiDeviceFlowToken() {
const CLIENT_ID = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com";
const userAgent = this.userAgent || "myAudi-Android/5.4.1 (Build 800343956) Android/14";
if (!this.config.rtoken) {
throw new Error("Kein refresh_token vorhanden");
}
const resp = await axios({
method: "post",
url: "https://emea.bff.cariad.digital/auth/v1/idk/oidc/token",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": userAgent,
Accept: "application/json",
},
data: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token: this.config.rtoken,
}).toString(),
validateStatus: () => true,
});
if (resp.status !== 200 || !resp.data || !resp.data.access_token) {
throw new Error("Refresh fehlgeschlagen: " + resp.status + " " + JSON.stringify(resp.data));
}
this.config.atoken = resp.data.access_token;
if (resp.data.refresh_token) {
this.config.rtoken = resp.data.refresh_token;
}
this.log.debug("Audi Device-Flow Token refreshed");
}
/**
* VW ID OIDC Hybrid-Flow Login.
*
* Umgeht Play Integrity / x-assertion am BFF: response_type="code id_token token"
* lässt Auth0 (identity.vwgroup.io) den access_token + id_token direkt im
* Callback-URL-Fragment liefern. Diese Auth0-signierten JWTs werden vom BFF
* (emea.bff.cariad.digital) ohne Token-Exchange-Step akzeptiert.
*
* Nachteil: Auth0 liefert KEIN refresh_token im Callback (Sicherheitsregel —
* keine long-lived Tokens in URLs). Wir machen daher alle ~110 Min einen
* vollständigen Re-Login mit User+Pass — der ganze Flow läuft serverseitig
* (kein Browser-Klick nötig).
*
* Inspiration: volkswagencarnet PR #333 (s1gmund80, 2026-05-30).
*/
async loginIdHybridFlow() {
const CLIENT_ID = "a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com";
const SCOPE = "openid profile badge cars dealers vin offline_access";
const REDIRECT = "weconnect://authenticated";
const X_REQUEST = "com.volkswagen.weconnect";
const userAgent = this.userAgent || "Volkswagen/3.61.0-android/14";
if (!this.config.user || !this.config.password) {
throw new Error("VW-ID Credentials (user/password) nicht gesetzt");
}
this.log.info("VW ID: starte OIDC Hybrid-Flow (kein x-assertion erforderlich)");
// Eigene Cookie-Jar — der Hybrid-Flow soll die Adapter-Hauptjar nicht
// verändern (sonst hängen unschöne Auth0-Cookies in nachfolgenden Calls).
const cookieJar = request.jar();
const followChain = async (startUrl) => {
let url = startUrl;
for (let i = 0; i < 20; i++) {
if (url.startsWith(REDIRECT.split(":")[0] + "://")) {
return { url, body: null };
}
const r = await new Promise((res, rej) =>
request(
{
url,
jar: cookieJar,
headers: {
"User-Agent": userAgent,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "de-DE,de;q=0.9",
"x-requested-with": X_REQUEST,
"upgrade-insecure-requests": "1",
},
gzip: true,
followRedirect: false,
},
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
if (r.resp.statusCode >= 300 && r.resp.statusCode < 400 && r.resp.headers.location) {
let next = r.resp.headers.location;
if (next.startsWith("/")) {
const u = new URL(url);
next = u.protocol + "//" + u.host + next;
}
if (next.includes("error=") || next.includes("/error")) {
throw new Error("auth error redirect: " + next);
}
url = next;
continue;
}
if (r.resp.statusCode >= 400) {
throw new Error("HTTP " + r.resp.statusCode + " bei " + url.substring(0, 80));
}
return { url, body: r.body, status: r.resp.statusCode };
}
throw new Error("too many redirects");
};
// STEP 1: Authorize mit response_type=code id_token token
const nonce = crypto.randomBytes(16).toString("hex");
const state = crypto.randomBytes(16).toString("hex");
const authUrl =
"https://identity.vwgroup.io/oidc/v1/authorize?" +
new URLSearchParams({
client_id: CLIENT_ID,
scope: SCOPE,
response_type: "code id_token token",
redirect_uri: REDIRECT,
nonce,
state,
}).toString();
const loginPage = await followChain(authUrl);
if (!loginPage.body) {
throw new Error("Login-Seite nicht erreicht — vermutlich Redirect zu callback ohne Login");
}
const stateMatch = String(loginPage.body).match(
/<input[^>]*name=["']state["'][^>]*value=["']([^"']*)["']/i,
);
if (!stateMatch) {
// Auth0 schickt manchmal eine SPA-Seite (terms-and-conditions) statt der
// klassischen <form>. In dem Fall hilft nur dass der User die App öffnet.
if (/termsAndConditions|terms-and-conditions/i.test(String(loginPage.body))) {
throw new Error(
"AGB / Terms-and-Conditions müssen in der Volkswagen App akzeptiert werden",
);
}
throw new Error("State-Token konnte aus Login-Seite nicht extrahiert werden");
}
const stateToken = stateMatch[1];
// STEP 2: POST /u/login mit action=default
// (Pflicht damit Auth0 die Submission als Login erkennt — vw-carnet PR #333)
const loginResp = await new Promise((res, rej) =>
request(
{
method: "POST",
url: "https://identity.vwgroup.io/u/login?state=" + stateToken,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": userAgent,
"Accept-Language": "de-DE,de;q=0.9",
"x-requested-with": X_REQUEST,
},
form: {
username: this.config.user,
password: this.config.password,
state: stateToken,
action: "default",
},
jar: cookieJar,
gzip: true,
followRedirect: false,
},
(e, resp, body) => (e ? rej(e) : res({ resp, body })),
),
);
if (loginResp.resp.statusCode !== 302 || !loginResp.resp.headers.location) {
throw new Error(
"Login-POST fehlgeschlagen — Status " +
loginResp.resp.statusCode +
" — vermutlich falsche Credentials",
);
}
let nextUrl = loginResp.resp.headers.location;
if (nextUrl.startsWith("/")) nextUrl = "https://identity.vwgroup.io" + nextUrl;
// STEP 3: Folge Redirect-Kette bis zum weconnect:// Callback
const callback = await followChain(nextUrl);
if (!callback.url.startsWith(REDIRECT.split(":")[0] + "://")) {
throw new Error("Callback-URL nicht erreicht: " + callback.url.substring(0, 120));
}
// STEP 4: Tokens aus URL-Fragment + Query parsen
const queryIdx = callback.url.indexOf("?");
const fragIdx = callback.url.indexOf("#");
let queryStr = "";
if (queryIdx !== -1) {
const end = fragIdx !== -1 && fragIdx > queryIdx ? fragIdx : callback.url.length;
queryStr = callback.url.substring(queryIdx + 1, end);
}
const fragStr = fragIdx !== -1 ? callback.url.substring(fragIdx + 1) : "";
const params = {};
for (const [k, v] of new URLSearchParams(queryStr).entries()) params[k] = v;
for (const [k, v] of new URLSearchParams(fragStr).entries()) params[k] = v;
if (!params.access_token) {
throw new Error("Kein access_token im Callback — Hybrid-Flow nicht erfolgreich");
}
this.config.atoken = params.access_token;
// Hybrid-Flow liefert keinen refresh_token. Wir merken uns nichts in
// this.config.rtoken — der periodische Re-Login holt einen frischen Token.
this.config.rtoken = "";
// expires_in ist in Sekunden, normalerweise 7200 (2h). Wir refreshen bei 90%
// damit ein eventueller Login-Fehler noch innerhalb des gültigen Tokens
// gefangen werden kann.
const expiresInSec = parseInt(params.expires_in, 10) || 7200;
const refreshMs = expiresInSec * 0.9 * 1000;
if (this.refreshTokenInterval) clearInterval(this.refreshTokenInterval);
this.refreshTokenInterval = setInterval(() => {
this.loginIdHybridFlow().catch((e) => {
this.log.error(
"VW ID hybrid-flow re-login failed: " + (e && e.message ? e.message : e),
);
this.log.error("Restart adapter in 10min");
clearInterval(this.refreshTokenInterval);
this.restartTimeout && clearTimeout(this.restartTimeout);
this.restartTimeout = setTimeout(() => this.restart(), 10 * 60 * 1000);
});
}, refreshMs);
this.log.info(
"VW ID Hybrid-Flow Login erfolgreich (token expires in " + expiresInSec + "s, re-login in " + Math.round(refreshMs / 60000) + "min)",
);
}
login() {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
// Audi (myAudi) nutzt jetzt den OAuth 2.0 Device-Code-Flow gegen
// identity.vwgroup.io direkt — umgeht Play Integrity / x-assertion am
// BFF. Verifiziert via myAudi 5.4.1 APK (technology.cariad.cat.idk.deviceflow.*)
// und Live-Tests vom 30.05.2026.
if (this.config.type === "audietron") {
try {
await this.loginAudiDeviceFlow();
resolve();
} catch (e) {
this.log.error("Audi device-flow login failed: " + (e && e.message ? e.message : e));
reject();
}
return;
}
// VW ID nutzt den OIDC Hybrid-Flow (response_type=code id_token token).
// Auth0 liefert access_token + id_token direkt im Callback-Fragment, der
// BFF akzeptiert diese ohne x-assertion. Inspiriert von volkswagencarnet
// PR #333 (s1gmund80, 2026-05-30) und live verifiziert 2026-05-31.
if (this.config.type === "id") {
try {
await this.loginIdHybridFlow();
resolve();
} catch (e) {
this.log.error("VW ID hybrid-flow login failed: " + (e && e.message ? e.message : e));
reject();
}
return;
}
const nonce = this.getNonce();
const state = uuidv4();
this.log.info(`Login in with ${this.config.type}`);
let [code_verifier, codeChallenge] = this.getCodeChallenge();
if (this.config.type === "seatelli" || this.config.type === "skodapower") {
[code_verifier, codeChallenge] = this.getCodeChallengev2();
}
const method = "GET";
const form = {};
// VW migrated authorize endpoint to the BFF host (Volkswagen 3.61.0 APK,
// ProductionEnvironment.smali). Other brands not yet verified, keep
// identity.vwgroup.io for them.
const authorizeBase =
this.config.type === "id"
? "https://emea.bff.cariad.digital/auth/v1/idk/oidc/authorize"
: "https://identity.vwgroup.io/oidc/v1/authorize";
let url =
authorizeBase + "?client_id=" +
this.clientId +
"&scope=" +
this.scope +
"&response_type=" +
this.responseType +
"&redirect_uri=" +
this.redirect +
"&nonce=" +
nonce +
"&state=" +
state;
if (
this.config.type === "vw" ||
this.config.type === "vwv2" ||
this.config.type === "go" ||
this.config.type === "seatelli" ||
this.config.type === "skodae" ||
this.config.type === "skodapower" ||
this.config.type === "audidata" ||
this.config.type === "audietron" ||
this.config.type === "seatcupra" ||
this.config.type === "seat"
) {
url += "&code_challenge=" + codeChallenge + "&code_challenge_method=S256";
}
if (this.config.type === "audi") {
url += "&ui_locales=de-DE%20de&prompt=login";
}
if (this.config.type === "id" && this.type !== "Wc") {
// For ID type: Use direct OpenID authorization endpoint (Python-style)
// This ensures the code is compatible with the OpenID token endpoint
this.log.debug("Using direct OpenID authorization endpoint (Python-style, no code_challenge)");
// The standard url above is already correct - it has all the right params
// Just make sure we're NOT adding code_challenge for ID type
}
this.log.debug("Login URL: " + url);
const loginRequest = request(
{
method: method,
url: url,
headers: {
"User-Agent": this.userAgent,
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate",
"x-requested-with": this.xrequest,
"upgrade-insecure-requests": 1,
},
jar: this.jar,
form: form,
gzip: true,
followAllRedirects: true,
},
(err, resp, body) => {
if (err || (resp && resp.statusCode >= 400)) {
if (this.type === "Wc") {
if (err && err.message && err.message === "Invalid protocol: wecharge:") {
this.log.debug("Found WeCharge connection");
this.getTokens(loginRequest, code_verifier, reject, resolve);
} else {
this.log.debug("No WeCharge found, cancel login");
resolve();
}
return;
}
if (err && err.message && err.message.indexOf("Invalid protocol:") !== -1) {
this.log.debug("Found Token");
this.getTokens(loginRequest, code_verifier, reject, resolve);
return;
}
this.log.error("Failed in first login step ");
err && this.log.error(err);
resp && this.log.error(resp.statusCode.toString());
body && this.log.error(JSON.stringify(body));
err && err.message && this.log.error(err.message);
loginRequest &&
loginRequest.uri &&
loginRequest.uri.query &&
this.log.debug(loginRequest.uri.query.toString());
reject();
return;
}
try {
const stateToken = this.extractStateToken(body);
// New authentication flow with state token
if (stateToken) {
this.log.info("Using new authentication flow with state token");
const loginForm = {
username: this.config.user,
password: this.config.password,
state: stateToken
};
const loginHeaders = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": this.userAgent,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate",
"x-requested-with": this.xrequest,
};
if (this.config.type === "id" && this.androidPackageName) {
loginHeaders["x-android-package-name"] = this.androidPackageName;
}
request.post(
{
url: "https://identity.vwgroup.io/u/login?state=" + stateToken,
headers: loginHeaders,
form: loginForm,
jar: this.jar,
gzip: true,