package org.gcube.common.keycloak; import static org.gcube.common.keycloak.model.OIDCConstants.AUDIENCE_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.CLIENT_CREDENTIALS_GRANT_TYPE; import static org.gcube.common.keycloak.model.OIDCConstants.CLIENT_ID_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.CLIENT_SECRET_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.GRANT_TYPE_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.PERMISSION_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.REFRESH_TOKEN_GRANT_TYPE; import static org.gcube.common.keycloak.model.OIDCConstants.REFRESH_TOKEN_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.TOKEN_PARAMETER; import static org.gcube.common.keycloak.model.OIDCConstants.UMA_TOKEN_GRANT_TYPE; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLEncoder; import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.gcube.common.gxrest.request.GXHTTPStringRequest; import org.gcube.common.gxrest.response.inbound.GXInboundResponse; import org.gcube.common.keycloak.model.ModelUtils; import org.gcube.common.keycloak.model.TokenIntrospectionResponse; import org.gcube.common.keycloak.model.TokenResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class DefaultKeycloakClient implements KeycloakClient { protected static Logger logger = LoggerFactory.getLogger(KeycloakClient.class); @Override public URL getTokenEndpointURL(URL realmBaseURL) throws KeycloakClientException { logger.debug("Constructing token endpoint URL starting from base URL: {}", realmBaseURL); try { URL tokenURL = null; if (realmBaseURL.getPath().endsWith("/")) { tokenURL = new URL(realmBaseURL, OPEN_ID_URI_PATH + "/" + TOKEN_URI_PATH); } else { tokenURL = new URL(realmBaseURL.toString() + "/" + OPEN_ID_URI_PATH + "/" + TOKEN_URI_PATH); } logger.debug("Constructed token URL is: {}", tokenURL); return tokenURL; } catch (MalformedURLException e) { throw new KeycloakClientException("Cannot constructs toke URL from base URL: " + realmBaseURL, e); } } @Override public URL getIntrospectionEndpointURL(URL realmBaseURL) throws KeycloakClientException { logger.debug("Constructing introspection URL starting from base URL: {}", realmBaseURL); try { URL tokenURL = null; if (realmBaseURL.getPath().endsWith("/")) { tokenURL = new URL(realmBaseURL, OPEN_ID_URI_PATH + "/" + TOKEN_URI_PATH + "/" + TOKEN_INTROSPECT_URI_PATH); } else { tokenURL = new URL(realmBaseURL.toString() + "/" + OPEN_ID_URI_PATH + "/" + TOKEN_URI_PATH + "/" + TOKEN_INTROSPECT_URI_PATH); } logger.debug("Constructed introspection URL is: {}", tokenURL); return tokenURL; } catch (MalformedURLException e) { throw new KeycloakClientException("Cannot constructs toke URL from base URL: " + realmBaseURL, e); } } @Override public URL computeIntrospectionEndpointURL(URL tokenEndpointURL) throws KeycloakClientException { logger.debug("Computing introspection endpoint URL starting from token endpoint URL: {}", tokenEndpointURL); try { URL introspectionURL = null; if (tokenEndpointURL.getPath().endsWith(TOKEN_URI_PATH + "/")) { introspectionURL = new URL(tokenEndpointURL, TOKEN_INTROSPECT_URI_PATH); } else { introspectionURL = new URL(tokenEndpointURL, TOKEN_URI_PATH + "/" + TOKEN_INTROSPECT_URI_PATH); } logger.debug("Computed introspection URL is: {}", introspectionURL); return introspectionURL; } catch (MalformedURLException e) { throw new KeycloakClientException("Cannot compute introspection URL from token URL: " + tokenEndpointURL, e); } } @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(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, constructBasicAuthenticationHeader(clientId, clientSecret), audience, permissions); } @Override 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"))); logger.trace("audience is {}", checkAudience(audience)); } 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"); } if (authorization == null || "".equals(authorization)) { throw new KeycloakClientException("Authorization must be not null nor empty"); } // Constructing request object GXHTTPStringRequest request; try { String queryString = params.entrySet().stream() .flatMap(p -> p.getValue().stream().map(v -> p.getKey() + "=" + v)) .reduce((p1, p2) -> p1 + "&" + p2).orElse(""); logger.trace("query string is {}", queryString); request = GXHTTPStringRequest.newRequest(tokenURL.toString()) .header("Content-Type", "application/x-www-form-urlencoded").withBody(queryString); if (authorization != null) { logger.debug("Adding authorization header as: {}", authorization); request = request.header("Authorization", authorization); } } catch (Exception e) { throw new KeycloakClientException("Cannot construct the request object correctly", e); } GXInboundResponse response; try { response = request.post(); } catch (Exception e) { throw new KeycloakClientException("Cannot send request correctly", e); } if (response.isSuccessResponse()) { try { return response.tryConvertStreamedContentFromJson(TokenResponse.class); } catch (Exception e) { throw new KeycloakClientException("Cannot construct token response object correctly", e); } } else { throw KeycloakClientException.create("Unable to get token", response.getHTTPCode(), response.getHeaderFields() .getOrDefault("content-type", Collections.singletonList("unknown/unknown")).get(0), response.getMessage()); } } private static String checkAudience(String audience) { if (audience.startsWith("/")) { try { logger.trace("Audience was provided in non URL encoded form, encoding it"); return URLEncoder.encode(audience, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'audience'", e); } } return audience; } @Override public TokenResponse refreshToken(URL tokenURL, TokenResponse tokenResponse) throws KeycloakClientException { return refreshToken(tokenURL, null, null, tokenResponse); } @Override public TokenResponse refreshToken(URL tokenURL, String clientId, String clientSecret, TokenResponse tokenResponse) throws KeycloakClientException { if (clientId == null) { logger.debug("Client id not set, trying to get it from access token info"); try { clientId = ModelUtils.getClientIdFromToken(ModelUtils.getAccessTokenFrom(tokenResponse)); } catch (Exception e) { throw new KeycloakClientException("Cannot construct access token object from token response", e); } } return refreshToken(tokenURL, clientId, clientSecret, tokenResponse.getRefreshToken()); } @Override public TokenResponse refreshToken(URL tokenURL, String clientId, String clientSecret, String refreshTokenJWTString) throws KeycloakClientException { if (tokenURL == null) { throw new KeycloakClientException("Token URL must be not null"); } if (clientId == null || "".equals(clientId)) { throw new KeycloakClientException("Client id must be not null nor empty"); } if (refreshTokenJWTString == null || "".equals(clientId)) { throw new KeycloakClientException("Refresh token JWT encoded string must be not null nor empty"); } logger.debug("Refreshing token from Keycloak server with URL: {}", tokenURL); // Constructing request object GXHTTPStringRequest request; try { Map params = new HashMap<>(); params.put(GRANT_TYPE_PARAMETER, REFRESH_TOKEN_GRANT_TYPE); params.put(REFRESH_TOKEN_PARAMETER, refreshTokenJWTString); params.put(CLIENT_ID_PARAMETER, URLEncoder.encode(clientId, "UTF-8")); if (clientSecret != null && !"".equals(clientSecret)) { params.put(CLIENT_SECRET_PARAMETER, URLEncoder.encode(clientSecret, "UTF-8")); } String queryString = params.entrySet().stream() .map(p -> p.getKey() + "=" + p.getValue()) .reduce((p1, p2) -> p1 + "&" + p2).orElse(""); request = GXHTTPStringRequest.newRequest(tokenURL.toString()).header("Content-Type", "application/x-www-form-urlencoded").withBody(queryString); } catch (Exception e) { throw new KeycloakClientException("Cannot construct the request object correctly", e); } GXInboundResponse response; try { response = request.post(); } catch (Exception e) { throw new KeycloakClientException("Cannot send request correctly", e); } if (response.isSuccessResponse()) { try { return response.tryConvertStreamedContentFromJson(TokenResponse.class); } catch (Exception e) { throw new KeycloakClientException("Cannot construct token response object correctly", e); } } else { throw KeycloakClientException.create("Unable to get token", response.getHTTPCode(), response.getHeaderFields() .getOrDefault("content-type", Collections.singletonList("unknown/unknown")).get(0), response.getMessage()); } } @Override public TokenIntrospectionResponse introspectAccessToken(URL introspectionURL, String clientId, String clientSecret, String accessTokenJWTString) throws KeycloakClientException { if (introspectionURL == null) { throw new KeycloakClientException("Introspection URL must be not null"); } if (clientId == null || "".equals(clientId)) { throw new KeycloakClientException("Client id must be not null nor empty"); } if (clientSecret == null || "".equals(clientSecret)) { throw new KeycloakClientException("Client secret must be not null nor empty"); } logger.debug("Verifying access token against Keycloak server with URL: {}", introspectionURL); // Constructing request object GXHTTPStringRequest request; try { Map params = new HashMap<>(); params.put(TOKEN_PARAMETER, accessTokenJWTString); String queryString = params.entrySet().stream() .map(p -> p.getKey() + "=" + p.getValue()) .reduce((p1, p2) -> p1 + "&" + p2).orElse(""); request = GXHTTPStringRequest.newRequest(introspectionURL.toString()).header("Content-Type", "application/x-www-form-urlencoded").withBody(queryString); request = request.header("Authorization", constructBasicAuthenticationHeader(clientId, clientSecret)); } catch (Exception e) { throw new KeycloakClientException("Cannot construct the request object correctly", e); } GXInboundResponse response; try { response = request.post(); } catch (Exception e) { throw new KeycloakClientException("Cannot send request correctly", e); } if (response.isSuccessResponse()) { try { return response.tryConvertStreamedContentFromJson(TokenIntrospectionResponse.class); } catch (Exception e) { throw new KeycloakClientException("Cannot construct introspection response object correctly", e); } } else { throw KeycloakClientException.create("Unable to get token introspection response", response.getHTTPCode(), response.getHeaderFields() .getOrDefault("content-type", Collections.singletonList("unknown/unknown")).get(0), response.getMessage()); } } @Override public boolean isAccessTokenVerified(URL introspectionURL, String clientId, String clientSecret, String accessTokenJWTString) throws KeycloakClientException { return introspectAccessToken(introspectionURL, clientId, clientSecret, accessTokenJWTString).getActive(); } }