Impementing PCKE for OIDC in Galaxy
While Galaxy has OIDC support that is fairly straightforward to set up, it does not support Proof Key for Code Exchange (PKCE) parameters currently. In order to add support for these parameters we need to modify the OIDC authorization code.
The file that we need to edit is galaxy/lib/galaxy/authnz/custos_authnz.py
. At the beginning of this file after the imports, we see that there are some constants defined. These are the names given to various cookies that get saved during the authentication process. Here we want to define one more constant that I call VERIFIER_COOKIE_NAME
and then set its value to galaxy-oidc-verifier
or something to that effect. An example is listed here:
STATE_COOKIE_NAME = 'galaxy-oidc-state'
NONCE_COOKIE_NAME = 'galaxy-oidc-nonce'
VERIFIER_COOKIE_NAME = 'galaxy-oidc-verifier'
After adding this constant, we can move to the authenticate method. Here we want to do the following: generate a code verifier and code challenge, add the code challenge and code challenge method (S256) to the authentication url that is being generated, and then save the code verifier as a cookie that we'll retrieve later. We'll start with the first task.
Right after the nonce is generated, we want to make sure that we generate a proper code verifier and code challenge. In order to do this, we need a few functions that we can call. Using https://github.com/RomeoDespres/pkce/blob/master/pkce/__init__.py
as a reference (you could import this package, however then we would have to deal with managing Galaxy's dependencies), I created a new python file called pkce_utils.py
in this directory and put the following in there:
"""
MIT License
Copyright (c) 2020 Roméo Després
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import secrets
import hashlib
import base64
from typing import Tuple
def generate_code_verifier(length: int = 128) -> str:
"""Return a random PKCE-compliant code verifier.
Parameters
----------
length : int
Code verifier length. Must verify `43 <= length <= 128`.
Returns
-------
code_verifier : str
Code verifier.
Raises
------
ValueError
When `43 <= length <= 128` is not verified.
"""
if not 43 <= length <= 128:
msg = 'Parameter `length` must verify `43 <= length <= 128`.'
raise ValueError(msg)
code_verifier = secrets.token_urlsafe(96)[:length]
return code_verifier
def generate_pkce_pair(code_verifier_length: int = 128) -> Tuple[str, str]:
"""Return random PKCE-compliant code verifier and code challenge.
Parameters
----------
code_verifier_length : int
Code verifier length. Must verify
`43 <= code_verifier_length <= 128`.
Returns
-------
code_verifier : str
code_challenge : str
Raises
------
ValueError
When `43 <= code_verifier_length <= 128` is not verified.
"""
if not 43 <= code_verifier_length <= 128:
msg = 'Parameter `code_verifier_length` must verify '
msg += '`43 <= code_verifier_length <= 128`.'
raise ValueError(msg)
code_verifier = generate_code_verifier(code_verifier_length)
code_challenge = get_code_challenge(code_verifier)
return code_verifier, code_challenge
def get_code_challenge(code_verifier: str) -> str:
"""Return the PKCE-compliant code challenge for a given verifier.
Parameters
----------
code_verifier : str
Code verifier. Must verify `43 <= len(code_verifier) <= 128`.
Returns
-------
code_challenge : str
Code challenge that corresponds to the input code verifier.
Raises
------
ValueError
When `43 <= len(code_verifier) <= 128` is not verified.
"""
if not 43 <= len(code_verifier) <= 128:
msg = 'Parameter `code_verifier` must verify '
msg += '`43 <= len(code_verifier) <= 128`.'
raise ValueError(msg)
hashed = hashlib.sha256(code_verifier.encode('ascii')).digest()
encoded = base64.urlsafe_b64encode(hashed)
code_challenge = encoded.decode('ascii')[:-1]
return code_challenge
Instead of creating a new file, you can also just put these functions and copyright (MIT license) inside of custos_authnz.py
directly.
Now that we can generate the appropriate parameters, we can use them in the authenticate()
method:
if "extra_params" in self.config:
extra_params.update(self.config['extra_params'])
code_verifier, code_challenge = pkce_utils.generate_pkce_pair(96)
extra_params['code_challenge'] = code_challenge
extra_params['code_challenge_method'] = 'S256'
authorization_url, state = oauth2_session.authorization_url(
base_authorize_url, **extra_params)
trans.set_cookie(value=code_verifier, name=VERIFIER_COOKIE_NAME)
We generate the parameters and store them in code_verifier
and code_challenge
. We then add the code challenge to the extra parameters table alongside the code challenge method which is "S256". Finally, we set a cookie with the value of the constant that we made earlier as the name and store the code verifier in said cookie. Now the code challenge should be added to the authentication url when this method is called.
Moving on to the fetch_token()
method, we now need to send the code verifier in the token request. First, we'll retrieve the cookie that we stored earlier, and then delete the cookie value. Then we'll add the verifier to the function call, which will just add it as an extra parameter in the request.
clientIdAndSec = f"{self.config['client_id']}:{self.config['client_secret']}" # for custos
code_verifier_cookie = trans.get_cookie(name=VERIFIER_COOKIE_NAME)
trans.set_cookie('', name=VERIFIER_COOKIE_NAME, age=-1)
return oauth2_session.fetch_token(
token_endpoint,
client_secret=client_secret,
authorization_response=trans.request.url,
include_client_id=True,
headers={"Authorization": f"Basic {util.unicodify(base64.b64encode(util.smart_str(clientIdAndSec)))}"}, # for custos
verify=self._get_verify_param(),
code_verifier=code_verifier_cookie)
With this change, the token request should be successful if the user logs in successfully.
While this should implement PKCE parameters for all requests, I'm working on incorporating config options to turn PKCE on or off and will update this if I get that working.