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

Added keycloak authentication #3486

Merged
merged 9 commits into from Apr 21, 2022
Merged

Conversation

iDmple
Copy link
Contributor

@iDmple iDmple commented Mar 17, 2022

Fixes #3334

I added keycloak authentication as discussed. This is needed for it:

pip3 install requests==2.25.1 urllib3==1.26.5 oauthlib python-keycloak

These specific versions are due to this issue.

Regarding how the XPRA auth is working, I'm unsure how to display the error messages in the client when the authentication fails in the function check, but it displays fine when it fails in __init__.

Please check this PR together with the associated PR in xpra-html5. Let me know if anything needs to be improved.

Copy link
Collaborator

@totaam totaam left a comment

Choose a reason for hiding this comment

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

This implements keycloak auth purely on the server so this is not safe at all over unencrypted connections.
All the client does is to send the password unencrypted. That's not how keycloak is meant to be used.

I could merge this module as keycloak_password or something - similar to the kerberos_password we already have.
But not the client changes.

A proper keycloak module would do the request from the client and send the token to the server only.

xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
@iDmple
Copy link
Contributor Author

iDmple commented Mar 18, 2022

I think there is a misunderstanding, this does not implement the grant_type password. This implements authorization_code. There is no password involved at all! In fact, it's a part of the protocol that the authorization code is sent over network as a URL parameter.

If you want me to request the token in the client, and then validate it in the server, that's possible, but I'm not sure what this changes security-wise. We would actually send a lot of info about the user, instead of just the authorization code...

Access-Token-Security-with-Keycloak

@totaam
Copy link
Collaborator

totaam commented Mar 18, 2022

There is no password involved at all!

There definitely is:
https://github.com/Xpra-org/xpra/pull/3486/files#diff-1a9bde312dd0af09b7dfac24f0b54286d45366435e1a1a8614f2981f46619398

I'm not sure what this changes security-wise.

It means that with your authentication module, the password is not sent over the network - often unencrypted.

@iDmple
Copy link
Contributor Author

iDmple commented Mar 18, 2022

What is sent through that is the authorization code, this isn't a password. I will improve this file to use another variable name instead.

@iDmple
Copy link
Contributor Author

iDmple commented Mar 21, 2022

I'm not sure what the handler file was for. I seem to have needed it at some point, but in the end, it isn't used at all so I removed it.

I hope these changes address your concerns.

@totaam
Copy link
Collaborator

totaam commented Mar 22, 2022

Please don't be put off by the number of comments below, it's all fairly minor at this point.

A few more questions about testing:

  • how do I go about testing this - preferably in an automated kind of way? (either mocking it, or using automated instructions for setting up a test keycloak instance)
  • can you add a trivial command line interface to run a simple authentication test? Something similar to:
    def main(args):
    if len(args)!=3:
    print("invalid number of arguments")
    print("usage:")
    print("%s username password" % (args[0],))
    return 1
    username = args[1]
    a = Authenticator(username=username)
    if a.check(args[2]):
    print("success")
    return 0
    else:
    print("failed")
    return -1

xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
self.redirect_uri = kwargs.pop("redirect_uri", KEYCLOAK_REDIRECT_URI)
self.scope = kwargs.pop("scope", KEYCLOAK_SCOPE)
self.grant_type = kwargs.pop("grant_type", KEYCLOAK_GRANT_TYPE)
kwargs["prompt"] = kwargs.pop("prompt", "keycloak")
Copy link
Collaborator

Choose a reason for hiding this comment

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

It wouldn't hurt to add a comment here, like "use keycloak as default prompt".

Copy link
Collaborator

Choose a reason for hiding this comment

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

minor: keycloak token as default prompt would be clearer IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When we add more grant_types we could have

  • keycloak password
  • keycloak authorization code
  • others

But never token. We could also implement them in different files if too different / complex.

We can add what you prefer here but in this case it will never be shown, or I don't see how. And it will never ask for a token.

xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
xpra/client/client_base.py Outdated Show resolved Hide resolved
log.error("Error: client does not support keycloak authentication")
return None
self.challenge_sent = True
return self.salt, "keycloak"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't this be password since there is no keycloak challenge handler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, this is correct. We are using this in the client. There is no password.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Just thinking out loud here now that I have figured out what I meant here.


The token is the "password" in this context (client auth modules) and it is sent unencrypted (see the change to xpra/net/digest.py). That's because the token is not meant to be re-usable from another client - how that's enforced is the responsibility of keycloak.

What bothers me here is not new to this authentication module so this is not a showstopper: the client will be prompting the user for a "keycloak" (and I think it should say keycloak token instead) and then sending it unencrypted without telling the user that this is the case, which could end up leaking a real password the user types in accidentally. (yes, users will do stupid things - I do too)

The kerberos and gss client handlers acquire the token themselves:

token = kerberos.authGSSClientResponse(ctx)

token = ctx.step()

And I was hoping that a keycloak client authentication module could do the same thing.
But the problem with all those modules is that when they fail, we end up also calling the prompt client auth handler as fallback..

The prompt handler even has an ugly default prompt specific for kerberos and gss:

if digest.startswith("gss:") or digest.startswith("kerberos:"):
prompt = "%s token" % (digest.split(":", 1)[0])

I am just thinking that it should be more obvious to the user that what they type in the prompt handler can be intercepted in this case.
(and obviously skip this warning if the connection to the server is actually encrypted with ssl, wss, ssh or aes)

xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
@totaam
Copy link
Collaborator

totaam commented Apr 12, 2022

Bump!

@iDmple
Copy link
Contributor Author

iDmple commented Apr 12, 2022

Hey, the work is almost done but I'm currently on holidays. Will finish it up next week!

@iDmple
Copy link
Contributor Author

iDmple commented Apr 19, 2022

Please don't be put off by the number of comments below, it's all fairly minor at this point.

A few more questions about testing:

  • how do I go about testing this - preferably in an automated kind of way? (either mocking it, or using automated instructions for setting up a test keycloak instance)
  • can you add a trivial command line interface to run a simple authentication test? Something similar to:
    def main(args):
    if len(args)!=3:
    print("invalid number of arguments")
    print("usage:")
    print("%s username password" % (args[0],))
    return 1
    username = args[1]
    a = Authenticator(username=username)
    if a.check(args[2]):
    print("success")
    return 0
    else:
    print("failed")
    return -1

About testing this I'm not sure. I'm not running my own keycloak instance. Maybe we will do so in the future.
I will try to add a command line interface. This is the last missing part.

@totaam
Copy link
Collaborator

totaam commented Apr 20, 2022

You've "resolved" the review without actually addressing any of the numerous comments I made.
Am I missing something? Did you forget to push your changes?

@iDmple
Copy link
Contributor Author

iDmple commented Apr 20, 2022

I'm going to push them soon, it was just easier to understand what I addressed already that way.

I have a question. I would like to implement the logout functionality. Do you have any pointers or examples on how to do that?

@totaam
Copy link
Collaborator

totaam commented Apr 20, 2022

I have a question. I would like to implement the logout functionality.

Good point. This wasn't needed until now.
I'll see if we can add a session hook.

@iDmple
Copy link
Contributor Author

iDmple commented Apr 20, 2022

I have a question. I would like to implement the logout functionality.

Good point. This wasn't needed until now. I'll see if we can add a session hook.

It's not urgent, just nice to have! Thanks!

@iDmple
Copy link
Contributor Author

iDmple commented Apr 20, 2022

Please don't be put off by the number of comments below, it's all fairly minor at this point.

A few more questions about testing:

  • how do I go about testing this - preferably in an automated kind of way? (either mocking it, or using automated instructions for setting up a test keycloak instance)
  • can you add a trivial command line interface to run a simple authentication test? Something similar to:
    def main(args):
    if len(args)!=3:
    print("invalid number of arguments")
    print("usage:")
    print("%s username password" % (args[0],))
    return 1
    username = args[1]
    a = Authenticator(username=username)
    if a.check(args[2]):
    print("success")
    return 0
    else:
    print("failed")
    return -1

I added the changes we discussed about.

In order to test you can call it that way:

python3 keycloak_auth.py '{"code":"authorization_code"}'

The authorization_code must be requested from the keycloak auth endpoint. I hope it's suitable.

Copy link
Collaborator

@totaam totaam left a comment

Choose a reason for hiding this comment

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

python3 keycloak_auth.py '{"code":"authorization_code"}'
The authorization_code must be requested from the keycloak auth endpoint. I hope it's suitable.

It is fine.
A couple of questions:

  • if a valid response is just a JSON string, wouldn't a keycloak auth client be trivial to implement?
  • do you have sample responses we could add to a unit test? (which is missing BTW)

There are just a few nitpicks more that I just found in the PR.
If you don't want to take care of them, just let me know and I'll tweak things after merging this PR.

xpra/server/auth/keycloak_auth.py Show resolved Hide resolved
xpra/server/auth/keycloak_auth.py Outdated Show resolved Hide resolved
log.error("Error: client does not support keycloak authentication")
return None
self.challenge_sent = True
return self.salt, "keycloak"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just thinking out loud here now that I have figured out what I meant here.


The token is the "password" in this context (client auth modules) and it is sent unencrypted (see the change to xpra/net/digest.py). That's because the token is not meant to be re-usable from another client - how that's enforced is the responsibility of keycloak.

What bothers me here is not new to this authentication module so this is not a showstopper: the client will be prompting the user for a "keycloak" (and I think it should say keycloak token instead) and then sending it unencrypted without telling the user that this is the case, which could end up leaking a real password the user types in accidentally. (yes, users will do stupid things - I do too)

The kerberos and gss client handlers acquire the token themselves:

token = kerberos.authGSSClientResponse(ctx)

token = ctx.step()

And I was hoping that a keycloak client authentication module could do the same thing.
But the problem with all those modules is that when they fail, we end up also calling the prompt client auth handler as fallback..

The prompt handler even has an ugly default prompt specific for kerberos and gss:

if digest.startswith("gss:") or digest.startswith("kerberos:"):
prompt = "%s token" % (digest.split(":", 1)[0])

I am just thinking that it should be more obvious to the user that what they type in the prompt handler can be intercepted in this case.
(and obviously skip this warning if the connection to the server is actually encrypted with ssl, wss, ssh or aes)

self.redirect_uri = kwargs.pop("redirect_uri", KEYCLOAK_REDIRECT_URI)
self.scope = kwargs.pop("scope", KEYCLOAK_SCOPE)
self.grant_type = kwargs.pop("grant_type", KEYCLOAK_GRANT_TYPE)
kwargs["prompt"] = kwargs.pop("prompt", "keycloak")
Copy link
Collaborator

Choose a reason for hiding this comment

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

minor: keycloak token as default prompt would be clearer IMO.

xpra/net/digest.py Outdated Show resolved Hide resolved
@iDmple
Copy link
Contributor Author

iDmple commented Apr 21, 2022

python3 keycloak_auth.py '{"code":"authorization_code"}'
The authorization_code must be requested from the keycloak auth endpoint. I hope it's suitable.

It is fine. A couple of questions:

  • if a valid response is just a JSON string, wouldn't a keycloak auth client be trivial to implement?
  • do you have sample responses we could add to a unit test? (which is missing BTW)

There are just a few nitpicks more that I just found in the PR. If you don't want to take care of them, just let me know and I'll tweak things after merging this PR.

How the client works, if you look at the diagram I sent above, is indeed very simple. We redirect to the auth endpoint, the user logs into keycloak. If successful we are redirected back and an authorization code is returned as a url parameter which we pass as a json. Then in the server (this PR), we use this authorization code to get a token on the token endpoint, then validate the token using the introspect endpoint.

This is exactly what I implemented in the client PR. Could you please review it while I take care of the last details here?

Regarding the unit tests, there is only one value that we can give, which is just a random string like above which is going to fail the auth, unless you have access to a keycloak server and can get a valid authorization code during testing.

@totaam
Copy link
Collaborator

totaam commented Apr 21, 2022

Regarding the unit tests, there is only one value that we can give, which is just a random string like above which is going to fail the auth, unless you have access to a keycloak server and can get a valid authorization code during testing.

Is the JSON string always just one code attribute and nothing else?
Aren't there other attributes that can be echanged?

As for the unit tests, even testing that authentication fails will at least exercise some of the code and increase code coverage.
Feed it:

  • invalid JSON string, None value, etc..
  • valid JSON string with garbage in it
  • valid JSON string with a code value (but either empty, a number, a float, etc..)
  • valid JSON string with a string code

Ideally we would mock a keycloak server to validate fully - but that's too much effort.

@iDmple
Copy link
Contributor Author

iDmple commented Apr 21, 2022

The token is the "password" in this context (client auth modules) and it is sent unencrypted (see the change to xpra/net/digest.py). That's because the token is not meant to be re-usable from another client - how that's enforced is the responsibility of keycloak.

In this case, this isn't the token that is sent unencrypted (it still uses SSL), it's the authorization code.

What bothers me here is not new to this authentication module so this is not a showstopper: the client will be prompting the user for a "keycloak" (and I think it should say keycloak token instead) and then sending it unencrypted without telling the user that this is the case, which could end up leaking a real password the user types in accidentally. (yes, users will do stupid things - I do too)

The client isn't prompting anything to the user. The client is redirecting to an URL to get the authorization code. I'm not sure in which conditions we would be able to see this prompt? Even if that happened, what it should prompt the user would be the authorization code, not the token. Keycloak has several grant_types and if we were to implement the password grant_type then we would be in the situation you mention, yes. However this isn't how this works with the authorization_code grant_type, please see diagram and explanation above.

The kerberos and gss client handlers acquire the token themselves:
And I was hoping that a keycloak client authentication module could do the same thing.
Indeed the keycloak authentication module does also aquire the token themselves:

# Get token
token = keycloak_openid.token(code=auth_code, grant_type=[KEYCLOAK_GRANT_TYPE], redirect_uri=KEYCLOAK_REDIRECT_URI)

The way that this authentication module works is not the same as gss/kerberos.

I hope I could clarify.

@iDmple
Copy link
Contributor Author

iDmple commented Apr 21, 2022

Regarding the unit tests, there is only one value that we can give, which is just a random string like above which is going to fail the auth, unless you have access to a keycloak server and can get a valid authorization code during testing.

Is the JSON string always just one code attribute and nothing else? Aren't there other attributes that can be echanged?

As for the unit tests, even testing that authentication fails will at least exercise some of the code and increase code coverage. Feed it:

  • invalid JSON string, None value, etc..
  • valid JSON string with garbage in it
  • valid JSON string with a code value (but either empty, a number, a float, etc..)
  • valid JSON string with a string code

Ideally we would mock a keycloak server to validate fully - but that's too much effort.

Actually it can also have an error inside. Let me send you an example in a minute.

Edit:

here is an example of error we can find in the response_json. It will either have a code or an error in this form:
{'error': 'invalid_scope', 'error_description': 'Invalid scopes: openid-profile email roles group team'}

@totaam totaam merged commit 3a5c73b into Xpra-org:master Apr 21, 2022
totaam added a commit that referenced this pull request Apr 21, 2022
* whitespace was wrong (4 spaces)
* empty lines
* error message format
* consistency: check for failures and shortcut out, success at the end
* unused import
* 'raise' format
@totaam
Copy link
Collaborator

totaam commented Apr 26, 2022

Stumbled upon this good overview of oauth2: The complete guide to protecting your APIs with OAuth2 - part 1

totaam added a commit that referenced this pull request Apr 30, 2022
the kwargs were being parsed but not actually used
@totaam
Copy link
Collaborator

totaam commented Apr 30, 2022

@iDmple please take a look at the obvious fixes from the unit test I have added - since I don't have a keycloak server to test against.
This is why we needed unit tests to merge this code.

totaam added a commit that referenced this pull request Apr 30, 2022
totaam added a commit that referenced this pull request Apr 30, 2022
totaam added a commit that referenced this pull request Apr 30, 2022
totaam added a commit that referenced this pull request Apr 30, 2022
totaam added a commit that referenced this pull request Apr 30, 2022
totaam added a commit that referenced this pull request Aug 28, 2022
@iDmple iDmple deleted the keycloak branch April 4, 2024 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

keycloak authentication module
2 participants