diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4b787..2317f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm # Changelog for "keycloak-client" +## [1.2.0-SNAPSHOT] +- Added OIDC token retrieve for clients [#23076] and UMA token from OIDC token instead for credentials + ## [v1.1.0] - Added refresh token facilities for expired tokens (#22515) and some helper methods added. diff --git a/pom.xml b/pom.xml index 03320d3..59e477a 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ org.gcube.common keycloak-client - 1.1.0 + 1.2.0-SNAPSHOT diff --git a/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java b/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java index cb06ca1..f381bd2 100644 --- a/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java +++ b/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java @@ -39,23 +39,36 @@ public class DefaultKeycloakClient implements KeycloakClient { logger.debug("Assuring use the rootVO to query the endpoint simple query. Actual scope is: {}", originalScope); String rootVOScope = "/" + originalScope.split("/")[1]; logger.debug("Setting rootVO scope into provider as: {}", rootVOScope); - ScopeProvider.instance.set(rootVOScope); - logger.debug("Creating simple query"); - SimpleQuery query = queryFor(ServiceEndpoint.class); - query.addCondition( - String.format("$resource/Profile/Category/text() eq '%s'", CATEGORY)) - .addCondition(String.format("$resource/Profile/Name/text() eq '%s'", NAME)) - .setResult(String.format("$resource/Profile/AccessPoint[Description/text() eq '%s']", DESCRIPTION)); + List accessPoints = null; - logger.debug("Creating client for AccessPoint"); - DiscoveryClient client = clientFor(AccessPoint.class); + // trying to be thread safe at least for these calls + synchronized (ScopeProvider.instance) { + boolean scopeModified = false; + if (!ScopeProvider.instance.get().equals(rootVOScope)) { + logger.debug("Overriding scope in the provider with rootVO scope : {}", rootVOScope); + ScopeProvider.instance.set(rootVOScope); + scopeModified = true; + } - logger.trace("Submitting query: {}", query); - List accessPoints = client.submit(query); + logger.debug("Creating simple query"); + SimpleQuery query = queryFor(ServiceEndpoint.class); + query.addCondition( + String.format("$resource/Profile/Category/text() eq '%s'", CATEGORY)) + .addCondition(String.format("$resource/Profile/Name/text() eq '%s'", NAME)) + .setResult(String.format("$resource/Profile/AccessPoint[Description/text() eq '%s']", DESCRIPTION)); - logger.debug("Restting scope into provider to the original value: {}", originalScope); - ScopeProvider.instance.set(originalScope); + logger.debug("Creating client for AccessPoint"); + DiscoveryClient client = clientFor(AccessPoint.class); + + logger.trace("Submitting query: {}", query); + accessPoints = client.submit(query); + + if (scopeModified) { + logger.debug("Resetting scope into provider to the original value: {}", originalScope); + ScopeProvider.instance.set(originalScope); + } + } if (accessPoints.size() == 0) { throw new KeycloakClientException("Service endpoint not found"); @@ -71,6 +84,32 @@ public class DefaultKeycloakClient implements KeycloakClient { } } + @Override + public TokenResponse queryOIDCToken(String clientId, String clientSecret) throws KeycloakClientException { + return queryOIDCToken(findTokenEndpointURL(), clientId, clientSecret); + } + + @Override + public TokenResponse queryOIDCToken(URL tokenURL, String clientId, String clientSecret) + throws KeycloakClientException { + + return queryOIDCToken(tokenURL, constructBasicAuthenticationHeader(clientId, clientSecret)); + } + + protected String constructBasicAuthenticationHeader(String clientId, String clientSecret) { + return "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()); + } + + @Override + public TokenResponse queryOIDCToken(URL tokenURL, String authorization) throws KeycloakClientException { + logger.debug("Querying OIDC token from Keycloak server with URL: {}", tokenURL); + + Map> params = new HashMap<>(); + params.put(GRANT_TYPE_PARAMETER, Arrays.asList(CLIENT_CREDENTIALS_GRANT_TYPE)); + + return performRequest(tokenURL, authorization, params); + } + @Override public TokenResponse queryUMAToken(String clientId, String clientSecret, List permissions) throws KeycloakClientException { @@ -78,6 +117,14 @@ public class DefaultKeycloakClient implements KeycloakClient { return queryUMAToken(clientId, clientSecret, ScopeProvider.instance.get(), permissions); } + @Override + public TokenResponse queryUMAToken(TokenResponse oidcTokenResponse, String audience, List permissions) + throws KeycloakClientException { + + return queryUMAToken(findTokenEndpointURL(), constructBeareAuthenticationHeader(oidcTokenResponse), audience, + permissions); + } + @Override public TokenResponse queryUMAToken(String clientId, String clientSecret, String audience, List permissions) throws KeycloakClientException { @@ -85,12 +132,23 @@ public class DefaultKeycloakClient implements KeycloakClient { return queryUMAToken(findTokenEndpointURL(), clientId, clientSecret, audience, permissions); } + @Override + public TokenResponse queryUMAToken(URL tokenURL, TokenResponse oidcTokenResponse, String audience, + List permissions) throws KeycloakClientException { + + return queryUMAToken(tokenURL, constructBeareAuthenticationHeader(oidcTokenResponse), audience, permissions); + } + + protected String constructBeareAuthenticationHeader(TokenResponse oidcTokenResponse) { + return "Bearer " + oidcTokenResponse.getAccessToken(); + } + @Override public TokenResponse queryUMAToken(URL tokenURL, String clientId, String clientSecret, String audience, List permissions) throws KeycloakClientException { return queryUMAToken(tokenURL, - "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()), + constructBasicAuthenticationHeader(clientId, clientSecret), audience, permissions); } @@ -98,6 +156,38 @@ public class DefaultKeycloakClient implements KeycloakClient { public TokenResponse queryUMAToken(URL tokenURL, String authorization, String audience, List permissions) throws KeycloakClientException { + if (audience == null || "".equals(audience)) { + throw new KeycloakClientException("Audience must be not null nor empty"); + } + + logger.debug("Querying UMA token from Keycloak server with URL: {}", tokenURL); + + Map> params = new HashMap<>(); + params.put(GRANT_TYPE_PARAMETER, Arrays.asList(UMA_TOKEN_GRANT_TYPE)); + + try { + params.put(AUDIENCE_PARAMETER, Arrays.asList(URLEncoder.encode(checkAudience(audience), "UTF-8"))); + } catch (UnsupportedEncodingException e) { + logger.error("Can't URL encode audience: {}", audience, e); + } + + if (permissions != null && !permissions.isEmpty()) { + params.put( + PERMISSION_PARAMETER, permissions.stream().map(s -> { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return ""; + } + }).collect(Collectors.toList())); + } + + return performRequest(tokenURL, authorization, params); + } + + protected TokenResponse performRequest(URL tokenURL, String authorization, Map> params) + throws KeycloakClientException { + if (tokenURL == null) { throw new KeycloakClientException("Token URL must be not null"); } @@ -105,31 +195,9 @@ public class DefaultKeycloakClient implements KeycloakClient { if (authorization == null || "".equals(authorization)) { throw new KeycloakClientException("Authorization must be not null nor empty"); } - - if (audience == null || "".equals(audience)) { - throw new KeycloakClientException("Audience must be not null nor empty"); - } - - logger.debug("Querying token from Keycloak server with URL: {}", tokenURL); - // Constructing request object GXHTTPStringRequest request; try { - Map> params = new HashMap<>(); - params.put(GRANT_TYPE_PARAMETER, Arrays.asList(UMA_TOKEN_GRANT_TYPE)); - - params.put(AUDIENCE_PARAMETER, Arrays.asList(URLEncoder.encode(checkAudience(audience), "UTF-8"))); - - if (permissions != null && !permissions.isEmpty()) { - params.put( - PERMISSION_PARAMETER, permissions.stream().map(s -> { - try { - return URLEncoder.encode(s, "UTF-8"); - } catch (UnsupportedEncodingException e) { - return ""; - } - }).collect(Collectors.toList())); - } String queryString = params.entrySet().stream() .flatMap(p -> p.getValue().stream().map(v -> p.getKey() + "=" + v)) diff --git a/src/main/java/org/gcube/common/keycloak/KeycloakClient.java b/src/main/java/org/gcube/common/keycloak/KeycloakClient.java index ba8a3e2..2e06e01 100644 --- a/src/main/java/org/gcube/common/keycloak/KeycloakClient.java +++ b/src/main/java/org/gcube/common/keycloak/KeycloakClient.java @@ -24,6 +24,37 @@ public interface KeycloakClient { */ URL findTokenEndpointURL() throws KeycloakClientException; + /** + * Queries an OIDC token from the Keycloak server discovered in the current scope, by using provided clientId and client secret + * + * @param clientId the client id + * @param clientSecret the client secret + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryOIDCToken(String clientId, String clientSecret) throws KeycloakClientException; + + /** + * Queries an OIDC token from the Keycloak server, by using provided clientId and client secret. + * + * @param tokenURL the token endpoint {@link URL} of the Keycloak server + * @param clientId the client id + * @param clientSecret the client secret + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryOIDCToken(URL tokenURL, String clientId, String clientSecret) throws KeycloakClientException; + + /** + * Queries an OIDC token from the Keycloak server, by using provided authorization. + * + * @param tokenUrl the token endpoint {@link URL} of the OIDC server + * @param authorization the authorization to be set as header (e.g. a "Basic ...." auth or an encoded JWT access token preceded by the "Bearer " string) + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryOIDCToken(URL tokenURL, String authorization) throws KeycloakClientException; + /** * Queries an UMA token from the Keycloak server, by using provided authorization, for the given audience (context), * in URLEncoded form or not, and optionally a list of permissions. @@ -38,6 +69,20 @@ public interface KeycloakClient { TokenResponse queryUMAToken(URL tokenURL, String authorization, String audience, List permissions) throws KeycloakClientException; + /** + * Queries an UMA token from the Keycloak server, by using access-token provided by the {@link TokenResponse} object + * for the given audience (context), in URLEncoded form or not, and optionally a list of permissions. + * + * @param clientId the client id + * @param clientSecret the client secret + * @param audience the audience (context) where to request the issuing of the ticket + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(URL tokenURL, TokenResponse oidcTokenResponse, String audience, + List permissions) throws KeycloakClientException; + /** * Queries an UMA token from the Keycloak server, by using provided clientId and client secret for the given audience * (context), in URLEncoded form or not, and optionally a list of permissions. @@ -51,7 +96,20 @@ public interface KeycloakClient { * @throws KeycloakClientException if something goes wrong performing the query */ TokenResponse queryUMAToken(URL tokenURL, String clientId, String clientSecret, String audience, - List permissions) + List permissions) throws KeycloakClientException; + + /** + * Queries an UMA token from the Keycloak server discovered in the current scope, by using access-token provided by the {@link TokenResponse} object + * for the given audience (context), in URLEncoded form or not, and optionally a list of permissions. + * + * @param clientId the client id + * @param clientSecret the client secret + * @param audience the audience (context) where to request the issuing of the ticket + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(TokenResponse oidcTokenResponse, String audience, List permissions) throws KeycloakClientException; /** @@ -70,7 +128,7 @@ public interface KeycloakClient { /** * Queries an UMA token from the Keycloak server discovered in the current scope, by using provided clientId and client secret - * for the current scope audience (context), in URLEncoded form or not, and optionally a list of permissions. + * for the current scope as audience (context), in URLEncoded form or not, and optionally a list of permissions. * * @param clientId the client id * @param clientSecret the client secret diff --git a/src/main/java/org/gcube/common/keycloak/model/OIDCConstants.java b/src/main/java/org/gcube/common/keycloak/model/OIDCConstants.java index 45212dd..bd73a12 100644 --- a/src/main/java/org/gcube/common/keycloak/model/OIDCConstants.java +++ b/src/main/java/org/gcube/common/keycloak/model/OIDCConstants.java @@ -4,6 +4,7 @@ public class OIDCConstants { public static final String PERMISSION_PARAMETER = "permission"; public static final String GRANT_TYPE_PARAMETER = "grant_type"; + public static final String CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"; public static final String UMA_TOKEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket"; public static final String AUDIENCE_PARAMETER = "audience"; public static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token";