diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cea80..ce375eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## vag_vehicle 0.2.0 + +Sign in with the account email and password instead of a pasted portal cookie, and sign in again when the portal session ends after about an hour (srcfl/device-drivers#143). The sign-in follows evcc's EU Data Act client: the identity form, the password page's `window._IDK` state, then the redirects back to the portal, skipping an optional marketing consent page. It needs an FTW Core with `host.http_request`. The host keeps the session cookies, and every redirect is a separate request checked against `allowed_hosts`. A failed sign-in waits 15 minutes before the next try, so a wrong password cannot lock the account. On an older Core, a pasted `cookie` works as before. `identity.vwgroup.io` joins `http_hosts`, `password` joins `config_secrets`, and the two sign-in form paths per brand are declared: the first in `auth_post_path`, so an older Core still accepts the driver, and the rest in `auth_post_paths`. + +The driver now reports the reading's age as `vehicle_soc_age_s`. The SoC data point carries the time the car measured it, and the portal's `Date` header gives the time now, so no wall clock is needed. A reading the car measured more than 20 minutes ago is not a fresh observation, even in a new file. A fresh first file after start is no longer marked stale. Ages are reported only in sign-in mode, since cookie mode reads through `host.http_get`, which does not return headers. Tested against a scripted portal, not yet against the live portal or a car. + ## esphome_dsmr 1.0.7 Declare an empty `host` in `connection_defaults`, so FTW setup passes the diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 2b448f0..d8116b5 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -168,8 +168,8 @@ Catalog source is not proof that a target can install or run a driver. | teslamate_vehicle | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | victron | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 74ef10e..ee6d178 100644 --- a/devices.yaml +++ b/devices.yaml @@ -2064,11 +2064,11 @@ manufacturers: protocols: - protocol: http driver: "vag_vehicle" - version: "0.1.1" + version: "0.2.0" ders: [vehicle] control: false firmware_versions: "" - notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet run against the portal or a car." + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request. The driver signs in with the account email and password and renews the session itself on an FTW Core with host.http_request; an older host needs a pasted portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Sign-in follows evcc and is tested against a scripted portal, not yet against the live portal or a car." - name: "Wallbox" model_families: - name: "Commander/Pulsar" diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index 9b92ed3..16ade9e 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -18,27 +18,41 @@ -- -- Porsche is not on this portal (VW, Audi, Škoda, SEAT, Cupra, MAN, -- Bentley, Elli). No wake / charge_start / climatisation — telemetry --- only. FTW's Lua host has no cookie jar and cannot complete the --- portal's OIDC form login, so the owner pastes a logged-in session --- Cookie header. When it expires the driver stops emitting. +-- only. +-- +-- Sign-in: with `email` and `password` the driver signs in through the +-- VW Group identity service itself, as evcc does, and signs in again +-- when the portal session ends (about an hour). There is no refresh +-- token. A failed sign-in waits 15 minutes before the next try, so a +-- wrong password cannot lock the account. This needs a host with +-- host.http_request (FTW Core with a per-driver cookie jar). On an +-- older host, or without a password, the owner pastes a logged-in +-- session Cookie header instead, and the driver stops emitting when it +-- expires. +-- +-- Age: the SoC data point carries the time the car measured it, and the +-- portal's Date header gives the time now, so the driver reports the +-- reading's age as `vehicle_soc_age_s` without a wall clock of its own. +-- A reading older than 20 minutes is not a fresh observation. -- -- What the site owner must provide: -- 1. A brand account already linked to the car. --- 2. One-time consent on the portal, then +-- 2. One-time consent on the portal in a browser, then -- Get customised data → continuous → All Data → 15 minutes. --- 3. VIN, brand, and the Cookie header from a logged-in portal tab --- (DevTools → Network → any portal request → Request Headers). +-- 3. VIN, brand, and the account email and password. -- -- drivers: -- - name: id4 -- lua: drivers/vag_vehicle.lua -- capabilities: -- http: --- allowed_hosts: ["eu-data-act.drivesomethinggreater.com"] +-- allowed_hosts: ["eu-data-act.drivesomethinggreater.com", "identity.vwgroup.io"] -- config: -- vin: "WVWZZZ..." -- brand: volkswagen # audi | skoda | seat | cupra --- cookie: "name=value; ..." # masked via config_secrets +-- email: "owner@example.com" +-- password: "..." # masked via config_secrets +-- # cookie: "name=value; ..." # instead of email/password on old hosts -- -- Keys (GUIDs) and names come from VW's data dictionary ("DataDictionary -- V5.0, Continuous Data"), as parsed in evcc's vehicle/vw/eudataact, which @@ -52,22 +66,44 @@ DRIVER = { id = "vag_vehicle", name = "VAG Vehicle (EU Data Act)", manufacturer = "Volkswagen Group", - version = "0.1.1", + version = "0.2.0", protocols = { "http" }, capabilities = { "vehicle" }, read_only = true, description = "Read-only VW / Audi / Škoda / SEAT / Cupra SoC and charge state from the EU Data Act portal. Not live BMS; charging does not need this cloud.", homepage = "https://eu-data-act.drivesomethinggreater.com/", - http_hosts = { "eu-data-act.drivesomethinggreater.com" }, + http_hosts = { "eu-data-act.drivesomethinggreater.com", "identity.vwgroup.io" }, authors = { "FTW contributors" }, tested_models = { "ID.3", "ID.4", "Enyaq", "Q4 e-tron" }, verification_status = "experimental", - config_secrets = { "cookie" }, + config_secrets = { "cookie", "password" }, + -- The two form posts of the identity sign-in, per brand client id. A + -- read-only driver may POST only here; Core matches each path exactly. + -- The first sits in auth_post_path: a Core that predates auth_post_paths + -- refuses a read-only driver holding http.post without one. Such a Core + -- has no host.http_request, so the driver never posts there. + auth_post_path = "/signin-service/v1/9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com/login/identifier", + auth_post_paths = { + "/signin-service/v1/9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com/login/authenticate", + "/signin-service/v1/cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com/login/identifier", + "/signin-service/v1/cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com/login/authenticate", + "/signin-service/v1/3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com/login/identifier", + "/signin-service/v1/3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com/login/authenticate", + "/signin-service/v1/f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com/login/identifier", + "/signin-service/v1/f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com/login/authenticate", + }, } PROTOCOL = "http" local BASE_URL = "https://eu-data-act.drivesomethinggreater.com" +local PORTAL_HOST = "eu-data-act.drivesomethinggreater.com" +local IDENTITY_URL = "https://identity.vwgroup.io" +local IDENTITY_HOST = "identity.vwgroup.io" +-- A failed sign-in waits this long, so retries cannot lock the account. +local LOGIN_BACKOFF_MS = 900000 +-- A reading the car measured longer ago than this is not a fresh one. +local FRESH_AGE_S = 1200 -- The portal writes a file about every 15 minutes; asking every 5 is -- enough. The host keeps the interval last set with set_poll_interval. local POLL_INTERVAL_MS = 300000 @@ -86,6 +122,16 @@ local BRANDS = { cupra = "Cupra", } +-- Identity client id and state suffix per brand, from evcc's +-- vehicle/vw/eudataact (after ioBroker.vw-connect's euDataAct.js). +local CLIENTS = { + Volkswagen = { id = "9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com", state = "VOLKSWAGEN_PASSENGER_CARS" }, + Audi = { id = "cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com", state = "AUDI" }, + Skoda = { id = "3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com", state = "SKODA" }, + Seat = { id = "f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", state = "SEAT" }, + Cupra = { id = "f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", state = "CUPRA" }, +} + -- SoC / limit / state / remaining-time ids from the Data Act dictionary. -- Name fallbacks stay for datasets that omit the GUID. local SOC_IDS = { @@ -132,6 +178,14 @@ local TTF_IDS = { local vin = nil local brand_name = nil local cookie = nil +local email = nil +local password = nil +-- Sign-in mode: the host keeps the session cookies; see login(). +local session_ok = false +local next_login_ms = 0 +-- Server time (epoch s) from the last portal Date header, and when it came. +local server_now_s = nil +local server_now_ms = 0 local request_id = nil local last_file = nil -- Newest content file at the first listing after start (false: there was @@ -144,6 +198,8 @@ local last = { charging_state = nil, time_to_full = nil, known_age = false, + -- Seconds between the car's measurement and the read, when known. + measured_age_s = nil, } --------------------------------------------------------------------------- @@ -544,8 +600,314 @@ local function auth_headers(extra) return h end +--------------------------------------------------------------------------- +-- Time. The host has no wall clock; the portal's Date header and the data +-- points' timestampUtc are both VW's clocks, so their difference is an age. +--------------------------------------------------------------------------- + +local function days_from_civil(y, m, d) + if m <= 2 then y = y - 1 end + local era = math.floor(y / 400) + local yoe = y - era * 400 + local doy = math.floor((153 * ((m + 9) % 12) + 2) / 5) + d - 1 + local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy + return era * 146097 + doe - 719468 +end + +-- "2026-09-26T07:00:00Z", optional fraction, Z or ±hh:mm. +local function iso_epoch(s) + if type(s) ~= "string" then return nil end + local y, mo, d, h, mi, se, rest = + s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)(.*)$") + if not y then return nil end + local t = days_from_civil(tonumber(y), tonumber(mo), tonumber(d)) * 86400 + + tonumber(h) * 3600 + tonumber(mi) * 60 + tonumber(se) + rest = rest:gsub("^%.%d+", "") + if rest == "" or rest == "Z" then return t end + local sign, oh, om = rest:match("^([+-])(%d%d):?(%d%d)$") + if not sign then return nil end + local off = tonumber(oh) * 3600 + tonumber(om) * 60 + if sign == "+" then return t - off end + return t + off +end + +local MONTHS = { Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, + Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12 } + +-- "Sat, 03 Oct 2026 10:00:00 GMT" +local function http_date_epoch(s) + if type(s) ~= "string" then return nil end + local d, mon, y, h, mi, se = s:match("(%d%d?) (%a%a%a) (%d%d%d%d) (%d%d):(%d%d):(%d%d) GMT") + local m = mon and MONTHS[mon] + if not m then return nil end + return days_from_civil(tonumber(y), m, tonumber(d)) * 86400 + + tonumber(h) * 3600 + tonumber(mi) * 60 + tonumber(se) +end + +local function note_server_time(headers) + local t = headers and http_date_epoch(headers.date) + if t then + server_now_s = t + server_now_ms = host.millis() + end +end + +--------------------------------------------------------------------------- +-- Sign-in through the VW Group identity service, as evcc's eudataact does. +-- Every redirect is one host.http_request call, so Core checks each hop +-- against allowed_hosts; the session cookies stay in the host's jar. +--------------------------------------------------------------------------- + +local function urlencode(v) + return (tostring(v):gsub("[^%w%-%._~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function urldecode(v) + return (tostring(v):gsub("%+", " "):gsub("%%(%x%x)", function(h) + return string.char(tonumber(h, 16)) + end)) +end + +-- pairs is a list of {name, value}, so the order is stable. +local function form(pairs_list) + local out = {} + for i = 1, #pairs_list do + out[i] = urlencode(pairs_list[i][1]) .. "=" .. urlencode(pairs_list[i][2] or "") + end + return table.concat(out, "&") +end + +local function html_unescape(v) + if not v then return nil end + v = v:gsub("&#x(%x+);", function(h) return string.char(tonumber(h, 16)) end) + v = v:gsub("&#(%d+);", function(n) return string.char(tonumber(n)) end) + v = v:gsub(""", '"'):gsub("'", "'"):gsub("<", "<"):gsub(">", ">") + return (v:gsub("&", "&")) +end + +local function attr(tag, name) + return html_unescape(tag:match("%s" .. name .. "%s*=%s*\"([^\"]*)\"") + or tag:match("%s" .. name .. "%s*=%s*'([^']*)'")) +end + +-- The action and named inputs of the form with this id. +local function parse_form(html, id) + local init = 1 + while true do + local _, tag_end, tag = string.find(html, "(]*>)", init) + if not tag_end then return nil end + if attr(tag, "id") == id then + local close = string.find(html, "", tag_end, true) or #html + local inner = string.sub(html, tag_end + 1, close) + local inputs = {} + for input in inner:gmatch("]*>") do + local name = attr(input, "name") + if name then inputs[name] = attr(input, "value") or "" end + end + return { action = attr(tag, "action"), inputs = inputs } + end + init = tag_end + 1 + end +end + +-- The password page carries its form state in a script object, +-- window._IDK = {...}. Read single fields; no JavaScript runs. +local function idk_block(html) + local s = string.find(html, "window._IDK", 1, true) + if not s then return nil end + local e = string.find(html, "", s, true) or #html + return string.sub(html, s, e) +end + +local function js_field(js, key) + for _, pattern in ipairs({ + "[^%w_]" .. key .. "%s*:%s*\"([^\"]*)\"", + "[^%w_]" .. key .. "%s*:%s*'([^']*)'", + "\"" .. key .. "\"%s*:%s*\"([^\"]*)\"", + }) do + local v = js:match(pattern) + if v then return v end + end + return nil +end + +local function url_host(u) + return type(u) == "string" and string.lower(u:match("^https://([^/:?#]+)") or "") or "" +end + +local function url_path(u) + return type(u) == "string" and (u:match("^https://[^/]+(/[^?#]*)") or "/") or "" +end + +local function sign_in_host(u) + local h = url_host(u) + return h == IDENTITY_HOST or h == PORTAL_HOST +end + +local function is_user_page(u) + local path = url_path(u) + return url_host(u) == PORTAL_HOST and path:sub(1, 14) == "/content/euda/" + and path:sub(-10) == "/user.html" +end + +local function request(method, url, body) + local headers = { ["Accept"] = "text/html,application/json" } + if body then headers["Content-Type"] = "application/x-www-form-urlencoded" end + local ok, r, err = pcall(host.http_request, { + method = method, url = url, headers = headers, body = body, + }) + if not ok then return nil, tostring(r) end + if not r then return nil, tostring(err) end + note_server_time(r.headers) + if r.status >= 400 then return nil, "HTTP " .. tostring(r.status) end + return r +end + +-- GET each redirect on a sign-in host until a page answers. Returns the +-- response and its URL. +local function follow(r, url) + for _ = 1, 10 do + if not (r.status >= 300 and r.status < 400) then return r, url end + if not r.location or not sign_in_host(r.location) then + return nil, nil, "sign-in redirected off the VW hosts" + end + url = r.location + local err + r, err = request("GET", url) + if not r then return nil, nil, err end + end + return nil, nil, "sign-in stopped after 10 redirects" +end + +local function nonce() + local out = {} + for i = 1, 43 do + local n = math.random(0, 51) + out[i] = string.char(n < 26 and 65 + n or 71 + n) + end + return table.concat(out) +end + +local function login() + local client = CLIENTS[brand_name] + if not client then return nil, "no sign-in client for " .. tostring(brand_name) end + host.http_cookies_clear() + + local start = IDENTITY_URL .. "/oidc/v1/authorize?" .. form({ + { "client_id", client.id }, + { "response_type", "code" }, + { "scope", "openid cars profile" }, + { "state", "de__en__" .. client.state }, + { "redirect_uri", BASE_URL .. "/login" }, + { "prompt", "login" }, + { "nonce", nonce() }, + }) + local r, err = request("GET", start) + if not r then return nil, err end + r, _, err = follow(r, start) + if not r then return nil, err end + + local f = parse_form(r.body or "", "emailPasswordForm") + if not f or not f.action then + return nil, "sign-in form not found (the VW page may have changed)" + end + local id_url = f.action + if id_url:sub(1, 1) == "/" then id_url = IDENTITY_URL .. id_url end + r, err = request("POST", id_url, form({ + { "_csrf", f.inputs._csrf }, + { "relayState", f.inputs.relayState }, + { "hmac", f.inputs.hmac }, + { "email", email }, + })) + if not r then return nil, err end + r, _, err = follow(r, id_url) + if not r then return nil, err end + + local js = idk_block(r.body or "") + if not js then return nil, "password page not found (the VW page may have changed)" end + local refused = js_field(js, "error") + if refused and refused ~= "" then return nil, "sign-in refused: " .. refused end + local identifier_url = js_field(js, "identifierUrl") + local post_action = js_field(js, "postAction") + local s, e + if identifier_url then s, e = string.find(id_url, identifier_url, 1, true) end + if not s or not post_action then return nil, "password form not found" end + local auth_url = string.sub(id_url, 1, s - 1) .. post_action .. string.sub(id_url, e + 1) + + r, err = request("POST", auth_url, form({ + { "_csrf", js_field(js, "csrf_token") }, + { "relayState", js_field(js, "relayState") }, + { "hmac", js_field(js, "hmac") }, + { "email", email }, + { "password", password }, + })) + if not r then return nil, err end + + -- Walk the redirects back to the portal, which sets its session cookies + -- on the way. Stop before fetching the landing page itself. + local url = auth_url + for _ = 1, 12 do + local loc = (r.status >= 300 and r.status < 400) and r.location or nil + if not loc then break end + if is_user_page(loc) then return true end + if url_host(loc) == IDENTITY_HOST and string.find(url_path(loc), "/consent/marketing/", 1, true) then + -- An optional marketing consent page: continue through its callback + -- without consenting. + local cb = loc:match("[?&]callback=([^&]*)") + if not cb then return nil, "marketing consent without a callback" end + cb = urldecode(cb):gsub(" ", "%%20") + local path = url_path(cb) + if url_host(cb) ~= IDENTITY_HOST or (path ~= "/oidc/v1/oauth/client/callback" + and path ~= "/oidc/v1/oauth/client/callback/success") then + return nil, "unexpected marketing consent callback" + end + loc = cb + end + if not sign_in_host(loc) then return nil, "sign-in redirected off the VW hosts" end + url = loc + r, err = request("GET", url) + if not r then return nil, err end + end + if is_user_page(url) then return true end + -- A wrong password comes back as the password page with VW's reason. + local page_js = idk_block(r.body or "") + local reason = page_js and js_field(page_js, "error") + if reason and reason ~= "" then return nil, "sign-in refused: " .. reason end + local path = url_path(url) + if string.find(path, "signin-service", 1, true) or string.find(path, "/consent", 1, true) + or string.find(path, "/error", 1, true) then + return nil, "sign-in did not finish: open the portal once in a browser and confirm consent" + end + return nil, "sign-in did not reach the portal" +end + +local function session_mode() + return email ~= nil and password ~= nil and host.http_request ~= nil +end + local function api_get(path, extra) - return safe_http_get(BASE_URL .. path, auth_headers(extra)) + if not session_mode() then + return safe_http_get(BASE_URL .. path, auth_headers(extra)) + end + local headers = { ["Accept"] = "application/json" } + if extra then + for k, v in pairs(extra) do headers[k] = v end + end + local ok, r, err = pcall(host.http_request, { url = BASE_URL .. path, headers = headers }) + if not ok then return nil, tostring(r) end + if not r then return nil, tostring(err) end + note_server_time(r.headers) + -- An ended session answers 401/403, or redirects to the sign-in page. + if r.status == 401 or r.status == 403 or (r.status >= 300 and r.status < 400) then + session_ok = false + return nil, "HTTP 401: session ended" + end + if r.status >= 400 then + return nil, "HTTP " .. tostring(r.status) .. ": " .. string.sub(r.body or "", 1, 200) + end + return r.body end -- The newest point among the candidates wins; on a tie, the earlier @@ -588,6 +950,7 @@ local function index_points(data) value = tostring(dp.value), key = dp.key, name = dp.dataFieldName or dp.DataFieldName, + ts = dp.timestampUtc or dp.TimestampUtc, seq = i, } if rec.key and rec.key ~= "" then points[rec.key] = rec end @@ -696,26 +1059,50 @@ local function emit_reading(soc, limit, state, ttf, fresh, stale) }) end +-- The reading's age now: measured age at the read plus time since. +local function emit_age(since_read_ms) + if last.measured_age_s == nil then return nil end + local age = last.measured_age_s + since_read_ms / 1000 + host.emit_metric("vehicle_soc_age_s", age, "s") + return age +end + local function emit_last() if last.soc == nil then return end local age = host.millis() - last.ts_ms if age > STALE_AFTER_MS then return end + local measured = emit_age(age) + local stale = not last.known_age or age > (STALE_AFTER_MS / 2) + if measured ~= nil then + stale = measured * 1000 > STALE_AFTER_MS / 2 + end emit_reading( last.soc, last.charge_limit, last.charging_state, last.time_to_full, - false, not last.known_age or age > (STALE_AFTER_MS / 2)) + false, stale) end -local function remember(soc, limit, state, ttf, known_age) +local function remember(soc, limit, state, ttf, known_age, measured_age_s) last.soc = soc last.charge_limit = limit last.charging_state = state last.time_to_full = ttf last.known_age = known_age + last.measured_age_s = measured_age_s last.ts_ms = host.millis() end +-- Seconds since the car measured this point, from the portal's clock. +local function point_age_s(point) + local measured = point and iso_epoch(point.ts) + if not measured or not server_now_s then return nil end + -- Subtract the epoch values first: they do not fit a 32-bit float. + local age = (server_now_s - measured) + (host.millis() - server_now_ms) / 1000 + if age < 0 then age = 0 end + return age +end + local function resolve_brand(raw) if type(raw) ~= "string" or raw == "" then return nil, "brand required" end local key = string.lower(raw) @@ -777,8 +1164,18 @@ function driver_init(config) host.set_make(brand_name) host.set_sn(vin) cookie = normalize_cookie(config.cookie or config.session_cookie) - if not cookie then - host.log("error", "vag: `cookie` required — paste the portal Cookie header after enabling the 15-minute All Data request. Charging does not need this cloud.") + if type(config.email) == "string" and config.email ~= "" and + type(config.password) == "string" and config.password ~= "" then + if host.http_request and host.http_cookies_clear then + email = config.email + password = config.password + elseif not cookie then + host.log("error", "vag: this FTW version cannot sign in with email and password; update FTW or paste the portal Cookie header. Charging does not need this cloud.") + return + end + end + if not session_mode() and not cookie then + host.log("error", "vag: `email` and `password` required (or a pasted portal `cookie`) after enabling the 15-minute All Data request. Charging does not need this cloud.") return end if host.set_watchdog_timeout_s then @@ -786,19 +1183,44 @@ function driver_init(config) end host.set_poll_interval(500) host.log("info", "vag: telemetry-only EU Data Act driver brand=" .. - brand_name .. " vin=" .. vin) + brand_name .. " vin=" .. vin .. + (session_mode() and " sign-in=email" or " sign-in=cookie")) end function driver_poll() host.set_poll_interval(POLL_INTERVAL_MS) - if not vin or not cookie or not brand_name then + if not vin or not brand_name or (not cookie and not session_mode()) then return POLL_INTERVAL_MS end + if session_mode() and not session_ok then + if host.millis() < next_login_ms then + emit_last() + return POLL_INTERVAL_MS + end + local ok, lerr = login() + if not ok then + next_login_ms = host.millis() + LOGIN_BACKOFF_MS + local es = tostring(lerr) + if es:find("not in allowed_hosts", 1, true) then + es = es .. " — add identity.vwgroup.io to capabilities.http.allowed_hosts" + end + host.log("warn", "vag: sign-in failed, next try in 15 min: " .. es) + emit_last() + return POLL_INTERVAL_MS + end + session_ok = true + request_id = nil + host.log("info", "vag: signed in to the EU Data Act portal") + end local id, iderr = ensure_request_id() if not id then local es = tostring(iderr) - if es:match("HTTP 401") or es:match("HTTP 403") then + if session_mode() and not session_ok then + host.log("info", "vag: portal session ended, signing in again") + emit_last() + return 1000 + elseif es:match("HTTP 401") or es:match("HTTP 403") then host.log("warn", "vag: portal session expired — refresh config.cookie. Charging continues without vehicle cloud.") elseif es:match("HTTP 404") or es:match("no data request") then host.log("warn", "vag: no continuous data request — enable All Data / 15 min on the EU Data Act portal") @@ -814,7 +1236,12 @@ function driver_poll() { type = "partial" }) if lerr then local es = tostring(lerr) - if es:match("HTTP 401") or es:match("HTTP 403") then + if session_mode() and not session_ok then + host.log("info", "vag: portal session ended, signing in again") + request_id = nil + emit_last() + return 1000 + elseif es:match("HTTP 401") or es:match("HTTP 403") then host.log("warn", "vag: portal session expired — refresh config.cookie. Charging continues without vehicle cloud.") request_id = nil elseif es:match("HTTP 404") then @@ -887,13 +1314,23 @@ function driver_poll() local state = map_charging_state(points) local known_age = name ~= baseline_file - remember(soc, limit, state, ttf, known_age) - host.log("info", "vag: " .. (known_age and "emit" or "first file since start, age unknown, stale:") .. + local fresh = known_age + local measured = point_age_s(soc_p) + if measured ~= nil then + -- The portal can deliver a new file with an old reading from a car + -- that sleeps, and the first file after start may be fresh. + known_age = true + fresh = measured <= FRESH_AGE_S + end + remember(soc, limit, state, ttf, known_age, measured) + host.log("info", "vag: " .. (fresh and "emit" or (known_age and "old reading:" or "first file since start, age unknown, stale:")) .. " soc=" .. tostring(soc) .. " limit=" .. tostring(limit) .. " state=" .. tostring(state) .. + (measured and (" age_s=" .. tostring(math.floor(measured))) or "") .. " file=" .. tostring(name)) - if known_age then + if fresh then + emit_age(0) emit_reading(soc, limit, state, ttf, true, false) else emit_last() @@ -909,6 +1346,12 @@ function driver_cleanup() vin = nil brand_name = nil cookie = nil + email = nil + password = nil + session_ok = false + next_login_ms = 0 + server_now_s = nil + last.measured_age_s = nil request_id = nil last_file = nil baseline_file = nil diff --git a/drivers/tests/lua_harness/test_vag_vehicle_login.lua b/drivers/tests/lua_harness/test_vag_vehicle_login.lua new file mode 100644 index 0000000..87dc5a6 --- /dev/null +++ b/drivers/tests/lua_harness/test_vag_vehicle_login.lua @@ -0,0 +1,268 @@ +-- VAG / EU Data Act driver: email sign-in through host.http_request, session +-- renewal, back-off, and reading age from the portal's Date header. +-- Args: deflated.zip (SoC point measured 2026-09-26T07:00:00Z) + +dofile("drivers/tests/lua_harness/host_mock.lua") + +local zip_path = arg[1] +assert(zip_path, "usage: test_vag_vehicle_login.lua deflated.zip") +local f = assert(io.open(zip_path, "rb")) +local dataset_zip = f:read("*a") +f:close() + +local VIN = "WVWZZZTESTVIN0001" +local REQ = "req-continuous-1" +local FILE = "2026-09-26T07-00-00_partial.zip" +local NEXT = "2026-09-26T07-15-00_partial.zip" +local CID = "9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com" +local ID = "https://identity.vwgroup.io" +local PORTAL = "https://eu-data-act.drivesomethinggreater.com" +local SIGNIN = ID .. "/signin-service/v1/" .. CID +local PASSWORD = "correct horse" + +-- A fake VW sign-in and portal. `session` stands for the cookies the host +-- jar would hold; http_cookies_clear drops it. +local fake + +local function reset_fake() + fake = { + session = false, + password = PASSWORD, + date = "Sat, 26 Sep 2026 07:05:00 GMT", + files = { { name = FILE, createdOn = "2026-09-26T07:00:00Z" } }, + downloads = { [FILE] = dataset_zip }, + requests = {}, + posts = {}, + logins = 0, + expire_next = false, + off_host = false, + } +end + +local function resp(status, extra) + local r = { status = status, headers = { date = fake.date }, body = "" } + for k, v in pairs(extra or {}) do r[k] = v end + return r +end + +local function redirect(location) + return resp(302, { location = location }) +end + +local LOGIN_PAGE = [[ +
+ + + + +
]] + +local function password_page(err) + return [[]] +end + +local function has(body, pair) + return body and string.find("&" .. body .. "&", "&" .. pair .. "&", 1, true) ~= nil +end + +function host.http_cookies_clear() + fake.session = false +end + +function host.http_request(opts) + local method = opts.method or "GET" + local url = opts.url + table.insert(fake.requests, method .. " " .. url) + if method == "POST" then table.insert(fake.posts, opts.body or "") end + local path = url:match("^https://[^/]+([^?]*)") or "" + + if url:find(ID .. "/oidc/v1/authorize?", 1, true) == 1 then + assert(url:find("client_id=" .. CID:gsub("@", "%%40"), 1, true), "brand client id") + assert(url:find("redirect_uri=https%3A%2F%2Feu-data-act", 1, true), "portal redirect uri") + return redirect(SIGNIN .. "/login?relayState=r0") + end + if url == SIGNIN .. "/login?relayState=r0" then + return resp(200, { body = LOGIN_PAGE }) + end + if method == "POST" and url == SIGNIN .. "/login/identifier" then + assert(has(opts.body, "_csrf=c1") and has(opts.body, "relayState=r1"), "identifier form state") + assert(has(opts.body, "hmac=h%261"), "entities decoded, then encoded: " .. opts.body) + assert(has(opts.body, "email=owner%40example.com"), "email") + assert(not opts.body:find("password", 1, true), "no password at the identifier step") + return resp(303, { location = SIGNIN .. "/login/authenticate?relayState=r1" }) + end + if method == "GET" and url == SIGNIN .. "/login/authenticate?relayState=r1" then + return resp(200, { body = password_page(nil) }) + end + if method == "POST" and url == SIGNIN .. "/login/authenticate" then + assert(has(opts.body, "_csrf=c2") and has(opts.body, "hmac=h2") and has(opts.body, "relayState=r2"), + "password form state from window._IDK") + if not has(opts.body, "password=correct%20horse") or fake.password ~= PASSWORD then + return resp(200, { body = password_page("login.errors.password_invalid") }) + end + if fake.off_host then + return redirect("https://evil.example/steal") + end + return redirect(ID .. "/oidc/v1/oauth/sso?x=1") + end + if url == ID .. "/oidc/v1/oauth/sso?x=1" then + return redirect(ID .. "/consent/marketing/abc?callback=" + .. "https%3A%2F%2Fidentity.vwgroup.io%2Foidc%2Fv1%2Foauth%2Fclient%2Fcallback%3Fscope%3Dopenid+cars") + end + if url == ID .. "/oidc/v1/oauth/client/callback?scope=openid%20cars" then + return redirect(PORTAL .. "/login?code=xyz") + end + if url == PORTAL .. "/login?code=xyz" then + fake.session = true + fake.logins = fake.logins + 1 + return redirect(PORTAL .. "/content/euda/en/user.html") + end + + if url:find(PORTAL .. "/proxy_api/", 1, true) == 1 then + assert(method == "GET", "portal reads are GETs") + assert(not (opts.headers or {}).Cookie, "sign-in mode leaves cookies to the host jar") + if fake.expire_next then + fake.expire_next = false + fake.session = false + end + if not fake.session then return resp(401, { body = "unauthorized" }) end + if path:find("/metadata/partial", 1, true) then + return resp(200, { body = host.json_encode({ Identifier = REQ }) }) + end + if path:find("/list", 1, true) then + return resp(200, { body = host.json_encode(fake.files) }) + end + if path:find("/download", 1, true) then + local body = fake.downloads[opts.headers.filename] + if not body then return resp(404) end + return resp(200, { body = body }) + end + end + error("unexpected request " .. method .. " " .. url) +end + +local function rows() + return host._emitted.vehicle or {} +end + +local function last_row() + local r = rows() + return r[#r] +end + +local function count(prefix) + local n = 0 + for _, r in ipairs(fake.requests) do + if r:find(prefix, 1, true) == 1 then n = n + 1 end + end + return n +end + +local function logged(needle) + for _, line in ipairs(host._logs) do + if string.find(line, needle, 1, true) then return true end + end + return false +end + +local function boot(cfg) + host.reset() + reset_fake() + dofile("drivers/lua/vag_vehicle.lua") + driver_init(cfg or { + vin = VIN, brand = "volkswagen", + email = "owner@example.com", password = PASSWORD, + }) +end + +-- Sign in, then read. The first file after start is fresh when the car +-- measured it 5 minutes ago, and the age comes from VW's clocks. +boot() +local interval = driver_poll() +assert(fake.logins == 1, "signed in once") +assert(logged("signed in"), "sign-in is logged") +assert(#rows() == 1, "first poll emits") +assert(last_row().soc == 63, "soc read after sign-in") +assert(last_row().soc_fresh == true, "a 5-minute-old reading is fresh") +assert(last_row().stale == false, "and not stale") +local age = host._metrics.vehicle_soc_age_s +assert(age and math.abs(age.value - 300) < 2, "age from Date header, got " .. tostring(age and age.value)) +assert(interval == 300000, "normal poll interval") +assert(count("POST " .. SIGNIN .. "/login/identifier") == 1 and count("POST " .. SIGNIN .. "/login/authenticate") == 1, + "two form posts") +assert(count("GET " .. PORTAL .. "/content/euda/") == 0, "landing page is not fetched") + +-- The replay of the same file keeps counting its age. +host._millis_counter = host._millis_counter + 60000 +driver_poll() +assert(last_row().soc_fresh == false, "replay is not fresh") +assert(host._metrics.vehicle_soc_age_s.value > 359, "replay age grows") + +-- The session ends after about an hour: the driver signs in again. +fake.expire_next = true +fake.files[2] = { name = NEXT, createdOn = "2026-09-26T07:15:00Z" } +fake.downloads[NEXT] = dataset_zip +fake.date = "Sat, 26 Sep 2026 07:20:00 GMT" +interval = driver_poll() +assert(interval == 1000, "an ended session retries promptly, got " .. tostring(interval)) +assert(logged("session ended"), "session end is logged") +driver_poll() +assert(fake.logins == 2, "signed in again") +-- The car measured this file at 07:00, now 07:20: 20 minutes, at the limit. +assert(last_row().soc == 63, "reading after renewal") + +-- A new file whose SoC point is two hours old is not a fresh reading. +boot() +fake.date = "Sat, 26 Sep 2026 09:00:00 GMT" +driver_poll() +assert(last_row().soc_fresh == false, "a two-hour-old reading is not fresh") +assert(last_row().stale == true, "and is stale") +assert(host._metrics.vehicle_soc_age_s.value >= 7200, "old age reported") + +-- A wrong password waits 15 minutes before the next try. +boot() +fake.password = "changed" +driver_poll() +assert(#rows() == 0, "no reading without a session") +assert(logged("sign-in failed, next try in 15 min"), "failure is logged") +assert(logged("login.errors.password_invalid"), "VW's reason is logged") +host._millis_counter = host._millis_counter + 300000 +driver_poll() +assert(count("POST " .. SIGNIN .. "/login/authenticate") == 1, "no retry inside the back-off") +fake.password = PASSWORD +host._millis_counter = host._millis_counter + 900000 +driver_poll() +assert(fake.logins == 1 and #rows() == 1, "retries after the back-off and reads") + +-- A redirect off the VW hosts ends the sign-in. +boot() +fake.off_host = true +driver_poll() +assert(fake.logins == 0 and #rows() == 0, "off-host redirect is refused") +assert(count("GET https://evil.example") == 0, "the off-host URL is never requested") + +-- The password never reaches a log line. +for _, line in ipairs(host._logs) do + assert(not line:find(PASSWORD, 1, true) and not line:find("correct%20horse", 1, true), + "password logged: " .. line) +end + +-- Without host.http_request (an older FTW) a pasted cookie still works, and +-- email/password alone explains what is missing. +local saved_request, saved_clear = host.http_request, host.http_cookies_clear +host.http_request, host.http_cookies_clear = nil, nil +boot() +assert(logged("cannot sign in with email and password"), "old host is explained") +driver_poll() +assert(#rows() == 0, "no reading without a way to sign in") +host.http_request, host.http_cookies_clear = saved_request, saved_clear + +print("vag_vehicle login: ok") diff --git a/drivers/tests/test_vag_vehicle.py b/drivers/tests/test_vag_vehicle.py index 9f22573..e81b249 100644 --- a/drivers/tests/test_vag_vehicle.py +++ b/drivers/tests/test_vag_vehicle.py @@ -96,3 +96,17 @@ def test_vag_vehicle_dataset_and_freshness(tmp_path: Path) -> None: check=False, ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_vag_vehicle_email_sign_in(tmp_path: Path) -> None: + path = tmp_path / "deflated.zip" + path.write_bytes(_zip(zipfile.ZIP_DEFLATED)) + result = subprocess.run( + [str(ROOT / "lua55"), "drivers/tests/lua_harness/test_vag_vehicle_login.lua", str(path)], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "vag_vehicle login: ok" in result.stdout diff --git a/index.yaml b/index.yaml index 060c258..e5fcfba 100644 --- a/index.yaml +++ b/index.yaml @@ -758,15 +758,15 @@ drivers: size_bytes: 12472 sha256: "3cf10b93755fa8b840375f53343e91b5efa98ddffe119a5679b43ee4bfb61c88" - name: "vag_vehicle" - version: "0.1.1" + version: "0.2.0" tier: community protocol: http connectivity: cloud setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 28621 - sha256: "907bb6f845765771a026d3b63f2080f941a6225a7fb51789898ee37e890f0856" + size_bytes: 46268 + sha256: "0e6fc2baa2309b983304247a51759c527bb0f06cad2198949c45bd3ee5614b1d" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index 7be15fa..9167b93 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -1,5 +1,5 @@ name: "vag_vehicle" -version: "0.1.1" +version: "0.2.0" tier: community author: "Sourceful Labs AB" protocol: http @@ -13,7 +13,7 @@ tested_devices: variants: [ID.3, ID.4, Enyaq, Q4 e-tron] regions: [EU] firmware_versions: "" - notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet run against the portal or a car." + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request. The driver signs in with the account email and password and renews the session itself on an FTW Core with host.http_request; an older host needs a pasted portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Sign-in follows evcc and is tested against a scripted portal, not yet against the live portal or a car." min_driver_version: "0.1.0" upstream_docs: - url: "https://raw.githubusercontent.com/evcc-io/evcc/master/vehicle/vw/eudataact/datadictionary.json" @@ -21,9 +21,9 @@ upstream_docs: kind: other url_stability: stable min_host_version: "2.0.0" -size_bytes: 28621 +size_bytes: 46268 dkb_id: "vag_vehicle" -sha256: "907bb6f845765771a026d3b63f2080f941a6225a7fb51789898ee37e890f0856" +sha256: "0e6fc2baa2309b983304247a51759c527bb0f06cad2198949c45bd3ee5614b1d" signature: "" bytecode_sha256: "" diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index deb7a8c..a6f3b9d 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -59,6 +59,8 @@ "http_get", "http_post", "http_patch", + "http_request", + "http_cookies_clear", "https_get", "https_post", "json_decode" @@ -87,6 +89,8 @@ "http_get", "http_post", "http_patch", + "http_request", + "http_cookies_clear", "mqtt_pub", "mqtt_publish", "mqtt_sub", diff --git a/support-status.json b/support-status.json index a5fd40a..fdc4b71 100644 --- a/support-status.json +++ b/support-status.json @@ -1724,7 +1724,7 @@ }, { "catalog_source": true, - "catalog_version": "0.1.1", + "catalog_version": "0.2.0", "driver_id": "vag_vehicle", "targets": { "blixt-l1": { diff --git a/tests/test_ftw_repository.py b/tests/test_ftw_repository.py index b6ac002..eb3be89 100644 --- a/tests/test_ftw_repository.py +++ b/tests/test_ftw_repository.py @@ -1025,10 +1025,45 @@ def test_signing_in_is_declared_or_it_does_not_happen( else: assert "auth_post_path" not in driver["metadata"], driver["id"] assert "http.post" not in driver["permissions"], driver["id"] - assert exempt == ["myuplink", "tesla_cloud"], ( + assert exempt == ["myuplink", "tesla_cloud", "vag_vehicle"], ( f"unexpected drivers allowed to POST: {exempt}") +def test_multi_step_sign_in_declares_every_path( + tmp_path: Path, keypair: tuple[str, str] +) -> None: + """A web login posts two forms; both paths are declared, nothing else.""" + manifest, output = build(tmp_path, keypair) + driver = next(d for d in manifest["drivers"] if d["id"] == "vag_vehicle") + artifact = (output / Path(driver["url"]).name).read_text() + # The first path also sits in auth_post_path, which an older Core needs + # before it runs a read-only driver holding http.post. + paths = [driver["metadata"]["auth_post_path"], *driver["metadata"]["auth_post_paths"]] + assert len(paths) == 8 and all(p.startswith("/signin-service/v1/") for p in paths) + assert {p.rsplit("/", 1)[1] for p in paths} == {"identifier", "authenticate"} + assert "http.post" in driver["permissions"] + for path in paths: + assert f'"{path}"' in artifact + # host.http_request is guarded the same way: GET passes, POST only to a + # declared path. + assert "host.http_request = function(opts)" in artifact + assert 'method == "POST" and __sourceful_ftw_is_auth(opts.url)' in artifact + + +def test_http_request_guard_only_where_used( + tmp_path: Path, keypair: tuple[str, str] +) -> None: + """Only a driver that calls host.http_request gets its guard, so adding + the guard did not change any other published artifact.""" + manifest, output = build(tmp_path, keypair) + guarded = [] + for driver in manifest["drivers"]: + artifact = (output / Path(driver["url"]).name).read_text() + if "host.http_request = function(opts)" in artifact: + guarded.append(driver["id"]) + assert guarded == ["vag_vehicle"], guarded + + def test_auth_post_path_must_be_a_path_and_must_mean_something( tmp_path: Path ) -> None: diff --git a/tools/ftw_repository.py b/tools/ftw_repository.py index f9dbe9b..ae25872 100644 --- a/tools/ftw_repository.py +++ b/tools/ftw_repository.py @@ -364,7 +364,7 @@ def _ftw_artifact( raw: bytes, metadata: dict[str, Any], read_only: bool, - auth_post_path: str = "", + auth_post_paths: list[str] | None = None, preserve_controls: bool = False, ) -> bytes: """Add FTW metadata and the host-call polyfills older hosts lack. @@ -379,14 +379,16 @@ def _ftw_artifact( write guards: those are meters and telemetry gateways saying what they are, not a policy imposed on them. - `auth_post_path` is for a driver that can only read once it has signed in. + `auth_post_paths` is for a driver that can only read once it has signed in. Over HTTP a POST is not evidence of actuation -- it is also how a driver exchanges a refresh token -- so denying it outright would cost such a driver every reading it takes. The exemption is scoped rather than trusted: - POST is permitted only to a URL ending in the declared path, and denied + POST is permitted only to a URL ending in a declared path, and denied everywhere else, so the flag enforces "this POST is authentication" instead of merely asserting it. Matching the path rather than a whole URL survives - a site pointing the driver at its own base URL. + a site pointing the driver at its own base URL. A web login posts more + than one form, so a driver may declare several paths. The same rule holds + for a POST through host.http_request. """ protocols = metadata.get("protocols", []) capabilities = metadata.get("capabilities", []) @@ -422,12 +424,15 @@ def _ftw_artifact( # marks control: true keeps the control path it was ported with. write_guards = "" if read_only: - if auth_post_path: + # Only a driver that calls host.http_request gets its guard, so every + # other published artifact stays byte-identical. + uses_http_request = b"http_request" in raw + if len(auth_post_paths or []) == 1 and not uses_http_request: # Sign in, then read. Anything else this driver tries to POST is # refused exactly as if it had no exemption at all. http_post_guard = ( "local __sourceful_ftw_http_post = host.http_post\n" - f"local __sourceful_ftw_auth_path = {_lua_string(auth_post_path)}\n" + f"local __sourceful_ftw_auth_path = {_lua_string(auth_post_paths[0])}\n" "host.http_post = function(url, ...)\n" " local path = type(url) == \"string\" and url:match(\"^[^?]*\") or \"\"\n" " if path:sub(-#__sourceful_ftw_auth_path) == __sourceful_ftw_auth_path then\n" @@ -437,8 +442,43 @@ def _ftw_artifact( "POST is allowed only for authentication\")\n" "end\n" ) + elif auth_post_paths: + http_post_guard = ( + "local __sourceful_ftw_http_post = host.http_post\n" + f"local __sourceful_ftw_auth_paths = {_lua_string_list(auth_post_paths)}\n" + "local function __sourceful_ftw_is_auth(url)\n" + " local path = type(url) == \"string\" and url:match(\"^[^?]*\") or \"\"\n" + " for _, auth in ipairs(__sourceful_ftw_auth_paths) do\n" + " if path:sub(-#auth) == auth then return true end\n" + " end\n" + " return false\n" + "end\n" + "host.http_post = function(url, ...)\n" + " if __sourceful_ftw_is_auth(url) then\n" + " return __sourceful_ftw_http_post(url, ...)\n" + " end\n" + " error(\"this driver declares itself read-only: " + "POST is allowed only for authentication\")\n" + "end\n" + ) else: http_post_guard = "host.http_post = __sourceful_ftw_write_denied\n" + if uses_http_request: + allow_auth = (" or (method == \"POST\" and __sourceful_ftw_is_auth(opts.url))" + if auth_post_paths else "") + http_post_guard += ( + "local __sourceful_ftw_http_request = host.http_request\n" + "if __sourceful_ftw_http_request then\n" + " host.http_request = function(opts)\n" + " local method = type(opts) == \"table\" and string.upper(tostring(opts.method or \"GET\")) or \"\"\n" + f" if method == \"GET\"{allow_auth} then\n" + " return __sourceful_ftw_http_request(opts)\n" + " end\n" + " error(\"this driver declares itself read-only: " + "POST is allowed only for authentication\")\n" + " end\n" + "end\n" + ) write_guards = ( "local function __sourceful_ftw_write_denied()\n" " error(\"this driver declares itself read-only\")\n" @@ -574,10 +614,13 @@ def _load_channel(config_path: Path, repo_root: Path) -> list[dict[str, Any]]: # It names the path its token exchange goes to, and the generated guard # holds it to exactly that -- see _ftw_artifact. auth_post_path = _string_field(body, "auth_post_path") if body else "" - if auth_post_path and not auth_post_path.startswith("/"): - raise RepositoryError( - f"{driver_id}: auth_post_path must be a path beginning with '/'") - if auth_post_path and not declares_read_only: + extra_auth_paths = _string_list_field(body, "auth_post_paths") if body else [] + auth_post_paths = ([auth_post_path] if auth_post_path else []) + extra_auth_paths + for path in auth_post_paths: + if not path.startswith("/"): + raise RepositoryError( + f"{driver_id}: auth_post_path must be a path beginning with '/'") + if auth_post_paths and not declares_read_only: raise RepositoryError( f"{driver_id}: auth_post_path only means anything with read_only") @@ -700,12 +743,14 @@ def _load_channel(config_path: Path, repo_root: Path) -> list[dict[str, Any]]: if auth_post_path and not controls: metadata["auth_post_path"] = auth_post_path + if extra_auth_paths and not controls: + metadata["auth_post_paths"] = extra_auth_paths artifact = _ftw_artifact( raw, metadata, read_only=not controls, - auth_post_path=auth_post_path if not controls else "", + auth_post_paths=auth_post_paths if not controls else [], preserve_controls=controls and declares_controls, ) if len(artifact) > MAX_DRIVER_BYTES: @@ -714,7 +759,7 @@ def _load_channel(config_path: Path, repo_root: Path) -> list[dict[str, Any]]: permissions = list(PROTOCOL_PERMISSIONS[protocol]) if controls: permissions += PROTOCOL_WRITE_PERMISSIONS[protocol] - elif auth_post_path: + elif auth_post_paths: # Read-only, but it cannot read a thing until it has signed in. permissions += PROTOCOL_WRITE_PERMISSIONS[protocol] @@ -820,7 +865,7 @@ def _validate_manifest(manifest: dict[str, Any]) -> None: # that POST to auth_post_path, so the permission cannot reach further # than the token exchange it was granted for. allowed_write = set() - if metadata.get("auth_post_path"): + if metadata.get("auth_post_path") or metadata.get("auth_post_paths"): allowed_write = {"http.post"} if read_only and write_permissions.intersection(permissions) - allowed_write: raise RepositoryError(f"{driver_id}: read-only driver has a write-capable permission")