]> git.basschouten.com Git - openhab-addons.git/blob
749733ec9ccebb8998ccf94dd38c72e8777e484a
[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.TokenExchangeRequest;
43 import org.openhab.binding.tesla.internal.protocol.sso.TokenResponse;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
46
47 import com.google.gson.Gson;
48
49 /**
50  * The {@link TeslaSSOHandler} is responsible for authenticating with the Tesla SSO service.
51  *
52  * @author Christian Güdel - Initial contribution
53  */
54 @NonNullByDefault
55 public class TeslaSSOHandler {
56
57     private final HttpClient httpClient;
58     private final Gson gson = new Gson();
59     private final Logger logger = LoggerFactory.getLogger(TeslaSSOHandler.class);
60
61     public TeslaSSOHandler(HttpClient httpClient) {
62         this.httpClient = httpClient;
63     }
64
65     @Nullable
66     public TokenResponse getAccessToken(String refreshToken) {
67         logger.debug("Exchanging SSO refresh token for API access token");
68
69         // get a new access token for the owner API token endpoint
70         RefreshTokenRequest refreshRequest = new RefreshTokenRequest(refreshToken);
71         String refreshTokenPayload = gson.toJson(refreshRequest);
72
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);
77
78         ContentResponse refreshResponse = executeHttpRequest(request);
79
80         if (refreshResponse != null && refreshResponse.getStatus() == 200) {
81             String refreshTokenResponse = refreshResponse.getContentAsString();
82             TokenResponse tokenResponse = gson.fromJson(refreshTokenResponse.trim(), TokenResponse.class);
83
84             if (tokenResponse != null && tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
85                 TokenExchangeRequest token = new TokenExchangeRequest();
86                 String tokenPayload = gson.toJson(token);
87
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);
94
95                 ContentResponse logonTokenResponse = executeHttpRequest(logonRequest);
96
97                 if (logonTokenResponse != null && logonTokenResponse.getStatus() == 200) {
98                     String tokenResponsePayload = logonTokenResponse.getContentAsString();
99                     TokenResponse tr = gson.fromJson(tokenResponsePayload.trim(), TokenResponse.class);
100
101                     if (tr != null && tr.token_type != null && !tr.access_token.isEmpty()) {
102                         return tr;
103                     }
104                 } else {
105                     logger.debug("An error occurred while exchanging SSO access token for API access token: {}",
106                             (logonTokenResponse != null ? logonTokenResponse.getStatus() : "no response"));
107                 }
108             }
109         } else {
110             logger.debug("An error occurred during refresh of SSO token: {}",
111                     (refreshResponse != null ? refreshResponse.getStatus() : "no response"));
112         }
113
114         return null;
115     }
116
117     /**
118      * Authenticates using username/password against Tesla SSO endpoints.
119      *
120      * @param username Username
121      * @param password Password
122      * @return Refresh token for use with {@link getAccessToken}
123      */
124     @Nullable
125     public String authenticate(String username, String password) {
126         String codeVerifier = generateRandomString(86);
127         String codeChallenge = null;
128         String state = generateRandomString(10);
129
130         try {
131             codeChallenge = getCodeChallenge(codeVerifier);
132         } catch (NoSuchAlgorithmException e) {
133             logger.error("An exception occurred while building login page request: '{}'", e.getMessage());
134             return null;
135         }
136
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);
141
142         addQueryParameters(loginPageRequest, codeChallenge, state);
143
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"));
149             return null;
150         }
151
152         logger.debug("Obtained SSO login page");
153
154         String authorizationCode = null;
155
156         if (loginPageResponse.getStatus() == 302) {
157             String redirectLocation = loginPageResponse.getHeaders().get(HttpHeader.LOCATION);
158             if (isValidRedirectLocation(redirectLocation)) {
159                 authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
160             } else {
161                 logger.debug("Unexpected redirect location received when fetching login page: {}", redirectLocation);
162                 return null;
163             }
164         } else {
165             Fields postData = new Fields();
166
167             try {
168                 Document doc = Jsoup.parse(loginPageResponse.getContentAsString());
169                 Element loginForm = doc.getElementsByTag("form").first();
170
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"));
176                     }
177                 }
178             } catch (Exception e) {
179                 logger.error("Failed to parse login page: {}", e.getMessage());
180                 logger.debug("login page response {}", loginPageResponse.getContentAsString());
181                 return null;
182             }
183
184             postData.add("identity", username);
185             postData.add("credential", password);
186
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
192                                                       // exist
193             addQueryParameters(formSubmitRequest, codeChallenge, state);
194
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"));
199                 return null;
200             }
201
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,
205                         redirectLocation);
206                 return null;
207             }
208
209             logger.debug("Obtained valid redirect location");
210             authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
211         }
212
213         if (authorizationCode == null) {
214             logger.debug("Did not receive an authorization code");
215             return null;
216         }
217
218         // exchange authorization code for SSO access + refresh token
219         AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
220                 codeVerifier);
221         String payload = gson.toJson(request);
222
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);
228
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;
238             }
239         } else {
240             logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
241                     (response != null ? response.getStatus() : "no response"));
242         }
243
244         return null;
245     }
246
247     private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
248         return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
249     }
250
251     @Nullable
252     private String extractAuthorizationCodeFromUri(String uri) {
253         Field code = httpClient.newRequest(uri).getParams().get("code");
254         return code != null ? code.getValue() : null;
255     }
256
257     private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
258         MessageDigest digest = MessageDigest.getInstance("SHA-256");
259         byte[] hash = digest.digest(codeVerifier.getBytes());
260
261         StringBuilder hashStr = new StringBuilder(hash.length * 2);
262         for (byte b : hash) {
263             hashStr.append(String.format("%02x", b));
264         }
265
266         return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
267     }
268
269     private String generateRandomString(int length) {
270         Random random = new Random();
271
272         String generatedString = random.ints('a', 'z' + 1).limit(length)
273                 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
274
275         return generatedString;
276     }
277
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);
286     }
287
288     @Nullable
289     private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
290         request.timeout(10, TimeUnit.SECONDS);
291
292         ContentResponse response;
293         try {
294             response = request.send();
295             return response;
296         } catch (InterruptedException | TimeoutException | ExecutionException e) {
297             logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());
298             return null;
299         }
300     }
301 }