From ff5a7839438b008321ed3940de86be01af56bacc Mon Sep 17 00:00:00 2001 From: exceptionfactory Date: Tue, 28 Mar 2023 07:35:10 -0500 Subject: [PATCH] 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 --- .../org/apache/nifi/util/NiFiProperties.java | 6 + .../main/asciidoc/administration-guide.adoc | 96 ++- .../nifi-framework/nifi-resources/pom.xml | 1 + .../src/main/resources/conf/nifi.properties | 1 + .../nifi/web/NiFiWebApiConfiguration.java | 7 +- .../nifi/web/NiFiWebApiResourceConfig.java | 1 - .../nifi/web/api/OIDCAccessResource.java | 578 -------------- .../main/resources/nifi-web-api-context.xml | 6 - .../nifi/web/api/OIDCAccessResourceTest.java | 159 ---- .../nifi-web/nifi-web-security/pom.xml | 4 + .../AuthenticationSecurityConfiguration.java | 2 +- ...wtAuthenticationSecurityConfiguration.java | 4 +- ...dcAuthenticationSecurityConfiguration.java | 49 -- .../OidcSecurityConfiguration.java | 516 ++++++++++++ .../WebSecurityConfiguration.java} | 45 +- .../security/logout/StandardLogoutFilter.java | 67 ++ ...s.java => OidcConfigurationException.java} | 26 +- .../security/oidc/OidcIdentityProvider.java | 105 --- .../nifi/web/security/oidc/OidcService.java | 270 ------- ...uststoreStrategy.java => OidcUrlPath.java} | 23 +- .../oidc/StandardOidcIdentityProvider.java | 631 --------------- .../AuthorizedClientExpirationCommand.java | 79 ++ .../oidc/client/web/OidcAuthorizedClient.java | 52 ++ .../web/OidcBearerTokenRefreshFilter.java | 232 ++++++ .../client/web/OidcRegistrationProperty.java | 35 + ...tandardAuthorizationRequestRepository.java | 137 ++++ ...tandardOidcAuthorizedClientRepository.java | 276 +++++++ .../TrackedAuthorizedClientRepository.java | 31 + .../AuthenticationResultConverter.java | 42 + .../web/converter/AuthorizedClient.java | 68 ++ .../converter/AuthorizedClientConverter.java | 40 + .../client/web/converter/AuthorizedToken.java | 60 ++ .../StandardAuthorizedClientConverter.java | 170 ++++ .../StandardOAuth2AuthenticationToken.java | 52 ++ .../oidc/logout/OidcLogoutFilter.java | 33 + .../oidc/logout/OidcLogoutSuccessHandler.java | 225 ++++++ .../ClientRegistrationProvider.java | 31 + .../DisabledClientRegistrationRepository.java | 31 + .../StandardClientRegistrationProvider.java | 138 ++++ ...StandardTokenRevocationResponseClient.java | 125 +++ .../revocation/TokenRevocationRequest.java | 44 ++ .../revocation/TokenRevocationResponse.java | 39 + .../TokenRevocationResponseClient.java | 30 + .../oidc/revocation/TokenTypeHint.java | 36 + .../OidcAuthenticationSuccessHandler.java | 201 +++++ .../logout/Saml2LocalLogoutFilter.java | 40 +- .../logout/Saml2SingleLogoutFilter.java | 1 + .../LogoutAuthenticationToken.java | 4 +- .../security/util/IdentityProviderUtils.java | 52 -- .../oidc/OidcServiceGroovyTest.groovy | 167 ---- ...ndardOidcIdentityProviderGroovyTest.groovy | 732 ------------------ .../web/security/oidc/OidcServiceTest.java | 157 ---- .../StandardOidcIdentityProviderTest.java | 82 -- .../web/OidcBearerTokenRefreshFilterTest.java | 248 ++++++ ...ardAuthorizationRequestRepositoryTest.java | 144 ++++ ...ardOidcAuthorizedClientRepositoryTest.java | 222 ++++++ ...StandardAuthorizedClientConverterTest.java | 183 +++++ .../logout/OidcLogoutSuccessHandlerTest.java | 261 +++++++ ...tandardClientRegistrationProviderTest.java | 137 ++++ ...dardTokenRevocationResponseClientTest.java | 129 +++ .../OidcAuthenticationSuccessHandlerTest.java | 228 ++++++ .../logout/Saml2LocalLogoutFilterTest.java | 4 +- .../logout/Saml2SingleLogoutFilterTest.java | 1 + .../apache/nifi/web/filter/LoginFilter.java | 25 +- .../apache/nifi/web/filter/LogoutFilter.java | 25 +- .../src/main/webapp/js/nf/canvas/nf-canvas.js | 33 +- 66 files changed, 4541 insertions(+), 3138 deletions(-) delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/OIDCAccessResourceTest.java delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcAuthenticationSecurityConfiguration.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java rename nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/{nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java => nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java} (78%) create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/StandardLogoutFilter.java rename nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/{OIDCEndpoints.java => OidcConfigurationException.java} (54%) delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java rename nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/{TruststoreStrategy.java => OidcUrlPath.java} (73%) delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/AuthorizedClientExpirationCommand.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcAuthorizedClient.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilter.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcRegistrationProperty.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepository.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepository.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/TrackedAuthorizedClientRepository.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthenticationResultConverter.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClient.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClientConverter.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedToken.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverter.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardOAuth2AuthenticationToken.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutFilter.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandler.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/ClientRegistrationProvider.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/DisabledClientRegistrationRepository.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClient.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationRequest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponse.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponseClient.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenTypeHint.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandler.java rename nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/{saml2/web/authentication/logout => token}/LogoutAuthenticationToken.java (93%) delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java delete mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilterTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepositoryTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepositoryTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverterTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandlerTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClientTest.java create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandlerTest.java diff --git a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java index 65b885d536..cb9f2dac58 100644 --- a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java +++ b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java @@ -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"; @@ -384,6 +385,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"; @@ -1178,6 +1180,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)); } diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc index 82fff7328c..fe836ec985 100644 --- a/nifi-docs/src/main/asciidoc/administration-guide.adoc +++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc @@ -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 <> 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 <>, <> and <>. @@ -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 <> 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 diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml index 9647a99107..034d399180 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml @@ -173,6 +173,7 @@ groups JDK + 60 secs diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties index 9c61d1bc19..498171b47f 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties @@ -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} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiConfiguration.java index 6e7bbf0b1c..c2f5f42ae7 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiConfiguration.java @@ -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", diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java index 2a8c29afe9..f5735202ec 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java @@ -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")); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java deleted file mode 100644 index 4547db202b..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OIDCAccessResource.java +++ /dev/null @@ -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 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 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 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 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 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 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 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; - } -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml index bea03aaf49..9cd20ce746 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml @@ -627,12 +627,6 @@ - - - - - - diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/OIDCAccessResourceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/OIDCAccessResourceTest.java deleted file mode 100644 index 3a870d3f6d..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/OIDCAccessResourceTest.java +++ /dev/null @@ -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; - } - - } -} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml index b46b2b116d..2e0f6adc70 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml @@ -236,6 +236,10 @@ org.springframework.security spring-security-oauth2-jose + + org.springframework.security + spring-security-oauth2-client + org.slf4j jcl-over-slf4j diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/AuthenticationSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/AuthenticationSecurityConfiguration.java index dd41344361..33d4d74717 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/AuthenticationSecurityConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/AuthenticationSecurityConfiguration.java @@ -37,7 +37,7 @@ import org.springframework.security.authentication.AuthenticationManager; JwtAuthenticationSecurityConfiguration.class, KerberosAuthenticationSecurityConfiguration.class, KnoxAuthenticationSecurityConfiguration.class, - OidcAuthenticationSecurityConfiguration.class, + OidcSecurityConfiguration.class, SamlAuthenticationSecurityConfiguration.class, X509AuthenticationSecurityConfiguration.class }) diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/JwtAuthenticationSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/JwtAuthenticationSecurityConfiguration.java index bcdf6afe52..911a7f767d 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/JwtAuthenticationSecurityConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/JwtAuthenticationSecurityConfiguration.java @@ -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; } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcAuthenticationSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcAuthenticationSecurityConfiguration.java deleted file mode 100644 index 06fbc035c4..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcAuthenticationSecurityConfiguration.java +++ /dev/null @@ -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()); - } -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java new file mode 100644 index 0000000000..ac37255590 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/OidcSecurityConfiguration.java @@ -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 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 authorizationRequestRepository() { + final Cache 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 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 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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java similarity index 78% rename from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java rename to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java index 2108b09abe..d9a7beb066 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/configuration/WebSecurityConfiguration.java @@ -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(); } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/StandardLogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/StandardLogoutFilter.java new file mode 100644 index 0000000000..e31278ec2d --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/StandardLogoutFilter.java @@ -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); + } + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OIDCEndpoints.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcConfigurationException.java similarity index 54% rename from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OIDCEndpoints.java rename to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcConfigurationException.java index 765b71e0fe..1ebf410a97 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OIDCEndpoints.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcConfigurationException.java @@ -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); + } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java deleted file mode 100644 index 8be9b6d687..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java +++ /dev/null @@ -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; -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java deleted file mode 100644 index 90f9d93126..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java +++ /dev/null @@ -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 stateLookupForPendingRequests; // identifier from cookie -> state value - private final Cache 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; - } - } - -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/TruststoreStrategy.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcUrlPath.java similarity index 73% rename from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/TruststoreStrategy.java rename to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcUrlPath.java index 43e1606ed8..65f4cea477 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/TruststoreStrategy.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcUrlPath.java @@ -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; + } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java deleted file mode 100644 index 82837ba75e..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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()); - } - } -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/AuthorizedClientExpirationCommand.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/AuthorizedClientExpirationCommand.java new file mode 100644 index 0000000000..e5763aadd2 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/AuthorizedClientExpirationCommand.java @@ -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 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 deleteExpired() { + try { + return trackedAuthorizedClientRepository.deleteExpired(); + } catch (final Exception e) { + logger.warn("Delete Expired Authorized Clients failed", e); + return Collections.emptyList(); + } + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcAuthorizedClient.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcAuthorizedClient.java new file mode 100644 index 0000000000..b75f1d1207 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcAuthorizedClient.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilter.java new file mode 100644 index 0000000000..e8f322d0d0 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilter.java @@ -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 refreshRequests = new ConcurrentHashMap<>(); + + private final BearerTokenProvider bearerTokenProvider; + + private final BearerTokenResolver bearerTokenResolver; + + private final JwtDecoder jwtDecoder; + + private final OAuth2AuthorizedClientRepository authorizedClientRepository; + + private final OAuth2AccessTokenResponseClient refreshTokenResponseClient; + + public OidcBearerTokenRefreshFilter( + final Duration refreshWindow, + final BearerTokenProvider bearerTokenProvider, + final BearerTokenResolver bearerTokenResolver, + final JwtDecoder jwtDecoder, + final OAuth2AuthorizedClientRepository authorizedClientRepository, + final OAuth2AccessTokenResponseClient 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()); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcRegistrationProperty.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcRegistrationProperty.java new file mode 100644 index 0000000000..49bc949c6d --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/OidcRegistrationProperty.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepository.java new file mode 100644 index 0000000000..ab8193b6fb --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepository.java @@ -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 { + 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 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 requestIdentifier = applicationCookieService.getCookieValue(request, ApplicationCookieName.OIDC_REQUEST_IDENTIFIER); + requestIdentifier.ifPresent(cache::evict); + } + return authorizationRequest; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepository.java new file mode 100644 index 0000000000..35bea43be2 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepository.java @@ -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 Authorized Client Type + */ + @SuppressWarnings("unchecked") + @Override + public 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 deleteExpired() { + final StateMap stateMap = getStateMap(); + final Map currentStateMap = stateMap.toMap(); + final Map updatedStateMap = new LinkedHashMap<>(); + + final List deletedAuthorizedClients = new ArrayList<>(); + + for (final Map.Entry 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> stateConsumer) { + try { + final StateMap stateMap = getStateMap(); + final Map currentStateMap = stateMap.toMap(); + final Map 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 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); + } + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/TrackedAuthorizedClientRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/TrackedAuthorizedClientRepository.java new file mode 100644 index 0000000000..aed7b0392a --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/TrackedAuthorizedClientRepository.java @@ -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 deleteExpired(); +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthenticationResultConverter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthenticationResultConverter.java new file mode 100644 index 0000000000..4cf86896d7 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthenticationResultConverter.java @@ -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 { + /** + * 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() + ); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClient.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClient.java new file mode 100644 index 0000000000..b29cc02edb --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClient.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClientConverter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClientConverter.java new file mode 100644 index 0000000000..79834b4b07 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedClientConverter.java @@ -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); +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedToken.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedToken.java new file mode 100644 index 0000000000..265eaf20c4 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/AuthorizedToken.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverter.java new file mode 100644 index 0000000000..34292fd461 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverter.java @@ -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 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 + ); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardOAuth2AuthenticationToken.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardOAuth2AuthenticationToken.java new file mode 100644 index 0000000000..0328ea7ba6 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardOAuth2AuthenticationToken.java @@ -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 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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutFilter.java new file mode 100644 index 0000000000..9093a06854 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutFilter.java @@ -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); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandler.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandler.java new file mode 100644 index 0000000000..661feb79d8 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandler.java @@ -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 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 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(); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/ClientRegistrationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/ClientRegistrationProvider.java new file mode 100644 index 0000000000..e6bb69ce27 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/ClientRegistrationProvider.java @@ -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(); +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/DisabledClientRegistrationRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/DisabledClientRegistrationRepository.java new file mode 100644 index 0000000000..9ea95ec0c4 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/DisabledClientRegistrationRepository.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java new file mode 100644 index 0000000000..bc14c4ba81 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProvider.java @@ -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 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 scope = new LinkedHashSet<>(metadataScope.toStringList()); + final List 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 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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClient.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClient.java new file mode 100644 index 0000000000..019dcfd25f --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClient.java @@ -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 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 configurationMetadata = providerDetails.getConfigurationMetadata(); + final Object revocationEndpoint = configurationMetadata.get(REVOCATION_ENDPOINT); + return revocationEndpoint == null ? null : URI.create(revocationEndpoint.toString()); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationRequest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationRequest.java new file mode 100644 index 0000000000..3e301c4f3f --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationRequest.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponse.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponse.java new file mode 100644 index 0000000000..58bc5380ff --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponse.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponseClient.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponseClient.java new file mode 100644 index 0000000000..2a60fd4c24 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenRevocationResponseClient.java @@ -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); +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenTypeHint.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenTypeHint.java new file mode 100644 index 0000000000..07ac39febf --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/revocation/TokenTypeHint.java @@ -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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandler.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..bd0967956f --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandler.java @@ -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 userIdentityMappings; + + private final List groupIdentityMappings; + + private final List 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 userIdentityMappings, + final List groupIdentityMappings, + final List 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 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 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 getGroups(final OidcUser oidcUser) { + final Set groups; + if (groupsClaimName == null || groupsClaimName.isEmpty()) { + groups = Collections.emptySet(); + } else { + final List groupsFound = oidcUser.getClaimAsStringList(groupsClaimName); + final List claimGroups = groupsFound == null ? Collections.emptyList() : groupsFound; + groups = claimGroups.stream() + .map(group -> IdentityMappingUtil.mapIdentity(group, groupIdentityMappings)) + .collect(Collectors.toSet()); + } + return groups; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilter.java index 6cfd210f5b..8c11232281 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilter.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilter.java @@ -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); } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilter.java index c44715ad1d..c20eb971c8 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilter.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilter.java @@ -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; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/LogoutAuthenticationToken.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/token/LogoutAuthenticationToken.java similarity index 93% rename from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/LogoutAuthenticationToken.java rename to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/token/LogoutAuthenticationToken.java index ded17216b4..cacb9f84d3 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml2/web/authentication/logout/LogoutAuthenticationToken.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/token/LogoutAuthenticationToken.java @@ -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; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java deleted file mode 100644 index e075c3b40c..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java +++ /dev/null @@ -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)); - } -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy deleted file mode 100644 index b99a2e3f93..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/OidcServiceGroovyTest.groovy +++ /dev/null @@ -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 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 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 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 - } -} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy deleted file mode 100644 index ff38744e47..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy +++ /dev/null @@ -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 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 props = [:]) { - def combinedProps = DEFAULT_NIFI_PROPERTIES + props - new NiFiProperties(combinedProps) - } - - @Test - void testShouldGetAvailableClaims() { - // Arrange - final Map 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 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 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 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 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 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 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 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 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 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 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 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 additionalClaims = [:]) { - Map 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 mockClaims(Map additionalClaims = [:]) { - final Map claims = [ - "iss" : "https://accounts.issuer.com", - "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", - "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.usercontent.com", - "sub" : "10703475345439756345540", - "email" : "person@nifi.apache.org", - "email_verified": "true", - "at_hash" : "JOGISUDHFiyGHDSFwV5Fah2A", - "iat" : 1590022674, - "exp" : 1590026274 - ] + additionalClaims - claims - } - - /** - * Forms an {@link HTTPRequest} object which returns a static response when {@code send( )} is called. - * - * @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 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 - } - } - } -} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java deleted file mode 100644 index 160d92eaeb..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/OidcServiceTest.java +++ /dev/null @@ -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")); - } -} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderTest.java deleted file mode 100644 index 662b90bfba..0000000000 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderTest.java +++ /dev/null @@ -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 additionalScopes) { - NiFiProperties properties = mock(NiFiProperties.class); - when(properties.getOidcAdditionalScopes()).thenReturn(additionalScopes); - return properties; - } -} \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilterTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilterTest.java new file mode 100644 index 0000000000..a3d2465168 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/OidcBearerTokenRefreshFilterTest.java @@ -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 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 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 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(); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepositoryTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepositoryTest.java new file mode 100644 index 0000000000..f1ac9b9db1 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardAuthorizationRequestRepositoryTest.java @@ -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 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(); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepositoryTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepositoryTest.java new file mode 100644 index 0000000000..6d9257ffbe --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/StandardOidcAuthorizedClientRepositoryTest.java @@ -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> 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 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 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 deletedAuthorizedClients = repository.deleteExpired(); + + assertTrue(deletedAuthorizedClients.isEmpty()); + } + + @Test + void testDeleteExpired() throws IOException { + final Map 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 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(); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverterTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverterTest.java new file mode 100644 index 0000000000..d0ae0e6e19 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/client/web/converter/StandardAuthorizedClientConverterTest.java @@ -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 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; + } + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandlerTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandlerTest.java new file mode 100644 index 0000000000..9537ca9e60 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/logout/OidcLogoutSuccessHandlerTest.java @@ -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 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 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 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 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); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java new file mode 100644 index 0000000000..97847926a1 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/registration/StandardClientRegistrationProviderTest.java @@ -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 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; + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClientTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClientTest.java new file mode 100644 index 0000000000..2e022d5536 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/revocation/StandardTokenRevocationResponseClientTest.java @@ -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 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 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(); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandlerTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandlerTest.java new file mode 100644 index 0000000000..557b79181d --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/oidc/web/authentication/OidcAuthenticationSuccessHandlerTest.java @@ -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 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 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); + } + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilterTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilterTest.java index 28ef76c375..dbde380970 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilterTest.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2LocalLogoutFilterTest.java @@ -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()); } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilterTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilterTest.java index fe11e195ac..0f76a5c690 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilterTest.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml2/web/authentication/logout/Saml2SingleLogoutFilterTest.java @@ -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; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java index 11681d7932..aa6cdfb388 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java @@ -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()); + } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java index 303d6db132..832c2566df 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java @@ -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()); + } } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js index 08b0242a98..77104bc3ce 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js @@ -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(); }); }); }