NIFI-6171 always send email scope for OIDC

NIFI-6171 re-added lookupEmail() as fallback
NIFI-6171 additional OIDC scopes via nifi.properties
NIFI-6171 alternative user identification (instead of email)  via nifi.properties
NIFI-6171 changed lookupEmail() so that any configured claim can be fetched fro the UserInfo endpoint

This closes #3398
This closes #2346
This commit is contained in:
simonl 2019-04-02 12:16:01 +02:00 committed by Matt Gilman
parent c5c9425989
commit 0650521eb2
No known key found for this signature in database
GPG Key ID: DF61EC19432AEE37
7 changed files with 167 additions and 30 deletions

View File

@ -158,6 +158,8 @@ public abstract class NiFiProperties {
public static final String SECURITY_USER_OIDC_CLIENT_ID = "nifi.security.user.oidc.client.id";
public static final String SECURITY_USER_OIDC_CLIENT_SECRET = "nifi.security.user.oidc.client.secret";
public static final String SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM = "nifi.security.user.oidc.preferred.jwsalgorithm";
public static final String SECURITY_USER_OIDC_ADDITIONAL_SCOPES = "nifi.security.user.oidc.additional.scopes";
public static final String SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER = "nifi.security.user.oidc.claim.identifying.user";
// apache knox
public static final String SECURITY_USER_KNOX_URL = "nifi.security.user.knox.url";
@ -947,6 +949,30 @@ public abstract class NiFiProperties {
return getProperty(SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM);
}
/**
* Returns additional scopes to be sent when requesting the access token from the IDP.
*
* @return List of additional scopes to be sent
*/
public List<String> getOidcAdditionalScopes() {
String rawProperty = getProperty(SECURITY_USER_OIDC_ADDITIONAL_SCOPES, "");
if (rawProperty.isEmpty()) {
return new ArrayList<>();
}
List<String> additionalScopes = Arrays.asList(rawProperty.split(","));
return additionalScopes.stream().map(String::trim).collect(Collectors.toList());
}
/**
* Returns the claim to be used to identify a user.
* Claim must be requested by adding the scope for it.
* Default is 'email'.
* @return The claim to be used to identify the user.
*/
public String getOidcClaimIdentifyingUser() {
return getProperty(SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER, "email").trim();
}
/**
* Returns whether Knox SSO is enabled.
*

View File

@ -17,6 +17,10 @@
package org.apache.nifi.util;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.net.URISyntaxException;
@ -127,6 +131,32 @@ public class NiFiPropertiesTest {
}
}
@Test
public void testAdditionalOidcScopesAreTrimmed() {
final String scope = "abc";
final String scopeLeadingWhitespace = " def";
final String scopeTrailingWhitespace = "ghi ";
final String scopeLeadingTrailingWhitespace = " jkl ";
String additionalScopes = String.join(",", scope, scopeLeadingWhitespace,
scopeTrailingWhitespace, scopeLeadingTrailingWhitespace);
NiFiProperties properties = mock(NiFiProperties.class);
when(properties.getProperty(NiFiProperties.SECURITY_USER_OIDC_ADDITIONAL_SCOPES, ""))
.thenReturn(additionalScopes);
when(properties.getOidcAdditionalScopes()).thenCallRealMethod();
List<String> scopes = properties.getOidcAdditionalScopes();
assertTrue(scopes.contains(scope));
assertFalse(scopes.contains(scopeLeadingWhitespace));
assertTrue(scopes.contains(scopeLeadingWhitespace.trim()));
assertFalse(scopes.contains(scopeTrailingWhitespace));
assertTrue(scopes.contains(scopeTrailingWhitespace.trim()));
assertFalse(scopes.contains(scopeLeadingTrailingWhitespace));
assertTrue(scopes.contains(scopeLeadingTrailingWhitespace.trim()));
}
private NiFiProperties loadNiFiProperties(final String propsPath, final Map<String, String> additionalProperties){
String realPath = null;
try{

View File

@ -349,6 +349,8 @@ To enable authentication via OpenId Connect the following properties must be con
|`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 for validating identity tokens. If this value is blank, it will default to `RS256` which is required to be supported
|`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.
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.

View File

@ -158,6 +158,8 @@
<nifi.security.user.oidc.client.id />
<nifi.security.user.oidc.client.secret />
<nifi.security.user.oidc.preferred.jwsalgorithm />
<nifi.security.user.oidc.additional.scopes />
<nifi.security.user.oidc.claim.identifying.user />
<!-- nifi.properties: apache knox -->
<nifi.security.user.knox.url />

View File

@ -171,6 +171,8 @@ nifi.security.user.oidc.read.timeout=${nifi.security.user.oidc.read.timeout}
nifi.security.user.oidc.client.id=${nifi.security.user.oidc.client.id}
nifi.security.user.oidc.client.secret=${nifi.security.user.oidc.client.secret}
nifi.security.user.oidc.preferred.jwsalgorithm=${nifi.security.user.oidc.preferred.jwsalgorithm}
nifi.security.user.oidc.additional.scopes=${nifi.security.user.oidc.additional.scopes}
nifi.security.user.oidc.claim.identifying.user=${nifi.security.user.oidc.claim.identifying.user}
# Apache Knox SSO Properties #
nifi.security.user.knox.url=${nifi.security.user.knox.url}

View File

@ -38,7 +38,6 @@ 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.OIDCScopeValue;
import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
@ -67,8 +66,6 @@ import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static com.nimbusds.openid.connect.sdk.claims.UserInfo.EMAIL_CLAIM_NAME;
/**
* OidcProvider for managing the OpenId Connect Authorization flow.
@ -153,17 +150,6 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint.");
}
// ensure the required scopes are present
if (oidcProviderMetadata.getScopes() == null) {
if (!oidcProviderMetadata.getScopes().contains(OIDCScopeValue.OPENID)) {
throw new RuntimeException("OpenId Connect Provider does not support the required scope: " + OIDCScopeValue.OPENID.getValue());
}
if (!oidcProviderMetadata.getScopes().contains(OIDCScopeValue.EMAIL) && oidcProviderMetadata.getUserInfoEndpointURI() == null) {
throw new RuntimeException(String.format("OpenId Connect Provider does not support '%s' scope and does not provide a UserInfo Endpoint.", OIDCScopeValue.EMAIL.getValue()));
}
}
// ensure the oidc provider supports basic or post client auth
List<ClientAuthenticationMethod> clientAuthenticationMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
logger.info("OpenId Connect: Available clientAuthenticationMethods {} ", clientAuthenticationMethods);
@ -258,12 +244,13 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
}
final Scope scope = new Scope("openid");
Scope scope = new Scope("openid", "email");
// if this provider supports email scope, include it to prevent a subsequent request to the user endpoint
if (oidcProviderMetadata.getScopes() != null && oidcProviderMetadata.getScopes().contains(OIDCScopeValue.EMAIL)) {
scope.add("email");
for (String additionalScope : properties.getOidcAdditionalScopes()) {
// Scope automatically prevents duplicated entries
scope.add(additionalScope);
}
return scope;
}
@ -307,9 +294,14 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
// validate the token - no nonce required for authorization code flow
final IDTokenClaimsSet claimsSet = tokenValidator.validate(oidcJwt, null);
// attempt to extract the email from the id token if possible
String email = claimsSet.getStringClaim(EMAIL_CLAIM_NAME);
if (StringUtils.isBlank(email)) {
// attempt to extract the configured claim to access the user's identity; default is 'email'
String identity = claimsSet.getStringClaim(properties.getOidcClaimIdentifyingUser());
if (StringUtils.isBlank(identity)) {
// explicitly try to get the identity from the UserInfo endpoint with the configured claim
logger.warn("The identity of the user was tried to get with the claim '" +
properties.getOidcClaimIdentifyingUser() + "'. The according additional scope is not " +
"configured correctly. Trying to get it from the UserInfo endpoint.");
// extract the bearer access token
final BearerAccessToken bearerAccessToken = oidcTokens.getBearerAccessToken();
if (bearerAccessToken == null) {
@ -317,7 +309,7 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
}
// invoke the UserInfo endpoint
email = lookupEmail(bearerAccessToken);
identity = lookupIdentityInUserInfo(bearerAccessToken);
}
// extract expiration details from the claims set
@ -326,18 +318,20 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
final long expiresIn = expiration.getTime() - now.getTimeInMillis();
// convert into a nifi jwt for retrieval later
final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(email, email, expiresIn, claimsSet.getIssuer().getValue());
final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(identity, identity, expiresIn,
claimsSet.getIssuer().getValue());
return jwtService.generateSignedToken(loginToken);
} else {
final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
throw new RuntimeException("An error occurred while invoking the Token endpoint: " + errorResponse.getErrorObject().getDescription());
throw new RuntimeException("An error occurred while invoking the Token endpoint: " +
errorResponse.getErrorObject().getDescription());
}
} catch (final ParseException | JOSEException | BadJOSEException e) {
throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage());
}
}
private String lookupEmail(final BearerAccessToken bearerAccessToken) throws IOException {
private String lookupIdentityInUserInfo(final BearerAccessToken bearerAccessToken) throws IOException {
try {
// build the user request
final UserInfoRequest request = new UserInfoRequest(oidcProviderMetadata.getUserInfoEndpointURI(), bearerAccessToken);
@ -359,13 +353,14 @@ public class StandardOidcIdentityProvider implements OidcIdentityProvider {
claimsSet = successResponse.getUserInfoJWT().getJWTClaimsSet();
}
final String email = claimsSet.getStringClaim(EMAIL_CLAIM_NAME);
final String identity = claimsSet.getStringClaim(properties.getOidcClaimIdentifyingUser());
// ensure we were able to get the user email
if (StringUtils.isBlank(email)) {
throw new IllegalStateException("Unable to extract email from the UserInfo token.");
// 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 email;
return identity;
}
} else {
final UserInfoErrorResponse errorResponse = (UserInfoErrorResponse) response;

View File

@ -0,0 +1,80 @@
/*
* 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.nifi.util.NiFiProperties;
import org.junit.Test;
import org.mockito.internal.util.reflection.Whitebox;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class StandardOidcIdentityProviderTest {
@Test
public void testValidateScopes() {
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(scope.toArray().length, 4);
assertTrue(scope.contains("openid"));
assertTrue(scope.contains("email"));
assertTrue(scope.contains(additionalScope_profile));
assertTrue(scope.contains(additionalScope_abc));
}
@Test
public void testNoDuplicatedScopes() {
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(scope.toArray().length, 4);
}
private StandardOidcIdentityProvider createOidcProviderWithAdditionalScopes(String... additionalScopes) {
final StandardOidcIdentityProvider provider = mock(StandardOidcIdentityProvider.class);
NiFiProperties properties = createNiFiPropertiesMockWithAdditionalScopes(Arrays.asList(additionalScopes));
Whitebox.setInternalState(provider, "properties", properties);
when(provider.isOidcEnabled()).thenReturn(true);
when(provider.getScope()).thenCallRealMethod();
return provider;
}
private NiFiProperties createNiFiPropertiesMockWithAdditionalScopes(List<String> additionalScopes) {
NiFiProperties properties = mock(NiFiProperties.class);
when(properties.getOidcAdditionalScopes()).thenReturn(additionalScopes);
return properties;
}
}