[Tesla] Add SSO handler to authenticate against Tesla SSO service (#10259)

Signed-off-by: Christian Güdel <cg@dmesg.ch>
pull/10265/head
Christian Güdel 2021-03-01 15:19:10 +01:00 committed by GitHub
parent 9c112a9a1d
commit 62e829f968
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 502 additions and 313 deletions

View File

@ -14,4 +14,13 @@
<name>openHAB Add-ons :: Bundles :: Tesla Binding</name>
<dependencies>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.8.3</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -31,13 +31,21 @@ public class TeslaBindingConstants {
public static final String PATH_DATA_REQUEST = "data_request/{cmd}";
public static final String PATH_VEHICLE_ID = "/{vid}/";
public static final String PATH_WAKE_UP = "wake_up";
public static final String URI_ACCESS_TOKEN = "oauth/token";
public static final String PATH_ACCESS_TOKEN = "oauth/token";
public static final String URI_EVENT = "https://streaming.vn.teslamotors.com/stream/";
public static final String URI_OWNERS = "https://owner-api.teslamotors.com/";
public static final String URI_OWNERS = "https://owner-api.teslamotors.com";
public static final String VALETPIN = "valetpin";
public static final String VEHICLES = "vehicles";
public static final String VIN = "vin";
// SSO URI constants
public static final String SSO_SCOPES = "openid email offline_access";
public static final String URI_SSO = "https://auth.tesla.com/oauth2/v3";
public static final String PATH_AUTHORIZE = "authorize";
public static final String PATH_TOKEN = "token";
public static final String URI_CALLBACK = "https://auth.tesla.com/void/callback";
public static final String CLIENT_ID = "ownerapi";
// Tesla REST API commands
public static final String COMMAND_ACTUATE_TRUNK = "actuate_trunk";
public static final String COMMAND_AUTO_COND_START = "auto_conditioning_start";

View File

@ -23,6 +23,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.tesla.internal.handler.TeslaAccountHandler;
import org.openhab.binding.tesla.internal.handler.TeslaVehicleHandler;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
@ -52,12 +53,14 @@ public class TeslaHandlerFactory extends BaseThingHandlerFactory {
THING_TYPE_MODEL3, THING_TYPE_MODELX, THING_TYPE_MODELY);
private final ClientBuilder clientBuilder;
private final HttpClientFactory httpClientFactory;
@Activate
public TeslaHandlerFactory(@Reference ClientBuilder clientBuilder) {
public TeslaHandlerFactory(@Reference ClientBuilder clientBuilder, @Reference HttpClientFactory httpClientFactory) {
this.clientBuilder = clientBuilder //
.connectTimeout(EVENT_STREAM_CONNECT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(EVENT_STREAM_READ_TIMEOUT, TimeUnit.SECONDS);
this.httpClientFactory = httpClientFactory;
}
@Override
@ -70,7 +73,7 @@ public class TeslaHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_ACCOUNT)) {
return new TeslaAccountHandler((Bridge) thing, clientBuilder.build());
return new TeslaAccountHandler((Bridge) thing, clientBuilder.build(), httpClientFactory);
} else {
return new TeslaVehicleHandler(thing, clientBuilder);
}

View File

@ -12,42 +12,30 @@
*/
package org.openhab.binding.tesla.internal.command;
import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.List;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.tesla.internal.TeslaBindingConstants;
import org.openhab.binding.tesla.internal.discovery.TeslaAccountDiscoveryService;
import org.openhab.binding.tesla.internal.protocol.TokenRequest;
import org.openhab.binding.tesla.internal.protocol.TokenRequestPassword;
import org.openhab.binding.tesla.internal.protocol.TokenResponse;
import org.openhab.binding.tesla.internal.handler.TeslaSSOHandler;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.util.UIDUtils;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* Console commands for interacting with the Tesla integration
@ -61,19 +49,18 @@ public class TeslaCommandExtension extends AbstractConsoleCommandExtension {
private static final String CMD_LOGIN = "login";
private final Logger logger = LoggerFactory.getLogger(TeslaCommandExtension.class);
@Reference(cardinality = ReferenceCardinality.OPTIONAL)
private @Nullable ClientBuilder injectedClientBuilder;
private @Nullable WebTarget tokenTarget;
private final TeslaAccountDiscoveryService teslaAccountDiscoveryService;
private final HttpClientFactory httpClientFactory;
@Activate
public TeslaCommandExtension(@Reference TeslaAccountDiscoveryService teslaAccountDiscoveryService) {
public TeslaCommandExtension(@Reference TeslaAccountDiscoveryService teslaAccountDiscoveryService,
@Reference HttpClientFactory httpClientFactory) {
super("tesla", "Interact with the Tesla integration.");
this.teslaAccountDiscoveryService = teslaAccountDiscoveryService;
this.httpClientFactory = httpClientFactory;
}
@Override
@ -120,59 +107,20 @@ public class TeslaCommandExtension extends AbstractConsoleCommandExtension {
}
private void login(Console console, String username, String password) {
try {
Gson gson = new Gson();
TeslaSSOHandler ssoHandler = new TeslaSSOHandler(httpClientFactory.getCommonHttpClient());
TokenRequest token = new TokenRequestPassword(username, password);
String payLoad = gson.toJson(token);
String refreshToken = ssoHandler.authenticate(username, password);
if (refreshToken != null) {
console.println("Refresh token: " + refreshToken);
Response response = getTokenTarget().request()
.post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
if (response != null) {
if (response.getStatus() == 200 && response.hasEntity()) {
String responsePayLoad = response.readEntity(String.class);
TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
console.println("Refresh token: " + tokenResponse.refresh_token);
ThingUID thingUID = new ThingUID(TeslaBindingConstants.THING_TYPE_ACCOUNT,
UIDUtils.encode(username));
DiscoveryResult result = DiscoveryResultBuilder.create(thingUID).withLabel("Tesla Account")
.withProperty(TeslaBindingConstants.CONFIG_REFRESHTOKEN, tokenResponse.refresh_token)
.withProperty(TeslaBindingConstants.CONFIG_USERNAME, username)
.withRepresentationProperty(TeslaBindingConstants.CONFIG_USERNAME).build();
teslaAccountDiscoveryService.thingDiscovered(result);
} else {
console.println(
"Failure: " + response.getStatus() + " " + response.getStatusInfo().getReasonPhrase());
}
}
} catch (Exception e) {
console.println("Failed to retrieve token: " + e.getMessage());
logger.error("Could not get refresh token.", e);
}
}
private synchronized WebTarget getTokenTarget() {
WebTarget target = this.tokenTarget;
if (target != null) {
return target;
ThingUID thingUID = new ThingUID(TeslaBindingConstants.THING_TYPE_ACCOUNT, UIDUtils.encode(username));
DiscoveryResult result = DiscoveryResultBuilder.create(thingUID).withLabel("Tesla Account")
.withProperty(TeslaBindingConstants.CONFIG_REFRESHTOKEN, refreshToken)
.withProperty(TeslaBindingConstants.CONFIG_USERNAME, username)
.withRepresentationProperty(TeslaBindingConstants.CONFIG_USERNAME).build();
teslaAccountDiscoveryService.thingDiscovered(result);
} else {
Client client;
try {
client = ClientBuilder.newBuilder().build();
} catch (Exception e) {
// we seem to have no Jersey, so let's hope for an injected builder by CXF
if (this.injectedClientBuilder != null) {
client = injectedClientBuilder.build();
} else {
throw new IllegalStateException("No JAX RS Client Builder available.");
}
}
WebTarget teslaTarget = client.target(URI_OWNERS);
target = teslaTarget.path(URI_ACCESS_TOKEN);
this.tokenTarget = target;
return target;
console.println("Failed to retrieve refresh token");
}
}
}

View File

@ -16,7 +16,6 @@ import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@ -30,7 +29,6 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import javax.ws.rs.ProcessingException;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientRequestContext;
import javax.ws.rs.client.ClientRequestFilter;
@ -42,13 +40,11 @@ import javax.ws.rs.core.Response;
import org.openhab.binding.tesla.internal.TeslaBindingConstants;
import org.openhab.binding.tesla.internal.discovery.TeslaVehicleDiscoveryService;
import org.openhab.binding.tesla.internal.protocol.TokenRequest;
import org.openhab.binding.tesla.internal.protocol.TokenRequestPassword;
import org.openhab.binding.tesla.internal.protocol.TokenRequestRefreshToken;
import org.openhab.binding.tesla.internal.protocol.TokenResponse;
import org.openhab.binding.tesla.internal.protocol.Vehicle;
import org.openhab.binding.tesla.internal.protocol.VehicleConfig;
import org.openhab.binding.tesla.internal.protocol.sso.TokenResponse;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
@ -85,13 +81,14 @@ public class TeslaAccountHandler extends BaseBridgeHandler {
// REST Client API variables
private final WebTarget teslaTarget;
private final WebTarget tokenTarget;
WebTarget vehiclesTarget; // this cannot be marked final as it is used in the runnable
final WebTarget vehicleTarget;
final WebTarget dataRequestTarget;
final WebTarget commandTarget;
final WebTarget wakeUpTarget;
private final TeslaSSOHandler ssoHandler;
// Threading and Job related variables
protected ScheduledFuture<?> connectJob;
@ -108,10 +105,11 @@ public class TeslaAccountHandler extends BaseBridgeHandler {
private TokenResponse logonToken;
private final Set<VehicleListener> vehicleListeners = new HashSet<>();
public TeslaAccountHandler(Bridge bridge, Client teslaClient) {
public TeslaAccountHandler(Bridge bridge, Client teslaClient, HttpClientFactory httpClientFactory) {
super(bridge);
this.teslaTarget = teslaClient.target(URI_OWNERS);
this.tokenTarget = teslaTarget.path(URI_ACCESS_TOKEN);
this.ssoHandler = new TeslaSSOHandler(httpClientFactory.getCommonHttpClient());
this.vehiclesTarget = teslaTarget.path(API_VERSION).path(VEHICLES);
this.vehicleTarget = vehiclesTarget.path(PATH_VEHICLE_ID);
this.dataRequestTarget = vehicleTarget.path(PATH_DATA_REQUEST);
@ -272,108 +270,44 @@ public class TeslaAccountHandler extends BaseBridgeHandler {
if (hasExpired) {
String username = (String) getConfig().get(CONFIG_USERNAME);
String password = (String) getConfig().get(CONFIG_PASSWORD);
String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
if (refreshToken == null || refreshToken.isEmpty()) {
if (username != null && !username.isEmpty()) {
String password = (String) getConfig().get(CONFIG_PASSWORD);
return authenticate(username, password);
if (username != null && !username.isEmpty() && password != null && !password.isEmpty()) {
try {
refreshToken = ssoHandler.authenticate(username, password);
} catch (Exception e) {
logger.error("An exception occurred while obtaining refresh token with username/password: '{}'",
e.getMessage());
}
if (refreshToken != null) {
// store refresh token from SSO endpoint in config, clear the password
Configuration cfg = editConfiguration();
cfg.put(TeslaBindingConstants.CONFIG_REFRESHTOKEN, refreshToken);
cfg.remove(TeslaBindingConstants.CONFIG_PASSWORD);
updateConfiguration(cfg);
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Failed to obtain refresh token with username/password.");
}
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Neither a refresh token nor credentials are provided.");
}
}
TokenRequestRefreshToken tokenRequest = null;
try {
tokenRequest = new TokenRequestRefreshToken(refreshToken);
} catch (GeneralSecurityException e) {
logger.error("An exception occurred while requesting a new token: '{}'", e.getMessage(), e);
}
String payLoad = gson.toJson(tokenRequest);
Response response = null;
try {
response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
} catch (ProcessingException e) {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
logger.debug("Authenticating: Response: {}:{}", response.getStatus(), response.getStatusInfo());
if (response.getStatus() == 200 && response.hasEntity()) {
String responsePayLoad = response.readEntity(String.class);
TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
if (!refreshToken.equals(tokenResponse.refresh_token)) {
Configuration configuration = editConfiguration();
configuration.put(CONFIG_REFRESHTOKEN, tokenResponse.refresh_token);
updateConfiguration(configuration);
}
if (tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
this.logonToken = tokenResponse;
logger.trace("Access Token is {}", logonToken.access_token);
}
return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
} else if (response.getStatus() == 401) {
if (username != null && !username.isEmpty()) {
String password = (String) getConfig().get(CONFIG_PASSWORD);
return authenticate(username, password);
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Refresh token is not valid and no credentials are provided.");
}
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"HTTP returncode " + response.getStatus());
this.logonToken = ssoHandler.getAccessToken(refreshToken);
if (this.logonToken == null) {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Failed to obtain access token for API.");
}
}
return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
}
private ThingStatusInfo authenticate(String username, String password) {
TokenRequest token = null;
try {
token = new TokenRequestPassword(username, password);
} catch (GeneralSecurityException e) {
logger.error("An exception occurred while building a password request token: '{}'", e.getMessage(), e);
}
if (token != null) {
String payLoad = gson.toJson(token);
Response response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
if (response != null) {
logger.debug("Authenticating: Response : {}:{}", response.getStatus(), response.getStatusInfo());
if (response.getStatus() == 200 && response.hasEntity()) {
String responsePayLoad = response.readEntity(String.class);
TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
if (tokenResponse.token_type != null && !tokenResponse.access_token.isEmpty()) {
this.logonToken = tokenResponse;
Configuration cfg = editConfiguration();
cfg.put(TeslaBindingConstants.CONFIG_REFRESHTOKEN, logonToken.refresh_token);
cfg.remove(TeslaBindingConstants.CONFIG_PASSWORD);
updateConfiguration(cfg);
return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
}
} else if (response.getStatus() == 401) {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Invalid credentials.");
} else {
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"HTTP returncode " + response.getStatus());
}
} else {
logger.debug("Authenticating: Response was null");
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Failed retrieving a response from the server.");
}
}
return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Cannot build request from credentials.");
}
protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target) {
logger.debug("Invoking: {}", command);
@ -424,7 +358,9 @@ public class TeslaAccountHandler extends BaseBridgeHandler {
try {
lock.lock();
if (getThing().getStatus() != ThingStatus.ONLINE) {
ThingStatusInfo status = getThing().getStatusInfo();
if (status.getStatus() != ThingStatus.ONLINE
&& status.getStatusDetail() != ThingStatusDetail.CONFIGURATION_ERROR) {
logger.debug("Setting up an authenticated connection to the Tesla back-end");
ThingStatusInfo authenticationResult = authenticate();
@ -471,7 +407,12 @@ public class TeslaAccountHandler extends BaseBridgeHandler {
updateStatus(ThingStatus.OFFLINE);
}
}
} else if (authenticationResult.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
// make sure to set thing to CONFIGURATION_ERROR in case of failed authentication in order not to
// hit request limit on retries on the Tesla SSO endpoints.
updateStatus(ThingStatus.OFFLINE, authenticationResult.getStatusDetail());
}
}
} catch (Exception e) {
logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);

View File

@ -0,0 +1,301 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.handler;
import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Iterator;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.util.FormContentProvider;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.util.Fields;
import org.eclipse.jetty.util.Fields.Field;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.openhab.binding.tesla.internal.protocol.sso.AuthorizationCodeExchangeRequest;
import org.openhab.binding.tesla.internal.protocol.sso.AuthorizationCodeExchangeResponse;
import org.openhab.binding.tesla.internal.protocol.sso.RefreshTokenRequest;
import org.openhab.binding.tesla.internal.protocol.sso.TokenExchangeRequest;
import org.openhab.binding.tesla.internal.protocol.sso.TokenResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link TeslaSSOHandler} is responsible for authenticating with the Tesla SSO service.
*
* @author Christian Güdel - Initial contribution
*/
@NonNullByDefault
public class TeslaSSOHandler {
private final HttpClient httpClient;
private final Gson gson = new Gson();
private final Logger logger = LoggerFactory.getLogger(TeslaSSOHandler.class);
public TeslaSSOHandler(HttpClient httpClient) {
this.httpClient = httpClient;
}
@Nullable
public TokenResponse getAccessToken(String refreshToken) {
logger.debug("Exchanging SSO refresh token for API access token");
// get a new access token for the owner API token endpoint
RefreshTokenRequest refreshRequest = new RefreshTokenRequest(refreshToken);
String refreshTokenPayload = gson.toJson(refreshRequest);
final org.eclipse.jetty.client.api.Request request = httpClient.newRequest(URI_SSO + "/" + PATH_TOKEN);
request.content(new StringContentProvider(refreshTokenPayload));
request.header(HttpHeader.CONTENT_TYPE, "application/json");
request.method(HttpMethod.POST);
ContentResponse refreshResponse = executeHttpRequest(request);
if (refreshResponse != null && refreshResponse.getStatus() == 200) {
String refreshTokenResponse = refreshResponse.getContentAsString();
TokenResponse tokenResponse = gson.fromJson(refreshTokenResponse.trim(), TokenResponse.class);
if (tokenResponse != null && tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
TokenExchangeRequest token = new TokenExchangeRequest();
String tokenPayload = gson.toJson(token);
final org.eclipse.jetty.client.api.Request logonRequest = httpClient
.newRequest(URI_OWNERS + "/" + PATH_ACCESS_TOKEN);
logonRequest.content(new StringContentProvider(tokenPayload));
logonRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
logonRequest.header(HttpHeader.AUTHORIZATION, "Bearer " + tokenResponse.access_token);
logonRequest.method(HttpMethod.POST);
ContentResponse logonTokenResponse = executeHttpRequest(logonRequest);
if (logonTokenResponse != null && logonTokenResponse.getStatus() == 200) {
String tokenResponsePayload = logonTokenResponse.getContentAsString();
TokenResponse tr = gson.fromJson(tokenResponsePayload.trim(), TokenResponse.class);
if (tr != null && tr.token_type != null && !tr.access_token.isEmpty()) {
return tr;
}
} else {
logger.debug("An error occurred while exchanging SSO access token for API access token: {}",
(logonTokenResponse != null ? logonTokenResponse.getStatus() : "no response"));
}
}
} else {
logger.debug("An error occurred during refresh of SSO token: {}",
(refreshResponse != null ? refreshResponse.getStatus() : "no response"));
}
return null;
}
/**
* Authenticates using username/password against Tesla SSO endpoints.
*
* @param username Username
* @param password Password
* @return Refresh token for use with {@link getAccessToken}
*/
@Nullable
public String authenticate(String username, String password) {
String codeVerifier = generateRandomString(86);
String codeChallenge = null;
String state = generateRandomString(10);
try {
codeChallenge = getCodeChallenge(codeVerifier);
} catch (NoSuchAlgorithmException e) {
logger.error("An exception occurred while building login page request: '{}'", e.getMessage());
return null;
}
final org.eclipse.jetty.client.api.Request loginPageRequest = httpClient
.newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
loginPageRequest.method(HttpMethod.GET);
loginPageRequest.followRedirects(false);
addQueryParameters(loginPageRequest, codeChallenge, state);
ContentResponse loginPageResponse = executeHttpRequest(loginPageRequest);
if (loginPageResponse == null
|| (loginPageResponse.getStatus() != 200 && loginPageResponse.getStatus() != 302)) {
logger.debug("Failed to obtain SSO login page, response status code: {}",
(loginPageResponse != null ? loginPageResponse.getStatus() : "no response"));
return null;
}
logger.debug("Obtained SSO login page");
String authorizationCode = null;
if (loginPageResponse.getStatus() == 302) {
String redirectLocation = loginPageResponse.getHeaders().get(HttpHeader.LOCATION);
if (isValidRedirectLocation(redirectLocation)) {
authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
} else {
logger.debug("Unexpected redirect location received when fetching login page: {}", redirectLocation);
return null;
}
} else {
Fields postData = new Fields();
try {
Document doc = Jsoup.parse(loginPageResponse.getContentAsString());
Element loginForm = doc.getElementsByTag("form").first();
Iterator<Element> elIt = loginForm.getElementsByTag("input").iterator();
while (elIt.hasNext()) {
Element input = elIt.next();
if (input.attr("type").equalsIgnoreCase("hidden")) {
postData.add(input.attr("name"), input.attr("value"));
}
}
} catch (Exception e) {
logger.error("Failed to parse login page: {}", e.getMessage());
logger.debug("login page response {}", loginPageResponse.getContentAsString());
return null;
}
postData.add("identity", username);
postData.add("credential", password);
final org.eclipse.jetty.client.api.Request formSubmitRequest = httpClient
.newRequest(URI_SSO + "/" + PATH_AUTHORIZE);
formSubmitRequest.method(HttpMethod.POST);
formSubmitRequest.content(new FormContentProvider(postData));
formSubmitRequest.followRedirects(false); // this should return a 302 ideally, but that location doesn't
// exist
addQueryParameters(formSubmitRequest, codeChallenge, state);
ContentResponse formSubmitResponse = executeHttpRequest(formSubmitRequest);
if (formSubmitResponse == null || formSubmitResponse.getStatus() != 302) {
logger.debug("Failed to obtain code from SSO login page when submitting form, response status code: {}",
(formSubmitResponse != null ? formSubmitResponse.getStatus() : "no response"));
return null;
}
String redirectLocation = formSubmitResponse.getHeaders().get(HttpHeader.LOCATION);
if (!isValidRedirectLocation(redirectLocation)) {
logger.debug("Redirect location not set or doesn't match expected callback URI {}: {}", URI_CALLBACK,
redirectLocation);
return null;
}
logger.debug("Obtained valid redirect location");
authorizationCode = extractAuthorizationCodeFromUri(redirectLocation);
}
if (authorizationCode == null) {
logger.debug("Did not receive an authorization code");
return null;
}
// exchange authorization code for SSO access + refresh token
AuthorizationCodeExchangeRequest request = new AuthorizationCodeExchangeRequest(authorizationCode,
codeVerifier);
String payload = gson.toJson(request);
final org.eclipse.jetty.client.api.Request tokenExchangeRequest = httpClient
.newRequest(URI_SSO + "/" + PATH_TOKEN);
tokenExchangeRequest.content(new StringContentProvider(payload));
tokenExchangeRequest.header(HttpHeader.CONTENT_TYPE, "application/json");
tokenExchangeRequest.method(HttpMethod.POST);
ContentResponse response = executeHttpRequest(tokenExchangeRequest);
if (response != null && response.getStatus() == 200) {
String responsePayload = response.getContentAsString();
AuthorizationCodeExchangeResponse ssoTokenResponse = gson.fromJson(responsePayload.trim(),
AuthorizationCodeExchangeResponse.class);
if (ssoTokenResponse != null && ssoTokenResponse.token_type != null
&& !ssoTokenResponse.access_token.isEmpty()) {
logger.debug("Obtained valid SSO refresh token");
return ssoTokenResponse.refresh_token;
}
} else {
logger.debug("An error occurred while exchanging authorization code for SSO refresh token: {}",
(response != null ? response.getStatus() : "no response"));
}
return null;
}
private Boolean isValidRedirectLocation(@Nullable String redirectLocation) {
return redirectLocation != null && redirectLocation.startsWith(URI_CALLBACK);
}
@Nullable
private String extractAuthorizationCodeFromUri(String uri) {
Field code = httpClient.newRequest(uri).getParams().get("code");
return code != null ? code.getValue() : null;
}
private String getCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes());
StringBuilder hashStr = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hashStr.append(String.format("%02x", b));
}
return Base64.getUrlEncoder().encodeToString(hashStr.toString().getBytes());
}
private String generateRandomString(int length) {
Random random = new Random();
String generatedString = random.ints('a', 'z' + 1).limit(length)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
return generatedString;
}
private void addQueryParameters(org.eclipse.jetty.client.api.Request request, String codeChallenge, String state) {
request.param("client_id", CLIENT_ID);
request.param("code_challenge", codeChallenge);
request.param("code_challenge_method", "S256");
request.param("redirect_uri", URI_CALLBACK);
request.param("response_type", "code");
request.param("scope", SSO_SCOPES);
request.param("state", state);
}
@Nullable
private ContentResponse executeHttpRequest(org.eclipse.jetty.client.api.Request request) {
request.timeout(10, TimeUnit.SECONDS);
ContentResponse response;
try {
response = request.send();
return response;
} catch (InterruptedException | TimeoutException | ExecutionException e) {
logger.debug("An exception occurred while invoking a HTTP request: '{}'", e.getMessage());
return null;
}
}
}

View File

@ -1,69 +0,0 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.ShortBufferException;
import javax.crypto.spec.SecretKeySpec;
import org.openhab.binding.tesla.internal.TeslaBindingConstants;
/**
* The {@link TokenRequest} is a datastructure to capture
* authentication/credentials required to log into the
* Tesla Remote Service
*
* @author Karel Goderis - Initial contribution
* @author Nicolai Grødum - Adding token based auth
*/
@SuppressWarnings("unused")
public abstract class TokenRequest {
private String client_id;
private String client_secret;
TokenRequest() throws GeneralSecurityException {
byte[] ci = { 115, -51, 67, -104, -107, 16, -116, -114, -11, -120, 41, 84, -106, -15, -67, 78, -10, -24, -47,
124, 35, 73, 10, 43, -9, 123, 127, 126, -114, 58, 23, 3, 115, -70, -115, 46, 17, 87, -115, 31, -67, -90,
-107, -100, 59, 18, -19, 91, 95, -52, 82, 91, -37, -83, -74, 39, 12, 59, 14, -81, 3, 95, -111, 72 };
byte[] cs = { -28, 97, -94, 108, 69, -40, 111, 53, 88, -57, 82, 111, 57, 98, 116, -63, -75, -37, 16, 95, 2,
-113, -46, -112, 32, 73, -43, 23, -114, 38, -110, -85, -42, 41, 98, 118, 30, -2, -11, 93, 22, 89, 56,
105, -128, 20, -24, -108, 76, 31, -19, 60, 69, -98, -122, 54, 67, 19, 72, -37, 106, 62, -120, -52 };
SecretKeySpec key = new SecretKeySpec(TeslaBindingConstants.API_NAME.getBytes(), "AES");
Cipher cipher;
try {
cipher = Cipher.getInstance("AES/ECB/NoPadding");
byte[] plainText = new byte[ci.length];
cipher.init(Cipher.DECRYPT_MODE, key);
int ptLength = cipher.update(ci, 0, ci.length, plainText, 0);
cipher.doFinal(plainText, ptLength);
this.client_id = new String(plainText);
cipher.init(Cipher.DECRYPT_MODE, key);
ptLength = cipher.update(cs, 0, cs.length, plainText, 0);
cipher.doFinal(plainText, ptLength);
this.client_secret = new String(plainText);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | ShortBufferException
| IllegalBlockSizeException | BadPaddingException e) {
throw e;
}
}
}

View File

@ -1,38 +0,0 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol;
import java.security.GeneralSecurityException;
/**
* The {@link TokenRequestPassword} is a datastructure to capture
* authentication/credentials required to log into the
* Tesla Remote Service
*
* @author Karel Goderis - Initial contribution
* @author Nicolai Grødum - Adding token based auth
*/
@SuppressWarnings("unused")
public class TokenRequestPassword extends TokenRequest {
private String grant_type = "password";
private String email;
private String password;
public TokenRequestPassword(String email, String password) throws GeneralSecurityException {
super();
this.email = email;
this.password = password;
}
}

View File

@ -1,33 +0,0 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol;
import java.security.GeneralSecurityException;
/**
* The {@link TokenRequestRefreshToken} is a datastructure to capture
* authentication/credentials required to log into the
* Tesla Remote Service
*
* @author Nicolai Grødum - Adding token based auth
*/
public class TokenRequestRefreshToken extends TokenRequest {
private String grant_type = "refresh_token";
private String refresh_token;
public TokenRequestRefreshToken(String refresh_token) throws GeneralSecurityException {
super();
this.refresh_token = refresh_token;
}
}

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol.sso;
import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
/**
* The {@link AuthorizationCodeExchangeRequest} is a datastructure to exchange
* the authorization code for an access token on the SSO endpoint
*
* @author Christian Güdel - Initial contribution
*/
@SuppressWarnings("unused") // Unused fields must not be removed since they are used for serialization to JSON
public class AuthorizationCodeExchangeRequest {
private String grant_type = "authorization_code";
private String client_id = CLIENT_ID;
private String code;
private String code_verifier;
private String redirect_uri = URI_CALLBACK;
public AuthorizationCodeExchangeRequest(String code, String codeVerifier) {
this.code = code;
this.code_verifier = codeVerifier;
}
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol.sso;
/**
* The {@link AuthorizationCodeExchangeResponse} is a datastructure to capture
* the response of an {@link AuthorizationCodeExchangeRequest}
*
* @author Christian Güdel - Initial contribution
*/
public class AuthorizationCodeExchangeResponse {
public String access_token;
public String refresh_token;
public String expires_in;
public String state;
public String token_type;
}

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol.sso;
import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
/**
* The {@link RefreshTokenRequest} is a datastructure to refresh
* the access token for the SSO endpoint
*
* @author Christian Güdel - Initial contribution
*/
public class RefreshTokenRequest {
public String grant_type = "refresh_token";
public String client_id = CLIENT_ID;
public String refresh_token;
public String scope = SSO_SCOPES;
public RefreshTokenRequest(String refresh_token) {
this.refresh_token = refresh_token;
}
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol.sso;
/**
* The {@link TokenExchangeRequest} is a datastructure to exchange
* the access token from the SSO endpoint for an owners API access token
*
* @author Christian Güdel - Initial contribution
*/
public class TokenExchangeRequest {
public String grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer";
public String client_id = "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384";
public String client_secret = "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3";
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.tesla.internal.protocol;
package org.openhab.binding.tesla.internal.protocol.sso;
/**
* The {@link TokenResponse} is a datastructure to capture