-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Keycloak modules retry request on authentication error, support refresh token parameter #9494
base: main
Are you sure you want to change the base?
Conversation
…not yet working [8857]
…ith retry logic [8857]
… credentials [8857]
…n, valid refresh token [8857]
…without username/pass [8857]
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.
hi @armkeh thanks for your contribution!
Couple of comments on the 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.
LGTM
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 your contribution! Please add a changelog fragment. Thanks.
token = _get_token_using_credentials(self.module.params) | ||
self.restheaders['Authorization'] = 'Bearer ' + token | ||
|
||
return make_request_ignoring_401() |
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.
Shouldn't the last request be made without ignoring 401?
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've fixed this now; to make the code wrapping the re-requests more consistent, the last request still catches the 401, but it's re-raised afterwards (thanks @russoz for pointing out the failure to raise exceptions in earlier commits).
if refresh_token is not None: | ||
token = _get_token_using_refresh_token(self.module.params) | ||
self.restheaders['Authorization'] = 'Bearer ' + token | ||
|
||
r = make_request_ignoring_401() | ||
if r is not None: | ||
return r |
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 we should not make a new request if the access token has not been refreshed.
if refresh_token is not None: | |
token = _get_token_using_refresh_token(self.module.params) | |
self.restheaders['Authorization'] = 'Bearer ' + token | |
r = make_request_ignoring_401() | |
if r is not None: | |
return r | |
if refresh_token is not None: | |
token = _get_token_using_refresh_token(self.module.params) | |
self.restheaders['Authorization'] = 'Bearer ' + token | |
r = make_request_ignoring_401() | |
if r is not None: | |
return r |
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, good catch; moved the re-requests inside the if statements to avoid making them unnecessarily.
…h token not provided (ansible-collections#8857)
…rn exception on failure (ansible-collections#8857)
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.
Couple of comments more.
def _request(self, url, method, data=None): | ||
def make_request_catching_401(): | ||
try: | ||
return open_url(url, method=method, data=data, | ||
http_agent=self.http_agent, headers=self.restheaders, | ||
timeout=self.connection_timeout, | ||
validate_certs=self.validate_certs) | ||
except HTTPError as e: | ||
if e.code != 401: | ||
raise e | ||
return e | ||
|
||
r = make_request_catching_401() | ||
if not isinstance(r, Exception): | ||
return r | ||
|
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.
Little late in the game for that, I know, but I believe this would be leaner as:
def _request(self, url, method, data=None): | |
def make_request_catching_401(): | |
try: | |
return open_url(url, method=method, data=data, | |
http_agent=self.http_agent, headers=self.restheaders, | |
timeout=self.connection_timeout, | |
validate_certs=self.validate_certs) | |
except HTTPError as e: | |
if e.code != 401: | |
raise e | |
return e | |
r = make_request_catching_401() | |
if not isinstance(r, Exception): | |
return r | |
def _request(self, url, method, data=None): | |
class Unauthorized(Exception): | |
pass | |
def make_request_catching_401(): | |
try: | |
return open_url(url, method=method, data=data, | |
http_agent=self.http_agent, headers=self.restheaders, | |
timeout=self.connection_timeout, | |
validate_certs=self.validate_certs) | |
except HTTPError as e: | |
if e.code != 401: | |
raise e | |
raise Unauthorized() | |
try: | |
return make_request_catching_401() | |
exception Unauthorized: | |
pass | |
except Exception: | |
raise |
and so on
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 like this, however we need to keep the original 401 exception in case no re-auth options are available and it needs to be re-raised. (Thanks for catching the mistake where it was being returned and not raised in your other comment.)
r = make_request_catching_401() | ||
|
||
return r | ||
|
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 last call to make_request_catching_401()
is not being verified. If, for whatever reason, the HTTP request inside that call returns a 401, that Exception object will be returned (not raised) and no error handling will be performed.
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, good catch; I've reworked the retry wrappers to make the flow a little clearer, removing early returns, and made sure to throw the exception if that's the final result.
…ble-collections#8857) Add test to verify this behaviour works.
…thentication failures (ansible-collections#8857)
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.
one possible improvement and a comment, other than that LGTM
try: | ||
r = json.loads(to_native(open_url(auth_url, method='POST', | ||
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, | ||
data=urlencode(payload)).read())) | ||
except ValueError as e: | ||
raise KeycloakError( | ||
'API returned invalid JSON when trying to obtain access token from %s: %s' | ||
% (auth_url, str(e))) | ||
except Exception as e: | ||
raise KeycloakError('Could not obtain access token from %s: %s' | ||
% (auth_url, str(e)), authError=e) | ||
|
||
try: | ||
token = r['access_token'] | ||
except KeyError: | ||
raise KeycloakError( | ||
'Could not obtain access token from %s' % auth_url) | ||
|
||
return 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.
It might be simpler to go:
try: | |
r = json.loads(to_native(open_url(auth_url, method='POST', | |
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, | |
data=urlencode(payload)).read())) | |
except ValueError as e: | |
raise KeycloakError( | |
'API returned invalid JSON when trying to obtain access token from %s: %s' | |
% (auth_url, str(e))) | |
except Exception as e: | |
raise KeycloakError('Could not obtain access token from %s: %s' | |
% (auth_url, str(e)), authError=e) | |
try: | |
token = r['access_token'] | |
except KeyError: | |
raise KeycloakError( | |
'Could not obtain access token from %s' % auth_url) | |
return token | |
try: | |
r = json.loads(to_native(open_url(auth_url, method='POST', | |
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, | |
data=urlencode(payload)).read())) | |
return r['access_token'] | |
except ValueError as e: | |
raise KeycloakError( | |
'API returned invalid JSON when trying to obtain access token from %s: %s' | |
% (auth_url, str(e))) | |
except KeyError: | |
raise KeycloakError( | |
'Could not obtain access token from %s' % auth_url) | |
except Exception as e: | |
raise KeycloakError('Could not obtain access token from %s: %s' | |
% (auth_url, str(e)), authError=e) |
or remove the except KeyError
block entirely - the raised error after that is almost the same.
except HTTPError as e: | ||
if e.code != 401: | ||
raise e | ||
return 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.
This still bugs me on principle - exceptions are meant to be raised not returned :-) - but let's not hold this PR back because of it. I'll make a note to myself to return to this later and see if I can make it any better.
SUMMARY
Fixes #8857.
Wraps all requests to Keycloak in the Keycloak modules (
keycloak_authentication
,keycloak_authz_authorization_scope
,keycloak_authz_custom_policy
, etc.) with retry logic to make use of a newrefresh_token
module parameter.This improves the user experience when using Keycloak modules with the
auth_token
parameter; previously if that token expired during playbook execution, subsequent tasks would fail. Now they "fall back" to using therefresh_token
, or, if it is not provided or is expired itself, to using theauth_username
andauth_password
.ISSUE TYPE
COMPONENT NAME
keycloak