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
|
// Until then, plugins are added at runtime as the "external" HTTP kind, which
|
||||||
// needs no rebuild. See ../README.md.
|
// needs no rebuild. See ../README.md.
|
||||||
package builtin
|
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