| /* |
| * 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.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.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.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 java.io.IOException; |
| import java.net.URI; |
| import java.net.URL; |
| import java.util.ArrayList; |
| import java.util.Calendar; |
| import java.util.Date; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.concurrent.TimeUnit; |
| import java.util.stream.Collectors; |
| import net.minidev.json.JSONObject; |
| import org.apache.commons.lang3.StringUtils; |
| import org.apache.nifi.authentication.exception.IdentityAccessException; |
| import org.apache.nifi.util.FormatUtils; |
| import org.apache.nifi.util.NiFiProperties; |
| import org.apache.nifi.web.security.jwt.JwtService; |
| import org.apache.nifi.web.security.token.LoginAuthenticationToken; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| |
| /** |
| * 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 NiFiProperties properties; |
| private JwtService jwtService; |
| private OIDCProviderMetadata oidcProviderMetadata; |
| private int oidcConnectTimeout; |
| private int oidcReadTimeout; |
| private IDTokenValidator tokenValidator; |
| private ClientID clientId; |
| private Secret clientSecret; |
| |
| /** |
| * Creates a new StandardOidcIdentityProvider. |
| * |
| * @param jwtService jwt service |
| * @param properties properties |
| */ |
| public StandardOidcIdentityProvider(final JwtService jwtService, final NiFiProperties properties) { |
| this.properties = properties; |
| this.jwtService = jwtService; |
| } |
| |
| /** |
| * 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.warn("The OIDC provider is not configured or enabled"); |
| return; |
| } |
| |
| 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(); |
| } |
| |
| /** |
| * Validates the retrieved OIDC provider metadata. |
| */ |
| private void validateOIDCProviderMetadata() { |
| // ensure the authorization endpoint is present |
| if (oidcProviderMetadata.getAuthorizationEndpointURI() == null) { |
| throw new RuntimeException("OpenId Connect Provider metadata does not contain an Authorization Endpoint."); |
| } |
| |
| // ensure the token endpoint is present |
| if (oidcProviderMetadata.getTokenEndpointURI() == null) { |
| throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint."); |
| } |
| |
| // ensure the oidc provider supports basic or post client auth |
| List<ClientAuthenticationMethod> clientAuthenticationMethods = oidcProviderMetadata.getTokenEndpointAuthMethods(); |
| logger.info("OpenId Connect: Available clientAuthenticationMethods {} ", clientAuthenticationMethods); |
| if (clientAuthenticationMethods == null || clientAuthenticationMethods.isEmpty()) { |
| clientAuthenticationMethods = new ArrayList<>(); |
| clientAuthenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); |
| oidcProviderMetadata.setTokenEndpointAuthMethods(clientAuthenticationMethods); |
| logger.warn("OpenId Connect: ClientAuthenticationMethods is null, Setting clientAuthenticationMethods as CLIENT_SECRET_BASIC"); |
| } else if (!clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) |
| && !clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) { |
| throw new RuntimeException(String.format("OpenId Connect Provider does not support %s or %s", |
| ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(), |
| ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue())); |
| } |
| |
| // extract the supported json web signature algorithms |
| final List<JWSAlgorithm> allowedAlgorithms = oidcProviderMetadata.getIDTokenJWSAlgs(); |
| if (allowedAlgorithms == null || allowedAlgorithms.isEmpty()) { |
| throw new RuntimeException("The OpenId Connect Provider does not support any JWS algorithms."); |
| } |
| |
| try { |
| // get the preferred json web signature algorithm |
| final 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); |
| } |
| } |
| |
| 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 = new DefaultResourceRetriever(oidcConnectTimeout, oidcReadTimeout); |
| 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); |
| } |
| } |
| |
| /** |
| * 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); |
| httpRequest.setConnectTimeout(oidcConnectTimeout); |
| httpRequest.setReadTimeout(oidcReadTimeout); |
| |
| 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 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 String exchangeAuthorizationCode(final AuthorizationGrant authorizationGrant) throws IOException { |
| // Check if OIDC is enabled |
| if (!isOidcEnabled()) { |
| throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED); |
| } |
| |
| // Build ClientAuthentication |
| final ClientAuthentication clientAuthentication = createClientAuthentication(); |
| |
| try { |
| // Build the token request |
| final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication); |
| return authorizeClient(tokenHttpRequest); |
| |
| } catch (final ParseException | JOSEException | BadJOSEException | java.text.ParseException e) { |
| throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage()); |
| } |
| } |
| |
| private String authorizeClient(HTTPRequest tokenHttpRequest) throws ParseException, IOException, BadJOSEException, JOSEException, java.text.ParseException { |
| // Get the token response |
| final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send()); |
| |
| // Handle success |
| if (response.indicatesSuccess()) { |
| return convertOIDCTokenToNiFiToken((OIDCTokenResponse) response); |
| } else { |
| // If the response was not successful |
| final TokenErrorResponse errorResponse = (TokenErrorResponse) response; |
| throw new RuntimeException("An error occurred while invoking the Token endpoint: " + |
| errorResponse.getErrorObject().getDescription()); |
| } |
| } |
| |
| private String convertOIDCTokenToNiFiToken(OIDCTokenResponse response) throws BadJOSEException, JOSEException, java.text.ParseException, IOException { |
| final OIDCTokenResponse oidcTokenResponse = response; |
| final OIDCTokens oidcTokens = oidcTokenResponse.getOIDCTokens(); |
| final JWT oidcJwt = oidcTokens.getIDToken(); |
| |
| // validate the token - no nonce required for authorization code flow |
| final IDTokenClaimsSet claimsSet = tokenValidator.validate(oidcJwt, null); |
| |
| // attempt to extract the configured claim to access the user's identity; default is 'email' |
| String identityClaim = properties.getOidcClaimIdentifyingUser(); |
| String identity = claimsSet.getStringClaim(identityClaim); |
| |
| // If default identity not available, attempt secondary identity extraction |
| if (StringUtils.isBlank(identity)) { |
| // Provide clear message to admin that desired claim is missing and present available claims |
| List<String> availableClaims = getAvailableClaims(oidcJwt.getJWTClaimsSet()); |
| logger.warn("Failed to obtain the identity of the user with the claim '{}'. The available claims on " + |
| "the OIDC response are: {}. Will attempt to obtain the identity from secondary sources", |
| identityClaim, availableClaims); |
| |
| // If the desired user claim was not "email" and "email" is present, use that |
| if (!identityClaim.equalsIgnoreCase(EMAIL_CLAIM) && availableClaims.contains(EMAIL_CLAIM)) { |
| identity = claimsSet.getStringClaim(EMAIL_CLAIM); |
| logger.info("The 'email' claim was present. Using that claim to avoid extra remote call"); |
| } else { |
| identity = retrieveIdentityFromUserInfoEndpoint(oidcTokens); |
| logger.info("Retrieved identity from UserInfo endpoint"); |
| } |
| } |
| |
| // extract expiration details from the claims set |
| final Calendar now = Calendar.getInstance(); |
| final Date expiration = claimsSet.getExpirationTime(); |
| final long expiresIn = expiration.getTime() - now.getTimeInMillis(); |
| |
| // convert into a nifi jwt for retrieval later |
| final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(identity, identity, expiresIn, |
| claimsSet.getIssuer().getValue()); |
| return jwtService.generateSignedToken(loginToken); |
| } |
| |
| 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) { |
| final HTTPRequest httpRequest = request.toHTTPRequest(); |
| httpRequest.setConnectTimeout(oidcConnectTimeout); |
| httpRequest.setReadTimeout(oidcReadTimeout); |
| return httpRequest; |
| } |
| |
| private ClientAuthentication createClientAuthentication() { |
| final ClientAuthentication clientAuthentication; |
| List<ClientAuthenticationMethod> authMethods = oidcProviderMetadata.getTokenEndpointAuthMethods(); |
| if (authMethods != null && authMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) { |
| clientAuthentication = new ClientSecretPost(clientId, clientSecret); |
| } else { |
| clientAuthentication = new ClientSecretBasic(clientId, clientSecret); |
| } |
| return clientAuthentication; |
| } |
| |
| private static List<String> getAvailableClaims(JWTClaimsSet claimSet) { |
| // Get the claims available in the ID token response |
| List<String> presentClaims = claimSet.getClaims().entrySet().stream() |
| // Check claim values are not empty |
| .filter(e -> StringUtils.isNotBlank(e.getValue().toString())) |
| // If not empty, put claim name in a map |
| .map(Map.Entry::getKey) |
| .sorted() |
| .collect(Collectors.toList()); |
| return presentClaims; |
| } |
| |
| 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()); |
| } |
| } |
| } |