[Netatmo] Modification of the tokenRefresh handling process (#14548)

* Modification of the tokenRefresh handling process
* Storing refreshToken in userdata/netatmo

---------

Signed-off-by: clinique <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital 2023-03-10 10:18:30 +01:00 committed by GitHub
parent 013422af32
commit d130595f85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 102 additions and 81 deletions

View File

@ -50,9 +50,6 @@ The Account bridge has the following configuration elements:
| webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | | webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet |
| webHookPostfix | String | No | String appended to the generated webhook address (should start with "/") | | webHookPostfix | String | No | String appended to the generated webhook address (should start with "/") |
| reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | | reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) |
| refreshToken | String | Yes* | The refresh token provided by Netatmo API after the granting process. Can be saved in case of file based configuration |
(*) Strictly said this parameter is not mandatory at first run, until you grant your binding on Netatmo Connect. Once present, you'll not have to grant again.
**Supported channels for the Account bridge thing:** **Supported channels for the Account bridge thing:**
@ -69,7 +66,6 @@ The Account bridge has the following configuration elements:
1. Go to the authorization page of your server. `http://<your openHAB address>:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this). 1. Go to the authorization page of your server. `http://<your openHAB address>:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this).
1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green.
1. The bridge configuration will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. So you can consult this token by opening the Thing page in MainUI, this is the value of the advanced parameter named “Refresh Token”. 1. The bridge configuration will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. So you can consult this token by opening the Thing page in MainUI, this is the value of the advanced parameter named “Refresh Token”.
1. If you're using file based .things config file, copy the provided refresh token in the **refreshToken** parameter of your thing definition (example below).
Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things. Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things.
@ -666,7 +662,7 @@ All these channels are read only.
### things/netatmo.things ### things/netatmo.things
```java ```java
Bridge netatmo:account:myaccount "Netatmo Account" [clientId="xxxxx", clientSecret="yyyy", refreshToken="zzzzz"] { Bridge netatmo:account:myaccount "Netatmo Account" [clientId="xxxxx", clientSecret="yyyy"] {
Bridge weather-station inside "Inside Weather Station" [id="70:ee:aa:aa:aa:aa"] { Bridge weather-station inside "Inside Weather Station" [id="70:ee:aa:aa:aa:aa"] {
outdoor outside "Outside Module" [id="02:00:00:aa:aa:aa"] { outdoor outside "Outside Module" [id="02:00:00:aa:aa:aa"] {
Channels: Channels:

View File

@ -17,6 +17,7 @@ import static org.openhab.core.auth.oauth2client.internal.Keyword.*;
import java.net.URI; import java.net.URI;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@ -43,80 +44,86 @@ import org.slf4j.LoggerFactory;
*/ */
@NonNullByDefault @NonNullByDefault
public class AuthenticationApi extends RestManager { public class AuthenticationApi extends RestManager {
private static final UriBuilder AUTH_BUILDER = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE);
private static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build(); private static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build();
private final Logger logger = LoggerFactory.getLogger(AuthenticationApi.class); private final Logger logger = LoggerFactory.getLogger(AuthenticationApi.class);
private final ScheduledExecutorService scheduler; private final ScheduledExecutorService scheduler;
private Optional<ScheduledFuture<?>> refreshTokenJob = Optional.empty(); private Optional<ScheduledFuture<?>> refreshTokenJob = Optional.empty();
private Optional<AccessTokenResponse> tokenResponse = Optional.empty(); private List<Scope> grantedScope = List.of();
private @Nullable String authorization;
public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService scheduler) { public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService scheduler) {
super(bridge, FeatureArea.NONE); super(bridge, FeatureArea.NONE);
this.scheduler = scheduler; this.scheduler = scheduler;
} }
public String authorize(ApiHandlerConfiguration credentials, @Nullable String code, @Nullable String redirectUri) public void authorize(ApiHandlerConfiguration credentials, String refreshToken, @Nullable String code,
throws NetatmoException { @Nullable String redirectUri) throws NetatmoException {
if (!(credentials.clientId.isBlank() || credentials.clientSecret.isBlank())) { if (!(credentials.clientId.isBlank() || credentials.clientSecret.isBlank())) {
Map<String, String> params = new HashMap<>(Map.of(SCOPE, FeatureArea.ALL_SCOPES)); Map<String, String> params = new HashMap<>(Map.of(SCOPE, FeatureArea.ALL_SCOPES));
String refreshToken = credentials.refreshToken;
if (!refreshToken.isBlank()) { if (!refreshToken.isBlank()) {
params.put(REFRESH_TOKEN, refreshToken); params.put(REFRESH_TOKEN, refreshToken);
} else { } else if (code != null && redirectUri != null) {
if (code != null && redirectUri != null) { params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code));
params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code));
}
} }
if (params.size() > 1) { if (params.size() > 1) {
return requestToken(credentials.clientId, credentials.clientSecret, params); requestToken(credentials.clientId, credentials.clientSecret, params);
return;
} }
} }
throw new IllegalArgumentException("Inconsistent configuration state, please file a bug report."); throw new IllegalArgumentException("Inconsistent configuration state, please file a bug report.");
} }
private String requestToken(String id, String secret, Map<String, String> entries) throws NetatmoException { private void requestToken(String clientId, String secret, Map<String, String> entries) throws NetatmoException {
Map<String, String> payload = new HashMap<>(entries);
payload.put(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN);
payload.putAll(Map.of(CLIENT_ID, id, CLIENT_SECRET, secret));
disconnect(); disconnect();
Map<String, String> payload = new HashMap<>(entries);
payload.putAll(Map.of(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN,
CLIENT_ID, clientId, CLIENT_SECRET, secret));
AccessTokenResponse response = post(TOKEN_URI, AccessTokenResponse.class, payload); AccessTokenResponse response = post(TOKEN_URI, AccessTokenResponse.class, payload);
refreshTokenJob = Optional.of(scheduler.schedule(() -> { refreshTokenJob = Optional.of(scheduler.schedule(() -> {
try { try {
requestToken(id, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken())); requestToken(clientId, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken()));
} catch (NetatmoException e) { } catch (NetatmoException e) {
logger.warn("Unable to refresh access token : {}", e.getMessage()); logger.warn("Unable to refresh access token : {}", e.getMessage());
} }
}, Math.round(response.getExpiresIn() * 0.8), TimeUnit.SECONDS)); }, Math.round(response.getExpiresIn() * 0.9), TimeUnit.SECONDS));
tokenResponse = Optional.of(response);
return response.getRefreshToken(); grantedScope = response.getScope();
authorization = "Bearer %s".formatted(response.getAccessToken());
apiBridge.storeRefreshToken(response.getRefreshToken());
} }
public void disconnect() { public void disconnect() {
tokenResponse = Optional.empty(); authorization = null;
grantedScope = List.of();
} }
public void dispose() { public void dispose() {
disconnect();
refreshTokenJob.ifPresent(job -> job.cancel(true)); refreshTokenJob.ifPresent(job -> job.cancel(true));
refreshTokenJob = Optional.empty(); refreshTokenJob = Optional.empty();
} }
public @Nullable String getAuthorization() { public Optional<String> getAuthorization() {
return tokenResponse.map(at -> String.format("Bearer %s", at.getAccessToken())).orElse(null); return Optional.ofNullable(authorization);
} }
public boolean matchesScopes(Set<Scope> requiredScopes) { public boolean matchesScopes(Set<Scope> requiredScopes) {
return requiredScopes.isEmpty() // either we do not require any scope, either connected and all scopes available return requiredScopes.isEmpty() || grantedScope.containsAll(requiredScopes);
|| (isConnected() && tokenResponse.map(at -> at.getScope().containsAll(requiredScopes)).orElse(false));
} }
public boolean isConnected() { public boolean isConnected() {
return tokenResponse.isPresent(); return authorization != null;
} }
public static UriBuilder getAuthorizationBuilder(String clientId) { public static UriBuilder getAuthorizationBuilder(String clientId) {
return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, FeatureArea.ALL_SCOPES) return getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE).queryParam(CLIENT_ID, clientId)
.queryParam(STATE, clientId); .queryParam(SCOPE, FeatureArea.ALL_SCOPES).queryParam(STATE, clientId);
} }
} }

View File

@ -40,7 +40,7 @@ public abstract class RestManager {
private static final UriBuilder API_URI_BUILDER = getApiBaseBuilder(PATH_API); private static final UriBuilder API_URI_BUILDER = getApiBaseBuilder(PATH_API);
private final Set<Scope> requiredScopes; private final Set<Scope> requiredScopes;
private final ApiBridgeHandler apiBridge; protected final ApiBridgeHandler apiBridge;
public RestManager(ApiBridgeHandler apiBridge, FeatureArea features) { public RestManager(ApiBridgeHandler apiBridge, FeatureArea features) {
this.requiredScopes = features.scopes; this.requiredScopes = features.scopes;

View File

@ -23,16 +23,14 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@NonNullByDefault @NonNullByDefault
public class ApiHandlerConfiguration { public class ApiHandlerConfiguration {
public static final String CLIENT_ID = "clientId"; public static final String CLIENT_ID = "clientId";
public static final String REFRESH_TOKEN = "refreshToken";
public String clientId = ""; public String clientId = "";
public String clientSecret = ""; public String clientSecret = "";
public String refreshToken = "";
public String webHookUrl = ""; public String webHookUrl = "";
public String webHookPostfix = ""; public String webHookPostfix = "";
public int reconnectInterval = 300; public int reconnectInterval = 300;
public ConfigurationLevel check() { public ConfigurationLevel check(String refreshToken) {
if (clientId.isBlank()) { if (clientId.isBlank()) {
return ConfigurationLevel.EMPTY_CLIENT_ID; return ConfigurationLevel.EMPTY_CLIENT_ID;
} else if (clientSecret.isBlank()) { } else if (clientSecret.isBlank()) {

View File

@ -16,10 +16,14 @@ import static java.util.Comparator.*;
import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*; import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.Collection; import java.util.Collection;
@ -70,7 +74,7 @@ import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService; import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService;
import org.openhab.binding.netatmo.internal.servlet.GrantServlet; import org.openhab.binding.netatmo.internal.servlet.GrantServlet;
import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; import org.openhab.binding.netatmo.internal.servlet.WebhookServlet;
import org.openhab.core.config.core.Configuration; import org.openhab.core.OpenHAB;
import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.DecimalType;
import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.ChannelUID;
@ -94,46 +98,56 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault @NonNullByDefault
public class ApiBridgeHandler extends BaseBridgeHandler { public class ApiBridgeHandler extends BaseBridgeHandler {
private static final int TIMEOUT_S = 20; private static final int TIMEOUT_S = 20;
private static final String REFRESH_TOKEN = "refreshToken";
private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
private final AuthenticationApi connectApi = new AuthenticationApi(this, scheduler);
private final Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
private final Deque<LocalDateTime> requestsTimestamps = new ArrayDeque<>(200);
private final BindingConfiguration bindingConf; private final BindingConfiguration bindingConf;
private final AuthenticationApi connectApi;
private final HttpClient httpClient; private final HttpClient httpClient;
private final NADeserializer deserializer; private final NADeserializer deserializer;
private final HttpService httpService; private final HttpService httpService;
private final ChannelUID requestCountChannelUID;
private final Path tokenFile;
private Optional<ScheduledFuture<?>> connectJob = Optional.empty(); private Optional<ScheduledFuture<?>> connectJob = Optional.empty();
private Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>(); private Optional<WebhookServlet> webHookServlet = Optional.empty();
private @Nullable WebhookServlet webHookServlet; private Optional<GrantServlet> grantServlet = Optional.empty();
private @Nullable GrantServlet grantServlet;
private Deque<LocalDateTime> requestsTimestamps;
private final ChannelUID requestCountChannelUID;
public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer, public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
BindingConfiguration configuration, HttpService httpService) { BindingConfiguration configuration, HttpService httpService) {
super(bridge); super(bridge);
this.bindingConf = configuration; this.bindingConf = configuration;
this.connectApi = new AuthenticationApi(this, scheduler);
this.httpClient = httpClient; this.httpClient = httpClient;
this.deserializer = deserializer; this.deserializer = deserializer;
this.httpService = httpService; this.httpService = httpService;
this.requestsTimestamps = new ArrayDeque<>(200); this.requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
this.requestCountChannelUID = new ChannelUID(getThing().getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
Path homeFolder = Paths.get(OpenHAB.getUserDataFolder(), BINDING_ID);
if (Files.notExists(homeFolder)) {
try {
Files.createDirectory(homeFolder);
} catch (IOException e) {
logger.warn("Unable to create {} folder : {}", homeFolder.toString(), e.getMessage());
}
}
tokenFile = homeFolder.resolve(REFRESH_TOKEN + "_" + thing.getUID().toString().replace(":", "_"));
} }
@Override @Override
public void initialize() { public void initialize() {
logger.debug("Initializing Netatmo API bridge handler."); logger.debug("Initializing Netatmo API bridge handler.");
updateStatus(ThingStatus.UNKNOWN); updateStatus(ThingStatus.UNKNOWN);
GrantServlet servlet = new GrantServlet(this, httpService);
servlet.startListening();
grantServlet = servlet;
scheduler.execute(() -> openConnection(null, null)); scheduler.execute(() -> openConnection(null, null));
} }
public void openConnection(@Nullable String code, @Nullable String redirectUri) { public void openConnection(@Nullable String code, @Nullable String redirectUri) {
ApiHandlerConfiguration configuration = getConfiguration(); ApiHandlerConfiguration configuration = getConfiguration();
ConfigurationLevel level = configuration.check();
String refreshToken = readRefreshToken();
ConfigurationLevel level = configuration.check(refreshToken);
switch (level) { switch (level) {
case EMPTY_CLIENT_ID: case EMPTY_CLIENT_ID:
case EMPTY_CLIENT_SECRET: case EMPTY_CLIENT_SECRET:
@ -141,6 +155,9 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
break; break;
case REFRESH_TOKEN_NEEDED: case REFRESH_TOKEN_NEEDED:
if (code == null || redirectUri == null) { if (code == null || redirectUri == null) {
GrantServlet servlet = new GrantServlet(this, httpService);
servlet.startListening();
grantServlet = Optional.of(servlet);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
break; break;
} // else we can proceed to get the token refresh } // else we can proceed to get the token refresh
@ -148,15 +165,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
try { try {
logger.debug("Connecting to Netatmo API."); logger.debug("Connecting to Netatmo API.");
String refreshToken = connectApi.authorize(configuration, code, redirectUri); connectApi.authorize(configuration, refreshToken, code, redirectUri);
if (configuration.refreshToken.isBlank()) {
logger.trace("Adding refresh token to configuration : {}", refreshToken);
Configuration thingConfig = editConfiguration();
thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken);
updateConfiguration(thingConfig);
configuration = getConfiguration();
}
if (!configuration.webHookUrl.isBlank()) { if (!configuration.webHookUrl.isBlank()) {
SecurityApi securityApi = getRestManager(SecurityApi.class); SecurityApi securityApi = getRestManager(SecurityApi.class);
@ -164,7 +173,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi, WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
configuration.webHookUrl, configuration.webHookPostfix); configuration.webHookUrl, configuration.webHookPostfix);
servlet.startListening(); servlet.startListening();
this.webHookServlet = servlet; this.webHookServlet = Optional.of(servlet);
} }
} }
@ -182,6 +191,30 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
} }
} }
private String readRefreshToken() {
if (Files.exists(tokenFile)) {
try {
return Files.readString(tokenFile);
} catch (IOException e) {
logger.warn("Unable to read token file {} : {}", tokenFile.toString(), e.getMessage());
}
}
return "";
}
public void storeRefreshToken(String refreshToken) {
if (refreshToken.isBlank()) {
logger.trace("Blank refresh token received - ignored");
} else {
logger.trace("Updating refresh token in {} : {}", tokenFile.toString(), refreshToken);
try {
Files.write(tokenFile, refreshToken.getBytes());
} catch (IOException e) {
logger.warn("Error saving refresh token to {} : {}", tokenFile.toString(), e.getMessage());
}
}
}
public ApiHandlerConfiguration getConfiguration() { public ApiHandlerConfiguration getConfiguration() {
return getConfigAs(ApiHandlerConfiguration.class); return getConfigAs(ApiHandlerConfiguration.class);
} }
@ -201,14 +234,13 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
@Override @Override
public void dispose() { public void dispose() {
logger.debug("Shutting down Netatmo API bridge handler."); logger.debug("Shutting down Netatmo API bridge handler.");
WebhookServlet localWebHook = this.webHookServlet;
if (localWebHook != null) { webHookServlet.ifPresent(servlet -> servlet.dispose());
localWebHook.dispose(); webHookServlet = Optional.empty();
}
GrantServlet localGrant = this.grantServlet; grantServlet.ifPresent(servlet -> servlet.dispose());
if (localGrant != null) { grantServlet = Optional.empty();
localGrant.dispose();
}
connectApi.dispose(); connectApi.dispose();
freeConnectJob(); freeConnectJob();
super.dispose(); super.dispose();
@ -245,10 +277,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS); Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
String auth = connectApi.getAuthorization(); connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
if (auth != null) {
request.header(HttpHeader.AUTHORIZATION, auth);
}
if (payload != null && contentType != null if (payload != null && contentType != null
&& (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) { && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
@ -390,6 +419,6 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
} }
public Optional<WebhookServlet> getWebHookServlet() { public Optional<WebhookServlet> getWebHookServlet() {
return Optional.ofNullable(webHookServlet); return webHookServlet;
} }
} }

View File

@ -17,13 +17,6 @@
<context>password</context> <context>password</context>
</parameter> </parameter>
<parameter name="refreshToken" type="text">
<label>@text/config.refreshToken.label</label>
<description>@text/config.refreshToken.description</description>
<context>password</context>
<advanced>true</advanced>
</parameter>
<parameter name="webHookUrl" type="text" required="false"> <parameter name="webHookUrl" type="text" required="false">
<label>@text/config.webHookUrl.label</label> <label>@text/config.webHookUrl.label</label>
<description>@text/config.webHookUrl.description</description> <description>@text/config.webHookUrl.description</description>

View File

@ -427,8 +427,6 @@ config.clientId.label = Client ID
config.clientId.description = Client ID provided for the application you created on http://dev.netatmo.com/createapp config.clientId.description = Client ID provided for the application you created on http://dev.netatmo.com/createapp
config.clientSecret.label = Client Secret config.clientSecret.label = Client Secret
config.clientSecret.description = Client Secret provided for the application you created. config.clientSecret.description = Client Secret provided for the application you created.
config.refreshToken.label = Refresh Token
config.refreshToken.description = Refresh token provided by the oAuth2 authentication process.
config.webHookPostfix.label = Webhook Postfix config.webHookPostfix.label = Webhook Postfix
config.webHookPostfix.description = String appended to the generated webhook address (should start with `/`). config.webHookPostfix.description = String appended to the generated webhook address (should start with `/`).
config.webHookUrl.label = Webhook Address config.webHookUrl.label = Webhook Address