Add a built-in Toyota Connected Europe plugin
Port pytoyoda's authentication and read-only data flow to a Go builtin plugin behind the existing plugin contract. Implements the three-legged ForgeRock/OAuth2 login (authenticate callback loop, authorize, token exchange), silent refresh with full re-auth fallback, and the full Toyota gateway header set with backoff on 429/5xx. Exposes read-only capabilities: vehicles, telemetry, location, health, status, electric, notifications, and service-history. Config takes a MyToyota email/password and a Toyota/Lexus brand select. Europe-only and read-only, matching pytoyoda's limitations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b6bb6b1df0
commit
5a729abd71
@@ -10,3 +10,8 @@
|
||||
// Until then, plugins are added at runtime as the "external" HTTP kind, which
|
||||
// needs no rebuild. See ../README.md.
|
||||
package builtin
|
||||
|
||||
import (
|
||||
// toyota — Toyota Connected Europe (MyToyota) read-only vehicle data.
|
||||
_ "drivervault/apiserver/internal/plugins/builtin/toyota"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
// 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.
|
||||
// - 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.
|
||||
const (
|
||||
epVehicleGUID = "/v2/vehicle/guid"
|
||||
epLocation = "/v1/location"
|
||||
epHealthStatus = "/v1/vehiclehealth/status"
|
||||
epRemoteStatus = "/v1/global/remote/status"
|
||||
epElectricStatus = "/v1/global/remote/electric/status"
|
||||
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.1.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryAPIsExternal,
|
||||
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: "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{
|
||||
{Key: "username", Label: "MyToyota email", Type: "text", Required: true,
|
||||
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
|
||||
{Key: "password", Label: "MyToyota password", Type: "password", Required: true, 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 "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
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package toyota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
d := (&Plugin{}).Descriptor()
|
||||
if d.Name != "toyota" {
|
||||
t.Fatalf("name = %q, want toyota", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// Required credential fields must be present.
|
||||
req := map[string]bool{}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Required {
|
||||
req[f.Key] = true
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"username", "password"} {
|
||||
if !req[k] {
|
||||
t.Errorf("config field %q should be required", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
// The plugin must self-register via init() so the manager can construct it.
|
||||
var found bool
|
||||
for _, v := range plugins.NewManager(t.TempDir()+"/plugins.json").List() {
|
||||
if v.Name == "toyota" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("toyota not registered with the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMACSHA256(t *testing.T) {
|
||||
// Mirrors pytoyoda: hmac.new(b"2.14.0", b"abc", sha256).hexdigest().
|
||||
got := hmacSHA256("2.14.0", "abc")
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("hex digest length = %d, want 64", len(got))
|
||||
}
|
||||
// Deterministic: same key/message → same digest.
|
||||
if got != hmacSHA256("2.14.0", "abc") {
|
||||
t.Fatal("hmac not deterministic")
|
||||
}
|
||||
// Key matters: a different client version changes the digest.
|
||||
if got == hmacSHA256("9.9.9", "abc") {
|
||||
t.Fatal("hmac ignored the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTUUID(t *testing.T) {
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"uuid":"abc-123","aud":"oneappsdkclient"}`))
|
||||
token := "header." + payload + ".sig"
|
||||
uuid, err := jwtUUID(token)
|
||||
if err != nil {
|
||||
t.Fatalf("jwtUUID: %v", err)
|
||||
}
|
||||
if uuid != "abc-123" {
|
||||
t.Fatalf("uuid = %q, want abc-123", uuid)
|
||||
}
|
||||
|
||||
if _, err := jwtUUID("not-a-jwt"); err == nil {
|
||||
t.Error("expected error for malformed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCode(t *testing.T) {
|
||||
loc := "com.toyota.oneapp:/oauth2Callback?code=AUTHCODE123&state=xyz"
|
||||
if got := extractCode(loc); got != "AUTHCODE123" {
|
||||
t.Fatalf("code = %q, want AUTHCODE123", got)
|
||||
}
|
||||
if got := extractCode("com.toyota.oneapp:/oauth2Callback"); got != "" {
|
||||
t.Fatalf("expected empty code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackHelpers(t *testing.T) {
|
||||
// A NameCallback shaped like ForgeRock's response.
|
||||
var cb map[string]any
|
||||
_ = json.Unmarshal([]byte(`{
|
||||
"type":"NameCallback",
|
||||
"output":[{"name":"prompt","value":"User Name"}],
|
||||
"input":[{"name":"IDToken1","value":""}]
|
||||
}`), &cb)
|
||||
|
||||
if output0(cb) != "User Name" {
|
||||
t.Fatalf("output0 = %q", output0(cb))
|
||||
}
|
||||
setInput0(cb, "driver@example.com")
|
||||
in := cb["input"].([]any)[0].(map[string]any)
|
||||
if in["value"] != "driver@example.com" {
|
||||
t.Fatalf("input value = %v, want driver@example.com", in["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeRequiresVIN(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p"})
|
||||
|
||||
if _, err := p.Invoke(context.Background(), "telemetry", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "requires a vin") {
|
||||
t.Fatalf("expected vin-required error, got %v", err)
|
||||
}
|
||||
if _, err := p.Invoke(context.Background(), "bogus", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "unknown action") {
|
||||
t.Fatalf("expected unknown-action error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user