-
Notifications
You must be signed in to change notification settings - Fork 275
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
Add support for OpenId Connect userinfo_endpoint #4649
base: main
Are you sure you want to change the base?
Add support for OpenId Connect userinfo_endpoint #4649
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4649 +/- ##
==========================================
- Coverage 65.26% 65.24% -0.03%
==========================================
Files 317 318 +1
Lines 22320 22398 +78
Branches 3591 3603 +12
==========================================
+ Hits 14568 14614 +46
- Misses 5953 5978 +25
- Partials 1799 1806 +7
|
Opening this up for review while I go ahead and try to test the use case. Going to use OpenSearch with the custom installation and Keycloak as the OIDC provider. |
@@ -1,73 +0,0 @@ | |||
/* |
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.
@stephen-crawford Is the user-info endpoint only used to extract the backend roles from the external IdP or does it have another use as well? (i.e. custom attributes)
Would it make sense to have a protected method in the AbstractHttpJwtAuthenticator to extractRoles
and have this class override the implementation?
The class renaming is making it harder to determine what the diffs are in this PR.
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 can contain anything so long as it minimally contains the sub
field. If it is signed, it must also contain a iss
and aud
field.
The renaming was done because the old naming convention was inconsistent with the rest of the Http authenticators.
The existing code was the nested class now inside the HTTPOpenIdAuthenticator file and the associated tests. The rest is new.
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.
Got it, I see an example positive response here: https://docs.aws.amazon.com/cognito/latest/developerguide/userinfo-endpoint.html#get-userinfo-response-sample
So userinfo endpoint needs to be called before extractRoles and extractSubject.
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.
Yeah, this code is pretty complicated which is why I tried to add a lot of tests. The issue is that the existing OIDC logic was not implemented with the userinfo endpoint in mind. It is tightly coupled to the JWTAuthenticator (and the abstract version), but the userinfo can be a jwt or json which complicates things.
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.
@cwperks I added a comment on the test file and the renamed file to help make clear what is new.
} | ||
|
||
public HTTPJwtKeyByOpenIdConnectAuthenticator getOpenIdJwtAuthenticator() { | ||
if (openIdJwtAuthenticator == null) { |
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's the purpose of synchronized here and multiple null checks?
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 made this for the case where we want to access the wrapped jwtAuthenticator. If the overall Authenticator is not initialized, we would want to be able to access the internal authenticator for testing I believe. The synchronized is meant to prevent the creation of multiple authenticators when getOpenIdJwtAuthenticator
is called multiple times. We don't want to create several instances of the jwtOpenIdAuthenticator
so this makes sure we only ever make one.
If you think there is a better way of doing this let me know.
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.
Singleton Pattern should help here correct?
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 am going to remove this since apparently this pattern is not recommended in modern coding practices ...
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/HTTPOpenIdAuthenticator.java
Outdated
Show resolved
Hide resolved
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/HTTPOpenIdAuthenticator.java
Outdated
Show resolved
Hide resolved
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/OpenIdConstants.java
Outdated
Show resolved
Hide resolved
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/OpenIdConstants.java
Outdated
Show resolved
Hide resolved
} | ||
|
||
@Test | ||
public void userinfoEndpointReturnsJwtWithAllRequirementsTest() throws 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.
This point starts the new tests.
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.
a lot of code is repetitive in the test below. Do you think we can extract it to a single method, say assertValidCredentials and assertInvalidCredentials?
|
||
String userinfoContent; | ||
|
||
try ( |
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 use nimbus' UserInfoResponse.parse(response)
?
The KeySetRetriever use's nimbuses similarly like this: https://github.com/opensearch-project/security/blob/main/src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/KeySetRetriever.java#L92-L97
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.
Hmm let me look. I just googled how to read the entity content properly but if we already have something that does that, then that would be great.
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.
So, I took a look and I think trying to use the package is more hassle than its worth. For starters, we need to add a dependency on https://mvnrepository.com/artifact/com.nimbusds/oauth2-oidc-sdk. But more notably, trying to use the Parse logic will require resolving a translation between the CloseableHttpResponse we current use and the generic HttpResponse that the built in method expects.
It seems like we are probably better off leaving things as they are for now.
return getJwtClaimsSetFromInfoContent(parsedToken); | ||
} | ||
|
||
private JWTClaimsSet getJwtClaimsSetFromInfoContent(String userInfoContent) throws OpenSearchSecurityException { |
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 new--how I split it out from the extract method--but the rest of the nested class was previously implemented.
return missing; | ||
} | ||
|
||
private final class HTTPJwtKeyByOpenIdConnectAuthenticator extends AbstractHTTPJwtAuthenticator { |
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.
All above is new. This internal class previously existed as the separate HttpJwtKeyByOpenIdConnectAuthenticator. I nested the class since the userinfo logic directly relies on it and the previous naming was out of convention.
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.
Thank you @stephen-crawford for adding this support. Changes look close. Left a few comments. Could you look into codecov annotations about missed lines in test coverage?
build.gradle
Outdated
@@ -608,7 +608,8 @@ dependencies { | |||
runtimeOnly 'org.ow2.asm:asm:9.7' | |||
|
|||
testImplementation 'org.apache.camel:camel-xmlsecurity:3.22.2' | |||
|
|||
testImplementation 'org.mockito:mockito-inline:5.2.0' | |||
//testImplementation 'org.mockito:mockito-inline:2.13.0' |
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.
nit: remove this commented line.
} | ||
|
||
public HTTPJwtKeyByOpenIdConnectAuthenticator getOpenIdJwtAuthenticator() { | ||
if (openIdJwtAuthenticator == null) { |
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.
Singleton Pattern should help here correct?
} | ||
|
||
@Test | ||
public void userinfoEndpointReturnsJwtWithAllRequirementsTest() throws 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.
a lot of code is repetitive in the test below. Do you think we can extract it to a single method, say assertValidCredentials and assertInvalidCredentials?
Hi @DarshitChanpura, thanks for taking a look. I will definitely address your comments before merging. Right now, I am having to backport these changes to the 2.x line to check the stability and functionality of the feature so it will be tomorrow or next week before I update--but I am going to. |
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
130cd6c
to
c837dcf
Compare
Signed-off-by: Stephen Crawford <[email protected]>
@cwperks I swapped over to using Nimbus for sending the requests like you suggested. I ran into some problems with the verification of the fields since it seems like the Nimbus validation is based off of the server response not the actual fields equaling the settings. I also addressed @DarshitChanpura's comments while takng care of this swap and revising the tests. Going to go back to manually testing things now. |
@stephen-crawford Cool feature! I am wondering about one thing: Is the userinfo endpoint then queried for each incoming REST request? If so, this might add quite a bit of latency on these requests. |
Hi @nibix, to answer your question, yes. The server will need to be queried for each request. There will be some additional latency when using this feature but presumably anyone who wants to use it would be aware of this trade off. I don't think there is an alternative to this approach since I don't believe our OIDC support caches the credentials for a session. This is a possible improvement down the line but from what I can tell, we expect each request to include valid authorization info. If we were to add caching of some kind, I would expect a new setting i.e. This would require a fair amount of additional work I suspect. |
@stephen-crawford There's already a user cache in security/src/main/java/org/opensearch/security/auth/BackendRegistry.java Lines 580 to 590 in 359272e
Currently, the user cache is only used when there is a To a certain extend, one could argue that the |
Hi @nibix, my understanding from the comment in the linked code is that the cache is not active for OIDC. It reads
So since OIDC is based on the JWT auth I interpreted this as meaning there would not be any caching here. Perhaps, I am mistaken. Either way, it sounds like you have reservations about making this change? Is there something you would like to see done to this PR to move forward? I still need to setup the test systems to actually confirm it works as expected but the code should mostly be complete. |
Exactly, my point was that there is no caching done for OIDC because it is assumed to be fast, i.e., just operating on locally available information. The original assumption is IMHO that "slow" operations are done in the But, I don't want to interfere here, especially, as the PR is already in an advanced state, just wanted to add some perspective. |
Signed-off-by: Stephen Crawford <[email protected]>
A round trip per request is a lot of overhead and keeping the user cache in sync across nodes would also be a challenging problem. Ideally, there is a token that has claims about the subject and roles that can be extracted after initial authentication so that only a round trip on creation of a session is necessary. Edit: Since OIDC is used for dashboards auth, the increase in latency may be tolerable for users that opt into this feature |
Signed-off-by: Stephen Crawford <[email protected]>
Signed-off-by: Stephen Crawford <[email protected]>
|
||
String missing = ""; | ||
|
||
if (claims.getClaim(SUB_CLAIM) == null || claims.getClaim(SUB_CLAIM).toString().isBlank() || !claims.getClaim("sub").equals(id)) { |
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 also support the subject_key
and roles_key
settings: https://opensearch.org/docs/latest/security/authentication-backends/openid-connect/#configure-openid-connect-integration
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 is not the same. The userinfo endpoint has to have the 'sub' field specifically.
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 sub (subject) Claim MUST always be returned in the UserInfo Response.
NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used."
https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/HTTPOpenIdAuthenticator.java
Outdated
Show resolved
Hide resolved
src/main/java/com/amazon/dlic/auth/http/jwt/keybyoidc/HTTPOpenIdAuthenticator.java
Outdated
Show resolved
Hide resolved
|
||
String finalBearerHeader = bearerHeader; | ||
|
||
AccessToken accessToken = new AccessToken(AccessTokenType.BEARER, finalBearerHeader) { |
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.
FYI Nimbus has a class called BearerAccessToken, would that be usable 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.
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.
Oh I didn't realize that.
); | ||
} | ||
|
||
String contentType = String.valueOf(httpResponse.getHeaderValues("content-type")); |
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.
Extract out the UserInfoSuccessResponse here and then call on UserInfoSuccessResponse.getEntityContentType to get the content type from the 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.
Will do
|
||
@Override | ||
public AuthCredentials extractCredentials(SecurityRequest request, ThreadContext context) throws OpenSearchSecurityException { | ||
if (this.userInfoEndpoint != null && !this.userInfoEndpoint.isBlank()) { |
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.
wdyt about moving the logic that calls the userinfo endpoint into a new method?
In AbstractHttpJwtAuthenticator would it make sense to add a new method called:
public JwtClaimsSet getJWTClaimsSet(String jwt) {
return jwt.getJWTClaimsSet(); // default implementation
}
That way this line can be replaced: https://github.com/opensearch-project/security/blob/main/src/main/java/com/amazon/dlic/auth/http/jwt/AbstractHTTPJwtAuthenticator.java#L131
In the OpenID authenticator can we then subclass that and either return jwt.getJWTClaimsSet()
(if userinfo endpoint is not configured) or if userinfo is configured then follow the logic that you have below.
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 am not sure what the benefit of further moving things would be. We already have separation of the userinfo vs. non-userinfo logic inside this handler. If you really prefer it that way, I can move things around, but don't see how
@Override
public AuthCredentials extractCredentials(SecurityRequest request, ThreadContext context) throws OpenSearchSecurityException {
return new AuthCredentials(this.getJwtClaims())
}
public JwtClaimsSet getJwtClaims() {
if (this.userInfoEndpoint != null && !this.userInfoEndpoint.isBlank()) {
return extractCredentials0(request, context);
}
return (this.openIdJwtAuthenticator.extractCredentials(request, context));
}
is better than
@Override
public AuthCredentials extractCredentials(SecurityRequest request, ThreadContext context) throws OpenSearchSecurityException {
if (this.userInfoEndpoint != null && !this.userInfoEndpoint.isBlank()) {
return extractCredentials0(request, context);
}
return (this.openIdJwtAuthenticator.extractCredentials(request, context));
}
These are functionally identical in my opinion and make the code harder to follow I feel. You can decide what you prefer though.
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 class would not need to override extractCredentials
, it would completely re-use extractCredentials
from AbstractHTTPJwtAuthenticator, but override the implementation of getJWTClaimsSet
.
Signed-off-by: Stephen Crawford <[email protected]>
Description
[Describe what this change achieves]
This change reworks the OpenID Connect authentication process to allow for the configuration of the new setting
userinfo_endpoint
. It also allows the configuration of the two associated settingsclient_id
andissuer_id_url
, which are used when validating the response provided by the OIDC provider back to the client.The userinfo_endpoint is a configuration of OIDC originally detailed in the RFC: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
It functions by creating an automated callback between the Client and the OIDC provider. In the RFC, these are referred to as the Relying Party (RP) and OpenID Provider (OP). In short while the existing OIDC support works by causing the OP to simply send a complete JWT to be parsed by OpenSearch, the new support works by returning that JWT back to the OP while querying the
userinfo_endpoint
configured during registration.In turn, the OP will return either a serialized JSON response or a new JWT. This response is then validated based on the details outlined in the RFC, before the
sub
androles
fields of either the JSON or JWT are formed into a new set of AuthCredentials.Here is an example of how this works
OP --/app/home# Bearer <sub: stephen>--> RP
RP --GET /<userinfo_endpoint> Bearer <sub: stephen--> OP
OP --OK <sub: stephen, roles: admin, iss: ...> --> RP
Issues Resolved
This change should resolve: #2040
Is this a backport? If so, please add backport PR # and/or commits #, and remove
backport-failed
label from the original PR.Do these changes introduce new permission(s) to be displayed in the static dropdown on the front-end? If so, please open a draft PR in the security dashboards plugin and link the draft PR here
Testing
I have updated the existing OIDC tests and added new tests which cover the JWT and JSON flows for the userinfo behavior.
Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.