2 * Copyright (c) 2010-2022 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.tesla.internal.handler;
15 import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
17 import java.security.MessageDigest;
18 import java.security.NoSuchAlgorithmException;
19 import java.util.Base64;
20 import java.util.Iterator;
21 import java.util.Random;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.util.FormContentProvider;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.http.HttpMethod;
34 import org.eclipse.jetty.util.Fields;
35 import org.eclipse.jetty.util.Fields.Field;
36 import org.jsoup.Jsoup;
37 import org.jsoup.nodes.Document;
38 import org.jsoup.nodes.Element;
39 import org.openhab.binding.tesla.internal.protocol.sso.AuthorizationCodeExchangeRequest;
40 import org.openhab.binding.tesla.internal.protocol.sso.AuthorizationCodeExchangeResponse;
41 import org.openhab.binding.tesla.internal.protocol.sso.RefreshTokenRequest;
42 import org.openhab.binding.tesla.internal.protocol.sso.TokenResponse;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
46 import com.google.gson.Gson;
49 * The {@link TeslaSSOHandler} is responsible for authenticating with the Tesla SSO service.
51 * @author Christian Güdel - Initial contribution
54 public class TeslaSSOHandler {
56 private final HttpClient httpClient;
57 private final Gson gson = new Gson();
58 private final Logger logger = LoggerFactory.getLogger(TeslaSSOHandler.class);
60 public TeslaSSOHandler(HttpClient httpClient) {
61 this.httpClient = httpClient;
65 public TokenResponse getAccessToken(String refreshToken) {
66 logger.debug("Exchanging SSO refresh token for API access token");
68 // get a new access token for the owner API token endpoint
69 RefreshTokenRequest refreshRequest = new RefreshTokenRequest(refreshToken);
70 String refreshTokenPayload = gson.toJson(refreshRequest);
72 final org.eclipse.jetty.client.api.Request request = httpClient.newRequest(URI_SSO + "/" + PATH_TOKEN);
73 request.content(new StringContentProvider(refreshTokenPayload));
74 request.header(HttpHeader.CONTENT_TYPE, "application/json");
75 request.method(HttpMethod.POST);
77 ContentResponse refreshResponse = executeHttpRequest(request);
79 if (refreshResponse != null && refreshResponse.getStatus() == 200) {
80 String refreshTokenResponse = refreshResponse.getContentAsString();
81 TokenResponse tokenResponse = gson.fromJson(refreshTokenResponse.trim(), TokenResponse.class);
83 if (tokenResponse != null && tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
86 logger.debug("An error occurred while exchanging SSO auth token for API access token.");
89 logger.debug("An error occurred during refresh of SSO token: {}",
90 (refreshResponse != null ? refreshResponse.getStatus() : "no response"));
97 * Authenticates using username/password against Tesla SSO endpoints.
99 * @param username Username
100 * @param password Password
101 * @return Refresh token for use with {@link getAccessToken}
104 public String authenticate(String username, String password) {
105 String codeVerifier = generateRandomString(86);
106 String codeChallenge = null;
107 String state = generateRandomString(10);
110 codeChallenge = getCodeChallenge(codeVerifier);
111 } catch (NoSuchAlgorithmException e) {
112 logger.error("An exception occurred while building login page request: '{}'", e.getMessage());
116 final org.eclipse.jetty.client.api.Request loginPageRequest = httpClient
117 .newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
118 loginPageRequest.method(HttpMethod.GET);
119 loginPageRequest.followRedirects(false);
121 addQueryParameters(loginPageRequest, codeChallenge, state);
123 ContentResponse loginPageResponse = executeHttpRequest(loginPageRequest);
124 if (loginPageResponse == null
125 || (loginPageResponse.getStatus() != 200 && loginPageResponse.getStatus() != 302)) {
126 logger.debug("Failed to obtain SSO login page, response status code: {}",
127 (loginPageResponse != null ? loginPageResponse.getStatus() : "no response"));
131 logger.debug("Obtained SSO login page");
133 String authorizationCode = null;
135 if (loginPageResponse.getStatus() == 302) {
136 String redirectLocation = loginPageResponse.getHeaders().get(HttpHeader.LOCATION);
137 if (isValidRedirectLocation(redirectLocation)) {
138 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
140 logger.debug("Unexpected redirect location received when fetching login page: {}", redirectLocation);
144 Fields postData = new Fields();
147 Document doc = Jsoup.parse(loginPageResponse.getContentAsString());
148 logger.trace("{}", doc.toString());
149 Element loginForm = doc.getElementsByTag("form").first();
151 Iterator<Element> elIt = loginForm.getElementsByTag("input").iterator();
152 while (elIt.hasNext()) {
153 Element input = elIt.next();
154 if (input.attr("type").equalsIgnoreCase("hidden")) {
155 postData.add(input.attr("name"), input.attr("value"));
158 } catch (Exception e) {
159 logger.error("Failed to parse login page: {}", e.getMessage());
160 logger.debug("login page response {}", loginPageResponse.getContentAsString());
164 postData.add("identity", username);
165 postData.add("credential", password);
167 final org.eclipse.jetty.client.api.Request formSubmitRequest = httpClient
168 .newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
169 formSubmitRequest.method(HttpMethod.POST);
170 formSubmitRequest.content(new FormContentProvider(postData));
171 formSubmitRequest.followRedirects(false); // this should return a 302 ideally, but that location doesn't
173 addQueryParameters(formSubmitRequest, codeChallenge, state);
175 ContentResponse formSubmitResponse = executeHttpRequest(formSubmitRequest);
176 if (formSubmitResponse == null || formSubmitResponse.getStatus() != 302) {
177 logger.debug("Failed to obtain code from SSO login page when submitting form, response status code: {}",
178 (formSubmitResponse != null ? formSubmitResponse.getStatus() : "no response"));
182 String redirectLocation = formSubmitResponse.getHeaders().get(HttpHeader.LOCATION);
183 if (!isValidRedirectLocation(redirectLocation)) {
184 logger.debug("Redirect location not set or doesn't match expected callback URI {}: {}", URI_CALLBACK,
189 logger.debug("Obtained valid redirect location");
190 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
193 if (authorizationCode == null) {
194 logger.debug("Did not receive an authorization code");
198 // exchange authorization code for SSO access + refresh token
199 AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
201 String payload = gson.toJson(request);
203 final org.eclipse.jetty.client.api.Request tokenExchangeRequest = httpClient
204 .newRequest(URI_SSO + "/" + PATH_TOKEN);
205 tokenExchangeRequest.content(new StringContentProvider(payload));
206 tokenExchangeRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
207 tokenExchangeRequest.method(HttpMethod.POST);
209 ContentResponse response = executeHttpRequest(tokenExchangeRequest);
210 if (response != null && response.getStatus() == 200) {
211 String responsePayload = response.getContentAsString();
212 AuthorizationCodeExchangeResponse ssoTokenResponse = gson.fromJson(responsePayload.trim(),
213 AuthorizationCodeExchangeResponse.class);
214 if (ssoTokenResponse != null && ssoTokenResponse.token_type != null
215 && !ssoTokenResponse.access_token.isEmpty()) {
216 logger.debug("Obtained valid SSO refresh token");
217 return ssoTokenResponse.refresh_token;
220 logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
221 (response != null ? response.getStatus() : "no response"));
227 private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
228 return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
232 private String extractAuthorizationCodeFromUri(String uri) {
233 Field code = httpClient.newRequest(uri).getParams().get("code");
234 return code != null ? code.getValue() : null;
237 private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
238 MessageDigest digest = MessageDigest.getInstance("SHA-256");
239 byte[] hash = digest.digest(codeVerifier.getBytes());
241 StringBuilder hashStr = new StringBuilder(hash.length * 2);
242 for (byte b : hash) {
243 hashStr.append(String.format("%02x", b));
246 return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
249 private String generateRandomString(int length) {
250 Random random = new Random();
252 String generatedString = random.ints('a', 'z' + 1).limit(length)
253 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
255 return generatedString;
258 private void addQueryParameters(org.eclipse.jetty.client.api.Request request, String codeChallenge, String state) {
259 request.param("client_id", CLIENT_ID);
260 request.param("code_challenge", codeChallenge);
261 request.param("code_challenge_method", "S256");
262 request.param("redirect_uri", URI_CALLBACK);
263 request.param("response_type", "code");
264 request.param("scope", SSO_SCOPES);
265 request.param("state", state);
269 private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
270 request.timeout(10, TimeUnit.SECONDS);
272 ContentResponse response;
274 response = request.send();
276 } catch (InterruptedException | TimeoutException | ExecutionException e) {
277 logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());