// Package toyota is a built-in connector for Toyota Connected Europe Services — // the backend behind the MyToyota app. 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" accessTokenURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/access_token" authenticateURL = "https://b2c-login.toyota-europe.com/json/realms/root/realms/tme/authenticate?authIndexType=service&authIndexValue=oneapp" authorizeURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/authorize?client_id=oneapp&scope=openid+profile+write&response_type=code&redirect_uri=com.toyota.oneapp:/oauth2Callback&code_challenge=plain&code_challenge_method=plain" redirectURI = "com.toyota.oneapp:/oauth2Callback" clientVersion = "2.14.0" // apiKey is the app's public API-gateway key (not the user's secret). apiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF" // basicAuth is base64("oneapp:oneapp") — the OAuth client's basic credentials. basicAuth = "basic b25lYXBwOm9uZWFwcA==" userAgent = "okhttp/4.10.0" ) // 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 route was not moved and stays where it was. const ( epVehicleGUID = "/v2/vehicle/guid" epLocation = "/v1/location" epHealthStatus = "/v1/vehiclehealth/status" epRemoteStatus = "/v1/vehicle/status" epElectricStatus = "/v1/global/remote/electric/status" epClimateStatus = "/v1/vehicle/climate-status" epClimateSettings = "/v1/vehicle/climate-settings" epTelemetry = "/v3/telemetry" epNotifications = "/v2/notification/history" epServiceHistory = "/v1/servicehistory/vehicle/summary" ) // 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) or "L" (Lexus) 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.2.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.", Options: []plugins.SelectOption{ {Value: "T", Label: "Toyota"}, {Value: "L", Label: "Lexus"}, }}, }, } } // 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" } // 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, _, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil) if err != nil { return nil, err } return json.RawMessage(body), nil } // 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, 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, 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", "oneapp") form.Set("code", code) form.Set("redirect_uri", 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", "oneapp") form.Set("redirect_uri", 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, accessTokenURL, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("authorization", 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") set("authorization", "Bearer "+tok.accessToken) set("user-agent", userAgent) if p.brand == "L" { set("x-appbrand", "L") set("brand", "L") } 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 }