NIFI-4890 Refactor OIDC with support for Refresh Tokens (#7013)

* NIFI-4890 Refactored OIDC with support for Refresh Tokens

- Implemented OIDC Authorization Code Grant Flow using Spring Security Filters
- Implemented OIDC RP-Initiated Logout 1.0
- Implemented OAuth2 Token Revocation RFC 7009 for Refresh Tokens
- Added OIDC Bearer Token Refresh Filter for updating application Bearer Tokens from Refresh Token exchanges
- Added configurable Token Refresh Window to application properties
- Removed original implementation and supporting classes

* NIFI-4890 Set Bearer Token expiration based on Access Token

* NIFI-4890 Corrected spelling and naming issues based on feedback

This closes #7013
This commit is contained in:
exceptionfactory 2023-03-28 07:35:10 -05:00 committed by GitHub
parent 623bcfd500
commit 26400fcbe9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 4541 additions and 3138 deletions

View File

@ -195,6 +195,7 @@ public class NiFiProperties extends ApplicationProperties {
public static final String SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER = "nifi.security.user.oidc.claim.identifying.user";
public static final String NIFI_SECURITY_USER_OIDC_CLAIM_GROUPS = "nifi.security.user.oidc.claim.groups";
public static final String SECURITY_USER_OIDC_FALLBACK_CLAIMS_IDENTIFYING_USER = "nifi.security.user.oidc.fallback.claims.identifying.user";
public static final String SECURITY_USER_OIDC_TOKEN_REFRESH_WINDOW = "nifi.security.user.oidc.token.refresh.window";
// apache knox
public static final String SECURITY_USER_KNOX_URL = "nifi.security.user.knox.url";
@ -385,6 +386,7 @@ public class NiFiProperties extends ApplicationProperties {
public static final String DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT = "5 secs";
public static final String DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT = "5 secs";
public static final String DEFAULT_SECURITY_USER_OIDC_TRUSTSTORE_STRATEGY = "JDK";
private static final String DEFAULT_SECURITY_USER_OIDC_TOKEN_REFRESH_WINDOW = "60 secs";
public static final String DEFAULT_SECURITY_USER_SAML_METADATA_SIGNING_ENABLED = "false";
public static final String DEFAULT_SECURITY_USER_SAML_REQUEST_SIGNING_ENABLED = "false";
public static final String DEFAULT_SECURITY_USER_SAML_WANT_ASSERTIONS_SIGNED = "true";
@ -1180,6 +1182,10 @@ public class NiFiProperties extends ApplicationProperties {
return getProperty(SECURITY_USER_OIDC_TRUSTSTORE_STRATEGY, DEFAULT_SECURITY_USER_OIDC_TRUSTSTORE_STRATEGY);
}
public String getOidcTokenRefreshWindow() {
return getProperty(SECURITY_USER_OIDC_TOKEN_REFRESH_WINDOW, DEFAULT_SECURITY_USER_OIDC_TOKEN_REFRESH_WINDOW);
}
public boolean shouldSendServerVersion() {
return Boolean.parseBoolean(getProperty(WEB_SHOULD_SEND_SERVER_VERSION, DEFAULT_WEB_SHOULD_SEND_SERVER_VERSION));
}

View File

@ -258,7 +258,7 @@ properties can be specified.
NOTE: It is important when enabling HTTPS that the `nifi.web.http.port` property be unset. NiFi only supports running on HTTP *or* HTTPS, not both simultaneously.
NiFi's web server will REQUIRE certificate based client authentication for users accessing the User Interface when not configured with an alternative
authentication mechanism which would require one way SSL (for instance LDAP, OpenId Connect, etc). Enabling an alternative authentication mechanism will
authentication mechanism which would require one way SSL (for instance LDAP, OpenID Connect, etc). Enabling an alternative authentication mechanism will
configure the web server to WANT certificate base client authentication. This will allow it to support users with certificates and those without that
may be logging in with credentials. See <<user_authentication>> for more details.
@ -315,9 +315,9 @@ The semantics match the use of the following Jetty APIs:
[[user_authentication]]
== User Authentication
NiFi supports user authentication via client certificates, via username/password, via Apache Knox, or via link:http://openid.net/connect[OpenId Connect^].
NiFi supports user authentication using a number of configurable protocols and strategies.
Username/password authentication is performed by a 'Login Identity Provider'. The Login Identity Provider is a pluggable mechanism for
Username and password authentication is performed by a 'Login Identity Provider'. The Login Identity Provider is a pluggable mechanism for
authenticating users via their username/password. Which Login Identity Provider to use is configured in the _nifi.properties_ file.
Currently NiFi offers username/password with Login Identity Providers options for <<single_user_identity_provider>>, <<ldap_login_identity_provider>> and <<kerberos_login_identity_provider>>.
@ -326,14 +326,14 @@ The `nifi.login.identity.provider.configuration.file` property specifies the con
The `nifi.security.user.login.identity.provider` property indicates which of the configured Login Identity Provider should be
used. The default value of this property is `single-user-provider` supporting authentication with a generated username and password.
During OpenId Connect authentication, NiFi will redirect users to login with the Provider before returning to NiFi. NiFi will then
call the Provider to obtain the user identity.
For Single sign-on authentication, NiFi will redirect users to the Identity Provider before returning to NiFi. NiFi will then
process responses and convert attributes to application token information.
During Apache Knox authentication, NiFi will redirect users to login with Apache Knox before returning to NiFi. NiFi will verify the Apache Knox
token during authentication.
NOTE: NiFi can only be configured for username/password, OpenId Connect, or Apache Knox at a given time. It does not support running each of
these concurrently. NiFi will require client certificates for authenticating users over HTTPS if none of these are configured.
NOTE: NiFi cannot be configured for multiple authentication strategies simultaneously.
NiFi will require client certificates for authenticating users over HTTPS if no other strategies have been configured.
A user cannot anonymously authenticate with a secured instance of NiFi unless `nifi.security.allow.anonymous.authentication` is set to `true`.
If this is the case, NiFi must also be configured with an Authorizer that supports authorizing an anonymous user. Currently, NiFi does not ship
@ -490,31 +490,81 @@ See also <<kerberos_service>> to allow single sign-on access via client Kerberos
NOTE: For changes to _nifi.properties_ and _login-identity-providers.xml_ to take effect, NiFi needs to be restarted. If NiFi is clustered, configuration files must be the same on all nodes.
[[openid_connect]]
=== OpenId Connect
=== OpenID Connect
To enable authentication via OpenId Connect the following properties must be configured in _nifi.properties_.
OpenID Connect integration provides single sign-on using a specified Authorization Server.
The implementation supports the Authorization Code Grant Type as described in
link:https://www.rfc-editor.org/rfc/rfc6749#section-4.1[RFC 6749 Section 4.1^] and
link:https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowSteps[OpenID Connect Core Section 3.1.1^].
After successful authentication with the Authorization Server, NiFi generates an application Bearer Token with an
expiration based on the OAuth2 Access Token expiration. NiFi stores authorized tokens using the local State
Provider and encrypts serialized information using the application Sensitive Properties Key.
The implementation enables
link:https://openid.net/specs/openid-connect-rpinitiated-1_0.html[OpenID Connect RP-Initiated Logout 1.0^] when the
Authorization Server includes an `end_session_endpoint` element in the OpenID Discovery configuration.
OpenID Connect integration supports using Refresh Tokens as described in
link:https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens[OpenID Connect Core Section 12].
NiFi tracks the expiration of the application Bearer Token and uses the stored Refresh Token to renew
access prior to Bearer Token expiration, based on the configured token refresh window. NiFi does not require OpenID
Connect Providers to support Refresh Tokens. When an OpenID Connect Provider does not return a Refresh Token, NiFi
requires the user to initiate a new session when the application Bearer Token expires.
The Refresh Token implementation allows the NiFi session to continue as long as the Refresh Token is valid and the
user agent presents a valid Bearer Token. The default value for the token refresh window is 60 seconds. For an Access
Token with an expiration of one hour, NiFi will attempt to renew access using the Refresh Token when receiving an HTTP
request 59 minutes after authenticating the Access Token. Revoked Refresh Tokens or expired application Bearer Tokens
result in standard session timeout behavior, requiring the user to initiate a new session.
The OpenID Connect implementation supports OAuth 2.0 Token Revocation as defined in
link:https://www.rfc-editor.org/rfc/rfc7009[RFC 7009^]. OpenID Connect Discovery configuration must include a
`revocation_endpoint` element that supports RFC 7009 standards. The application sends revocation requests for Refresh
Tokens when the authenticated Resource Owner initiates the logout process.
The implementation includes a scheduled process for removing and revoking expired Refresh Tokens when the corresponding
Access Token has expired, indicating that the Resource Owner has terminated the application session. Scheduled session
termination occurs when the user closes the browser without initiating the logout process. The scheduled process avoids
extended storage of Refresh Tokens for users who are no longer interacting with the application.
OpenID Connect integration supports the following settings in _nifi.properties_.
[options="header"]
|==================================================================================================================================================
| Property Name | Description
|`nifi.security.user.oidc.discovery.url` | The discovery URL for the desired OpenId Connect Provider (link:http://openid.net/specs/openid-connect-discovery-1_0.html[http://openid.net/specs/openid-connect-discovery-1_0.html^]).
|`nifi.security.user.oidc.connect.timeout` | Connect timeout when communicating with the OpenId Connect Provider. The default value is `5 secs`.
|`nifi.security.user.oidc.read.timeout` | Read timeout when communicating with the OpenId Connect Provider. The default value is `5 secs`.
|`nifi.security.user.oidc.client.id` | The client id for NiFi after registration with the OpenId Connect Provider.
|`nifi.security.user.oidc.client.secret` | The client secret for NiFi after registration with the OpenId Connect Provider.
|`nifi.security.user.oidc.preferred.jwsalgorithm` | The preferred algorithm for validating identity tokens. If this value is blank, it will default to `RS256` which is required to be supported
by the OpenId Connect Provider according to the specification. If this value is `HS256`, `HS384`, or `HS512`, NiFi will attempt to validate HMAC protected tokens using the specified client secret.
| Property Name | Description
|`nifi.security.user.oidc.discovery.url` | The link:http://openid.net/specs/openid-connect-discovery-1_0.html[Discovery Configuration URL^] for the OpenID Connect Provider
|`nifi.security.user.oidc.connect.timeout` | Socket Connect timeout when communicating with the OpenID Connect Provider. The default value is `5 secs`
|`nifi.security.user.oidc.read.timeout` | Socket Read timeout when communicating with the OpenID Connect Provider. The default value is `5 secs`
|`nifi.security.user.oidc.client.id` | The Client ID for NiFi registered with the OpenID Connect Provider
|`nifi.security.user.oidc.client.secret` | The Client Secret for NiFi registered with the OpenID Connect Provider
|`nifi.security.user.oidc.preferred.jwsalgorithm` | The preferred algorithm for validating identity tokens. If this value is blank, it will default to `RS256` which is required to be supported
by the OpenID Connect Provider according to the specification. If this value is `HS256`, `HS384`, or `HS512`, NiFi will attempt to validate HMAC protected tokens using the specified client secret.
If this value is `none`, NiFi will attempt to validate unsecured/plain tokens. Other values for this algorithm will attempt to parse as an RSA or EC algorithm to be used in conjunction with the
JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the discovery URL.
|`nifi.security.user.oidc.additional.scopes` | Comma separated scopes that are sent to OpenId Connect Provider in addition to `openid` and `email`.
|`nifi.security.user.oidc.claim.identifying.user` | Claim that identifies the user to be logged in; default is `email`. May need to be requested via the `nifi.security.user.oidc.additional.scopes` before usage.
|`nifi.security.user.oidc.fallback.claims.identifying.user` | Comma separated possible fallback claims used to identify the user in case `nifi.security.user.oidc.claim.identifying.user` claim is not present for the login user.
|`nifi.security.user.oidc.claim.groups` | Name of the ID token claim that contains an array of group names of which the
JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the discovery URL
|`nifi.security.user.oidc.additional.scopes` | Comma separated scopes that are sent to OpenID Connect Provider in addition to `openid` and `email`
|`nifi.security.user.oidc.claim.identifying.user` | Claim that identifies the authenticated user. The default value is `email`. Claim names may need to be requested using the `nifi.security.user.oidc.additional.scopes` property
|`nifi.security.user.oidc.fallback.claims.identifying.user` | Comma-separated list of possible fallback claims used to identify the user when the `nifi.security.user.oidc.claim.identifying.user` claim is not found.
|`nifi.security.user.oidc.claim.groups` | Name of the ID token claim that contains an array of group names of which the
user is a member. Application groups must be supplied from a User Group Provider with matching names in order for the
authorization process to use ID token claim groups. The default value is `groups`.
|`nifi.security.user.oidc.truststore.strategy` | If value is `NIFI`, use the NiFi truststore when connecting to the OIDC service, otherwise if value is `JDK` use Java's default `cacerts` truststore. The default value is `JDK`.
|`nifi.security.user.oidc.truststore.strategy` | HTTPS Certificate Trust Store Strategy defines the source of certificate authorities that NiFi uses when communicating with the OpenID Connect Provider.
The value of `JDK` uses the Java platform default configuration stored in `cacerts` under the Java Home directory.
The value of `NIFI` enables using the trust store configured in the `nifi.security.truststore` property. The default value is `JDK`
|`nifi.security.user.oidc.token.refresh.window` | The Token Refresh Window specifies the amount of time before the NiFi authorization session expires when the application will attempt to renew access using a cached Refresh Token. The default is `60 secs`
|==================================================================================================================================================
==== OpenID Connect REST Resources
OpenID Connect authentication enables the following REST resources for integration with an OpenID Connect 1.0 Authorization Server:
[options="header"]
|======================================
| Resource Path | Description
| /nifi-api/access/oidc/callback/consumer | Process OIDC 1.0 Login Authentication Responses from an Authentication Server.
| /nifi/logout-complete | Path for redirect after successful OIDC RP-Initiated Logout 1.0 processing
|======================================
[[saml]]
=== SAML

View File

@ -173,6 +173,7 @@
<nifi.security.user.oidc.fallback.claims.identifying.user />
<nifi.security.user.oidc.claim.groups>groups</nifi.security.user.oidc.claim.groups>
<nifi.security.user.oidc.truststore.strategy>JDK</nifi.security.user.oidc.truststore.strategy>
<nifi.security.user.oidc.token.refresh.window>60 secs</nifi.security.user.oidc.token.refresh.window>
<!-- nifi.properties: apache knox -->
<nifi.security.user.knox.url />

View File

@ -204,6 +204,7 @@ nifi.security.user.oidc.claim.identifying.user=${nifi.security.user.oidc.claim.i
nifi.security.user.oidc.fallback.claims.identifying.user=${nifi.security.user.oidc.fallback.claims.identifying.user}
nifi.security.user.oidc.claim.groups=${nifi.security.user.oidc.claim.groups}
nifi.security.user.oidc.truststore.strategy=${nifi.security.user.oidc.truststore.strategy}
nifi.security.user.oidc.token.refresh.window=${nifi.security.user.oidc.token.refresh.window}
# Apache Knox SSO Properties #
nifi.security.user.knox.url=${nifi.security.user.knox.url}

View File

@ -16,18 +16,17 @@
*/
package org.apache.nifi.web;
import org.apache.nifi.web.security.configuration.AuthenticationSecurityConfiguration;
import org.apache.nifi.web.security.configuration.WebSecurityConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.ImportResource;
/**
*
* Web Application Spring Configuration
*/
@Configuration
@Import({
NiFiWebApiSecurityConfiguration.class,
AuthenticationSecurityConfiguration.class
WebSecurityConfiguration.class
})
@ImportResource({"classpath:nifi-context.xml",
"classpath:nifi-administration-context.xml",

View File

@ -98,7 +98,6 @@ public class NiFiWebApiResourceConfig extends ResourceConfig {
register(ctx.getBean("countersResource"));
register(ctx.getBean("systemDiagnosticsResource"));
register(ctx.getBean("accessResource"));
register(ctx.getBean("oidcResource"));
register(ctx.getBean("accessPolicyResource"));
register(ctx.getBean("tenantsResource"));
register(ctx.getBean("versionsResource"));

View File

@ -1,578 +0,0 @@
/*
* 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.api;
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.AuthenticationResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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.IdpUserGroupService;
import org.apache.nifi.authentication.exception.AuthenticationNotSupportedException;
import org.apache.nifi.authorization.user.NiFiUserUtils;
import org.apache.nifi.idp.IdpType;
import org.apache.nifi.security.util.SslContextFactory;
import org.apache.nifi.security.util.StandardTlsConfiguration;
import org.apache.nifi.security.util.TlsConfiguration;
import org.apache.nifi.security.util.TlsException;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.oidc.OIDCEndpoints;
import org.apache.nifi.web.security.oidc.OidcService;
import org.apache.nifi.web.security.oidc.TruststoreStrategy;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Path(OIDCEndpoints.OIDC_ACCESS_ROOT)
@Api(
value = OIDCEndpoints.OIDC_ACCESS_ROOT,
description = "Endpoints for obtaining an access token or checking access status."
)
public class OIDCAccessResource extends ApplicationResource {
private static final Logger logger = LoggerFactory.getLogger(OIDCAccessResource.class);
private static final String OIDC_AUTHENTICATION_NOT_CONFIGURED = "OIDC authentication not configured";
private static final String OIDC_ID_TOKEN_AUTHN_ERROR = "Unable to exchange authorization for ID token: ";
private static final String OIDC_REQUEST_IDENTIFIER_NOT_FOUND = "The request identifier was not found in the request.";
private static final String OIDC_FAILED_TO_PARSE_REDIRECT_URI = "Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login/logout process.";
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 int msTimeout = 30_000;
private static final boolean LOGGING_IN = true;
private OidcService oidcService;
private IdpUserGroupService idpUserGroupService;
private BearerTokenProvider bearerTokenProvider;
public OIDCAccessResource() {
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path(OIDCEndpoints.LOGIN_REQUEST_RELATIVE)
@ApiOperation(
value = "Initiates a request to authenticate through the configured OpenId Connect provider.",
notes = NON_GUARANTEED_ENDPOINT
)
public void oidcRequest(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
try {
validateOidcConfiguration();
} catch (AuthenticationNotSupportedException e) {
forwardToLoginMessagePage(httpServletRequest, httpServletResponse, e.getMessage());
throw e;
}
// generate the authorization uri
URI authorizationURI = oidcRequestAuthorizationCode(httpServletResponse, getOidcCallback());
// generate the response
httpServletResponse.sendRedirect(authorizationURI.toString());
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path(OIDCEndpoints.LOGIN_CALLBACK_RELATIVE)
@ApiOperation(
value = "Redirect/callback URI for processing the result of the OpenId Connect login sequence.",
notes = NON_GUARANTEED_ENDPOINT
)
public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, LOGGING_IN);
final Optional<String> requestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (requestIdentifier.isPresent() && oidcResponse.indicatesSuccess()) {
final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse;
final String oidcRequestIdentifier = requestIdentifier.get();
checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, LOGGING_IN);
try {
// exchange authorization code for id token
final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode();
final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcCallback()));
// get the oidc token
LoginAuthenticationToken oidcToken = oidcService.exchangeAuthorizationCodeForLoginAuthenticationToken(authorizationGrant);
// exchange the oidc token for the NiFi token
final String bearerToken = bearerTokenProvider.getBearerToken(oidcToken);
// store the NiFi token
oidcService.storeJwt(oidcRequestIdentifier, bearerToken);
Set<String> groups = oidcToken.getAuthorities().stream().map(authority -> authority.getAuthority()).collect(Collectors.collectingAndThen(
Collectors.toSet(),
Collections::unmodifiableSet
));
idpUserGroupService.replaceUserGroups(oidcToken.getName(), IdpType.OIDC, groups);
} 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
forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
return;
}
// redirect to the name page
httpServletResponse.sendRedirect(getNiFiUri());
} else {
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// report the unsuccessful login
forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt.");
}
}
@POST
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.TEXT_PLAIN)
@Path(OIDCEndpoints.TOKEN_EXCHANGE_RELATIVE)
@ApiOperation(
value = "Retrieves a JWT following a successful login sequence using the configured OpenId Connect provider.",
response = String.class,
notes = NON_GUARANTEED_ENDPOINT
)
public Response oidcExchange(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) {
try {
validateOidcConfiguration();
} catch (final AuthenticationNotSupportedException e) {
logger.debug("OIDC authentication not supported", e);
return Response.status(Response.Status.CONFLICT).entity(e.getMessage()).build();
}
final Optional<String> requestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (!requestIdentifier.isPresent()) {
final String message = "The login request identifier was not found in the request. Unable to continue.";
logger.warn(message);
return Response.status(Response.Status.BAD_REQUEST).entity(message).build();
}
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// get the jwt
final String jwt = oidcService.getJwt(requestIdentifier.get());
if (jwt == null) {
throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue.");
}
setBearerToken(httpServletResponse, jwt);
return generateOkResponse(jwt).build();
}
@GET
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.WILDCARD)
@Path(OIDCEndpoints.LOGOUT_REQUEST_RELATIVE)
@ApiOperation(
value = "Performs a logout in the OpenId Provider.",
notes = NON_GUARANTEED_ENDPOINT
)
public void oidcLogout(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
try {
validateOidcConfiguration();
} catch (final AuthenticationNotSupportedException e) {
throw e;
}
final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity();
applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.AUTHORIZATION_BEARER);
logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity);
idpUserGroupService.deleteUserGroups(mappedUserIdentity);
logger.debug("Deleted user groups for user [{}]", mappedUserIdentity);
// Determine the logout method
String logoutMethod = determineLogoutMethod();
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(OIDCEndpoints.LOGOUT_CALLBACK_RELATIVE)
@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 {
final AuthenticationResponse oidcResponse = parseOidcResponse(httpServletRequest, httpServletResponse, !LOGGING_IN);
final Optional<String> requestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (requestIdentifier.isPresent() && oidcResponse != null && oidcResponse.indicatesSuccess()) {
final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse;
// confirm state
final String oidcRequestIdentifier = requestIdentifier.get();
checkOidcState(httpServletResponse, oidcRequestIdentifier, successfulOidcResponse, false);
// Determine which logout method to use
String logoutMethod = determineLogoutMethod();
// 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
forwardToLogoutMessagePage(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
forwardToLogoutMessagePage(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
forwardToLogoutMessagePage(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 {
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// report the unsuccessful logout
forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful logout attempt.");
}
}
/**
* 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();
applicationCookieService.addCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier);
final State state = oidcService.createState(oidcRequestIdentifier);
return 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();
}
private String determineLogoutMethod() {
if (oidcService.getEndSessionEndpoint() != null) {
return ID_TOKEN_LOGOUT;
} else if (oidcService.getRevocationEndpoint() != null) {
return REVOKE_ACCESS_TOKEN_LOGOUT;
} else {
return STANDARD_LOGOUT;
}
}
/**
* 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 {
final CloseableHttpClient httpClient = getHttpClient();
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)) {
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());
}
} finally {
httpClient.close();
}
}
private CloseableHttpClient getHttpClient() {
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(msTimeout)
.setConnectionRequestTimeout(msTimeout)
.setSocketTimeout(msTimeout)
.build();
HttpClientBuilder builder = HttpClientBuilder
.create()
.setDefaultRequestConfig(config);
if (TruststoreStrategy.NIFI.name().equals(properties.getOidcClientTruststoreStrategy())) {
builder.setSSLContext(getSslContext());
}
return builder.build();
}
protected AuthenticationResponse parseOidcResponse(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, boolean isLogin) throws Exception {
final String pageTitle = getForwardPageTitle(isLogin);
try {
validateOidcConfiguration();
} catch (final AuthenticationNotSupportedException e) {
forwardToMessagePage(httpServletRequest, httpServletResponse, pageTitle, e.getMessage());
throw e;
}
final Optional<String> requestIdentifier = getOidcRequestIdentifier(httpServletRequest);
if (!requestIdentifier.isPresent()) {
forwardToMessagePage(httpServletRequest, httpServletResponse, pageTitle, OIDC_REQUEST_IDENTIFIER_NOT_FOUND);
throw new IllegalStateException(OIDC_REQUEST_IDENTIFIER_NOT_FOUND);
}
final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse;
try {
oidcResponse = AuthenticationResponseParser.parse(getRequestUri());
return oidcResponse;
} catch (final ParseException e) {
logger.error(OIDC_FAILED_TO_PARSE_REDIRECT_URI);
// remove the oidc request cookie
removeOidcRequestCookie(httpServletResponse);
// forward to the error page
forwardToMessagePage(httpServletRequest, httpServletResponse, pageTitle, OIDC_FAILED_TO_PARSE_REDIRECT_URI);
throw e;
}
}
protected void checkOidcState(HttpServletResponse httpServletResponse, final String oidcRequestIdentifier, AuthenticationSuccessResponse successfulOidcResponse, boolean isLogin) throws Exception {
// confirm state
final State state = successfulOidcResponse.getState();
if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) {
logger.error("OIDC Request [{}] State [{}] not valid", oidcRequestIdentifier, state);
removeOidcRequestCookie(httpServletResponse);
forwardToMessagePage(httpServletRequest, httpServletResponse, getForwardPageTitle(isLogin), "Purposed state does not match " +
"the stored state. Unable to continue login/logout process.");
}
}
private SSLContext getSslContext() {
TlsConfiguration tlsConfiguration = StandardTlsConfiguration.fromNiFiProperties(properties);
try {
return SslContextFactory.createSslContext(tlsConfiguration);
} catch (TlsException e) {
throw new RuntimeException("Unable to establish an SSL context for OIDC access resource from nifi.properties", e);
}
}
private void validateOidcConfiguration() throws AuthenticationNotSupportedException {
// only consider user specific access over https
if (!httpServletRequest.isSecure()) {
throw new AuthenticationNotSupportedException(AccessResource.AUTHENTICATION_NOT_ENABLED_MSG);
}
// ensure OIDC is actually configured/enabled
if (!oidcService.isOidcEnabled()) {
throw new AuthenticationNotSupportedException(OIDC_AUTHENTICATION_NOT_CONFIGURED);
}
}
private String getForwardPageTitle(boolean isLogin) {
return isLogin ? ApplicationResource.LOGIN_ERROR_TITLE : ApplicationResource.LOGOUT_ERROR_TITLE;
}
protected String getOidcCallback() {
return generateResourceUri("access", "oidc", "callback");
}
private String getOidcLogoutCallback() {
return generateResourceUri("access", "oidc", "logoutCallback");
}
private URI getRevokeEndpoint() {
return oidcService.getRevocationEndpoint();
}
private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) {
applicationCookieService.removeCookie(getCookieResourceUri(), httpServletResponse, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER);
}
private Optional<String> getOidcRequestIdentifier(final HttpServletRequest request) {
return applicationCookieService.getCookieValue(request, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER);
}
public void setOidcService(OidcService oidcService) {
this.oidcService = oidcService;
}
public void setIdpUserGroupService(IdpUserGroupService idpUserGroupService) {
this.idpUserGroupService = idpUserGroupService;
}
public void setBearerTokenProvider(final BearerTokenProvider bearerTokenProvider) {
this.bearerTokenProvider = bearerTokenProvider;
}
public void setProperties(final NiFiProperties properties) {
this.properties = properties;
}
protected NiFiProperties getProperties() {
return properties;
}
}

View File

@ -627,12 +627,6 @@
<property name="requestReplicator" ref="requestReplicator" />
<property name="flowController" ref="flowController" />
</bean>
<bean id="oidcResource" class="org.apache.nifi.web.api.OIDCAccessResource" scope="singleton">
<property name="oidcService" ref="oidcService"/>
<property name="idpUserGroupService" ref="idpUserGroupService" />
<property name="properties" ref="nifiProperties"/>
<property name="bearerTokenProvider" ref="bearerTokenProvider"/>
</bean>
<bean id="accessPolicyResource" class="org.apache.nifi.web.api.AccessPolicyResource" scope="singleton">
<constructor-arg ref="serviceFacade"/>
<constructor-arg ref="authorizer"/>

View File

@ -1,159 +0,0 @@
/*
* 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.api;
import com.nimbusds.oauth2.sdk.AuthorizationCode;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.ErrorObject;
import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.jwt.provider.StandardBearerTokenProvider;
import org.apache.nifi.web.security.oidc.OidcService;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.apache.nifi.idp.IdpType.OIDC;
import static org.apache.nifi.web.security.cookie.ApplicationCookieName.OIDC_REQUEST_IDENTIFIER;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
public class OIDCAccessResourceTest {
final static String REQUEST_IDENTIFIER = "an-identifier";
final static String OIDC_LOGIN_FAILURE_MESSAGE = "Unsuccessful login attempt.";
@Test
public void testOidcCallbackSuccess() throws Exception {
HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class);
MockHttpServletResponse mockResponse = new MockHttpServletResponse();
Cookie[] cookies = { new Cookie(OIDC_REQUEST_IDENTIFIER.getCookieName(), REQUEST_IDENTIFIER) };
Mockito.when(mockRequest.getCookies()).thenReturn(cookies);
OidcService oidcService = Mockito.mock(OidcService.class);
MockOIDCAccessResource accessResource = new MockOIDCAccessResource(oidcService, true);
IdpUserGroupService idpUserGroupService = Mockito.mock(IdpUserGroupService.class);
accessResource.setIdpUserGroupService(idpUserGroupService);
accessResource.oidcCallback(mockRequest, mockResponse);
Mockito.verify(oidcService).storeJwt(any(String.class), any(String.class));
Mockito.verify(idpUserGroupService).replaceUserGroups(MockOIDCAccessResource.IDENTITY, OIDC, Stream.of(MockOIDCAccessResource.ROLE).collect(Collectors.toSet()));
}
@Test
public void testOidcCallbackFailure() throws Exception {
HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class);
MockHttpServletResponse mockResponse = new MockHttpServletResponse();
Cookie[] cookies = { new Cookie(OIDC_REQUEST_IDENTIFIER.getCookieName(), REQUEST_IDENTIFIER) };
Mockito.when(mockRequest.getCookies()).thenReturn(cookies);
OidcService oidcService = Mockito.mock(OidcService.class);
MockOIDCAccessResource accessResource = new MockOIDCCallbackFailure(oidcService, false);
accessResource.oidcCallback(mockRequest, mockResponse);
}
public class MockOIDCCallbackFailure extends MockOIDCAccessResource {
public MockOIDCCallbackFailure(OidcService oidcService, Boolean requestShouldSucceed) throws IOException {
super(oidcService, requestShouldSucceed);
}
@Override
protected void forwardToLoginMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
assertEquals(OIDC_LOGIN_FAILURE_MESSAGE, message);
}
}
public class MockOIDCAccessResource extends OIDCAccessResource {
final static String BEARER_TOKEN = "bearer_token";
final static String IDENTITY = "identity";
final static String ROLE = "role";
final static String AUTHORIZATION_CODE = "authorization_code";
final static String CALLBACK_URL = "https://nifi.apache.org/nifi-api/access/oidc/callback";
final static String RESOURCE_URI = "resource_uri";
private Boolean requestShouldSucceed;
public MockOIDCAccessResource(final OidcService oidcService, final Boolean requestShouldSucceed) throws IOException {
this.requestShouldSucceed = requestShouldSucceed;
final BearerTokenProvider bearerTokenProvider = Mockito.mock(StandardBearerTokenProvider.class);
Mockito.when(bearerTokenProvider.getBearerToken(any(LoginAuthenticationToken.class))).thenReturn(BEARER_TOKEN);
setOidcService(oidcService);
setBearerTokenProvider(bearerTokenProvider);
final LoginAuthenticationToken token = Mockito.mock(LoginAuthenticationToken.class);
Mockito.when(token.getName()).thenReturn(IDENTITY);
Mockito.when(token.getAuthorities()).thenReturn(Stream.of(new SimpleGrantedAuthority(ROLE)).collect(Collectors.toSet()));
Mockito.when(oidcService.exchangeAuthorizationCodeForLoginAuthenticationToken(any(AuthorizationGrant.class))).thenReturn(token);
}
@Override
protected AuthenticationResponse parseOidcResponse(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, boolean isLogin) {
if (requestShouldSucceed) {
return getSuccessResponse();
} else {
return getErrorResponse();
}
}
@Override
protected void checkOidcState(HttpServletResponse httpServletResponse, final String oidcRequestIdentifier, AuthenticationSuccessResponse successfulOidcResponse, boolean isLogin)
throws Exception {
// do nothing
}
@Override
protected String getOidcCallback() {
return CALLBACK_URL;
}
@Override
protected String generateResourceUri(final String... path) {
return RESOURCE_URI;
}
@Override
protected URI getCookieResourceUri() {
return URI.create(RESOURCE_URI);
}
private AuthenticationResponse getSuccessResponse() {
AuthenticationSuccessResponse successResponse = Mockito.mock(AuthenticationSuccessResponse.class);
Mockito.when(successResponse.indicatesSuccess()).thenReturn(true);
Mockito.when(successResponse.getAuthorizationCode()).thenReturn(new AuthorizationCode(AUTHORIZATION_CODE));
return successResponse;
}
private AuthenticationResponse getErrorResponse() {
AuthenticationErrorResponse errorResponse = Mockito.mock(AuthenticationErrorResponse.class);
Mockito.when(errorResponse.indicatesSuccess()).thenReturn(false);
Mockito.when(errorResponse.getErrorObject()).thenReturn(new ErrorObject("HTTP 500", "OIDC server error"));
return errorResponse;
}
}
}

View File

@ -194,6 +194,10 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>

View File

@ -37,7 +37,7 @@ import org.springframework.security.authentication.AuthenticationManager;
JwtAuthenticationSecurityConfiguration.class,
KerberosAuthenticationSecurityConfiguration.class,
KnoxAuthenticationSecurityConfiguration.class,
OidcAuthenticationSecurityConfiguration.class,
OidcSecurityConfiguration.class,
SamlAuthenticationSecurityConfiguration.class,
X509AuthenticationSecurityConfiguration.class
})

View File

@ -59,8 +59,8 @@ import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import java.time.Duration;
import java.util.Arrays;
@ -228,7 +228,7 @@ public class JwtAuthenticationSecurityConfiguration {
@Bean
public ThreadPoolTaskScheduler commandScheduler() {
final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix(getClass().getSimpleName());
scheduler.setThreadNamePrefix(JwtAuthenticationSecurityConfiguration.class.getSimpleName());
return scheduler;
}
}

View File

@ -1,49 +0,0 @@
/*
* 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.configuration;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.oidc.OidcService;
import org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OIDC Configuration for Authentication Security
*/
@Configuration
public class OidcAuthenticationSecurityConfiguration {
private final NiFiProperties niFiProperties;
@Autowired
public OidcAuthenticationSecurityConfiguration(
final NiFiProperties niFiProperties
) {
this.niFiProperties = niFiProperties;
}
@Bean
public StandardOidcIdentityProvider oidcProvider() {
return new StandardOidcIdentityProvider(niFiProperties);
}
@Bean
public OidcService oidcService() {
return new OidcService(oidcProvider());
}
}

View File

@ -0,0 +1,516 @@
/*
* 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.configuration;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import okhttp3.OkHttpClient;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.authorization.util.IdentityMappingUtil;
import org.apache.nifi.components.state.StateManager;
import org.apache.nifi.components.state.StateManagerProvider;
import org.apache.nifi.encrypt.PropertyEncryptor;
import org.apache.nifi.security.util.SslContextFactory;
import org.apache.nifi.security.util.StandardTlsConfiguration;
import org.apache.nifi.security.util.TlsConfiguration;
import org.apache.nifi.security.util.TlsException;
import org.apache.nifi.util.FormatUtils;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.util.StringUtils;
import org.apache.nifi.web.security.StandardAuthenticationEntryPoint;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.logout.LogoutRequestManager;
import org.apache.nifi.web.security.oidc.OidcConfigurationException;
import org.apache.nifi.web.security.oidc.OidcUrlPath;
import org.apache.nifi.web.security.oidc.client.web.AuthorizedClientExpirationCommand;
import org.apache.nifi.web.security.oidc.client.web.OidcBearerTokenRefreshFilter;
import org.apache.nifi.web.security.oidc.client.web.converter.AuthenticationResultConverter;
import org.apache.nifi.web.security.oidc.client.web.converter.AuthorizedClientConverter;
import org.apache.nifi.web.security.oidc.client.web.StandardAuthorizationRequestRepository;
import org.apache.nifi.web.security.oidc.client.web.converter.StandardAuthorizedClientConverter;
import org.apache.nifi.web.security.oidc.client.web.StandardOidcAuthorizedClientRepository;
import org.apache.nifi.web.security.oidc.logout.OidcLogoutFilter;
import org.apache.nifi.web.security.oidc.logout.OidcLogoutSuccessHandler;
import org.apache.nifi.web.security.oidc.registration.ClientRegistrationProvider;
import org.apache.nifi.web.security.oidc.registration.DisabledClientRegistrationRepository;
import org.apache.nifi.web.security.oidc.registration.StandardClientRegistrationProvider;
import org.apache.nifi.web.security.oidc.revocation.StandardTokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.web.authentication.OidcAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider;
import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* OpenID Connect Configuration for Spring Security
*/
@Configuration
public class OidcSecurityConfiguration {
private static final Duration REQUEST_EXPIRATION = Duration.ofSeconds(60);
private static final long AUTHORIZATION_REQUEST_CACHE_SIZE = 1000;
private static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(5);
private static final String NIFI_TRUSTSTORE_STRATEGY = "NIFI";
private static final RequestCache nullRequestCache = new NullRequestCache();
private final Duration keyRotationPeriod;
private final NiFiProperties properties;
private final StateManagerProvider stateManagerProvider;
private final PropertyEncryptor propertyEncryptor;
private final BearerTokenProvider bearerTokenProvider;
private final BearerTokenResolver bearerTokenResolver;
private final IdpUserGroupService idpUserGroupService;
private final JwtDecoder jwtDecoder;
private final LogoutRequestManager logoutRequestManager;
@Autowired
public OidcSecurityConfiguration(
final NiFiProperties properties,
final StateManagerProvider stateManagerProvider,
final PropertyEncryptor propertyEncryptor,
final BearerTokenProvider bearerTokenProvider,
final BearerTokenResolver bearerTokenResolver,
final IdpUserGroupService idpUserGroupService,
final JwtDecoder jwtDecoder,
final LogoutRequestManager logoutRequestManager
) {
this.properties = Objects.requireNonNull(properties, "Properties required");
this.stateManagerProvider = Objects.requireNonNull(stateManagerProvider, "State Manager Provider required");
this.propertyEncryptor = Objects.requireNonNull(propertyEncryptor, "Property Encryptor required");
this.bearerTokenProvider = Objects.requireNonNull(bearerTokenProvider, "Bearer Token Provider required");
this.bearerTokenResolver = Objects.requireNonNull(bearerTokenResolver, "Bearer Token Resolver required");
this.idpUserGroupService = Objects.requireNonNull(idpUserGroupService, "User Group Service required");
this.jwtDecoder = Objects.requireNonNull(jwtDecoder, "JWT Decoder required");
this.logoutRequestManager = Objects.requireNonNull(logoutRequestManager, "Logout Request Manager required");
this.keyRotationPeriod = properties.getSecurityUserJwsKeyRotationPeriod();
}
/**
* Authorization Code Grant Filter handles Authorization Server responses and updates the Authorized Client
* Repository with ID Token and optional Refresh Token information
*
* @param authenticationManager Spring Security Authentication Manager
* @return OAuth2 Authorization Code Grant Filter
*/
@Bean
public OAuth2AuthorizationCodeGrantFilter oAuth2AuthorizationCodeGrantFilter(final AuthenticationManager authenticationManager) {
final OAuth2AuthorizationCodeGrantFilter filter = new OAuth2AuthorizationCodeGrantFilter(
clientRegistrationRepository(),
authorizedClientRepository(),
authenticationManager
);
filter.setAuthorizationRequestRepository(authorizationRequestRepository());
filter.setRequestCache(nullRequestCache);
return filter;
}
/**
* Authorization Request Redirect Filter handles initial OpenID Connect authentication and redirects to the
* Authorization Server using default filter path from Spring Security
*
* @return OAuth2 Authorization Request Redirect Filter
*/
@Bean
public OAuth2AuthorizationRequestRedirectFilter oAuth2AuthorizationRequestRedirectFilter() {
final OAuth2AuthorizationRequestRedirectFilter filter = new OAuth2AuthorizationRequestRedirectFilter(clientRegistrationRepository());
filter.setAuthorizationRequestRepository(authorizationRequestRepository());
filter.setRequestCache(nullRequestCache);
return filter;
}
/**
* Login Authentication Filter handles Authentication Responses from the Authorization Server
*
* @param authenticationManager Spring Security Authentication Manager
* @param authenticationEntryPoint Authentication Entry Point for handling failures
* @return OAuth2 Login Authentication Filter
*/
@Bean
public OAuth2LoginAuthenticationFilter oAuth2LoginAuthenticationFilter(final AuthenticationManager authenticationManager, final StandardAuthenticationEntryPoint authenticationEntryPoint) {
final OAuth2LoginAuthenticationFilter filter = new OAuth2LoginAuthenticationFilter(
clientRegistrationRepository(),
authorizedClientRepository(),
OidcUrlPath.CALLBACK.getPath()
);
filter.setAuthenticationManager(authenticationManager);
filter.setAuthorizationRequestRepository(authorizationRequestRepository());
filter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler());
filter.setAllowSessionCreation(false);
filter.setSessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy());
filter.setAuthenticationResultConverter(new AuthenticationResultConverter());
final AuthenticationEntryPointFailureHandler authenticationFailureHandler = new AuthenticationEntryPointFailureHandler(authenticationEntryPoint);
filter.setAuthenticationFailureHandler(authenticationFailureHandler);
return filter;
}
/**
* OpenID Connect Bearer Token Refresh Filter exchanges OAuth2 Refresh Tokens with the Authorization Server and
* generates new application Bearer Tokens on successful responses
*
* @return Bearer Token Refresh Filter
*/
@Bean
public OidcBearerTokenRefreshFilter oidcBearerTokenRefreshFilter() {
final DefaultRefreshTokenTokenResponseClient refreshTokenResponseClient = new DefaultRefreshTokenTokenResponseClient();
refreshTokenResponseClient.setRestOperations(oidcRestOperations());
final String refreshWindowProperty = properties.getOidcTokenRefreshWindow();
final double refreshWindowSeconds = FormatUtils.getPreciseTimeDuration(refreshWindowProperty, TimeUnit.SECONDS);
final Duration refreshWindow = Duration.ofSeconds(Math.round(refreshWindowSeconds));
return new OidcBearerTokenRefreshFilter(
refreshWindow,
bearerTokenProvider,
bearerTokenResolver,
jwtDecoder,
authorizedClientRepository(),
refreshTokenResponseClient
);
}
/**
* Logout Filter for completing logout processing using RP-Initiated Logout 1.0 when supported
*
* @return OpenID Connect Logout Filter
*/
@Bean
public OidcLogoutFilter oidcLogoutFilter() {
return new OidcLogoutFilter(oidcLogoutSuccessHandler());
}
/**
* Logout Success Handler redirects to the Authorization Server when supported
*
* @return Logout Success Handler
*/
@Bean
public LogoutSuccessHandler oidcLogoutSuccessHandler() {
return new OidcLogoutSuccessHandler(
logoutRequestManager,
idpUserGroupService,
clientRegistrationRepository(),
authorizedClientRepository(),
tokenRevocationResponseClient()
);
}
/**
* Authorization Code Grant Authentication Provider wired to Spring Security Authentication Manager
*
* @return OpenID Connect Authorization Code Authentication Provider
*/
@Bean
public OidcAuthorizationCodeAuthenticationProvider oidcAuthorizationCodeAuthenticationProvider() {
final OidcAuthorizationCodeAuthenticationProvider provider = new OidcAuthorizationCodeAuthenticationProvider(
accessTokenResponseClient(),
oidcUserService()
);
provider.setJwtDecoderFactory(idTokenDecoderFactory());
return provider;
}
/**
* Access Token Response Client for retrieving Access Tokens using Authorization Codes
*
* @return OAuth2 Access Token Response Client
*/
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
final DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
accessTokenResponseClient.setRestOperations(oidcRestOperations());
return accessTokenResponseClient;
}
/**
* OpenID Connect User Service wired to Authentication Provider for retrieving User Information
*
* @return OpenID Connect User Service
*/
@Bean
public OidcUserService oidcUserService() {
final OidcUserService oidcUserService = new OidcUserService();
final DefaultOAuth2UserService userService = new DefaultOAuth2UserService();
userService.setRestOperations(oidcRestOperations());
oidcUserService.setOauth2UserService(userService);
return oidcUserService;
}
/**
* Authorized Client Repository for storing OpenID Connect Tokens in application State Manager
*
* @return Authorized Client Repository
*/
@Bean
public StandardOidcAuthorizedClientRepository authorizedClientRepository() {
final StateManager stateManager = stateManagerProvider.getStateManager(StandardOidcAuthorizedClientRepository.class.getName());
return new StandardOidcAuthorizedClientRepository(stateManager, authorizedClientConverter());
}
@Bean
public AuthorizedClientExpirationCommand authorizedClientExpirationCommand() {
final AuthorizedClientExpirationCommand command = new AuthorizedClientExpirationCommand(authorizedClientRepository(), tokenRevocationResponseClient());
oidcCommandScheduler().scheduleAtFixedRate(command, keyRotationPeriod);
return command;
}
/**
* Command Scheduled for OpenID Connect operations
*
* @return Thread Pool Task Executor
*/
@Bean
public ThreadPoolTaskScheduler oidcCommandScheduler() {
final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix(OidcSecurityConfiguration.class.getSimpleName());
return scheduler;
}
/**
* Authorized Client Converter for OpenID Connect Tokens supporting serialization of OpenID Connect Tokens
*
* @return Authorized Client Converter
*/
@Bean
public AuthorizedClientConverter authorizedClientConverter() {
return new StandardAuthorizedClientConverter(propertyEncryptor, clientRegistrationRepository());
}
/**
* OpenID Connect Authorization Request Repository with Cache abstraction based on Caffeine implementation
*
* @return Authorization Request Repository
*/
@Bean
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
final Cache<Object, Object> caffeineCache = Caffeine.newBuilder()
.maximumSize(AUTHORIZATION_REQUEST_CACHE_SIZE)
.expireAfterWrite(REQUEST_EXPIRATION)
.build();
final CaffeineCache cache = new CaffeineCache(StandardAuthorizationRequestRepository.class.getSimpleName(), caffeineCache);
return new StandardAuthorizationRequestRepository(cache);
}
/**
* OpenID Connect Identifier Token Decoder with configured JWS Algorithm for verification
*
* @return OpenID Connect Identifier Token Decoder
*/
@Bean
public JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory() {
OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
final String preferredJwdAlgorithm = properties.getOidcPreferredJwsAlgorithm();
if (StringUtils.isNotEmpty(preferredJwdAlgorithm)) {
idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> getJwsAlgorithm(preferredJwdAlgorithm));
}
return idTokenDecoderFactory;
}
/**
* Token Revocation Response Client responsible for transmitting Refresh Token revocation requests to the Provider
*
* @return Token Revocation Response Client
*/
@Bean
public TokenRevocationResponseClient tokenRevocationResponseClient() {
return new StandardTokenRevocationResponseClient(oidcRestOperations(), clientRegistrationRepository());
}
/**
* Client Registration Repository for OpenID Connect Discovery
*
* @return Client Registration Repository
*/
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
final ClientRegistrationRepository clientRegistrationRepository;
if (properties.isOidcEnabled()) {
final ClientRegistrationProvider clientRegistrationProvider = new StandardClientRegistrationProvider(properties, oidcRestOperations());
final ClientRegistration clientRegistration = clientRegistrationProvider.getClientRegistration();
clientRegistrationRepository = new InMemoryClientRegistrationRepository(clientRegistration);
} else {
clientRegistrationRepository = new DisabledClientRegistrationRepository();
}
return clientRegistrationRepository;
}
/**
* OpenID Connect REST Operations for communication with Authorization Servers
*
* @return REST Operations
*/
@Bean
public RestOperations oidcRestOperations() {
final RestTemplate restTemplate = new RestTemplate(oidcClientHttpRequestFactory());
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
restTemplate.setMessageConverters(
Arrays.asList(
new FormHttpMessageConverter(),
new OAuth2AccessTokenResponseHttpMessageConverter(),
new StringHttpMessageConverter(),
new MappingJackson2HttpMessageConverter()
)
);
return restTemplate;
}
/**
* OpenID Connect Client HTTP Request Factory for communication with Authorization Servers
*
* @return Client HTTP Request Factory
*/
@Bean
public ClientHttpRequestFactory oidcClientHttpRequestFactory() {
final OkHttpClient httpClient = getHttpClient();
return new OkHttp3ClientHttpRequestFactory(httpClient);
}
private OkHttpClient getHttpClient() {
final Duration connectTimeout = getTimeout(properties.getOidcConnectTimeout());
final Duration readTimeout = getTimeout(properties.getOidcReadTimeout());
final OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectTimeout(connectTimeout)
.readTimeout(readTimeout);
if (NIFI_TRUSTSTORE_STRATEGY.equals(properties.getOidcClientTruststoreStrategy())) {
setSslSocketFactory(builder);
}
return builder.build();
}
private Duration getTimeout(final String timeoutExpression) {
try {
final double duration = FormatUtils.getPreciseTimeDuration(timeoutExpression, TimeUnit.MILLISECONDS);
final long rounded = Math.round(duration);
return Duration.ofMillis(rounded);
} catch (final RuntimeException e) {
return DEFAULT_SOCKET_TIMEOUT;
}
}
private void setSslSocketFactory(final OkHttpClient.Builder builder) {
final TlsConfiguration tlsConfiguration = StandardTlsConfiguration.fromNiFiProperties(properties);
try {
final X509TrustManager trustManager = Objects.requireNonNull(SslContextFactory.getX509TrustManager(tlsConfiguration), "TrustManager required");
final TrustManager[] trustManagers = new TrustManager[] { trustManager };
final SSLContext sslContext = Objects.requireNonNull(SslContextFactory.createSslContext(tlsConfiguration, trustManagers), "SSLContext required");
final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
builder.sslSocketFactory(sslSocketFactory, trustManager);
} catch (final TlsException e) {
throw new OidcConfigurationException("OpenID Connect HTTP TLS configuration failed", e);
}
}
private OidcAuthenticationSuccessHandler getAuthenticationSuccessHandler() {
final List<String> userClaimNames = new ArrayList<>();
userClaimNames.add(properties.getOidcClaimIdentifyingUser());
userClaimNames.addAll(properties.getOidcFallbackClaimsIdentifyingUser());
return new OidcAuthenticationSuccessHandler(
bearerTokenProvider,
idpUserGroupService,
IdentityMappingUtil.getIdentityMappings(properties),
IdentityMappingUtil.getGroupMappings(properties),
userClaimNames,
properties.getOidcClaimGroups()
);
}
private JwsAlgorithm getJwsAlgorithm(final String preferredJwsAlgorithm) {
final JwsAlgorithm jwsAlgorithm;
final MacAlgorithm macAlgorithm = MacAlgorithm.from(preferredJwsAlgorithm);
if (macAlgorithm == null) {
final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.from(preferredJwsAlgorithm);
if (signatureAlgorithm == null) {
final String message = String.format("Preferred JWS Algorithm [%s] not supported", preferredJwsAlgorithm);
throw new OidcConfigurationException(message);
}
jwsAlgorithm = signatureAlgorithm;
} else {
jwsAlgorithm = macAlgorithm;
}
return jwsAlgorithm;
}
}

View File

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.web;
package org.apache.nifi.web.security.configuration;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.StandardAuthenticationEntryPoint;
@ -24,21 +24,26 @@ import org.apache.nifi.web.security.csrf.SkipReplicatedCsrfFilter;
import org.apache.nifi.web.security.csrf.StandardCookieCsrfTokenRepository;
import org.apache.nifi.web.security.knox.KnoxAuthenticationFilter;
import org.apache.nifi.web.security.log.AuthenticationUserFilter;
import org.apache.nifi.web.security.oidc.OIDCEndpoints;
import org.apache.nifi.web.security.oidc.client.web.OidcBearerTokenRefreshFilter;
import org.apache.nifi.web.security.oidc.logout.OidcLogoutFilter;
import org.apache.nifi.web.security.saml2.web.authentication.logout.Saml2LocalLogoutFilter;
import org.apache.nifi.web.security.saml2.web.authentication.logout.Saml2SingleLogoutFilter;
import org.apache.nifi.web.security.x509.X509AuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter;
import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseFilter;
@ -53,10 +58,13 @@ import java.util.List;
/**
* Application Security Configuration using Spring Security
*/
@Import({
AuthenticationSecurityConfiguration.class
})
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class NiFiWebApiSecurityConfiguration {
@EnableMethodSecurity
public class WebSecurityConfiguration {
/**
* Spring Security Authentication Manager configured using Authentication Providers from specific configuration classes
*
@ -77,6 +85,11 @@ public class NiFiWebApiSecurityConfiguration {
final BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter,
final KnoxAuthenticationFilter knoxAuthenticationFilter,
final NiFiAnonymousAuthenticationFilter anonymousAuthenticationFilter,
final OAuth2LoginAuthenticationFilter oAuth2LoginAuthenticationFilter,
final OAuth2AuthorizationCodeGrantFilter oAuth2AuthorizationCodeGrantFilter,
final OAuth2AuthorizationRequestRedirectFilter oAuth2AuthorizationRequestRedirectFilter,
final OidcBearerTokenRefreshFilter oidcBearerTokenRefreshFilter,
final OidcLogoutFilter oidcLogoutFilter,
final Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter,
final Saml2WebSsoAuthenticationRequestFilter saml2WebSsoAuthenticationRequestFilter,
final Saml2MetadataFilter saml2MetadataFilter,
@ -95,18 +108,14 @@ public class NiFiWebApiSecurityConfiguration {
.servletApi().disable()
.securityContext().disable()
.authorizeHttpRequests(authorize -> authorize
.antMatchers(
.requestMatchers(
"/access",
"/access/config",
"/access/token",
"/access/kerberos",
"/access/knox/callback",
"/access/knox/request",
"/access/logout/complete",
OIDCEndpoints.TOKEN_EXCHANGE,
OIDCEndpoints.LOGIN_REQUEST,
OIDCEndpoints.LOGIN_CALLBACK,
OIDCEndpoints.LOGOUT_CALLBACK
"/access/logout/complete"
).permitAll()
.anyRequest().authenticated()
)
@ -149,6 +158,14 @@ public class NiFiWebApiSecurityConfiguration {
}
}
if (properties.isOidcEnabled()) {
http.addFilterBefore(oAuth2LoginAuthenticationFilter, AnonymousAuthenticationFilter.class);
http.addFilterBefore(oAuth2AuthorizationCodeGrantFilter, AnonymousAuthenticationFilter.class);
http.addFilterBefore(oAuth2AuthorizationRequestRedirectFilter, AnonymousAuthenticationFilter.class);
http.addFilterBefore(oidcBearerTokenRefreshFilter, AnonymousAuthenticationFilter.class);
http.addFilterBefore(oidcLogoutFilter, CsrfFilter.class);
}
return http.build();
}
}

View File

@ -0,0 +1,67 @@
/*
* 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.logout;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* Standard Logout Filter completes application Logout Requests
*/
public class StandardLogoutFilter extends OncePerRequestFilter {
private final AntPathRequestMatcher requestMatcher;
private final LogoutSuccessHandler logoutSuccessHandler;
public StandardLogoutFilter(
final AntPathRequestMatcher requestMatcher,
final LogoutSuccessHandler logoutSuccessHandler
) {
this.requestMatcher = requestMatcher;
this.logoutSuccessHandler = logoutSuccessHandler;
}
/**
* Call Logout Success Handler when request path matches
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @param filterChain Filter Chain
* @throws ServletException Thrown on FilterChain.doFilter() failures
* @throws IOException Thrown on FilterChain.doFilter() failures
*/
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
if (requestMatcher.matches(request)) {
final SecurityContext securityContext = SecurityContextHolder.getContext();
final Authentication authentication = securityContext.getAuthentication();
logoutSuccessHandler.onLogoutSuccess(request, response, authentication);
} else {
filterChain.doFilter(request, response);
}
}
}

View File

@ -16,22 +16,16 @@
*/
package org.apache.nifi.web.security.oidc;
public interface OIDCEndpoints {
/**
* OpenID Connect Configuration Exception
*/
public class OidcConfigurationException extends RuntimeException {
String OIDC_ACCESS_ROOT = "/access/oidc";
public OidcConfigurationException(final String message) {
super(message);
}
String LOGIN_REQUEST_RELATIVE = "/request";
String LOGIN_REQUEST = OIDC_ACCESS_ROOT + LOGIN_REQUEST_RELATIVE;
String LOGIN_CALLBACK_RELATIVE = "/callback";
String LOGIN_CALLBACK = OIDC_ACCESS_ROOT + LOGIN_CALLBACK_RELATIVE;
String TOKEN_EXCHANGE_RELATIVE = "/exchange";
String TOKEN_EXCHANGE = OIDC_ACCESS_ROOT + TOKEN_EXCHANGE_RELATIVE;
String LOGOUT_REQUEST_RELATIVE = "/logout";
String LOGOUT_REQUEST = OIDC_ACCESS_ROOT + LOGOUT_REQUEST_RELATIVE;
String LOGOUT_CALLBACK_RELATIVE = "/logoutCallback";
String LOGOUT_CALLBACK = OIDC_ACCESS_ROOT + LOGOUT_CALLBACK_RELATIVE;
public OidcConfigurationException(final String message, final Throwable cause) {
super(message, cause);
}
}

View File

@ -1,105 +0,0 @@
/*
* 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.AuthorizationGrant;
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 {
String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED = "OpenId Connect support is not configured";
/**
* Initializes the provider.
*/
void initializeProvider();
/**
* Returns whether OIDC support is enabled.
*
* @return whether OIDC support is enabled
*/
boolean isOidcEnabled();
/**
* Returns the configured client id.
*
* @return the client id
*/
ClientID getClientId();
/**
* Returns the URI for the authorization endpoint.
*
* @return uri for the authorization endpoint
*/
URI getAuthorizationEndpoint();
/**
* Returns the URI for the end session endpoint.
*
* @return uri for the end session endpoint
*/
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.
*
* @return support scopes
*/
Scope getScope();
/**
* 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 Login Authentication Token
* @throws IOException if there was an exceptional error while communicating with the OIDC provider
*/
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

@ -1,270 +0,0 @@
/*
* 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.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.id.State;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.apache.nifi.web.security.util.CacheKey;
import org.apache.nifi.web.security.util.IdentityProviderUtils;
import java.io.IOException;
import java.net.URI;
import java.util.concurrent.TimeUnit;
import static org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED;
/**
* OidcService is a service for managing the OpenId Connect Authorization flow.
*/
public class OidcService {
private final OidcIdentityProvider identityProvider;
private final Cache<CacheKey, State> stateLookupForPendingRequests; // identifier from cookie -> state value
private final Cache<CacheKey, String> jwtLookupForCompletedRequests; // identifier from cookie -> jwt or identity (and generate jwt on retrieval)
/**
* Creates a new OIDC with an expiration of 1 minute.
*
* @param identityProvider The identity provider
*/
public OidcService(final OidcIdentityProvider identityProvider) {
this(identityProvider, 60, TimeUnit.SECONDS);
}
/**
* Creates a new OIDC Service.
*
* @param identityProvider The identity provider
* @param duration The expiration duration
* @param units The expiration units
* @throws NullPointerException If units is null
* @throws IllegalArgumentException If duration is negative
*/
public OidcService(final OidcIdentityProvider identityProvider, final int duration, final TimeUnit units) {
if (identityProvider == null) {
throw new RuntimeException("The OidcIdentityProvider must be specified.");
}
identityProvider.initializeProvider();
this.identityProvider = identityProvider;
this.stateLookupForPendingRequests = Caffeine.newBuilder().expireAfterWrite(duration, units).build();
this.jwtLookupForCompletedRequests = Caffeine.newBuilder().expireAfterWrite(duration, units).build();
}
/**
* Returns whether OpenId Connect is enabled.
*
* @return whether OpenId Connect is enabled
*/
public boolean isOidcEnabled() {
return identityProvider.isOidcEnabled();
}
/**
* Returns the OpenId Connect authorization endpoint.
*
* @return the authorization endpoint
*/
public URI getAuthorizationEndpoint() {
return identityProvider.getAuthorizationEndpoint();
}
/**
* Returns the OpenId Connect end session endpoint.
*
* @return the end session endpoint
*/
public URI getEndSessionEndpoint() {
return identityProvider.getEndSessionEndpoint();
}
/**
* Returns the OpenId Connect revocation endpoint.
*
* @return the revocation endpoint
*/
public URI getRevocationEndpoint() {
return identityProvider.getRevocationEndpoint();
}
/**
* Returns the OpenId Connect scope.
*
* @return scope
*/
public Scope getScope() {
return identityProvider.getScope();
}
/**
* Returns the OpenId Connect client id.
*
* @return client id
*/
public String getClientId() {
return identityProvider.getClientId().getValue();
}
/**
* Initiates an OpenId Connection authorization code flow using the specified request identifier to maintain state.
*
* @param oidcRequestIdentifier request identifier
* @return state
*/
public State createState(final String oidcRequestIdentifier) {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
final State state = new State(IdentityProviderUtils.generateStateValue());
synchronized (stateLookupForPendingRequests) {
final State cachedState = stateLookupForPendingRequests.get(oidcRequestIdentifierKey, key -> state);
if (!IdentityProviderUtils.timeConstantEqualityCheck(state.getValue(), cachedState.getValue())) {
throw new IllegalStateException("An existing login request is already in progress.");
}
}
return state;
}
/**
* Validates the proposed state with the given request identifier. Will return false if the
* state does not match or if entry for this request identifier has expired.
*
* @param oidcRequestIdentifier request identifier
* @param proposedState proposed state
* @return whether the state is valid or not
*/
public boolean isStateValid(final String oidcRequestIdentifier, final State proposedState) {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
if (proposedState == null) {
throw new IllegalArgumentException("Proposed state must be specified.");
}
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
synchronized (stateLookupForPendingRequests) {
final State state = stateLookupForPendingRequests.getIfPresent(oidcRequestIdentifierKey);
if (state != null) {
stateLookupForPendingRequests.invalidate(oidcRequestIdentifierKey);
}
return state != null && IdentityProviderUtils.timeConstantEqualityCheck(state.getValue(), proposedState.getValue());
}
}
/**
* Exchanges the specified authorization grant for an ID token.
*
* @param authorizationGrant authorization grant
* @return a Login Authentication Token
* @throws IOException exceptional case for communication error with the OpenId Connect provider
*/
public LoginAuthenticationToken exchangeAuthorizationCodeForLoginAuthenticationToken(final AuthorizationGrant authorizationGrant) throws IOException {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
// 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);
// Cache the jwt for later retrieval
synchronized (jwtLookupForCompletedRequests) {
final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, key -> jwt);
if (!IdentityProviderUtils.timeConstantEqualityCheck(jwt, cachedJwt)) {
throw new IllegalStateException("An existing login request is already in progress.");
}
}
}
/**
* 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
* this request identifier expired.
*
* @param oidcRequestIdentifier request identifier
* @return jwt token
*/
public String getJwt(final String oidcRequestIdentifier) {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
synchronized (jwtLookupForCompletedRequests) {
final String jwt = jwtLookupForCompletedRequests.getIfPresent(oidcRequestIdentifierKey);
if (jwt != null) {
jwtLookupForCompletedRequests.invalidate(oidcRequestIdentifierKey);
}
return jwt;
}
}
}

View File

@ -17,17 +17,20 @@
package org.apache.nifi.web.security.oidc;
/**
* Indicates which truststore should be used when creating an HttpClient for an https URL.
* Shared configuration for OpenID Connect URL Paths
*/
public enum TruststoreStrategy {
public enum OidcUrlPath {
CALLBACK("/access/oidc/callback"),
/**
* Use the JDK truststore.
*/
JDK,
LOGOUT("/access/oidc/logout");
/**
* Use NiFi's truststore specified in nifi.properties.
*/
NIFI;
private final String path;
OidcUrlPath(final String path) {
this.path = path;
}
public String getPath() {
return path;
}
}

View File

@ -1,631 +0,0 @@
/*
* 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.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.ErrorObject;
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.Request;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.TokenErrorResponse;
import com.nimbusds.oauth2.sdk.TokenRequest;
import com.nimbusds.oauth2.sdk.TokenResponse;
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
import com.nimbusds.oauth2.sdk.auth.ClientSecretPost;
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;
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 net.minidev.json.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.authentication.exception.IdentityAccessException;
import org.apache.nifi.security.util.SslContextFactory;
import org.apache.nifi.security.util.StandardTlsConfiguration;
import org.apache.nifi.security.util.TlsConfiguration;
import org.apache.nifi.security.util.TlsException;
import org.apache.nifi.util.FormatUtils;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* OidcProvider for managing the OpenId Connect Authorization flow.
*/
public class StandardOidcIdentityProvider implements OidcIdentityProvider {
private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProvider.class);
private final String EMAIL_CLAIM = "email";
private final NiFiProperties properties;
private OIDCProviderMetadata oidcProviderMetadata;
private int oidcConnectTimeout;
private int oidcReadTimeout;
private IDTokenValidator tokenValidator;
private ClientID clientId;
private Secret clientSecret;
private SSLContext sslContext;
/**
* Creates a new StandardOidcIdentityProvider.
*
* @param properties properties
*/
public StandardOidcIdentityProvider(final NiFiProperties properties) {
this.properties = properties;
}
/**
* Loads OIDC configuration values from {@link NiFiProperties}, connects to external OIDC provider, and retrieves
* and validates provider metadata.
*/
@Override
public void initializeProvider() {
// attempt to process the oidc configuration if configured
if (!properties.isOidcEnabled()) {
logger.debug("The OIDC provider is not configured or enabled");
return;
}
// Set up trust store SSLContext
if (TruststoreStrategy.NIFI.name().equals(properties.getOidcClientTruststoreStrategy())) {
setSslContext();
}
validateOIDCConfiguration();
try {
// retrieve the oidc provider metadata
oidcProviderMetadata = retrieveOidcProviderMetadata(properties.getOidcDiscoveryUrl());
} catch (IOException | ParseException e) {
throw new RuntimeException("Unable to retrieve OpenId Connect Provider metadata from: " + properties.getOidcDiscoveryUrl(), e);
}
validateOIDCProviderMetadata();
}
private void setSslContext() {
TlsConfiguration tlsConfiguration = StandardTlsConfiguration.fromNiFiProperties(properties);
try {
this.sslContext = SslContextFactory.createSslContext(tlsConfiguration);
} catch (TlsException e) {
throw new RuntimeException("Unable to establish an SSL context for OIDC identity provider from nifi.properties", e);
}
}
/**
* Validates the retrieved OIDC provider metadata.
*/
private void validateOIDCProviderMetadata() {
// ensure the authorization endpoint is present
if (oidcProviderMetadata.getAuthorizationEndpointURI() == null) {
throw new RuntimeException("OpenId Connect Provider metadata does not contain an Authorization Endpoint.");
}
// ensure the token endpoint is present
if (oidcProviderMetadata.getTokenEndpointURI() == null) {
throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint.");
}
// ensure the oidc provider supports basic or post client auth
List<ClientAuthenticationMethod> clientAuthenticationMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
logger.info("OpenId Connect: Available clientAuthenticationMethods {} ", clientAuthenticationMethods);
if (clientAuthenticationMethods == null || clientAuthenticationMethods.isEmpty()) {
clientAuthenticationMethods = new ArrayList<>();
clientAuthenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
oidcProviderMetadata.setTokenEndpointAuthMethods(clientAuthenticationMethods);
logger.warn("OpenId Connect: ClientAuthenticationMethods is null, Setting clientAuthenticationMethods as CLIENT_SECRET_BASIC");
} else if (!clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
&& !clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
throw new RuntimeException(String.format("OpenId Connect Provider does not support %s or %s",
ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(),
ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()));
}
// extract the supported json web signature algorithms
final List<JWSAlgorithm> allowedAlgorithms = oidcProviderMetadata.getIDTokenJWSAlgs();
if (allowedAlgorithms == null || allowedAlgorithms.isEmpty()) {
throw new RuntimeException("The OpenId Connect Provider does not support any JWS algorithms.");
}
try {
// get the preferred json web signature algorithm
final JWSAlgorithm preferredJwsAlgorithm = extractJwsAlgorithm();
if (preferredJwsAlgorithm == null) {
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId);
} else if (JWSAlgorithm.HS256.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS384.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS512.equals(preferredJwsAlgorithm)) {
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, clientSecret);
} else {
final ResourceRetriever retriever = getResourceRetriever();
tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, oidcProviderMetadata.getJWKSetURI().toURL(), retriever);
}
} catch (final Exception e) {
throw new RuntimeException("Unable to create the ID token validator for the configured OpenId Connect Provider: " + e.getMessage(), e);
}
}
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.
*/
private void validateOIDCConfiguration() {
if (properties.isLoginIdentityProviderEnabled() || properties.isKnoxSsoEnabled()) {
throw new RuntimeException("OpenId Connect support cannot be enabled if the Login Identity Provider or Apache Knox SSO is configured.");
}
// oidc connect timeout
final String rawConnectTimeout = properties.getOidcConnectTimeout();
try {
oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(rawConnectTimeout, TimeUnit.MILLISECONDS);
} catch (final Exception e) {
logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
NiFiProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT, rawConnectTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS);
}
// oidc read timeout
final String rawReadTimeout = properties.getOidcReadTimeout();
try {
oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(rawReadTimeout, TimeUnit.MILLISECONDS);
} catch (final Exception e) {
logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
NiFiProperties.SECURITY_USER_OIDC_READ_TIMEOUT, rawReadTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT, TimeUnit.MILLISECONDS);
}
// client id
final String rawClientId = properties.getOidcClientId();
if (StringUtils.isBlank(rawClientId)) {
throw new RuntimeException("Client ID is required when configuring an OIDC Provider.");
}
clientId = new ClientID(rawClientId);
// client secret
final String rawClientSecret = properties.getOidcClientSecret();
if (StringUtils.isBlank(rawClientSecret)) {
throw new RuntimeException("Client secret is required when configuring an OIDC Provider.");
}
clientSecret = new Secret(rawClientSecret);
}
/**
* Returns the retrieved OIDC provider metadata from the external provider.
*
* @param discoveryUri the remote OIDC provider endpoint for service discovery
* @return the provider metadata
* @throws IOException if there is a problem connecting to the remote endpoint
* @throws ParseException if there is a problem parsing the response
*/
private OIDCProviderMetadata retrieveOidcProviderMetadata(final String discoveryUri) throws IOException, ParseException {
final URL url = new URL(discoveryUri);
final HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.GET, url);
setHttpRequestProperties(httpRequest);
final HTTPResponse httpResponse = httpRequest.send();
if (httpResponse.getStatusCode() != 200) {
throw new IOException("Unable to download OpenId Connect Provider metadata from " + url + ": Status code " + httpResponse.getStatusCode());
}
final JSONObject jsonObject = httpResponse.getContentAsJSONObject();
return OIDCProviderMetadata.parse(jsonObject);
}
@Override
public boolean isOidcEnabled() {
return properties.isOidcEnabled();
}
@Override
public URI getAuthorizationEndpoint() {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
return oidcProviderMetadata.getAuthorizationEndpointURI();
}
@Override
public URI getEndSessionEndpoint() {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
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()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
Scope scope = new Scope("openid", EMAIL_CLAIM);
for (String additionalScope : properties.getOidcAdditionalScopes()) {
// Scope automatically prevents duplicated entries
scope.add(additionalScope);
}
return scope;
}
@Override
public ClientID getClientId() {
if (!isOidcEnabled()) {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
return clientId;
}
@Override
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);
}
try {
// Authenticate and authorize the client request
final TokenResponse response = authorizeClient(authorizationGrant);
// 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);
}
}
@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 response;
} else {
// If the response was not successful
final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
final ErrorObject errorObject = errorResponse.getErrorObject();
throw new RuntimeException("An error occurred while invoking the Token endpoint: " +
errorObject.getDescription() + " (" + errorObject.getCode() + ")");
}
}
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
final IDTokenClaimsSet claimsSet = validateIdToken(oidcJwt);
// Attempt to extract the configured claim to access the user's identity; default is 'email'
String identityClaim = properties.getOidcClaimIdentifyingUser();
String identity = claimsSet.getStringClaim(identityClaim);
// Attempt to extract groups from the configured claim; default is 'groups'
String groupsClaim = properties.getOidcClaimGroups();
List<String> groups = claimsSet.getStringListClaim(groupsClaim);
// If default identity not available, attempt secondary identity extraction
if (StringUtils.isBlank(identity)) {
// Provide clear message to admin that desired claim is missing and present available claims
List<String> availableClaims = getAvailableClaims(oidcJwt.getJWTClaimsSet());
logger.warn("Failed to obtain the identity of the user with the claim '{}'. The available claims on " +
"the OIDC response are: {}. Will attempt to obtain the identity from secondary sources",
identityClaim, availableClaims);
// If the desired user claim was not "email" and "email" is present, use that
if (!identityClaim.equalsIgnoreCase(EMAIL_CLAIM) && availableClaims.contains(EMAIL_CLAIM)) {
identity = claimsSet.getStringClaim(EMAIL_CLAIM);
logger.info("The 'email' claim was present. Using that claim to avoid extra remote call");
} else {
final List<String> fallbackClaims = properties.getOidcFallbackClaimsIdentifyingUser();
for (String fallbackClaim : fallbackClaims) {
if (availableClaims.contains(fallbackClaim)) {
identity = claimsSet.getStringClaim(fallbackClaim);
break;
}
}
if (StringUtils.isBlank(identity)) {
identity = retrieveIdentityFromUserInfoEndpoint(oidcTokens);
}
}
}
// Extract expiration details from the claims set
final Calendar now = Calendar.getInstance();
final Date expiration = claimsSet.getExpirationTime();
final long expiresIn = expiration.getTime() - now.getTimeInMillis();
Set<SimpleGrantedAuthority> authorities = groups != null ? groups.stream().map(
group -> new SimpleGrantedAuthority(group)).collect(
Collectors.collectingAndThen(
Collectors.toSet(),
Collections::unmodifiableSet
)) : null;
return new LoginAuthenticationToken(
identity, identity, expiresIn, claimsSet.getIssuer().getValue(), authorities);
}
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
final BearerAccessToken bearerAccessToken = oidcTokens.getBearerAccessToken();
if (bearerAccessToken == null) {
throw new IllegalStateException("No access token found in the ID tokens");
}
// Invoke the UserInfo endpoint
HTTPRequest userInfoRequest = createUserInfoRequest(bearerAccessToken);
return lookupIdentityInUserInfo(userInfoRequest);
}
private HTTPRequest createTokenHTTPRequest(AuthorizationGrant authorizationGrant, ClientAuthentication clientAuthentication) {
final TokenRequest request = new TokenRequest(oidcProviderMetadata.getTokenEndpointURI(), clientAuthentication, authorizationGrant);
return formHTTPRequest(request);
}
private HTTPRequest createUserInfoRequest(BearerAccessToken bearerAccessToken) {
final UserInfoRequest request = new UserInfoRequest(oidcProviderMetadata.getUserInfoEndpointURI(), bearerAccessToken);
return formHTTPRequest(request);
}
private HTTPRequest formHTTPRequest(Request request) {
return setHttpRequestProperties(request.toHTTPRequest());
}
private HTTPRequest setHttpRequestProperties(final HTTPRequest httpRequest) {
httpRequest.setConnectTimeout(oidcConnectTimeout);
httpRequest.setReadTimeout(oidcReadTimeout);
if (TruststoreStrategy.NIFI.name().equals(properties.getOidcClientTruststoreStrategy())) {
httpRequest.setSSLSocketFactory(sslContext.getSocketFactory());
}
return httpRequest;
}
private ResourceRetriever getResourceRetriever() {
if (TruststoreStrategy.NIFI.name().equals(properties.getOidcClientTruststoreStrategy())) {
return new DefaultResourceRetriever(oidcConnectTimeout, oidcReadTimeout, 0, true, sslContext.getSocketFactory());
} else {
return new DefaultResourceRetriever(oidcConnectTimeout, oidcReadTimeout);
}
}
private ClientAuthentication createClientAuthentication() {
final ClientAuthentication clientAuthentication;
List<ClientAuthenticationMethod> authMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
if (authMethods != null && authMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
clientAuthentication = new ClientSecretPost(clientId, clientSecret);
} else {
clientAuthentication = new ClientSecretBasic(clientId, clientSecret);
}
return clientAuthentication;
}
private static List<String> getAvailableClaims(JWTClaimsSet claimSet) {
// Get the claims available in the ID token response
return claimSet.getClaims().entrySet().stream()
// Check claim values are not empty
.filter(e -> e.getValue() != null && StringUtils.isNotBlank(e.getValue().toString()))
// If not empty, put claim name in a map
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
}
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
final UserInfoResponse response = UserInfoResponse.parse(userInfoHttpRequest.send());
// interpret the details
if (response.indicatesSuccess()) {
final UserInfoSuccessResponse successResponse = (UserInfoSuccessResponse) response;
final JWTClaimsSet claimsSet;
if (successResponse.getUserInfo() != null) {
claimsSet = successResponse.getUserInfo().toJWTClaimsSet();
} else {
claimsSet = successResponse.getUserInfoJWT().getJWTClaimsSet();
}
final String identity = claimsSet.getStringClaim(properties.getOidcClaimIdentifyingUser());
// ensure we were able to get the user's identity
if (StringUtils.isBlank(identity)) {
throw new IllegalStateException("Unable to extract identity from the UserInfo token using the claim '" +
properties.getOidcClaimIdentifyingUser() + "'.");
} else {
return identity;
}
} else {
final UserInfoErrorResponse errorResponse = (UserInfoErrorResponse) response;
throw new IdentityAccessException("An error occurred while invoking the UserInfo endpoint: " + errorResponse.getErrorObject().getDescription());
}
} catch (final ParseException | java.text.ParseException e) {
throw new IdentityAccessException("Unable to parse the response from the UserInfo token request: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,79 @@
/*
* 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.client.web;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationRequest;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponse;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.revocation.TokenTypeHint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Runnable command to delete expired OpenID Authorized Clients
*/
public class AuthorizedClientExpirationCommand implements Runnable {
private static final Logger logger = LoggerFactory.getLogger(AuthorizedClientExpirationCommand.class);
private final TrackedAuthorizedClientRepository trackedAuthorizedClientRepository;
private final TokenRevocationResponseClient tokenRevocationResponseClient;
public AuthorizedClientExpirationCommand(
final TrackedAuthorizedClientRepository trackedAuthorizedClientRepository,
final TokenRevocationResponseClient tokenRevocationResponseClient
) {
this.trackedAuthorizedClientRepository = Objects.requireNonNull(trackedAuthorizedClientRepository, "Repository required");
this.tokenRevocationResponseClient = Objects.requireNonNull(tokenRevocationResponseClient, "Response Client required");
}
/**
* Run expiration command and send Token Revocation Requests for Refresh Tokens of expired Authorized Clients
*/
public void run() {
logger.debug("Delete Expired Authorized Clients started");
final List<OidcAuthorizedClient> deletedAuthorizedClients = deleteExpired();
for (final OidcAuthorizedClient authorizedClient : deletedAuthorizedClients) {
final String identity = authorizedClient.getPrincipalName();
final OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
if (refreshToken == null) {
logger.debug("Identity [{}] OIDC Refresh Token not found", identity);
} else {
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(refreshToken.getTokenValue(), TokenTypeHint.REFRESH_TOKEN.getHint());
final TokenRevocationResponse revocationResponse = tokenRevocationResponseClient.getRevocationResponse(revocationRequest);
logger.debug("Identity [{}] OIDC Refresh Token revocation response status [{}]", identity, revocationResponse.getStatusCode());
}
}
logger.debug("Delete Expired Authorized Clients completed");
}
private List<OidcAuthorizedClient> deleteExpired() {
try {
return trackedAuthorizedClientRepository.deleteExpired();
} catch (final Exception e) {
logger.warn("Delete Expired Authorized Clients failed", e);
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.client.web;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import java.util.Objects;
/**
* OpenID Connect Authorized Client with ID Token
*/
public class OidcAuthorizedClient extends OAuth2AuthorizedClient {
private final OidcIdToken idToken;
public OidcAuthorizedClient(
final ClientRegistration clientRegistration,
final String principalName,
final OAuth2AccessToken accessToken,
final OAuth2RefreshToken refreshToken,
final OidcIdToken idToken
) {
super(clientRegistration, principalName, accessToken, refreshToken);
this.idToken = Objects.requireNonNull(idToken, "OpenID Connect ID Token required");
}
/**
* Get OpenID Connect ID Token
*
* @return OpenID Connect ID Token
*/
public OidcIdToken getIdToken() {
return idToken;
}
}

View File

@ -0,0 +1,232 @@
/*
* 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.client.web;
import org.apache.nifi.authorization.user.NiFiUserUtils;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.cookie.ApplicationCookieService;
import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.jwt.provider.SupportedClaim;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* OpenID Connect Filter for evaluating the application Bearer Token and returning an updated Bearer Token after successful OAuth2 Refresh Token processing
*/
public class OidcBearerTokenRefreshFilter extends OncePerRequestFilter {
private static final String ROOT_PATH = "/";
private static final Logger logger = LoggerFactory.getLogger(OidcBearerTokenRefreshFilter.class);
private final AntPathRequestMatcher currentUserRequestMatcher = new AntPathRequestMatcher("/flow/current-user");
private final ApplicationCookieService applicationCookieService = new StandardApplicationCookieService();
private final Duration refreshWindow;
private final ConcurrentMap<String, Instant> refreshRequests = new ConcurrentHashMap<>();
private final BearerTokenProvider bearerTokenProvider;
private final BearerTokenResolver bearerTokenResolver;
private final JwtDecoder jwtDecoder;
private final OAuth2AuthorizedClientRepository authorizedClientRepository;
private final OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenResponseClient;
public OidcBearerTokenRefreshFilter(
final Duration refreshWindow,
final BearerTokenProvider bearerTokenProvider,
final BearerTokenResolver bearerTokenResolver,
final JwtDecoder jwtDecoder,
final OAuth2AuthorizedClientRepository authorizedClientRepository,
final OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenResponseClient
) {
this.refreshWindow = Objects.requireNonNull(refreshWindow, "Refresh Window required");
this.bearerTokenProvider = Objects.requireNonNull(bearerTokenProvider, "Bearer Token Provider required");
this.bearerTokenResolver = Objects.requireNonNull(bearerTokenResolver, "Bearer Token Resolver required");
this.jwtDecoder = Objects.requireNonNull(jwtDecoder, "JWT Decoder required");
this.authorizedClientRepository = Objects.requireNonNull(authorizedClientRepository, "Authorized Client Repository required");
this.refreshTokenResponseClient = Objects.requireNonNull(refreshTokenResponseClient, "Refresh Token Response Client required");
}
/**
* Run Bearer Token Refresh check for matched HTTP Requests and avoid processing multiple requests for the same user
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @param filterChain Filter Chain
* @throws ServletException Thrown on filter processing failures
* @throws IOException Thrown on filter processing failures
*/
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
if (currentUserRequestMatcher.matches(request)) {
final String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
if (refreshRequests.putIfAbsent(userIdentity, Instant.now()) == null) {
logger.debug("Identity [{}] Bearer Token refresh processing started", userIdentity);
try {
processRequest(userIdentity, request, response);
} catch (final Exception e) {
logger.error("Identity [{}] Bearer Token refresh processing failed", userIdentity, e);
} finally {
refreshRequests.remove(userIdentity);
logger.debug("Identity [{}] Bearer Token refresh processing completed", userIdentity);
}
}
}
filterChain.doFilter(request, response);
}
private void processRequest(final String userIdentity, final HttpServletRequest request, final HttpServletResponse response) {
if (isRefreshRequired(userIdentity, request)) {
logger.info("Identity [{}] Bearer Token refresh required", userIdentity);
final OidcAuthorizedClient authorizedClient = loadAuthorizedClient(request);
if (authorizedClient == null) {
logger.warn("Identity [{}] OIDC Authorized Client not found", userIdentity);
} else {
final OAuth2AccessTokenResponse tokenResponse = getRefreshTokenResponse(authorizedClient, request, response);
if (tokenResponse == null) {
logger.warn("Identity [{}] OpenID Connect Refresh Token not found", userIdentity);
} else {
final URI resourceUri = RequestUriBuilder.fromHttpServletRequest(request).path(ROOT_PATH).build();
final String issuer = resourceUri.toString();
final String bearerToken = getBearerToken(userIdentity, issuer, tokenResponse);
applicationCookieService.addSessionCookie(resourceUri, response, ApplicationCookieName.AUTHORIZATION_BEARER, bearerToken);
}
}
}
}
private boolean isRefreshRequired(final String userIdentity, final HttpServletRequest request) {
final boolean required;
final String token = bearerTokenResolver.resolve(request);
if (token == null) {
logger.debug("Identity [{}] Bearer Token not found", userIdentity);
required = false;
} else {
final Jwt jwt = jwtDecoder.decode(token);
final Instant expiresAt = Objects.requireNonNull(jwt.getExpiresAt(), "Bearer Token expiration claim not found");
final Instant refreshRequired = Instant.now().plus(refreshWindow);
required = refreshRequired.isAfter(expiresAt);
}
return required;
}
private OidcAuthorizedClient loadAuthorizedClient(final HttpServletRequest request) {
final SecurityContext context = SecurityContextHolder.getContext();
final Authentication principal = context.getAuthentication();
return authorizedClientRepository.loadAuthorizedClient(OidcRegistrationProperty.REGISTRATION_ID.getProperty(), principal, request);
}
private String getBearerToken(final String userIdentity, final String issuer, final OAuth2AccessTokenResponse tokenResponse) {
final OAuth2AccessToken accessToken = tokenResponse.getAccessToken();
final long sessionExpiration = getSessionExpiration(accessToken);
final LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(userIdentity, userIdentity, sessionExpiration, issuer);
return bearerTokenProvider.getBearerToken(loginAuthenticationToken);
}
private long getSessionExpiration(final OAuth2AccessToken accessToken) {
final Instant tokenExpiration = accessToken.getExpiresAt();
if (tokenExpiration == null) {
throw new IllegalArgumentException("OpenID Connect Access Token expiration claim not found");
}
final Duration expiration = Duration.between(Instant.now(), tokenExpiration);
return expiration.toMillis();
}
private OAuth2AccessTokenResponse getRefreshTokenResponse(final OidcAuthorizedClient authorizedClient, final HttpServletRequest request, final HttpServletResponse response) {
final OAuth2AccessTokenResponse tokenResponse;
if (authorizedClient.getRefreshToken() == null) {
tokenResponse = null;
} else {
tokenResponse = getRefreshTokenResponse(authorizedClient);
final OAuth2RefreshToken responseRefreshToken = tokenResponse.getRefreshToken();
final OAuth2RefreshToken refreshToken = responseRefreshToken == null ? authorizedClient.getRefreshToken() : responseRefreshToken;
final OidcAuthorizedClient refreshedAuthorizedClient = new OidcAuthorizedClient(
authorizedClient.getClientRegistration(),
authorizedClient.getPrincipalName(),
tokenResponse.getAccessToken(),
refreshToken,
authorizedClient.getIdToken()
);
final OAuth2AuthenticationToken authenticationToken = getAuthenticationToken(authorizedClient);
authorizedClientRepository.saveAuthorizedClient(refreshedAuthorizedClient, authenticationToken, request, response);
}
return tokenResponse;
}
private OAuth2AccessTokenResponse getRefreshTokenResponse(final OidcAuthorizedClient authorizedClient) {
final ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
final OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
final OAuth2RefreshToken refreshToken = Objects.requireNonNull(authorizedClient.getRefreshToken(), "Refresh Token required");
final OAuth2RefreshTokenGrantRequest grantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration, accessToken, refreshToken);
return refreshTokenResponseClient.getTokenResponse(grantRequest);
}
private OAuth2AuthenticationToken getAuthenticationToken(final OidcAuthorizedClient authorizedClient) {
final ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
final OidcIdToken idToken = authorizedClient.getIdToken();
final OidcUser oidcUser = new DefaultOidcUser(Collections.emptyList(), idToken, SupportedClaim.SUBJECT.getClaim());
return new OAuth2AuthenticationToken(oidcUser, Collections.emptyList(), clientRegistration.getRegistrationId());
}
}

View File

@ -0,0 +1,35 @@
/*
* 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.client.web;
/**
* OpenID Connect configuration property for Registration information
*/
public enum OidcRegistrationProperty {
/** Registration Identifier for URL path matching */
REGISTRATION_ID("consumer");
private final String property;
OidcRegistrationProperty(final String property) {
this.property = property;
}
public String getProperty() {
return property;
}
}

View File

@ -0,0 +1,137 @@
/*
* 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.client.web;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.cookie.ApplicationCookieService;
import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* OAuth2 Authorization Request Repository with Spring Cache storage and Request Identifier tracked using HTTP cookies
*/
public class StandardAuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private static final Logger logger = LoggerFactory.getLogger(StandardAuthorizationRequestRepository.class);
private static final ApplicationCookieService applicationCookieService = new StandardApplicationCookieService();
private final Cache cache;
/**
* Standard Authorization Request Repository Constructor
*
* @param cache Spring Cache for Authorization Requests
*/
public StandardAuthorizationRequestRepository(final Cache cache) {
this.cache = Objects.requireNonNull(cache, "Cache required");
}
/**
* Load Authorization Request using Request Identifier Cookie to lookup cached requests
*
* @param request HTTP Servlet Request
* @return Cached OAuth2 Authorization Request or null when not found
*/
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(final HttpServletRequest request) {
final Optional<String> requestIdentifier = applicationCookieService.getCookieValue(request, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER);
final OAuth2AuthorizationRequest authorizationRequest;
if (requestIdentifier.isPresent()) {
final String identifier = requestIdentifier.get();
authorizationRequest = cache.get(identifier, OAuth2AuthorizationRequest.class);
if (authorizationRequest == null) {
logger.warn("OIDC Authentication Request [{}] not found", identifier);
}
} else {
logger.warn("OIDC Authorization Request Identifier cookie not found");
authorizationRequest = null;
}
return authorizationRequest;
}
/**
* Save Authorization Request in cache and set HTTP Request Identifier cookie for tracking
*
* @param authorizationRequest OAuth2 Authorization Request to be cached
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
*/
@Override
public void saveAuthorizationRequest(final OAuth2AuthorizationRequest authorizationRequest, final HttpServletRequest request, final HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequest(request, response);
} else {
final String identifier = UUID.randomUUID().toString();
cache.put(identifier, authorizationRequest);
final URI resourceUri = RequestUriBuilder.fromHttpServletRequest(request).build();
applicationCookieService.addCookie(resourceUri, response, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER, identifier);
logger.debug("OIDC Authentication Request [{}] saved", identifier);
}
}
/**
* Remove Authorization Request from cache without updating HTTP response cookies
*
* @param request HTTP Servlet Request
* @return OAuth2 Authorization Request removed or null when not found
*/
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(final HttpServletRequest request) {
return removeAuthorizationRequest(request, null);
}
/**
* Remove Authorization Request from cache and remove HTTP cookie with Request Identifier
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @return OAuth2 Authorization Request removed or null when not found
*/
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(final HttpServletRequest request, final HttpServletResponse response) {
final OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request);
if (authorizationRequest == null) {
logger.warn("OIDC Authentication Request not removed");
} else {
if (response == null) {
logger.warn("HTTP Servlet Response not specified");
} else {
final URI resourceUri = RequestUriBuilder.fromHttpServletRequest(request).build();
applicationCookieService.removeCookie(resourceUri, response, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER);
}
final Optional<String> requestIdentifier = applicationCookieService.getCookieValue(request, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER);
requestIdentifier.ifPresent(cache::evict);
}
return authorizationRequest;
}
}

View File

@ -0,0 +1,276 @@
/*
* 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.client.web;
import org.apache.nifi.components.state.Scope;
import org.apache.nifi.components.state.StateManager;
import org.apache.nifi.components.state.StateMap;
import org.apache.nifi.web.security.oidc.client.web.converter.AuthorizedClientConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
/**
* OpenID Connect implementation of Authorized Client Repository for storing OAuth2 Tokens
*/
public class StandardOidcAuthorizedClientRepository implements OAuth2AuthorizedClientRepository, TrackedAuthorizedClientRepository {
private static final Logger logger = LoggerFactory.getLogger(StandardOidcAuthorizedClientRepository.class);
private static final Scope SCOPE = Scope.LOCAL;
private final StateManager stateManager;
private final AuthorizedClientConverter authorizedClientConverter;
/**
* Standard OpenID Connect Authorized Client Repository Constructor
*
* @param stateManager State Manager for storing authorized clients
* @param authorizedClientConverter Authorized Client Converter
*/
public StandardOidcAuthorizedClientRepository(
final StateManager stateManager,
final AuthorizedClientConverter authorizedClientConverter
) {
this.stateManager = Objects.requireNonNull(stateManager, "State Manager required");
this.authorizedClientConverter = Objects.requireNonNull(authorizedClientConverter, "Authorized Client Converter required");
}
/**
* Load Authorized Client from State Manager
*
* @param clientRegistrationId the identifier for the client's registration
* @param principal Principal Resource Owner to be loaded
* @param request HTTP Servlet Request not used
* @return Authorized Client or null when not found
* @param <T> Authorized Client Type
*/
@SuppressWarnings("unchecked")
@Override
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(
final String clientRegistrationId,
final Authentication principal,
final HttpServletRequest request
) {
final OidcAuthorizedClient authorizedClient;
final String encoded = findEncoded(principal);
final String principalId = getPrincipalId(principal);
if (encoded == null) {
logger.debug("Identity [{}] OIDC Authorized Client not found", principalId);
authorizedClient = null;
} else {
authorizedClient = authorizedClientConverter.getDecoded(encoded);
if (authorizedClient == null) {
logger.warn("Identity [{}] Removing OIDC Authorized Client after decoding failed", principalId);
removeAuthorizedClient(principal);
}
}
return (T) authorizedClient;
}
/**
* Save Authorized Client in State Manager
*
* @param authorizedClient Authorized Client to be saved
* @param principal Principal Resource Owner to be saved
* @param request HTTP Servlet Request not used
* @param response HTTP Servlet Response not used
*/
@Override
public void saveAuthorizedClient(
final OAuth2AuthorizedClient authorizedClient,
final Authentication principal,
final HttpServletRequest request,
final HttpServletResponse response
) {
final OidcAuthorizedClient oidcAuthorizedClient = getOidcAuthorizedClient(authorizedClient, principal);
final String encoded = authorizedClientConverter.getEncoded(oidcAuthorizedClient);
final String principalId = getPrincipalId(principal);
updateState(principalId, stateMap -> stateMap.put(principalId, encoded));
}
/**
* Remove Authorized Client from State Manager
*
* @param clientRegistrationId Client Registration Identifier not used
* @param principal Principal Resource Owner to be removed
* @param request HTTP Servlet Request not used
* @param response HTTP Servlet Response not used
*/
@Override
public void removeAuthorizedClient(
final String clientRegistrationId,
final Authentication principal,
final HttpServletRequest request,
final HttpServletResponse response
) {
removeAuthorizedClient(principal);
}
/**
* Delete expired Authorized Clients from the configured State Manager
*
* @return Deleted OIDC Authorized Clients
*/
@Override
public synchronized List<OidcAuthorizedClient> deleteExpired() {
final StateMap stateMap = getStateMap();
final Map<String, String> currentStateMap = stateMap.toMap();
final Map<String, String> updatedStateMap = new LinkedHashMap<>();
final List<OidcAuthorizedClient> deletedAuthorizedClients = new ArrayList<>();
for (final Map.Entry<String, String> encodedEntry : currentStateMap.entrySet()) {
final String identity = encodedEntry.getKey();
final String encoded = encodedEntry.getValue();
try {
final OidcAuthorizedClient authorizedClient = authorizedClientConverter.getDecoded(encoded);
if (isExpired(authorizedClient)) {
deletedAuthorizedClients.add(authorizedClient);
} else {
updatedStateMap.put(identity, encoded);
}
} catch (final Exception e) {
logger.warn("Decoding OIDC Authorized Client [{}] failed", identity, e);
}
}
setStateMap(updatedStateMap);
if (deletedAuthorizedClients.isEmpty()) {
logger.debug("Expired Authorized Clients not found");
} else {
logger.debug("Deleted Expired Authorized Clients: State before contained [{}] and after [{}]", currentStateMap.size(), updatedStateMap.size());
}
return deletedAuthorizedClients;
}
private boolean isExpired(final OidcAuthorizedClient authorizedClient) {
final OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
final Instant expiration = accessToken.getExpiresAt();
return expiration == null || Instant.now().isAfter(expiration);
}
private void removeAuthorizedClient(final Authentication principal) {
final String principalId = getPrincipalId(principal);
updateState(principalId, stateMap -> stateMap.remove(principalId));
}
private OidcAuthorizedClient getOidcAuthorizedClient(final OAuth2AuthorizedClient authorizedClient, final Authentication authentication) {
final OidcIdToken oidcIdToken = getOidcIdToken(authentication);
return new OidcAuthorizedClient(
authorizedClient.getClientRegistration(),
authorizedClient.getPrincipalName(),
authorizedClient.getAccessToken(),
authorizedClient.getRefreshToken(),
oidcIdToken
);
}
private OidcIdToken getOidcIdToken(final Authentication authentication) {
final OidcIdToken oidcIdToken;
if (authentication instanceof OAuth2AuthenticationToken) {
final OAuth2AuthenticationToken authenticationToken = (OAuth2AuthenticationToken) authentication;
final OAuth2User oAuth2User = authenticationToken.getPrincipal();
if (oAuth2User instanceof OidcUser) {
final OidcUser oidcUser = (OidcUser) oAuth2User;
oidcIdToken = oidcUser.getIdToken();
} else {
final String message = String.format("OpenID Connect User not found [%s]", oAuth2User.getClass());
throw new IllegalArgumentException(message);
}
} else {
final String message = String.format("OpenID Connect Authentication Token not found [%s]", authentication.getClass());
throw new IllegalArgumentException(message);
}
return oidcIdToken;
}
private String findEncoded(final Authentication authentication) {
final String principalId = getPrincipalId(authentication);
final StateMap stateMap = getStateMap();
return stateMap.get(principalId);
}
private String getPrincipalId(final Authentication authentication) {
return authentication.getName();
}
private synchronized void updateState(final String principalId, final Consumer<Map<String, String>> stateConsumer) {
try {
final StateMap stateMap = getStateMap();
final Map<String, String> currentStateMap = stateMap.toMap();
final Map<String, String> updated = new LinkedHashMap<>(currentStateMap);
stateConsumer.accept(updated);
final boolean completed;
if (currentStateMap.isEmpty()) {
stateManager.setState(updated, SCOPE);
completed = true;
} else {
completed = stateManager.replace(stateMap, updated, SCOPE);
}
if (completed) {
logger.info("Identity [{}] OIDC Authorized Client update completed", principalId);
} else {
logger.info("Identity [{}] OIDC Authorized Client update failed", principalId);
}
} catch (final Exception e) {
logger.warn("Identity [{}] OIDC Authorized Client update processing failed", principalId, e);
}
}
private void setStateMap(final Map<String, String> stateMap) {
try {
stateManager.setState(stateMap, SCOPE);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
private StateMap getStateMap() {
try {
return stateManager.getState(SCOPE);
} catch (final IOException e) {
throw new UncheckedIOException("Get State for OIDC Authorized Clients failed", e);
}
}
}

View File

@ -0,0 +1,31 @@
/*
* 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.client.web;
import java.util.List;
/**
* Tracked abstraction for Authorized Client Repository supporting deletion of expired clients
*/
public interface TrackedAuthorizedClientRepository {
/**
* Delete expired Authorized Clients
*
* @return Deleted OIDC Authorized Clients
*/
List<OidcAuthorizedClient> deleteExpired();
}

View File

@ -0,0 +1,42 @@
/*
* 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.client.web.converter;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
/**
* OAuth2 Authentication Result Converter returns an extended Authentication Token containing the OAuth2 Access Token
*/
public class AuthenticationResultConverter implements Converter<OAuth2LoginAuthenticationToken, OAuth2AuthenticationToken> {
/**
* Convert OAuth2 Login Authentication Token to OAuth2 Authentication Token containing the OAuth2 Access Token
*
* @param source OAuth2 Login Authentication Token
* @return Standard extension of OAuth2 Authentication Token
*/
@Override
public OAuth2AuthenticationToken convert(final OAuth2LoginAuthenticationToken source) {
return new StandardOAuth2AuthenticationToken(
source.getPrincipal(),
source.getAuthorities(),
source.getClientRegistration().getRegistrationId(),
source.getAccessToken()
);
}
}

View File

@ -0,0 +1,68 @@
/*
* 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.client.web.converter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;
/**
* Serialized representation of Authorized Client
*/
public class AuthorizedClient {
private final String principalName;
private final AuthorizedToken accessToken;
private final AuthorizedToken refreshToken;
private final AuthorizedToken idToken;
@JsonCreator
public AuthorizedClient(
@JsonProperty("principalName")
final String principalName,
@JsonProperty("accessToken")
final AuthorizedToken accessToken,
@JsonProperty("refreshToken")
final AuthorizedToken refreshToken,
@JsonProperty("idToken")
final AuthorizedToken idToken
) {
this.principalName = Objects.requireNonNull(principalName, "Principal Name required");
this.accessToken = Objects.requireNonNull(accessToken, "Access Token required");
this.refreshToken = refreshToken;
this.idToken = Objects.requireNonNull(idToken, "ID Token required");
}
public String getPrincipalName() {
return principalName;
}
public AuthorizedToken getAccessToken() {
return accessToken;
}
public AuthorizedToken getRefreshToken() {
return refreshToken;
}
public AuthorizedToken getIdToken() {
return idToken;
}
}

View File

@ -0,0 +1,40 @@
/*
* 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.client.web.converter;
import org.apache.nifi.web.security.oidc.client.web.OidcAuthorizedClient;
/**
* Abstraction for encoding and decoding OpenID Connect Authorized Client Tokens
*/
public interface AuthorizedClientConverter {
/**
* Get encoded representation of Authorized Client
*
* @param oidcAuthorizedClient OpenID Connect Authorized Client required
* @return Encoded representation
*/
String getEncoded(OidcAuthorizedClient oidcAuthorizedClient);
/**
* Get decoded OpenID Connect Authorized Client
*
* @param encoded Encoded representation required
* @return OpenID Connect Authorized Client
*/
OidcAuthorizedClient getDecoded(String encoded);
}

View File

@ -0,0 +1,60 @@
/*
* 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.client.web.converter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Objects;
/**
* Serialized representation of Authorized Token with minimum required properties
*/
public class AuthorizedToken {
private final String tokenValue;
private final Instant issuedAt;
private final Instant expiresAt;
@JsonCreator
public AuthorizedToken(
@JsonProperty("tokenValue")
final String tokenValue,
@JsonProperty("issuedAt")
final Instant issuedAt,
@JsonProperty("expiresAt")
final Instant expiresAt
) {
this.tokenValue = Objects.requireNonNull(tokenValue, "Token Value required");
this.issuedAt = issuedAt;
this.expiresAt = expiresAt;
}
public String getTokenValue() {
return tokenValue;
}
public Instant getIssuedAt() {
return issuedAt;
}
public Instant getExpiresAt() {
return expiresAt;
}
}

View File

@ -0,0 +1,170 @@
/*
* 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.client.web.converter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.nifi.encrypt.PropertyEncryptor;
import org.apache.nifi.web.security.jwt.provider.SupportedClaim;
import org.apache.nifi.web.security.oidc.OidcConfigurationException;
import org.apache.nifi.web.security.oidc.client.web.OidcAuthorizedClient;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Standard Authorized Client Converter with JSON serialization and encryption
*/
public class StandardAuthorizedClientConverter implements AuthorizedClientConverter {
private static final Logger logger = LoggerFactory.getLogger(StandardAuthorizedClientConverter.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModules(new JavaTimeModule());
private final PropertyEncryptor propertyEncryptor;
private final ClientRegistrationRepository clientRegistrationRepository;
public StandardAuthorizedClientConverter(
final PropertyEncryptor propertyEncryptor,
final ClientRegistrationRepository clientRegistrationRepository
) {
this.propertyEncryptor = Objects.requireNonNull(propertyEncryptor, "Property Encryptor required");
this.clientRegistrationRepository = Objects.requireNonNull(clientRegistrationRepository, "Client Registry Repository required");
}
/**
* Get encoded representation serializes as JSON and returns an encrypted string
*
* @param oidcAuthorizedClient OpenID Connect Authorized Client required
* @return JSON serialized encrypted string
*/
@Override
public String getEncoded(final OidcAuthorizedClient oidcAuthorizedClient) {
Objects.requireNonNull(oidcAuthorizedClient, "Authorized Client required");
try {
final AuthorizedClient authorizedClient = writeAuthorizedClient(oidcAuthorizedClient);
final String serialized = OBJECT_MAPPER.writeValueAsString(authorizedClient);
return propertyEncryptor.encrypt(serialized);
} catch (final Exception e) {
throw new OidcConfigurationException("OIDC Authorized Client serialization failed", e);
}
}
/**
* Get decoded Authorized Client from encrypted string containing JSON
*
* @param encoded Encoded representation required
* @return OpenID Connect Authorized Client or null on decoding failures
*/
@Override
public OidcAuthorizedClient getDecoded(final String encoded) {
Objects.requireNonNull(encoded, "Encoded representation required");
try {
final String decrypted = propertyEncryptor.decrypt(encoded);
final AuthorizedClient authorizedClient = OBJECT_MAPPER.readValue(decrypted, AuthorizedClient.class);
return readAuthorizedClient(authorizedClient);
} catch (final Exception e) {
logger.warn("OIDC Authorized Client decoding failed", e);
return null;
}
}
private AuthorizedClient writeAuthorizedClient(final OidcAuthorizedClient oidcAuthorizedClient) {
final OAuth2AccessToken oidcAccessToken = oidcAuthorizedClient.getAccessToken();
final AuthorizedToken accessToken = new AuthorizedToken(
oidcAccessToken.getTokenValue(),
oidcAccessToken.getIssuedAt(),
oidcAccessToken.getExpiresAt()
);
final AuthorizedToken refreshToken;
final OAuth2RefreshToken oidcRefreshToken = oidcAuthorizedClient.getRefreshToken();
if (oidcRefreshToken == null) {
refreshToken = null;
} else {
refreshToken = new AuthorizedToken(
oidcRefreshToken.getTokenValue(),
oidcRefreshToken.getIssuedAt(),
oidcRefreshToken.getExpiresAt()
);
}
final OidcIdToken oidcIdToken = oidcAuthorizedClient.getIdToken();
final AuthorizedToken idToken = new AuthorizedToken(
oidcIdToken.getTokenValue(),
oidcIdToken.getIssuedAt(),
oidcIdToken.getExpiresAt()
);
final String principalName = oidcAuthorizedClient.getPrincipalName();
return new AuthorizedClient(principalName, accessToken, refreshToken, idToken);
}
private OidcAuthorizedClient readAuthorizedClient(final AuthorizedClient authorizedClient) {
final ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty());
final String principalName = authorizedClient.getPrincipalName();
final OAuth2AccessToken accessToken = getAccessToken(authorizedClient.getAccessToken());
final OAuth2RefreshToken refreshToken = getRefreshToken(authorizedClient.getRefreshToken());
final OidcIdToken idToken = getIdToken(authorizedClient);
return new OidcAuthorizedClient(clientRegistration, principalName, accessToken, refreshToken, idToken);
}
private OAuth2AccessToken getAccessToken(final AuthorizedToken authorizedToken) {
return new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
authorizedToken.getTokenValue(),
authorizedToken.getIssuedAt(),
authorizedToken.getExpiresAt()
);
}
private OAuth2RefreshToken getRefreshToken(final AuthorizedToken authorizedToken) {
return authorizedToken == null ? null : new OAuth2RefreshToken(
authorizedToken.getTokenValue(),
authorizedToken.getIssuedAt(),
authorizedToken.getExpiresAt()
);
}
private OidcIdToken getIdToken(final AuthorizedClient authorizedClient) {
final AuthorizedToken authorizedToken = authorizedClient.getIdToken();
final Map<String, Object> claims = new LinkedHashMap<>();
claims.put(SupportedClaim.SUBJECT.getClaim(), authorizedClient.getPrincipalName());
claims.put(SupportedClaim.ISSUED_AT.getClaim(), authorizedToken.getIssuedAt());
claims.put(SupportedClaim.EXPIRATION.getClaim(), authorizedToken.getExpiresAt());
return new OidcIdToken(
authorizedToken.getTokenValue(),
authorizedToken.getIssuedAt(),
authorizedToken.getExpiresAt(),
claims
);
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.client.web.converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Objects;
/**
* OAuth2 Authentication Token extended to include the OAuth2 Access Token
*/
public class StandardOAuth2AuthenticationToken extends OAuth2AuthenticationToken {
private final OAuth2AccessToken accessToken;
public StandardOAuth2AuthenticationToken(
final OAuth2User principal,
final Collection<? extends GrantedAuthority> authorities,
final String authorizedClientRegistrationId,
final OAuth2AccessToken accessToken
) {
super(principal, authorities, authorizedClientRegistrationId);
this.accessToken = Objects.requireNonNull(accessToken, "Access Token required");
}
/**
* Get Credentials returns the OAuth2 Access Token
*
* @return OAuth2 Access Token
*/
@Override
public Object getCredentials() {
return accessToken;
}
}

View File

@ -0,0 +1,33 @@
/*
* 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.logout;
import org.apache.nifi.web.security.logout.StandardLogoutFilter;
import org.apache.nifi.web.security.oidc.OidcUrlPath;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
/**
* OpenID Connect Logout Filter completes application Logout Requests
*/
public class OidcLogoutFilter extends StandardLogoutFilter {
public OidcLogoutFilter(
final LogoutSuccessHandler logoutSuccessHandler
) {
super(new AntPathRequestMatcher(OidcUrlPath.LOGOUT.getPath()), logoutSuccessHandler);
}
}

View File

@ -0,0 +1,225 @@
/*
* 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.logout;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.cookie.ApplicationCookieService;
import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.security.logout.LogoutRequest;
import org.apache.nifi.web.security.logout.LogoutRequestManager;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.apache.nifi.web.security.oidc.client.web.OidcAuthorizedClient;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationRequest;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponse;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.revocation.TokenTypeHint;
import org.apache.nifi.web.security.token.LogoutAuthenticationToken;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* OpenID Connect Logout Success Handler supports RP-Initiated Logout and
* completes Logout Requests with conditional redirect to Authorization Server
*/
public class OidcLogoutSuccessHandler implements LogoutSuccessHandler {
static final String END_SESSION_ENDPOINT = "end_session_endpoint";
private static final String LOGOUT_COMPLETE_PATH = "/nifi/logout-complete";
private static final String ID_TOKEN_HINT_PARAMETER = "id_token_hint";
private static final String POST_LOGOUT_REDIRECT_URI_PARAMETER = "post_logout_redirect_uri";
private static final Logger logger = LoggerFactory.getLogger(OidcLogoutSuccessHandler.class);
private final ApplicationCookieService applicationCookieService = new StandardApplicationCookieService();
private final LogoutRequestManager logoutRequestManager;
private final IdpUserGroupService idpUserGroupService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final OAuth2AuthorizedClientRepository authorizedClientRepository;
private final TokenRevocationResponseClient tokenRevocationResponseClient;
/**
* OpenID Connect Logout Success Handler with RP-Initiated Logout 1.0 and RFC 7009 Token Revocation
*
* @param logoutRequestManager Application Logout Request Manager
* @param idpUserGroupService User Group Service for clearing cached groups
* @param clientRegistrationRepository OIDC Client Registry Repository for configuration information
* @param authorizedClientRepository OIDC Authorized Client Repository for cached tokens
* @param tokenRevocationResponseClient OIDC Revocation Response Client for revoking Refresh Tokens
*/
public OidcLogoutSuccessHandler(
final LogoutRequestManager logoutRequestManager,
final IdpUserGroupService idpUserGroupService,
final ClientRegistrationRepository clientRegistrationRepository,
final OAuth2AuthorizedClientRepository authorizedClientRepository,
final TokenRevocationResponseClient tokenRevocationResponseClient
) {
this.logoutRequestManager = Objects.requireNonNull(logoutRequestManager, "Logout Request Manager required");
this.idpUserGroupService = Objects.requireNonNull(idpUserGroupService, "User Group Service required");
this.clientRegistrationRepository = Objects.requireNonNull(clientRegistrationRepository, "Client Registration Repository required");
this.authorizedClientRepository = Objects.requireNonNull(authorizedClientRepository, "Authorized Client Repository required");
this.tokenRevocationResponseClient = Objects.requireNonNull(tokenRevocationResponseClient, "Revocation Response Client required");
}
/**
* On Logout Success complete Logout Request based on Logout Request Identifier found in cookies
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @param authentication Authentication is not used
* @throws IOException Thrown on HttpServletResponse.sendRedirect() failures
*/
@Override
public void onLogoutSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException {
final Optional<String> logoutRequestIdentifier = applicationCookieService.getCookieValue(request, ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER);
if (logoutRequestIdentifier.isPresent()) {
final String targetUrl;
final String requestIdentifier = logoutRequestIdentifier.get();
final LogoutRequest logoutRequest = logoutRequestManager.get(requestIdentifier);
if (logoutRequest == null) {
logger.warn("OIDC Logout Request [{}] not found", requestIdentifier);
targetUrl = getPostLogoutRedirectUri(request);
} else {
final String mappedUserIdentity = logoutRequest.getMappedUserIdentity();
idpUserGroupService.deleteUserGroups(mappedUserIdentity);
targetUrl = processLogoutRequest(request, response, requestIdentifier, mappedUserIdentity);
}
response.sendRedirect(targetUrl);
}
}
private String processLogoutRequest(final HttpServletRequest request, final HttpServletResponse response, final String requestIdentifier, final String mappedUserIdentity) {
final String targetUrl;
final ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty());
final OidcAuthorizedClient oidcAuthorizedClient = getOidcAuthorizedClient(clientRegistration, mappedUserIdentity, request);
if (oidcAuthorizedClient == null) {
targetUrl = getPostLogoutRedirectUri(request);
logger.warn("OIDC Logout Request [{}] Identity [{}] ID Token not found", requestIdentifier, mappedUserIdentity);
} else {
final URI endSessionEndpoint = getEndSessionEndpoint(clientRegistration);
if (endSessionEndpoint == null) {
targetUrl = getPostLogoutRedirectUri(request);
logger.debug("OIDC Logout Request [{}] Identity [{}] end_session_endpoint not provided", requestIdentifier, mappedUserIdentity);
} else {
final String postLogoutRedirectUri = getPostLogoutRedirectUri(request);
final OidcIdToken oidcIdToken = oidcAuthorizedClient.getIdToken();
final String idToken = oidcIdToken.getTokenValue();
targetUrl = getEndSessionTargetUrl(endSessionEndpoint, idToken, postLogoutRedirectUri);
logger.info("OIDC Logout Request [{}] Identity [{}] initiated", requestIdentifier, mappedUserIdentity);
}
final LogoutAuthenticationToken principal = new LogoutAuthenticationToken(mappedUserIdentity);
authorizedClientRepository.removeAuthorizedClient(OidcRegistrationProperty.REGISTRATION_ID.getProperty(), principal, request, response);
processRefreshTokenRevocation(oidcAuthorizedClient, mappedUserIdentity);
processAccessTokenRevocation(oidcAuthorizedClient, mappedUserIdentity);
}
return targetUrl;
}
private void processAccessTokenRevocation(final OidcAuthorizedClient oidcAuthorizedClient, final String userIdentity) {
final OAuth2AccessToken accessToken = oidcAuthorizedClient.getAccessToken();
final String token = accessToken.getTokenValue();
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(token, TokenTypeHint.ACCESS_TOKEN.getHint());
final TokenRevocationResponse revocationResponse = tokenRevocationResponseClient.getRevocationResponse(revocationRequest);
logger.info("Identity [{}] OIDC Access Token Revocation completed [HTTP {}]", userIdentity, revocationResponse.getStatusCode());
}
private void processRefreshTokenRevocation(final OidcAuthorizedClient oidcAuthorizedClient, final String userIdentity) {
final OAuth2RefreshToken refreshToken = oidcAuthorizedClient.getRefreshToken();
if (refreshToken == null) {
logger.debug("Identity [{}] OIDC Refresh Token not found for revocation", userIdentity);
} else {
final String token = refreshToken.getTokenValue();
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(token, TokenTypeHint.REFRESH_TOKEN.getHint());
final TokenRevocationResponse revocationResponse = tokenRevocationResponseClient.getRevocationResponse(revocationRequest);
logger.info("Identity [{}] OIDC Refresh Token Revocation completed [HTTP {}]", userIdentity, revocationResponse.getStatusCode());
}
}
private OidcAuthorizedClient getOidcAuthorizedClient(final ClientRegistration clientRegistration, final String userIdentity, final HttpServletRequest request) {
final OidcAuthorizedClient oidcAuthorizedClient;
final String clientRegistrationId = clientRegistration.getRegistrationId();
final LogoutAuthenticationToken authenticationToken = new LogoutAuthenticationToken(userIdentity);
final OAuth2AuthorizedClient authorizedClient = authorizedClientRepository.loadAuthorizedClient(clientRegistrationId, authenticationToken, request);
if (authorizedClient == null) {
logger.warn("Identity [{}] OpenID Connect Authorized Client not found", userIdentity);
oidcAuthorizedClient = null;
} else if (authorizedClient instanceof OidcAuthorizedClient) {
oidcAuthorizedClient = (OidcAuthorizedClient) authorizedClient;
} else {
logger.error("Identity [{}] OpenID Connect Authorized Client Class not found [{}]", userIdentity, authorizedClient.getClass());
oidcAuthorizedClient = null;
}
return oidcAuthorizedClient;
}
private URI getEndSessionEndpoint(final ClientRegistration clientRegistration) {
final ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
final Map<String, Object> configurationMetadata = providerDetails.getConfigurationMetadata();
final Object endSessionEndpoint = configurationMetadata.get(END_SESSION_ENDPOINT);
return endSessionEndpoint == null ? null : URI.create(endSessionEndpoint.toString());
}
private String getEndSessionTargetUrl(final URI endSessionEndpoint, final String idToken, final String postLogoutRedirectUri) {
final UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint);
builder.queryParam(ID_TOKEN_HINT_PARAMETER, idToken);
builder.queryParam(POST_LOGOUT_REDIRECT_URI_PARAMETER, postLogoutRedirectUri);
return builder.encode(StandardCharsets.UTF_8).build().toUriString();
}
private String getPostLogoutRedirectUri(final HttpServletRequest request) {
return RequestUriBuilder.fromHttpServletRequest(request)
.path(LOGOUT_COMPLETE_PATH)
.build()
.toString();
}
}

View File

@ -0,0 +1,31 @@
/*
* 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.registration;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
/**
* Provider interface for OpenID Connect Client Registration abstracting access to metadata from a configurable location
*/
public interface ClientRegistrationProvider {
/**
* Get Client Registration
*
* @return OAuth2 Client Registration
*/
ClientRegistration getClientRegistration();
}

View File

@ -0,0 +1,31 @@
/*
* 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.registration;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
/**
* Disabled implementation of Client Registration Repository when OpenID Connect is not configured
*/
public class DisabledClientRegistrationRepository implements ClientRegistrationRepository {
@Override
public ClientRegistration findByRegistrationId(final String registrationId) {
return null;
}
}

View File

@ -0,0 +1,138 @@
/*
* 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.registration;
import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_POST;
import static com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.NONE;
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.oidc.OidcConfigurationException;
import org.apache.nifi.web.security.oidc.OidcUrlPath;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.web.client.RestOperations;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Standard implementation of Client Registration Provider using Application Properties
*/
public class StandardClientRegistrationProvider implements ClientRegistrationProvider {
private static final String REGISTRATION_REDIRECT_URI = String.format("{baseUrl}%s", OidcUrlPath.CALLBACK.getPath());
private final NiFiProperties properties;
private final RestOperations restOperations;
public StandardClientRegistrationProvider(
final NiFiProperties properties,
final RestOperations restOperations
) {
this.properties = Objects.requireNonNull(properties, "Properties required");
this.restOperations = Objects.requireNonNull(restOperations, "REST Operations required");
}
/**
* Get Client Registration using OpenID Connect Discovery URL
*
* @return Client Registration
*/
@Override
public ClientRegistration getClientRegistration() {
final String clientId = properties.getOidcClientId();
final String clientSecret = properties.getOidcClientSecret();
final OIDCProviderMetadata providerMetadata = getProviderMetadata();
final ClientAuthenticationMethod clientAuthenticationMethod = getClientAuthenticationMethod(providerMetadata.getTokenEndpointAuthMethods());
final String issuerUri = providerMetadata.getIssuer().getValue();
final String tokenUri = providerMetadata.getTokenEndpointURI().toASCIIString();
final Map<String, Object> configurationMetadata = new LinkedHashMap<>(providerMetadata.toJSONObject());
final String authorizationUri = providerMetadata.getAuthorizationEndpointURI().toASCIIString();
final String jwkSetUri = providerMetadata.getJWKSetURI().toASCIIString();
final String userInfoUri = providerMetadata.getUserInfoEndpointURI().toASCIIString();
final Scope metadataScope = providerMetadata.getScopes();
final Set<String> scope = new LinkedHashSet<>(metadataScope.toStringList());
final List<String> additionalScopes = properties.getOidcAdditionalScopes();
scope.addAll(additionalScopes);
final String userNameAttributeName = properties.getOidcClaimIdentifyingUser();
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.clientId(clientId)
.clientSecret(clientSecret)
.clientName(issuerUri)
.issuerUri(issuerUri)
.tokenUri(tokenUri)
.authorizationUri(authorizationUri)
.jwkSetUri(jwkSetUri)
.userInfoUri(userInfoUri)
.providerConfigurationMetadata(configurationMetadata)
.redirectUri(REGISTRATION_REDIRECT_URI)
.scope(scope)
.userNameAttributeName(userNameAttributeName)
.clientAuthenticationMethod(clientAuthenticationMethod)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.build();
}
private OIDCProviderMetadata getProviderMetadata() {
final String discoveryUrl = properties.getOidcDiscoveryUrl();
final String metadataObject;
try {
metadataObject = restOperations.getForObject(discoveryUrl, String.class);
} catch (final RuntimeException e) {
final String message = String.format("OpenID Connect Metadata URL [%s] retrieval failed", discoveryUrl);
throw new OidcConfigurationException(message, e);
}
try {
return OIDCProviderMetadata.parse(metadataObject);
} catch (final ParseException e) {
throw new OidcConfigurationException("OpenID Connect Metadata parsing failed", e);
}
}
private ClientAuthenticationMethod getClientAuthenticationMethod(final List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods) {
final ClientAuthenticationMethod clientAuthenticationMethod;
if (metadataAuthMethods == null || metadataAuthMethods.contains(CLIENT_SECRET_BASIC)) {
clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
} else if (metadataAuthMethods.contains(CLIENT_SECRET_POST)) {
clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_POST;
} else if (metadataAuthMethods.contains(NONE)) {
clientAuthenticationMethod = ClientAuthenticationMethod.NONE;
} else {
clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
}
return clientAuthenticationMethod;
}
}

View File

@ -0,0 +1,125 @@
/*
* 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.revocation;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestOperations;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
/**
* Standard implementation for handling Token Revocation Requests using Spring REST Operations
*/
public class StandardTokenRevocationResponseClient implements TokenRevocationResponseClient {
static final String REVOCATION_ENDPOINT = "revocation_endpoint";
private static final Logger logger = LoggerFactory.getLogger(StandardTokenRevocationResponseClient.class);
private final RestOperations restOperations;
private final ClientRegistrationRepository clientRegistrationRepository;
public StandardTokenRevocationResponseClient(
final RestOperations restOperations,
final ClientRegistrationRepository clientRegistrationRepository
) {
this.restOperations = Objects.requireNonNull(restOperations, "REST Operations required");
this.clientRegistrationRepository = Objects.requireNonNull(clientRegistrationRepository, "Client Registry Repository required");
}
/**
* Get Revocation Response as described in RFC 7009 Section 2.2 or return success when the Revocation Endpoint is not configured
*
* @param revocationRequest Revocation Request is required
* @return Token Revocation Response
*/
@Override
public TokenRevocationResponse getRevocationResponse(final TokenRevocationRequest revocationRequest) {
Objects.requireNonNull(revocationRequest, "Revocation Request required");
final ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty());
final ResponseEntity<?> responseEntity = getResponseEntity(revocationRequest, clientRegistration);
final HttpStatus statusCode = responseEntity.getStatusCode();
return new TokenRevocationResponse(statusCode.is2xxSuccessful(), statusCode.value());
}
private ResponseEntity<?> getResponseEntity(final TokenRevocationRequest revocationRequest, final ClientRegistration clientRegistration) {
final RequestEntity<?> requestEntity = getRequestEntity(revocationRequest, clientRegistration);
if (requestEntity == null) {
return ResponseEntity.ok(null);
} else {
try {
final ResponseEntity<?> responseEntity = restOperations.exchange(requestEntity, String.class);
logger.debug("Token Revocation Request processing completed [HTTP {}]", responseEntity.getStatusCode());
return responseEntity;
} catch (final Exception e) {
logger.warn("Token Revocation Request processing failed", e);
return ResponseEntity.internalServerError().build();
}
}
}
private RequestEntity<?> getRequestEntity(final TokenRevocationRequest revocationRequest, final ClientRegistration clientRegistration) {
final RequestEntity<?> requestEntity;
final URI revocationEndpoint = getRevocationEndpoint(clientRegistration);
if (revocationEndpoint == null) {
requestEntity = null;
logger.info("OIDC Revocation Endpoint not found");
} else {
final LinkedMultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
parameters.add(OAuth2ParameterNames.TOKEN, revocationRequest.getToken());
final String tokenTypeHint = revocationRequest.getTokenTypeHint();
if (StringUtils.hasLength(tokenTypeHint)) {
parameters.add(OAuth2ParameterNames.TOKEN_TYPE_HINT, tokenTypeHint);
}
final HttpHeaders headers = new HttpHeaders();
final String clientId = clientRegistration.getClientId();
final String clientSecret = clientRegistration.getClientSecret();
headers.setBasicAuth(clientId, clientSecret, StandardCharsets.UTF_8);
requestEntity = RequestEntity.post(revocationEndpoint)
.headers(headers)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(parameters);
}
return requestEntity;
}
private URI getRevocationEndpoint(final ClientRegistration clientRegistration) {
final ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
final Map<String, Object> configurationMetadata = providerDetails.getConfigurationMetadata();
final Object revocationEndpoint = configurationMetadata.get(REVOCATION_ENDPOINT);
return revocationEndpoint == null ? null : URI.create(revocationEndpoint.toString());
}
}

View File

@ -0,0 +1,44 @@
/*
* 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.revocation;
import java.util.Objects;
/**
* OAuth2 Token Revocation Request as described in RFC 7009 Section 2.1
*/
public class TokenRevocationRequest {
private final String token;
private final String tokenTypeHint;
public TokenRevocationRequest(
final String token,
final String tokenTypeHint
) {
this.token = Objects.requireNonNull(token, "Token required");
this.tokenTypeHint = tokenTypeHint;
}
public String getToken() {
return token;
}
public String getTokenTypeHint() {
return tokenTypeHint;
}
}

View File

@ -0,0 +1,39 @@
/*
* 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.revocation;
/**
* OAuth2 Token Revocation Request as described in RFC 7009 Section 2.1
*/
public class TokenRevocationResponse {
private final boolean success;
private final int statusCode;
public TokenRevocationResponse(final boolean success, final int statusCode) {
this.success = success;
this.statusCode = statusCode;
}
public boolean isSuccess() {
return success;
}
public int getStatusCode() {
return statusCode;
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.revocation;
/**
* Abstraction for processing Token Revocation requests according to RFC 7009
*/
public interface TokenRevocationResponseClient {
/**
* Get Token Revocation Response based on Revocation Request
*
* @param revocationRequest Revocation Request is required
* @return Token Revocation Response
*/
TokenRevocationResponse getRevocationResponse(TokenRevocationRequest revocationRequest);
}

View File

@ -0,0 +1,36 @@
/*
* 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.revocation;
/**
* Token Type Hint values as defined in RFC 7009 Section 2.1
*/
public enum TokenTypeHint {
ACCESS_TOKEN("access_token"),
REFRESH_TOKEN("refresh_token");
private final String hint;
TokenTypeHint(final String hint) {
this.hint = hint;
}
public String getHint() {
return hint;
}
}

View File

@ -0,0 +1,201 @@
/*
* 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.web.authentication;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.authorization.util.IdentityMapping;
import org.apache.nifi.authorization.util.IdentityMappingUtil;
import org.apache.nifi.idp.IdpType;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.cookie.ApplicationCookieService;
import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.oidc.OidcConfigurationException;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.apache.nifi.web.util.RequestUriBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* OpenID Connect Authentication Success Handler redirects to the user interface and sets a Session Cookie with a Bearer Token
*/
public class OidcAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private static final String UI_PATH = "/nifi/";
private static final String ROOT_PATH = "/";
private final ApplicationCookieService applicationCookieService = new StandardApplicationCookieService();
private final BearerTokenProvider bearerTokenProvider;
private final IdpUserGroupService idpUserGroupService;
private final List<IdentityMapping> userIdentityMappings;
private final List<IdentityMapping> groupIdentityMappings;
private final List<String> userClaimNames;
private final String groupsClaimName;
/**
* OpenID Connect Authentication Success Handler requires Bearer Token Provider and expiration for generated tokens
*
* @param bearerTokenProvider Bearer Token Provider
* @param idpUserGroupService User Group Service for persisting groups from the Identity Provider
* @param userIdentityMappings User Identity Mappings
* @param groupIdentityMappings Group Identity Mappings
* @param userClaimNames Claim Names for User Identity
* @param groupsClaimName Claim Name for Groups contained in ID Token or null when not configured
*/
public OidcAuthenticationSuccessHandler(
final BearerTokenProvider bearerTokenProvider,
final IdpUserGroupService idpUserGroupService,
final List<IdentityMapping> userIdentityMappings,
final List<IdentityMapping> groupIdentityMappings,
final List<String> userClaimNames,
final String groupsClaimName
) {
this.bearerTokenProvider = Objects.requireNonNull(bearerTokenProvider, "Bearer Token Provider required");
this.idpUserGroupService = Objects.requireNonNull(idpUserGroupService, "User Group Service required");
this.userIdentityMappings = Objects.requireNonNull(userIdentityMappings, "User Identity Mappings required");
this.groupIdentityMappings = Objects.requireNonNull(groupIdentityMappings, "Group Identity Mappings required");
this.userClaimNames = Objects.requireNonNull(userClaimNames, "User Claim Names required");
this.groupsClaimName = groupsClaimName;
}
/**
* Determine Redirect Target URL based on Request URL and add Session Cookie containing a Bearer Token
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @param authentication OpenID Connect Authentication
* @return Redirect Target URL
*/
@Override
public String determineTargetUrl(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) {
final URI resourceUri = RequestUriBuilder.fromHttpServletRequest(request).path(ROOT_PATH).build();
processAuthentication(response, authentication, resourceUri);
final URI targetUri = RequestUriBuilder.fromHttpServletRequest(request).path(UI_PATH).build();
return targetUri.toString();
}
private void processAuthentication(final HttpServletResponse response, final Authentication authentication, final URI resourceUri) {
final OAuth2AuthenticationToken authenticationToken = getAuthenticationToken(authentication);
final OidcUser oidcUser = getOidcUser(authenticationToken);
final String identity = getIdentity(oidcUser);
final Set<String> groups = getGroups(oidcUser);
idpUserGroupService.replaceUserGroups(identity, IdpType.OIDC, groups);
final OAuth2AccessToken accessToken = getAccessToken(authenticationToken);
final String bearerToken = getBearerToken(identity, oidcUser, accessToken);
applicationCookieService.addSessionCookie(resourceUri, response, ApplicationCookieName.AUTHORIZATION_BEARER, bearerToken);
}
private String getBearerToken(final String identity, final OidcUser oidcUser, final OAuth2AccessToken accessToken) {
final long sessionExpiration = getSessionExpiration(accessToken);
final String issuer = oidcUser.getIssuer().toString();
final LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(identity, identity, sessionExpiration, issuer);
return bearerTokenProvider.getBearerToken(loginAuthenticationToken);
}
private long getSessionExpiration(final OAuth2Token token) {
final Instant tokenExpiration = token.getExpiresAt();
if (tokenExpiration == null) {
throw new IllegalArgumentException("Token expiration claim not found");
}
final Instant tokenIssued = token.getIssuedAt();
if (tokenIssued == null) {
throw new IllegalArgumentException("Token issued claim not found");
}
final Duration expiration = Duration.between(tokenIssued, tokenExpiration);
return expiration.toMillis();
}
private OAuth2AuthenticationToken getAuthenticationToken(final Authentication authentication) {
if (authentication instanceof OAuth2AuthenticationToken) {
return (OAuth2AuthenticationToken) authentication;
} else {
final String message = String.format("OAuth2AuthenticationToken not found [%s]", authentication.getClass());
throw new IllegalArgumentException(message);
}
}
private OAuth2AccessToken getAccessToken(final OAuth2AuthenticationToken authenticationToken) {
final Object credentials = authenticationToken.getCredentials();
if (credentials instanceof OAuth2AccessToken) {
return (OAuth2AccessToken) credentials;
} else {
final String message = String.format("OAuth2AccessToken not found in credentials [%s]", credentials.getClass());
throw new IllegalArgumentException(message);
}
}
private OidcUser getOidcUser(final OAuth2AuthenticationToken authenticationToken) {
final OAuth2User principalUser = authenticationToken.getPrincipal();
if (principalUser instanceof OidcUser) {
return (OidcUser) principalUser;
} else {
final String message = String.format("OpenID Connect User not found [%s]", principalUser.getClass());
throw new IllegalArgumentException(message);
}
}
private String getIdentity(final OidcUser oidcUser) {
final Optional<String> userNameFound = userClaimNames.stream()
.map(oidcUser::getClaimAsString)
.filter(Objects::nonNull)
.findFirst();
final String identity = userNameFound.orElseThrow(() -> {
final String message = String.format("User Identity not found in Token Claims %s", userClaimNames);
return new OidcConfigurationException(message);
});
return IdentityMappingUtil.mapIdentity(identity, userIdentityMappings);
}
private Set<String> getGroups(final OidcUser oidcUser) {
final Set<String> groups;
if (groupsClaimName == null || groupsClaimName.isEmpty()) {
groups = Collections.emptySet();
} else {
final List<String> groupsFound = oidcUser.getClaimAsStringList(groupsClaimName);
final List<String> claimGroups = groupsFound == null ? Collections.emptyList() : groupsFound;
groups = claimGroups.stream()
.map(group -> IdentityMappingUtil.mapIdentity(group, groupIdentityMappings))
.collect(Collectors.toSet());
}
return groups;
}
}

View File

@ -16,51 +16,17 @@
*/
package org.apache.nifi.web.security.saml2.web.authentication.logout;
import org.apache.nifi.web.security.logout.StandardLogoutFilter;
import org.apache.nifi.web.security.saml2.SamlUrlPath;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* SAML 2 Logout Filter completes application Logout Requests
*/
public class Saml2LocalLogoutFilter extends OncePerRequestFilter {
private final AntPathRequestMatcher requestMatcher = new AntPathRequestMatcher(SamlUrlPath.LOCAL_LOGOUT_REQUEST.getPath());
private final LogoutSuccessHandler logoutSuccessHandler;
public class Saml2LocalLogoutFilter extends StandardLogoutFilter {
public Saml2LocalLogoutFilter(
final LogoutSuccessHandler logoutSuccessHandler
) {
this.logoutSuccessHandler = logoutSuccessHandler;
}
/**
* Call Logout Success Handler when request path matches
*
* @param request HTTP Servlet Request
* @param response HTTP Servlet Response
* @param filterChain Filter Chain
* @throws ServletException Thrown on FilterChain.doFilter() failures
* @throws IOException Thrown on FilterChain.doFilter() failures
*/
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
if (requestMatcher.matches(request)) {
final SecurityContext securityContext = SecurityContextHolder.getContext();
final Authentication authentication = securityContext.getAuthentication();
logoutSuccessHandler.onLogoutSuccess(request, response, authentication);
} else {
filterChain.doFilter(request, response);
}
super(new AntPathRequestMatcher(SamlUrlPath.LOCAL_LOGOUT_REQUEST.getPath()), logoutSuccessHandler);
}
}

View File

@ -22,6 +22,7 @@ import org.apache.nifi.web.security.cookie.StandardApplicationCookieService;
import org.apache.nifi.web.security.logout.LogoutRequest;
import org.apache.nifi.web.security.logout.LogoutRequestManager;
import org.apache.nifi.web.security.saml2.SamlUrlPath;
import org.apache.nifi.web.security.token.LogoutAuthenticationToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;

View File

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.web.security.saml2.web.authentication.logout;
package org.apache.nifi.web.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
@ -22,7 +22,7 @@ import org.springframework.security.core.authority.AuthorityUtils;
import java.util.Objects;
/**
* Logout Authentication Token for processing Logout Requests using Spring Security SAML 2 handlers
* Logout Authentication Token for processing Logout Requests using Spring Security handlers
*/
public class LogoutAuthenticationToken extends AbstractAuthenticationToken {
private final String name;

View File

@ -1,52 +0,0 @@
/*
* 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.util;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
public class IdentityProviderUtils {
/**
* Generates a value to use as State in an identity provider login sequence. 128 bits is considered cryptographically strong
* with current hardware/software, but a Base32 digit needs 5 bits to be fully encoded, so 128 is rounded up to 130. Base32
* is chosen because it encodes data with a single case and without including confusing or URI-incompatible characters,
* unlike Base64, but is approximately 20% more compact than Base16/hexadecimal
*
* @return the state value
*/
public static String generateStateValue() {
return new BigInteger(130, new SecureRandom()).toString(32);
}
/**
* Implements a time constant equality check. If either value is null, false is returned.
*
* @param value1 value1
* @param value2 value2
* @return if value1 equals value2
*/
public static boolean timeConstantEqualityCheck(final String value1, final String value2) {
if (value1 == null || value2 == null) {
return false;
}
return MessageDigest.isEqual(value1.getBytes(StandardCharsets.UTF_8), value2.getBytes(StandardCharsets.UTF_8));
}
}

View File

@ -1,167 +0,0 @@
/*
* 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 org.apache.nifi.util.NiFiProperties
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertNull
class OidcServiceGroovyTest {
private static final Logger logger = LoggerFactory.getLogger(OidcServiceGroovyTest.class)
private static final Map<String, Object> DEFAULT_NIFI_PROPERTIES = [
"nifi.security.user.oidc.discovery.url" : "https://localhost/oidc",
"nifi.security.user.login.identity.provider" : "provider",
"nifi.security.user.knox.url" : "url",
"nifi.security.user.oidc.connect.timeout" : "1000",
"nifi.security.user.oidc.read.timeout" : "1000",
"nifi.security.user.oidc.client.id" : "expected_client_id",
"nifi.security.user.oidc.client.secret" : "expected_client_secret",
"nifi.security.user.oidc.claim.identifying.user" : "username",
"nifi.security.user.oidc.preferred.jwsalgorithm" : ""
]
// Mock collaborators
private static NiFiProperties mockNiFiProperties
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"
@BeforeAll
static void setUpOnce() throws Exception {
logger.metaClass.methodMissing = { String name, args ->
logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
}
}
@BeforeEach
void setUp() throws Exception {
mockNiFiProperties = buildNiFiProperties()
soip = new StandardOidcIdentityProvider(mockNiFiProperties)
}
private static NiFiProperties buildNiFiProperties(Map<String, Object> props = [:]) {
def combinedProps = DEFAULT_NIFI_PROPERTIES + props
new NiFiProperties(combinedProps)
}
@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}")
assertEquals(MOCK_JWT, cachedJwt)
}
@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
assertEquals(MOCK_JWT, retrievedJwt)
}
@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
assertNull(retrievedJwt)
}
private static StandardOidcIdentityProvider buildIdentityProviderWithMockInitializedProvider(Map<String, String> additionalProperties = [:]) {
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(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

@ -1,732 +0,0 @@
/*
* 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.jose.JWSAlgorithm
import com.nimbusds.jwt.JWT
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.PlainJWT
import com.nimbusds.oauth2.sdk.AuthorizationCode
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic
import com.nimbusds.oauth2.sdk.auth.ClientSecretPost
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.id.Issuer
import com.nimbusds.oauth2.sdk.token.AccessToken
import com.nimbusds.oauth2.sdk.token.BearerAccessToken
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 net.minidev.json.JSONObject
import org.apache.commons.codec.binary.Base64
import org.apache.nifi.util.NiFiProperties
import org.apache.nifi.util.StringUtils
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertNotNull
import static org.junit.jupiter.api.Assertions.assertNull
import static org.junit.jupiter.api.Assertions.assertThrows
import static org.junit.jupiter.api.Assertions.assertTrue
class StandardOidcIdentityProviderGroovyTest {
private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProviderGroovyTest.class)
private static final Map<String, Object> DEFAULT_NIFI_PROPERTIES = [
"nifi.security.user.oidc.discovery.url" : "https://localhost/oidc",
"nifi.security.user.login.identity.provider" : "provider",
"nifi.security.user.knox.url" : "url",
"nifi.security.user.oidc.connect.timeout" : "1000",
"nifi.security.user.oidc.read.timeout" : "1000",
"nifi.security.user.oidc.client.id" : "expected_client_id",
"nifi.security.user.oidc.client.secret" : "expected_client_secret",
"nifi.security.user.oidc.claim.identifying.user" : "username",
"nifi.security.user.oidc.preferred.jwsalgorithm" : ""
]
// Mock collaborators
private static NiFiProperties mockNiFiProperties
private static final String MOCK_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZ" +
"SI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZp" +
"X3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY19" +
"0ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw"
@BeforeAll
static void setUpOnce() throws Exception {
logger.metaClass.methodMissing = { String name, args ->
logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
}
}
@BeforeEach
void setUp() throws Exception {
mockNiFiProperties = buildNiFiProperties()
}
private static NiFiProperties buildNiFiProperties(Map<String, Object> props = [:]) {
def combinedProps = DEFAULT_NIFI_PROPERTIES + props
new NiFiProperties(combinedProps)
}
@Test
void testShouldGetAvailableClaims() {
// Arrange
final Map<String, String> EXPECTED_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",
"empty_claim" : ""
]
final List<String> POPULATED_CLAIM_NAMES = EXPECTED_CLAIMS.findAll { k, v -> StringUtils.isNotBlank(v) }.keySet().sort()
JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(EXPECTED_CLAIMS)
// Act
def definedClaims = StandardOidcIdentityProvider.getAvailableClaims(mockJWTClaimsSet)
logger.info("Defined claims: ${definedClaims}")
// Assert
assertEquals(POPULATED_CLAIM_NAMES, definedClaims)
}
@Test
void testShouldCreateClientAuthenticationFromPost() {
// Arrange
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
// Set Authorization Method
soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_POST]
final List<ClientAuthenticationMethod> mockAuthMethod = soip.oidcProviderMetadata["tokenEndpointAuthMethods"]
logger.info("Provided Auth Method: ${mockAuthMethod}")
// Define expected values
final ClientID CLIENT_ID = new ClientID("expected_client_id")
final Secret CLIENT_SECRET = new Secret("expected_client_secret")
// Inject into OIP
soip.clientId = CLIENT_ID
soip.clientSecret = CLIENT_SECRET
final ClientAuthentication EXPECTED_CLIENT_AUTHENTICATION = new ClientSecretPost(CLIENT_ID, CLIENT_SECRET)
// Act
def clientAuthentication = soip.createClientAuthentication()
logger.info("Client Auth properties: ${clientAuthentication.getProperties()}")
// Assert
assertEquals(EXPECTED_CLIENT_AUTHENTICATION.getClientID(), clientAuthentication.getClientID())
logger.info("Client secret: ${(clientAuthentication as ClientSecretPost).clientSecret.value}")
assertEquals(((ClientSecretPost) EXPECTED_CLIENT_AUTHENTICATION).getClientSecret(), ((ClientSecretPost) clientAuthentication).getClientSecret())
}
@Test
void testShouldCreateClientAuthenticationFromBasic() {
// Arrange
// Mock collaborators
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
// Set Auth Method
soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
final List<ClientAuthenticationMethod> mockAuthMethod = soip.oidcProviderMetadata["tokenEndpointAuthMethods"]
logger.info("Provided Auth Method: ${mockAuthMethod}")
// Define expected values
final ClientID CLIENT_ID = new ClientID("expected_client_id")
final Secret CLIENT_SECRET = new Secret("expected_client_secret")
// Inject into OIP
soip.clientId = CLIENT_ID
soip.clientSecret = CLIENT_SECRET
final ClientAuthentication EXPECTED_CLIENT_AUTHENTICATION = new ClientSecretBasic(CLIENT_ID, CLIENT_SECRET)
// Act
def clientAuthentication = soip.createClientAuthentication()
logger.info("Client authentication properties: ${clientAuthentication.properties}")
// Assert
assertEquals(EXPECTED_CLIENT_AUTHENTICATION.getClientID(), clientAuthentication.getClientID())
assertEquals(EXPECTED_CLIENT_AUTHENTICATION.getMethod(), clientAuthentication.getMethod())
logger.info("Client secret: ${(clientAuthentication as ClientSecretBasic).clientSecret.value}")
assertEquals(EXPECTED_CLIENT_AUTHENTICATION.clientSecret, (clientAuthentication as ClientSecretBasic).getClientSecret())
}
@Test
void testShouldCreateTokenHTTPRequest() {
// Arrange
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
// Mock AuthorizationGrant
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
AuthorizationCode mockCode = new AuthorizationCode("ABCDE")
def mockAuthGrant = new AuthorizationCodeGrant(mockCode, mockURI)
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
// Set OIDC Provider metadata attributes
final ClientID CLIENT_ID = new ClientID("expected_client_id")
final Secret CLIENT_SECRET = new Secret("expected_client_secret")
// Inject into OIP
soip.clientId = CLIENT_ID
soip.clientSecret = CLIENT_SECRET
soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
soip.oidcProviderMetadata["tokenEndpointURI"] = new URI("https://localhost/token")
// Mock ClientAuthentication
def clientAuthentication = soip.createClientAuthentication()
// Act
def httpRequest = soip.createTokenHTTPRequest(mockAuthGrant, clientAuthentication)
logger.info("HTTP Request: ${httpRequest.dump()}")
logger.info("Query: ${URLDecoder.decode(httpRequest.query, "UTF-8")}")
// Assert
assertEquals("POST", httpRequest.getMethod().name())
assertTrue(httpRequest.query.contains("code=" + mockCode.value))
String encodedUri = URLEncoder.encode("https://localhost/oidc", "UTF-8")
assertTrue(httpRequest.query.contains("redirect_uri="+encodedUri+"&grant_type=authorization_code"))
}
@Test
void testShouldLookupIdentityInUserInfo() {
// Arrange
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
final String EXPECTED_IDENTITY = "my_username"
def responseBody = [username: EXPECTED_IDENTITY, sub: "testSub"]
HTTPRequest mockUserInfoRequest = mockHttpRequest(responseBody, 200, "HTTP OK")
// Act
String identity = soip.lookupIdentityInUserInfo(mockUserInfoRequest)
logger.info("Identity: ${identity}")
// Assert
assertEquals(EXPECTED_IDENTITY, identity)
}
@Test
void testLookupIdentityUserInfoShouldHandleMissingIdentity() {
// Arrange
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
def responseBody = [username: "", sub: "testSub"]
HTTPRequest mockUserInfoRequest = mockHttpRequest(responseBody, 200, "HTTP NO USER")
// Act
IllegalStateException ise = assertThrows(IllegalStateException.class, () -> soip.lookupIdentityInUserInfo(mockUserInfoRequest))
assertTrue(ise.getMessage().contains("Unable to extract identity from the UserInfo token using the claim 'username'."))
}
@Test
void testLookupIdentityUserInfoShouldHandle500() {
// Arrange
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNiFiProperties)
Issuer mockIssuer = new Issuer("https://localhost/oidc")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
def errorBody = [error : "Failure to authenticate",
error_description: "The provided username and password were not correct",
error_uri : "https://localhost/oidc/error"]
HTTPRequest mockUserInfoRequest = mockHttpRequest(errorBody, 500, "HTTP ERROR")
RuntimeException re = assertThrows(RuntimeException.class, () -> soip.lookupIdentityInUserInfo(mockUserInfoRequest))
assertTrue(re.getMessage().contains("An error occurred while invoking the UserInfo endpoint: The provided username and password " +
"were not correct"))
}
@Test
void testShouldConvertOIDCTokenToLoginAuthenticationToken() {
// Arrange
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "email"])
OIDCTokenResponse mockResponse = mockOIDCTokenResponse()
logger.info("OIDC Token Response: ${mockResponse.dump()}")
// Act
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}")
assertTrue(contents.contains("LoginAuthenticationToken for person@nifi.apache.org issued by https://accounts.issuer.com expiring at"))
// Assert expiration
final String[] expList = expiration.split("\\s")
final Long expLong = Long.parseLong(expList[0])
assertTrue(expLong <= System.currentTimeMillis() + 10_000)
}
@Test
void testConvertOIDCTokenToLoginAuthenticationTokenShouldHandleBlankIdentity() {
// Arrange
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim"])
OIDCTokenResponse mockResponse = mockOIDCTokenResponse()
logger.info("OIDC Token Response: ${mockResponse.dump()}")
// Act
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}")
assertTrue(contents.contains("LoginAuthenticationToken for person@nifi.apache.org issued by https://accounts.issuer.com expiring at"))
// Get the expiration
final ArrayList<String> expires = expiration.split("[\\D*]")
final long exp = Long.parseLong(expires[0])
logger.info("exp: ${exp} ms")
assertTrue(exp <= System.currentTimeMillis() + 10_000)
}
@Test
void testConvertOIDCTokenToLoginAuthenticationTokenShouldHandleNoEmailClaimHasFallbackClaims() {
// Arrange
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(
["nifi.security.user.oidc.claim.identifying.user": "email",
"nifi.security.user.oidc.fallback.claims.identifying.user": "upn" ])
String expectedUpn = "xxx@aaddomain"
OIDCTokenResponse mockResponse = mockOIDCTokenResponse(["email": null, "upn": expectedUpn])
logger.info("OIDC Token Response with no email and upn: ${mockResponse.dump()}")
String loginToken = soip.convertOIDCTokenToLoginAuthenticationToken(mockResponse)
logger.info("NiFi token create with upn: ${loginToken}")
// Assert
// Split JWT into components and decode Base64 to JSON
def (String contents, String expiration) = loginToken.tokenize("\\[\\]")
logger.info("Token contents: ${contents} | Expiration: ${expiration}")
assertTrue(contents.contains("LoginAuthenticationToken for " + expectedUpn + " issued by https://accounts.issuer.com expiring at"))
}
@Test
void testAuthorizeClientRequestShouldHandleError() {
// Arrange
// Build ID Provider with mock token endpoint URI to make a connection
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:])
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
RuntimeException re = assertThrows(RuntimeException.class, () -> soip.authorizeClientRequest(mockTokenRequest))
// Assert
assertTrue(re.getMessage().contains("An error occurred while invoking the Token endpoint: null"))
}
@Test
void testConvertOIDCTokenToLoginAuthNTokenShouldHandleBlankIdentityAndNoEmailClaim() {
// Arrange
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim", "getOidcFallbackClaimsIdentifyingUser": [] ])
OIDCTokenResponse mockResponse = mockOIDCTokenResponse(["email": null])
logger.info("OIDC Token Response: ${mockResponse.dump()}")
// Act
assertThrows(IOException.class, () -> soip.convertOIDCTokenToLoginAuthenticationToken(mockResponse))
}
@Test
void testShouldAuthorizeClientRequest() {
// Arrange
// Build ID Provider with mock token endpoint URI to make a connection
StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:])
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 tokenResponse = soip.authorizeClientRequest(mockTokenRequest)
logger.info("Token Response: ${tokenResponse.dump()}")
// Assert
assertNotNull(tokenResponse)
}
@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
assertNotNull(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
assertNull(accessTokenString)
}
@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
Exception e = assertThrows(Exception.class, () -> soip.validateAccessToken(mockOidcTokens))
// Assert
assertTrue(e.getMessage().contains("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
assertNotNull(idTokenString)
assertEquals(EXPECTED_ID_TOKEN, idTokenString)
// 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)
assertTrue(payloadString.contains("\"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
assertNotNull(claimsSet)
assertTrue(claimsSetString.contains("\"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
assertNotNull(oidcTokens)
// Assert ID Tokens match
final JSONObject oidcJson = oidcTokens.toJSONObject()
final String idToken = oidcJson["id_token"]
logger.info("ID Token String: ${idToken}")
assertEquals(EXPECTED_ID_TOKEN, idToken)
}
private StandardOidcIdentityProvider buildIdentityProviderWithMockTokenValidator(Map<String, String> additionalProperties = [:]) {
NiFiProperties mockNFP = buildNiFiProperties(additionalProperties)
StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockNFP)
// Mock OIDC provider metadata
Issuer mockIssuer = new Issuer("mockIssuer")
URI mockURI = new URI("https://localhost/oidc")
OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
soip.oidcProviderMetadata = metadata
// Set OIDC Provider metadata attributes
final ClientID CLIENT_ID = new ClientID("expected_client_id")
final Secret CLIENT_SECRET = new Secret("expected_client_secret")
// Inject into OIP
soip.clientId = CLIENT_ID
soip.clientSecret = CLIENT_SECRET
soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
soip.oidcProviderMetadata["tokenEndpointURI"] = new URI("https://localhost/oidc/token")
soip.oidcProviderMetadata["userInfoEndpointURI"] = new URI("https://localhost/oidc/userInfo")
// Mock token validator
IDTokenValidator mockTokenValidator = new IDTokenValidator(mockIssuer, CLIENT_ID) {
@Override
IDTokenClaimsSet validate(JWT jwt, Nonce nonce) {
return new IDTokenClaimsSet(jwt.getJWTClaimsSet())
}
}
soip.tokenValidator = mockTokenValidator
soip
}
private OIDCTokenResponse mockOIDCTokenResponse(Map<String, Object> additionalClaims = [:]) {
Map<String, Object> claims = mockClaims(additionalClaims)
// Create Claims Set
JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims)
// Create JWT
JWT mockJwt = new PlainJWT(mockJWTClaimsSet)
// Mock access tokens
AccessToken mockAccessToken = new BearerAccessToken()
RefreshToken mockRefreshToken = new RefreshToken()
// Create OIDC Tokens
OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken)
// Create OIDC Token Response
OIDCTokenResponse mockResponse = new OIDCTokenResponse(mockOidcTokens)
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.
*
* @param body the JSON body in Map form
* @param statusCode the HTTP status code
* @param status the HTTP status message
* @param headers an optional map of HTTP response headers
* @param method the HTTP method to mock
* @param url the endpoint URL
* @return the static HTTP response
*/
private static HTTPRequest mockHttpRequest(def body,
int statusCode = 200,
String status = "HTTP Response",
Map<String, String> headers = [:],
HTTPRequest.Method method = HTTPRequest.Method.GET,
URL url = new URL("https://localhost/oidc")) {
new HTTPRequest(method, url) {
HTTPResponse send() {
HTTPResponse mockResponse = new HTTPResponse(statusCode)
mockResponse.setStatusMessage(status)
(["Content-Type": "application/json"] + headers).each { String h, String v -> mockResponse.setHeader(h, v) }
def responseBody = body
mockResponse.setContent(JsonOutput.toJson(responseBody))
mockResponse
}
}
}
}

View File

@ -1,157 +0,0 @@
/*
* 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.AuthorizationCode;
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.id.State;
import java.net.URI;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class OidcServiceTest {
public static final String TEST_REQUEST_IDENTIFIER = "test-request-identifier";
public static final String TEST_STATE = "test-state";
@Test
public void testOidcNotEnabledCreateState() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () -> service.createState(TEST_REQUEST_IDENTIFIER));
}
@Test
public void testCreateStateMultipleInvocations() {
final OidcService service = getServiceWithOidcSupport();
service.createState(TEST_REQUEST_IDENTIFIER);
assertThrows(IllegalStateException.class, () -> service.createState(TEST_REQUEST_IDENTIFIER));
}
@Test
public void testOidcNotEnabledValidateState() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () -> service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE)));
}
@Test
public void testOidcUnknownState() {
final OidcService service = getServiceWithOidcSupport();
assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE)));
}
@Test
public void testValidateState() {
final OidcService service = getServiceWithOidcSupport();
final State state = service.createState(TEST_REQUEST_IDENTIFIER);
assertTrue(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
}
@Test
public void testValidateStateExpiration() throws Exception {
final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS);
final State state = service.createState(TEST_REQUEST_IDENTIFIER);
Thread.sleep(3 * 1000);
assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
}
@Test
public void testStoreJwtMultipleInvocation() {
final OidcService service = getServiceWithOidcSupport();
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);
assertThrows(IllegalStateException.class, () -> service.storeJwt(TEST_REQUEST_IDENTIFIER, TEST_JWT2));
}
@Test
public void testOidcNotEnabledExchangeCodeForLoginAuthenticationToken() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () -> service.exchangeAuthorizationCodeForLoginAuthenticationToken(getAuthorizationGrant()));
}
@Test
public void testOidcNotEnabledExchangeCodeForAccessToken() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () ->service.exchangeAuthorizationCodeForAccessToken(getAuthorizationGrant()));
}
@Test
public void testOidcNotEnabledExchangeCodeForIdToken() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () -> service.exchangeAuthorizationCodeForIdToken(getAuthorizationGrant()));
}
@Test
public void testOidcNotEnabledGetJwt() {
final OidcService service = getServiceWithNoOidcSupport();
assertThrows(IllegalStateException.class, () -> service.getJwt(TEST_REQUEST_IDENTIFIER));
}
private OidcService getServiceWithNoOidcSupport() {
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
when(provider.isOidcEnabled()).thenReturn(false);
final OidcService service = new OidcService(provider);
assertFalse(service.isOidcEnabled());
return service;
}
private OidcService getServiceWithOidcSupport() {
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
when(provider.isOidcEnabled()).thenReturn(true);
final OidcService service = new OidcService(provider);
assertTrue(service.isOidcEnabled());
return service;
}
private OidcService getServiceWithOidcSupportAndCustomExpiration(final int duration, final TimeUnit units) throws Exception {
final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
when(provider.isOidcEnabled()).thenReturn(true);
when(provider.exchangeAuthorizationCodeforLoginAuthenticationToken(any())).then(invocation -> UUID.randomUUID().toString());
final OidcService service = new OidcService(provider, duration, units);
assertTrue(service.isOidcEnabled());
return service;
}
private AuthorizationGrant getAuthorizationGrant() {
return new AuthorizationCodeGrant(new AuthorizationCode("code"), URI.create("http://localhost:8080/nifi"));
}
}

View File

@ -1,82 +0,0 @@
/*
* 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.Scope;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.nifi.util.NiFiProperties;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class StandardOidcIdentityProviderTest {
@Test
public void testValidateScopes() throws IllegalAccessException {
final String additionalScope_profile = "profile";
final String additionalScope_abc = "abc";
final StandardOidcIdentityProvider provider = createOidcProviderWithAdditionalScopes(additionalScope_profile,
additionalScope_abc);
Scope scope = provider.getScope();
// two additional scopes are set, two (openid, email) are hard-coded
assertEquals(4, scope.toArray().length);
assertTrue(scope.contains("openid"));
assertTrue(scope.contains("email"));
assertTrue(scope.contains(additionalScope_profile));
assertTrue(scope.contains(additionalScope_abc));
}
@Test
public void testNoDuplicatedScopes() throws IllegalAccessException {
final String additionalScopeDuplicate = "abc";
final StandardOidcIdentityProvider provider = createOidcProviderWithAdditionalScopes(additionalScopeDuplicate,
"def", additionalScopeDuplicate);
Scope scope = provider.getScope();
// three additional scopes are set but one is duplicated and mustn't be returned; note that there is
// another one inserted in between the duplicated; two (openid, email) are hard-coded
assertEquals(4, scope.toArray().length);
}
private StandardOidcIdentityProvider createOidcProviderWithAdditionalScopes(String... additionalScopes) throws IllegalAccessException {
final StandardOidcIdentityProvider provider = mock(StandardOidcIdentityProvider.class);
NiFiProperties properties = createNiFiPropertiesMockWithAdditionalScopes(Arrays.asList(additionalScopes));
Field propertiesField = FieldUtils.getDeclaredField(StandardOidcIdentityProvider.class, "properties", true);
propertiesField.set(provider, properties);
when(provider.isOidcEnabled()).thenReturn(true);
when(provider.getScope()).thenCallRealMethod();
return provider;
}
private NiFiProperties createNiFiPropertiesMockWithAdditionalScopes(List<String> additionalScopes) {
NiFiProperties properties = mock(NiFiProperties.class);
when(properties.getOidcAdditionalScopes()).thenReturn(additionalScopes);
return properties;
}
}

View File

@ -0,0 +1,248 @@
/*
* 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.client.web;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OidcBearerTokenRefreshFilterTest {
private static final String CLIENT_ID = "client-id";
private static final String REDIRECT_URI = "http://localhost:8080";
private static final String AUTHORIZATION_URI = "http://localhost/authorize";
private static final String TOKEN_URI = "http://localhost/token";
private static final String SUB_CLAIM = "sub";
private static final String IDENTITY = "user-identity";
private static final Duration REFRESH_WINDOW = Duration.ZERO;
private static final String CURRENT_USER_URI = "/flow/current-user";
private static final String BEARER_TOKEN = "bearer-token";
private static final String BEARER_TOKEN_REFRESHED = "bearer-token-refreshed";
private static final String ACCESS_TOKEN = "access-token";
private static final String REFRESH_TOKEN = "refresh-token";
private static final String ID_TOKEN = "id-token";
private static final String EXP_CLAIM = "exp";
private static final int INSTANT_OFFSET = 1;
@Mock
BearerTokenProvider bearerTokenProvider;
@Mock
BearerTokenResolver bearerTokenResolver;
@Mock
JwtDecoder jwtDecoder;
@Mock
OAuth2AuthorizedClientRepository authorizedClientRepository;
@Mock
OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> refreshTokenResponseClient;
@Mock
OidcAuthorizedClient authorizedClient;
MockHttpServletRequest request;
MockHttpServletResponse response;
MockFilterChain filterChain;
OidcBearerTokenRefreshFilter filter;
@BeforeEach
void setFilter() {
filter = new OidcBearerTokenRefreshFilter(
REFRESH_WINDOW,
bearerTokenProvider,
bearerTokenResolver,
jwtDecoder,
authorizedClientRepository,
refreshTokenResponseClient
);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
filterChain = new MockFilterChain();
}
@Test
void testDoFilterPathNotMatched() throws ServletException, IOException {
filter.doFilter(request, response, filterChain);
verifyNoInteractions(bearerTokenResolver);
}
@Test
void testDoFilterBearerTokenNotFound() throws ServletException, IOException {
request.setServletPath(CURRENT_USER_URI);
filter.doFilter(request, response, filterChain);
verify(bearerTokenResolver).resolve(eq(request));
}
@Test
void testDoFilterBearerTokenFoundRefreshNotRequired() throws ServletException, IOException {
request.setServletPath(CURRENT_USER_URI);
when(bearerTokenResolver.resolve(eq(request))).thenReturn(BEARER_TOKEN);
final Jwt jwt = getJwt(Instant.MAX);
when(jwtDecoder.decode(eq(BEARER_TOKEN))).thenReturn(jwt);
filter.doFilter(request, response, filterChain);
verifyNoInteractions(authorizedClientRepository);
}
@Test
void testDoFilterRefreshRequiredClientNotFound() throws ServletException, IOException {
request.setServletPath(CURRENT_USER_URI);
when(bearerTokenResolver.resolve(eq(request))).thenReturn(BEARER_TOKEN);
final Jwt jwt = getJwt(Instant.now().minusSeconds(INSTANT_OFFSET));
when(jwtDecoder.decode(eq(BEARER_TOKEN))).thenReturn(jwt);
when(authorizedClientRepository.loadAuthorizedClient(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()), any(), eq(request))).thenReturn(null);
filter.doFilter(request, response, filterChain);
final Cookie cookie = response.getCookie(ApplicationCookieName.AUTHORIZATION_BEARER.getCookieName());
assertNull(cookie);
}
@Test
void testDoFilterRefreshRequiredRefreshTokenNotFound() throws ServletException, IOException {
request.setServletPath(CURRENT_USER_URI);
when(bearerTokenResolver.resolve(eq(request))).thenReturn(BEARER_TOKEN);
final Jwt jwt = getJwt(Instant.now().minusSeconds(INSTANT_OFFSET));
when(jwtDecoder.decode(eq(BEARER_TOKEN))).thenReturn(jwt);
when(authorizedClientRepository.loadAuthorizedClient(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()), any(), eq(request))).thenReturn(authorizedClient);
filter.doFilter(request, response, filterChain);
final Cookie cookie = response.getCookie(ApplicationCookieName.AUTHORIZATION_BEARER.getCookieName());
assertNull(cookie);
}
@Test
void testDoFilterBearerTokenTokenRefreshed() throws ServletException, IOException {
request.setServletPath(CURRENT_USER_URI);
when(bearerTokenResolver.resolve(eq(request))).thenReturn(BEARER_TOKEN);
final Instant expiration = Instant.now().minusSeconds(INSTANT_OFFSET);
final Jwt jwt = getJwt(expiration);
when(jwtDecoder.decode(eq(BEARER_TOKEN))).thenReturn(jwt);
final Instant issued = expiration.minus(Duration.ofHours(INSTANT_OFFSET));
final OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issued, expiration);
final OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(REFRESH_TOKEN, issued);
final Map<String, Object> claims = Collections.singletonMap(SUB_CLAIM, IDENTITY);
final OidcIdToken idToken = new OidcIdToken(ID_TOKEN, issued, expiration, claims);
when(authorizedClient.getAccessToken()).thenReturn(accessToken);
when(authorizedClient.getRefreshToken()).thenReturn(refreshToken);
when(authorizedClient.getIdToken()).thenReturn(idToken);
when(authorizedClient.getClientRegistration()).thenReturn(getClientRegistration());
when(authorizedClient.getPrincipalName()).thenReturn(IDENTITY);
when(authorizedClientRepository.loadAuthorizedClient(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()), any(), eq(request))).thenReturn(authorizedClient);
final OAuth2AccessTokenResponse tokenResponse = OAuth2AccessTokenResponse.withToken(ACCESS_TOKEN)
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.expiresIn(INSTANT_OFFSET)
.build();
when(refreshTokenResponseClient.getTokenResponse(any())).thenReturn(tokenResponse);
when(bearerTokenProvider.getBearerToken(any())).thenReturn(BEARER_TOKEN_REFRESHED);
filter.doFilter(request, response, filterChain);
final Cookie cookie = response.getCookie(ApplicationCookieName.AUTHORIZATION_BEARER.getCookieName());
assertNotNull(cookie);
assertEquals(BEARER_TOKEN_REFRESHED, cookie.getValue());
}
private Jwt getJwt(final Instant expiration) {
final Map<String, Object> claims = Collections.singletonMap(EXP_CLAIM, expiration);
final Instant issued = expiration.minus(Duration.ofHours(INSTANT_OFFSET));
return new Jwt(BEARER_TOKEN, issued, expiration, claims, claims);
}
ClientRegistration getClientRegistration() {
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId(CLIENT_ID)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.userNameAttributeName(SUB_CLAIM)
.build();
}
}

View File

@ -0,0 +1,144 @@
/*
* 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.client.web;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cache.Cache;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import javax.servlet.http.Cookie;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StandardAuthorizationRequestRepositoryTest {
private static final String AUTHORIZATION_REQUEST_URI = "http://localhost/authorize";
private static final String CLIENT_ID = "client-id";
private static final int MAX_AGE_EXPIRED = 0;
@Captor
ArgumentCaptor<String> identifierCaptor;
@Mock
Cache cache;
MockHttpServletRequest httpServletRequest;
MockHttpServletResponse httpServletResponse;
StandardAuthorizationRequestRepository repository;
@BeforeEach
void setRepository() {
repository = new StandardAuthorizationRequestRepository(cache);
httpServletRequest = new MockHttpServletRequest();
httpServletResponse = new MockHttpServletResponse();
}
@Test
void testLoadAuthorizationRequestNotFound() {
final OAuth2AuthorizationRequest authorizationRequest = repository.loadAuthorizationRequest(httpServletRequest);
assertNull(authorizationRequest);
}
@Test
void testLoadAuthorizationRequestFound() {
final String identifier = UUID.randomUUID().toString();
httpServletRequest.setCookies(new Cookie(ApplicationCookieName.OIDC_REQUEST_IDENTIFIER.getCookieName(), identifier));
final OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest();
when(cache.get(eq(identifier), eq(OAuth2AuthorizationRequest.class))).thenReturn(authorizationRequest);
final OAuth2AuthorizationRequest loadedAuthorizationRequest = repository.loadAuthorizationRequest(httpServletRequest);
assertEquals(authorizationRequest, loadedAuthorizationRequest);
}
@Test
void testRemoveAuthorizationRequestNotFound() {
final OAuth2AuthorizationRequest authorizationRequest = repository.removeAuthorizationRequest(httpServletRequest);
assertNull(authorizationRequest);
}
@Test
void testRemoveAuthorizationRequestFound() {
final String identifier = UUID.randomUUID().toString();
httpServletRequest.setCookies(new Cookie(ApplicationCookieName.OIDC_REQUEST_IDENTIFIER.getCookieName(), identifier));
final OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest();
when(cache.get(eq(identifier), eq(OAuth2AuthorizationRequest.class))).thenReturn(authorizationRequest);
final OAuth2AuthorizationRequest removedAuthorizationRequest = repository.removeAuthorizationRequest(httpServletRequest, httpServletResponse);
assertEquals(authorizationRequest, removedAuthorizationRequest);
verify(cache).evict(identifierCaptor.capture());
final String evictedIdentifier = identifierCaptor.getValue();
assertEquals(identifier, evictedIdentifier);
final Cookie cookie = httpServletResponse.getCookie(ApplicationCookieName.OIDC_REQUEST_IDENTIFIER.getCookieName());
assertNotNull(cookie);
assertEquals(MAX_AGE_EXPIRED, cookie.getMaxAge());
}
@Test
void testSaveAuthorizationRequest() {
final OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest();
repository.saveAuthorizationRequest(authorizationRequest, httpServletRequest, httpServletResponse);
verify(cache).put(identifierCaptor.capture(), eq(authorizationRequest));
final String identifier = identifierCaptor.getValue();
assertCookieFound(identifier);
}
private void assertCookieFound(final String identifier) {
final Cookie cookie = httpServletResponse.getCookie(ApplicationCookieName.OIDC_REQUEST_IDENTIFIER.getCookieName());
assertNotNull(cookie);
final String cookieValue = cookie.getValue();
assertEquals(identifier, cookieValue);
}
private OAuth2AuthorizationRequest getAuthorizationRequest() {
return OAuth2AuthorizationRequest.authorizationCode()
.authorizationRequestUri(AUTHORIZATION_REQUEST_URI)
.authorizationUri(AUTHORIZATION_REQUEST_URI)
.clientId(CLIENT_ID)
.build();
}
}

View File

@ -0,0 +1,222 @@
/*
* 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.client.web;
import org.apache.nifi.components.state.Scope;
import org.apache.nifi.components.state.StateManager;
import org.apache.nifi.components.state.StateMap;
import org.apache.nifi.web.security.oidc.client.web.converter.AuthorizedClientConverter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StandardOidcAuthorizedClientRepositoryTest {
private static final String REGISTRATION_ID = OidcRegistrationProperty.REGISTRATION_ID.getProperty();
private static final String IDENTITY = "user-identity";
private static final String ENCODED_CLIENT = "encoded-client";
private static final String CLIENT_ID = "client-id";
private static final String REDIRECT_URI = "http://localhost:8080";
private static final String AUTHORIZATION_URI = "http://localhost/authorize";
private static final String TOKEN_URI = "http://localhost/token";
private static final String TOKEN = "token";
private static final int EXPIRES_OFFSET = 60;
private static final Scope SCOPE = Scope.LOCAL;
@Mock
StateManager stateManager;
@Mock
StateMap stateMap;
@Mock
AuthorizedClientConverter authorizedClientConverter;
@Mock
OidcAuthorizedClient authorizedClient;
@Captor
ArgumentCaptor<Map<String, String>> stateMapCaptor;
MockHttpServletRequest request;
MockHttpServletResponse response;
StandardOidcAuthorizedClientRepository repository;
@BeforeEach
void setRepository() {
repository = new StandardOidcAuthorizedClientRepository(stateManager, authorizedClientConverter);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
}
@Test
void testLoadAuthorizedClientNotFound() throws IOException {
final Authentication principal = mock(Authentication.class);
when(principal.getName()).thenReturn(IDENTITY);
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
final OidcAuthorizedClient authorizedClient = repository.loadAuthorizedClient(REGISTRATION_ID, principal, request);
assertNull(authorizedClient);
}
@Test
void testLoadAuthorizedClientFound() throws IOException {
final Authentication principal = mock(Authentication.class);
when(principal.getName()).thenReturn(IDENTITY);
when(stateMap.get(eq(IDENTITY))).thenReturn(ENCODED_CLIENT);
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
when(authorizedClientConverter.getDecoded(eq(ENCODED_CLIENT))).thenReturn(authorizedClient);
final OidcAuthorizedClient authorizedClientFound = repository.loadAuthorizedClient(REGISTRATION_ID, principal, request);
assertEquals(authorizedClient, authorizedClientFound);
}
@Test
void testSaveAuthorizedClient() throws IOException {
final OAuth2AuthenticationToken principal = mock(OAuth2AuthenticationToken.class);
final OidcUser oidcUser = mock(OidcUser.class);
final OidcIdToken idToken = mock(OidcIdToken.class);
final OAuth2AccessToken accessToken = mock(OAuth2AccessToken.class);
when(principal.getName()).thenReturn(IDENTITY);
when(principal.getPrincipal()).thenReturn(oidcUser);
when(oidcUser.getIdToken()).thenReturn(idToken);
when(authorizedClient.getClientRegistration()).thenReturn(getClientRegistration());
when(authorizedClient.getPrincipalName()).thenReturn(IDENTITY);
when(authorizedClient.getAccessToken()).thenReturn(accessToken);
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
when(authorizedClientConverter.getEncoded(isA(OidcAuthorizedClient.class))).thenReturn(ENCODED_CLIENT);
repository.saveAuthorizedClient(authorizedClient, principal, request, response);
verify(authorizedClientConverter).getEncoded(isA(OidcAuthorizedClient.class));
verify(stateManager).setState(stateMapCaptor.capture(), eq(SCOPE));
final Map<String, String> updatedStateMap = stateMapCaptor.getValue();
final String encodedClient = updatedStateMap.get(IDENTITY);
assertEquals(ENCODED_CLIENT, encodedClient);
}
@Test
void testRemoveAuthorizedClient() throws IOException {
final Authentication principal = mock(Authentication.class);
when(principal.getName()).thenReturn(IDENTITY);
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
repository.removeAuthorizedClient(REGISTRATION_ID, principal, request, response);
verify(stateManager).setState(stateMapCaptor.capture(), eq(SCOPE));
final Map<String, String> updatedStateMap = stateMapCaptor.getValue();
assertTrue(updatedStateMap.isEmpty());
}
@Test
void testRemoveAuthorizedClientStateManagerException() throws IOException {
final Authentication principal = mock(Authentication.class);
when(principal.getName()).thenReturn(IDENTITY);
when(stateManager.getState(eq(SCOPE))).thenThrow(new IOException());
assertDoesNotThrow(() -> repository.removeAuthorizedClient(REGISTRATION_ID, principal, request, response));
}
@Test
void testDeleteExpiredEmpty() throws IOException {
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
final List<OidcAuthorizedClient> deletedAuthorizedClients = repository.deleteExpired();
assertTrue(deletedAuthorizedClients.isEmpty());
}
@Test
void testDeleteExpired() throws IOException {
final Map<String, String> currentStateMap = new LinkedHashMap<>();
currentStateMap.put(IDENTITY, ENCODED_CLIENT);
when(stateMap.toMap()).thenReturn(currentStateMap);
when(stateManager.getState(eq(SCOPE))).thenReturn(stateMap);
final Instant issuedAt = Instant.MIN;
final Instant expiresAt = Instant.now().minusSeconds(EXPIRES_OFFSET);
final OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, TOKEN, issuedAt, expiresAt);
when(authorizedClient.getAccessToken()).thenReturn(accessToken);
when(authorizedClientConverter.getDecoded(eq(ENCODED_CLIENT))).thenReturn(authorizedClient);
final List<OidcAuthorizedClient> deletedAuthorizedClients = repository.deleteExpired();
assertFalse(deletedAuthorizedClients.isEmpty());
final OidcAuthorizedClient deletedAuthorizedClient = deletedAuthorizedClients.iterator().next();
assertEquals(authorizedClient, deletedAuthorizedClient);
}
ClientRegistration getClientRegistration() {
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId(CLIENT_ID)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.build();
}
}

View File

@ -0,0 +1,183 @@
/*
* 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.client.web.converter;
import org.apache.nifi.encrypt.PropertyEncryptor;
import org.apache.nifi.web.security.jwt.provider.SupportedClaim;
import org.apache.nifi.web.security.logout.LogoutRequest;
import org.apache.nifi.web.security.oidc.client.web.OidcAuthorizedClient;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StandardAuthorizedClientConverterTest {
private static final String CLIENT_ID = "client-id";
private static final String REDIRECT_URI = "http://localhost:8080";
private static final String AUTHORIZATION_URI = "http://localhost/authorize";
private static final String TOKEN_URI = "http://localhost/token";
private static final String USER_IDENTITY = LogoutRequest.class.getSimpleName();
private static final String ACCESS_TOKEN = "access";
private static final String REFRESH_TOKEN = "refresh";
private static final String ID_TOKEN = "id";
@Mock
ClientRegistrationRepository clientRegistrationRepository;
StandardAuthorizedClientConverter converter;
@BeforeEach
void setConverter() {
converter = new StandardAuthorizedClientConverter(new StringPropertyEncryptor(), clientRegistrationRepository);
}
@Test
void testGetEncoded() {
final OidcAuthorizedClient oidcAuthorizedClient = getOidcAuthorizedClient(new OAuth2RefreshToken(REFRESH_TOKEN, Instant.now()));
final String encoded = converter.getEncoded(oidcAuthorizedClient);
assertNotNull(encoded);
}
@Test
void testGetDecodedInvalid() {
final OidcAuthorizedClient oidcAuthorizedClient = converter.getDecoded(String.class.getName());
assertNull(oidcAuthorizedClient);
}
@Test
void testGetEncodedDecoded() {
final OidcAuthorizedClient oidcAuthorizedClient = getOidcAuthorizedClient(new OAuth2RefreshToken(REFRESH_TOKEN, Instant.now()));
final String encoded = converter.getEncoded(oidcAuthorizedClient);
assertNotNull(encoded);
final ClientRegistration clientRegistration = getClientRegistration();
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
final OidcAuthorizedClient decoded = converter.getDecoded(encoded);
assertEquals(decoded.getClientRegistration().getRedirectUri(), clientRegistration.getRedirectUri());
assertAuthorizedClientEquals(oidcAuthorizedClient, decoded);
}
@Test
void testGetEncodedDecodedNullRefreshToken() {
final OidcAuthorizedClient oidcAuthorizedClient = getOidcAuthorizedClient(null);
final String encoded = converter.getEncoded(oidcAuthorizedClient);
assertNotNull(encoded);
final ClientRegistration clientRegistration = getClientRegistration();
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
final OidcAuthorizedClient decoded = converter.getDecoded(encoded);
assertEquals(decoded.getClientRegistration().getRedirectUri(), clientRegistration.getRedirectUri());
assertAuthorizedClientEquals(oidcAuthorizedClient, decoded);
}
void assertAuthorizedClientEquals(final OidcAuthorizedClient expected, final OidcAuthorizedClient actual) {
assertNotNull(actual);
assertEquals(expected.getPrincipalName(), actual.getPrincipalName());
assertEquals(expected.getAccessToken().getTokenValue(), actual.getAccessToken().getTokenValue());
assertEquals(expected.getAccessToken().getExpiresAt(), actual.getAccessToken().getExpiresAt());
final OidcIdToken idToken = actual.getIdToken();
assertEquals(expected.getIdToken().getTokenValue(), idToken.getTokenValue());
assertEquals(expected.getIdToken().getExpiresAt(), idToken.getExpiresAt());
assertEquals(USER_IDENTITY, idToken.getSubject());
final OAuth2RefreshToken expectedRefreshToken = expected.getRefreshToken();
if (expectedRefreshToken == null) {
assertNull(actual.getRefreshToken());
} else {
final OAuth2RefreshToken actualRefreshToken = actual.getRefreshToken();
assertNotNull(actualRefreshToken);
assertEquals(expectedRefreshToken.getTokenValue(), actualRefreshToken.getTokenValue());
assertEquals(expectedRefreshToken.getExpiresAt(), actualRefreshToken.getExpiresAt());
}
}
OidcAuthorizedClient getOidcAuthorizedClient(final OAuth2RefreshToken refreshToken) {
final Instant issuedAt = Instant.now();
final Instant expiresAt = Instant.MAX;
final ClientRegistration clientRegistration = getClientRegistration();
final OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issuedAt, expiresAt);
final Map<String, Object> claims = new LinkedHashMap<>();
claims.put(SupportedClaim.ISSUED_AT.getClaim(), issuedAt);
claims.put(SupportedClaim.EXPIRATION.getClaim(), expiresAt);
final OidcIdToken idToken = new OidcIdToken(ID_TOKEN, issuedAt, expiresAt, claims);
return new OidcAuthorizedClient(
clientRegistration,
USER_IDENTITY,
accessToken,
refreshToken,
idToken
);
}
ClientRegistration getClientRegistration() {
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId(CLIENT_ID)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.build();
}
private static class StringPropertyEncryptor implements PropertyEncryptor {
@Override
public String encrypt(String property) {
return property;
}
@Override
public String decrypt(String encryptedProperty) {
return encryptedProperty;
}
}
}

View File

@ -0,0 +1,261 @@
/*
* 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.logout;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.logout.LogoutRequest;
import org.apache.nifi.web.security.logout.LogoutRequestManager;
import org.apache.nifi.web.security.oidc.client.web.OidcAuthorizedClient;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationRequest;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponse;
import org.apache.nifi.web.security.oidc.revocation.TokenRevocationResponseClient;
import org.apache.nifi.web.security.oidc.revocation.TokenTypeHint;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import javax.servlet.http.Cookie;
import java.io.IOException;
import java.util.Collections;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OidcLogoutSuccessHandlerTest {
private static final String REQUEST_IDENTIFIER = UUID.randomUUID().toString();
private static final String CLIENT_ID = "client-id";
private static final String REDIRECT_URI = "http://localhost:8080";
private static final String AUTHORIZATION_URI = "http://localhost/authorize";
private static final String TOKEN_URI = "http://localhost/token";
private static final String END_SESSION_URI = "http://localhost/end_session";
private static final String USER_IDENTITY = LogoutRequest.class.getSimpleName();
private static final String REQUEST_URI = "/nifi-api";
private static final int SERVER_PORT = 8080;
private static final String REDIRECTED_URL = "http://localhost:8080/nifi/logout-complete";
private static final String ACCESS_TOKEN = "access-token";
private static final String REFRESH_TOKEN = "refresh-token";
private static final String ID_TOKEN = "oidc-id-token";
private static final String END_SESSION_REDIRECT_URL = String.format("%s?id_token_hint=%s&post_logout_redirect_uri=%s", END_SESSION_URI, ID_TOKEN, REDIRECTED_URL);
@Mock
IdpUserGroupService idpUserGroupService;
@Mock
ClientRegistrationRepository clientRegistrationRepository;
@Mock
OAuth2AuthorizedClientRepository authorizedClientRepository;
@Mock
Authentication authentication;
@Mock
OAuth2AccessToken accessToken;
@Mock
OAuth2RefreshToken refreshToken;
@Mock
OidcIdToken idToken;
@Mock
TokenRevocationResponseClient tokenRevocationResponseClient;
@Captor
ArgumentCaptor<TokenRevocationRequest> revocationRequestCaptor;
MockHttpServletRequest httpServletRequest;
MockHttpServletResponse httpServletResponse;
LogoutRequestManager logoutRequestManager;
OidcLogoutSuccessHandler handler;
@BeforeEach
void setHandler() {
logoutRequestManager = new LogoutRequestManager();
handler = new OidcLogoutSuccessHandler(
logoutRequestManager,
idpUserGroupService,
clientRegistrationRepository,
authorizedClientRepository,
tokenRevocationResponseClient
);
httpServletRequest = new MockHttpServletRequest();
httpServletRequest.setServerPort(SERVER_PORT);
httpServletResponse = new MockHttpServletResponse();
}
@Test
void testOnLogoutSuccessRequestNotFound() throws IOException {
setRequestCookie();
handler.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication);
final String redirectedUrl = httpServletResponse.getRedirectedUrl();
assertEquals(REDIRECTED_URL, redirectedUrl);
verifyNoInteractions(idpUserGroupService);
}
@Test
void testOnLogoutSuccessRequestFoundEndSessionNotSupported() throws IOException {
setRequestCookie();
startLogoutRequest();
final ClientRegistration clientRegistration = getClientRegistrationBuilder().build();
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
handler.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication);
final String redirectedUrl = httpServletResponse.getRedirectedUrl();
assertEquals(REDIRECTED_URL, redirectedUrl);
assertUserGroupAuthorizedClientRemoved();
}
@Test
void testOnLogoutSuccessRequestFoundEndSessionSupportedTokenNotFound() throws IOException {
setRequestCookie();
startLogoutRequest();
final Map<String, Object> configurationMetadata = Collections.singletonMap(OidcLogoutSuccessHandler.END_SESSION_ENDPOINT, END_SESSION_URI);
final ClientRegistration clientRegistration = getClientRegistrationBuilder().providerConfigurationMetadata(configurationMetadata).build();
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
handler.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication);
final String redirectedUrl = httpServletResponse.getRedirectedUrl();
assertEquals(REDIRECTED_URL, redirectedUrl);
assertUserGroupAuthorizedClientRemoved();
}
@Test
void testOnLogoutSuccessRequestFoundEndSessionSupportedTokenFound() throws IOException {
setRequestCookie();
startLogoutRequest();
final Map<String, Object> configurationMetadata = Collections.singletonMap(OidcLogoutSuccessHandler.END_SESSION_ENDPOINT, END_SESSION_URI);
final ClientRegistration clientRegistration = getClientRegistrationBuilder().providerConfigurationMetadata(configurationMetadata).build();
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
when(idToken.getTokenValue()).thenReturn(ID_TOKEN);
final OidcAuthorizedClient oidcAuthorizedClient = new OidcAuthorizedClient(
clientRegistration,
USER_IDENTITY,
accessToken,
refreshToken,
idToken
);
when(authorizedClientRepository.loadAuthorizedClient(
eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()),
isA(Authentication.class),
eq(httpServletRequest))
).thenReturn(oidcAuthorizedClient);
final TokenRevocationResponse revocationResponse = new TokenRevocationResponse(true, HttpStatus.OK.value());
when(tokenRevocationResponseClient.getRevocationResponse(any())).thenReturn(revocationResponse);
when(accessToken.getTokenValue()).thenReturn(ACCESS_TOKEN);
when(refreshToken.getTokenValue()).thenReturn(REFRESH_TOKEN);
handler.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication);
final String redirectedUrl = httpServletResponse.getRedirectedUrl();
assertEquals(END_SESSION_REDIRECT_URL, redirectedUrl);
assertUserGroupAuthorizedClientRemoved();
verify(authorizedClientRepository).removeAuthorizedClient(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()), any(), eq(httpServletRequest), eq(httpServletResponse));
verify(tokenRevocationResponseClient, times(2)).getRevocationResponse(revocationRequestCaptor.capture());
final Iterator<TokenRevocationRequest> revocationRequests = revocationRequestCaptor.getAllValues().iterator();
final TokenRevocationRequest firstRevocationRequest = revocationRequests.next();
assertEquals(TokenTypeHint.REFRESH_TOKEN.getHint(), firstRevocationRequest.getTokenTypeHint());
assertEquals(REFRESH_TOKEN, firstRevocationRequest.getToken());
final TokenRevocationRequest secondRevocationRequest = revocationRequests.next();
assertEquals(TokenTypeHint.ACCESS_TOKEN.getHint(), secondRevocationRequest.getTokenTypeHint());
assertEquals(ACCESS_TOKEN, secondRevocationRequest.getToken());
}
void assertUserGroupAuthorizedClientRemoved() {
verify(idpUserGroupService).deleteUserGroups(eq(USER_IDENTITY));
}
void setRequestCookie() {
final Cookie cookie = new Cookie(ApplicationCookieName.LOGOUT_REQUEST_IDENTIFIER.getCookieName(), REQUEST_IDENTIFIER);
httpServletRequest.setCookies(cookie);
httpServletRequest.setRequestURI(REQUEST_URI);
}
void startLogoutRequest() {
final LogoutRequest logoutRequest = new LogoutRequest(REQUEST_IDENTIFIER, USER_IDENTITY);
logoutRequestManager.start(logoutRequest);
}
ClientRegistration.Builder getClientRegistrationBuilder() {
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId(CLIENT_ID)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI);
}
}

View File

@ -0,0 +1,137 @@
/*
* 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.registration;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.id.Issuer;
import com.nimbusds.openid.connect.sdk.SubjectType;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.web.security.oidc.OidcConfigurationException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthenticationMethod;
import org.springframework.web.client.RestOperations;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StandardClientRegistrationProviderTest {
private static final String DISCOVERY_URL = "http://localhost/.well-known/openid-configuration";
private static final String ISSUER = "http://localhost";
private static final URI JWK_SET_URI = URI.create("http://localhost/jwks");
private static final URI TOKEN_ENDPOINT_URI = URI.create("http://localhost/oauth2/v1/token");
private static final URI USER_INFO_URI = URI.create("http://localhost/oauth2/v1/userinfo");
private static final URI AUTHORIZATION_ENDPOINT_URI = URI.create("http://localhost/oauth2/v1/authorize");
private static final String CLIENT_ID = "client-id";
private static final String CLIENT_SECRET = "client-secret";
private static final String USER_NAME_ATTRIBUTE_NAME = "email";
private static final String INVALID_CONFIGURATION = "{}";
@Mock
RestOperations restOperations;
@Test
void testGetClientRegistration() {
final NiFiProperties properties = getProperties();
final StandardClientRegistrationProvider provider = new StandardClientRegistrationProvider(properties, restOperations);
final OIDCProviderMetadata providerMetadata = getProviderMetadata();
final String serializedMetadata = providerMetadata.toString();
when(restOperations.getForObject(eq(DISCOVERY_URL), eq(String.class))).thenReturn(serializedMetadata);
final ClientRegistration clientRegistration = provider.getClientRegistration();
assertNotNull(clientRegistration);
assertEquals(CLIENT_ID, clientRegistration.getClientId());
assertEquals(CLIENT_SECRET, clientRegistration.getClientSecret());
final ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
assertEquals(ISSUER, providerDetails.getIssuerUri());
assertEquals(JWK_SET_URI.toString(), providerDetails.getJwkSetUri());
assertEquals(AUTHORIZATION_ENDPOINT_URI.toString(), providerDetails.getAuthorizationUri());
assertEquals(TOKEN_ENDPOINT_URI.toString(), providerDetails.getTokenUri());
final ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint = providerDetails.getUserInfoEndpoint();
assertEquals(USER_INFO_URI.toString(), userInfoEndpoint.getUri());
assertEquals(USER_NAME_ATTRIBUTE_NAME, userInfoEndpoint.getUserNameAttributeName());
assertEquals(AuthenticationMethod.HEADER, userInfoEndpoint.getAuthenticationMethod());
}
@Test
void testGetClientRegistrationRetrievalFailed() {
final NiFiProperties properties = getProperties();
final StandardClientRegistrationProvider provider = new StandardClientRegistrationProvider(properties, restOperations);
when(restOperations.getForObject(eq(DISCOVERY_URL), eq(String.class))).thenThrow(new RuntimeException());
assertThrows(OidcConfigurationException.class, provider::getClientRegistration);
}
@Test
void testGetClientRegistrationParsingFailed() {
final NiFiProperties properties = getProperties();
final StandardClientRegistrationProvider provider = new StandardClientRegistrationProvider(properties, restOperations);
when(restOperations.getForObject(eq(DISCOVERY_URL), eq(String.class))).thenReturn(INVALID_CONFIGURATION);
assertThrows(OidcConfigurationException.class, provider::getClientRegistration);
}
private NiFiProperties getProperties() {
final Properties properties = new Properties();
properties.put(NiFiProperties.SECURITY_USER_OIDC_DISCOVERY_URL, DISCOVERY_URL);
properties.put(NiFiProperties.SECURITY_USER_OIDC_CLIENT_ID, CLIENT_ID);
properties.put(NiFiProperties.SECURITY_USER_OIDC_CLIENT_SECRET, CLIENT_SECRET);
properties.put(NiFiProperties.SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER, USER_NAME_ATTRIBUTE_NAME);
return NiFiProperties.createBasicNiFiProperties(null, properties);
}
private OIDCProviderMetadata getProviderMetadata() {
final Issuer issuer = new Issuer(ISSUER);
final List<SubjectType> subjectTypes = Collections.singletonList(SubjectType.PUBLIC);
final OIDCProviderMetadata providerMetadata = new OIDCProviderMetadata(issuer, subjectTypes, JWK_SET_URI);
providerMetadata.setTokenEndpointURI(TOKEN_ENDPOINT_URI);
providerMetadata.setUserInfoEndpointURI(USER_INFO_URI);
providerMetadata.setAuthorizationEndpointURI(AUTHORIZATION_ENDPOINT_URI);
final Scope scopes = new Scope();
providerMetadata.setScopes(scopes);
return providerMetadata;
}
}

View File

@ -0,0 +1,129 @@
/*
* 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.revocation;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.web.client.RestOperations;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StandardTokenRevocationResponseClientTest {
private static final String CLIENT_ID = "client-id";
private static final String CLIENT_SECRET = "client-secret";
private static final String REDIRECT_URI = "http://localhost:8080";
private static final String AUTHORIZATION_URI = "http://localhost/authorize";
private static final String TOKEN_URI = "http://localhost/token";
private static final String REVOCATION_ENDPOINT_URI = "http://localhost/revoke";
private static final String TOKEN = "token";
private static final String TOKEN_TYPE_HINT = "refresh_token";
@Mock
RestOperations restOperations;
@Mock
ClientRegistrationRepository clientRegistrationRepository;
StandardTokenRevocationResponseClient client;
@BeforeEach
void setClient() {
client = new StandardTokenRevocationResponseClient(restOperations, clientRegistrationRepository);
}
@Test
void testGetRevocationResponseEndpointNotFound() {
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(TOKEN, TOKEN_TYPE_HINT);
final ClientRegistration clientRegistration = getClientRegistration(null);
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
final TokenRevocationResponse revocationResponse = client.getRevocationResponse(revocationRequest);
assertNotNull(revocationResponse);
assertTrue(revocationResponse.isSuccess());
}
@Test
void testGetRevocationResponseException() {
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(TOKEN, TOKEN_TYPE_HINT);
final ClientRegistration clientRegistration = getClientRegistration(REVOCATION_ENDPOINT_URI);
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
when(restOperations.exchange(any(), eq(String.class))).thenThrow(new RuntimeException());
final TokenRevocationResponse revocationResponse = client.getRevocationResponse(revocationRequest);
assertNotNull(revocationResponse);
assertFalse(revocationResponse.isSuccess());
}
@Test
void testGetRevocationResponse() {
final TokenRevocationRequest revocationRequest = new TokenRevocationRequest(TOKEN, TOKEN_TYPE_HINT);
final ClientRegistration clientRegistration = getClientRegistration(REVOCATION_ENDPOINT_URI);
when(clientRegistrationRepository.findByRegistrationId(eq(OidcRegistrationProperty.REGISTRATION_ID.getProperty()))).thenReturn(clientRegistration);
final ResponseEntity<String> responseEntity = ResponseEntity.ok().build();
when(restOperations.exchange(any(), eq(String.class))).thenReturn(responseEntity);
final TokenRevocationResponse revocationResponse = client.getRevocationResponse(revocationRequest);
assertNotNull(revocationResponse);
assertTrue(revocationResponse.isSuccess());
}
ClientRegistration getClientRegistration(final String revocationEndpoint) {
final Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put(StandardTokenRevocationResponseClient.REVOCATION_ENDPOINT, revocationEndpoint);
return ClientRegistration.withRegistrationId(OidcRegistrationProperty.REGISTRATION_ID.getProperty())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.redirectUri(REDIRECT_URI)
.authorizationUri(AUTHORIZATION_URI)
.tokenUri(TOKEN_URI)
.providerConfigurationMetadata(metadata)
.build();
}
}

View File

@ -0,0 +1,228 @@
/*
* 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.web.authentication;
import org.apache.nifi.admin.service.IdpUserGroupService;
import org.apache.nifi.authorization.util.IdentityMapping;
import org.apache.nifi.idp.IdpType;
import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.jwt.provider.BearerTokenProvider;
import org.apache.nifi.web.security.oidc.client.web.OidcRegistrationProperty;
import org.apache.nifi.web.security.oidc.client.web.converter.StandardOAuth2AuthenticationToken;
import org.apache.nifi.web.security.token.LoginAuthenticationToken;
import org.apache.nifi.web.util.WebUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import javax.servlet.ServletContext;
import javax.servlet.http.Cookie;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Collections;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OidcAuthenticationSuccessHandlerTest {
@Mock
OidcUser oidcUser;
@Mock
BearerTokenProvider bearerTokenProvider;
@Mock
IdpUserGroupService idpUserGroupService;
@Captor
ArgumentCaptor<LoginAuthenticationToken> authenticationTokenCaptor;
MockHttpServletRequest httpServletRequest;
MockHttpServletResponse httpServletResponse;
OidcAuthenticationSuccessHandler handler;
private static final String REQUEST_URI = "/nifi-api";
private static final String UI_PATH = "/nifi/";
private static final String ROOT_PATH = "/";
private static final int SERVER_PORT = 8080;
private static final String ISSUER = "http://localhost/issuer";
private static final String LOCALHOST_URL = "http://localhost:8080";
private static final String TARGET_URL = String.format("%s%s", LOCALHOST_URL, UI_PATH);
private static final String USER_NAME_CLAIM = "email";
private static final String GROUPS_CLAIM = "groups";
private static final String IDENTITY = Authentication.class.getSimpleName();
private static final String IDENTITY_UPPER = IDENTITY.toUpperCase();
private static final String AUTHORITY = GrantedAuthority.class.getSimpleName();
private static final String AUTHORITY_LOWER = AUTHORITY.toLowerCase();
private static final String ACCESS_TOKEN = "access-token";
private static final Duration TOKEN_EXPIRATION = Duration.ofHours(1);
private static final Instant ACCESS_TOKEN_ISSUED = Instant.ofEpochSecond(0);
private static final Instant ACCESS_TOKEN_EXPIRES = ACCESS_TOKEN_ISSUED.plus(TOKEN_EXPIRATION);
private static final String FIRST_GROUP = "$1";
private static final Pattern MATCH_PATTERN = Pattern.compile("(.*)");
static final String FORWARDED_PATH = "/forwarded";
static final String FORWARDED_COOKIE_PATH = String.format("%s/", FORWARDED_PATH);
private static final String FORWARDED_TARGET_URL = String.format("%s%s%s", LOCALHOST_URL, FORWARDED_PATH, UI_PATH);
private static final String ALLOWED_CONTEXT_PATHS_PARAMETER = "allowedContextPaths";
private static final IdentityMapping UPPER_IDENTITY_MAPPING = new IdentityMapping(
IdentityMapping.Transform.UPPER.toString(),
MATCH_PATTERN,
FIRST_GROUP,
IdentityMapping.Transform.UPPER
);
private static final IdentityMapping LOWER_IDENTITY_MAPPING = new IdentityMapping(
IdentityMapping.Transform.LOWER.toString(),
MATCH_PATTERN,
FIRST_GROUP,
IdentityMapping.Transform.LOWER
);
@BeforeEach
void setHandler() {
handler = new OidcAuthenticationSuccessHandler(
bearerTokenProvider,
idpUserGroupService,
Collections.singletonList(UPPER_IDENTITY_MAPPING),
Collections.singletonList(LOWER_IDENTITY_MAPPING),
Collections.singletonList(USER_NAME_CLAIM),
GROUPS_CLAIM
);
httpServletRequest = new MockHttpServletRequest();
httpServletRequest.setServerPort(SERVER_PORT);
httpServletResponse = new MockHttpServletResponse();
}
@Test
void testDetermineTargetUrl() {
httpServletRequest.setRequestURI(REQUEST_URI);
assertTargetUrlEquals(TARGET_URL);
assertBearerCookieAdded(ROOT_PATH);
assertReplaceUserGroupsInvoked();
}
@Test
void testDetermineTargetUrlForwardedPath() {
final ServletContext servletContext = httpServletRequest.getServletContext();
servletContext.setInitParameter(ALLOWED_CONTEXT_PATHS_PARAMETER, FORWARDED_PATH);
httpServletRequest.addHeader(WebUtils.FORWARDED_PREFIX_HTTP_HEADER, FORWARDED_PATH);
httpServletRequest.setRequestURI(REQUEST_URI);
assertTargetUrlEquals(FORWARDED_TARGET_URL);
assertBearerCookieAdded(FORWARDED_COOKIE_PATH);
assertReplaceUserGroupsInvoked();
}
void assertReplaceUserGroupsInvoked() {
verify(idpUserGroupService).replaceUserGroups(eq(IDENTITY_UPPER), eq(IdpType.OIDC), eq(Collections.singleton(AUTHORITY_LOWER)));
}
void assertTargetUrlEquals(final String expectedTargetUrl) {
setOidcUser();
final StandardOAuth2AuthenticationToken authentication = getAuthenticationToken();
final String targetUrl = handler.determineTargetUrl(httpServletRequest, httpServletResponse, authentication);
assertEquals(expectedTargetUrl, targetUrl);
}
void assertBearerCookieAdded(final String expectedCookiePath) {
final Cookie responseCookie = httpServletResponse.getCookie(ApplicationCookieName.AUTHORIZATION_BEARER.getCookieName());
assertNotNull(responseCookie);
assertEquals(expectedCookiePath, responseCookie.getPath());
verify(bearerTokenProvider).getBearerToken(authenticationTokenCaptor.capture());
final LoginAuthenticationToken authenticationToken = authenticationTokenCaptor.getValue();
final Instant expiration = Instant.ofEpochMilli(authenticationToken.getExpiration());
final ChronoUnit truncation = ChronoUnit.MINUTES;
final Instant expirationTruncated = expiration.truncatedTo(truncation);
final Instant expected = Instant.now().plus(TOKEN_EXPIRATION).truncatedTo(truncation);
assertEquals(expected, expirationTruncated);
}
void setOidcUser() {
when(oidcUser.getClaimAsString(eq(USER_NAME_CLAIM))).thenReturn(IDENTITY);
when(oidcUser.getClaimAsStringList(eq(GROUPS_CLAIM))).thenReturn(Collections.singletonList(AUTHORITY));
when(oidcUser.getIssuer()).thenReturn(getIssuer());
}
StandardOAuth2AuthenticationToken getAuthenticationToken() {
final OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, ACCESS_TOKEN_ISSUED, ACCESS_TOKEN_EXPIRES);
final Collection<? extends GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(AUTHORITY));
return new StandardOAuth2AuthenticationToken(oidcUser, authorities, OidcRegistrationProperty.REGISTRATION_ID.getProperty(), accessToken);
}
URL getIssuer() {
try {
return new URL(ISSUER);
} catch (final MalformedURLException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -58,7 +58,7 @@ class Saml2LocalLogoutFilterTest {
@Test
void testDoFilterInternalNotMatched() throws ServletException, IOException {
filter.doFilterInternal(httpServletRequest, httpServletResponse, filterChain);
filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
verifyNoInteractions(logoutSuccessHandler);
}
@ -66,7 +66,7 @@ class Saml2LocalLogoutFilterTest {
@Test
void testDoFilterInternal() throws ServletException, IOException {
httpServletRequest.setPathInfo(SamlUrlPath.LOCAL_LOGOUT_REQUEST.getPath());
filter.doFilterInternal(httpServletRequest, httpServletResponse, filterChain);
filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
verify(logoutSuccessHandler).onLogoutSuccess(eq(httpServletRequest), eq(httpServletResponse), isNull());
}

View File

@ -20,6 +20,7 @@ import org.apache.nifi.web.security.cookie.ApplicationCookieName;
import org.apache.nifi.web.security.logout.LogoutRequest;
import org.apache.nifi.web.security.logout.LogoutRequestManager;
import org.apache.nifi.web.security.saml2.SamlUrlPath;
import org.apache.nifi.web.security.token.LogoutAuthenticationToken;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

View File

@ -34,6 +34,8 @@ import java.net.URI;
* Filter for determining appropriate login location.
*/
public class LoginFilter implements Filter {
private static final String OAUTH2_AUTHORIZATION_PATH = "/nifi-api/oauth2/authorization/consumer";
private static final String SAML2_AUTHENTICATE_FILTER_PATH = "/nifi-api/saml2/authenticate/consumer";
private ServletContext servletContext;
@ -49,18 +51,20 @@ public class LoginFilter implements Filter {
final boolean supportsKnoxSso = Boolean.parseBoolean(servletContext.getInitParameter("knox-supported"));
final boolean supportsSAML = Boolean.parseBoolean(servletContext.getInitParameter("saml-supported"));
if (supportsOidc) {
final ServletContext apiContext = servletContext.getContext("/nifi-api");
apiContext.getRequestDispatcher("/access/oidc/request").forward(request, response);
} else if (supportsKnoxSso) {
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final RequestUriBuilder requestUriBuilder = RequestUriBuilder.fromHttpServletRequest(httpServletRequest);
if (supportsKnoxSso) {
final ServletContext apiContext = servletContext.getContext("/nifi-api");
apiContext.getRequestDispatcher("/access/knox/request").forward(request, response);
} else if (supportsOidc) {
final URI redirectUri = requestUriBuilder.path(OAUTH2_AUTHORIZATION_PATH).build();
// Redirect to authorization URL defined in Spring Security OAuth2AuthorizationRequestRedirectFilter
sendRedirect(response, redirectUri);
} else if (supportsSAML) {
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final URI authenticateUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(SAML2_AUTHENTICATE_FILTER_PATH).build();
final URI redirectUri = requestUriBuilder.path(SAML2_AUTHENTICATE_FILTER_PATH).build();
// Redirect to request consumer URL defined in Spring Security OpenSamlAuthenticationRequestResolver.requestMatcher
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(authenticateUri.toString());
sendRedirect(response, redirectUri);
} else {
filterChain.doFilter(request, response);
}
@ -69,4 +73,9 @@ public class LoginFilter implements Filter {
@Override
public void destroy() {
}
private void sendRedirect(final ServletResponse response, final URI redirectUri) throws IOException {
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(redirectUri.toString());
}
}

View File

@ -35,6 +35,12 @@ import java.net.URI;
*/
public class LogoutFilter implements Filter {
private static final String OIDC_LOGOUT_URL = "/nifi-api/access/oidc/logout";
private static final String SAML_LOCAL_LOGOUT_URL = "/nifi-api/access/saml/local-logout/request";
private static final String SAML_SINGLE_LOGOUT_URL = "/nifi-api/access/saml/single-logout/request";
private ServletContext servletContext;
@Override
@ -57,19 +63,13 @@ public class LogoutFilter implements Filter {
// to retrieve information about the user logging out.
if (supportsOidc) {
final ServletContext apiContext = servletContext.getContext("/nifi-api");
apiContext.getRequestDispatcher("/access/oidc/logout").forward(request, response);
sendRedirect(OIDC_LOGOUT_URL, request, response);
} else if (supportsKnoxSso) {
final ServletContext apiContext = servletContext.getContext("/nifi-api");
apiContext.getRequestDispatcher("/access/knox/logout").forward(request, response);
} else if (supportsSaml) {
// Redirect to request URL defined in nifi-web-api security filter configuration
final String logoutUrl = supportsSamlSingleLogout ? "/nifi-api/access/saml/single-logout/request" : "/nifi-api/access/saml/local-logout/request";
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final URI targetUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(logoutUrl).build();
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(targetUri.toString());
final String logoutUrl = supportsSamlSingleLogout ? SAML_SINGLE_LOGOUT_URL : SAML_LOCAL_LOGOUT_URL;
sendRedirect(logoutUrl, request, response);
} else {
final ServletContext apiContext = servletContext.getContext("/nifi-api");
apiContext.getRequestDispatcher("/access/logout/complete").forward(request, response);
@ -79,4 +79,11 @@ public class LogoutFilter implements Filter {
@Override
public void destroy() {
}
private void sendRedirect(final String logoutUrl, final ServletRequest request, final ServletResponse response) throws IOException {
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final URI targetUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(logoutUrl).build();
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(targetUri.toString());
}
}

View File

@ -113,7 +113,6 @@
currentUser: '../nifi-api/flow/current-user',
controllerBulletins: '../nifi-api/flow/controller/bulletins',
kerberos: '../nifi-api/access/kerberos',
oidc: '../nifi-api/access/oidc/exchange',
revision: '../nifi-api/flow/revision',
banners: '../nifi-api/flow/banners'
}
@ -904,27 +903,19 @@
successfulAuthentication(jwt);
}).fail(function () {
$.ajax({
type: 'POST',
url: config.urls.oidc,
dataType: 'text'
}).done(function (jwt) {
successfulAuthentication(jwt)
type: 'GET',
url: config.urls.accessTokenExpiration,
dataType: 'json'
}).done(function (accessTokenExpirationEntity) {
var accessTokenExpiration = accessTokenExpirationEntity.accessTokenExpiration;
// Convert ISO 8601 string to session expiration in seconds
var expiration = Date.parse(accessTokenExpiration.expiration);
var expirationSeconds = expiration / 1000;
var sessionExpiration = Math.round(expirationSeconds);
nfAuthorizationStorage.setToken(sessionExpiration);
deferred.resolve();
}).fail(function () {
$.ajax({
type: 'GET',
url: config.urls.accessTokenExpiration,
dataType: 'json'
}).done(function (accessTokenExpirationEntity) {
var accessTokenExpiration = accessTokenExpirationEntity.accessTokenExpiration;
// Convert ISO 8601 string to session expiration in seconds
var expiration = Date.parse(accessTokenExpiration.expiration);
var expirationSeconds = expiration / 1000;
var sessionExpiration = Math.round(expirationSeconds);
nfAuthorizationStorage.setToken(sessionExpiration);
deferred.resolve();
}).fail(function () {
deferred.reject();
});
deferred.reject();
});
});
}