Toyota: the Subaru realm, and the header the app now sends everywhere
pytoyoda moved from 5.2.0 to 5.2.7 in the four days before this, and most of it is Pydantic defaults and pagination that a connector returning raw JSON never had. Two things cross over. The Solterra is a Toyota underneath and lives on the same Connected Europe backend, but a Subaru account does not log in as the MyToyota app: it has its own ForgeRock realm (alliance-subaru), its own OAuth client and redirect, and its own client credentials, on the same host. The four login URLs that were constants become a brandAuth chosen at Init — Toyota and Lexus keep the tme realm and the oneapp client, Subaru gets its own — and the three URLs are derived from the parts that differ, because the shape is ForgeRock's and does not vary by realm. Past the login nothing changes: same data API, same endpoints, same headers, which is why it is a third option on the connector rather than a fourth connector. A brand on the wrong realm authenticates against a directory it is not in and reads as a bad password, so a test pins each brand to its realm and client. The app now sends x-user-region on every call. The gateway only insists on it for remote commands on Suzuki-badged DCM24 cars, which this connector never issues; but a read that is identical to the app's is cheaper to keep than one that is deliberately distinct, so it goes out too. The x-appbrand/brand pair that Lexus already sent goes out for Subaru as well; a Toyota account still sends neither. The brand is chosen in three places that do not read the descriptor — the Web App and Phone App settings forms hard-code their options — so Subaru is added to both, and to the six translation files behind them. The car importer expands "S" to Subaru so an imported Solterra gets its make. Left where they were: the trips endpoint, which predates the last sync and wants a date range and a card, and the wake calls, which a read-only connector does not make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9a95131a5
commit
a26c608fc3
12 files changed
+175
-26
No files matched your search
@@ -362,6 +362,10 @@ func TestToyotaVehiclesFallbacks(t *testing.T) {
|
||||
if got[0].Name != "Lexus IS 300h" {
|
||||
t.Errorf("name = %q, want Lexus IS 300h", got[0].Name)
|
||||
}
|
||||
// The third brand on the wire: a Solterra reports itself as "S".
|
||||
if m := toyotaMake("S"); m != "Subaru" {
|
||||
t.Errorf(`toyotaMake("S") = %q, want Subaru`, m)
|
||||
}
|
||||
|
||||
// A vehicle with nothing to address it by is skipped, not imported blind.
|
||||
anonymous := toyotaSource{}.vehicles(json.RawMessage(`{"payload": [{"colour": "blue"}]}`))
|
||||
|
||||
@@ -105,14 +105,16 @@ func (toyotaSource) vehicles(raw json.RawMessage) []providerVehicle {
|
||||
return out
|
||||
}
|
||||
|
||||
// toyotaMake expands the one-letter brand code the app uses on the wire ("T"/"L")
|
||||
// and otherwise passes the reported brand through in title case.
|
||||
// toyotaMake expands the one-letter brand code the app uses on the wire
|
||||
// ("T"/"L"/"S") and otherwise passes the reported brand through in title case.
|
||||
func toyotaMake(brand string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(brand)) {
|
||||
case "T", "TOYOTA", "":
|
||||
return "Toyota"
|
||||
case "L", "LEXUS":
|
||||
return "Lexus"
|
||||
case "S", "SUBARU":
|
||||
return "Subaru"
|
||||
}
|
||||
b := strings.TrimSpace(brand)
|
||||
return strings.ToUpper(b[:1]) + strings.ToLower(b[1:])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
@@ -51,20 +52,64 @@ import (
|
||||
// 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"
|
||||
apiBaseURL = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io"
|
||||
loginBase = "https://b2c-login.toyota-europe.com"
|
||||
|
||||
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=="
|
||||
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
|
||||
@@ -105,7 +150,8 @@ type tokenInfo struct {
|
||||
type Plugin struct {
|
||||
username string
|
||||
password string
|
||||
brand string // "T" (Toyota) or "L" (Lexus)
|
||||
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
|
||||
@@ -122,7 +168,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "toyota",
|
||||
Provider: "Toyota Connected Europe",
|
||||
Version: "0.2.0",
|
||||
Version: "0.3.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryVehicles,
|
||||
AuthType: plugins.AuthOAuth2,
|
||||
@@ -149,10 +195,11 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
{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.",
|
||||
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"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
@@ -170,6 +217,7 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
if p.brand == "" {
|
||||
p.brand = "T"
|
||||
}
|
||||
p.auth = authFor(p.brand)
|
||||
// Config change invalidates any cached token.
|
||||
p.tok = nil
|
||||
|
||||
@@ -339,7 +387,7 @@ func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
|
||||
payload = b
|
||||
}
|
||||
|
||||
resp, err := p.postRaw(ctx, authenticateURL, "application/json", payload, false)
|
||||
resp, err := p.postRaw(ctx, p.auth.authenticateURL, "application/json", payload, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -361,7 +409,7 @@ func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
|
||||
// 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)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.auth.authorizeURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -388,9 +436,9 @@ func (p *Plugin) performAuthorization(ctx context.Context, tokenID string) (stri
|
||||
// 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("client_id", p.auth.clientID)
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("redirect_uri", p.auth.redirectURI)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", "plain")
|
||||
return p.tokenRequest(ctx, form)
|
||||
@@ -403,8 +451,8 @@ func (p *Plugin) refreshTokens(ctx context.Context) error {
|
||||
return errors.New("toyota: no refresh token")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("client_id", "oneapp")
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
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)
|
||||
@@ -413,11 +461,11 @@ func (p *Plugin) refreshTokens(ctx context.Context) error {
|
||||
|
||||
// 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()))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.auth.accessTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("authorization", basicAuth)
|
||||
req.Header.Set("authorization", p.auth.basicAuth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := p.authClient.Do(req)
|
||||
@@ -547,11 +595,18 @@ func (p *Plugin) applyHeaders(h http.Header, vin string, tok tokenInfo) {
|
||||
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)
|
||||
if p.brand == "L" {
|
||||
set("x-appbrand", "L")
|
||||
set("brand", "L")
|
||||
// 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -39,6 +40,85 @@ func TestDescriptor(t *testing.T) {
|
||||
if !fields["password"].Secret {
|
||||
t.Error("password field must be marked secret")
|
||||
}
|
||||
// The three brands that share the backend must all be offered: a value the
|
||||
// select does not list cannot be chosen from the panel.
|
||||
brands := map[string]bool{}
|
||||
for _, o := range fields["brand"].Options {
|
||||
brands[o.Value] = true
|
||||
}
|
||||
for _, b := range []string{"T", "L", "S"} {
|
||||
if !brands[b] {
|
||||
t.Errorf("brand option %q should be offered", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandAuth pins the per-brand login: Toyota and Lexus are the MyToyota app
|
||||
// in the "tme" realm, Subaru is its own client in "alliance-subaru". A brand on
|
||||
// the wrong realm authenticates against a user directory it is not in, which
|
||||
// reads as a bad password.
|
||||
func TestBrandAuth(t *testing.T) {
|
||||
for _, brand := range []string{"", "T", "L"} {
|
||||
a := authFor(brand)
|
||||
if a.clientID != "oneapp" || !strings.Contains(a.authenticateURL, "/realms/tme/") {
|
||||
t.Errorf("brand %q: expected the oneapp client in realm tme, got %q at %s", brand, a.clientID, a.authenticateURL)
|
||||
}
|
||||
}
|
||||
s := authFor("S")
|
||||
if !strings.HasSuffix(s.clientID, ".subaru.com") || !strings.Contains(s.accessTokenURL, "/realms/alliance-subaru/") {
|
||||
t.Errorf("Subaru: expected its own client in realm alliance-subaru, got %q at %s", s.clientID, s.accessTokenURL)
|
||||
}
|
||||
if s.redirectURI != "com.subaru.oneapp:/oauth2Callback" {
|
||||
t.Errorf("Subaru redirect = %q", s.redirectURI)
|
||||
}
|
||||
if !strings.Contains(s.authorizeURL, "client_id="+s.clientID+"&") || !strings.Contains(s.authorizeURL, "redirect_uri="+s.redirectURI+"&") {
|
||||
t.Errorf("Subaru authorize URL does not carry its own client and redirect: %s", s.authorizeURL)
|
||||
}
|
||||
// The three URLs of one brand must share a realm.
|
||||
if !strings.Contains(s.authorizeURL, "/realms/alliance-subaru/") || !strings.Contains(s.authenticateURL, "/realms/alliance-subaru/") {
|
||||
t.Error("Subaru login URLs disagree on the realm")
|
||||
}
|
||||
if s.basicAuth == toyotaAuth.basicAuth {
|
||||
t.Error("Subaru must present its own client credentials")
|
||||
}
|
||||
|
||||
// Init picks the client for the configured brand.
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p", "brand": "S"})
|
||||
if p.auth.clientID != s.clientID {
|
||||
t.Errorf("Init(brand=S) chose client %q", p.auth.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeadersFollowUpstream keeps the request headers in step with what the app
|
||||
// sends: x-user-region on every call, and the brand named twice more for the
|
||||
// non-Toyota brands only.
|
||||
func TestHeadersFollowUpstream(t *testing.T) {
|
||||
tok := tokenInfo{accessToken: "at", uuid: "uuid-1"}
|
||||
headers := func(brand string) http.Header {
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p", "brand": brand})
|
||||
h := http.Header{}
|
||||
p.applyHeaders(h, "VIN1", tok)
|
||||
return h
|
||||
}
|
||||
for _, brand := range []string{"T", "L", "S"} {
|
||||
h := headers(brand)
|
||||
if got := h["x-user-region"]; len(got) != 1 || got[0] != "EU" {
|
||||
t.Errorf("brand %s: x-user-region = %v, want [EU]", brand, got)
|
||||
}
|
||||
if got := h["x-brand"]; len(got) != 1 || got[0] != brand {
|
||||
t.Errorf("brand %s: x-brand = %v", brand, got)
|
||||
}
|
||||
_, appbrand := h["x-appbrand"]
|
||||
_, bare := h["brand"]
|
||||
if brand == "T" && (appbrand || bare) {
|
||||
t.Error("a Toyota account must not send x-appbrand/brand")
|
||||
}
|
||||
if brand != "T" && (!appbrand || !bare || h["x-appbrand"][0] != brand || h["brand"][0] != brand) {
|
||||
t.Errorf("brand %s: x-appbrand=%v brand=%v, want both = %s", brand, h["x-appbrand"], h["brand"], brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndpointsFollowUpstream guards the mid-2026 migration: Toyota put the
|
||||
|
||||
@@ -735,6 +735,7 @@
|
||||
"toyota": "Toyota Connected (MyToyota)",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
|
||||
"categories": {
|
||||
"vehicles": "Bilproducenter",
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"brand": "Brand",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"inherited": "Set above your level",
|
||||
"inheritedFrom": "Inherited from {source}",
|
||||
"sourceGlobal": "the administrator",
|
||||
|
||||
@@ -743,6 +743,7 @@
|
||||
"toyota": "Toyota Connected (MyToyota)",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
|
||||
"categories": {
|
||||
"vehicles": "Producenci samochodów",
|
||||
|
||||
@@ -1652,6 +1652,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
||||
("", "common.empty"),
|
||||
("T", "settings.integrations.brandToyota"),
|
||||
("L", "settings.integrations.brandLexus"),
|
||||
("S", "settings.integrations.brandSubaru"),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -606,6 +606,7 @@
|
||||
"brand": "Mærke",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"inherited": "Sat på et højere niveau",
|
||||
"inheritedFrom": "Nedarvet fra {source}",
|
||||
"sourceGlobal": "administratoren",
|
||||
|
||||
@@ -605,6 +605,7 @@
|
||||
"brand": "Brand",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"inherited": "Set above your level",
|
||||
"inheritedFrom": "Inherited from {source}",
|
||||
"sourceGlobal": "the administrator",
|
||||
|
||||
@@ -612,6 +612,7 @@
|
||||
"brand": "Marka",
|
||||
"brandToyota": "Toyota",
|
||||
"brandLexus": "Lexus",
|
||||
"brandSubaru": "Subaru",
|
||||
"inherited": "Ustawione na wyższym poziomie",
|
||||
"inheritedFrom": "Odziedziczone od: {source}",
|
||||
"sourceGlobal": "administratora",
|
||||
|
||||
@@ -1511,6 +1511,7 @@ onBeforeUnmount(() => {
|
||||
<option value="">—</option>
|
||||
<option value="T">{{ t("settings.integrations.brandToyota") }}</option>
|
||||
<option value="L">{{ t("settings.integrations.brandLexus") }}</option>
|
||||
<option value="S">{{ t("settings.integrations.brandSubaru") }}</option>
|
||||
</select>
|
||||
<p v-if="toyotaField('brand').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('brand') }}</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user