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.TokenExchangeRequest;
43 import org.openhab.binding.tesla.internal.protocol.sso.TokenResponse;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
47 import com.google.gson.Gson;
50 * The {@link TeslaSSOHandler} is responsible for authenticating with the Tesla SSO service.
52 * @author Christian Güdel - Initial contribution
55 public class TeslaSSOHandler {
57 private final HttpClient httpClient;
58 private final Gson gson = new Gson();
59 private final Logger logger = LoggerFactory.getLogger(TeslaSSOHandler.class);
61 public TeslaSSOHandler(HttpClient httpClient) {
62 this.httpClient = httpClient;
66 public TokenResponse getAccessToken(String refreshToken) {
67 logger.debug("Exchanging SSO refresh token for API access token");
69 // get a new access token for the owner API token endpoint
70 RefreshTokenRequest refreshRequest = new RefreshTokenRequest(refreshToken);
71 String refreshTokenPayload = gson.toJson(refreshRequest);
73 final org.eclipse.jetty.client.api.Request request = httpClient.newRequest(URI_SSO + "/" + PATH_TOKEN);
74 request.content(new StringContentProvider(refreshTokenPayload));
75 request.header(HttpHeader.CONTENT_TYPE, "application/json");
76 request.method(HttpMethod.POST);
78 ContentResponse refreshResponse = executeHttpRequest(request);
80 if (refreshResponse != null && refreshResponse.getStatus() == 200) {
81 String refreshTokenResponse = refreshResponse.getContentAsString();
82 TokenResponse tokenResponse = gson.fromJson(refreshTokenResponse.trim(), TokenResponse.class);
84 if (tokenResponse != null && tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
85 TokenExchangeRequest token = new TokenExchangeRequest();
86 String tokenPayload = gson.toJson(token);
88 final org.eclipse.jetty.client.api.Request logonRequest = httpClient
89 .newRequest(URI_OWNERS + "/" + PATH_ACCESS_TOKEN);
90 logonRequest.content(new StringContentProvider(tokenPayload));
91 logonRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
92 logonRequest.header(HttpHeader.AUTHORIZATION, "Bearer " + tokenResponse.access_token);
93 logonRequest.method(HttpMethod.POST);
95 ContentResponse logonTokenResponse = executeHttpRequest(logonRequest);
97 if (logonTokenResponse != null && logonTokenResponse.getStatus() == 200) {
98 String tokenResponsePayload = logonTokenResponse.getContentAsString();
99 TokenResponse tr = gson.fromJson(tokenResponsePayload.trim(), TokenResponse.class);
101 if (tr != null && tr.token_type != null && !tr.access_token.isEmpty()) {
105 logger.debug("An error occurred while exchanging SSO access token for API access token: {}",
106 (logonTokenResponse != null ? logonTokenResponse.getStatus() : "no response"));
110 logger.debug("An error occurred during refresh of SSO token: {}",
111 (refreshResponse != null ? refreshResponse.getStatus() : "no response"));
118 * Authenticates using username/password against Tesla SSO endpoints.
120 * @param username Username
121 * @param password Password
122 * @return Refresh token for use with {@link getAccessToken}
125 public String authenticate(String username, String password) {
126 String codeVerifier = generateRandomString(86);
127 String codeChallenge = null;
128 String state = generateRandomString(10);
131 codeChallenge = getCodeChallenge(codeVerifier);
132 } catch (NoSuchAlgorithmException e) {
133 logger.error("An exception occurred while building login page request: '{}'", e.getMessage());
137 final org.eclipse.jetty.client.api.Request loginPageRequest = httpClient
138 .newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
139 loginPageRequest.method(HttpMethod.GET);
140 loginPageRequest.followRedirects(false);
142 addQueryParameters(loginPageRequest, codeChallenge, state);
144 ContentResponse loginPageResponse = executeHttpRequest(loginPageRequest);
145 if (loginPageResponse == null
146 || (loginPageResponse.getStatus() != 200 && loginPageResponse.getStatus() != 302)) {
147 logger.debug("Failed to obtain SSO login page, response status code: {}",
148 (loginPageResponse != null ? loginPageResponse.getStatus() : "no response"));
152 logger.debug("Obtained SSO login page");
154 String authorizationCode = null;
156 if (loginPageResponse.getStatus() == 302) {
157 String redirectLocation = loginPageResponse.getHeaders().get(HttpHeader.LOCATION);
158 if (isValidRedirectLocation(redirectLocation)) {
159 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
161 logger.debug("Unexpected redirect location received when fetching login page: {}", redirectLocation);
165 Fields postData = new Fields();
168 Document doc = Jsoup.parse(loginPageResponse.getContentAsString());
169 logger.trace("{}", doc.toString());
170 Element loginForm = doc.getElementsByTag("form").first();
172 Iterator<Element> elIt = loginForm.getElementsByTag("input").iterator();
173 while (elIt.hasNext()) {
174 Element input = elIt.next();
175 if (input.attr("type").equalsIgnoreCase("hidden")) {
176 postData.add(input.attr("name"), input.attr("value"));
179 } catch (Exception e) {
180 logger.error("Failed to parse login page: {}", e.getMessage());
181 logger.debug("login page response {}", loginPageResponse.getContentAsString());
185 postData.add("identity", username);
186 postData.add("credential", password);
188 final org.eclipse.jetty.client.api.Request formSubmitRequest = httpClient
189 .newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
190 formSubmitRequest.method(HttpMethod.POST);
191 formSubmitRequest.content(new FormContentProvider(postData));
192 formSubmitRequest.followRedirects(false); // this should return a 302 ideally, but that location doesn't
194 addQueryParameters(formSubmitRequest, codeChallenge, state);
196 ContentResponse formSubmitResponse = executeHttpRequest(formSubmitRequest);
197 if (formSubmitResponse == null || formSubmitResponse.getStatus() != 302) {
198 logger.debug("Failed to obtain code from SSO login page when submitting form, response status code: {}",
199 (formSubmitResponse != null ? formSubmitResponse.getStatus() : "no response"));
203 String redirectLocation = formSubmitResponse.getHeaders().get(HttpHeader.LOCATION);
204 if (!isValidRedirectLocation(redirectLocation)) {
205 logger.debug("Redirect location not set or doesn't match expected callback URI {}: {}", URI_CALLBACK,
210 logger.debug("Obtained valid redirect location");
211 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
214 if (authorizationCode == null) {
215 logger.debug("Did not receive an authorization code");
219 // exchange authorization code for SSO access + refresh token
220 AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
222 String payload = gson.toJson(request);
224 final org.eclipse.jetty.client.api.Request tokenExchangeRequest = httpClient
225 .newRequest(URI_SSO + "/" + PATH_TOKEN);
226 tokenExchangeRequest.content(new StringContentProvider(payload));
227 tokenExchangeRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
228 tokenExchangeRequest.method(HttpMethod.POST);
230 ContentResponse response = executeHttpRequest(tokenExchangeRequest);
231 if (response != null && response.getStatus() == 200) {
232 String responsePayload = response.getContentAsString();
233 AuthorizationCodeExchangeResponse ssoTokenResponse = gson.fromJson(responsePayload.trim(),
234 AuthorizationCodeExchangeResponse.class);
235 if (ssoTokenResponse != null && ssoTokenResponse.token_type != null
236 && !ssoTokenResponse.access_token.isEmpty()) {
237 logger.debug("Obtained valid SSO refresh token");
238 return ssoTokenResponse.refresh_token;
241 logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
242 (response != null ? response.getStatus() : "no response"));
248 private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
249 return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
253 private String extractAuthorizationCodeFromUri(String uri) {
254 Field code = httpClient.newRequest(uri).getParams().get("code");
255 return code != null ? code.getValue() : null;
258 private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
259 MessageDigest digest = MessageDigest.getInstance("SHA-256");
260 byte[] hash = digest.digest(codeVerifier.getBytes());
262 StringBuilder hashStr = new StringBuilder(hash.length * 2);
263 for (byte b : hash) {
264 hashStr.append(String.format("%02x", b));
267 return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
270 private String generateRandomString(int length) {
271 Random random = new Random();
273 String generatedString = random.ints('a', 'z' + 1).limit(length)
274 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
276 return generatedString;
279 private void addQueryParameters(org.eclipse.jetty.client.api.Request request, String codeChallenge, String state) {
280 request.param("client_id", CLIENT_ID);
281 request.param("code_challenge", codeChallenge);
282 request.param("code_challenge_method", "S256");
283 request.param("redirect_uri", URI_CALLBACK);
284 request.param("response_type", "code");
285 request.param("scope", SSO_SCOPES);
286 request.param("state", state);
290 private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
291 request.timeout(10, TimeUnit.SECONDS);
293 ContentResponse response;
295 response = request.send();
297 } catch (InterruptedException | TimeoutException | ExecutionException e) {
298 logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());