2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.mybmw.internal.handler.auth;
15 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.API_OAUTH_CONFIG;
16 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.APP_VERSIONS;
17 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTHORIZATION_CODE;
18 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTH_PROVIDER;
19 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.BRAND_BMW;
20 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_LOGIN;
21 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_PUBLIC_KEY;
22 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.EADRAX_SERVER_MAP;
23 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.LOGIN_NONCE;
24 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OAUTH_ENDPOINT;
25 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OCP_APIM_KEYS;
26 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_CHINA;
27 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_NORTH_AMERICA;
28 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_ROW;
29 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.USER_AGENT;
30 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.X_USER_AGENT;
31 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.AUTHORIZATION;
32 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CLIENT_ID;
33 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE;
34 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE;
35 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE_METHOD;
36 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_VERIFIER;
37 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CONTENT_TYPE_URL_ENCODED;
38 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.GRANT_TYPE;
39 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_ACP_SUBSCRIPTION_KEY;
40 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_BMW_CORRELATION_ID;
41 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_CORRELATION_ID;
42 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_IDENTITY_PROVIDER;
43 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_USER_AGENT;
44 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.NONCE;
45 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.PASSWORD;
46 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.REDIRECT_URI;
47 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.RESPONSE_TYPE;
48 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.SCOPE;
49 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.STATE;
50 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.USERNAME;
52 import java.nio.charset.StandardCharsets;
53 import java.security.KeyFactory;
54 import java.security.MessageDigest;
55 import java.security.NoSuchAlgorithmException;
56 import java.security.PublicKey;
57 import java.security.spec.X509EncodedKeySpec;
58 import java.util.Base64;
59 import java.util.UUID;
61 import javax.crypto.Cipher;
63 import org.eclipse.jdt.annotation.NonNullByDefault;
64 import org.eclipse.jdt.annotation.Nullable;
65 import org.eclipse.jetty.client.HttpClient;
66 import org.eclipse.jetty.client.HttpResponseException;
67 import org.eclipse.jetty.client.api.ContentResponse;
68 import org.eclipse.jetty.client.api.Request;
69 import org.eclipse.jetty.client.util.StringContentProvider;
70 import org.eclipse.jetty.http.HttpHeader;
71 import org.eclipse.jetty.util.MultiMap;
72 import org.eclipse.jetty.util.UrlEncoded;
73 import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration;
74 import org.openhab.binding.mybmw.internal.dto.auth.AuthQueryResponse;
75 import org.openhab.binding.mybmw.internal.dto.auth.AuthResponse;
76 import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse;
77 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration;
78 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse;
79 import org.openhab.binding.mybmw.internal.handler.backend.JsonStringDeserializer;
80 import org.openhab.binding.mybmw.internal.utils.Constants;
81 import org.openhab.core.util.StringUtils;
82 import org.slf4j.Logger;
83 import org.slf4j.LoggerFactory;
87 * requests the tokens for MyBMW API authorization
89 * thanks to bimmer_connected https://github.com/bimmerconnected/bimmer_connected
91 * @author Bernd Weymann - Initial contribution
92 * @author Martin Grassl - extracted from myBmwProxy
95 public class MyBMWTokenController {
97 private final Logger logger = LoggerFactory.getLogger(MyBMWTokenController.class);
99 private Token token = new Token();
100 private MyBMWBridgeConfiguration configuration;
101 private HttpClient httpClient;
103 public MyBMWTokenController(MyBMWBridgeConfiguration configuration, HttpClient httpClient) {
104 this.configuration = configuration;
105 this.httpClient = httpClient;
109 * Gets new token if old one is expired or invalid. In case of error the token
111 * So if token refresh fails the corresponding requests will also fail and
112 * update the Thing status accordingly.
116 public Token getToken() {
117 if (!token.isValid()) {
118 boolean tokenUpdateSuccess = false;
119 switch (configuration.region) {
121 tokenUpdateSuccess = updateTokenChina();
123 case REGION_NORTH_AMERICA:
125 tokenUpdateSuccess = updateToken();
128 logger.warn("Region {} not supported", configuration.region);
131 if (!tokenUpdateSuccess) {
132 logger.warn("Authorization failed!");
139 * Everything is caught by surrounding try catch
141 * - JSONSyntax Exceptions
142 * - potential NullPointer Exceptions
144 * @return true if the token was successfully updated
146 private synchronized boolean updateToken() {
149 * Step 1) Get basic values for further queries
151 String uuidString = UUID.randomUUID().toString();
153 String authValuesUrl = "https://" + EADRAX_SERVER_MAP.get(configuration.region) + API_OAUTH_CONFIG;
154 Request authValuesRequest = httpClient.newRequest(authValuesUrl);
155 authValuesRequest.header(HEADER_ACP_SUBSCRIPTION_KEY, OCP_APIM_KEYS.get(configuration.region));
156 authValuesRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
157 APP_VERSIONS.get(configuration.region), configuration.region));
158 authValuesRequest.header(HEADER_X_IDENTITY_PROVIDER, AUTH_PROVIDER);
159 authValuesRequest.header(HEADER_X_CORRELATION_ID, uuidString);
160 authValuesRequest.header(HEADER_BMW_CORRELATION_ID, uuidString);
162 ContentResponse authValuesResponse = authValuesRequest.send();
163 if (authValuesResponse.getStatus() != 200) {
164 throw new HttpResponseException("URL: " + authValuesRequest.getURI() + ", Error: "
165 + authValuesResponse.getStatus() + ", Message: " + authValuesResponse.getContentAsString(),
168 AuthQueryResponse aqr = JsonStringDeserializer.deserializeString(authValuesResponse.getContentAsString(),
169 AuthQueryResponse.class);
171 logger.trace("authQueryResponse: {}", aqr);
174 * Step 2) Calculate values for oauth base parameters
176 String codeVerifier = generateCodeVerifier();
177 String codeChallenge = generateCodeChallenge(codeVerifier);
178 String state = generateState();
180 MultiMap<@Nullable String> baseParams = new MultiMap<>();
181 baseParams.put(CLIENT_ID, aqr.clientId);
182 baseParams.put(RESPONSE_TYPE, CODE);
183 baseParams.put(REDIRECT_URI, aqr.returnUrl);
184 baseParams.put(STATE, state);
185 baseParams.put(NONCE, LOGIN_NONCE);
186 baseParams.put(SCOPE, String.join(Constants.SPACE, aqr.scopes));
187 baseParams.put(CODE_CHALLENGE, codeChallenge);
188 baseParams.put(CODE_CHALLENGE_METHOD, "S256");
191 * Step 3) Authorization with username and password
193 String loginUrl = aqr.gcdmBaseUrl + OAUTH_ENDPOINT;
194 Request loginRequest = httpClient.POST(loginUrl);
196 loginRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
198 MultiMap<@Nullable String> loginParams = new MultiMap<>(baseParams);
199 loginParams.put(GRANT_TYPE, AUTHORIZATION_CODE);
200 loginParams.put(USERNAME, configuration.userName);
201 loginParams.put(PASSWORD, configuration.password);
202 loginRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
203 UrlEncoded.encode(loginParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
204 ContentResponse loginResponse = loginRequest.send();
205 if (loginResponse.getStatus() != 200) {
206 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
207 + loginResponse.getStatus() + ", Message: " + loginResponse.getContentAsString(),
210 String authCode = getAuthCode(loginResponse.getContentAsString());
213 * Step 4) Authorize with code
215 Request authRequest = httpClient.POST(loginUrl).followRedirects(false);
216 MultiMap<@Nullable String> authParams = new MultiMap<>(baseParams);
217 authParams.put(AUTHORIZATION, authCode);
218 authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
219 authRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
220 UrlEncoded.encode(authParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
221 ContentResponse authResponse = authRequest.send();
222 if (authResponse.getStatus() != 302) {
223 throw new HttpResponseException("URL: " + authRequest.getURI() + ", Error: " + authResponse.getStatus()
224 + ", Message: " + authResponse.getContentAsString(), authResponse);
226 String code = codeFromUrl(authResponse.getHeaders().get(HttpHeader.LOCATION));
229 * Step 5) Request token
231 Request codeRequest = httpClient.POST(aqr.tokenEndpoint);
232 String basicAuth = "Basic "
233 + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes());
234 codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
235 codeRequest.header(AUTHORIZATION, basicAuth);
237 MultiMap<@Nullable String> codeParams = new MultiMap<>();
238 codeParams.put(CODE, code);
239 codeParams.put(CODE_VERIFIER, codeVerifier);
240 codeParams.put(REDIRECT_URI, aqr.returnUrl);
241 codeParams.put(GRANT_TYPE, AUTHORIZATION_CODE);
242 codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
243 UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
244 ContentResponse codeResponse = codeRequest.send();
245 if (codeResponse.getStatus() != 200) {
246 throw new HttpResponseException("URL: " + codeRequest.getURI() + ", Error: " + codeResponse.getStatus()
247 + ", Message: " + codeResponse.getContentAsString(), codeResponse);
249 AuthResponse ar = JsonStringDeserializer.deserializeString(codeResponse.getContentAsString(),
251 token.setType(ar.tokenType);
252 token.setToken(ar.accessToken);
253 token.setExpiration(ar.expiresIn);
255 } catch (Exception e) {
256 logger.warn("Authorization Exception: {}", e.getMessage());
261 private String generateState() {
262 String stateBytes = StringUtils.getRandomAlphabetic(64).toLowerCase();
263 return Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes.getBytes());
266 private String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
267 MessageDigest digest = MessageDigest.getInstance("SHA-256");
268 byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
269 return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
272 private String generateCodeVerifier() {
273 String verfifierBytes = StringUtils.getRandomAlphabetic(64).toLowerCase();
274 return Base64.getUrlEncoder().withoutPadding().encodeToString(verfifierBytes.getBytes());
277 private String getAuthCode(String response) {
278 String[] keys = response.split("&");
279 for (int i = 0; i < keys.length; i++) {
280 if (keys[i].startsWith(AUTHORIZATION)) {
281 String authCode = keys[i].split("=")[1];
282 authCode = authCode.split("\"")[0];
286 return Constants.EMPTY;
289 private String codeFromUrl(String encodedUrl) {
290 final MultiMap<@Nullable String> tokenMap = new MultiMap<>();
291 UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
292 final StringBuilder codeFound = new StringBuilder();
293 tokenMap.forEach((key, value) -> {
294 if (value.size() > 0) {
295 String val = value.get(0);
296 if (key.endsWith(CODE) && (val != null)) {
297 codeFound.append(val.toString());
301 return codeFound.toString();
304 private synchronized boolean updateTokenChina() {
307 * Step 1) get public key
309 String publicKeyUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_PUBLIC_KEY;
310 Request oauthQueryRequest = httpClient.newRequest(publicKeyUrl);
311 oauthQueryRequest.header(HttpHeader.USER_AGENT, USER_AGENT);
312 oauthQueryRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
313 APP_VERSIONS.get(configuration.region), configuration.region));
314 ContentResponse publicKeyResponse = oauthQueryRequest.send();
315 if (publicKeyResponse.getStatus() != 200) {
316 throw new HttpResponseException("URL: " + oauthQueryRequest.getURI() + ", Error: "
317 + publicKeyResponse.getStatus() + ", Message: " + publicKeyResponse.getContentAsString(),
320 ChinaPublicKeyResponse pkr = JsonStringDeserializer
321 .deserializeString(publicKeyResponse.getContentAsString(), ChinaPublicKeyResponse.class);
324 * Step 2) Encode password with public key
326 // https://www.baeldung.com/java-read-pem-file-keys
327 String publicKeyStr = pkr.data.value;
328 String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "")
329 .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "").replace("\\r", "")
330 .replace("\\n", "").trim();
331 byte[] encoded = Base64.getDecoder().decode(publicKeyPEM);
332 X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded);
333 KeyFactory kf = KeyFactory.getInstance("RSA");
334 PublicKey publicKey = kf.generatePublic(spec);
335 // https://www.thexcoders.net/java-ciphers-rsa/
336 Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
337 cipher.init(Cipher.ENCRYPT_MODE, publicKey);
338 byte[] encryptedBytes = cipher.doFinal(configuration.password.getBytes());
339 String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes);
342 * Step 3) Send Auth with encoded password
344 String tokenUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_LOGIN;
345 Request loginRequest = httpClient.POST(tokenUrl);
346 loginRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
347 APP_VERSIONS.get(configuration.region), configuration.region));
348 String jsonContent = "{ \"mobile\":\"" + configuration.userName + "\", \"password\":\"" + encodedPassword
350 loginRequest.content(new StringContentProvider(jsonContent));
351 ContentResponse tokenResponse = loginRequest.send();
352 if (tokenResponse.getStatus() != 200) {
353 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
354 + tokenResponse.getStatus() + ", Message: " + tokenResponse.getContentAsString(),
357 String authCode = getAuthCode(tokenResponse.getContentAsString());
360 * Step 4) Decode access token
362 ChinaTokenResponse cat = JsonStringDeserializer.deserializeString(authCode, ChinaTokenResponse.class);
363 String token = cat.data.accessToken;
364 // https://www.baeldung.com/java-jwt-token-decode
365 String[] chunks = token.split("\\.");
366 String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1]));
367 ChinaTokenExpiration cte = JsonStringDeserializer.deserializeString(tokenJwtDecodeStr,
368 ChinaTokenExpiration.class);
369 Token t = new Token();
371 t.setType(cat.data.tokenType);
372 t.setExpirationTotal(cte.exp);
374 } catch (Exception e) {
375 logger.warn("Authorization Exception: {}", e.getMessage());