Refactored keypair management

This commit is contained in:
Éloi Rivard 2023-07-01 18:46:11 +02:00
parent c30d2f7161
commit 4f42798e39
15 changed files with 106 additions and 138 deletions

View file

@ -4,6 +4,7 @@ import socket
from collections.abc import Mapping
import toml
from flask import current_app
ROOT = os.path.dirname(os.path.abspath(__file__))
@ -58,7 +59,7 @@ def setup_config(app, config=None, validate_config=True):
)
if app.debug: # pragma: no cover
install(app.config)
install(app.config, debug=True)
if validate_config:
validate(app.config)
@ -81,20 +82,18 @@ def validate_keypair(config):
if (
"OIDC" in config
and "JWT" in config["OIDC"]
and not os.path.exists(config["OIDC"]["JWT"]["PUBLIC_KEY"])
and not config["OIDC"]["JWT"].get("PUBLIC_KEY")
and not current_app.debug
):
raise ConfigurationException(
f'Public key does not exist {config["OIDC"]["JWT"]["PUBLIC_KEY"]}'
)
raise ConfigurationException("No public key has been set")
if (
"OIDC" in config
and "JWT" in config["OIDC"]
and not os.path.exists(config["OIDC"]["JWT"]["PRIVATE_KEY"])
and not config["OIDC"]["JWT"].get("PRIVATE_KEY")
and not current_app.debug
):
raise ConfigurationException(
f'Private key does not exist {config["OIDC"]["JWT"]["PRIVATE_KEY"]}'
)
raise ConfigurationException("No private key has been set")
def validate_smtp_configuration(config):

View file

@ -6,6 +6,6 @@ class InstallationException(Exception):
pass
def install(config):
install_oidc(config)
BaseBackend.get().install(config)
def install(config, debug=False):
install_oidc(config, debug=debug)
BaseBackend.get().install(config, debug=debug)

View file

@ -57,7 +57,7 @@ class Backend(BaseBackend):
setup_ldap_models(config)
@classmethod
def install(cls, config):
def install(cls, config, debug=False):
cls.setup_schemas(config)
with ldap_connection(config) as conn:
models.Token.install(conn)

View file

@ -181,15 +181,15 @@ WRITE = [
# "xxxxxxx-yyyyyyy-zzzzzz",
# ]
# The jwt configuration. You can generate a RSA keypair with:
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC.JWT]
# The path to the private key.
PRIVATE_KEY = "canaille/private.pem"
# The path to the public key.
PUBLIC_KEY = "canaille/public.pem"
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter

View file

@ -41,11 +41,6 @@ from .utils import SCOPE_DETAILS
bp = Blueprint("endpoints", __name__, url_prefix="/oauth")
def get_public_key():
with open(current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]) as fd:
return fd.read()
@bp.route("/authorize", methods=["GET", "POST"])
def authorize():
current_app.logger.debug(
@ -238,7 +233,9 @@ def client_registration_management(client_id):
def jwks():
kty = current_app.config["OIDC"]["JWT"].get("KTY", DEFAULT_JWT_KTY)
alg = current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG)
jwk = JsonWebKey.import_key(get_public_key(), {"kty": kty})
jwk = JsonWebKey.import_key(
current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"], {"kty": kty}
)
return jsonify(
{
"keys": [
@ -291,7 +288,9 @@ def end_session():
)
if data.get("id_token_hint"):
id_token = jwt.decode(data["id_token_hint"], get_public_key())
id_token = jwt.decode(
data["id_token_hint"], current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]
)
if not id_token["iss"] == get_issuer():
return jsonify(
{

View file

@ -1,18 +1,9 @@
import os
from cryptography.hazmat.backends import default_backend as crypto_default_backend
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def install(config):
if (
not config.get("OIDC", {}).get("JWT")
or os.path.exists(config["OIDC"]["JWT"]["PUBLIC_KEY"])
or os.path.exists(config["OIDC"]["JWT"]["PRIVATE_KEY"])
):
return
def generate_keypair():
key = rsa.generate_private_key(
backend=crypto_default_backend(), public_exponent=65537, key_size=2048
)
@ -24,9 +15,20 @@ def install(config):
public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH
)
return private_key, public_key
with open(config["OIDC"]["JWT"]["PUBLIC_KEY"], "wb") as fd:
fd.write(public_key)
with open(config["OIDC"]["JWT"]["PRIVATE_KEY"], "wb") as fd:
fd.write(private_key)
def install(config, debug=False):
if (
not debug
or not config.get("OIDC", {}).get("JWT")
or (
config["OIDC"]["JWT"].get("PUBLIC_KEY")
and config["OIDC"]["JWT"].get("PRIVATE_KEY")
)
):
return
private_key, public_key = generate_keypair()
config["OIDC"]["JWT"]["PUBLIC_KEY"] = public_key.decode()
config["OIDC"]["JWT"]["PRIVATE_KEY"] = private_key.decode()

View file

@ -67,9 +67,8 @@ def get_issuer():
def get_jwt_config(grant):
with open(current_app.config["OIDC"]["JWT"]["PRIVATE_KEY"]) as pk:
return {
"key": pk.read(),
"key": current_app.config["OIDC"]["JWT"]["PRIVATE_KEY"],
"alg": current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG),
"iss": get_issuer(),
"exp": current_app.config["OIDC"]["JWT"].get("EXP", DEFAULT_JWT_EXP),
@ -353,8 +352,7 @@ class ClientManagementMixin:
def resolve_public_key(self, request):
# At the moment the only keypair accepted in software statement
# is the one used to isues JWTs. This might change somedays.
with open(current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"], "rb") as fd:
return fd.read()
return current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]
class ClientRegistrationEndpoint(ClientManagementMixin, _ClientRegistrationEndpoint):

View file

@ -191,10 +191,14 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC.JWT]
# The path to the private key.
PRIVATE_KEY = "conf/private.pem"
# The path to the public key.
PUBLIC_KEY = "conf/public.pem"
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter

View file

@ -192,10 +192,14 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
]
[OIDC.JWT]
# The path to the private key.
PRIVATE_KEY = "conf/private.pem"
# The path to the public key.
PUBLIC_KEY = "conf/public.pem"
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter

View file

@ -22,6 +22,11 @@ Canaille is based on Flask, so any `flask configuration <https://flask.palletspr
:SECRET_KEY:
**Required.** The Flask secret key. You should set a random string here.
.. note ::
Remember that you can also use SECRET_KEY_FILE to store the secret key
outside the configuration file.
:NAME:
*Optional.* The name of your organization. If not set `Canaille` will be used.
@ -190,15 +195,18 @@ OIDC
OIDC.JWT
--------
Canaille needs a key pair to sign the JWT. The installation command will generate a key pair for you, but you can also do it manually.
Canaille needs a key pair to sign the JWT. The installation command will generate a key pair for you, but you can also do it manually. In debug mode, a in-memory keypair will be used.
:PRIVATE_KEY:
**Required.** The path to the private key.
e.g. ``/path/to/private.pem``
**Required.** The content of the private key..
:PUBLIC_KEY:
**Required.** The path to the public key.
e.g. ``/path/to/public.pem``
**Required.** The content of the public key.
.. note ::
Remember that you can also use PRIVATE_KEY_FILE and PUBLIC_KEY_FILE
to store the keys outside the configuration file.
:ISS:
*Optional.* The URI of the identity provider.

View file

@ -39,19 +39,18 @@ You should then edit your configuration file to adapt the values to your needs.
Installation
============
Automatic installation
----------------------
Automatic schemas installation
------------------------------
A few steps of the installation process can be automatized.
If you want to install the LDAP schemas or generate the keypair yourself, then you can jump to the manual installation section.
If you want to install the LDAP schemas yourself, then you can jump to the manual installation section.
.. code-block:: bash
env CONFIG="$CANAILLE_CONF_DIR/config.toml" "$CANAILLE_INSTALL_DIR/env/bin/canaille" install
Manual installation
-------------------
Manual schemas installation
---------------------------
LDAP schemas
^^^^^^^^^^^^
@ -84,7 +83,7 @@ Be careful to stop your ldap server before running ``slapadd``
sudo service slapd start
Generate the key pair
^^^^^^^^^^^^^^^^^^^^^
---------------------
You must generate a keypair that canaille will use to sign tokens.
You can customize those commands, as long as they match the ``JWT`` section of your configuration file.

View file

@ -1,29 +1,14 @@
import os
import pytest
from canaille import create_app
from canaille.commands import cli
from flask_webtest import TestApp
from canaille.oidc.installation import install
@pytest.fixture
def configuration(ldap_configuration):
yield ldap_configuration
def test_install_keypair(configuration):
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
install(configuration, debug=False)
assert "PRIVATE_KEY" not in configuration["OIDC"]["JWT"]
assert "PUBLIC_KEY" not in configuration["OIDC"]["JWT"]
def test_install_keypair(configuration, tmpdir):
keys_dir = os.path.join(tmpdir, "keys")
os.makedirs(keys_dir)
configuration["OIDC"]["JWT"]["PRIVATE_KEY"] = os.path.join(keys_dir, "private.pem")
configuration["OIDC"]["JWT"]["PUBLIC_KEY"] = os.path.join(keys_dir, "public.pem")
assert not os.path.exists(configuration["OIDC"]["JWT"]["PRIVATE_KEY"])
assert not os.path.exists(configuration["OIDC"]["JWT"]["PUBLIC_KEY"])
testclient = TestApp(create_app(configuration, validate=False))
runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["install"])
assert res.exit_code == 0, res.stdout
assert os.path.exists(configuration["OIDC"]["JWT"]["PRIVATE_KEY"])
assert os.path.exists(configuration["OIDC"]["JWT"]["PUBLIC_KEY"])
install(configuration, debug=True)
assert configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
assert configuration["OIDC"]["JWT"]["PUBLIC_KEY"]

View file

@ -5,11 +5,9 @@ import uuid
import pytest
from authlib.oidc.core.grants.util import generate_id_token
from canaille.app import models
from canaille.oidc.installation import generate_keypair
from canaille.oidc.oauth import generate_user_info
from canaille.oidc.oauth import get_jwt_config
from cryptography.hazmat.backends import default_backend as crypto_default_backend
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from werkzeug.security import gen_salt
@ -23,44 +21,18 @@ def app(app, configuration, backend):
@pytest.fixture(scope="session")
def keypair():
key = rsa.generate_private_key(
backend=crypto_default_backend(), public_exponent=65537, key_size=2048
)
private_key = key.private_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PrivateFormat.PKCS8,
crypto_serialization.NoEncryption(),
)
public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.OpenSSH, crypto_serialization.PublicFormat.OpenSSH
)
return private_key, public_key
return generate_keypair()
@pytest.fixture
def keypair_path(keypair, tmp_path):
def configuration(configuration, keypair):
private_key, public_key = keypair
private_key_path = os.path.join(tmp_path, "private.pem")
with open(private_key_path, "wb") as fd:
fd.write(private_key)
public_key_path = os.path.join(tmp_path, "public.pem")
with open(public_key_path, "wb") as fd:
fd.write(public_key)
return private_key_path, public_key_path
@pytest.fixture
def configuration(configuration, keypair_path):
private_key_path, public_key_path = keypair_path
conf = {
**configuration,
"OIDC": {
"JWT": {
"PUBLIC_KEY": public_key_path,
"PRIVATE_KEY": private_key_path,
"PUBLIC_KEY": public_key,
"PRIVATE_KEY": private_key,
"ISS": "https://auth.mydomain.tld",
}
},

View file

@ -22,19 +22,19 @@ def test_issuer(testclient):
assert get_issuer() == "http://localhost/"
def test_no_private_key(configuration):
configuration["OIDC"]["JWT"]["PRIVATE_KEY"] = "invalid-path"
def test_no_private_key(testclient, configuration):
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
with pytest.raises(
ConfigurationException,
match=r"Private key does not exist",
match=r"No private key has been set",
):
validate(configuration)
def test_no_public_key(configuration):
configuration["OIDC"]["JWT"]["PUBLIC_KEY"] = "invalid-path"
def test_no_public_key(testclient, configuration):
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
with pytest.raises(
ConfigurationException,
match=r"Public key does not exist",
match=r"No public key has been set",
):
validate(configuration)

View file

@ -120,8 +120,8 @@ def test_client_registration_with_authentication_invalid_token(
}
def test_client_registration_with_software_statement(testclient, backend, keypair_path):
private_key_path, _ = keypair_path
def test_client_registration_with_software_statement(testclient, backend, keypair):
private_key, _ = keypair
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
software_statement_payload = {
@ -132,8 +132,6 @@ def test_client_registration_with_software_statement(testclient, backend, keypai
"grant_types": ["authorization_code"],
}
software_statement_header = {"alg": "RS256"}
with open(private_key_path) as fd:
private_key = fd.read()
software_statement = jwt.encode(
software_statement_header, software_statement_payload, private_key
).decode()