diff --git a/API Server/internal/api/vehicleproviders_test.go b/API Server/internal/api/vehicleproviders_test.go index c3b8327..4966b1f 100644 --- a/API Server/internal/api/vehicleproviders_test.go +++ b/API Server/internal/api/vehicleproviders_test.go @@ -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"}]}`)) diff --git a/API Server/internal/api/vehicleproviders_toyota.go b/API Server/internal/api/vehicleproviders_toyota.go index de9e33b..e467b50 100644 --- a/API Server/internal/api/vehicleproviders_toyota.go +++ b/API Server/internal/api/vehicleproviders_toyota.go @@ -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:]) diff --git a/API Server/internal/plugins/builtin/toyota/toyota.go b/API Server/internal/plugins/builtin/toyota/toyota.go index a02593c..dcc712e 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota.go +++ b/API Server/internal/plugins/builtin/toyota/toyota.go @@ -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) diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go index cd5a6f1..90d9012 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota_test.go +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -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 diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 086088a..a730d98 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -735,6 +735,7 @@ "toyota": "Toyota Connected (MyToyota)", "brandToyota": "Toyota", "brandLexus": "Lexus", + "brandSubaru": "Subaru", "ankerSolix": "Anker Solix (V1 Smart EV Charger)", "categories": { "vehicles": "Bilproducenter", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index c7e078d..fffa887 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -511,6 +511,7 @@ "brand": "Brand", "brandToyota": "Toyota", "brandLexus": "Lexus", + "brandSubaru": "Subaru", "inherited": "Set above your level", "inheritedFrom": "Inherited from {source}", "sourceGlobal": "the administrator", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index b99ca36..09f4968 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -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", diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index ca767eb..1f6d94d 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -1652,6 +1652,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> { ("", "common.empty"), ("T", "settings.integrations.brandToyota"), ("L", "settings.integrations.brandLexus"), + ("S", "settings.integrations.brandSubaru"), ], ), ], diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 492de54..514ff2f 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 62be165..cef4fcd 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -605,6 +605,7 @@ "brand": "Brand", "brandToyota": "Toyota", "brandLexus": "Lexus", + "brandSubaru": "Subaru", "inherited": "Set above your level", "inheritedFrom": "Inherited from {source}", "sourceGlobal": "the administrator", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index e8825f2..7eef65d 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -612,6 +612,7 @@ "brand": "Marka", "brandToyota": "Toyota", "brandLexus": "Lexus", + "brandSubaru": "Subaru", "inherited": "Ustawione na wyższym poziomie", "inheritedFrom": "Odziedziczone od: {source}", "sourceGlobal": "administratora", diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 88c4224..b5cf84f 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -1511,6 +1511,7 @@ onBeforeUnmount(() => { +
{{ toyotaSourceLabel('brand') }}