zoho self client - new test code

This commit is contained in:
Ralph Soika 2025-04-22 22:20:13 +02:00
parent 628d10bae9
commit a756997247
10 changed files with 449 additions and 8 deletions

BIN
doc/images/zoho-001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
doc/images/zoho-002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -41,3 +41,50 @@ ee9ba49f16e77c64abb89cea13542d2326d8d03ce8
## 3. Generate Access and Refresh Token ## 3. Generate Access and Refresh Token
https://accounts.zoho.com/oauth/v2/token?code=1000.dd7exxxxxxxxxxxxxxxxxxxxxxxx9bb8.b6c0xxxxxxxxxxxxxxxxxxxxxxxxdca4&client_id=1000.NLTM2KUEHI696MSLGEIANATVGGX7IN&client_secret=ee9ba49f16e77c64abb89cea13542d2326d8d03ce8&redirect_uri=http://www.zoho.com/books&grant_type=authorization_code https://accounts.zoho.com/oauth/v2/token?code=1000.dd7exxxxxxxxxxxxxxxxxxxxxxxx9bb8.b6c0xxxxxxxxxxxxxxxxxxxxxxxxdca4&client_id=1000.NLTM2KUEHI696MSLGEIANATVGGX7IN&client_secret=ee9ba49f16e77c64abb89cea13542d2326d8d03ce8&redirect_uri=http://www.zoho.com/books&grant_type=authorization_code
# Self Client
Man kann bei zoho mit einem sogenannten 'Self Client' arbeiten. Dieser erlaubt es ohne Redirect auf die Zoho Webseite einen Code anzufordern:
## 1. Self Client einrichten
Auf die API COnsole von Zoho zugreifen:
https://api-console.zoho.eu/client/1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ
<img src="../images/zoho-001.png" width="800">
Es kann maximal ein Client eingerichtet werden
Unter Scope trägt man dann den gewünschten Scope ein. Z.B.:
ZohoBooks.invoices.CREATE,ZohoBooks.invoices.READ,ZohoBooks.invoices.UPDATE,ZohoBooks.invoices.DELETE
und eine Description
<img src="../images/zoho-002.png">
Den generierten Code nun in Anwendung übertragen und innerhalb (!!) der 'Time Duration' den Access und Refresh Token generieren!
Siehe Junit Test `TestUpdateTokens`
Find details also [here](https://www.zoho.com/writer/help/api/v1/oauth-step2.html#self-client)
## Example for JUnit Test
Der Junit Test `TestUpdateTokens` zeigt das Prinzip
```
client ID= 1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ
client secret= 3f3e74c5212bbc924a7ebf77f887c6296db1106c47
```
In der Web GUi einen neuen Code generieren:
Scope=ZohoBooks.invoices.CREATE,ZohoBooks.invoices.READ,ZohoBooks.invoices.UPDATE,ZohoBooks.invoices.DELETE
Danach den Code token kopieren:
1000.7fe6315e8848b92a2f281b885677d37a.428c02dc2309606c86271842f1f67de4
**Note:** Der Code muss für Test immer wieder neu erstellt werden, da er nur für einige Minuten gültig ist!

BIN
doc/zoho/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View file

@ -0,0 +1,74 @@
package com.alexanderlogistics.zoho;
/**
* Helper class for JSON deserialization
*
* If the request is successful, you would receive the following:
*
* <pre>
{
"access_token": "{access_token}",
"refresh_token": "{refresh_token}",
"api_domain": "https://www.zohoapis.com",
"token_type": "Bearer",
"expires_in": 3600
}
* </pre>
*/
public class TokenResponse {
private String access_token;
private String refresh_token;
private long expires_in;
private String api_domain;
private String token_type;
private String scope; // Neu hinzugefügt für dein Response-Feld
// Getter/Setter notwendig für Yasson
public String getAccess_token() {
return access_token;
}
public void setAccess_token(String access_token) {
this.access_token = access_token;
}
public String getRefresh_token() {
return refresh_token;
}
public void setRefresh_token(String refresh_token) {
this.refresh_token = refresh_token;
}
public long getExpires_in() {
return expires_in;
}
public void setExpires_in(long expires_in) {
this.expires_in = expires_in;
}
public String getApi_domain() {
return api_domain;
}
public void setApi_domain(String api_domain) {
this.api_domain = api_domain;
}
public String getToken_type() {
return token_type;
}
public void setToken_type(String token_type) {
this.token_type = token_type;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
}

View file

@ -0,0 +1,40 @@
package com.alexanderlogistics.zoho;
import jakarta.annotation.security.DeclareRoles;
import jakarta.annotation.security.RolesAllowed;
import jakarta.annotation.security.RunAs;
import jakarta.ejb.Lock;
import jakarta.ejb.LockType;
import jakarta.ejb.Singleton;
/**
* Der ZohoAPIService stellt methoden für den Zugriff auf die Zoho API bereit.
* Er handelt auch die Authentifizeirung über einen Access Token. Der
* ZohoOAuhtGrantService der dazu dient einen AccessCode von Zoho zu ermitteln
* übergibt den code an diesesn Servcie. Er dietn also als Singelton
*/
@DeclareRoles({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS",
"org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS",
"org.imixs.ACCESSLEVEL.MANAGERACCESS" })
@RolesAllowed({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS",
"org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS",
"org.imixs.ACCESSLEVEL.MANAGERACCESS" })
@Singleton
@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS")
public class ZohoAPIService {
private String accessToken;
private String clientID;
private String clientSecret;
@Lock(LockType.WRITE) // Standardmäßig ist @Lock(LockType.READ)
public void setAccessToken(String token) {
this.accessToken = token;
}
@Lock(LockType.READ) // Erlaubt parallele Lesezugriffe
public String getAccessToken() {
return accessToken;
}
}

View file

@ -32,11 +32,9 @@ import java.util.logging.Logger;
import org.imixs.workflow.exceptions.QueryException; import org.imixs.workflow.exceptions.QueryException;
import jakarta.annotation.Resource; import jakarta.ejb.EJB;
import jakarta.ejb.SessionContext;
import jakarta.ejb.Stateless; import jakarta.ejb.Stateless;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
@ -69,11 +67,8 @@ public class ZohoOAuthGrantService {
@Context @Context
private HttpServletRequest servletRequest; private HttpServletRequest servletRequest;
@Context @EJB
HttpServletResponse servletResponse; ZohoOAuthManager zohoOAuthManager;
@Resource
private SessionContext ctx;
private static Logger logger = Logger.getLogger(ZohoOAuthGrantService.class.getName()); private static Logger logger = Logger.getLogger(ZohoOAuthGrantService.class.getName());
@ -122,6 +117,8 @@ public class ZohoOAuthGrantService {
logger.info("│ ├── location=" + location); logger.info("│ ├── location=" + location);
logger.info("│ ├── accounts-server=" + accountsServer); logger.info("│ ├── accounts-server=" + accountsServer);
zohoOAuthManager.updateTokens(code);
return "Zoho Grant Token received!"; return "Zoho Grant Token received!";
} }

View file

@ -0,0 +1,149 @@
package com.alexanderlogistics.zoho;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
import java.util.logging.Logger;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.imixs.workflow.exceptions.InvalidAccessException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.security.RunAs;
import jakarta.ejb.Lock;
import jakarta.ejb.LockType;
import jakarta.ejb.Singleton;
import jakarta.inject.Inject;
import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
/**
* Der ZohoOAuthManager stellt Methoden bereit um einen Access Token zu
* erstellen.
*
*
*/
@Singleton
@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS")
public class ZohoOAuthManager {
private static Logger logger = Logger.getLogger(ZohoOAuthManager.class.getName());
private String refreshToken;
private String accessToken;
private long accessTokenExpiry; // Timestamp (System.currentTimeMillis() + expires_in*1000)
@Inject
@ConfigProperty(name = "zoho.client.id", defaultValue = "1000.NLTM2KUEHI696MSLGEIANATVGGX7IN")
Optional<String> clientID;
@Inject
@ConfigProperty(name = "zoho.client.secret", defaultValue = "ee9ba49f16e77c64abb89cea13542d2326d8d03ce8")
Optional<String> clientSecret;
private HttpClient httpClient;
private Jsonb jsonb;
@PostConstruct
void init() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.jsonb = JsonbBuilder.create();
}
/**
* Step 3: Generate Access and Refresh Token
*
* After getting code from the GrantService we make a POST request
*
* See description: https://www.zoho.com/books/api/v3/oauth/
*
*
* @param code
*/
@Lock(LockType.WRITE)
public void updateTokens(String code) {
logger.info("├── update Zoho tokens");
if (!clientID.isPresent()) {
throw new InvalidAccessException("Missing client ID");
}
if (!clientSecret.isPresent()) {
throw new InvalidAccessException("Missing client secret");
}
try {
String redirectURI = "https://alexander-logistics-dwc.office-workflow.de/api/zoho/grant";
// Korrekte URL-Konstruktion mit Query-Parametern
// String requestUrl = "https://accounts.zoho.com/oauth/v2/token" +
// "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) +
// "&client_id=" + URLEncoder.encode(clientID.get(), StandardCharsets.UTF_8) +
// "&client_secret=" + URLEncoder.encode(clientSecret.get(),
// StandardCharsets.UTF_8) +
// "&redirect_uri=" + URLEncoder.encode("http://www.zoho.com/books",
// StandardCharsets.UTF_8) +
// "&grant_type=authorization_code";
String requestUrl = "https://accounts.zoho.eu/oauth/v2/token" +
"?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) +
"&client_id=" + URLEncoder.encode(clientID.get(), StandardCharsets.UTF_8) +
"&client_secret=" + URLEncoder.encode(clientSecret.get(), StandardCharsets.UTF_8) +
"&redirect_uri=" + URLEncoder.encode(redirectURI, StandardCharsets.UTF_8) +
"&grant_type=authorization_code";
logger.info("│ ├── Request URI=" + requestUrl);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(requestUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.noBody()) // Wichtig: Kein Body!
.build();
HttpResponse<String> response;
response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
logger.info("│ ├── Request statusCode=" + response.statusCode());
if (response.statusCode() == 200) {
// 1. Response-Logging für Debugging
logger.info("│ ├── Request statusCode=" + response.body());
TokenResponse tokenResponse = jsonb.fromJson(response.body(),
TokenResponse.class);
this.accessToken = tokenResponse.getAccess_token();
logger.info("│ ├── accessToken=" + accessToken);
this.refreshToken = tokenResponse.getRefresh_token();
logger.info("│ ├── refreshToken=" + refreshToken);
this.accessTokenExpiry = System.currentTimeMillis() +
(tokenResponse.getExpires_in() * 1000);
logger.info("│ ├── expires_in=" + tokenResponse.getExpires_in());
} else {
throw new RuntimeException("Failed to get tokens: " +
response.statusCode() + " - " + response.body());
}
} catch (IOException | InterruptedException e) {
throw new InvalidAccessException("Unable to resolve access tokens: " + e.getMessage());
}
}
@Lock(LockType.READ)
public String getValidAccessToken() {
return null;
}
}

View file

@ -0,0 +1,111 @@
package com.alexanderlogistics.zoho;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.logging.Logger;
import org.imixs.workflow.exceptions.InvalidAccessException;
import org.imixs.workflow.exceptions.ModelException;
import org.imixs.workflow.exceptions.PluginException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
/**
* Diese Klasse generiert einen access und refresh token anhand eines Code
* Tokens
*
*
* @author rsoika
*/
public class TestUpdateTokens {
final static String ENCODING = "UTF-8";
private static Logger logger = Logger.getLogger(TestUpdateTokens.class.getName());
private HttpClient httpClient;
private Jsonb jsonb;
private String code = "1000.a6cff79178a76099a625eb41249a6c91.b3679ff9365902502d2e3ca55303d5d6";
private String clientID = "1000.GR5FTPZYB06HEWSFSSX4DJU3OBD4EJ";
private String clientSecret = "3f3e74c5212bbc924a7ebf77f887c6296db1106c47";
@BeforeEach
public void setup() throws PluginException, ModelException {
logger.info("setup test environment ...");
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.jsonb = JsonbBuilder.create();
}
/**
* Testet den Vorgang einen Access und Refresh Token zu generieren
*
* @throws IOException
*/
@Test
public void testUpdateTokens() throws IOException {
logger.info("├── update Zoho tokens");
try {
String redirectURI = "https://alexander-logistics-dwc.office-workflow.de/api/zoho/grant";
String requestUrl = "https://accounts.zoho.eu/oauth/v2/token" +
"?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) +
"&client_id=" + URLEncoder.encode(clientID, StandardCharsets.UTF_8) +
"&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8) +
"&redirect_uri=" + URLEncoder.encode(redirectURI, StandardCharsets.UTF_8) +
"&grant_type=authorization_code";
logger.info("│ ├── Request URI=" + requestUrl);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(requestUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.noBody()) // Wichtig: Kein Body!
.build();
HttpResponse<String> response;
response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
logger.info("│ ├── Request statusCode=" + response.statusCode());
if (response.statusCode() == 200) {
// 1. Response-Logging für Debugging
logger.info("│ ├── Request statusCode=" + response.body());
TokenResponse tokenResponse = jsonb.fromJson(response.body(),
TokenResponse.class);
String accessToken = tokenResponse.getAccess_token();
logger.info("│ ├── accessToken=" + accessToken);
String refreshToken = tokenResponse.getRefresh_token();
logger.info("│ ├── refreshToken=" + refreshToken);
logger.info("│ ├── expires_in=" + tokenResponse.getExpires_in());
} else {
throw new RuntimeException("Failed to get tokens: " +
response.statusCode() + " - " + response.body());
}
} catch (IOException | InterruptedException e) {
throw new InvalidAccessException("Unable to resolve access tokens: " + e.getMessage());
}
}
}

23
pom.xml
View file

@ -326,5 +326,28 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- JSON-B Implementation for Tests -->
<dependency>
<groupId>org.eclipse</groupId>
<artifactId>yasson</artifactId>
<version>3.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.json</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.2.1</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>