mirror of https://github.com/apache/nifi.git
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:
parent
c5c9425989
commit
0650521eb2
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue