├── tests
├── test_plugin.py
├── test_views.py
└── test_provider.py
├── .gitignore
├── src
└── sentry_auth_saml2
│ ├── __init__.py
│ ├── okta
│ ├── apps.py
│ ├── __init__.py
│ ├── provider.py
│ └── templates
│ │ └── sentry_auth_okta
│ │ └── select-idp.html
│ ├── auth0
│ ├── apps.py
│ ├── __init__.py
│ ├── provider.py
│ └── templates
│ │ └── sentry_auth_auth0
│ │ └── select-idp.html
│ ├── generic
│ ├── apps.py
│ ├── provider.py
│ ├── __init__.py
│ ├── templates
│ │ └── sentry_auth_saml2
│ │ │ ├── configure.html
│ │ │ ├── map-attributes.html
│ │ │ └── select-idp.html
│ └── views.py
│ ├── onelogin
│ ├── apps.py
│ ├── __init__.py
│ ├── provider.py
│ └── templates
│ │ └── sentry_auth_onelogin
│ │ └── select-idp.html
│ ├── rippling
│ ├── apps.py
│ ├── __init__.py
│ ├── templates
│ │ └── sentry_auth_rippling
│ │ │ ├── wait-for-completion.html
│ │ │ └── select-idp.html
│ └── provider.py
│ ├── views.py
│ └── forms.py
├── conftest.py
├── setup.cfg
├── MANIFEST.in
├── Makefile
├── README.md
├── setup.py
└── LICENSE
/tests/test_plugin.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.egg-info/
3 | *.eggs
4 | /dist
5 | /build
6 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
--------------------------------------------------------------------------------
/tests/test_provider.py:
--------------------------------------------------------------------------------
1 | from sentry.testutils import TestCase
2 |
3 |
4 | class GenericSAML2ProviderTest(TestCase):
5 | def test_simple(self):
6 | pass
7 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import sys
4 | import os.path
5 |
6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
7 |
8 | pytest_plugins = [
9 | 'sentry.utils.pytest'
10 | ]
11 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [wheel]
2 | universal = 1
3 |
4 | [tool:pytest]
5 | python_files = test*.py
6 | addopts = --tb=native -p no:doctest
7 | norecursedirs = bin dist docs htmlcov script hooks node_modules .* {args}
8 |
9 | [flake8]
10 | ignore = F999,E501,E128,E124,E402,W503,E731,C901
11 | max-line-length = 100
12 | exclude = .tox,.git,*/migrations/*,node_modules/*,docs/*
13 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/okta/apps.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Config(AppConfig):
7 | name = "sentry_auth_saml2.okta"
8 |
9 | def ready(self):
10 | from sentry.auth import register
11 |
12 | from .provider import OktaSAML2Provider
13 |
14 | register('okta', OktaSAML2Provider)
15 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/auth0/apps.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Config(AppConfig):
7 | name = "sentry_auth_saml2.auth0"
8 |
9 | def ready(self):
10 | from sentry.auth import register
11 |
12 | from .provider import Auth0SAML2Provider
13 |
14 | register('auth0', Auth0SAML2Provider)
15 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/apps.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Config(AppConfig):
7 | name = "sentry_auth_saml2.generic"
8 |
9 | def ready(self):
10 | from sentry.auth import register
11 |
12 | from .provider import GenericSAML2Provider
13 |
14 | register('saml2', GenericSAML2Provider)
15 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/onelogin/apps.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Config(AppConfig):
7 | name = "sentry_auth_saml2.onelogin"
8 |
9 | def ready(self):
10 | from sentry.auth import register
11 |
12 | from .provider import OneLoginSAML2Provider
13 |
14 | register('onelogin', OneLoginSAML2Provider)
15 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/rippling/apps.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Config(AppConfig):
7 | name = "sentry_auth_saml2.rippling"
8 |
9 | def ready(self):
10 | from sentry.auth import register
11 |
12 | from .provider import RipplingSAML2Provider
13 |
14 | register('rippling', RipplingSAML2Provider)
15 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include setup.py package.json webpack.config.js README.rst MANIFEST.in LICENSE AUTHORS
2 | recursive-include src/sentry_auth_saml2 *.html
3 | recursive-include src/sentry_auth_saml2 *.js
4 | recursive-include src/sentry_auth_saml2 *.jsx
5 | recursive-include src/sentry_auth_saml2 *.css
6 | recursive-include src/sentry_auth_saml2 *.js.gz
7 | recursive-include src/sentry_auth_saml2 *.js.map
8 | global-exclude *~
9 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/provider.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from sentry.auth.providers.saml2 import SAML2Provider
4 |
5 | from .views import SAML2ConfigureView, SelectIdP, MapAttributes
6 |
7 |
8 | class GenericSAML2Provider(SAML2Provider):
9 | name = 'SAML2'
10 |
11 | def get_configure_view(self):
12 | return SAML2ConfigureView.as_view()
13 |
14 | def get_saml_setup_pipeline(self):
15 | return [SelectIdP(), MapAttributes()]
16 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean develop install-tests lint publish test
2 |
3 | develop:
4 | pip install "pip==19.2.3"
5 | SENTRY_LIGHT_BUILD=1 pip install --no-use-pep517 -e ../sentry
6 | pip install .[tests]
7 |
8 | lint:
9 | @echo "--> Linting python"
10 | flake8
11 | @echo ""
12 |
13 | test:
14 | @echo "--> Running Python tests"
15 | py.test tests || exit 1
16 | @echo ""
17 |
18 | publish:
19 | python setup.py sdist bdist_wheel upload
20 |
21 | clean:
22 | rm -rf *.egg-info src/*.egg-info
23 | rm -rf dist build
24 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/views.py:
--------------------------------------------------------------------------------
1 | from sentry.auth.view import AuthView
2 | from sentry_auth_saml2.forms import process_metadata
3 |
4 |
5 | def make_simple_setup(form_cls, template_path):
6 | class SelectIdP(AuthView):
7 | def handle(self, request, helper):
8 | form = process_metadata(form_cls, request, helper)
9 |
10 | if form:
11 | return self.respond(template_path, {'form': form})
12 | else:
13 | return helper.next_step()
14 |
15 | return SelectIdP
16 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/okta/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 |
5 | if django.VERSION[:2] >= (1, 8):
6 | # Django 1.9 specifically requires that models not be imported before setup,
7 | # which sentry.auth does, so we need to use AppConfig here.
8 | # Also works on 1.8.
9 | default_app_config = "sentry_auth_saml2.okta.apps.Config"
10 | else:
11 | # Provide backwards compatibility.
12 | from sentry.auth import register
13 |
14 | from .provider import OktaSAML2Provider
15 |
16 | register('okta', OktaSAML2Provider)
17 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/auth0/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 |
5 | if django.VERSION[:2] >= (1, 8):
6 | # Django 1.9 specifically requires that models not be imported before setup,
7 | # which sentry.auth does, so we need to use AppConfig here.
8 | # Also works on 1.8.
9 | default_app_config = "sentry_auth_saml2.auth0.apps.Config"
10 | else:
11 | # Provide backwards compatibility.
12 | from sentry.auth import register
13 |
14 | from .provider import Auth0SAML2Provider
15 |
16 | register('auth0', Auth0SAML2Provider)
17 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 |
5 | if django.VERSION[:2] >= (1, 8):
6 | # Django 1.9 specifically requires that models not be imported before setup,
7 | # which sentry.auth does, so we need to use AppConfig here.
8 | # Also works on 1.8.
9 | default_app_config = "sentry_auth_saml2.generic.apps.Config"
10 | else:
11 | # Provide backwards compatibility.
12 | from sentry.auth import register
13 |
14 | from .provider import GenericSAML2Provider
15 |
16 | register('saml2', GenericSAML2Provider)
17 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/onelogin/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 |
5 | if django.VERSION[:2] >= (1, 8):
6 | # Django 1.9 specifically requires that models not be imported before setup,
7 | # which sentry.auth does, so we need to use AppConfig here.
8 | # Also works on 1.8.
9 | default_app_config = "sentry_auth_saml2.onelogin.apps.Config"
10 | else:
11 | # Provide backwards compatibility.
12 | from sentry.auth import register
13 |
14 | from .provider import OneLoginSAML2Provider
15 |
16 | register('onelogin', OneLoginSAML2Provider)
17 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/rippling/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 |
5 | if django.VERSION[:2] >= (1, 8):
6 | # Django 1.9 specifically requires that models not be imported before setup,
7 | # which sentry.auth does, so we need to use AppConfig here.
8 | # Also works on 1.8.
9 | default_app_config = "sentry_auth_saml2.rippling.apps.Config"
10 | else:
11 | # Provide backwards compatibility.
12 | from sentry.auth import register
13 |
14 | from .provider import RipplingSAML2Provider
15 |
16 | register('rippling', RipplingSAML2Provider)
17 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/templates/sentry_auth_saml2/configure.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_tags %}
2 | {% load i18n %}
3 |
4 |
7 |
8 |
9 | {{ forms.saml|as_crispy_errors }}
10 |
11 | {% for field in forms.saml %}
12 | {{ field|as_crispy_field }}
13 | {% endfor %}
14 |
15 |
16 | {{ forms.attrs|as_crispy_errors }}
17 |
18 | {% for field in forms.attrs %}
19 | {{ field|as_crispy_field }}
20 | {% endfor %}
21 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/okta/provider.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from sentry.auth.providers.saml2 import SAML2Provider, Attributes
4 | from sentry_auth_saml2.views import make_simple_setup
5 | from sentry_auth_saml2.forms import URLMetadataForm
6 |
7 |
8 | SelectIdP = make_simple_setup(
9 | URLMetadataForm,
10 | 'sentry_auth_okta/select-idp.html',
11 | )
12 |
13 |
14 | class OktaSAML2Provider(SAML2Provider):
15 | name = 'Okta'
16 |
17 | def get_saml_setup_pipeline(self):
18 | return [SelectIdP()]
19 |
20 | def attribute_mapping(self):
21 | return {
22 | Attributes.IDENTIFIER: 'identifier',
23 | Attributes.USER_EMAIL: 'email',
24 | Attributes.FIRST_NAME: 'firstName',
25 | Attributes.LAST_NAME: 'lastName',
26 | }
27 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/auth0/provider.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from sentry.auth.providers.saml2 import SAML2Provider, Attributes
4 | from sentry_auth_saml2.views import make_simple_setup
5 | from sentry_auth_saml2.forms import URLMetadataForm
6 |
7 |
8 | SelectIdP = make_simple_setup(
9 | URLMetadataForm,
10 | 'sentry_auth_auth0/select-idp.html',
11 | )
12 |
13 |
14 | class Auth0SAML2Provider(SAML2Provider):
15 | name = 'Auth0'
16 |
17 | def get_saml_setup_pipeline(self):
18 | return [SelectIdP()]
19 |
20 | def attribute_mapping(self):
21 | return {
22 | Attributes.IDENTIFIER: 'user_id',
23 | Attributes.USER_EMAIL: 'email',
24 | # Auth0 does not provider first / last names
25 | Attributes.FIRST_NAME: 'name',
26 | Attributes.LAST_NAME: None,
27 | }
28 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/rippling/templates/sentry_auth_rippling/wait-for-completion.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load sentry_assets %}
3 | {% load i18n %}
4 |
5 | {% block wrapperclass %}narrow auth{% endblock %}
6 | {% block modal_header_signout %}{% endblock %}
7 |
8 | {% block title %}{% trans "Register Rippling" %} | {{ block.super }}{% endblock %}
9 |
10 | {% block main %}
11 |
12 | Before completing your Rippling SSO auth setup on Sentry, you will need
13 | to complete the rippling app setup. Click Complete Setup
14 | once done.
15 |
16 |
17 |
25 | {% endblock %}
26 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/auth0/templates/sentry_auth_auth0/select-idp.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load sentry_assets %}
3 | {% load i18n %}
4 |
5 | {% block wrapperclass %}narrow auth{% endblock %}
6 | {% block modal_header_signout %}{% endblock %}
7 |
8 | {% block title %}{% trans "Register Auth0" %} | {{ block.super }}{% endblock %}
9 |
10 | {% block main %}
11 | {% trans "Register Auth0" %}
12 |
13 |
14 | As part of Auth0 SSO provisioning, you must to provide the Auth0 identity
15 | provider Metadata URL to Sentry.
16 |
17 |
18 |
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/onelogin/provider.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from django import forms
4 |
5 | from sentry.auth.providers.saml2 import SAML2Provider, Attributes
6 | from sentry_auth_saml2.views import make_simple_setup
7 | from sentry_auth_saml2.forms import URLMetadataForm
8 |
9 |
10 | # Onelogin specifically calls their Metadata URL a 'Issuer URL'
11 | class OneLoginURLMetadataForm(URLMetadataForm):
12 | metadata_url = forms.URLField(label='Issuer URL')
13 |
14 |
15 | SelectIdP = make_simple_setup(
16 | OneLoginURLMetadataForm,
17 | 'sentry_auth_onelogin/select-idp.html',
18 | )
19 |
20 |
21 | class OneLoginSAML2Provider(SAML2Provider):
22 | name = 'OneLogin'
23 |
24 | def get_saml_setup_pipeline(self):
25 | return [SelectIdP()]
26 |
27 | def attribute_mapping(self):
28 | return {
29 | Attributes.IDENTIFIER: 'PersonImmutableID',
30 | Attributes.USER_EMAIL: 'User.email',
31 | Attributes.FIRST_NAME: 'User.FirstName',
32 | Attributes.LAST_NAME: 'User.LastName',
33 | }
34 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/onelogin/templates/sentry_auth_onelogin/select-idp.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load sentry_assets %}
3 | {% load i18n %}
4 |
5 | {% block wrapperclass %}narrow auth{% endblock %}
6 | {% block modal_header_signout %}{% endblock %}
7 |
8 | {% block title %}{% trans "Register OneLogin" %} | {{ block.super }}{% endblock %}
9 |
10 | {% block main %}
11 | {% trans "Register OneLogin" %}
12 |
13 |
14 | As part of OneLogin SSO provisioning, you must to provide the OneLogin
15 | identity provider Issuer URL to Sentry.
16 |
17 |
18 |
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/okta/templates/sentry_auth_okta/select-idp.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load sentry_assets %}
3 | {% load i18n %}
4 |
5 | {% block wrapperclass %}narrow auth{% endblock %}
6 | {% block modal_header_signout %}{% endblock %}
7 |
8 | {% block title %}{% trans "Register Okta" %} | {{ block.super }}{% endblock %}
9 |
10 | {% block main %}
11 | {% trans "Register Okta" %}
12 |
13 |
14 | As part of Okta SSO provisioning, you must to provide the Okta
15 | identity provider Metadata URL to Sentry.
16 |
17 |
18 |
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/rippling/templates/sentry_auth_rippling/select-idp.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load sentry_assets %}
3 | {% load i18n %}
4 |
5 | {% block wrapperclass %}narrow auth{% endblock %}
6 | {% block modal_header_signout %}{% endblock %}
7 |
8 | {% block title %}{% trans "Register Rippling" %} | {{ block.super }}{% endblock %}
9 |
10 | {% block main %}
11 | {% trans "Register Rippling" %}
12 |
13 |
14 | As part of Rippling SSO provisioning, you must to provide the Rippling
15 | identity provider Metadata URL to Sentry.
16 |
17 |
18 |
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/templates/sentry_auth_saml2/map-attributes.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load crispy_forms_tags %}
3 | {% load sentry_assets %}
4 | {% load i18n %}
5 |
6 | {% block wrapperclass %}narrow auth{% endblock %}
7 | {% block modal_header_signout %}{% endblock %}
8 |
9 | {% block title %}{% trans "SAML2 Setup" %} | {{ block.super }}{% endblock %}
10 |
11 | {% block main %}
12 | Map Identity Provider Attributes
13 |
14 |
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SAML2 Auth for Sentry
2 | DEPRECATED: This project now lives in [sentry](https://github.com/getsentry/sentry/tree/master/src/sentry/auth/providers/saml2)
3 |
4 | *Note:* SAML2 Authenttication is still currently an experimental feature.
5 |
6 | An SSO provider for Sentry which enables SAML SSO and SLO support, including
7 | various identity provider support.
8 |
9 | The following identity providers are supported
10 |
11 | * [OneLogin](https://www.onelogin.com/)
12 | * [Okta](https://www.okta.com/)
13 | * [Auth0](https://auth0.com/)
14 | * [Rippling](https://rippling.com/)
15 |
16 | A generic SAML2 module is also provided, which may be configured with any
17 | Identity Provider that conforms to the SAML2 specification.
18 |
19 | ## Install
20 |
21 | ```
22 | $ pip install https://github.com/getsentry/sentry-auth-saml2/archive/master.zip
23 | ```
24 |
25 | ## Configuration
26 |
27 | Refer to the Sentry [Single Sign-On
28 | documentation](https://docs.sentry.io/learn/sso/) for individual SAML Identity
29 | Provider configurations.
30 |
31 | Refer to the [Enabling SSO
32 | documentation](https://docs.sentry.io/server/sso/#enabling-sso)
33 | for what feature flags to enable for this plugin.
34 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | sentry-auth-saml2
4 | =================
5 | """
6 | from setuptools import setup, find_packages
7 |
8 |
9 | install_requires = [
10 | 'python3-saml>=1.4.0'
11 | ]
12 |
13 | tests_require = [
14 | "exam>=0.5.1",
15 | 'pytest==4.6.5',
16 | "pytest-html==1.22.0",
17 | 'sentry-flake8==0.1.1',
18 | ]
19 |
20 | setup(
21 | name='sentry-auth-saml2',
22 | version='0.1.0.dev',
23 | author='Sentry',
24 | author_email='support@getsentry.com',
25 | url='https://www.getsentry.com',
26 | description='SAML SSO provider for Sentry',
27 | long_description=__doc__,
28 | license='Apache 2.0',
29 | package_dir={'': 'src'},
30 | packages=find_packages('src', exclude=['tests']),
31 | zip_safe=False,
32 | install_requires=install_requires,
33 | tests_require=tests_require,
34 | extras_require={'tests': tests_require},
35 | include_package_data=True,
36 | entry_points={
37 | 'sentry.apps': [
38 | 'auth_saml2 = sentry_auth_saml2.generic',
39 | 'auth_onelogin = sentry_auth_saml2.onelogin',
40 | 'auth_okta = sentry_auth_saml2.okta',
41 | 'auth_auth0 = sentry_auth_saml2.auth0',
42 | 'auth_rippling = sentry_auth_saml2.rippling',
43 | ],
44 | },
45 | classifiers=[
46 | 'Intended Audience :: Developers',
47 | 'Intended Audience :: System Administrators',
48 | 'Operating System :: OS Independent',
49 | 'Topic :: Software Development'
50 | ],
51 | )
52 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/rippling/provider.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from sentry.auth.view import AuthView
4 | from sentry.auth.providers.saml2 import SAML2Provider, Attributes
5 | from sentry_auth_saml2.views import make_simple_setup
6 | from sentry_auth_saml2.forms import URLMetadataForm
7 |
8 |
9 | SelectIdP = make_simple_setup(
10 | URLMetadataForm,
11 | 'sentry_auth_rippling/select-idp.html',
12 | )
13 |
14 |
15 | class WaitForCompletion(AuthView):
16 | """
17 | Rippling provides the Metadata URL during initial application setup, before
18 | configuration values have been saved, thus we cannot immediately attempt to
19 | create an identity for the setting up the SSO.
20 |
21 | This is simply an extra step to wait for them to complete that.
22 | """
23 | def handle(self, request, helper):
24 | if 'continue_setup' in request.POST:
25 | return helper.next_step()
26 |
27 | return self.respond('sentry_auth_rippling/wait-for-completion.html')
28 |
29 |
30 | class RipplingSAML2Provider(SAML2Provider):
31 | name = 'Rippling'
32 |
33 | # Rippling is currently it's own feature
34 | required_feature = 'organizations:sso-rippling'
35 |
36 | def get_saml_setup_pipeline(self):
37 | return [SelectIdP(), WaitForCompletion()]
38 |
39 | def attribute_mapping(self):
40 | return {
41 | Attributes.IDENTIFIER: 'user_id',
42 | Attributes.USER_EMAIL: 'urn:oid:1.2.840.113549.1.9.1.1',
43 | Attributes.FIRST_NAME: 'first_name',
44 | Attributes.LAST_NAME: 'last_name',
45 | }
46 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, print_function
2 |
3 | from django.core.urlresolvers import reverse
4 |
5 | from sentry.auth.view import AuthView, ConfigureView
6 | from sentry.utils.http import absolute_uri
7 |
8 | from sentry_auth_saml2.forms import (
9 | AttributeMappingForm, SAMLForm, URLMetadataForm, XMLMetadataForm,
10 | process_metadata,
11 | )
12 |
13 |
14 | class SAML2ConfigureView(ConfigureView):
15 | def dispatch(self, request, organization, provider):
16 | sp_metadata_url = absolute_uri(reverse('sentry-auth-organization-saml-metadata', args=[organization.slug]))
17 |
18 | if request.method != 'POST':
19 | saml_form = SAMLForm(provider.config['idp'])
20 | attr_mapping_form = AttributeMappingForm(provider.config['attribute_mapping'])
21 | else:
22 | saml_form = SAMLForm(request.POST)
23 | attr_mapping_form = AttributeMappingForm(request.POST)
24 |
25 | if saml_form.is_valid() and attr_mapping_form.is_valid():
26 | provider.config['idp'] = saml_form.cleaned_data
27 | provider.config['attr_mapping_form'] = attr_mapping_form.cleaned_data
28 | provider.save()
29 |
30 | return self.render('sentry_auth_saml2/configure.html', {
31 | 'sp_metadata_url': sp_metadata_url,
32 | 'forms': {'saml': saml_form, 'attrs': attr_mapping_form},
33 | })
34 |
35 |
36 | class SelectIdP(AuthView):
37 | def handle(self, request, helper):
38 | op = 'url'
39 |
40 | forms = {
41 | 'url': URLMetadataForm(),
42 | 'xml': XMLMetadataForm(),
43 | 'idp': SAMLForm(),
44 | }
45 |
46 | if 'action_save' in request.POST:
47 | op = request.POST['action_save']
48 | form_cls = forms[op].__class__
49 | forms[op] = process_metadata(form_cls, request, helper)
50 |
51 | # process_metadata will return None when the action was successful and
52 | # data was bound to the helper.
53 | if not forms[op]:
54 | return helper.next_step()
55 |
56 | return self.respond('sentry_auth_saml2/select-idp.html', {
57 | 'op': op,
58 | 'forms': forms,
59 | })
60 |
61 |
62 | class MapAttributes(AuthView):
63 | def handle(self, request, helper):
64 | if 'save_mappings' not in request.POST:
65 | form = AttributeMappingForm()
66 | else:
67 | form = AttributeMappingForm(request.POST)
68 | if form.is_valid():
69 | helper.bind_state('attribute_mapping', form.cleaned_data)
70 | return helper.next_step()
71 |
72 | return self.respond('sentry_auth_saml2/map-attributes.html', {
73 | 'form': form,
74 | })
75 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/generic/templates/sentry_auth_saml2/select-idp.html:
--------------------------------------------------------------------------------
1 | {% extends "sentry/bases/modal.html" %}
2 | {% load crispy_forms_tags %}
3 | {% load sentry_assets %}
4 | {% load i18n %}
5 |
6 | {% block wrapperclass %}narrow auth{% endblock %}
7 | {% block modal_header_signout %}{% endblock %}
8 |
9 | {% block title %}{% trans "SAML2 Setup" %} | {{ block.super }}{% endblock %}
10 |
11 | {% block main %}
12 | Register Identity Provider
13 |
14 |
25 |
26 |
90 | {% endblock %}
91 |
--------------------------------------------------------------------------------
/src/sentry_auth_saml2/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import django
4 | from django import forms
5 | from django.utils.encoding import force_text
6 | from django.utils.translation import ugettext_lazy as _
7 | from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
8 |
9 | if django.VERSION >= (1, 8):
10 | from django.forms.utils import ErrorList
11 | else:
12 | from django.forms.util import ErrorList
13 |
14 | from sentry.http import safe_urlopen
15 |
16 |
17 | def extract_idp_data_from_parsed_data(data):
18 | """
19 | Transform data returned by the OneLogin_Saml2_IdPMetadataParser into the
20 | expected IdP dict shape.
21 | """
22 | idp = data.get('idp', {})
23 |
24 | # In some scenarios the IDP sticks the x509cert in the x509certMulti
25 | # parameter
26 | cert = idp.get('x509cert', idp.get('x509certMulti', {}).get('signing', [None])[0])
27 |
28 | return {
29 | 'entity_id': idp.get('entityId'),
30 | 'sso_url': idp.get('singleSignOnService', {}).get('url'),
31 | 'slo_url': idp.get('singleLogoutService', {}).get('url'),
32 | 'x509cert': cert,
33 | }
34 |
35 |
36 | def process_url(form):
37 | url = form.cleaned_data['metadata_url']
38 | response = safe_urlopen(url)
39 | data = OneLogin_Saml2_IdPMetadataParser.parse(response.content)
40 | return extract_idp_data_from_parsed_data(data)
41 |
42 |
43 | def process_xml(form):
44 | # cast unicode xml to byte string so lxml won't complain when trying to
45 | # parse a xml document with a type declaration.
46 | xml = form.cleaned_data['metadata_xml'].encode('utf8')
47 | data = OneLogin_Saml2_IdPMetadataParser.parse(xml)
48 | return extract_idp_data_from_parsed_data(data)
49 |
50 |
51 | class URLMetadataForm(forms.Form):
52 | metadata_url = forms.URLField(label='Metadata URL')
53 | processor = process_url
54 |
55 |
56 | class XMLMetadataForm(forms.Form):
57 | metadata_xml = forms.CharField(label='Metadata XML', widget=forms.Textarea)
58 | processor = process_xml
59 |
60 |
61 | class SAMLForm(forms.Form):
62 | entity_id = forms.CharField(label='Entity ID')
63 | sso_url = forms.URLField(label='Single Sign On URL')
64 | slo_url = forms.URLField(label='Single Log Out URL', required=False)
65 | x509cert = forms.CharField(label='x509 public certificate', widget=forms.Textarea)
66 | processor = lambda d: d.cleaned_data
67 |
68 |
69 | def process_metadata(form_cls, request, helper):
70 | form = form_cls()
71 |
72 | if 'action_save' not in request.POST:
73 | return form
74 |
75 | form = form_cls(request.POST)
76 |
77 | if not form.is_valid():
78 | return form
79 |
80 | try:
81 | data = form_cls.processor(form)
82 | except Exception:
83 | errors = form._errors.setdefault('__all__', ErrorList())
84 | errors.append('Failed to parse provided SAML2 metadata')
85 | return form
86 |
87 | saml_form = SAMLForm(data)
88 | if not saml_form.is_valid():
89 | field_errors = ['%s: %s' % (k, ', '.join([force_text(i) for i in v])) for k, v in saml_form.errors.items()]
90 | error_list = ', '.join(field_errors)
91 |
92 | errors = form._errors.setdefault('__all__', ErrorList())
93 | errors.append(u'Invalid metadata: {}'.format(error_list))
94 | return form
95 |
96 | helper.bind_state('idp', data)
97 |
98 | # Data is bound, do not respond with a form to signal the nexts steps
99 | return None
100 |
101 |
102 | class AttributeMappingForm(forms.Form):
103 | # NOTE: These fields explicitly map to the sentry.auth.saml2.Attributes keys
104 | identifier = forms.CharField(
105 | label='IdP User ID',
106 | widget=forms.TextInput(attrs={'placeholder': 'eg. user.uniqueID'}),
107 | help_text=_('The IdPs unique ID attribute key for the user. This is '
108 | 'what Sentry will used to identify the users identity from '
109 | 'the identity provider.'),
110 | )
111 | user_email = forms.CharField(
112 | label='User Email',
113 | widget=forms.TextInput(attrs={'placeholder': 'eg. user.email'}),
114 | help_text=_('The IdPs email address attribute key for the '
115 | 'user. Upon initial linking this will be used to identify '
116 | 'the user in Sentry.'),
117 | )
118 | first_name = forms.CharField(label='First Name', required=False)
119 | last_name = forms.CharField(label='Last Name', required=False)
120 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2016 Functional Software, Inc.
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
--------------------------------------------------------------------------------