forked from Github-Mirrors/canaille
Refactored keypair management
This commit is contained in:
parent
c30d2f7161
commit
4f42798e39
15 changed files with 106 additions and 138 deletions
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue