Skip to content
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

Fix responses for the token APIs #54532

Merged
merged 15 commits into from
Apr 16, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions x-pack/docs/en/rest-api/security/oidc-logout-api.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
=== OpenID Connect logout API

Submits a request to invalidate a refresh token and an access token that was
generated as a response to a call to `/_security/oidc/authenticate`.
generated as a response to a call to `/_security/oidc/authenticate`.

[[security-api-oidc-logout-request]]
==== {api-request-title}
Expand Down Expand Up @@ -48,7 +48,7 @@ POST /_security/oidc/logout
"refresh_token": "vLBPvmAB6KvwvJZr27cS"
}
--------------------------------------------------
// TEST[catch:unauthorized]
// TEST[catch:request]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The (expected for this test) 500 was masked by the 401 that was thrown in

invalidateRefreshToken(request.getRefreshToken(), ActionListener.wrap(ignore -> {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary because the response changes to 200 and error now happens in the OIDC part as 500?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is necessary because we don't do the login flow so we don't have tokens to invalidate for the logout. We expect that this call would fail for that reason - but it failing with a 401 was a mistake


The following example output of the response contains the URI pointing to the
End Session Endpoint of the OpenID Connect Provider with all the parameters of
Expand All @@ -60,4 +60,4 @@ the Logout Request, as HTTP GET parameters:
"redirect" : "https://op-provider.org/logout?id_token_hint=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c&post_logout_redirect_uri=http%3A%2F%2Foidc-kibana.elastic.co%2Floggedout&state=lGYK0EcSLjqH6pkT5EVZjC6eIW5YCGgywj2sxROO"
}
--------------------------------------------------
// NOTCONSOLE
// NOTCONSOLE
4 changes: 2 additions & 2 deletions x-pack/docs/en/security/authentication/oidc-guide.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ authenticate a user with OpenID Connect:

. Make an HTTP POST request to `_security/oidc/prepare`, authenticating as the `facilitator` user, using the name of the
OpenID Connect realm in the {es} configuration in the request body. For more
details, see
details, see
<<security-api-oidc-prepare-authentication>>.
+
[source,console]
Expand Down Expand Up @@ -678,7 +678,7 @@ POST /_security/oidc/logout
"refresh_token": "vLBPvmAB6KvwvJZr27cS"
}
--------------------------------------------------
// TEST[catch:unauthorized]
// TEST[catch:request]
+
If the realm is configured accordingly, this may result in a response with a `redirect` parameter indicating where
the user needs to be redirected in the OP in order to complete the logout process.
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.xpack.core.XPackField;
import org.elasticsearch.xpack.core.XPackPlugin;
import org.elasticsearch.xpack.core.XPackSettings;
Expand Down Expand Up @@ -584,7 +585,8 @@ public void invalidateAccessToken(String accessToken, ActionListener<TokensInval
final Iterator<TimeValue> backoff = DEFAULT_BACKOFF.iterator();
decodeToken(accessToken, ActionListener.wrap(userToken -> {
if (userToken == null) {
listener.onFailure(traceLog("invalidate token", accessToken, malformedTokenException()));
logger.trace("The access token [{}] is expired and already deleted", accessToken);
listener.onResponse(TokensInvalidationResult.emptyResult());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change, a 200 response will be returned if the token index does not exist. This behaviour is different from the refresh token invalidation, which returns 400 invalidGrant when the index does not exist.

} else {
indexInvalidation(Collections.singleton(userToken), backoff, "access_token", null, listener);
}
Expand Down Expand Up @@ -623,9 +625,17 @@ public void invalidateRefreshToken(String refreshToken, ActionListener<TokensInv
maybeStartTokenRemover();
final Iterator<TimeValue> backoff = DEFAULT_BACKOFF.iterator();
findTokenFromRefreshToken(refreshToken,
backoff, ActionListener.wrap(searchResponse -> {
final Tuple<UserToken, String> parsedTokens = parseTokensFromDocument(searchResponse.getSourceAsMap(), null);
indexInvalidation(Collections.singletonList(parsedTokens.v1()), backoff, "refresh_token", null, listener);
backoff, ActionListener.wrap(searchHits -> {
if (searchHits.getHits().length < 1) {
logger.debug("could not find token document for refresh token");
listener.onResponse(TokensInvalidationResult.emptyResult());
} else if (searchHits.getHits().length > 1) {
listener.onFailure(new IllegalStateException("multiple tokens share the same refresh token"));
} else {
final Tuple<UserToken, String> parsedTokens =
parseTokensFromDocument(searchHits.getAt(0).getSourceAsMap(), null);
indexInvalidation(Collections.singletonList(parsedTokens.v1()), backoff, "refresh_token", null, listener);
}
}, listener::onFailure));
}
}
Expand Down Expand Up @@ -827,21 +837,30 @@ public void refreshToken(String refreshToken, ActionListener<Tuple<String, Strin
ensureEnabled();
final Instant refreshRequested = clock.instant();
final Iterator<TimeValue> backoff = DEFAULT_BACKOFF.iterator();
final Consumer<Exception> onFailure = ex -> listener.onFailure(traceLog("find token by refresh token", refreshToken, ex));
findTokenFromRefreshToken(refreshToken,
backoff,
ActionListener.wrap(tokenDocHit -> {
final Authentication clientAuth = securityContext.getAuthentication();
innerRefresh(refreshToken, tokenDocHit.getId(), tokenDocHit.getSourceAsMap(), tokenDocHit.getSeqNo(),
tokenDocHit.getPrimaryTerm(),
clientAuth, backoff, refreshRequested, listener);
ActionListener.wrap(searchHits -> {
if (searchHits.getHits().length < 1) {
logger.warn("could not find token document for refresh token");
onFailure.accept(invalidGrantException("could not refresh the requested token"));
} else if (searchHits.getHits().length > 1) {
onFailure.accept(new IllegalStateException("multiple tokens share the same refresh token"));
} else {
final SearchHit tokenDocHit = searchHits.getAt(0);
final Authentication clientAuth = securityContext.getAuthentication();
innerRefresh(refreshToken, tokenDocHit.getId(), tokenDocHit.getSourceAsMap(), tokenDocHit.getSeqNo(),
tokenDocHit.getPrimaryTerm(),
clientAuth, backoff, refreshRequested, listener);
}
}, listener::onFailure));
}

/**
* Infers the format and version of the passed in {@code refreshToken}. Delegates the actual search of the token document to
* {@code #findTokenFromRefreshToken(String, SecurityIndexManager, Iterator, ActionListener)} .
*/
private void findTokenFromRefreshToken(String refreshToken, Iterator<TimeValue> backoff, ActionListener<SearchHit> listener) {
private void findTokenFromRefreshToken(String refreshToken, Iterator<TimeValue> backoff, ActionListener<SearchHits> listener) {
if (refreshToken.length() == TOKEN_LENGTH) {
// first check if token has the old format before the new version-prepended one
logger.debug("Assuming an unversioned refresh token [{}], generated for node versions"
Expand All @@ -860,7 +879,7 @@ private void findTokenFromRefreshToken(String refreshToken, Iterator<TimeValue>
if (refreshTokenVersion.before(VERSION_TOKENS_INDEX_INTRODUCED) || unencodedRefreshToken.length() != TOKEN_LENGTH) {
logger.debug("Decoded refresh token [{}] with version [{}] is invalid.", unencodedRefreshToken,
refreshTokenVersion);
listener.onFailure(malformedTokenException());
listener.onResponse(SearchHits.empty());
} else {
// TODO Remove this conditional after backporting to 7.x
if (refreshTokenVersion.onOrAfter(VERSION_HASHED_TOKENS)) {
Expand All @@ -872,7 +891,7 @@ private void findTokenFromRefreshToken(String refreshToken, Iterator<TimeValue>
}
} catch (IOException e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kinda reasonates on what we have discussed the other day around how many statements should be surrounded by a try-catch block. The IOException is only thrown by unpackVersionAndPayload() which is the first statement in this try-catch block. The intention for returning 404 when unpacking fails would be much easier to understand if unpackVersionAndPayload is the only statement surrounded by this try-catch block.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point, took a swing at it

logger.debug(() -> new ParameterizedMessage("Could not decode refresh token [{}].", refreshToken), e);
listener.onFailure(malformedTokenException());
listener.onResponse(SearchHits.empty());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All usage of malformedTokenException seems to be deleted so itself can be removed as well.

}
}
}
Expand All @@ -884,7 +903,7 @@ private void findTokenFromRefreshToken(String refreshToken, Iterator<TimeValue>
* backoff policy. This method requires the tokens index where the token document, pointed to by the refresh token, resides.
*/
private void findTokenFromRefreshToken(String refreshToken, SecurityIndexManager tokensIndexManager, Iterator<TimeValue> backoff,
ActionListener<SearchHit> listener) {
ActionListener<SearchHits> listener) {
final Consumer<Exception> onFailure = ex -> listener.onFailure(traceLog("find token by refresh token", refreshToken, ex));
final Consumer<Exception> maybeRetryOnFailure = ex -> {
if (backoff.hasNext()) {
Expand Down Expand Up @@ -917,13 +936,8 @@ private void findTokenFromRefreshToken(String refreshToken, SecurityIndexManager
if (searchResponse.isTimedOut()) {
logger.debug("find token from refresh token response timed out, retrying");
maybeRetryOnFailure.accept(invalidGrantException("could not refresh the requested token"));
} else if (searchResponse.getHits().getHits().length < 1) {
logger.warn("could not find token document for refresh token");
onFailure.accept(invalidGrantException("could not refresh the requested token"));
} else if (searchResponse.getHits().getHits().length > 1) {
onFailure.accept(new IllegalStateException("multiple tokens share the same refresh token"));
} else {
listener.onResponse(searchResponse.getHits().getAt(0));
listener.onResponse(searchResponse.getHits());
}
}, e -> {
if (isShardNotAvailableException(e)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,12 @@ public void testExpiredTokensDeletedAfterExpiration() throws Exception {
final RestHighLevelClient restClient = new TestRestHighLevelClient();
CreateTokenResponse response = restClient.security().createToken(CreateTokenRequest.passwordGrant(
SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()), SECURITY_REQUEST_OPTIONS);

final String accessToken = response.getAccessToken();
final String refreshToken = response.getRefreshToken();
Instant created = Instant.now();

InvalidateTokenResponse invalidateResponse = restClient.security().invalidateToken(
new InvalidateTokenRequest(response.getAccessToken(), null, null, null), SECURITY_REQUEST_OPTIONS);
new InvalidateTokenRequest(accessToken, null, null, null), SECURITY_REQUEST_OPTIONS);
assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(1));
assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0));
assertThat(invalidateResponse.getErrors().size(), equalTo(0));
Expand All @@ -168,18 +169,31 @@ public void testExpiredTokensDeletedAfterExpiration() throws Exception {
assertBusy(() -> {
if (deleteTriggered.compareAndSet(false, true)) {
// invalidate a invalid token... doesn't matter that it is bad... we just want this action to trigger the deletion
ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () ->
restClient.security().invalidateToken(new InvalidateTokenRequest("fooobar", null, null, null),
SECURITY_REQUEST_OPTIONS));
assertThat(e.getMessage(), containsString("token malformed"));
assertThat(e.status(), equalTo(RestStatus.UNAUTHORIZED));
InvalidateTokenResponse invalidateResponseTwo = restClient.security()
.invalidateToken(new InvalidateTokenRequest("fooobar", null, null, null),
SECURITY_REQUEST_OPTIONS);
assertThat(invalidateResponseTwo.getInvalidatedTokens(), equalTo(0));
assertThat(invalidateResponseTwo.getPreviouslyInvalidatedTokens(), equalTo(0));
assertThat(invalidateResponseTwo.getErrors().size(), equalTo(0));
}
restClient.indices().refresh(new RefreshRequest(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS), SECURITY_REQUEST_OPTIONS);
SearchResponse searchResponse = restClient.search(new SearchRequest(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS)
.source(SearchSourceBuilder.searchSource()
.query(QueryBuilders.termQuery("doc_type", "token")).terminateAfter(1)), SECURITY_REQUEST_OPTIONS);
.source(SearchSourceBuilder.searchSource()
.query(QueryBuilders.termQuery("doc_type", "token")).terminateAfter(1)), SECURITY_REQUEST_OPTIONS);
assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L));
}, 30, TimeUnit.SECONDS);

// Now the documents are deleted, try to invalidate the access token and refresh token again
InvalidateTokenResponse invalidateAccessTokenResponse = restClient.security().invalidateToken(
new InvalidateTokenRequest(accessToken, null, null, null), SECURITY_REQUEST_OPTIONS);
assertThat(invalidateAccessTokenResponse.getInvalidatedTokens(), equalTo(0));
assertThat(invalidateAccessTokenResponse.getPreviouslyInvalidatedTokens(), equalTo(0));
assertThat(invalidateAccessTokenResponse.getErrors().size(), equalTo(0));
InvalidateTokenResponse invalidateRefreshTokenResponse = restClient.security().invalidateToken(
new InvalidateTokenRequest(refreshToken, null, null, null), SECURITY_REQUEST_OPTIONS);
assertThat(invalidateRefreshTokenResponse.getInvalidatedTokens(), equalTo(0));
assertThat(invalidateRefreshTokenResponse.getPreviouslyInvalidatedTokens(), equalTo(0));
assertThat(invalidateRefreshTokenResponse.getErrors().size(), equalTo(0));
}

public void testInvalidateAllTokensForUser() throws Exception {
Expand Down