]> git.basschouten.com Git - openhab-addons.git/blob
edae2f431bd03c32d0cc39af37c20e4abeaa8c33
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.tesla.internal.handler;
14
15 import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
16
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;
25
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;
45
46 import com.google.gson.Gson;
47
48 /**
49  * The {@link TeslaSSOHandler} is responsible for authenticating with the Tesla SSO service.
50  *
51  * @author Christian Güdel - Initial contribution
52  */
53 @NonNullByDefault
54 public class TeslaSSOHandler {
55
56     private final HttpClient httpClient;
57     private final Gson gson = new Gson();
58     private final Logger logger = LoggerFactory.getLogger(TeslaSSOHandler.class);
59
60     public TeslaSSOHandler(HttpClient httpClient) {
61         this.httpClient = httpClient;
62     }
63
64     @Nullable
65     public TokenResponse getAccessToken(String refreshToken) {
66         logger.debug("Exchanging SSO refresh token for API access token");
67
68         // get a new access token for the owner API token endpoint
69         RefreshTokenRequest refreshRequest = new RefreshTokenRequest(refreshToken);
70         String refreshTokenPayload = gson.toJson(refreshRequest);
71
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);
76
77         ContentResponse refreshResponse = executeHttpRequest(request);
78
79         if (refreshResponse != null && refreshResponse.getStatus() == 200) {
80             String refreshTokenResponse = refreshResponse.getContentAsString();
81             TokenResponse tokenResponse = gson.fromJson(refreshTokenResponse.trim(), TokenResponse.class);
82
83             if (tokenResponse != null && tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
84                 return tokenResponse;
85             } else {
86                 logger.debug("An error occurred while exchanging SSO auth token for API access token.");
87             }
88         } else {
89             logger.debug("An error occurred during refresh of SSO token: {}",
90                     (refreshResponse != null ? refreshResponse.getStatus() : "no response"));
91         }
92
93         return null;
94     }
95
96     /**
97      * Authenticates using username/password against Tesla SSO endpoints.
98      *
99      * @param username Username
100      * @param password Password
101      * @return Refresh token for use with {@link getAccessToken}
102      */
103     @Nullable
104     public String authenticate(String username, String password) {
105         String codeVerifier = generateRandomString(86);
106         String codeChallenge = null;
107         String state = generateRandomString(10);
108
109         try {
110             codeChallenge = getCodeChallenge(codeVerifier);
111         } catch (NoSuchAlgorithmException e) {
112             logger.error("An exception occurred while building login page request: '{}'", e.getMessage());
113             return null;
114         }
115
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);
120
121         addQueryParameters(loginPageRequest, codeChallenge, state);
122
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"));
128             return null;
129         }
130
131         logger.debug("Obtained SSO login page");
132
133         String authorizationCode = null;
134
135         if (loginPageResponse.getStatus() == 302) {
136             String redirectLocation = loginPageResponse.getHeaders().get(HttpHeader.LOCATION);
137             if (isValidRedirectLocation(redirectLocation)) {
138                 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
139             } else {
140                 logger.debug("Unexpected redirect location received when fetching login page: {}", redirectLocation);
141                 return null;
142             }
143         } else {
144             Fields postData = new Fields();
145
146             try {
147                 Document doc = Jsoup.parse(loginPageResponse.getContentAsString());
148                 logger.trace("{}", doc.toString());
149                 Element loginForm = doc.getElementsByTag("form").first();
150
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"));
156                     }
157                 }
158             } catch (Exception e) {
159                 logger.error("Failed to parse login page: {}", e.getMessage());
160                 logger.debug("login page response {}", loginPageResponse.getContentAsString());
161                 return null;
162             }
163
164             postData.add("identity", username);
165             postData.add("credential", password);
166
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
172                                                       // exist
173             addQueryParameters(formSubmitRequest, codeChallenge, state);
174
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"));
179                 return null;
180             }
181
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,
185                         redirectLocation);
186                 return null;
187             }
188
189             logger.debug("Obtained valid redirect location");
190             authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
191         }
192
193         if (authorizationCode == null) {
194             logger.debug("Did not receive an authorization code");
195             return null;
196         }
197
198         // exchange authorization code for SSO access + refresh token
199         AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
200                 codeVerifier);
201         String payload = gson.toJson(request);
202
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);
208
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;
218             }
219         } else {
220             logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
221                     (response != null ? response.getStatus() : "no response"));
222         }
223
224         return null;
225     }
226
227     private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
228         return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
229     }
230
231     @Nullable
232     private String extractAuthorizationCodeFromUri(String uri) {
233         Field code = httpClient.newRequest(uri).getParams().get("code");
234         return code != null ? code.getValue() : null;
235     }
236
237     private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
238         MessageDigest digest = MessageDigest.getInstance("SHA-256");
239         byte[] hash = digest.digest(codeVerifier.getBytes());
240
241         StringBuilder hashStr = new StringBuilder(hash.length * 2);
242         for (byte b : hash) {
243             hashStr.append(String.format("%02x", b));
244         }
245
246         return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
247     }
248
249     private String generateRandomString(int length) {
250         Random random = new Random();
251
252         String generatedString = random.ints('a', 'z' + 1).limit(length)
253                 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
254
255         return generatedString;
256     }
257
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);
266     }
267
268     @Nullable
269     private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
270         request.timeout(10, TimeUnit.SECONDS);
271
272         ContentResponse response;
273         try {
274             response = request.send();
275             return response;
276         } catch (InterruptedException | TimeoutException | ExecutionException e) {
277             logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());
278             return null;
279         }
280     }
281 }