From c4670a33d70188f8a95710b62d6e0ba91c91ad2c Mon Sep 17 00:00:00 2001 From: Brittney Exline Date: Mon, 22 Jan 2018 11:33:54 -0500 Subject: [PATCH] ENT-839 Add ability to configure SP metadata for an IdP --- common/djangoapps/third_party_auth/admin.py | 4 +- .../migrations/0016_auto_20180130_0938.py | 25 ++ common/djangoapps/third_party_auth/models.py | 269 +++++++++++------- .../djangoapps/third_party_auth/provider.py | 4 +- common/djangoapps/third_party_auth/saml.py | 64 ++++- common/djangoapps/third_party_auth/tasks.py | 3 +- .../third_party_auth/tests/testutil.py | 2 +- common/djangoapps/third_party_auth/views.py | 12 +- lms/envs/aws.py | 2 + 9 files changed, 265 insertions(+), 120 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0016_auto_20180130_0938.py diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index a368db8ae4..b102e2eacc 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -73,7 +73,7 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): """ Don't show every single field in the admin change list """ return ( 'name_with_update_link', 'enabled', 'site', 'entity_id', 'metadata_source', - 'has_data', 'mode', 'change_date', 'changed_by', + 'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by', ) list_display_links = None @@ -142,7 +142,7 @@ class SAMLConfigurationAdmin(KeyedConfigurationModelAdmin): def get_list_display(self, request): """ Shorten the public/private keys in the change view """ return ( - 'site', 'change_date', 'changed_by', 'enabled', 'entity_id', + 'site', 'slug', 'change_date', 'changed_by', 'enabled', 'entity_id', 'org_info_str', 'key_summary', 'edit_link', ) diff --git a/common/djangoapps/third_party_auth/migrations/0016_auto_20180130_0938.py b/common/djangoapps/third_party_auth/migrations/0016_auto_20180130_0938.py new file mode 100644 index 0000000000..631788a82c --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0016_auto_20180130_0938.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0015_samlproviderconfig_archived'), + ] + + operations = [ + migrations.AddField( + model_name='samlconfiguration', + name='slug', + field=models.SlugField(default=b'default', help_text=b'A short string uniquely identifying this configuration. Cannot contain spaces. Examples: "ubc", "mit-staging"', max_length=30), + ), + migrations.AddField( + model_name='samlproviderconfig', + name='saml_configuration', + field=models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='third_party_auth.SAMLConfiguration', null=True), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index b5fcb5b360..e43c5b2e4f 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -359,6 +359,149 @@ class OAuth2ProviderConfig(ProviderConfig): raise KeyError +class SAMLConfiguration(ConfigurationModel): + """ + General configuration required for this edX instance to act as a SAML + Service Provider and allow users to authenticate via third party SAML + Identity Providers (IdPs) + """ + KEY_FIELDS = ('site_id', 'slug') + site = models.ForeignKey( + Site, + default=settings.SITE_ID, + related_name='%(class)ss', + help_text=_( + 'The Site that this SAML configuration belongs to.' + ), + ) + slug = models.SlugField( + max_length=30, + default='default', + help_text=( + 'A short string uniquely identifying this configuration. ' + 'Cannot contain spaces. Examples: "ubc", "mit-staging"' + ), + ) + private_key = models.TextField( + help_text=( + 'To generate a key pair as two files, run ' + '"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". ' + 'Paste the contents of saml.key here. ' + 'For increased security, you can avoid storing this in your database by leaving ' + 'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PRIVATE_KEY setting ' + 'in your instance\'s Django settings (or lms.auth.json).' + ), + blank=True, + ) + public_key = models.TextField( + help_text=( + 'Public key certificate. ' + 'For increased security, you can avoid storing this in your database by leaving ' + 'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PUBLIC_CERT setting ' + 'in your instance\'s Django settings (or lms.auth.json).' + ), + blank=True, + ) + entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID") + org_info_str = models.TextField( + verbose_name="Organization Info", + default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}', + help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language", + ) + other_config_str = models.TextField( + default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}', + help_text=( + "JSON object defining advanced settings that are passed on to python-saml. " + "Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA" + ), + ) + + class Meta(object): + app_label = "third_party_auth" + verbose_name = "SAML Configuration" + verbose_name_plural = verbose_name + + def __str__(self): + """ + Return human-readable string representation. + """ + return "SAMLConfiguration {site}: {slug} on {date:%Y-%m-%d %H:%M:%S}".format( + site=self.site.name, + slug=self.slug, + date=self.change_date, + ) + + def clean(self): + """ Standardize and validate fields """ + super(SAMLConfiguration, self).clean() + self.org_info_str = clean_json(self.org_info_str, dict) + self.other_config_str = clean_json(self.other_config_str, dict) + + self.private_key = ( + self.private_key + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .strip() + ) + self.public_key = ( + self.public_key + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .strip() + ) + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + default_saml_contact = { + # Default contact information to put into the SAML metadata that gets generated by python-saml. + "givenName": _("{platform_name} Support").format( + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) + ), + "emailAddress": configuration_helpers.get_value('TECH_SUPPORT_EMAIL', settings.TECH_SUPPORT_EMAIL), + } + + if name == "ORG_INFO": + return json.loads(self.org_info_str) + if name == "SP_ENTITY_ID": + return self.entity_id + if name == "SP_PUBLIC_CERT": + if self.public_key: + return self.public_key + # To allow instances to avoid storing keys in the DB, the key pair can also be set via Django: + if self.slug == 'default': + return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') + else: + public_certs = getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {}) + return public_certs.get(self.slug, '') + if name == "SP_PRIVATE_KEY": + if self.private_key: + return self.private_key + # To allow instances to avoid storing keys in the DB, the private key can also be set via Django: + if self.slug == 'default': + return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') + else: + private_keys = getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {}) + return private_keys.get(self.slug, '') + other_config = { + # These defaults can be overriden by self.other_config_str + "GET_ALL_EXTRA_DATA": True, # Save all attribute values the IdP sends into the UserSocialAuth table + "TECHNICAL_CONTACT": default_saml_contact, + "SUPPORT_CONTACT": default_saml_contact, + } + other_config.update(json.loads(self.other_config_str)) + return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings + + +def active_saml_configurations_filter(): + """ + Returns a mapping to be used for the SAMLProviderConfig to limit the SAMLConfiguration choices to the current set. + """ + query_set = SAMLConfiguration.objects.current_set() + return {'id__in': query_set.values_list('id', flat=True)} + + class SAMLProviderConfig(ProviderConfig): """ Configuration Entry for a SAML/Shibboleth provider. @@ -402,7 +545,9 @@ class SAMLProviderConfig(ProviderConfig): help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.") automatic_refresh_enabled = models.BooleanField( default=True, verbose_name="Enable automatic metadata refresh", - help_text="When checked, the SAML provider's metadata will be included in the automatic refresh job, if configured.") + help_text="When checked, the SAML provider's metadata will be included " + "in the automatic refresh job, if configured." + ) identity_provider_type = models.CharField( max_length=128, blank=False, verbose_name="Identity Provider Type", default=STANDARD_SAML_PROVIDER_KEY, choices=get_saml_idp_choices(), help_text=( @@ -431,6 +576,13 @@ class SAMLProviderConfig(ProviderConfig): 'in this field for additional configuration.' )) archived = models.BooleanField(default=False) + saml_configuration = models.ForeignKey( + SAMLConfiguration, + on_delete=models.SET_NULL, + limit_choices_to=active_saml_configurations_filter, + null=True, + blank=True, + ) def clean(self): """ Standardize and validate fields """ @@ -494,119 +646,16 @@ class SAMLProviderConfig(ProviderConfig): raise AuthNotConfigured(provider_name=self.name) conf['x509cert'] = data.public_key conf['url'] = data.sso_url + + # Add SAMLConfiguration appropriate for this IdP + conf['saml_sp_configuration'] = ( + self.saml_configuration or + SAMLConfiguration.current(self.site.id, 'default') + ) idp_class = get_saml_idp_class(self.identity_provider_type) return idp_class(self.idp_slug, **conf) -class SAMLConfiguration(ConfigurationModel): - """ - General configuration required for this edX instance to act as a SAML - Service Provider and allow users to authenticate via third party SAML - Identity Providers (IdPs) - """ - KEY_FIELDS = ('site_id', ) - site = models.ForeignKey( - Site, - default=settings.SITE_ID, - related_name='%(class)ss', - help_text=_( - 'The Site that this SAML configuration belongs to.' - ), - ) - private_key = models.TextField( - help_text=( - 'To generate a key pair as two files, run ' - '"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". ' - 'Paste the contents of saml.key here. ' - 'For increased security, you can avoid storing this in your database by leaving ' - 'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PRIVATE_KEY setting ' - 'in your instance\'s Django settings (or lms.auth.json).' - ), - blank=True, - ) - public_key = models.TextField( - help_text=( - 'Public key certificate. ' - 'For increased security, you can avoid storing this in your database by leaving ' - 'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PUBLIC_CERT setting ' - 'in your instance\'s Django settings (or lms.auth.json).' - ), - blank=True, - ) - entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID") - org_info_str = models.TextField( - verbose_name="Organization Info", - default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}', - help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language", - ) - other_config_str = models.TextField( - default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}', - help_text=( - "JSON object defining advanced settings that are passed on to python-saml. " - "Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA" - ), - ) - - class Meta(object): - app_label = "third_party_auth" - verbose_name = "SAML Configuration" - verbose_name_plural = verbose_name - - def clean(self): - """ Standardize and validate fields """ - super(SAMLConfiguration, self).clean() - self.org_info_str = clean_json(self.org_info_str, dict) - self.other_config_str = clean_json(self.other_config_str, dict) - - self.private_key = ( - self.private_key - .replace("-----BEGIN RSA PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END RSA PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .strip() - ) - self.public_key = ( - self.public_key - .replace("-----BEGIN CERTIFICATE-----", "") - .replace("-----END CERTIFICATE-----", "") - .strip() - ) - - def get_setting(self, name): - """ Get the value of a setting, or raise KeyError """ - default_saml_contact = { - # Default contact information to put into the SAML metadata that gets generated by python-saml. - "givenName": _("{platform_name} Support").format( - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) - ), - "emailAddress": configuration_helpers.get_value('TECH_SUPPORT_EMAIL', settings.TECH_SUPPORT_EMAIL), - } - - if name == "ORG_INFO": - return json.loads(self.org_info_str) - if name == "SP_ENTITY_ID": - return self.entity_id - if name == "SP_PUBLIC_CERT": - if self.public_key: - return self.public_key - # To allow instances to avoid storing keys in the DB, the key pair can also be set via Django: - return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') - if name == "SP_PRIVATE_KEY": - if self.private_key: - return self.private_key - # To allow instances to avoid storing keys in the DB, the private key can also be set via Django: - return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') - other_config = { - # These defaults can be overriden by self.other_config_str - "GET_ALL_EXTRA_DATA": True, # Save all attribute values the IdP sends into the UserSocialAuth table - "TECHNICAL_CONTACT": default_saml_contact, - "SUPPORT_CONTACT": default_saml_contact, - } - other_config.update(json.loads(self.other_config_str)) - return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings - - class SAMLProviderData(models.Model): """ Data about a SAML IdP that is fetched automatically by 'manage.py saml pull' diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index cb6ce1c6c0..c4dff5d1ca 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -33,7 +33,7 @@ class Registry(object): provider = OAuth2ProviderConfig.current(oauth2_slug) if provider.enabled_for_current_site and provider.backend_name in _PSA_OAUTH2_BACKENDS: yield provider - if SAMLConfiguration.is_enabled(Site.objects.get_current(get_current_request())): + if SAMLConfiguration.is_enabled(Site.objects.get_current(get_current_request()), 'default'): idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True) for idp_slug in idp_slugs: provider = SAMLProviderConfig.current(idp_slug) @@ -118,7 +118,7 @@ class Registry(object): if provider.backend_name == backend_name and provider.enabled_for_current_site: yield provider elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled( - Site.objects.get_current(get_current_request())): + Site.objects.get_current(get_current_request()), 'default'): idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True) for idp_name in idp_names: provider = SAMLProviderConfig.current(idp_name) diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py index 17982069c4..71a4bc7a2b 100644 --- a/common/djangoapps/third_party_auth/saml.py +++ b/common/djangoapps/third_party_auth/saml.py @@ -9,6 +9,7 @@ from django.contrib.sites.models import Site from django.http import Http404 from django.utils.functional import cached_property from django_countries import countries +from onelogin.saml2.settings import OneLogin_Saml2_Settings from social_core.backends.saml import OID_EDU_PERSON_ENTITLEMENT, SAMLAuth, SAMLIdentityProvider from social_core.exceptions import AuthForbidden @@ -38,6 +39,62 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method except KeyError: return self.strategy.setting(name, default, backend=self) + def get_idp_setting(self, idp, name, default=None): + try: + return idp.saml_sp_configuration.get_setting(name) + except KeyError: + return self.setting(name, default) + + def generate_saml_config(self, idp=None): + """ + Override of SAMLAuth.generate_saml_config to use an idp's configured saml_sp_configuration if given. + """ + if idp: + abs_completion_url = self.redirect_uri + config = { + 'contactPerson': { + 'technical': self.get_idp_setting(idp, 'TECHNICAL_CONTACT'), + 'support': self.get_idp_setting(idp, 'SUPPORT_CONTACT') + }, + 'debug': True, + 'idp': idp.saml_config_dict if idp else {}, + 'organization': self.get_idp_setting(idp, 'ORG_INFO'), + 'security': { + 'metadataValidUntil': '', + 'metadataCacheDuration': 'P10D', # metadata valid for ten days + }, + 'sp': { + 'assertionConsumerService': { + 'url': abs_completion_url, + # python-saml only supports HTTP-POST + 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + }, + 'entityId': self.get_idp_setting(idp, 'SP_ENTITY_ID'), + 'x509cert': self.get_idp_setting(idp, 'SP_PUBLIC_CERT'), + 'privateKey': self.get_idp_setting(idp, 'SP_PRIVATE_KEY'), + }, + 'strict': True, # We must force strict mode - for security + } + config["security"].update(self.get_idp_setting(idp, "SECURITY_CONFIG", {})) + config["sp"].update(self.get_idp_setting(idp, "SP_EXTRA", {})) + return config + else: + return super(SAMLAuthBackend, self).generate_saml_config() + + def generate_metadata_xml(self, idp_name=None): # pylint: disable=arguments-differ + """ + Override of SAMLAuth.generate_metadata_xml to accept an optional idp parameter. + """ + idp = self.get_idp(idp_name) if idp_name else None + config = self.generate_saml_config(idp) + saml_settings = OneLogin_Saml2_Settings( + config, + sp_validation_only=True + ) + metadata = saml_settings.get_sp_metadata() + errors = saml_settings.validate_metadata(metadata) + return metadata, errors + def auth_url(self): """ Check that SAML is enabled and that the request includes an 'idp' @@ -98,7 +155,7 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method @cached_property def _config(self): from .models import SAMLConfiguration - return SAMLConfiguration.current(Site.objects.get_current(get_current_request())) + return SAMLConfiguration.current(Site.objects.get_current(get_current_request()), 'default') class EdXSAMLIdentityProvider(SAMLIdentityProvider): @@ -120,6 +177,11 @@ class EdXSAMLIdentityProvider(SAMLIdentityProvider): }) return details + @property + def saml_sp_configuration(self): + """Get the SAMLConfiguration for this IdP""" + return self.conf['saml_sp_configuration'] + class SapSuccessFactorsIdentityProvider(EdXSAMLIdentityProvider): """ diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py index 185f4974fa..c7a1b8acde 100644 --- a/common/djangoapps/third_party_auth/tasks.py +++ b/common/djangoapps/third_party_auth/tasks.py @@ -52,12 +52,13 @@ def fetch_saml_metadata(): url_map = {} for idp_slug in saml_providers: config = SAMLProviderConfig.current(idp_slug) + saml_config_slug = config.saml_configuration.slug if config.saml_configuration else 'default' # Skip SAML provider configurations which do not qualify for fetching if any([ not config.enabled, not config.automatic_refresh_enabled, - not SAMLConfiguration.is_enabled(config.site) + not SAMLConfiguration.is_enabled(config.site, saml_config_slug) ]): num_skipped += 1 continue diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index e47d41d4a0..4112d30b17 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -86,7 +86,7 @@ class ThirdPartyAuthTestMixin(object): def configure_saml_provider(self, **kwargs): """ Update the settings for a SAML-based third party auth provider """ self.assertTrue( - SAMLConfiguration.is_enabled(Site.objects.get_current()), + SAMLConfiguration.is_enabled(Site.objects.get_current(), 'default'), "SAML Provider Configuration only works if SAML is enabled." ) obj = SAMLProviderConfig(**kwargs) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index aaa5bb713e..2324d1539c 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -13,7 +13,7 @@ from social_core.utils import setting_name from student.models import UserProfile from student.views import compose_and_send_activation_email -from .models import SAMLConfiguration +from .models import SAMLConfiguration, SAMLProviderConfig URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social' @@ -41,13 +41,19 @@ def saml_metadata_view(request): Get the Service Provider metadata for this edx-platform instance. You must send this XML to any Shibboleth Identity Provider that you wish to use. """ - if not SAMLConfiguration.is_enabled(request.site): + idp_slug = request.GET.get('tpa_hint', None) + saml_config = 'default' + if idp_slug: + idp = SAMLProviderConfig.current(idp_slug) + if idp.saml_configuration: + saml_config = idp.saml_configuration.slug + if not SAMLConfiguration.is_enabled(request.site, saml_config): raise Http404 complete_url = reverse('social:complete', args=("tpa-saml", )) if settings.APPEND_SLASH and not complete_url.endswith('/'): complete_url = complete_url + '/' # Required for consistency saml_backend = load_backend(load_strategy(request), "tpa-saml", redirect_uri=complete_url) - metadata, errors = saml_backend.generate_metadata_xml() + metadata, errors = saml_backend.generate_metadata_xml(idp_slug) if not errors: return HttpResponse(content=metadata, content_type='text/xml') diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 3aa5e39ba1..b8dcbdb500 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -711,6 +711,8 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): # if you want (though it's easier to format the key values as JSON without the delimiters). SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {}) + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {}) SOCIAL_AUTH_OAUTH_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_OAUTH_SECRETS', {}) SOCIAL_AUTH_LTI_CONSUMER_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {})