-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
NIFI-9065 Add support for OAuth2AccessTokenProvider in InvokeHTTP. #5319
Conversation
Hey @MikeThomsen - IIRC you contributed the existing controller service in the first place, you may want to have a look at this pull request. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for contributing this feature @tpalfy. The general capability looks like a helpful improvement to InvokeHTTP
, but there are several concerns with the implementation as it stands.
At a high level, removing the existing OAuth2AccessTokenProvider
interface and implementation seems like a problem for a minor version update. Although there do not appear to be any other direct references to the current implementation, removing the existing interface and API would break others who have developed custom components. The current interface appears to have some issues, so introducing a new interface would be one way to provide backward compatibility. In that case, the existing interface and implementation should be marked as deprecated.
I also noted a handful of concerns on the new implementation related to properties and exception handling.
@@ -1112,6 +1121,11 @@ private void setHeaderProperties(final ProcessContext context, final Request.Bui | |||
requestBuilder.addHeader("Date", RFC_2616_DATE_TIME.format(universalCoordinatedTimeNow)); | |||
} | |||
|
|||
if (context.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).isSet()) { | |||
OAuth2AccessTokenProvider oauth2AccessTokenProvider = context.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).asControllerService(OAuth2AccessTokenProvider.class); | |||
requestBuilder.addHeader("Authorization", "Bearer " + oauth2AccessTokenProvider.getAccessToken()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting the Authorization
header will conflict with the Username and Password properties used for configuring Basic or Digest authentication. One way to address this is introducing more logic in customValidate()
to ensure that those properties are not set when OAuth2 Access Token Provider is set.
private String accessToken; | ||
private String refreshToken; | ||
private String tokenType; | ||
private Integer expires; | ||
private String scope; | ||
private Integer expiresIn; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be changed to an int
to avoid unexpected NullPointerExceptions. Changing this to java.time.Duration
would be a helpful improvement to avoid potential ambiguity.
|
||
private Long fetchTime; | ||
private final Long fetchTime; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be changed to a long
to avoid unexpected NullPointerExceptions in isExpired()
. It would probably be better to represent that as a java.time.Instant
.
@Tags({"oauth2", "provider", "authorization", "access token", "http" }) | ||
@CapabilityDescription("Provides OAuth 2.0 access tokens that can be used as Bearer authorization header in HTTP requests." + | ||
" Uses Resource Owner Password Credentials Grant.") | ||
public class PasswordBasedOauth2TokenProvider extends AbstractControllerService implements OAuth2AccessTokenProvider { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation includes both Password and Client Secret properties, are both of those necessary in all use cases? The previous implementation had a separate method using only the Client Credentials, so that should also be implemented for completeness.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I omitted the support for the Client Credentials Grant type from this service because to my understanding it has very limited usage that is not really applicable in a NiFi context.
To my understanding OAuth works like this:
Users can do certain things in the system, clients by themselves can do basically nothing. Nothing.
They merely serve as conduits for users. Like proxies, whatever they try to do - it must be initiated by a user.
The only exception when they need to access resources specifically meant for them as clients - and them only.
For example they might be able to change their secret. Or maybe their registration has an expiry and they can query the expiration date. And that's what the Client Credentials Grant type for - you have no user involved, you only need the Client ID and the Client Secret.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I investigated a bit more and there might be scenarios where resources are basically public but only for registered entities. Which can include both users and clients.
In such a scenario using the Client Credentials Grant flow is a valid use-case.
I'll add it to the new service as well.
/** | ||
* @return A valid OAuth2 access token | ||
*/ | ||
String getAccessToken(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a particular reason for changing the interface to return a String
as opposed to an object of some kind?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a published API, so I'm not going to back this change without a good reason.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought simply accessing the token itself is enough but I guess it makes sense to be able to access the corresponding metadata. I'll change it to return AccessDetails
.
} catch (Exception e) { | ||
getLogger().info("Couldn't refresh access token, probably refresh token has expired." + | ||
" Getting new token using credentials."); | ||
acquireAuthorizationDetails(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method could also throw any number of RuntimeExceptions, are expecting the caller to implement optional handling of RuntimeExceptions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see this any more special than other methods (which "could also throw any number of RuntimeExceptions"). The framework-provided default error-handling should suffice in my opinion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in the other comment, breaking out exception handling between issues with HTTP responses, versus other types of issues would help determine whether to call acquireAuthorizationDetails()
. At that point, I agree that framework-level handling is sufficient for exception processing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested all error scenarios and in all cases I see the issue properly reported.
The information provided is clear and lacks no detail.
Unless presented a concrete detailed and reproducible example that would prove otherwise I consider this level of error handling sufficient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the response, I added a comment with some additional details and one potential suggestion.
.name("client-secret") | ||
.displayName("Client secret") | ||
.dependsOn(CLIENT_ID) | ||
.required(true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This property is required, but it appears to be ignored if Client ID
is not specified.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that's exactly what I wanted to achieve. It depends on CLIENT_ID. If that is set, this must be set as well. If CLIENT_ID is not set, this can be ignored.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for clarifying, I missed the dependsOn()
attribute in the initial review.
|
||
return accessDetails; | ||
} catch (IOException e) { | ||
throw new ProcessException(e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like another more specific exception could be better here, or perhaps an UncheckedIOException
. In either case, a message should be included for troubleshooting. In this case, the IOException
could be coming from the HTTP call itself, or the JSON parsing. Recommend breaking out those operations into separate methods for clearer error handling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I see the value in making this more complex.
Whatever the issue was it will get reported properly and no information will be lost. I think we can rely on the default exception handling of the framework.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For clarification, I am not recommending making this more complex, but just changing the wrapping exception. Since this is catching IOException
, wrapping it in an UncheckedIOException
, with a message, seems better.
|
||
ObjectMapper mapper = new ObjectMapper() | ||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) | ||
.setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason for including the full class in these two lines?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IntelliJ did this way for some reason. Didn't notice, I'll fix it.
throw new ProcessException(String.format("Got HTTP %d during oauth2 request.", response.code())); | ||
} | ||
|
||
ObjectMapper mapper = new ObjectMapper() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ObjectMapper instance can be reused, instead of being created in every method invocation.
@tpalfy what was the reason behind all but scrapping and redoing the existing controller service? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The idea seems simple, but I'm not tracking why this ticket went from adding to InvokeHttp and turned into a rewrite of the service.
@@ -1112,6 +1121,11 @@ private void setHeaderProperties(final ProcessContext context, final Request.Bui | |||
requestBuilder.addHeader("Date", RFC_2616_DATE_TIME.format(universalCoordinatedTimeNow)); | |||
} | |||
|
|||
if (context.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).isSet()) { | |||
OAuth2AccessTokenProvider oauth2AccessTokenProvider = context.getProperty(OAUTH2_ACCESS_TOKEN_PROVIDER).asControllerService(OAuth2AccessTokenProvider.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be done once with @OnScheduled
.
/** | ||
* @return A valid OAuth2 access token | ||
*/ | ||
String getAccessToken(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a published API, so I'm not going to back this change without a good reason.
@MikeThomsen My main issue with the previous solution is that it leaves too many responsibilities in the hands of the caller - i.e. the processor. This makes it harder to integrate as those processors need to take care of getting username, password, client id and client secret configurations themselves (not to mention validation) and pass that to the controller service. I would be curious how the previous solution was tested in a real-life environment. |
It started as a controller service for a private bundle I have been maintaining for a client. It's not the only component we have on site that uses it. We tend to start testing with password flows first and then move to client credentials as we move toward production. The logic behind the implementation is that the controller service is meant to be dumb with regard to the specific credentials and provide functionality to a particular process with its own configured credentials. One major concern I have is with the naming convention. Password* implies a particular OAuth2 strategy, but the service supports both password and client credential flows. |
I agree with the concern on naming, it seems like the name should be changed, or there should be different implementations. |
} catch (Exception e) { | ||
getLogger().info("Couldn't refresh access token, probably refresh token has expired." + | ||
" Getting new token."); | ||
acquireAccessDetails(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the case of an IOException
thrown from refreshAccessDetails()
? Whether wrapped in a ProcessException
or UncheckedIOException
, this will hit this catch block. In that case, the failure is not due to an expired refresh token, but a network issue like a SocketTimeoutException.
At minimum, the log message is not indicative of that type of failure. It also seems like making another call to the service with acquireAuthorizationDetails()
is unlikely to succeed. One option is to catch and throw IOExceptions
.
} catch (Exception e) { | |
getLogger().info("Couldn't refresh access token, probably refresh token has expired." + | |
" Getting new token."); | |
acquireAccessDetails(); | |
} | |
} catch (final IOException ioe) { | |
throw new UncheckedIOException("Refresh Access Token Failed", ioe); | |
} catch (final Exception e) { | |
getLogger().info("Refresh Access Token Failed [{}]: New Token request started", e.getMessage()); | |
acquireAccessDetails(); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might save a round to the access server with this approach but that's about the only advantage. However a simple retry (in this case, try to get a whole new access token) might even provide a solution for a momentary connection issue. Overall I still think the current implementation is more optimal.
As for troubleshooting I stand by my previous opinion: I tested all error scenarios and in all cases I see the issue properly reported. The information provided is clear and lacks no detail.
Unless presented a concrete detailed and reproducible example that would prove otherwise I consider this level of error handling sufficient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the initial request for a token fails, it is important to know the reason, and OAuth2 responses provide specific details for the failure. Another problem with the current implementation is that the exception details are not logged.
@MikeThomsen can probably provide more detail on the reason that the current implementation separates the initial token request and the refresh token request. Although there is more work on the caller, these are two different concepts that can fail for different reasons.
Although the general functionality would be an improvement to InvokeHTTP, I am -1 on the current approach. If token refresh remains handled implicitly in the Controller Service, the failure conditions need to be handled more clearly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the initial request for a token fails, it is important to know the reason, and OAuth2 responses provide specific details for the failure.
I agree. I tested this scenario and - as I said previously: I see the issue properly reported. The information provided is clear and lacks no detail.
Another problem with the current implementation is that the exception details are not logged.
Okay, I'll add the exception to the logger call.
If you still think you can present a concrete example where you can show that a certain issue can't be investigated efficiently or a case is not handled properly please give me the details how to reproduce it and I'd be very happy to take a look.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for following up @tpalfy. On further review, I noted a couple issues with OkHttp Response and ResponseBody objects not being closed according to their documentation. I also highlighted the potential for the service to maintain a reference to an invalid refresh token if the refreshAccessToken()
request fails. For these reasons, I also recommend adding a unit test method that exercises some exception conditions.
...provider-service/src/main/java/org/apache/nifi/oauth2/StandardOauth2AccessTokenProvider.java
Show resolved
Hide resolved
...provider-service/src/main/java/org/apache/nifi/oauth2/StandardOauth2AccessTokenProvider.java
Show resolved
Hide resolved
...provider-service/src/main/java/org/apache/nifi/oauth2/StandardOauth2AccessTokenProvider.java
Show resolved
Hide resolved
this.accessDetails = getAccessDetails(refreshRequest); | ||
} | ||
|
||
private AccessToken getAccessDetails(Request newRequest) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private AccessToken getAccessDetails(Request newRequest) { | |
private AccessToken getAccessDetails(final Request newRequest) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a matter of taste, I'm against the excessive use of the final
keyword.
Making local variables final
usually just makes the code less readable and much more painful to debug for no real benefit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Usage of final
is not consistent across the application, but it does provide an important indication that the variable reference cannot be changed in subsequent lines, so it is more than a stylistic question. I recommend it, but it isn't required.
getLogger().error(String.format("Bad response from the server during oauth2 request:\n%s", responseBody)); | ||
throw new ProcessException(String.format("Got HTTP %d during oauth2 request.", response.code())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newline character is platform-specific, recommend the following adjustments:
getLogger().error(String.format("Bad response from the server during oauth2 request:\n%s", responseBody)); | |
throw new ProcessException(String.format("Got HTTP %d during oauth2 request.", response.code())); | |
getLogger().error("OAuth2 Request Failed [HTTP {}] {}", response.code(), responseBody); | |
throw new ProcessException(String.format("OAuth2 Request Failed [HTTP %d]", response.code())); |
...provider-service/src/main/java/org/apache/nifi/oauth2/StandardOauth2AccessTokenProvider.java
Show resolved
Hide resolved
} | ||
|
||
private void acquireAccessDetails() { | ||
getLogger().debug("Getting a new access token."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although logging is not consistent across the application, recommend removing the period .
character from the message:
getLogger().debug("Getting a new access token."); | |
getLogger().debug("Getting a new access token"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can do
try { | ||
refreshAccessDetails(); | ||
} catch (Exception e) { | ||
getLogger().info("Couldn't refresh access token.", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this generalized exception handling and retry remains, should this be logged as warning?
getLogger().info("Couldn't refresh access token.", e); | |
getLogger().warn("Refresh Access Token Failed", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's better to keep this as info
.
If the subsequent new token acquisition succeeds we don't want to leave a warning that basically immediately became outdated.
try { | ||
refreshAccessDetails(); | ||
} catch (Exception e) { | ||
getLogger().info("Couldn't refresh access token.", e); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the refresh request throws an Exception, the object still retains a reference to the expired Access Token. If the subsequent call to acquireAccessDetails()
also throws an Exception, it looks like the class will retain a reference to a refreshToken that is potentially invalid, does that sounds possible? One option would be setting accessDetails = null
in this catch block, which would cause subsequent calls to getAccessDetails()
to call acquireAccessDetails()
instead of going down this path again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@exceptionfactory , although I don't have a strong opinion on this particular point my concern with null
ing the accessDetails
is that it might be a bit confusing to the reader and we don't save too much here:
- if there'll be multiple calls to this method and the refresh-acquire pair fails then there's some bigger problem -> the "extra" refresh try doesn't make too much difference.
- But if the subsequent call succeeds at
acquireAccessDetails
then we only have one unnecessary refresh call.
Compared to how confusing the accessDetails = null
could be it might not be worth it.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd rather leave out this type of micromanagement code. The benefit doesn't translate to practical advantage in real-life situations. Also, retrying the refresh after the new token acquisition failed very well may make sense and could succeed. Maybe it fails more often than not but the point is, it doesn't really matter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that setting accessDetails
to null
could be confusing. That is part of the reason for recommend more discrete exception handling. Here is the potential scenario:
- Initial
acquireAccessDetails()
succeeds and includes a refresh token - Access Token expires, leading to the service calling
refreshAccessDetails()
- The call to
refreshAccessDetails()
fails due to anIOException
- The call to
acquireAccessDetails()
fails due to anIOException
As long there are continuing connection issues, the service will continue calling both refreshAccessDetails()
and acquireAccessDetails()
on every getAccessDetails()
invocation. The same thing would happen in the case of both methods throwing some type of RuntimeException
related to a bad HTTP response. Logs might be enough to identify the problem, but the main concern is the state of the access details. Given that this new service implementation intends to hide the complexities of the state from the caller, it seems important to have the implementation itself as clear as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking a bit more about this.
I understand the reason of setting the accessDetails
to null
. What if the acquireAccessDetails
resets it at the beginning? Logically it belongs there and it's cleaner for the reader that after the acquireAccessDetails
it either has a value or is null
due to an exception.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @adenes, I was thinking about that approach as well. Setting accessDetails
to null
in acquireAccessDetails
does seem like the logical location to address this concern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to move past theoretical potential scenarios and consider reproducible issues?
I understand how the mentioned scenario would work. I thought about it already a long time ago.
Yes, it's possible that there would be multiple unsuccessful calls to the access server.
Yes, if the access server is unaccessible, we would have 2 unsuccessful calls at each attempt.
Where I disagree is that this is actually a problem. Would we be missing any important detail to be able to solve the issue? Would there be a scenario where this implementation would not work but another implementation would? I see no proof for any of those to be true.
As for setting acquireAccessDetails
to null
, again I see no case where this would be better than leaving it as it is. In fact I'd argue that in case we would like to do a memory dump it's better to keep the last working accessDetails
all the time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the reply @tpalfy. Although runtime issues can create some strange edge cases, I can appreciate not wanting to get stuck on theoretical concerns.
In order to move this forward, creating unit tests that exercise exception conditions would satisfy my concerns. It would be very helpful to have one unit test that throws some type of IOException, and another that throws an exception as the result of a non-success HTTP status. Having those two tests would exercise this block of code and cover the bases.
With the addition of unit tests, and if @MikeThomsen signs off on this refactored approach, I can support these changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expecting better test coverage is fair enough. Going to add ones that cover the various error cases.
To be honest previously I thought about adding unit tests but the non-error cases would rely so much on mocks that it would no longer ensure proper functionality in a real-life environment. So I settled for manual testing. But for the error-cases it makes sense to add some unit tests to cover the error handling.
...provider-service/src/main/java/org/apache/nifi/oauth2/StandardOauth2AccessTokenProvider.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for including the additional unit tests @tpalfy! Using assertThrows()
follows the current JUnit recommendation for expecting exception cases.
Although less of a concern, checking for the exact message in the log seems a bit brittle. Adjusting the log verification to look for a keyword, such as refresh
, would be helpful.
The main concern is the use of assertThrows()
instead of a try-catch with fail()
. Otherwise, the general test methods look clear enough.
The mocked approach works as implemented, although if you are interested, the OkHttp mockwebserver
library used for testing InvokeHTTP
is another option.
try { | ||
testSubject.getAccessDetails(); | ||
fail(); | ||
} catch (ProcessException e) { | ||
// THEN | ||
checkLoggedDebugWhenRefreshThrowsIOException(); | ||
|
||
checkLoggedRefreshError(new ProcessException("OAuth2 access token request failed [HTTP 500]")); | ||
|
||
checkedLoggedErrorWhenRefreshReturnsBadHTTPResponse(expectedLoggedInfo); | ||
|
||
checkError(new ProcessException("OAuth2 access token request failed [HTTP 503]"), e); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With recent work underway to refactor unit tests, it would be helpful to implement this test using assertThrows()
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Junit5 assertThrow
support is kind of up to par to the current JavaSE solution, although even that is debatable. It is fairly new and hasn't been proven to be obviously superior yet.
The Junit4 approach however has objective shortcomings.
Switching to Junit5 is out of scope of this change.
Leaving the tests as they are is the most optimal in my opinion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JUnit 4.13 includes assertThrows()
, which is already used in other places throughout the system. Using assertThrows
with the current JUnit 4 test methods should be a straightforward adjustment, and will also streamline future migration to JUnit 5.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for updating the unit tests @tpalfy. This looks closer to completion, although I think it would also be useful to add a unit test to InvokeHTTP
that exercises the OAuth2 service. Otherwise, the failure unit tests do a good job of exercising the various exception conditions.
@MikeThomsen do you have any additional feedback on the current implementation?
.name("ssl-context") | ||
.displayName("SSL Context") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other components such as InvokeHTTP
and ListenHTTP
use SSL Context Service
as the property name, so recommend adjusting the naming for consistency:
.name("ssl-context") | |
.displayName("SSL Context") | |
.name("ssl-context-service") | |
.displayName("SSL Context Service") |
results.add(new ValidationResult.Builder() | ||
.subject("Authorization properties") | ||
.valid(false) | ||
.explanation("Can't use username+password and OAuth2 authorization at the same time") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be helpful to reference to actual property names in the message, similar to formatted message in customValidate()
on the service, as opposed to just username+password
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I disagree. There are 3 fairly long property names that could be referenced here ('Basic Authentication Username', 'Basic Authentication Password' and 'OAuth2 Access Token provider') and putting them all in a fairly convoluted manner in the explanation would make it too bloated and harder to actually interpret the issue.
On the other hand having the explanation message be something like "Can't use Basic- and OAuth2 authorization at the same time" would be a bit too lacking and would require just a bit too much extra effort to figure out what exactly the issue is.
I think the current message gives just the right amount of detail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although it seems helpful to call out the actual property name, I would be happy with something shorter that removes the +
character. Here is one suggestion:
.explanation("Can't use username+password and OAuth2 authorization at the same time") | |
.explanation("OAuth2 Authorization cannot be configured together with Username and Password properties") |
...e/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/InvokeHTTP.java
Show resolved
Hide resolved
final List<ValidationResult> validationResults = new ArrayList<>(super.customValidate(validationContext)); | ||
|
||
if ( | ||
validationContext.getProperty(GRANT_TYPE).getValue().equals(CLIENT_CREDENTIALS_GRANT_TYPE.getValue()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There do not appear to be any unit tests that exercise this validation.
Thanks for working the feedback @tpalfy, the latest version looks good. I ran through some basic runtime testing and was able to observe the error reporting. @MikeThomsen and @adenes, do either of you have any additional feedback? |
@exceptionfactory I'll try to take a look this weekend. |
Hi, |
@tpalfy - can you rebase against latest? |
@exceptionfactory @MikeThomsen @pvillard31 @OlivierBondu |
Thanks for the updates @tpalfy, the latest version looks good. Given that this deprecates the current API and @MikeThomsen previously expressed some concern of the changes, will wait for additional approval. @MikeThomsen What do you think of the current approach? |
Hi all, Happy new year to all ! Best wishes, good health, etc. 😄 Did you had any chance to move this PR forward ? Or would you have a rough time estimate for the feature ? Either would definitely be of help ! Thanks |
@MikeThomsen do you have any remaining concerns or does this look good to you? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall looks ready to merge, but one point about validation needs to be discussed.
.displayName("Username") | ||
.description("Username on the service that is being accessed.") | ||
.dependsOn(GRANT_TYPE, RESOURCE_OWNER_PASSWORD_CREDENTIALS_GRANT_TYPE) | ||
.required(true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about the validation/required field settings here. I'd feel better if username, password, client_id and client_secret were validated in a custom validator that checks to ensure the right values, lengths, etc. are there. I've experienced enough weirdness with nifi-mock
on this to be a little wary here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the current design is adequate.
We have username, password, client id and client secret of which the former and latter two are tied and we have no pre-existing knowledge about them regarding "right values, lenghts, etc."
The .required
and .dependsOn
settings properly cover all available scenarios.
@tpalfy - can you rebase against main? Unless there is something else to be discussed here (@MikeThomsen @exceptionfactory), I'm happy to get this merged for inclusion in NiFi 1.16 |
…e OAuth2-related implementations had to be adjusted.
…ils renamed back to AccessToken. Minor refactor in AccessToken for robustness and code quality. OAuth2AccessTokenProvider.getAccessDetails returns a full AccessToken object instead of a String. In PasswordBasedOauth2TokenProvider json mapper for AccessToken made constant. In InvokeHTTP added custom validation to prevend clashing of basic/digest and oauth2 authorization. Access token provider service is "warmed up" at @onschedule (retrieves a valid token that it then caches).
…roller service (which was renamed from 'PasswordBasedOauth2TokenProvider' to 'StandardOauth2AccessTokenProvider'). Cached access token is cleared when service is disabled (to force a new retrieval in case configuration has changed).
…fresh token. Adding exception details to the log when refreshing access token fails.
… Fixed NPE in StandardOauth2AccessTokenProviderTest.
…ccessTokenProvider.
…by using 'assertThrows' to check error handling.
…eckLoggedDebugWhenRefreshThrowsIOException' to 'checkLoggedDebugWhenRefreshFails'.
…cessTokenProviderTest.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current version looks good to me, thanks for the work on this @tpalfy! I defer to @pvillard31 or @MikeThomsen for merging.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Merging, please file follow-up JIRAs if needed/required.
Thanks all for working on this very useful improvement!
@pvillard31 going to be copying this over to our client's network to start some live testing against real OAuth2-driven services. |
return fetchTime; | ||
} | ||
|
||
public boolean isExpired() { | ||
return System.currentTimeMillis() >= ( fetchTime + (expires * 1000) ); | ||
boolean expired = Duration.between(Instant.now(), fetchTime.plusSeconds(expiresIn - EXPIRY_MARGIN)).isNegative(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expiresIn needs to be multiplied by 1000, or EXPIRY_MARGIN needs to be set to 5. Per OAuth standards the expires_in is in seconds. I've confirmed (using keycloak) that setting my token expiration to 5 minutes gets converted to 300 seconds and then on this step has 5000 subtracted from it. Which of course expires my non expired token.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for raising this concern @lawrencegrass. This pull request is already closed and merged. Can you summarize the problem and create a new issue in the Apache NiFi Jira project?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Created JIRA.
https://issues.apache.org/jira/browse/NIFI-9797
Signed-off-by: Pierre Villard <pierre.villard.fr@gmail.com> This closes apache#5319.
Signed-off-by: Pierre Villard <pierre.villard.fr@gmail.com> This closes apache#5319.
For all changes:
Is there a JIRA ticket associated with this PR? Is it referenced
in the commit message?
Does your PR title start with NIFI-XXXX where XXXX is the JIRA number you are trying to resolve? Pay particular attention to the hyphen "-" character.
Has your PR been rebased against the latest commit within the target branch (typically
main
)?Is your initial contribution a single, squashed commit? Additional commits in response to PR reviewer feedback should be made on this branch and pushed to allow change tracking. Do not
squash
or use--force
when pushing to allow for clean monitoring of changes.For code changes:
mvn -Pcontrib-check clean install
at the rootnifi
folder?LICENSE
file, including the mainLICENSE
file undernifi-assembly
?NOTICE
file, including the mainNOTICE
file found undernifi-assembly
?.displayName
in addition to .name (programmatic access) for each of the new properties?For documentation related changes:
Note:
Please ensure that once the PR is submitted, you check GitHub Actions CI for build issues and submit an update to your PR as soon as possible.