pytoyoda went from 5.2.7 to 6.0.0. Two changes cross over to a connector that
returns raw JSON.
The electric status read was the one route left on /v1/global/remote after the
mid-2026 retirement. Toyota has now fenced it the same way, and the app reads
the same data from /v1/vehicle/electric/status; that is where the electric
capability now points. Upstream took the route from a capture of the app and
has proved it only with unit tests, so the old route is kept: when the new one
answers 403 or 404, the read is tried once on the old one before it fails.
Upstream also widened the electric gate from fuelType "E" to "E" or "I", since
"I" is Toyota's code for a plug-in hybrid. The car importer passed the code
through the generic normalizer, which knows neither letter, so an imported
Toyota came in without a fuel type. "E" now imports as electric and "I" as
hybrid; other letters stay unset because nothing pins them down.
The plugin is now 0.4.0.
Left where they were: phevUsableBatteryLevel, a new field on the electric
payload, already reaches the card because the payload is passed through
whole. The trip list has stopped returning routes, which are now one call per
trip on /v1/trips/{id}; this connector has never read trips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
770 lines
26 KiB
Go
770 lines
26 KiB
Go
// Package toyota is a built-in connector for Toyota Connected Europe Services —
|
|
// the backend behind the MyToyota app, and behind the Lexus and Subaru (Solterra)
|
|
// apps that share it. It is a Go re-implementation of the authentication and
|
|
// read-only data flow from the pytoyoda project
|
|
// (https://github.com/pytoyoda/pytoyoda), adapted to DriverVault's plugin
|
|
// contract.
|
|
//
|
|
// Scope & limitations (inherited from pytoyoda):
|
|
// - Europe only. Other Toyota regions use a different backend and are not
|
|
// supported.
|
|
// - Read-only. Only vehicle information is retrieved; no remote control
|
|
// (lock, climate, charging) commands are implemented, and neither are the
|
|
// POST wake calls upstream uses to refresh a stale reading — climate and
|
|
// status are read as the car last reported them.
|
|
// - Unofficial. This talks to a private API with hardcoded app credentials
|
|
// extracted from the MyToyota app; Toyota may change or break it at any time.
|
|
//
|
|
// Authentication is a three-legged OAuth2 (ForgeRock/OpenAM) flow:
|
|
// 1. POST the /authenticate endpoint in a callback loop, filling in the
|
|
// username then password, until a tokenId is returned.
|
|
// 2. GET the /authorize endpoint with that tokenId as a cookie; the 302
|
|
// redirect carries the authorization code in its Location query string.
|
|
// 3. POST the /access_token endpoint to exchange the code for an access +
|
|
// refresh + id token. The id token (a JWT) carries the account UUID.
|
|
//
|
|
// Access tokens are short-lived and refreshed with the refresh token; a full
|
|
// re-authentication is the fallback when refresh fails.
|
|
package toyota
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/plugins"
|
|
)
|
|
|
|
// Upstream URLs and hardcoded app credentials. These mirror pytoyoda's const.py
|
|
// and are the values baked into the official MyToyota Android app (client
|
|
// "oneapp"); they are not user secrets.
|
|
const (
|
|
apiBaseURL = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io"
|
|
loginBase = "https://b2c-login.toyota-europe.com"
|
|
|
|
clientVersion = "2.14.0"
|
|
// apiKey is the app's public API-gateway key (not the user's secret).
|
|
apiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF"
|
|
userAgent = "okhttp/4.10.0"
|
|
)
|
|
|
|
// brandAuth is the OAuth client an account lives under. Toyota and Lexus share
|
|
// the "tme" realm and the "oneapp" client; Subaru's Solterra is sold through the
|
|
// same European backend but logs in under its own realm and client on the same
|
|
// host. Everything past the login — the data API, the headers, the endpoints —
|
|
// is common, which is why one connector serves all three.
|
|
type brandAuth struct {
|
|
clientID string
|
|
redirectURI string
|
|
// basicAuth is base64(clientID:secret) — the OAuth client's own credentials,
|
|
// baked into the app.
|
|
basicAuth string
|
|
|
|
accessTokenURL string
|
|
authenticateURL string
|
|
authorizeURL string
|
|
}
|
|
|
|
// newBrandAuth derives a realm's three login URLs from the parts that differ.
|
|
// The shape is ForgeRock's, so it is the same for every realm on the host.
|
|
func newBrandAuth(realm, clientID, redirectURI, basicAuth string) brandAuth {
|
|
return brandAuth{
|
|
clientID: clientID,
|
|
redirectURI: redirectURI,
|
|
basicAuth: basicAuth,
|
|
accessTokenURL: loginBase + "/oauth2/realms/root/realms/" + realm + "/access_token",
|
|
authenticateURL: loginBase + "/json/realms/root/realms/" + realm + "/authenticate?authIndexType=service&authIndexValue=oneapp",
|
|
authorizeURL: loginBase + "/oauth2/realms/root/realms/" + realm + "/authorize?client_id=" + clientID +
|
|
"&scope=openid+profile+write&response_type=code&redirect_uri=" + redirectURI +
|
|
"&code_challenge=plain&code_challenge_method=plain",
|
|
}
|
|
}
|
|
|
|
var (
|
|
// toyotaAuth is base64("oneapp:oneapp").
|
|
toyotaAuth = newBrandAuth("tme", "oneapp", "com.toyota.oneapp:/oauth2Callback",
|
|
"basic b25lYXBwOm9uZWFwcA==")
|
|
subaruAuth = newBrandAuth("alliance-subaru", "8c4921b0b08901fef389ce1af49c4e10.subaru.com", "com.subaru.oneapp:/oauth2Callback",
|
|
"basic OGM0OTIxYjBiMDg5MDFmZWYzODljZTFhZjQ5YzRlMTAuc3ViYXJ1LmNvbTpJaGNkcjV4YmhIYlRSMk9aOGdRa3YyNTZicmhTYjc=")
|
|
)
|
|
|
|
// authFor picks the OAuth client for a brand code. Only Subaru has its own;
|
|
// Toyota, Lexus and anything unrecognised log in as the MyToyota app does.
|
|
func authFor(brand string) brandAuth {
|
|
if brand == "S" {
|
|
return subaruAuth
|
|
}
|
|
return toyotaAuth
|
|
}
|
|
|
|
// Read-only API endpoints exposed as capabilities.
|
|
//
|
|
// Toyota retired the /v1/global/remote/{status,climate-*} read routes in mid-2026
|
|
// — they now sit behind AWS SigV4 and answer a plain Bearer token with 403 — and
|
|
// the app reads that state from /v1/vehicle/* instead. epRemoteStatus and the two
|
|
// climate endpoints below follow pytoyoda onto the new namespace. The electric
|
|
// status read followed in September 2026 (pytoyoda 6.0.0, from a capture of the
|
|
// app); upstream has not proven it against a live car, so epElectricStatusLegacy
|
|
// is kept as the fallback when the new route refuses.
|
|
const (
|
|
epVehicleGUID = "/v2/vehicle/guid"
|
|
epLocation = "/v1/location"
|
|
epHealthStatus = "/v1/vehiclehealth/status"
|
|
epRemoteStatus = "/v1/vehicle/status"
|
|
epElectricStatus = "/v1/vehicle/electric/status"
|
|
epClimateStatus = "/v1/vehicle/climate-status"
|
|
epClimateSettings = "/v1/vehicle/climate-settings"
|
|
epTelemetry = "/v3/telemetry"
|
|
epNotifications = "/v2/notification/history"
|
|
epServiceHistory = "/v1/servicehistory/vehicle/summary"
|
|
|
|
epElectricStatusLegacy = "/v1/global/remote/electric/status"
|
|
)
|
|
|
|
// tokenExpiryMargin is subtracted from the reported token lifetime so a request
|
|
// never fires with a token that expires mid-flight.
|
|
const tokenExpiryMargin = 30 * time.Second
|
|
|
|
func init() {
|
|
plugins.Register("toyota", func() plugins.Plugin { return &Plugin{} })
|
|
}
|
|
|
|
// tokenInfo holds the currently-held OAuth tokens and the derived account UUID.
|
|
type tokenInfo struct {
|
|
accessToken string
|
|
refreshToken string
|
|
uuid string
|
|
expiration time.Time
|
|
}
|
|
|
|
// Plugin is the Toyota Connected Europe connector.
|
|
type Plugin struct {
|
|
username string
|
|
password string
|
|
brand string // "T" (Toyota), "L" (Lexus) or "S" (Subaru)
|
|
auth brandAuth // the OAuth client for brand, fixed at Init
|
|
|
|
mu sync.Mutex // guards tok and the login flow
|
|
tok *tokenInfo
|
|
|
|
// authClient does NOT follow redirects — the authorize step must see the
|
|
// raw 302 to read the code out of the Location header.
|
|
authClient *http.Client
|
|
// dataClient follows redirects for ordinary data requests.
|
|
dataClient *http.Client
|
|
}
|
|
|
|
// Descriptor returns the plugin's static metadata for the admin panel.
|
|
func (p *Plugin) Descriptor() plugins.Descriptor {
|
|
return plugins.Descriptor{
|
|
Name: "toyota",
|
|
Provider: "Toyota Connected Europe",
|
|
Version: "0.4.0",
|
|
Kind: plugins.KindBuiltin,
|
|
Category: plugins.CategoryVehicles,
|
|
AuthType: plugins.AuthOAuth2,
|
|
Capabilities: []plugins.Capability{
|
|
{ID: "vehicles", Method: "GET", Endpoint: epVehicleGUID, Description: "List vehicles registered to the account."},
|
|
{ID: "telemetry", Method: "GET", Endpoint: epTelemetry, Description: "Odometer, fuel level and range for a VIN."},
|
|
{ID: "location", Method: "GET", Endpoint: epLocation, Description: "Last known parked location for a VIN."},
|
|
{ID: "health", Method: "GET", Endpoint: epHealthStatus, Description: "Vehicle health / dashboard warning lights for a VIN."},
|
|
{ID: "status", Method: "GET", Endpoint: epRemoteStatus, Description: "General remote status (doors, windows, lights) for a VIN."},
|
|
{ID: "electric", Method: "GET", Endpoint: epElectricStatus, Description: "Battery level, EV range and charging status for a VIN."},
|
|
{ID: "climate", Method: "GET", Endpoint: epClimateStatus, Description: "Current climate state (cabin temperature, whether it is running) for a VIN."},
|
|
{ID: "climate-settings", Method: "GET", Endpoint: epClimateSettings, Description: "Stored climate preset (target temperature, seat and mirror heating) for a VIN."},
|
|
{ID: "notifications", Method: "GET", Endpoint: epNotifications, Description: "Notification history for a VIN."},
|
|
{ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."},
|
|
},
|
|
ConfigFields: []plugins.ConfigField{
|
|
// Credentials are intentionally NOT required at the global (panel) layer:
|
|
// this connector runs under each user's own MyToyota account, supplied in
|
|
// the Web App's per-user integration settings. A superadmin/org admin may
|
|
// still set a shared (e.g. fleet) account here that users inherit. See the
|
|
// cascade in internal/api/integrations.go.
|
|
{Key: "username", Label: "MyToyota email", Type: "text",
|
|
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
|
|
{Key: "password", Label: "MyToyota password", Type: "password", Secret: true,
|
|
Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
|
|
{Key: "brand", Label: "Brand", Type: "select", Default: "T",
|
|
Help: "Vehicle brand tied to the account. Subaru accounts (Solterra) log in through their own realm on the same service.",
|
|
Options: []plugins.SelectOption{
|
|
{Value: "T", Label: "Toyota"},
|
|
{Value: "L", Label: "Lexus"},
|
|
{Value: "S", Label: "Subaru"},
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Init applies resolved config and builds the HTTP clients. It performs no
|
|
// network I/O; login happens lazily on the first request or health check.
|
|
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.username = strings.TrimSpace(config["username"])
|
|
p.password = config["password"]
|
|
p.brand = strings.TrimSpace(config["brand"])
|
|
if p.brand == "" {
|
|
p.brand = "T"
|
|
}
|
|
p.auth = authFor(p.brand)
|
|
// Config change invalidates any cached token.
|
|
p.tok = nil
|
|
|
|
p.authClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
p.dataClient = &http.Client{Timeout: 30 * time.Second}
|
|
return nil
|
|
}
|
|
|
|
// HealthCheck logs in (if needed) and lists the account's vehicles.
|
|
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
|
start := time.Now()
|
|
body, _, err := p.apiRequest(ctx, http.MethodGet, epVehicleGUID, "", nil)
|
|
lat := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())}
|
|
}
|
|
|
|
detail := "authenticated; vehicles reachable"
|
|
if n, ok := countVehicles(body); ok {
|
|
detail = fmt.Sprintf("authenticated; %d vehicle(s) on account", n)
|
|
}
|
|
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
|
|
}
|
|
|
|
// Invoke runs a named read-only capability. Every capability except "vehicles"
|
|
// requires a "vin" in params. The upstream JSON is returned verbatim.
|
|
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
|
var pp struct {
|
|
VIN string `json:"vin"`
|
|
}
|
|
if len(params) > 0 {
|
|
if err := json.Unmarshal(params, &pp); err != nil {
|
|
return nil, fmt.Errorf("toyota: invalid params: %w", err)
|
|
}
|
|
}
|
|
pp.VIN = strings.TrimSpace(pp.VIN)
|
|
|
|
var endpoint string
|
|
needVIN := true
|
|
switch action {
|
|
case "vehicles":
|
|
endpoint, needVIN = epVehicleGUID, false
|
|
case "telemetry":
|
|
endpoint = epTelemetry
|
|
case "location":
|
|
endpoint = epLocation
|
|
case "health":
|
|
endpoint = epHealthStatus
|
|
case "status":
|
|
endpoint = epRemoteStatus
|
|
case "electric":
|
|
endpoint = epElectricStatus
|
|
case "climate":
|
|
endpoint = epClimateStatus
|
|
case "climate-settings":
|
|
endpoint = epClimateSettings
|
|
case "notifications":
|
|
endpoint = epNotifications
|
|
case "service-history":
|
|
endpoint = epServiceHistory
|
|
default:
|
|
return nil, fmt.Errorf("toyota: unknown action %q", action)
|
|
}
|
|
if needVIN && pp.VIN == "" {
|
|
return nil, fmt.Errorf("toyota: action %q requires a vin", action)
|
|
}
|
|
|
|
body, status, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil)
|
|
if err != nil && endpoint == epElectricStatus && electricFallback(status) {
|
|
body, _, err = p.apiRequest(ctx, http.MethodGet, epElectricStatusLegacy, pp.VIN, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.RawMessage(body), nil
|
|
}
|
|
|
|
// electricFallback reports whether a failed read of the new electric route should
|
|
// be retried on the old one: a 403 is the gateway's answer for a route a token
|
|
// may not use, a 404 one it does not know. Anything else would fail there too.
|
|
func electricFallback(status int) bool {
|
|
return status == http.StatusForbidden || status == http.StatusNotFound
|
|
}
|
|
|
|
// Shutdown releases pooled connections.
|
|
func (p *Plugin) Shutdown(context.Context) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.dataClient != nil {
|
|
p.dataClient.CloseIdleConnections()
|
|
}
|
|
if p.authClient != nil {
|
|
p.authClient.CloseIdleConnections()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- token management --------------------------------------------------------
|
|
|
|
// ensureToken guarantees a non-expired access token, refreshing or performing a
|
|
// full authentication as needed. Serialized by p.mu so concurrent requests
|
|
// trigger at most one login.
|
|
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.tok != nil && time.Now().UTC().Before(p.tok.expiration) {
|
|
return *p.tok, nil
|
|
}
|
|
if p.tok != nil && p.tok.refreshToken != "" {
|
|
if err := p.refreshTokens(ctx); err == nil {
|
|
return *p.tok, nil
|
|
}
|
|
// fall through to a full re-authentication
|
|
}
|
|
if err := p.authenticate(ctx); err != nil {
|
|
return tokenInfo{}, err
|
|
}
|
|
return *p.tok, nil
|
|
}
|
|
|
|
// authenticate runs the full three-legged login. Caller holds p.mu.
|
|
func (p *Plugin) authenticate(ctx context.Context) error {
|
|
if p.username == "" || p.password == "" {
|
|
return errors.New("toyota: username and password are required")
|
|
}
|
|
tokenID, err := p.performAuthentication(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
code, err := p.performAuthorization(ctx, tokenID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.retrieveTokens(ctx, code)
|
|
}
|
|
|
|
// performAuthentication drives the ForgeRock callback loop and returns the
|
|
// tokenId. The response object is echoed back in full each round (preserving
|
|
// authId and any fields we don't model), with only the input values filled in.
|
|
func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
|
|
var data map[string]any
|
|
|
|
for i := 0; i < 10; i++ {
|
|
if cbs, ok := data["callbacks"].([]any); ok {
|
|
for _, c := range cbs {
|
|
cb, ok := c.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch typ, _ := cb["type"].(string); typ {
|
|
case "NameCallback":
|
|
if output0(cb) == "User Name" {
|
|
setInput0(cb, p.username)
|
|
}
|
|
case "PasswordCallback":
|
|
setInput0(cb, p.password)
|
|
case "TextOutputCallback":
|
|
if output0(cb) == "User Not Found" {
|
|
return "", errors.New("toyota: authentication failed — user not found")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
payload := []byte("{}")
|
|
if data != nil {
|
|
b, err := json.Marshal(data)
|
|
if err != nil {
|
|
return "", fmt.Errorf("toyota: encode auth payload: %w", err)
|
|
}
|
|
payload = b
|
|
}
|
|
|
|
resp, err := p.postRaw(ctx, p.auth.authenticateURL, "application/json", payload, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body, status := resp.body, resp.status
|
|
if status != http.StatusOK {
|
|
return "", fmt.Errorf("toyota: authentication failed (HTTP %d): %s", status, shorten(string(body)))
|
|
}
|
|
data = map[string]any{}
|
|
if err := json.Unmarshal(body, &data); err != nil {
|
|
return "", fmt.Errorf("toyota: decode auth response: %w", err)
|
|
}
|
|
if tid, ok := data["tokenId"].(string); ok && tid != "" {
|
|
return tid, nil
|
|
}
|
|
}
|
|
return "", errors.New("toyota: authentication failed — no tokenId after 10 rounds")
|
|
}
|
|
|
|
// performAuthorization exchanges the tokenId (as an SSO cookie) for an
|
|
// authorization code carried in the 302 Location header.
|
|
func (p *Plugin) performAuthorization(ctx context.Context, tokenID string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.auth.authorizeURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("cookie", "iPlanetDirectoryPro="+tokenID)
|
|
|
|
resp, err := p.authClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("toyota: authorize request: %w", err)
|
|
}
|
|
defer drain(resp)
|
|
|
|
if resp.StatusCode != http.StatusFound {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
|
return "", fmt.Errorf("toyota: authorization failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
|
|
}
|
|
code := extractCode(resp.Header.Get("location"))
|
|
if code == "" {
|
|
return "", errors.New("toyota: authorization redirect carried no code")
|
|
}
|
|
return code, nil
|
|
}
|
|
|
|
// retrieveTokens exchanges the authorization code for the token set and stores
|
|
// it. Caller holds p.mu.
|
|
func (p *Plugin) retrieveTokens(ctx context.Context, code string) error {
|
|
form := url.Values{}
|
|
form.Set("client_id", p.auth.clientID)
|
|
form.Set("code", code)
|
|
form.Set("redirect_uri", p.auth.redirectURI)
|
|
form.Set("grant_type", "authorization_code")
|
|
form.Set("code_verifier", "plain")
|
|
return p.tokenRequest(ctx, form)
|
|
}
|
|
|
|
// refreshTokens uses the stored refresh token to obtain a fresh access token.
|
|
// Caller holds p.mu.
|
|
func (p *Plugin) refreshTokens(ctx context.Context) error {
|
|
if p.tok == nil || p.tok.refreshToken == "" {
|
|
return errors.New("toyota: no refresh token")
|
|
}
|
|
form := url.Values{}
|
|
form.Set("client_id", p.auth.clientID)
|
|
form.Set("redirect_uri", p.auth.redirectURI)
|
|
form.Set("grant_type", "refresh_token")
|
|
form.Set("code_verifier", "plain")
|
|
form.Set("refresh_token", p.tok.refreshToken)
|
|
return p.tokenRequest(ctx, form)
|
|
}
|
|
|
|
// tokenRequest POSTs a form to the access-token endpoint and stores the result.
|
|
func (p *Plugin) tokenRequest(ctx context.Context, form url.Values) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.auth.accessTokenURL, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("authorization", p.auth.basicAuth)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := p.authClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("toyota: token request: %w", err)
|
|
}
|
|
defer drain(resp)
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("toyota: token request failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
|
|
}
|
|
return p.storeTokens(body)
|
|
}
|
|
|
|
// storeTokens parses a token response, decodes the account UUID from the id
|
|
// token, and updates p.tok. Caller holds p.mu.
|
|
func (p *Plugin) storeTokens(body []byte) error {
|
|
var td struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(body, &td); err != nil {
|
|
return fmt.Errorf("toyota: decode token response: %w", err)
|
|
}
|
|
var missing []string
|
|
if td.AccessToken == "" {
|
|
missing = append(missing, "access_token")
|
|
}
|
|
if td.IDToken == "" {
|
|
missing = append(missing, "id_token")
|
|
}
|
|
if td.RefreshToken == "" {
|
|
missing = append(missing, "refresh_token")
|
|
}
|
|
if td.ExpiresIn == 0 {
|
|
missing = append(missing, "expires_in")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("toyota: token response missing fields: %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
uuid, err := jwtUUID(td.IDToken)
|
|
if err != nil {
|
|
return fmt.Errorf("toyota: %w", err)
|
|
}
|
|
|
|
lifetime := time.Duration(td.ExpiresIn)*time.Second - tokenExpiryMargin
|
|
if lifetime < 0 {
|
|
lifetime = 0
|
|
}
|
|
p.tok = &tokenInfo{
|
|
accessToken: td.AccessToken,
|
|
refreshToken: td.RefreshToken,
|
|
uuid: uuid,
|
|
expiration: time.Now().UTC().Add(lifetime),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- data requests -----------------------------------------------------------
|
|
|
|
// apiRequest performs an authenticated request against the data API, retrying
|
|
// transient failures (429 and 5xx) with exponential backoff. It returns the
|
|
// response body and status; a non-2xx final status is returned as an error.
|
|
func (p *Plugin) apiRequest(ctx context.Context, method, endpoint, vin string, params url.Values) ([]byte, int, error) {
|
|
tok, err := p.ensureToken(ctx)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
target := apiBaseURL + endpoint
|
|
if len(params) > 0 {
|
|
target += "?" + params.Encode()
|
|
}
|
|
|
|
backoffs := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
|
|
var lastBody []byte
|
|
var lastStatus int
|
|
|
|
for attempt := 0; attempt <= len(backoffs); attempt++ {
|
|
req, err := http.NewRequestWithContext(ctx, method, target, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
p.applyHeaders(req.Header, vin, tok)
|
|
|
|
resp, err := p.dataClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("toyota: request %s: %w", endpoint, err)
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
drain(resp)
|
|
lastBody, lastStatus = body, resp.StatusCode
|
|
|
|
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted {
|
|
return body, resp.StatusCode, nil
|
|
}
|
|
|
|
transient := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError
|
|
if !transient || attempt == len(backoffs) {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, lastStatus, ctx.Err()
|
|
case <-time.After(backoffs[attempt]):
|
|
}
|
|
}
|
|
return lastBody, lastStatus, fmt.Errorf("toyota: request %s failed (HTTP %d): %s", endpoint, lastStatus, shorten(string(lastBody)))
|
|
}
|
|
|
|
// applyHeaders writes the full set of headers Toyota's gateway expects. Keys are
|
|
// set directly on the map (lowercase, uncanonicalized) to match the app exactly
|
|
// on HTTP/1.1; HTTP/2 lowercases all header names regardless.
|
|
func (p *Plugin) applyHeaders(h http.Header, vin string, tok tokenInfo) {
|
|
set := func(k, v string) { h[k] = []string{v} }
|
|
set("x-api-key", apiKey)
|
|
set("API_KEY", apiKey)
|
|
set("x-guid", tok.uuid)
|
|
set("guid", tok.uuid)
|
|
set("x-client-ref", hmacSHA256(clientVersion, tok.uuid))
|
|
set("x-correlationid", genUUID())
|
|
set("x-appversion", clientVersion)
|
|
set("x-channel", "ONEAPP")
|
|
set("x-brand", p.brand)
|
|
set("x-region", "EU")
|
|
// x-user-region mirrors x-region. The gateway only insists on it for remote
|
|
// commands on Suzuki-badged DCM24 cars (CTP-GENERIC-40017 otherwise), which
|
|
// this connector never sends, but the app sets it on every call and the
|
|
// reads are cheaper to keep identical to the app than to keep distinct.
|
|
set("x-user-region", "EU")
|
|
set("authorization", "Bearer "+tok.accessToken)
|
|
set("user-agent", userAgent)
|
|
// The non-Toyota brands name themselves twice more; a Toyota account sends
|
|
// neither header.
|
|
if p.brand == "L" || p.brand == "S" {
|
|
set("x-appbrand", p.brand)
|
|
set("brand", p.brand)
|
|
}
|
|
if vin != "" {
|
|
set("vin", vin)
|
|
}
|
|
}
|
|
|
|
// ---- small helpers -----------------------------------------------------------
|
|
|
|
// rawResponse is a fully-read response used by the auth flow.
|
|
type rawResponse struct {
|
|
status int
|
|
body []byte
|
|
}
|
|
|
|
// postRaw POSTs a body and reads the whole response. When followRedirects is
|
|
// false the no-redirect authClient is used.
|
|
func (p *Plugin) postRaw(ctx context.Context, u, contentType string, body []byte, followRedirects bool) (rawResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return rawResponse{}, err
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
|
|
client := p.authClient
|
|
if followRedirects {
|
|
client = p.dataClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return rawResponse{}, fmt.Errorf("toyota: POST %s: %w", u, err)
|
|
}
|
|
defer drain(resp)
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return rawResponse{status: resp.StatusCode, body: b}, nil
|
|
}
|
|
|
|
// output0 returns callback.output[0].value.
|
|
func output0(cb map[string]any) string {
|
|
out, _ := cb["output"].([]any)
|
|
if len(out) == 0 {
|
|
return ""
|
|
}
|
|
m, _ := out[0].(map[string]any)
|
|
v, _ := m["value"].(string)
|
|
return v
|
|
}
|
|
|
|
// setInput0 sets callback.input[0].value in place.
|
|
func setInput0(cb map[string]any, val string) {
|
|
in, _ := cb["input"].([]any)
|
|
if len(in) == 0 {
|
|
return
|
|
}
|
|
if m, ok := in[0].(map[string]any); ok {
|
|
m["value"] = val
|
|
}
|
|
}
|
|
|
|
// extractCode pulls the ?code= parameter out of a redirect Location, tolerating
|
|
// the app's custom URI scheme (com.toyota.oneapp:/oauth2Callback?code=…).
|
|
func extractCode(location string) string {
|
|
i := strings.IndexByte(location, '?')
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
vals, err := url.ParseQuery(location[i+1:])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return vals.Get("code")
|
|
}
|
|
|
|
// jwtUUID base64url-decodes a JWT's payload (without signature verification, as
|
|
// pytoyoda does) and returns its "uuid" claim.
|
|
func jwtUUID(idToken string) (string, error) {
|
|
parts := strings.Split(idToken, ".")
|
|
if len(parts) < 2 {
|
|
return "", errors.New("malformed id token")
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
// tolerate padded encodings
|
|
if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
|
|
return "", fmt.Errorf("decode id token: %w", err)
|
|
}
|
|
}
|
|
var claims struct {
|
|
UUID string `json:"uuid"`
|
|
}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return "", fmt.Errorf("decode id token claims: %w", err)
|
|
}
|
|
if claims.UUID == "" {
|
|
return "", errors.New("id token has no uuid claim")
|
|
}
|
|
return claims.UUID, nil
|
|
}
|
|
|
|
// hmacSHA256 returns the lowercase hex HMAC-SHA256 of message under key.
|
|
func hmacSHA256(key, message string) string {
|
|
m := hmac.New(sha256.New, []byte(key))
|
|
m.Write([]byte(message))
|
|
return hex.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
// genUUID returns a random RFC-4122 v4 UUID string.
|
|
func genUUID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand should never fail; fall back to a fixed correlation id.
|
|
return "00000000-0000-4000-8000-000000000000"
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
// countVehicles best-effort extracts the vehicle count from a /vehicle/guid
|
|
// response. The payload is an array of vehicle objects under "payload".
|
|
func countVehicles(body []byte) (int, bool) {
|
|
var env struct {
|
|
Payload []json.RawMessage `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(body, &env); err != nil || env.Payload == nil {
|
|
return 0, false
|
|
}
|
|
return len(env.Payload), true
|
|
}
|
|
|
|
// drain closes a response body after discarding any remainder so the connection
|
|
// can be reused.
|
|
func drain(resp *http.Response) {
|
|
if resp != nil && resp.Body != nil {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// shorten trims long/multiline upstream messages for health details and errors.
|
|
func shorten(s string) string {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
|
if len(s) > 200 {
|
|
return s[:200] + "…"
|
|
}
|
|
return s
|
|
}
|