From f93b4451fbb59f1ac6b80641f730a1788b126f6a Mon Sep 17 00:00:00 2001 From: Felipe Reyes Date: Tue, 16 Aug 2022 10:44:26 -0400 Subject: [PATCH] Add OIDCOAuth config to Apache. This change introduces the configuration of OAuth to enable auth-openidc which is browser-less. When enable-oauth is set to true (the default) and oidc-auth-verify-jwks-uri is empty, the charm will try use oidc-oauth-introspection-endpoint if set, otherwise the charm will fetch the content at oidc-provider-metadata-url and use the value available at the key introspection_endpoint. --- config.yaml | 23 ++++++ requirements.txt | 1 + src/charm.py | 105 +++++++++++++++++-------- templates/apache-openidc-location.conf | 41 +++++++++- 4 files changed, 136 insertions(+), 34 deletions(-) diff --git a/config.yaml b/config.yaml index 36be16f..7994768 100644 --- a/config.yaml +++ b/config.yaml @@ -85,3 +85,26 @@ options: description: | The claim that is used when setting the REMOTE_USER variable on OpenID Connect protected paths, for example: email. + oidc-provider-jwks-uri: + default: '' + type: string + description: | + . + enable-oauth: + default: true + type: boolean + description: | + Set to true to enable OAuth2 support. + oidc-oauth-verify-jwks-uri: + default: '' + type: string + description: | + The JWKs URL on which the Authorization Server publishes the keys used + to sign its JWT access tokens. + oidc-oauth-introspection-endpoint: + default: '' + type: string + description: | + OAuth 2.0 Authorization Server token introspection endpoint. When + `enable-oauth` is set to true and this option unset (the default), the + introspection endpoint available in the metadata will be used. diff --git a/requirements.txt b/requirements.txt index 8cce361..51d8d88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ ops>=1.5.0 git+https://opendev.org/openstack/charm-ops-openstack@master#egg=ops_openstack +requests diff --git a/src/charm.py b/src/charm.py index 4a3f21b..609b275 100755 --- a/src/charm.py +++ b/src/charm.py @@ -22,11 +22,12 @@ import subprocess from typing import List from uuid import uuid4 +import ops_openstack.core +import requests + from ops.main import main from ops.model import StatusBase, ActiveStatus, BlockedStatus -import ops_openstack.core - from ops_openstack.adapters import ( ConfigurationAdapter, ) @@ -61,7 +62,7 @@ class KeystoneOpenIDCOptions(ConfigurationAdapter): @property def hostname(self) -> str: - """Hostname as advertised by the principal charm""" + """Hostname as advertised by the principal charm.""" data = self._get_principal_data() try: return json.loads(data['hostname']) @@ -70,6 +71,7 @@ class KeystoneOpenIDCOptions(ConfigurationAdapter): @property def openidc_location_config(self) -> str: + """Path to the file with the OpenID Connect configuration.""" return os.path.join(self.charm_instance.config_dir, f'openidc-location.{self.idp_id}.conf') @@ -81,7 +83,7 @@ class KeystoneOpenIDCOptions(ConfigurationAdapter): @property def idp_id(self) -> str: - return self.charm_instance.unit.app.name + return 'openid' @property def scheme(self) -> str: @@ -115,7 +117,24 @@ class KeystoneOpenIDCOptions(ConfigurationAdapter): logger.debug('Using oidc-crypto-passphrase from app databag') return crypto_passphrase else: - logger.warn('The oidc-crypto-passphrase has not been set') + logger.warning('The oidc-crypto-passphrase has not been set') + return None + + @property + def metadata(self): + """Metadata content offered by the Identity Provider. + + The content available at the url configured in + oidc-provider-metadata-url is read and parsed as json. + """ + if self.oidc_provider_metadata_url: + logging.info('GETing content from %s', + self.oidc_provider_metadata_url) + r = requests.get(self.oidc_provider_metadata_url) + return r.json() + else: + logging.info('Metadata was not retrieved since ' + 'oidc-provider-metadata-url is not set') return None @@ -126,6 +145,8 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): REQUIRED_RELATIONS = ['keystone-fid-service-provider', 'websso-fid-service-provider'] + REQUIRED_KEYS = ['oidc_crypto_passphrase', 'oidc_client_id', + 'hostname', 'port', 'scheme'] APACHE2_MODULE = 'auth_openidc' CONFIG_FILE_OWNER = 'root' @@ -133,7 +154,7 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): release = 'xena' # First release supported. - auth_method = 'mapped' # the driver to be used. + auth_method = 'openid' # the driver to be used. def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -168,14 +189,22 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): ) # Event handlers - - # Extending the default handler for install hook to enable the apache2 - # openidc module. def on_install(self, _): + """Install hook handler. + + This event handler installs the list of packages defined in the + property PACKAGES and enables the openidc apache module. + """ super().on_install(_) self.enable_module() def _on_start(self, _): + """Start hook handler. + + Set the flag `is_started` which is consumed by the update-status + hook. This charm doesn't run new services, so there is no need to + start anything. + """ self._stored.is_started = True def _on_keystone_fid_service_provider_relation_joined(self, event): @@ -189,7 +218,9 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): relation = self.model.get_relation('keystone-fid-service-provider') data = relation.data[self.unit] - data['auth-method'] = json.dumps(self.auth_method) + # When (if) this patch is merged, we can use auth-method + # https://review.opendev.org/c/openstack/charm-keystone/+/852601 + # data['auth-method'] = json.dumps(self.auth_method) data['protocol-name'] = json.dumps(self.options.idp_id) data['remote-id-attribute'] = json.dumps( self.options.remote_id_attribute) @@ -230,39 +261,37 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): for relation in relations: data = relation.data[self.unit.app] break - logger.info('Generating oidc-client-secret') - client_secret = str(uuid4()) - data.update({'oidc-client-secret': client_secret}) + logger.info('Generating oidc-crypto-passphrase') + data.update({'oidc-crypto-passphrase': str(uuid4())}) else: - logger.debug('Not leader, skipping oidc-client-secret generation') + logger.debug('Not leader, skipping oidc-crypto-passphrase ' + 'generation') def _on_cluster_relation_changed(self, _): self._on_config_changed(_) - # properties - @property - def restart_map(self): - return {self.options.openidc_location_config: ['apache2']} - - @property - def restart_functions(self): - return {'apache2': self.request_restart} - def is_data_ready(self) -> bool: if not self.model.get_relation('cluster'): return False + return len(self.find_missing_keys()) == 0 + + def find_missing_keys(self) -> List[str]: + + """Find keys not set that are needed for the charm to work correctly. + + :returns: List of configuration keys that need to be set and are not. + """ options = KeystoneOpenIDCOptions(self) - required_keys = ['oidc_crypto_passphrase', 'oidc_client_id', - 'hostname', 'port', 'scheme'] missing_keys = [] - for key in required_keys: + for key in self.REQUIRED_KEYS: if getattr(options, key) in [None, '']: missing_keys.append(key) if missing_keys: logger.debug('Incomplete data: %s', ' '.join(missing_keys)) - return len(missing_keys) == 0 + + return missing_keys def services(self) -> List[str]: """Determine the list of services that should be running.""" @@ -275,19 +304,24 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): return BlockedStatus('incomplete data') def enable_module(self): - logger.info(f'Enabling apache2 module: {self.APACHE2_MODULE}') + """Enable oidc Apache module.""" + logger.info('Enabling apache2 module: %s', self.APACHE2_MODULE) subprocess.check_call(['a2enmod', self.APACHE2_MODULE]) def disable_module(self): - logger.info(f'Disabling apache2 module: {self.APACHE2_MODULE}') + """Disable oidc Apache module.""" + logger.info('Disabling apache2 module: %s', self.APACHE2_MODULE) subprocess.check_call(['a2dismod', self.APACHE2_MODULE]) def request_restart(self, service_name=None): - """Request a restart of the service to the principal.""" + """Request a restart of the service to the principal. + + :param service_name: name of the service to restart, but unused. + """ relation = self.model.get_relation('keystone-fid-service-provider') data = relation.data[self.unit] - logger.debug('Requesting a restart to the principal charm') + logger.info('Requesting a restart to the principal charm') data['restart-nonce'] = json.dumps(str(uuid4())) def render_config(self): @@ -307,6 +341,15 @@ class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm): perms=0o440 ) + # properties + @property + def restart_map(self): + return {self.options.openidc_location_config: ['apache2']} + + @property + def restart_functions(self): + return {'apache2': self.request_restart} + @property def config_dir(self): return CONFIG_DIR diff --git a/templates/apache-openidc-location.conf b/templates/apache-openidc-location.conf index 6139431..dbd06be 100644 --- a/templates/apache-openidc-location.conf +++ b/templates/apache-openidc-location.conf @@ -33,13 +33,48 @@ OIDCCryptoPassphrase {{ options.oidc_crypto_passphrase }} OIDCRedirectURI {{ options.scheme }}://{{ options.hostname }}:{{ options.port }}/v3/OS-FEDERATION/identity_providers/{{ options.idp_id }}/protocols/{{ options.protocol_id }}/auth {% if options.oidc_remote_user_claim -%} -OICDRemoteUserClaim {{ options.oidc_remote_user_claim }} +OIDCRemoteUserClaim {{ options.oidc_remote_user_claim }} {% endif -%} +{% if options.oidc_provider_jwks_uri -%} +OIDCProviderJwksUri {{ options.oidc_provider_jwks_uri }} +{% endif -%} + +{%- if options.enable_oauth %} +{%- if options.oidc_auth_verify_jwks_uri %} +OIDCOAuthVerifyJwksUri {{ options.oidc_auth_verify_jwks_uri }} +{%- else %} +OIDCOAuthIntrospectionEndpoint {{ options.oidc_oauth_introspection_endpoint|default(options.provider_metadata.introspection_endpoint) }} +OIDCOAuthIntrospectionEndpointParams token_type_hint=access_token +OIDCOAuthClientID {{ options.oidc_client_id }} +{%- if options.oidc_client_secret %} +OIDCOAuthClientSecret {{ options.oidc_client_secret }} +{%- endif %} +{%- endif %} +{%- endif %} AuthType {{ options.auth_type }} Require valid-user -{% if options.debug -%} +{%- if options.debug %} LogLevel debug -{% endif -%} +{%- endif %} + +# Support for websso from Horizon +OIDCRedirectURI "{{ options.scheme }}://{{ options.hostname }}:{{ options.port }}/v3/auth/OS-FEDERATION/identity_providers/{{ options.idp_id }}/protocols/{{ options.protocol_id }}/websso" +OIDCRedirectURI "{{ options.scheme }}://{{ options.hostname }}:{{ options.port }}/v3/auth/OS-FEDERATION/websso/{{ options.protocol_id }}" + + + Require valid-user + AuthType openid-connect +{%- if options.debug %} + LogLevel debug +{%- endif %} + + + Require valid-user + AuthType openid-connect +{%- if options.debug %} + LogLevel debug +{%- endif %} +