package org.gcube.oidc.rest; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; import java.net.URLEncoder; import java.util.Arrays; import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class OpenIdConnectRESTHelper { protected static final Logger logger = LoggerFactory.getLogger(OpenIdConnectRESTHelper.class); private static final String RESPONSE_ERROR_KEY = "error"; private static final String RESPONSE_ERROR_INVALID_GRANT = "invalid_grant"; private static final String RESPONSE_ERROR_ACCESS_DENIED = "access_denied"; private static final String RESPONSE_ERROR_DESCRIPTION_KEY = "error_description"; private static final String RESPONSE_ERROR_DESCRIPTION_TINA = "Token is not active"; private static final String RESPONSE_ERROR_DESCRIPTION_IBT = "Invalid bearer token"; private static final String RESPONSE_ERROR_DESCRIPTION_NOT_AUTHORIZED = "not_authorized"; public static String buildLoginRequestURL(URL loginURL, String clientId, String state, String redirectURI) throws UnsupportedEncodingException { Map> params = new HashMap>(); params.put("client_id", Arrays.asList(URLEncoder.encode(clientId, "UTF-8"))); params.put("response_type", Arrays.asList("code")); params.put("scope", Arrays.asList("openid")); params.put("state", Arrays.asList(URLEncoder.encode(state, "UTF-8"))); params.put("redirect_uri", Arrays.asList(URLEncoder.encode(redirectURI, "UTF-8"))); params.put("login", Arrays.asList("true")); String q = mapToQueryString(params); return loginURL + "?" + q; } public static String mapToQueryString(Map> params) { String q = params.entrySet().stream().flatMap(p -> p.getValue().stream().map(v -> p.getKey() + "=" + v)) .reduce((p1, p2) -> p1 + "&" + p2).orElse(""); logger.debug("Query string is: {}", q); return q; } /** * Queries from the OIDC server an OIDC access token, by using provided clientId and client secret. * * @param clientId the client id * @param clientSecret the client secret * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryClientToken(String clientId, String clientSecret, URL tokenURL) throws OpenIdConnectRESTHelperException { return queryClientToken(clientId, clientSecret, tokenURL, null); } /** * Queries from the OIDC server an OIDC access token, by using provided clientId and client secret. * * @param clientId the client id * @param clientSecret the client secret * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryClientToken(String clientId, String clientSecret, URL tokenURL, Map extraHeaders) throws OpenIdConnectRESTHelperException { Map> params = new HashMap<>(); params.put("grant_type", Arrays.asList("client_credentials")); try { params.put("client_id", Arrays.asList(URLEncoder.encode(clientId, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'client_id'", e); } try { params.put("client_secret", Arrays.asList(URLEncoder.encode(clientSecret, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'client_secret'", e); } return performQueryTokenWithPOST(tokenURL, null, params, extraHeaders); } public static JWTToken queryToken(String clientId, URL tokenURL, String code, String scope, String redirectURI) throws Exception { return queryToken(clientId, tokenURL, code, scope, redirectURI, null); } public static JWTToken queryToken(String clientId, URL tokenURL, String code, String scope, String redirectURI, Map extraHeaders) throws Exception { Map> params = new HashMap<>(); params.put("client_id", Arrays.asList(URLEncoder.encode(clientId, "UTF-8"))); params.put("grant_type", Arrays.asList("authorization_code")); params.put("scope", Arrays.asList(URLEncoder.encode(scope, "UTF-8"))); params.put("code", Arrays.asList(URLEncoder.encode(code, "UTF-8"))); params.put("redirect_uri", Arrays.asList(URLEncoder.encode(redirectURI, "UTF-8"))); return performQueryTokenWithPOST(tokenURL, null, params, extraHeaders); } protected static JWTToken performQueryTokenWithPOST(URL tokenURL, String authorization, Map> params) throws OpenIdConnectRESTHelperException { return performQueryTokenWithPOST(tokenURL, authorization, params, null); } protected static JWTToken performQueryTokenWithPOST(URL tokenURL, String authorization, Map> params, Map headers) throws OpenIdConnectRESTHelperException { logger.debug("Querying access token from OIDC server with URL: {}", tokenURL); StringBuilder sb; try { HttpURLConnection httpURLConnection = performURLEncodedPOSTSendData(tokenURL, params, authorization, headers); sb = new StringBuilder(); int httpResultCode = httpURLConnection.getResponseCode(); logger.trace("HTTP Response code: {}", httpResultCode); String responseContentType = httpURLConnection.getContentType(); if (responseContentType != null) { logger.debug("Response content type is: {}", responseContentType); } else { responseContentType = ""; } if (httpResultCode != HttpURLConnection.HTTP_OK) { BufferedReader br = new BufferedReader( new InputStreamReader(httpURLConnection.getErrorStream(), "UTF-8")); String line = null; while ((line = br.readLine()) != null) { sb.append(line + "\n"); } br.close(); throw OpenIdConnectRESTHelperException.create("Unable to get token", httpResultCode, responseContentType, sb.toString()); } else { BufferedReader br = new BufferedReader( new InputStreamReader(httpURLConnection.getInputStream(), "UTF-8")); String line = null; while ((line = br.readLine()) != null) { sb.append(line + "\n"); } br.close(); } } catch (IOException e) { throw new OpenIdConnectRESTHelperException("Unable to get the token", e); } return JWTToken.fromString(sb.toString()); } protected static HttpURLConnection performURLEncodedPOSTSendData(URL url, Map> params, String authorization) throws IOException, ProtocolException, UnsupportedEncodingException { return performURLEncodedPOSTSendData(url, params, authorization, null); } protected static HttpURLConnection performURLEncodedPOSTSendData(URL url, Map> params, String authorization, Map headers) throws IOException, ProtocolException, UnsupportedEncodingException { HttpURLConnection con = (HttpURLConnection) url.openConnection(); con.setRequestMethod("POST"); con.setDoOutput(true); con.setDoInput(true); con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); con.setRequestProperty("Accept", "application/json"); if (authorization != null) { logger.debug("Adding authorization header as: {}", authorization); con.setRequestProperty("Authorization", authorization); } if (headers != null) { for (String key : headers.keySet()) { con.setRequestProperty(key, headers.get(key)); } } OutputStream os = con.getOutputStream(); String queryString = mapToQueryString(params); logger.debug("Parameters query string is: {}", queryString); os.write(queryString.getBytes("UTF-8")); os.close(); return con; } /** * Queries from the OIDC server an UMA token, by using provided clientId and client secret for the given audience * (context), in URLEncoded form or not, and optionally a list of permissions. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param clientId the client id * @param clientSecret the client secret * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not) * @param permissions a list of permissions, can be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryUMAToken(URL tokenUrl, String clientId, String clientSecret, String audience, List permissions) throws OpenIdConnectRESTHelperException { return queryUMAToken(tokenUrl, clientId, clientSecret, audience, permissions, null); } /** * Queries from the OIDC server an UMA token, by using provided clientId and client secret for the given audience * (context), in URLEncoded form or not, and optionally a list of permissions. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param clientId the client id * @param clientSecret the client secret * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not) * @param permissions a list of permissions, can be null * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryUMAToken(URL tokenUrl, String clientId, String clientSecret, String audience, List permissions, Map extraHeaders) throws OpenIdConnectRESTHelperException { return queryUMAToken(tokenUrl, "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()), audience, permissions, extraHeaders); } /** * Queries from the OIDC server an UMA token, by using provided access token, for the given audience (context), * in URLEncoded form or not, and optionally a list of permissions. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param authorization the auth token (the access token URLEncoded by the "Bearer " string) * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not) * @param permissions a list of permissions, can be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryUMAToken(URL tokenUrl, String authorization, String audience, List permissions) throws OpenIdConnectRESTHelperException { return queryUMAToken(tokenUrl, authorization, audience, permissions, null); } /** * Queries from the OIDC server an UMA token, by using provided access token, for the given audience (context), * in URLEncoded form or not, and optionally a list of permissions. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param authorization the auth token (the access token URLEncoded by the "Bearer " string) * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not) * @param permissions a list of permissions, can be null * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryUMAToken(URL tokenUrl, String authorization, String audience, List permissions, Map extraHeaders) throws OpenIdConnectRESTHelperException { Map> params = new HashMap<>(); params.put("grant_type", Arrays.asList("urn:ietf:params:oauth:grant-type:uma-ticket")); if (audience.startsWith("/")) { try { logger.trace("Audience was provided in non URL encoded form, encoding it"); audience = URLEncoder.encode(audience, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'audience'", e); } } try { params.put("audience", Arrays.asList(URLEncoder.encode(audience, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'audience'", e); } if (permissions != null && !permissions.isEmpty()) { params.put( "permission", permissions.stream().map(s -> { try { return URLEncoder.encode(s, "UTF-8"); } catch (UnsupportedEncodingException e) { return ""; } }).collect(Collectors.toList())); } return performQueryTokenWithPOST(tokenUrl, authorization, params, extraHeaders); } /** * Queries from the OIDC server an exchanged token by using provided access token, optionally for the given audience (context) * in URLEncoded form or not. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param authorization the auth token (the access token URLEncoded by the "Bearer " string) * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not), may be null * @param clientId the client id * @param clientSecret the client secret * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryExchangeToken(URL tokenUrl, String authorization, String audience, String client_id, String client_secret, Map extraHeaders) throws OpenIdConnectRESTHelperException { return queryExchangeToken(tokenUrl, authorization, audience, client_id, client_secret, "urn:ietf:params:oauth:token-type:access_token", null, extraHeaders); } /** * Queries from the OIDC server an exchanged token by using provided access token, optionally for the given audience (context) * in URLEncoded form or not. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param authorization the auth token (the access token URLEncoded by the "Bearer " string) * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not), may be null * @param clientId the client id * @param clientSecret the client secret * @param withRefreshToken request also the refresh token (forced to true for offline requests) * @param offline request a refresh token of offline type (TYP claim) * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryExchangeToken(URL tokenUrl, String authorization, String audience, String clientId, String clientSecret, boolean withRefreshToken, boolean offline, Map extraHeaders) throws OpenIdConnectRESTHelperException { return queryExchangeToken(tokenUrl, authorization, audience, clientId, clientSecret, withRefreshToken || offline ? "urn:ietf:params:oauth:token-type:refresh_token" : "urn:ietf:params:oauth:token-type:access_token", offline ? "offline_access" : null, extraHeaders); } /** * Queries from the OIDC server an exchanged token by using provided access token, optionally for the given audience (context) * in URLEncoded form or not. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param authorization the auth token (the access token URLEncoded by the "Bearer " string) * @param audience the audience (context) where to request the issuing of the token (URLEncoded or not), may be null * @param clientId the client id * @param clientSecret the client secret * @param requestedTokenType the requested token type (e.g. urn:ietf:params:oauth:token-type:refresh_token for refresh token) * @param scope the optional scope to request (e.g. offline_access for an offline token) * @param extraHeaders extra HTTP headers to add to the request (e.g. X-D4Science-Context custom header), may be null * @return the issued token * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken queryExchangeToken(URL tokenUrl, String authorization, String audience, String clientId, String clientSecret, String requestedTokenType, String scope, Map extraHeaders) throws OpenIdConnectRESTHelperException { logger.info("Querying exchange token for context: " + audience); Map> params = new HashMap<>(); params.put("subject_token", Arrays.asList(authorization)); params.put("client_id", Arrays.asList(clientId)); params.put("client_secret", Arrays.asList(clientSecret)); params.put("grant_type", Arrays.asList("urn:ietf:params:oauth:grant-type:token-exchange")); params.put("subject_token_type", Arrays.asList("urn:ietf:params:oauth:token-type:access_token")); params.put("requested_token_type", Arrays.asList(requestedTokenType)); if (scope != null) { params.put("scope", Arrays.asList(scope)); } if (audience != null) { if (audience.startsWith("/")) { try { logger.trace("Audience was provided in non URL encoded form, encoding it"); audience = URLEncoder.encode(audience, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'audience'", e); } } try { params.put("audience", Arrays.asList(URLEncoder.encode(audience, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'audience'", e); } } return performQueryTokenWithPOST(tokenUrl, null, params, extraHeaders); } /** * Refreshes the token from the OIDC server. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param token the token to be refreshed * @return a new token refreshed from the previous one * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken refreshToken(URL tokenURL, JWTToken token) throws OpenIdConnectRESTHelperException { return refreshToken(tokenURL, null, null, token); } /** * Refreshes the token from the OIDC server for a specific client represented by the client id. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param clientId the client id * @param token the token to be refreshed * @return a new token refreshed from the previous one * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken refreshToken(URL tokenURL, String clientId, JWTToken token) throws OpenIdConnectRESTHelperException { return refreshToken(tokenURL, clientId, null, token); } /** * Refreshes the token from the OIDC server for a specific client represented by the client id. * * @param tokenUrl the token endpoint {@link URL} of the OIDC server * @param clientId the client id * @param clientSecret the client secret * @param token the token to be refreshed * @return a new token refreshed from the previous one * @throws OpenIdConnectRESTHelperException if an error occurs (also an unauthorized call), inspect the exception for details */ public static JWTToken refreshToken(URL tokenURL, String clientId, String clientSecret, JWTToken token) throws OpenIdConnectRESTHelperException { Map> params = new HashMap<>(); params.put("grant_type", Arrays.asList("refresh_token")); if (clientId == null) { clientId = getClientIdFromToken(token); } try { params.put("client_id", Arrays.asList(URLEncoder.encode(clientId, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'client_id'", e); } if (clientSecret != null) { try { params.put("client_secret", Arrays.asList(URLEncoder.encode(clientSecret, "UTF-8"))); } catch (UnsupportedEncodingException e) { logger.error("Cannot URL encode 'client_secret'", e); } } params.put("refresh_token", Arrays.asList(token.getRefreshTokenString())); return performQueryTokenWithPOST(tokenURL, null, params); } protected static String getClientIdFromToken(JWTToken token) { String clientId; logger.debug("Client id not provided, using authorized party field (azp)"); clientId = token.getAzp(); if (clientId == null) { logger.debug("Authorized party field (azp) not present, getting one of the audience field (aud)"); clientId = getFirstAudienceNoAccount(token); } return clientId; } private static String getFirstAudienceNoAccount(JWTToken token) { // Trying to get it from the token's audience ('aud' field), getting the first except the 'account' List tokenAud = token.getAud(); tokenAud.remove(JWTToken.ACCOUNT_RESOURCE); if (tokenAud.size() > 0) { return tokenAud.iterator().next(); } else { // Setting it to empty string to avoid NPE in encoding return ""; } } /** * Performs the logout (SSOut) from all the sessions opened in the OIDC server. * * @param logoutUrl the logut endpoint {@link URL} of the OIDC server * @param token the token used to take info from * @return true if the logout is performed correctly, false otherwise * @throws IOException if an I/O error occurs during the communication with the server */ public static boolean logout(URL logoutUrl, JWTToken token) throws IOException { return logout(logoutUrl, null, token); } /** * Performs the logout from the session related to the provided client id in the OIDC server. * * @param logoutUrl the logut endpoint {@link URL} of the OIDC server * @param clientId the client id * @param token the token used to take info from * @return true if the logout is performed correctly, false otherwise * @throws IOException if an I/O error occurs during the communication with the server */ public static boolean logout(URL logoutUrl, String clientId, JWTToken token) throws IOException { Map> params = new HashMap<>(); if (clientId == null) { clientId = getClientIdFromToken(token); } params.put("client_id", Arrays.asList(URLEncoder.encode(clientId, "UTF-8"))); params.put("refresh_token", Arrays.asList(token.getRefreshTokenString())); logger.info("Performing logut from OIDC server with URL: " + logoutUrl); HttpURLConnection httpURLConnection = performURLEncodedPOSTSendData(logoutUrl, params, token.getAccessTokenAsBearer()); int responseCode = httpURLConnection.getResponseCode(); if (responseCode == 204) { logger.info("Logout performed correctly"); return true; } else { logger.error("Cannot perfrom logout: [{}] {}", responseCode, httpURLConnection.getResponseMessage()); } return false; } public static byte[] getUserAvatar(URL avatarURL, JWTToken token) { return getUserAvatar(avatarURL, token != null ? token.getAccessTokenAsBearer() : null); } public static byte[] getUserAvatar(URL avatarURL, String authorization) { logger.debug("Getting user avatar from URL: {}", avatarURL); ByteArrayOutputStream buffer; try { HttpURLConnection conn = (HttpURLConnection) avatarURL.openConnection(); conn.setRequestMethod("GET"); conn.setDoOutput(false); conn.setDoInput(true); conn.setRequestProperty("Accept", "image/png, image/gif"); if (authorization != null) { logger.debug("Adding authorization header as: {}", authorization); conn.setRequestProperty("Authorization", authorization); } if (conn.getResponseCode() == 200) { String contentType = conn.getContentType(); logger.debug("Getting the stream to the avatar resource with MIME: {}", contentType); InputStream is = conn.getInputStream(); buffer = new ByteArrayOutputStream(); int nRead; byte[] data = new byte[1024]; while ((nRead = is.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } buffer.flush(); return buffer.toByteArray(); } else { logger.debug("Something wrong on the avatar server. Response code: {}", conn.getResponseCode()); } } catch (FileNotFoundException e) { logger.debug("User's avatar not found"); } catch (IOException e) { logger.warn("Downloading user's avatar", e); } return null; } protected static boolean matchesErrorAndDescription(String jsonString, String expectedError, String exepectedErrorDescription) { try { JSONObject json = (JSONObject) new JSONParser().parse(jsonString); return expectedError.equals(json.get(RESPONSE_ERROR_KEY)) && (exepectedErrorDescription == null || exepectedErrorDescription.equals(json.get(RESPONSE_ERROR_DESCRIPTION_KEY))); } catch (ParseException e) { // Is an unparseable JSON } return false; } public static boolean isTokenNotActiveError(String jsonString) { return matchesErrorAndDescription(jsonString, RESPONSE_ERROR_INVALID_GRANT, RESPONSE_ERROR_DESCRIPTION_TINA); } public static boolean isInvalidBearerTokenError(String jsonString) { return matchesErrorAndDescription(jsonString, RESPONSE_ERROR_INVALID_GRANT, RESPONSE_ERROR_DESCRIPTION_IBT); } public static boolean isAccessDeniedNotAuthorizedError(String jsonString) { return matchesErrorAndDescription(jsonString, RESPONSE_ERROR_ACCESS_DENIED, RESPONSE_ERROR_DESCRIPTION_NOT_AUTHORIZED); } }