UNPKG

iobroker.vw-connect

Version:
1,222 lines (1,159 loc) 336 kB
// @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,