diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml index 51b665f843..6803f0e34f 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml @@ -422,5 +422,9 @@ 1.3 test + + org.apache.httpcomponents + httpclient + diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java index b8d35e934a..7aee52b021 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java @@ -16,6 +16,7 @@ */ package org.apache.nifi.web; +import java.util.Arrays; import org.apache.nifi.util.NiFiProperties; import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationFilter; import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationProvider; @@ -48,8 +49,6 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; - /** * NiFi Web Api Spring security. Applies the various NiFiAuthenticationFilter servlet filters which will extract authentication * credentials from API requests. @@ -92,7 +91,7 @@ public class NiFiWebApiSecurityConfiguration extends WebSecurityConfigurerAdapte webSecurity .ignoring() .antMatchers("/access", "/access/config", "/access/token", "/access/kerberos", - "/access/oidc/exchange", "/access/oidc/callback", "/access/oidc/request", + "/access/oidc/exchange", "/access/oidc/callback", "/access/oidc/logoutCallback", "/access/oidc/request", "/access/knox/callback", "/access/knox/request"); } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java index 9a3eec241f..32e1b226f1 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java @@ -20,6 +20,7 @@ import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; import com.nimbusds.oauth2.sdk.AuthorizationGrant; import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; import com.nimbusds.oauth2.sdk.id.State; import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse; import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser; @@ -29,10 +30,15 @@ import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; +import java.io.IOException; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.servlet.ServletContext; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; @@ -49,6 +55,14 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import org.apache.commons.lang3.StringUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; import org.apache.nifi.admin.service.AdministrationException; import org.apache.nifi.authentication.AuthenticationResponse; import org.apache.nifi.authentication.LoginCredentials; @@ -104,6 +118,14 @@ public class AccessResource extends ApplicationResource { private static final String OIDC_REQUEST_IDENTIFIER = "oidc-request-identifier"; private static final String OIDC_ERROR_TITLE = "Unable to continue login sequence"; + private static final String OIDC_ID_TOKEN_AUTHN_ERROR = "Unable to exchange authorization for ID token: "; + private static final String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG = "OpenId Connect support is not configured"; + private static final String REVOKE_ACCESS_TOKEN_LOGOUT = "oidc_access_token_logout"; + private static final String ID_TOKEN_LOGOUT = "oidc_id_token_logout"; + private static final String STANDARD_LOGOUT = "oidc_standard_logout"; + private static final Pattern REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.google\\.com)"); + private static final Pattern ID_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.okta)"); + private static final int msTimeout = 30_000; private static final String AUTHENTICATION_NOT_ENABLED_MSG = "User authentication/authorization is only supported when running over HTTPS."; @@ -166,34 +188,15 @@ public class AccessResource extends ApplicationResource { // ensure oidc is enabled if (!oidcService.isOidcEnabled()) { - forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured."); + forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); return; } - final String oidcRequestIdentifier = UUID.randomUUID().toString(); - - // generate a cookie to associate this login sequence - final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(60); - cookie.setSecure(true); - httpServletResponse.addCookie(cookie); - - // get the state for this request - final State state = oidcService.createState(oidcRequestIdentifier); - - // build the authorization uri - final URI authorizationUri = UriBuilder.fromUri(oidcService.getAuthorizationEndpoint()) - .queryParam("client_id", oidcService.getClientId()) - .queryParam("response_type", "code") - .queryParam("scope", oidcService.getScope().toString()) - .queryParam("state", state.getValue()) - .queryParam("redirect_uri", getOidcCallback()) - .build(); + // generate the authorization uri + URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcCallback()); // generate the response - httpServletResponse.sendRedirect(authorizationUri.toString()); + httpServletResponse.sendRedirect(authorizationURI.toString()); } @GET @@ -207,19 +210,20 @@ public class AccessResource extends ApplicationResource { public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { // only consider user specific access over https if (!httpServletRequest.isSecure()) { - forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS."); + forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG); return; } // ensure oidc is enabled if (!oidcService.isOidcEnabled()) { - forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured."); + forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); return; } final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER); if (oidcRequestIdentifier == null) { - forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue."); + forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " + + "not found in the request. Unable to continue."); return; } @@ -233,7 +237,8 @@ public class AccessResource extends ApplicationResource { removeOidcRequestCookie(httpServletResponse); // forward to the error page - forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process."); + forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " + + "from the OpenId Connect Provider. Unable to continue login process."); return; } @@ -243,13 +248,15 @@ public class AccessResource extends ApplicationResource { // confirm state final State state = successfulOidcResponse.getState(); if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) { - logger.error("The state value returned by the OpenId Connect Provider does not match the stored state. Unable to continue login process."); + logger.error("The state value returned by the OpenId Connect Provider does not match the stored state. " + + "Unable to continue login process."); // remove the oidc request cookie removeOidcRequestCookie(httpServletResponse); // forward to the error page - forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match the stored state. Unable to continue login process."); + forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " + + "the stored state. Unable to continue login process."); return; } @@ -257,15 +264,24 @@ public class AccessResource extends ApplicationResource { // exchange authorization code for id token final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode(); final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcCallback())); - oidcService.exchangeAuthorizationCode(oidcRequestIdentifier, authorizationGrant); + + // get the oidc token + LoginAuthenticationToken oidcToken = oidcService.exchangeAuthorizationCodeForLoginAuthenticationToken(authorizationGrant); + + // exchange the oidc token for the NiFi token + String nifiJwt = jwtService.generateSignedToken(oidcToken); + + // store the NiFi token + oidcService.storeJwt(oidcRequestIdentifier, nifiJwt); + } catch (final Exception e) { - logger.error("Unable to exchange authorization for ID token: " + e.getMessage(), e); + logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e); // remove the oidc request cookie removeOidcRequestCookie(httpServletResponse); // forward to the error page - forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to exchange authorization for ID token: " + e.getMessage()); + forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); return; } @@ -277,7 +293,8 @@ public class AccessResource extends ApplicationResource { // report the unsuccessful login final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse; - forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: " + errorOidcResponse.getErrorObject().getDescription()); + forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: " + + errorOidcResponse.getErrorObject().getDescription()); } } @@ -290,7 +307,7 @@ public class AccessResource extends ApplicationResource { response = String.class, notes = NON_GUARANTEED_ENDPOINT ) - public Response oidcExchange(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + public Response oidcExchange(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) { // only consider user specific access over https if (!httpServletRequest.isSecure()) { throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG); @@ -298,7 +315,7 @@ public class AccessResource extends ApplicationResource { // ensure oidc is enabled if (!oidcService.isOidcEnabled()) { - throw new IllegalStateException("OpenId Connect is not configured."); + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); } final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER); @@ -329,24 +346,198 @@ public class AccessResource extends ApplicationResource { ) public void oidcLogout(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { if (!httpServletRequest.isSecure()) { - throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS."); + throw new IllegalStateException(AUTHENTICATION_NOT_ENABLED_MSG); } if (!oidcService.isOidcEnabled()) { - throw new IllegalStateException("OpenId Connect is not configured."); + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); } - URI endSessionEndpoint = oidcService.getEndSessionEndpoint(); - String postLogoutRedirectUri = generateResourceUri("..", "nifi", "logout-complete"); + // Get the oidc discovery url + String oidcDiscoveryUrl = properties.getOidcDiscoveryUrl(); - if (endSessionEndpoint == null) { - // handle the case, where the OpenID Provider does not have an end session endpoint - httpServletResponse.sendRedirect(postLogoutRedirectUri); + // Determine the logout method + String logoutMethod = determineLogoutMethod(oidcDiscoveryUrl); + + switch (logoutMethod) { + case REVOKE_ACCESS_TOKEN_LOGOUT: + case ID_TOKEN_LOGOUT: + // Make a request to the IdP + URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcLogoutCallback()); + httpServletResponse.sendRedirect(authorizationURI.toString()); + break; + case STANDARD_LOGOUT: + default: + // Get the OIDC end session endpoint + URI endSessionEndpoint = oidcService.getEndSessionEndpoint(); + String postLogoutRedirectUri = generateResourceUri( "..", "nifi", "logout-complete"); + + if (endSessionEndpoint == null) { + httpServletResponse.sendRedirect(postLogoutRedirectUri); + } else { + URI logoutUri = UriBuilder.fromUri(endSessionEndpoint) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build(); + httpServletResponse.sendRedirect(logoutUri.toString()); + } + break; + } + } + + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + @Path("oidc/logoutCallback") + @ApiOperation( + value = "Redirect/callback URI for processing the result of the OpenId Connect logout sequence.", + notes = NON_GUARANTEED_ENDPOINT + ) + public void oidcLogoutCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { + // only consider user specific access over https + if (!httpServletRequest.isSecure()) { + forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG); + return; + } + + // ensure oidc is enabled + if (!oidcService.isOidcEnabled()) { + forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG); + return; + } + + final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER); + if (oidcRequestIdentifier == null) { + forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " + + "not found in the request. Unable to continue."); + return; + } + + final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse; + try { + oidcResponse = AuthenticationResponseParser.parse(getRequestUri()); + } catch (final ParseException e) { + logger.error("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue " + + "logout process: " + e.getMessage(), e); + + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // forward to the error page + forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " + + "from the OpenId Connect Provider. Unable to continue logout process."); + return; + } + + if (oidcResponse.indicatesSuccess()) { + final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse; + + // confirm state + final State state = successfulOidcResponse.getState(); + if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) { + logger.error("The state value returned by the OpenId Connect Provider does not match the stored " + + "state. Unable to continue login process."); + + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // forward to the error page + forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " + + "the stored state. Unable to continue login process."); + return; + } + + // Get the oidc discovery url + String oidcDiscoveryUrl = properties.getOidcDiscoveryUrl(); + + // Determine which logout method to use + String logoutMethod = determineLogoutMethod(oidcDiscoveryUrl); + + // Get the authorization code and grant + final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode(); + final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcLogoutCallback())); + + switch (logoutMethod) { + case REVOKE_ACCESS_TOKEN_LOGOUT: + // Use the Revocation endpoint + access token + final String accessToken; + try { + // Return the access token + accessToken = oidcService.exchangeAuthorizationCodeForAccessToken(authorizationGrant); + } catch (final Exception e) { + logger.error("Unable to exchange authorization for the Access token: " + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); + return; + } + + // Build the revoke URI and send the POST request + URI revokeEndpoint = getRevokeEndpoint(); + + if (revokeEndpoint != null) { + try { + // Logout with the revoke endpoint + revokeEndpointRequest(httpServletResponse, accessToken, revokeEndpoint); + + } catch (final IOException e) { + logger.error("There was an error logging out of the OpenId Connect Provider: " + + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToMessagePage(httpServletRequest, httpServletResponse, + "There was an error logging out of the OpenId Connect Provider: " + + e.getMessage()); + } + } + break; + case ID_TOKEN_LOGOUT: + // Use the end session endpoint + ID Token + final String idToken; + try { + // Return the ID Token + idToken = oidcService.exchangeAuthorizationCodeForIdToken(authorizationGrant); + } catch (final Exception e) { + logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e); + + // Remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // Forward to the error page + forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage()); + return; + } + + // Get the OIDC end session endpoint + URI endSessionEndpoint = oidcService.getEndSessionEndpoint(); + String postLogoutRedirectUri = generateResourceUri("..", "nifi", "logout-complete"); + + if (endSessionEndpoint == null) { + logger.debug("Unable to log out of the OpenId Connect Provider. The end session endpoint is: null." + + " Redirecting to the logout page."); + httpServletResponse.sendRedirect(postLogoutRedirectUri); + } else { + URI logoutUri = UriBuilder.fromUri(endSessionEndpoint) + .queryParam("id_token_hint", idToken) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build(); + httpServletResponse.sendRedirect(logoutUri.toString()); + } + break; + } } else { - URI logoutUri = UriBuilder.fromUri(endSessionEndpoint) - .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) - .build(); - httpServletResponse.sendRedirect(logoutUri.toString()); + // remove the oidc request cookie + removeOidcRequestCookie(httpServletResponse); + + // report the unsuccessful logout + final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse; + forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful logout attempt: " + + errorOidcResponse.getErrorObject().getDescription()); } } @@ -394,7 +585,7 @@ public class AccessResource extends ApplicationResource { public void knoxCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception { // only consider user specific access over https if (!httpServletRequest.isSecure()) { - forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS."); + forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG); return; } @@ -768,7 +959,7 @@ public class AccessResource extends ApplicationResource { ) public Response logOut(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) { if (!httpServletRequest.isSecure()) { - throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS."); + throw new IllegalStateException(AUTHENTICATION_NOT_ENABLED_MSG); } String userIdentity = NiFiUserUtils.getNiFiUserIdentity(); @@ -828,6 +1019,14 @@ public class AccessResource extends ApplicationResource { return generateResourceUri("access", "oidc", "callback"); } + private String getOidcLogoutCallback() { + return generateResourceUri("access", "oidc", "logoutCallback"); + } + + private URI getRevokeEndpoint() { + return oidcService.getRevocationEndpoint(); + } + private String getNiFiUri() { final String nifiApiUrl = generateResourceUri(); final String baseUrl = StringUtils.substringBeforeLast(nifiApiUrl, "/nifi-api"); @@ -851,6 +1050,98 @@ public class AccessResource extends ApplicationResource { uiContext.getRequestDispatcher("/WEB-INF/pages/message-page.jsp").forward(httpServletRequest, httpServletResponse); } + private String determineLogoutMethod(String oidcDiscoveryUrl) { + Matcher accessTokenMatcher = REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT.matcher(oidcDiscoveryUrl); + Matcher idTokenMatcher = ID_TOKEN_LOGOUT_FORMAT.matcher(oidcDiscoveryUrl); + + if (accessTokenMatcher.find()) { + return REVOKE_ACCESS_TOKEN_LOGOUT; + } else if (idTokenMatcher.find()) { + return ID_TOKEN_LOGOUT; + } else { + return STANDARD_LOGOUT; + } + } + + /** + * Generates the request Authorization URI for the OpenID Connect Provider. Returns an authorization + * URI using the provided callback URI. + * + * @param httpServletResponse the servlet response + * @param callback the OIDC callback URI + * @return the authorization URI + */ + private URI oidcRequestAuthorizationCode(@Context HttpServletResponse httpServletResponse, String callback) { + + final String oidcRequestIdentifier = UUID.randomUUID().toString(); + + // generate a cookie to associate this login sequence + final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(60); + cookie.setSecure(true); + httpServletResponse.addCookie(cookie); + + // get the state for this request + final State state = oidcService.createState(oidcRequestIdentifier); + + // build the authorization uri + final URI authorizationUri = UriBuilder.fromUri(oidcService.getAuthorizationEndpoint()) + .queryParam("client_id", oidcService.getClientId()) + .queryParam("response_type", "code") + .queryParam("scope", oidcService.getScope().toString()) + .queryParam("state", state.getValue()) + .queryParam("redirect_uri", callback) + .build(); + + // return Authorization URI + return authorizationUri; + } + + /** + * Sends a POST request to the revoke endpoint to log out of the ID Provider. + * + * @param httpServletResponse the servlet response + * @param accessToken the OpenID Connect Provider access token + * @param revokeEndpoint the name of the cookie + * @throws IOException exceptional case for communication error with the OpenId Connect Provider + */ + + private void revokeEndpointRequest(@Context HttpServletResponse httpServletResponse, String accessToken, URI revokeEndpoint) throws IOException { + + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(msTimeout) + .setConnectionRequestTimeout(msTimeout) + .setSocketTimeout(msTimeout) + .build(); + + CloseableHttpClient httpClient = HttpClientBuilder + .create() + .setDefaultRequestConfig(config) + .build(); + HttpPost httpPost = new HttpPost(revokeEndpoint); + + List params = new ArrayList<>(); + // Append a query param with the access token + params.add(new BasicNameValuePair("token", accessToken)); + httpPost.setEntity(new UrlEncodedFormEntity(params)); + + try (CloseableHttpResponse response = httpClient.execute(httpPost)) { + httpClient.close(); + + if (response.getStatusLine().getStatusCode() == HTTPResponse.SC_OK) { + // Redirect to logout page + logger.debug("You are logged out of the OpenId Connect Provider."); + String postLogoutRedirectUri = generateResourceUri("..", "nifi", "logout-complete"); + httpServletResponse.sendRedirect(postLogoutRedirectUri); + } else { + logger.error("There was an error logging out of the OpenId Connect Provider. " + + "Response status: " + response.getStatusLine().getStatusCode()); + } + } + } + // setters public void setLoginIdentityProvider(LoginIdentityProvider loginIdentityProvider) { diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java index cecd792d7b..8be9b6d687 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java @@ -22,6 +22,7 @@ import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.oauth2.sdk.id.ClientID; import java.io.IOException; import java.net.URI; +import org.apache.nifi.web.security.token.LoginAuthenticationToken; public interface OidcIdentityProvider { @@ -60,6 +61,13 @@ public interface OidcIdentityProvider { */ URI getEndSessionEndpoint(); + /** + * Returns the URI for the revocation endpoint. + * + * @return uri for the revocation endpoint + */ + URI getRevocationEndpoint(); + /** * Returns the scopes supported by the OIDC provider. * @@ -68,12 +76,30 @@ public interface OidcIdentityProvider { Scope getScope(); /** - * Exchanges the supplied authorization grant for an ID token. Extracts the identity from the ID - * token and converts it into NiFi JWT. + * Exchanges the supplied authorization grant for a Login ID Token. Extracts the identity from the ID + * token. * * @param authorizationGrant authorization grant for invoking the Token Endpoint - * @return a NiFi JWT + * @return a Login Authentication Token * @throws IOException if there was an exceptional error while communicating with the OIDC provider */ - String exchangeAuthorizationCode(AuthorizationGrant authorizationGrant) throws IOException; + LoginAuthenticationToken exchangeAuthorizationCodeforLoginAuthenticationToken(AuthorizationGrant authorizationGrant) throws IOException; + + /** + * Exchanges the supplied authorization grant for an Access Token. + * + * @param authorizationGrant authorization grant for invoking the Token Endpoint + * @return an Access Token String + * @throws Exception if there was an exceptional error while communicating with the OIDC provider + */ + String exchangeAuthorizationCodeForAccessToken(AuthorizationGrant authorizationGrant) throws Exception; + + /** + * Exchanges the supplied authorization grant for an ID Token. + * + * @param authorizationGrant authorization grant for invoking the Token Endpoint + * @return an ID Token String + * @throws IOException if there was an exceptional error while communicating with the OIDC provider + */ + String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) throws IOException; } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java index b749085556..5474c43b01 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java @@ -29,6 +29,7 @@ import java.security.MessageDigest; import java.security.SecureRandom; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import org.apache.nifi.web.security.token.LoginAuthenticationToken; import org.apache.nifi.web.security.util.CacheKey; import static org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED; @@ -98,6 +99,15 @@ public class OidcService { return identityProvider.getEndSessionEndpoint(); } + /** + * Returns the OpenId Connect revocation endpoint. + * + * @return the revocation endpoint + */ + public URI getRevocationEndpoint() { + return identityProvider.getRevocationEndpoint(); + } + /** * Returns the OpenId Connect scope. * @@ -186,25 +196,66 @@ public class OidcService { } /** - * Exchanges the specified authorization grant for an ID token for the given request identifier. + * Exchanges the specified authorization grant for an ID token. * - * @param oidcRequestIdentifier request identifier * @param authorizationGrant authorization grant + * @return a Login Authentication Token * @throws IOException exceptional case for communication error with the OpenId Connect provider */ - public void exchangeAuthorizationCode(final String oidcRequestIdentifier, final AuthorizationGrant authorizationGrant) throws IOException { + public LoginAuthenticationToken exchangeAuthorizationCodeForLoginAuthenticationToken(final AuthorizationGrant authorizationGrant) throws IOException { if (!isOidcEnabled()) { throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); } - final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier); - final String nifiJwt = retrieveNifiJwt(authorizationGrant); + // Retrieve Login Authentication Token + return identityProvider.exchangeAuthorizationCodeforLoginAuthenticationToken(authorizationGrant); + } + /** + * Exchanges the specified authorization grant for an access token. + * + * @param authorizationGrant authorization grant + * @return an Access Token string + * @throws IOException exceptional case for communication error with the OpenId Connect provider + */ + public String exchangeAuthorizationCodeForAccessToken(final AuthorizationGrant authorizationGrant) throws Exception { + if (!isOidcEnabled()) { + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); + } + + // Retrieve access token + return identityProvider.exchangeAuthorizationCodeForAccessToken(authorizationGrant); + } + + /** + * Exchanges the specified authorization grant for an ID Token. + * + * @param authorizationGrant authorization grant + * @return an ID Token string + * @throws IOException exceptional case for communication error with the OpenId Connect provider + */ + public String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) throws IOException { + if (!isOidcEnabled()) { + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); + } + + // Retrieve ID token + return identityProvider.exchangeAuthorizationCodeForIdToken(authorizationGrant); + } + + /** + * Stores the NiFi Jwt. + * + * @param oidcRequestIdentifier request identifier + * @param jwt NiFi JWT + */ + public void storeJwt(final String oidcRequestIdentifier, final String jwt) { + final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier); try { - // cache the jwt for later retrieval + // Cache the jwt for later retrieval synchronized (jwtLookupForCompletedRequests) { - final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, () -> nifiJwt); - if (!timeConstantEqualityCheck(nifiJwt, cachedJwt)) { + final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, () -> jwt); + if (!timeConstantEqualityCheck(jwt, cachedJwt)) { throw new IllegalStateException("An existing login request is already in progress."); } } @@ -213,17 +264,6 @@ public class OidcService { } } - /** - * Exchange the authorization code to retrieve a NiFi JWT. - * - * @param authorizationGrant authorization grant - * @return NiFi JWT - * @throws IOException exceptional case for communication error with the OpenId Connect provider - */ - public String retrieveNifiJwt(final AuthorizationGrant authorizationGrant) throws IOException { - return identityProvider.exchangeAuthorizationCode(authorizationGrant); - } - /** * Returns the resulting JWT for the given request identifier. Will return null if the request * identifier is not associated with a JWT or if the login sequence was not completed before diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java index f7b54f109e..a26926e2d6 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java @@ -38,6 +38,7 @@ import com.nimbusds.oauth2.sdk.auth.Secret; import com.nimbusds.oauth2.sdk.http.HTTPRequest; import com.nimbusds.oauth2.sdk.http.HTTPResponse; import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.token.AccessToken; import com.nimbusds.oauth2.sdk.token.BearerAccessToken; import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; @@ -45,10 +46,13 @@ import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse; import com.nimbusds.openid.connect.sdk.UserInfoRequest; import com.nimbusds.openid.connect.sdk.UserInfoResponse; import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse; +import com.nimbusds.openid.connect.sdk.claims.AccessTokenHash; import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import com.nimbusds.openid.connect.sdk.token.OIDCTokens; +import com.nimbusds.openid.connect.sdk.validators.AccessTokenValidator; import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator; +import com.nimbusds.openid.connect.sdk.validators.InvalidHashException; import java.io.IOException; import java.net.URI; import java.net.URL; @@ -159,18 +163,7 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { try { // get the preferred json web signature algorithm - final String rawPreferredJwsAlgorithm = properties.getOidcPreferredJwsAlgorithm(); - - final JWSAlgorithm preferredJwsAlgorithm; - if (StringUtils.isBlank(rawPreferredJwsAlgorithm)) { - preferredJwsAlgorithm = JWSAlgorithm.RS256; - } else { - if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) { - preferredJwsAlgorithm = null; - } else { - preferredJwsAlgorithm = JWSAlgorithm.parse(rawPreferredJwsAlgorithm); - } - } + final JWSAlgorithm preferredJwsAlgorithm = extractJwsAlgorithm(); if (preferredJwsAlgorithm == null) { tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId); @@ -185,6 +178,23 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { } } + private JWSAlgorithm extractJwsAlgorithm() { + + final String rawPreferredJwsAlgorithm = properties.getOidcPreferredJwsAlgorithm(); + + final JWSAlgorithm preferredJwsAlgorithm; + if (StringUtils.isBlank(rawPreferredJwsAlgorithm)) { + preferredJwsAlgorithm = JWSAlgorithm.RS256; + } else { + if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) { + preferredJwsAlgorithm = null; + } else { + preferredJwsAlgorithm = JWSAlgorithm.parse(rawPreferredJwsAlgorithm); + } + } + return preferredJwsAlgorithm; + } + /** * Loads the initial configuration values relating to the OIDC provider from the class {@link NiFiProperties} and populates the individual fields. */ @@ -262,7 +272,6 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { if (!isOidcEnabled()) { throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); } - return oidcProviderMetadata.getAuthorizationEndpointURI(); } @@ -274,6 +283,14 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { return oidcProviderMetadata.getEndSessionEndpointURI(); } + @Override + public URI getRevocationEndpoint() { + if (!isOidcEnabled()) { + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); + } + return oidcProviderMetadata.getRevocationEndpointURI(); + } + @Override public Scope getScope() { if (!isOidcEnabled()) { @@ -295,37 +312,101 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { if (!isOidcEnabled()) { throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); } - return clientId; } @Override - public String exchangeAuthorizationCode(final AuthorizationGrant authorizationGrant) throws IOException { + public LoginAuthenticationToken exchangeAuthorizationCodeforLoginAuthenticationToken(final AuthorizationGrant authorizationGrant) throws IOException { // Check if OIDC is enabled if (!isOidcEnabled()) { throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); } - // Build ClientAuthentication - final ClientAuthentication clientAuthentication = createClientAuthentication(); - try { - // Build the token request - final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication); - return authorizeClient(tokenHttpRequest); + // Authenticate and authorize the client request + final TokenResponse response = authorizeClient(authorizationGrant); - } catch (final ParseException | JOSEException | BadJOSEException | java.text.ParseException e) { - throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage()); + // Convert response to Login Authentication Token + // We only want to do this for login + return convertOIDCTokenToLoginAuthenticationToken((OIDCTokenResponse) response); + + } catch (final RuntimeException | ParseException | JOSEException | BadJOSEException | java.text.ParseException e) { + throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage(), e); } } - private String authorizeClient(HTTPRequest tokenHttpRequest) throws ParseException, IOException, BadJOSEException, JOSEException, java.text.ParseException { + @Override + public String exchangeAuthorizationCodeForAccessToken(final AuthorizationGrant authorizationGrant) throws Exception { + // Check if OIDC is enabled + if (!isOidcEnabled()) { + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); + } + + try { + // Authenticate and authorize the client request + final TokenResponse response = authorizeClient(authorizationGrant); + return getAccessTokenString((OIDCTokenResponse) response); + + } catch (final RuntimeException | ParseException | IOException | java.text.ParseException | InvalidHashException e) { + throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage(), e); + } + } + + @Override + public String exchangeAuthorizationCodeForIdToken(final AuthorizationGrant authorizationGrant) { + // Check if OIDC is enabled + if (!isOidcEnabled()) { + throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); + } + + try { + // Authenticate and authorize the client request + final TokenResponse response = authorizeClient(authorizationGrant); + return getIdTokenString((OIDCTokenResponse) response); + + } catch (final RuntimeException | JOSEException | BadJOSEException | ParseException | IOException e) { + throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage(), e); + } + } + + private String getAccessTokenString(final OIDCTokenResponse response) throws Exception { + final OIDCTokens oidcTokens = getOidcTokens(response); + + // Validate the Access Token + validateAccessToken(oidcTokens); + + // Return the Access Token String + return oidcTokens.getAccessToken().getValue(); + } + + private String getIdTokenString(OIDCTokenResponse response) throws BadJOSEException, JOSEException { + final OIDCTokens oidcTokens = getOidcTokens(response); + + // Validate the Token - no nonce required for authorization code flow + validateIdToken(oidcTokens.getIDToken()); + + // Return the ID Token string + return oidcTokens.getIDTokenString(); + } + + private TokenResponse authorizeClient(AuthorizationGrant authorizationGrant) throws ParseException, IOException { + // Build ClientAuthentication + final ClientAuthentication clientAuthentication = createClientAuthentication(); + + // Build the token request + final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication); + + // Send the request and parse for success + return authorizeClientRequest(tokenHttpRequest); + } + + private TokenResponse authorizeClientRequest(HTTPRequest tokenHttpRequest) throws ParseException, IOException { // Get the token response final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send()); // Handle success if (response.indicatesSuccess()) { - return convertOIDCTokenToNiFiToken((OIDCTokenResponse) response); + return response; } else { // If the response was not successful final TokenErrorResponse errorResponse = (TokenErrorResponse) response; @@ -334,15 +415,14 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { } } - private String convertOIDCTokenToNiFiToken(OIDCTokenResponse response) throws BadJOSEException, JOSEException, java.text.ParseException, IOException { - final OIDCTokenResponse oidcTokenResponse = response; - final OIDCTokens oidcTokens = oidcTokenResponse.getOIDCTokens(); + private LoginAuthenticationToken convertOIDCTokenToLoginAuthenticationToken(OIDCTokenResponse response) throws BadJOSEException, JOSEException, java.text.ParseException, IOException { + final OIDCTokens oidcTokens = getOidcTokens(response); final JWT oidcJwt = oidcTokens.getIDToken(); - // validate the token - no nonce required for authorization code flow - final IDTokenClaimsSet claimsSet = tokenValidator.validate(oidcJwt, null); + // Validate the token + final IDTokenClaimsSet claimsSet = validateIdToken(oidcJwt); - // attempt to extract the configured claim to access the user's identity; default is 'email' + // Attempt to extract the configured claim to access the user's identity; default is 'email' String identityClaim = properties.getOidcClaimIdentifyingUser(); String identity = claimsSet.getStringClaim(identityClaim); @@ -364,26 +444,30 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { } } - // extract expiration details from the claims set + // Extract expiration details from the claims set final Calendar now = Calendar.getInstance(); final Date expiration = claimsSet.getExpirationTime(); final long expiresIn = expiration.getTime() - now.getTimeInMillis(); - // convert into a nifi jwt for retrieval later - final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(identity, identity, expiresIn, - claimsSet.getIssuer().getValue()); - return jwtService.generateSignedToken(loginToken); + // Convert into a NiFi JWT for retrieval later + final LoginAuthenticationToken loginToken = new LoginAuthenticationToken( + identity, identity, expiresIn, claimsSet.getIssuer().getValue()); + return loginToken; + } + + private OIDCTokens getOidcTokens(OIDCTokenResponse response) { + return response.getOIDCTokens(); } private String retrieveIdentityFromUserInfoEndpoint(OIDCTokens oidcTokens) throws IOException { - // explicitly try to get the identity from the UserInfo endpoint with the configured claim - // extract the bearer access token + // Explicitly try to get the identity from the UserInfo endpoint with the configured claim + // Extract the bearer access token final BearerAccessToken bearerAccessToken = oidcTokens.getBearerAccessToken(); if (bearerAccessToken == null) { throw new IllegalStateException("No access token found in the ID tokens"); } - // invoke the UserInfo endpoint + // Invoke the UserInfo endpoint HTTPRequest userInfoRequest = createUserInfoRequest(bearerAccessToken); return lookupIdentityInUserInfo(userInfoRequest); } @@ -428,6 +512,38 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider { return presentClaims; } + private void validateAccessToken(OIDCTokens oidcTokens) throws Exception { + // Get the Access Token to validate + final AccessToken accessToken = oidcTokens.getAccessToken(); + + // Get the preferredJwsAlgorithm for validation + final JWSAlgorithm jwsAlgorithm = extractJwsAlgorithm(); + + // Get the accessTokenHash for validation + final String atHashString = oidcTokens + .getIDToken() + .getJWTClaimsSet() + .getStringClaim("at_hash"); + + // Compute the Access Token hash + final AccessTokenHash atHash = new AccessTokenHash(atHashString); + + try { + // Validate the Token + AccessTokenValidator.validate(accessToken, jwsAlgorithm, atHash); + } catch (InvalidHashException e) { + throw new Exception("Unable to validate the Access Token: " + e.getMessage()); + } + } + + private IDTokenClaimsSet validateIdToken(JWT oidcJwt) throws BadJOSEException, JOSEException { + try { + return tokenValidator.validate(oidcJwt, null); + } catch (BadJOSEException e) { + throw new BadJOSEException("Unable to validate the ID Token: " + e.getMessage()); + } + } + private String lookupIdentityInUserInfo(final HTTPRequest userInfoHttpRequest) throws IOException { try { // send the user request diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy new file mode 100644 index 0000000000..5480ad4837 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.web.security.oidc + + +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.apache.nifi.admin.service.KeyService +import org.apache.nifi.key.Key +import org.apache.nifi.util.NiFiProperties +import org.apache.nifi.web.security.jwt.JwtService +import org.apache.nifi.web.security.token.LoginAuthenticationToken +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.concurrent.TimeUnit + +@RunWith(JUnit4.class) +class OidcServiceGroovyTest extends GroovyTestCase { + private static final Logger logger = LoggerFactory.getLogger(OidcServiceGroovyTest.class) + + private static final Key SIGNING_KEY = new Key(id: 1, identity: "signingKey", key: "mock-signing-key-value") + private static final Map DEFAULT_NIFI_PROPERTIES = [ + isOidcEnabled : true, + getOidcDiscoveryUrl : "https://localhost/oidc", + isLoginIdentityProviderEnabled: false, + isKnoxSsoEnabled : false, + getOidcConnectTimeout : "1000", + getOidcReadTimeout : "1000", + getOidcClientId : "expected_client_id", + getOidcClientSecret : "expected_client_secret", + getOidcClaimIdentifyingUser : "username", + getOidcPreferredJwsAlgorithm : "" + ] + + // Mock collaborators + private static NiFiProperties mockNiFiProperties + private static JwtService mockJwtService = [:] as JwtService + private static StandardOidcIdentityProvider soip + + private static final String MOCK_REQUEST_IDENTIFIER = "mock-request-identifier" + private static final String MOCK_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0lEQyBVbml0IFRlc3Rlci" + + "IsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3Vua" + + "XRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzd" + + "CIsImVtYWlsIjoib2lkY190ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9" + + ".b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw" + + @BeforeClass + static void setUpOnce() throws Exception { + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + } + + @Before + void setUp() throws Exception { + mockNiFiProperties = buildNiFiProperties() + soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiProperties) + } + + @After + void teardown() throws Exception { + } + + private static NiFiProperties buildNiFiProperties(Map props = [:]) { + def combinedProps = DEFAULT_NIFI_PROPERTIES + props + def mockNFP = combinedProps.collectEntries { String k, def v -> + [k, { -> return v }] + } + mockNFP as NiFiProperties + } + + private static JwtService buildJwtService() { + def mockJS = new JwtService([:] as KeyService) { + @Override + String generateSignedToken(LoginAuthenticationToken lat) { + signNiFiToken(lat) + } + } + mockJS + } + + private static String signNiFiToken(LoginAuthenticationToken lat) { + String identity = "mockUser" + String USERNAME_CLAIM = "username" + String KEY_ID_CLAIM = "keyId" + Calendar expiration = Calendar.getInstance() + expiration.setTimeInMillis(System.currentTimeMillis() + 10_000) + String username = lat.getName() + + return Jwts.builder().setSubject(identity) + .setIssuer(lat.getIssuer()) + .setAudience(lat.getIssuer()) + .claim(USERNAME_CLAIM, username) + .claim(KEY_ID_CLAIM, SIGNING_KEY.getId()) + .setExpiration(expiration.getTime()) + .setIssuedAt(Calendar.getInstance().getTime()) + .signWith(SignatureAlgorithm.HS256, SIGNING_KEY.key.getBytes("UTF-8")).compact() + } + + @Test + void testShouldStoreJwt() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockInitializedProvider([:]) + + OidcService service = new OidcService(soip) + + // Expected JWT + logger.info("EXPECTED_JWT: ${MOCK_JWT}") + + // Act + service.storeJwt(MOCK_REQUEST_IDENTIFIER, MOCK_JWT) + + // Assert + final String cachedJwt = service.getJwt(MOCK_REQUEST_IDENTIFIER) + logger.info("Cached JWT: ${cachedJwt}") + + assert cachedJwt == MOCK_JWT + } + + @Test + void testShouldGetJwt() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockInitializedProvider([:]) + + OidcService service = new OidcService(soip) + + // Expected JWT + logger.info("EXPECTED_JWT: ${MOCK_JWT}") + + // store the jwt + service.storeJwt(MOCK_REQUEST_IDENTIFIER, MOCK_JWT) + + // Act + final String retrievedJwt = service.getJwt(MOCK_REQUEST_IDENTIFIER) + logger.info("Retrieved JWT: ${retrievedJwt}") + + // Assert + assert retrievedJwt == MOCK_JWT + } + + @Test + void testGetJwtShouldReturnNullWithExpiredDuration() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockInitializedProvider([:]) + + final int DURATION = 500 + final TimeUnit EXPIRATION_UNITS = TimeUnit.MILLISECONDS + OidcService service = new OidcService(soip, DURATION, EXPIRATION_UNITS) + + // Expected JWT + logger.info("EXPECTED_JWT: ${MOCK_JWT}") + + // Store the jwt + service.storeJwt(MOCK_REQUEST_IDENTIFIER, MOCK_JWT) + + // Put thread to sleep + long millis = 1000 + Thread.sleep(millis) + logger.info("Thread will sleep for: ${millis} ms") + + // Act + final String retrievedJwt = service.getJwt(MOCK_REQUEST_IDENTIFIER) + logger.info("Retrieved JWT: ${retrievedJwt}") + + // Assert + assert retrievedJwt == null + } + + private static StandardOidcIdentityProvider buildIdentityProviderWithMockInitializedProvider(Map additionalProperties = [:]) { + JwtService mockJS = buildJwtService() + NiFiProperties mockNFP = buildNiFiProperties(additionalProperties) + + // Mock OIDC provider metadata + Issuer mockIssuer = new Issuer("mockIssuer") + URI mockURI = new URI("https://localhost/oidc") + OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI) + + StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJS, mockNFP) { + @Override + void initializeProvider() { + soip.oidcProviderMetadata = metadata + soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC] + soip.oidcProviderMetadata["userInfoEndpointURI"] = new URI("https://localhost/oidc/token") + } + } + soip + } +} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy index 0c343edf0c..43afb9bcd6 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy @@ -16,6 +16,7 @@ */ package org.apache.nifi.web.security.oidc +import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jwt.JWT import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.PlainJWT @@ -36,14 +37,16 @@ import com.nimbusds.oauth2.sdk.token.RefreshToken import com.nimbusds.openid.connect.sdk.Nonce import com.nimbusds.openid.connect.sdk.OIDCTokenResponse import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.claims.AccessTokenHash import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata import com.nimbusds.openid.connect.sdk.token.OIDCTokens import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator import groovy.json.JsonOutput -import groovy.json.JsonSlurper import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm +import net.minidev.json.JSONObject +import org.apache.commons.codec.binary.Base64 import org.apache.nifi.admin.service.KeyService import org.apache.nifi.key.Key import org.apache.nifi.util.NiFiProperties @@ -65,7 +68,8 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { private static final Key SIGNING_KEY = new Key(id: 1, identity: "signingKey", key: "mock-signing-key-value") private static final Map DEFAULT_NIFI_PROPERTIES = [ - isOidcEnabled : false, +// isOidcEnabled : false, + isOidcEnabled : true, getOidcDiscoveryUrl : "https://localhost/oidc", isLoginIdentityProviderEnabled: false, isKnoxSsoEnabled : false, @@ -73,13 +77,19 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { getOidcReadTimeout : 1000, getOidcClientId : "expected_client_id", getOidcClientSecret : "expected_client_secret", - getOidcClaimIdentifyingUser : "username" + getOidcClaimIdentifyingUser : "username", + getOidcPreferredJwsAlgorithm : "" ] // Mock collaborators private static NiFiProperties mockNiFiProperties private static JwtService mockJwtService = [:] as JwtService + private static final String MOCK_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZ" + + "SI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZp" + + "X3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY19" + + "0ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw" + @BeforeClass static void setUpOnce() throws Exception { logger.metaClass.methodMissing = { String name, args -> @@ -110,7 +120,6 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { String generateSignedToken(LoginAuthenticationToken lat) { signNiFiToken(lat) } - } mockJS } @@ -137,9 +146,9 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { void testShouldGetAvailableClaims() { // Arrange final Map EXPECTED_CLAIMS = [ - "iss" : "https://accounts.google.com", - "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com", - "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com", + "iss" : "https://accounts.issuer.com", + "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", + "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", "sub" : "10703475345439756345540", "email" : "person@nifi.apache.org", "email_verified": "true", @@ -346,11 +355,12 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { logger.expected(msg) // Assert - assert msg =~ "An error occurred while invoking the UserInfo endpoint: The provided username and password were not correct" + assert msg =~ "An error occurred while invoking the UserInfo endpoint: The provided username and password " + + "were not correct" } @Test - void testShouldConvertOIDCTokenToNiFiToken() { + void testShouldConvertOIDCTokenToLoginAuthenticationToken() { // Arrange StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "email"]) @@ -358,35 +368,24 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { logger.info("OIDC Token Response: ${mockResponse.dump()}") // Act - String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse) - logger.info("NiFi token: ${nifiToken}") + final String loginToken = soip.convertOIDCTokenToLoginAuthenticationToken(mockResponse) + logger.info("Login Authentication token: ${loginToken}") // Assert + // Split ID Token into components + def (String contents, String expiration) = loginToken.tokenize("\\[\\]") + logger.info("Token contents: ${contents} | Expiration: ${expiration}") - // Split JWT into components and decode Base64 to JSON - def (String headerB64, String payloadB64, String signatureB64) = nifiToken.tokenize("\\.") - logger.info("Header: ${headerB64} | Payload: ${payloadB64} | Signature: ${signatureB64}") - String headerJson = new String(Base64.decoder.decode(headerB64), "UTF-8") - String payloadJson = new String(Base64.decoder.decode(payloadB64), "UTF-8") - // String signatureJson = new String(Base64.decoder.decode(signatureB64), "UTF-8") + assert contents =~ "LoginAuthenticationToken for person@nifi\\.apache\\.org issued by https://accounts\\.issuer\\.com expiring at" - // Parse JSON into objects - def slurper = new JsonSlurper() - def header = slurper.parseText(headerJson) - logger.info("Header: ${header}") - - assert header.alg == "HS256" - - def payload = slurper.parseText(payloadJson) - logger.info("Payload: ${payload}") - - assert payload.username == "person@nifi.apache.org" - assert payload.keyId == 1 - assert payload.exp <= System.currentTimeMillis() + 10_000 + // Assert expiration + final String[] expList = expiration.split("\\s") + final Long expLong = Long.parseLong(expList[0]) + assert expLong <= System.currentTimeMillis() + 10_000 } @Test - void testConvertOIDCTokenToNiFiTokenShouldHandleBlankIdentity() { + void testConvertOIDCTokenToLoginAuthenticationTokenShouldHandleBlankIdentity() { // Arrange StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim"]) @@ -394,34 +393,26 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { logger.info("OIDC Token Response: ${mockResponse.dump()}") // Act - String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse) - logger.info("NiFi token: ${nifiToken}") + String loginToken = soip.convertOIDCTokenToLoginAuthenticationToken(mockResponse) + logger.info("Login Authentication token: ${loginToken}") // Assert - // Split JWT into components and decode Base64 to JSON - def (String headerB64, String payloadB64, String signatureB64) = nifiToken.tokenize("\\.") - logger.info("Header: ${headerB64} | Payload: ${payloadB64} | Signature: ${signatureB64}") - String headerJson = new String(Base64.decoder.decode(headerB64), "UTF-8") - String payloadJson = new String(Base64.decoder.decode(payloadB64), "UTF-8") - // String signatureJson = new String(Base64.decoder.decode(signatureB64), "UTF-8") + // Split ID Token into components + def (String contents, String expiration) = loginToken.tokenize("\\[\\]") + logger.info("Token contents: ${contents} | Expiration: ${expiration}") - // Parse JSON into objects - def slurper = new JsonSlurper() - def header = slurper.parseText(headerJson) - logger.info("Header: ${header}") + assert contents =~ "LoginAuthenticationToken for person@nifi\\.apache\\.org issued by https://accounts\\.issuer\\.com expiring at" - assert header.alg == "HS256" + // Get the expiration + final ArrayList expires = expiration.split("[\\D*]") + final long exp = Long.parseLong(expires[0]) + logger.info("exp: ${exp} ms") - def payload = slurper.parseText(payloadJson) - logger.info("Payload: ${payload}") - - assert payload.username == "person@nifi.apache.org" - assert payload.keyId == 1 - assert payload.exp <= System.currentTimeMillis() + 10_000 + assert exp <= System.currentTimeMillis() + 10_000 } @Test - void testConvertOIDCTokenToNiFiTokenShouldHandleBlankIdentityAndNoEmailClaim() { + void testConvertOIDCTokenToLoginAuthNTokenShouldHandleBlankIdentityAndNoEmailClaim() { // Arrange StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim"]) @@ -429,57 +420,270 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { logger.info("OIDC Token Response: ${mockResponse.dump()}") // Act - def msg = shouldFail { - String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse) - logger.info("NiFi token: ${nifiToken}") + def msg = shouldFail(ConnectException) { + String loginAuthenticationToken = soip.convertOIDCTokenToLoginAuthenticationToken(mockResponse) + logger.info("Login authentication token: ${loginAuthenticationToken}") } + logger.expected(msg) // Assert assert msg =~ "Connection refused|Remote host terminated the handshake" } @Test - void testShouldAuthorizeClient() { + void testShouldAuthorizeClientRequest() { // Arrange // Build ID Provider with mock token endpoint URI to make a connection StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:]) - // Mock the JWT - def jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY190ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw" - - def responseBody = [id_token: jwt, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"] + def responseBody = [id_token: MOCK_JWT, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"] HTTPRequest mockTokenRequest = mockHttpRequest(responseBody, 200, "HTTP OK") // Act - def nifiToken = soip.authorizeClient(mockTokenRequest) - logger.info("NiFi Token: ${nifiToken.dump()}") + def tokenResponse = soip.authorizeClientRequest(mockTokenRequest) + logger.info("Token Response: ${tokenResponse.dump()}") // Assert - assert nifiToken + assert tokenResponse } @Test - void testAuthorizeClientShouldHandleError() { + void testAuthorizeClientRequestShouldHandleError() { // Arrange // Build ID Provider with mock token endpoint URI to make a connection StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:]) - // Mock the JWT - def jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY190ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw" - - def responseBody = [id_token: jwt, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"] + def responseBody = [id_token: MOCK_JWT, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"] HTTPRequest mockTokenRequest = mockHttpRequest(responseBody, 500, "HTTP SERVER ERROR") // Act def msg = shouldFail(RuntimeException) { - def nifiToken = soip.authorizeClient(mockTokenRequest) + def nifiToken = soip.authorizeClientRequest(mockTokenRequest) logger.info("NiFi token: ${nifiToken}") } + logger.expected(msg) // Assert assert msg =~ "An error occurred while invoking the Token endpoint: null" } + @Test + void testShouldGetAccessTokenString() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Mock access tokens + AccessToken mockAccessToken = new BearerAccessToken() + RefreshToken mockRefreshToken = new RefreshToken() + + // Compute the access token hash + final JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256 + AccessTokenHash EXPECTED_HASH = AccessTokenHash.compute(mockAccessToken, jwsAlgorithm) + logger.info("Expected at_hash: ${EXPECTED_HASH}") + + // Create mock claims with at_hash + Map mockClaims = (["at_hash": EXPECTED_HASH.toString()]) + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(mockClaims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Create OIDC Tokens + OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken) + + // Create OIDC Token Response + OIDCTokenResponse mockResponse = new OIDCTokenResponse(mockOidcTokens) + + // Act + String accessTokenString = soip.getAccessTokenString(mockResponse) + logger.info("Access token: ${accessTokenString}") + + // Assert + assert accessTokenString + } + + @Test + void testShouldValidateAccessToken() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Mock access tokens + AccessToken mockAccessToken = new BearerAccessToken() + logger.info("mock access token: ${mockAccessToken.toString()}") + RefreshToken mockRefreshToken = new RefreshToken() + + // Compute the access token hash + final JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256 + AccessTokenHash EXPECTED_HASH = AccessTokenHash.compute(mockAccessToken, jwsAlgorithm) + logger.info("Expected at_hash: ${EXPECTED_HASH}") + + // Create mock claim + final Map claims = mockClaims(["at_hash":EXPECTED_HASH.toString()]) + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Create OIDC Tokens + OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken) + logger.info("mock id tokens: ${mockOidcTokens.getIDToken().properties}") + + // Act + String accessTokenString = soip.validateAccessToken(mockOidcTokens) + logger.info("Access Token: ${accessTokenString}") + + // Assert + assert accessTokenString == null + } + + @Test + void testValidateAccessTokenShouldHandleMismatchedATHash() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Mock access tokens + AccessToken mockAccessToken = new BearerAccessToken() + RefreshToken mockRefreshToken = new RefreshToken() + + // Compute the access token hash + final JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256 + AccessTokenHash expectedHash = AccessTokenHash.compute(mockAccessToken, jwsAlgorithm) + logger.info("Expected at_hash: ${expectedHash}") + + // Create mock claim with incorrect 'at_hash' + final Map claims = mockClaims() + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Create OIDC Tokens + OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken) + + // Act + def msg = shouldFail(Exception) { + soip.validateAccessToken(mockOidcTokens) + } + logger.expected(msg) + + // Assert + assert msg =~ "Unable to validate the Access Token: Access token hash \\(at_hash\\) mismatch" + } + + @Test + void testShouldGetIdTokenString() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Mock access tokens + AccessToken mockAccessToken = new BearerAccessToken() + RefreshToken mockRefreshToken = new RefreshToken() + + // Create mock claim + final Map claims = mockClaims() + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Create OIDC Tokens + OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken) + + final String EXPECTED_ID_TOKEN = mockOidcTokens.getIDTokenString() + logger.info("EXPECTED_ID_TOKEN: ${EXPECTED_ID_TOKEN}") + + // Create OIDC Token Response + OIDCTokenResponse mockResponse = new OIDCTokenResponse(mockOidcTokens) + + // Act + final String idTokenString = soip.getIdTokenString(mockResponse) + logger.info("ID Token: ${idTokenString}") + + // Assert + assert idTokenString + assert idTokenString == EXPECTED_ID_TOKEN + + // Assert that 'email:person@nifi.apache.org' is present + def (String header, String payload) = idTokenString.split("\\.") + final byte[] idTokenBytes = Base64.decodeBase64(payload) + final String payloadString = new String(idTokenBytes, "UTF-8") + logger.info(payloadString) + + assert payloadString =~ "\"email\":\"person@nifi\\.apache\\.org\"" + } + + @Test + void testShouldValidateIdToken() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Create mock claim + final Map claims = mockClaims() + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Act + final IDTokenClaimsSet claimsSet = soip.validateIdToken(mockJwt) + final String claimsSetString = claimsSet.toJSONObject().toString() + logger.info("ID Token Claims Set: ${claimsSetString}") + + // Assert + assert claimsSet + assert claimsSetString =~ "\"email\":\"person@nifi\\.apache\\.org\"" + } + + @Test + void testShouldGetOidcTokens() { + // Arrange + StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator() + + // Mock access tokens + AccessToken mockAccessToken = new BearerAccessToken() + RefreshToken mockRefreshToken = new RefreshToken() + + // Create mock claim + final Map claims = mockClaims() + + // Create Claims Set + JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) + + // Create JWT + JWT mockJwt = new PlainJWT(mockJWTClaimsSet) + + // Create OIDC Tokens + OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken) + + final String EXPECTED_ID_TOKEN = mockOidcTokens.getIDTokenString() + logger.info("EXPECTED_ID_TOKEN: ${EXPECTED_ID_TOKEN}") + + // Create OIDC Token Response + OIDCTokenResponse mockResponse = new OIDCTokenResponse(mockOidcTokens) + + // Act + final OIDCTokens oidcTokens = soip.getOidcTokens(mockResponse) + logger.info("OIDC Tokens: ${oidcTokens.toJSONObject()}") + + // Assert + assert oidcTokens + + // Assert ID Tokens match + final JSONObject oidcJson = oidcTokens.toJSONObject() + final String idToken = oidcJson["id_token"] + logger.info("ID Token String: ${idToken}") + assert idToken == EXPECTED_ID_TOKEN + } private StandardOidcIdentityProvider buildIdentityProviderWithMockTokenValidator(Map additionalProperties = [:]) { JwtService mockJS = buildJwtService() @@ -515,17 +719,7 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { } private OIDCTokenResponse mockOIDCTokenResponse(Map additionalClaims = [:]) { - final Map claims = [ - "iss" : "https://accounts.google.com", - "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com", - "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com", - "sub" : "10703475345439756345540", - "email" : "person@nifi.apache.org", - "email_verified": "true", - "at_hash" : "JOGISUDHFiyGHDSFwV5Fah2A", - "iat" : 1590022674, - "exp" : 1590026274 - ] + additionalClaims + Map claims = mockClaims(additionalClaims) // Create Claims Set JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims) @@ -545,6 +739,20 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { mockResponse } + private static Map mockClaims(Map additionalClaims = [:]) { + final Map claims = [ + "iss" : "https://accounts.issuer.com", + "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", + "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", + "sub" : "10703475345439756345540", + "email" : "person@nifi.apache.org", + "email_verified": "true", + "at_hash" : "JOGISUDHFiyGHDSFwV5Fah2A", + "iat" : 1590022674, + "exp" : 1590026274 + ] + additionalClaims + claims + } /** * Forms an {@link HTTPRequest} object which returns a static response when {@code send( )} is called. @@ -574,11 +782,4 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase { } } } - - class MockOIDCProviderMetadata extends OIDCProviderMetadata { - - MockOIDCProviderMetadata() { - super([:] as Issuer, [SubjectType.PUBLIC] as List, new URI("https://localhost")) - } - } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java index 543b003c2b..15aedb9fdf 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java @@ -18,16 +18,15 @@ package org.apache.nifi.web.security.oidc; import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.AuthorizationGrant; import com.nimbusds.oauth2.sdk.id.State; -import org.junit.Test; - +import java.io.IOException; import java.net.URI; import java.util.UUID; import java.util.concurrent.TimeUnit; +import org.junit.Test; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -39,32 +38,32 @@ public class OidcServiceTest { public static final String TEST_STATE = "test-state"; @Test(expected = IllegalStateException.class) - public void testOidcNotEnabledCreateState() throws Exception { + public void testOidcNotEnabledCreateState() { final OidcService service = getServiceWithNoOidcSupport(); service.createState(TEST_REQUEST_IDENTIFIER); } @Test(expected = IllegalStateException.class) - public void testCreateStateMultipleInvocations() throws Exception { + public void testCreateStateMultipleInvocations() { final OidcService service = getServiceWithOidcSupport(); service.createState(TEST_REQUEST_IDENTIFIER); service.createState(TEST_REQUEST_IDENTIFIER); } @Test(expected = IllegalStateException.class) - public void testOidcNotEnabledValidateState() throws Exception { + public void testOidcNotEnabledValidateState() { final OidcService service = getServiceWithNoOidcSupport(); service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE)); } @Test - public void testOidcUnknownState() throws Exception { + public void testOidcUnknownState() { final OidcService service = getServiceWithOidcSupport(); assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE))); } @Test - public void testValidateState() throws Exception { + public void testValidateState() { final OidcService service = getServiceWithOidcSupport(); final State state = service.createState(TEST_REQUEST_IDENTIFIER); assertTrue(service.isStateValid(TEST_REQUEST_IDENTIFIER, state)); @@ -81,41 +80,46 @@ public class OidcServiceTest { } @Test(expected = IllegalStateException.class) - public void testOidcNotEnabledExchangeCode() throws Exception { - final OidcService service = getServiceWithNoOidcSupport(); - service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant()); - } - - @Test(expected = IllegalStateException.class) - public void testExchangeCodeMultipleInvocation() throws Exception { + public void testStoreJwtMultipleInvocation() { final OidcService service = getServiceWithOidcSupport(); - service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant()); - service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant()); + + final String TEST_JWT1 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0" + + "lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF9hd" + + "XRoyZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw"; + + final String TEST_JWT2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6Ik5pRm" + + "kgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF" + + "9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY190ZXN0QG5pZmk" + + "uYXBhY2hlLm9yZyJ9.nlYhplDLXeGAwW62rJ_ZnEaG7nxEB4TbaJNK-_pC4WQ"; + + service.storeJwt(TEST_REQUEST_IDENTIFIER, TEST_JWT1); + service.storeJwt(TEST_REQUEST_IDENTIFIER, TEST_JWT2); } @Test(expected = IllegalStateException.class) - public void testOidcNotEnabledGetJwt() throws Exception { + public void testOidcNotEnabledExchangeCodeForLoginAuthenticationToken() throws Exception { + final OidcService service = getServiceWithNoOidcSupport(); + service.exchangeAuthorizationCodeForLoginAuthenticationToken(getAuthorizationGrant()); + } + + @Test(expected = IllegalStateException.class) + public void testOidcNotEnabledExchangeCodeForAccessToken() throws Exception { + final OidcService service = getServiceWithNoOidcSupport(); + service.exchangeAuthorizationCodeForAccessToken(getAuthorizationGrant()); + } + + @Test(expected = IllegalStateException.class) + public void testOidcNotEnabledExchangeCodeForIdToken() throws IOException { + final OidcService service = getServiceWithNoOidcSupport(); + service.exchangeAuthorizationCodeForIdToken(getAuthorizationGrant()); + } + + @Test(expected = IllegalStateException.class) + public void testOidcNotEnabledGetJwt() { final OidcService service = getServiceWithNoOidcSupport(); service.getJwt(TEST_REQUEST_IDENTIFIER); } - @Test - public void testGetJwt() throws Exception { - final OidcService service = getServiceWithOidcSupport(); - service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant()); - assertNotNull(service.getJwt(TEST_REQUEST_IDENTIFIER)); - } - - @Test - public void testGetJwtExpiration() throws Exception { - final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS); - service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant()); - - Thread.sleep(3 * 1000); - - assertNull(service.getJwt(TEST_REQUEST_IDENTIFIER)); - } - private OidcService getServiceWithNoOidcSupport() { final OidcIdentityProvider provider = mock(OidcIdentityProvider.class); when(provider.isOidcEnabled()).thenReturn(false); @@ -126,10 +130,9 @@ public class OidcServiceTest { return service; } - private OidcService getServiceWithOidcSupport() throws Exception { + private OidcService getServiceWithOidcSupport() { final OidcIdentityProvider provider = mock(OidcIdentityProvider.class); when(provider.isOidcEnabled()).thenReturn(true); - when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString()); final OidcService service = new OidcService(provider); assertTrue(service.isOidcEnabled()); @@ -140,7 +143,7 @@ public class OidcServiceTest { private OidcService getServiceWithOidcSupportAndCustomExpiration(final int duration, final TimeUnit units) throws Exception { final OidcIdentityProvider provider = mock(OidcIdentityProvider.class); when(provider.isOidcEnabled()).thenReturn(true); - when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString()); + when(provider.exchangeAuthorizationCodeforLoginAuthenticationToken(any())).then(invocation -> UUID.randomUUID().toString()); final OidcService service = new OidcService(provider, duration, units); assertTrue(service.isOidcEnabled()); @@ -148,7 +151,7 @@ public class OidcServiceTest { return service; } - private AuthorizationCodeGrant getAuthorizationCodeGrant() { + private AuthorizationGrant getAuthorizationGrant() { return new AuthorizationCodeGrant(new AuthorizationCode("code"), URI.create("http://localhost:8080/nifi")); } } \ No newline at end of file