NIFI-7584 Added OIDC logout mechanism.

Added method to validate the OIDC Access Token for the revoke endpoint.
Created a new callback URI of oidc/logoutCallback to handle certain OIDC logout cases.
Changed method to exchange the Authorization Code for a Login Authentication Token.
Added a new method to exchange the AuthN Code for an Access Token.
Changed method to convert OIDC Token to a Login AuthN Token instead of a NiFi JWT.
Created new OidcServiceGroovyTest class.

NIFI-7584-rebase Added test.

NIFI-7584 Fixed a checkstyle issue.

NIFI-7584 Removed a dependency not in use.

NIFI-7584 Made revisions based on PR review.
Refactored revoke endpoint POST request to a private method.
Removed unnecessary dependencies.
Fixed Regex Pattern to search for literal dot character.
Fixed logging the Exception message.
Fixed caught Exception.
Changed timeout value to a static variable.
Changed repeating error messages to a static string.
Reduced sleep duration in unit test.
Refactored cookie generation to private method.

NIFI-7584 Fixed the snapshot version.

Signed-off-by: Nathan Gough <thenatog@gmail.com>

This closes #4593.
This commit is contained in:
mtien 2020-09-14 18:43:43 -07:00 committed by Nathan Gough
parent c70f4ac530
commit bf962f6227
9 changed files with 1133 additions and 239 deletions

View File

@ -422,5 +422,9 @@
<version>1.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -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");
}

View File

@ -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<NameValuePair> 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) {

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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<String, Object> 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<String, Object> 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<String, String> 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
}
}

View File

@ -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<String, Object> 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<String, String> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, String> additionalProperties = [:]) {
JwtService mockJS = buildJwtService()
@ -515,17 +719,7 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase {
}
private OIDCTokenResponse mockOIDCTokenResponse(Map<String, Object> additionalClaims = [:]) {
final Map<String, Object> 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<String, Object> claims = mockClaims(additionalClaims)
// Create Claims Set
JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims)
@ -545,6 +739,20 @@ class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase {
mockResponse
}
private static Map<String, Object> mockClaims(Map<String, Object> additionalClaims = [:]) {
final Map<String, Object> 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<SubjectType>, new URI("https://localhost"))
}
}
}

View File

@ -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"));
}
}