Skip to content

Commit

Permalink
Rewrite OAuth flow to completely implement BMW PKCS flow
Browse files Browse the repository at this point in the history
  • Loading branch information
rikroe committed Nov 17, 2021
1 parent f496563 commit 3628c35
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 88 deletions.
77 changes: 46 additions & 31 deletions bimmer_connected/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,28 @@
This library is not affiliated with or endorsed by BMW Group.
"""

import base64
import datetime
import hashlib
import json
import logging
import os
import pathlib
import urllib
import json
from threading import Lock
from typing import Any, Callable, Dict, List
import requests
from requests.auth import HTTPBasicAuth
from requests.exceptions import HTTPError
from requests.models import Response

from bimmer_connected.country_selector import (
Regions,
get_server_url,
get_gcdm_oauth_endpoint,
get_gcdm_oauth_authorization
get_ocp_apim_key,
)
from bimmer_connected.vehicle import ConnectedDriveVehicle, CarBrand
from bimmer_connected.const import AUTH_URL, TOKEN_URL, VEHICLES_URL
from bimmer_connected.const import AUTH_URL, OAUTH_CONFIG_URL, VEHICLES_URL, X_USER_AGENT

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -78,29 +81,44 @@ def _get_oauth_token(self) -> None:
try:
# We need a session for cross-request cookies
oauth_session = requests.Session()
oauth_settings = get_gcdm_oauth_authorization(self._region)
# oauth_settings = get_gcdm_oauth_authorization(self._region)
r_oauth_settings = oauth_session.get(
OAUTH_CONFIG_URL.format(server=self.server_url),
headers={
"ocp-apim-subscription-key": get_ocp_apim_key(self._region),
"x-user-agent": X_USER_AGENT.format("bmw"),
}
)
r_oauth_settings.raise_for_status()
oauth_settings = r_oauth_settings.json()

# My BMW login flow
_LOGGER.debug("Authenticating against GCDM with MyBMW flow.")
authenticate_url = AUTH_URL.format(
gcdm_oauth_endpoint=get_gcdm_oauth_endpoint(self._region)
)

# Setting up PKCS data
verifier_bytes = os.urandom(64)
code_verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b'=')

challenge_bytes = hashlib.sha256(code_verifier).digest()
code_challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=')

state_bytes = os.urandom(16)
state = base64.urlsafe_b64encode(state_bytes).rstrip(b'=')

authenticate_url = AUTH_URL.format(gcdm_base_url=oauth_settings["gcdmBaseUrl"])
authenticate_headers = {
"Content-Type": "application/x-www-form-urlencoded",
}

# we really need all of these parameters
oauth_base_values = {
"client_id": oauth_settings["authenticate"]["client_id"],
"client_id": oauth_settings["clientId"],
"response_type": "code",
"redirect_uri": "com.bmw.connected://oauth",
"state": oauth_settings["authenticate"]["state"],
"redirect_uri": oauth_settings["returnUrl"],
"state": state,
"nonce": "login_nonce",
"scope": (
"openid profile email offline_access smacc vehicle_data perseus dlm svds cesim vsapi "
"remote_services fupo authenticate_user"
),
"code_challenge": "ycbv7dNBiqH2sgCdyV7JkuA1c4aqNj9wq7jRmwVOh_s",
"scope": " ".join(oauth_settings["scopes"]),
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}

Expand All @@ -115,11 +133,12 @@ def _get_oauth_token(self) -> None:
)
)
response = oauth_session.post(
authenticate_url, headers=authenticate_headers, data=authenticate_data
authenticate_url,
headers=authenticate_headers,
data=authenticate_data,
)
response.raise_for_status()
authorization = dict(urllib.parse.parse_qsl(response.json()["redirect_to"]))["authorization"]
_LOGGER.debug("got authorization challenge %s", authorization)

code_data = urllib.parse.urlencode(
dict(oauth_base_values, **{"authorization": authorization})
Expand All @@ -129,30 +148,27 @@ def _get_oauth_token(self) -> None:
)
response.raise_for_status()
code = dict(urllib.parse.parse_qsl(response.next.path_url.split('?')[1]))["code"]
_LOGGER.debug("got login code %s", code)

_LOGGER.debug("getting new oauth token")
token_url = TOKEN_URL.format(
gcdm_oauth_endpoint=get_gcdm_oauth_endpoint(self._region)
)
token_url = oauth_settings["tokenEndpoint"]

# My BMW login flow
token_headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": oauth_settings["token"]["Authorization"],
# "Authorization": oauth_settings["token"]["Authorization"],
}
token_values = {
"code": code,
"code_verifier": oauth_settings["token"]["code_verifier"],
"redirect_uri": "com.bmw.connected://oauth",
"code_verifier": code_verifier,
"redirect_uri": oauth_settings["returnUrl"],
"grant_type": "authorization_code",
}

token_data = urllib.parse.urlencode(token_values)
response = oauth_session.post(
token_url,
headers=token_headers,
data=token_data
data=token_data,
auth=HTTPBasicAuth(oauth_settings["clientId"], oauth_settings["clientSecret"])
)
response.raise_for_status()
response_json = response.json()
Expand Down Expand Up @@ -184,7 +200,7 @@ def request_header(self, brand: CarBrand = None) -> Dict[str, str]:
brand = brand or CarBrand.BMW
headers = {
"accept": "application/json",
"x-user-agent": "android(v1.07_20200330);{};1.7.0(11152)".format(brand.value),
"x-user-agent": X_USER_AGENT.format(brand.value),
"Authorization": "Bearer {}".format(self._oauth_token),
}
return headers
Expand Down Expand Up @@ -284,7 +300,7 @@ def _get_vehicles(self) -> None:
"""Retrieve list of vehicle for the account."""
_LOGGER.debug('Getting vehicle list')
self._get_oauth_token()
vehicles = []

for brand in CarBrand:
response = self.send_request(
VEHICLES_URL.format(server=self.server_url),
Expand All @@ -302,8 +318,7 @@ def _get_vehicles(self) -> None:
if existing_vehicle:
existing_vehicle.update_state(vehicle_dict)
else:
vehicles.append(ConnectedDriveVehicle(self, vehicle_dict))
self._vehicles = vehicles
self._vehicles.append(ConnectedDriveVehicle(self, vehicle_dict))

def get_vehicle(self, vin: str) -> ConnectedDriveVehicle:
"""Get vehicle with given VIN.
Expand Down
5 changes: 3 additions & 2 deletions bimmer_connected/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""URLs for different services and error code mapping."""

AUTH_URL = 'https://{gcdm_oauth_endpoint}/oauth/authenticate'
TOKEN_URL = 'https://{gcdm_oauth_endpoint}/oauth/token'
AUTH_URL = '{gcdm_base_url}/gcdm/oauth/authenticate'
X_USER_AGENT = 'android(v1.07_20200330);{};1.7.0(11152)'

BASE_URL = 'https://{server}'
OAUTH_CONFIG_URL = BASE_URL + '/eadrax-ucs/v1/presentation/oauth/config'

Expand Down
115 changes: 62 additions & 53 deletions bimmer_connected/country_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,62 +13,66 @@ class Regions(Enum):
REST_OF_WORLD = 2


#: Mapping from regions to servers
_SERVER_URLS_LEGACY = {
Regions.NORTH_AMERICA: "b2vapi.bmwgroup.us",
Regions.REST_OF_WORLD: "b2vapi.bmwgroup.com",
# Regions.CHINA: "b2vapi.bmwgroup.cn:8592",
}
# #: Mapping from regions to servers
# _SERVER_URLS_LEGACY = {
# Regions.NORTH_AMERICA: "b2vapi.bmwgroup.us",
# Regions.REST_OF_WORLD: "b2vapi.bmwgroup.com",
# # Regions.CHINA: "b2vapi.bmwgroup.cn:8592",
# }

_SERVER_URLS_EADRAX = {
Regions.NORTH_AMERICA: "cocoapi.bmwgroup.us",
Regions.REST_OF_WORLD: "cocoapi.bmwgroup.com",
# Regions.CHINA: None,
}


#: Mapping from regions to servers
_GCDM_OAUTH_ENDPOINTS = {
Regions.NORTH_AMERICA: "login.bmwusa.com/gcdm",
Regions.REST_OF_WORLD: "customer.bmwgroup.com/gcdm",
# Regions.CHINA: "customer.bmwgroup.cn/gcdm",
_OCP_APIM_KEYS = {
Regions.NORTH_AMERICA: "31e102f5-6f7e-7ef3-9044-ddce63891362",
Regions.REST_OF_WORLD: "4f1c85a3-758f-a37d-bbb6-f8704494acfa",
}

_GCDM_OAUTH_AUTHORIZATION = {
Regions.NORTH_AMERICA: {
"token": {
"Authorization": ("Basic NTQzOTRhNGItYjZjMS00NWZlLWI3YjItOGZkM2FhOTI1M2F"
"hOmQ5MmYzMWMwLWY1NzktNDRmNS1hNzdkLTk2NmY4ZjAwZTM1MQ=="),
"code_verifier": "KDarcVUpgymBDCgHDH0PwwMfzycDxu1joeklioOhwXA",
},
"authenticate": {
"client_id": "54394a4b-b6c1-45fe-b7b2-8fd3aa9253aa",
"state": "rgastJbZsMtup49-Lp0FMQ",
},
},
Regions.REST_OF_WORLD: {
"token": {
"Authorization": ("Basic MzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ"
"4OmMwZTMzOTNkLTcwYTItNGY2Zi05ZDNjLTg1MzBhZjY0ZDU1Mg=="),
"code_verifier": "7PsmfPS5MpaNt0jEcPpi-B7M7u0gs1Nzw6ex0Y9pa-0",
},
"authenticate": {
"client_id": "31c357a0-7a1d-4590-aa99-33b97244d048",
"state": "cEG9eLAIi6Nv-aaCAniziE_B6FPoobva3qr5gukilYw",
},
},
# Regions.CHINA: {
# "token": {
# "Authorization": ("Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGN"
# "EanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg=="),
# "code_verifier": "",
# },
# "authenticate": {
# "client_id": None,
# "state": None,
# },
# },
}
# #: Mapping from regions to servers
# _GCDM_OAUTH_ENDPOINTS = {
# Regions.NORTH_AMERICA: "login.bmwusa.com/gcdm",
# Regions.REST_OF_WORLD: "customer.bmwgroup.com/gcdm",
# # Regions.CHINA: "customer.bmwgroup.cn/gcdm",
# }

# _GCDM_OAUTH_AUTHORIZATION = {
# Regions.NORTH_AMERICA: {
# "token": {
# "Authorization": ("Basic NTQzOTRhNGItYjZjMS00NWZlLWI3YjItOGZkM2FhOTI1M2F"
# "hOmQ5MmYzMWMwLWY1NzktNDRmNS1hNzdkLTk2NmY4ZjAwZTM1MQ=="),
# "code_verifier": "KDarcVUpgymBDCgHDH0PwwMfzycDxu1joeklioOhwXA",
# },
# "authenticate": {
# "client_id": "54394a4b-b6c1-45fe-b7b2-8fd3aa9253aa",
# "state": "rgastJbZsMtup49-Lp0FMQ",
# },
# },
# Regions.REST_OF_WORLD: {
# "token": {
# "Authorization": ("Basic MzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ"
# "4OmMwZTMzOTNkLTcwYTItNGY2Zi05ZDNjLTg1MzBhZjY0ZDU1Mg=="),
# "code_verifier": "7PsmfPS5MpaNt0jEcPpi-B7M7u0gs1Nzw6ex0Y9pa-0",
# },
# "authenticate": {
# "client_id": "31c357a0-7a1d-4590-aa99-33b97244d048",
# "state": "cEG9eLAIi6Nv-aaCAniziE_B6FPoobva3qr5gukilYw",
# },
# },
# Regions.CHINA: {
# "token": {
# "Authorization": ("Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGN"
# "EanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg=="),
# "code_verifier": "",
# },
# "authenticate": {
# "client_id": None,
# "state": None,
# },
# },
# }


def valid_regions() -> List[str]:
Expand Down Expand Up @@ -105,11 +109,16 @@ def get_server_url(region: Regions) -> str:
return _SERVER_URLS_EADRAX[region]


def get_gcdm_oauth_endpoint(region: Regions) -> str:
"""Get the url of the server for the region."""
return _GCDM_OAUTH_ENDPOINTS[region]
def get_ocp_apim_key(region: Regions) -> str:
"""Get the authorization for OAuth settings."""

return _OCP_APIM_KEYS[region]

def get_gcdm_oauth_authorization(region: Regions) -> str:
"""Get the url of the server for the region."""
return _GCDM_OAUTH_AUTHORIZATION[region]
# def get_gcdm_oauth_endpoint(region: Regions) -> str:
# """Get the url of the server for the region."""
# return _GCDM_OAUTH_ENDPOINTS[region]


# def get_gcdm_oauth_authorization(region: Regions) -> str:
# """Get the url of the server for the region."""
# return _GCDM_OAUTH_AUTHORIZATION[region]
31 changes: 31 additions & 0 deletions test/responses/auth/oauth_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"clientName": "mybmwapp",
"clientSecret": "clientSecret",
"clientId": "clientId",
"gcdmBaseUrl": "https://customer.bmwgroup.com",
"returnUrl": "com.bmw.connected://oauth",
"brand": "bmw",
"language": "en",
"country": "US",
"authorizationEndpoint": "https://customer.bmwgroup.com/oneid/login",
"tokenEndpoint": "https://customer.bmwgroup.com/gcdm/oauth/token",
"scopes": [
"openid",
"profile",
"email",
"offline_access",
"smacc",
"vehicle_data",
"perseus",
"dlm",
"svds",
"cesim",
"vsapi",
"remote_services",
"fupo",
"authenticate_user"
],
"promptValues": [
"login"
]
}
9 changes: 7 additions & 2 deletions test/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ def return_vehicles(request, context): # pylint: disable=inconsistent-return-st
def get_base_adapter():
"""Returns mocked adapter for auth."""
adapter = requests_mock.Adapter()
adapter.register_uri(
"GET",
"/eadrax-ucs/v1/presentation/oauth/config",
json=load_response(RESPONSE_DIR / "auth" / "oauth_config.json"),
)
adapter.register_uri("POST", "/gcdm/oauth/authenticate", json=authenticate_callback)
adapter.register_uri("POST", "/gcdm/oauth/token", json=load_response(RESPONSE_DIR / "auth" / "auth_token.json"))
adapter.register_uri("GET", "/eadrax-vcs/v1/vehicles", json=return_vehicles)
Expand Down Expand Up @@ -93,7 +98,7 @@ def test_vehicles(self):

def test_invalid_password(self):
"""Test parsing the results of an invalid password."""
with requests_mock.Mocker() as mock:
with requests_mock.Mocker(adapter=get_base_adapter()) as mock:
mock.post(
"/gcdm/oauth/authenticate",
json=load_response(RESPONSE_DIR / "auth" / "auth_error_wrong_password.json"),
Expand All @@ -104,7 +109,7 @@ def test_invalid_password(self):

def test_server_error(self):
"""Test parsing the results of a server error."""
with requests_mock.Mocker() as mock:
with requests_mock.Mocker(adapter=get_base_adapter()) as mock:
mock.post(
"/gcdm/oauth/authenticate",
text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt"),
Expand Down

0 comments on commit 3628c35

Please sign in to comment.