2 * Copyright (c) 2010-2021 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 Element loginForm = doc.getElementsByTag("form").first();
171 Iterator<Element> elIt = loginForm.getElementsByTag("input").iterator();
172 while (elIt.hasNext()) {
173 Element input = elIt.next();
174 if (input.attr("type").equalsIgnoreCase("hidden")) {
175 postData.add(input.attr("name"), input.attr("value"));
178 } catch (Exception e) {
179 logger.error("Failed to parse login page: {}", e.getMessage());
180 logger.debug("login page response {}", loginPageResponse.getContentAsString());
184 postData.add("identity", username);
185 postData.add("credential", password);
187 final org.eclipse.jetty.client.api.Request formSubmitRequest = httpClient
188 .newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
189 formSubmitRequest.method(HttpMethod.POST);
190 formSubmitRequest.content(new FormContentProvider(postData));
191 formSubmitRequest.followRedirects(false); // this should return a 302 ideally, but that location doesn't
193 addQueryParameters(formSubmitRequest, codeChallenge, state);
195 ContentResponse formSubmitResponse = executeHttpRequest(formSubmitRequest);
196 if (formSubmitResponse == null || formSubmitResponse.getStatus() != 302) {
197 logger.debug("Failed to obtain code from SSO login page when submitting form, response status code: {}",
198 (formSubmitResponse != null ? formSubmitResponse.getStatus() : "no response"));
202 String redirectLocation = formSubmitResponse.getHeaders().get(HttpHeader.LOCATION);
203 if (!isValidRedirectLocation(redirectLocation)) {
204 logger.debug("Redirect location not set or doesn't match expected callback URI {}: {}", URI_CALLBACK,
209 logger.debug("Obtained valid redirect location");
210 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
213 if (authorizationCode == null) {
214 logger.debug("Did not receive an authorization code");
218 // exchange authorization code for SSO access + refresh token
219 AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
221 String payload = gson.toJson(request);
223 final org.eclipse.jetty.client.api.Request tokenExchangeRequest = httpClient
224 .newRequest(URI_SSO + "/" + PATH_TOKEN);
225 tokenExchangeRequest.content(new StringContentProvider(payload));
226 tokenExchangeRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
227 tokenExchangeRequest.method(HttpMethod.POST);
229 ContentResponse response = executeHttpRequest(tokenExchangeRequest);
230 if (response != null && response.getStatus() == 200) {
231 String responsePayload = response.getContentAsString();
232 AuthorizationCodeExchangeResponse ssoTokenResponse = gson.fromJson(responsePayload.trim(),
233 AuthorizationCodeExchangeResponse.class);
234 if (ssoTokenResponse != null && ssoTokenResponse.token_type != null
235 && !ssoTokenResponse.access_token.isEmpty()) {
236 logger.debug("Obtained valid SSO refresh token");
237 return ssoTokenResponse.refresh_token;
240 logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
241 (response != null ? response.getStatus() : "no response"));
247 private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
248 return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
252 private String extractAuthorizationCodeFromUri(String uri) {
253 Field code = httpClient.newRequest(uri).getParams().get("code");
254 return code != null ? code.getValue() : null;
257 private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
258 MessageDigest digest = MessageDigest.getInstance("SHA-256");
259 byte[] hash = digest.digest(codeVerifier.getBytes());
261 StringBuilder hashStr = new StringBuilder(hash.length * 2);
262 for (byte b : hash) {
263 hashStr.append(String.format("%02x", b));
266 return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
269 private String generateRandomString(int length) {
270 Random random = new Random();
272 String generatedString = random.ints('a', 'z' + 1).limit(length)
273 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
275 return generatedString;
278 private void addQueryParameters(org.eclipse.jetty.client.api.Request request, String codeChallenge, String state) {
279 request.param("client_id", CLIENT_ID);
280 request.param("code_challenge", codeChallenge);
281 request.param("code_challenge_method", "S256");
282 request.param("redirect_uri", URI_CALLBACK);
283 request.param("response_type", "code");
284 request.param("scope", SSO_SCOPES);
285 request.param("state", state);
289 private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
290 request.timeout(10, TimeUnit.SECONDS);
292 ContentResponse response;
294 response = request.send();
296 } catch (InterruptedException | TimeoutException | ExecutionException e) {
297 logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());