Full Code of divio/django-simple-sso for AI

master 05531919b46c cached
32 files
46.2 KB
11.5k tokens
100 symbols
1 requests
Download .txt
Repository: divio/django-simple-sso
Branch: master
Commit: 05531919b46c
Files: 32
Total size: 46.2 KB

Directory structure:
gitextract_r9bcdcde/

├── .coveragerc
├── .editorconfig
├── .gitignore
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── setup.py
├── simple_sso/
│   ├── __init__.py
│   ├── exceptions.py
│   ├── models.py
│   ├── sso_client/
│   │   ├── __init__.py
│   │   └── client.py
│   ├── sso_server/
│   │   ├── __init__.py
│   │   ├── apps.py
│   │   ├── migrations/
│   │   │   ├── 0001_initial.py
│   │   │   ├── 0002_consumer_name_max_length.py
│   │   │   ├── 0003_token_redirect_to_max_length.py
│   │   │   └── __init__.py
│   │   ├── models.py
│   │   └── server.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── requirements.txt
│   ├── settings.py
│   ├── test_core.py
│   ├── test_migrations.py
│   ├── urls.py
│   └── utils/
│       ├── __init__.py
│       └── context_managers.py
├── tox.ini
└── travis.yml

================================================
FILE CONTENTS
================================================

================================================
FILE: .coveragerc
================================================
[run]
branch = True
source = simple_sso
omit =
    migrations/*
    tests/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    if self.debug:
    if settings.DEBUG
    raise AssertionError
    raise NotImplementedError
    if 0:
    if __name__ == .__main__.:
ignore_errors = True


================================================
FILE: .editorconfig
================================================
# editorconfig.org

root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 80

[*.py]
max_line_length = 120
quote_type = single

[*.{scss,js,html}]
max_line_length = 120
indent_style = space
quote_type = double

[*.js]
max_line_length = 120
quote_type = single

[*.rst]
max_line_length = 80

[*.yml]
indent_size = 2


================================================
FILE: .gitignore
================================================
*.py[cod]
*$py.class
*.egg-info
*.log
*.pot
.DS_Store
.coverage/
.eggs/
.idea/
.project/
.pydevproject/
.vscode/
.settings/
.tox/
__pycache__/
build/
dist/
env/

local.sqlite


================================================
FILE: CHANGELOG.rst
================================================
=========
Changelog
=========

1.3.0 (2025-05-05)
==================

* Remove the abandoned dependency `webservices`, causing issues in the newer python versions because of the use of reserved names.


1.2.0 (2022-12-14)
==================

* Increased the max length of the Token.Token.redirect_to field to 1023


1.1.0 (2021-08-16)
==================

* Added support to update user-data on login (#61)


1.0.0 (2020-09-03)
==================

* Added changelog
* Added test framework
* Added support for Django 3.1
* Dropped support for Python 2.7 and Python 3.4
* Dropped support for Django < 2.2
* Aligned files with other addons
* Pinned itsdangerous<1.0.0 as the timestamp calculations changed


================================================
FILE: LICENSE
================================================
Copyright (c) 2011, Jonas Obrist
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of Jonas Obrist nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL JONAS OBRIST BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

================================================
FILE: MANIFEST.in
================================================
include LICENSE
include README.rst
recursive-exclude * *.py[co]


================================================
FILE: README.rst
================================================
=================
django-simple-sso
=================

|pypi| |build| |coverage|


Documentation
=============

See ``REQUIREMENTS`` in the `setup.py <https://github.com/divio/django-simple-sso/blob/master/setup.py>`_
file for additional dependencies:

|python| |django|


Django Simple SSO Specification (DRAFT)
=======================================

Terminology
***********

Server
------

The server is a Django website that holds all the user information and
authenticates users.

Client
------

The client is a Django website that provides login via SSO using the **Server**.
It does not hold any user information.

Key
---

A unique key identifying a **Client**. This key can be made public.

Secret
------

A secret key shared between the **Server** and a single **Client**. This secret
should never be shared with anyone other than the **Server** and **Client** and
must not be transferred unencrypted.

Workflow
********

* User wants to log into a **Client** by clicking a "Login" button. The
  initially requested URL can be passed using the ``next`` GET parameter.
* The **Client**'s Python code does a HTTP request to the **Server** to request a
  authentication token, this is called the **Request Token Request**.
* The **Server** returns a **Request Token**.
* The **Client** redirects the User to a view on the **Server** using the
  **Request Token**, this is the **Authorization Request**.
* If the user is not logged in the the **Server**, they are prompted to log in.
* The user is redirected to the **Client** including the **Request Token** and a
  **Auth Token**, this is the ``Authentication Request``.
* The **Client**'s Python code does a HTTP request to the **Server** to verify the
  **Auth Token**, this is called the **Auth Token Verification Request**.
* If the **Auth Token** is valid, the **Server** returns a serialized Django User
  object.
* The **Client** logs the user in using the Django User received from the **Server**.

Requests
********

General
-------

All requests have a ``signature`` and ``key`` parameter, see **Security**.

Request Token Request
---------------------

* Client: Python
* Target: **Server**
* Method: GET
* Extra Parameters: None
* Responses:

    * ``200``: Everything went fine, the body of the response is a url encoded
      query string containing with the ``request_token`` key holding the
      **Request Token** as well as the ``signature``.
    * ``400``: Bad request (missing GET parameters)
    * ``403``: Forbidden (invalid signature)


Authorization Request
---------------------

* Client: Browser (User)
* Target: **Server**
* Method: GET
* Extra Parameters:

    * ``request_token``

* Responses:

    * ``200``: Everything okay, prompt user to log in or continue.
    * ``400``: Bad request (missing GET parameter).
    * ``403``: Forbidden (invalid **Request Token**).


Authentication Request
----------------------

* Client: Browser (User)
* Target: **Client**
* Method: GET
* Extra Parameters:

    * ``request_token``: The **Request Token** returned by the
      **Request Token Request**.
    * ``auth_token``: The **Auth Token** generated by the **Authorization Request**.

* Responses:

    * ``200``: Everything went fine, the user is now logged in.
    * ``400``: Bad request (missing GET parameters).
    * ``403``: Forbidden (invalid **Request Token**).


Auth Token Verification Request
-------------------------------

* Client: Python
* Target: **Server**
* Method: GET
* Extra Parameters:

    * ``auth_token``: The **Auth Token** obtained by the **Authentication Request**.

* Responses:

    * ``200``: Everything went fine, the body of the response is a url encoded
      query string containing the ``user`` key which is the JSON serialized
      representation of the Django user to create as well as the ``signature``.

Security
********

Every request is signed using HMAC-SHA256. The signature is in the ``signature``
parameter. The signature message is the urlencoded, alphabetically ordered
query string. The signature key is the **Secret** of the **Client**. To verify
the signature the ``key`` parameter holding the **key** of the **Client** is
also sent with every request from the **Client** to the **Server**.

Example
-------

GET Request with the GET parameters ``key=bundle123`` and the private key
``secret key``: ``fbf6396d0fc40d563e2be3c861f7eb5a1b821b76c2ac943d40a7a63b288619a9``

The User object
***************

The User object returned by a successful **Auth Token Verification Request**
does not contain all the information about the Django User, in particular, it
does not contain the password.

The user object contains at least the following data:

* ``username``: The unique username of this user.
* ``email``: The email of this user.
* ``first_name``: The first name of this user, this field is required, but may
  be empty.
* ``last_name``: The last name of this user, this field is required, but may
  be empty.
* ``is_staff``: Can this user access the Django admin on the **Client**?
* ``is_superuser``: Does this user have superuser access to the **Client**?
* ``is_active``: Is the user active?

Implementation
**************

On the server
-------------

* Add ``simple_sso.sso_server`` to ``INSTALLED_APPS``.
* Create an instance (potentially of a subclass) of
  ``simple_sso.sso_server.server.Server`` and include the return value of the
  ``get_urls`` method on that instance into your url patterns.


On the client
-------------

* Create a new instance of ``simple_sso.sso_server.models.Consumer`` on the
  **Server**.
* Add the ``SIMPLE_SSO_SECRET`` and ``SIMPLE_SSO_KEY`` settings as provided by
  the **Server**'s ``simple_sso.sso_server.models.Client`` model.
* Add the ``SIMPLE_SSO_SERVER`` setting which is the absolute URL pointing to
  the root where the ``simple_sso.sso_server.urls`` where include on the
  **Server**.
* Add the ``simple_sso.sso_client.urls`` patterns somewhere on the client.


Running Tests
*************

You can run tests by executing::

    virtualenv env
    source env/bin/activate
    pip install -r tests/requirements.txt
    python setup.py test


.. |pypi| image:: https://badge.fury.io/py/django-simple.sso.svg
    :target: http://badge.fury.io/py/django-simple.sso
.. |build| image:: https://travis-ci.org/divio/django-simple.sso.svg?branch=master
    :target: https://travis-ci.org/divio/django-simple.sso
.. |coverage| image:: https://codecov.io/gh/divio/django-simple.sso/branch/master/graph/badge.svg
    :target: https://codecov.io/gh/divio/django-simple.sso

.. |python| image:: https://img.shields.io/badge/python-3.5+-blue.svg
    :target: https://pypi.org/project/django-simple.sso/
.. |django| image:: https://img.shields.io/badge/django-2.2,%203.0,%203.1-blue.svg
    :target: https://www.djangoproject.com/


================================================
FILE: setup.py
================================================
#!/usr/bin/env python
from setuptools import find_packages, setup

from simple_sso import __version__


REQUIREMENTS = [
    'Django>=2.2',
    'itsdangerous<1.0.0',
    'requests',
]


CLASSIFIERS = [
    'Development Status :: 5 - Production/Stable',
    'Environment :: Web Environment',
    'Intended Audience :: Developers',
    'License :: OSI Approved :: BSD License',
    'Operating System :: OS Independent',
    'Programming Language :: Python',
    'Programming Language :: Python :: 3',
    'Programming Language :: Python :: 3.5',
    'Programming Language :: Python :: 3.6',
    'Programming Language :: Python :: 3.7',
    'Programming Language :: Python :: 3.8',
    'Framework :: Django',
    'Framework :: Django :: 2.2',
    'Framework :: Django :: 3.0',
    'Framework :: Django :: 3.1',
    'Topic :: Internet :: WWW/HTTP',
    'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
    'Topic :: Software Development',
    'Topic :: Software Development :: Libraries',
]


setup(
    name='django-simple-sso',
    version=__version__,
    author='Divio AG',
    author_email='info@divio.com',
    url='http://github.com/aldryn/django-simple-sso',
    license='BSD-3-Clause',
    description='Simple SSO for Django',
    long_description=open('README.rst').read(),
    packages=find_packages(),
    include_package_data=True,
    zip_safe=False,
    install_requires=REQUIREMENTS,
    classifiers=CLASSIFIERS,
    test_suite='tests.settings.run',
)


================================================
FILE: simple_sso/__init__.py
================================================
__version__ = '1.3.0'


================================================
FILE: simple_sso/exceptions.py
================================================
class WebserviceError(Exception):
    pass


class BadRequest(WebserviceError):
    pass


================================================
FILE: simple_sso/models.py
================================================
"""
This is only here so I can run tests
"""


================================================
FILE: simple_sso/sso_client/__init__.py
================================================


================================================
FILE: simple_sso/sso_client/client.py
================================================
from copy import copy
from urllib.parse import urlparse, urlunparse, urljoin, urlencode

from django.urls import re_path
from django.contrib.auth import login
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User
from django.http import HttpResponseRedirect
from django.urls import NoReverseMatch, reverse
from django.views.generic import View
from itsdangerous import URLSafeTimedSerializer

from simple_sso.utils import SyncConsumer


class LoginView(View):
    client = None

    def get(self, request):
        next_ = self.get_next()
        scheme = 'https' if request.is_secure() else 'http'
        query = urlencode([('next', next_)])
        netloc = request.get_host()
        path = reverse('simple-sso-authenticate')
        redirect_to = urlunparse((scheme, netloc, path, '', query, ''))
        request_token = self.client.get_request_token(redirect_to)
        host = urljoin(self.client.server_url, 'authorize/')
        url = '%s?%s' % (host, urlencode([('token', request_token)]))
        return HttpResponseRedirect(url)

    def get_next(self):
        """
        Given a request, returns the URL where a user should be redirected to
        after login. Defaults to '/'
        """
        next_ = self.request.GET.get('next', None)
        if not next_:
            return '/'
        netloc = urlparse(next_)[1]
        # Heavier security check -- don't allow redirection to a different
        # host.
        # Taken from django.contrib.auth.views.login
        if netloc and netloc != self.request.get_host():
            return '/'
        return next_


class AuthenticateView(LoginView):
    client = None

    def get(self, request):
        raw_access_token = request.GET['access_token']
        access_token = URLSafeTimedSerializer(self.client.private_key).loads(raw_access_token)
        user = self.client.get_user(access_token)
        user.backend = self.client.backend
        login(request, user)
        next_ = self.get_next()
        return HttpResponseRedirect(next_)


class Client:
    login_view = LoginView
    authenticate_view = AuthenticateView
    backend = "%s.%s" % (ModelBackend.__module__, ModelBackend.__name__)
    user_extra_data = None

    def __init__(self, server_url, public_key, private_key,
                 user_extra_data=None):
        self.server_url = server_url
        self.public_key = public_key
        self.private_key = private_key
        self.consumer = SyncConsumer(self.server_url, self.public_key, self.private_key)
        if user_extra_data:
            self.user_extra_data = user_extra_data

    @classmethod
    def from_dsn(cls, dsn):
        parse_result = urlparse(dsn)
        public_key = parse_result.username
        private_key = parse_result.password
        netloc = parse_result.hostname
        if parse_result.port:
            netloc += ':%s' % parse_result.port
        server_url = urlunparse((parse_result.scheme, netloc, parse_result.path,
                                 parse_result.params, parse_result.query, parse_result.fragment))
        return cls(server_url, public_key, private_key)

    def get_request_token(self, redirect_to):
        try:
            url = reverse('simple-sso-request-token')
        except NoReverseMatch:
            # thisisfine
            url = '/request-token/'
        return self.consumer.consume(url, {'redirect_to': redirect_to})['request_token']

    def get_user(self, access_token):
        data = {'access_token': access_token}
        if self.user_extra_data:
            data['extra_data'] = self.user_extra_data

        try:
            url = reverse('simple-sso-verify')
        except NoReverseMatch:
            # thisisfine
            url = '/verify/'
        user_data = self.consumer.consume(url, data)
        user = self.build_user(user_data)
        return user

    def build_user(self, user_data):
        try:
            user = User.objects.get(username=user_data['username'])
            # Update user data, excluding username changes
            # Work on copied _tmp dict to keep an untouched user_data
            user_data_tmp = copy(user_data)
            del user_data_tmp['username']
            for _attr, _val in user_data_tmp.items():
                setattr(user, _attr, _val)
        except User.DoesNotExist:
            user = User(**user_data)
        user.set_unusable_password()
        user.save()
        return user

    def get_urls(self):
        return [
            re_path(r'^$', self.login_view.as_view(client=self), name='simple-sso-login'),
            re_path(r'^authenticate/$', self.authenticate_view.as_view(client=self), name='simple-sso-authenticate'),
        ]


================================================
FILE: simple_sso/sso_server/__init__.py
================================================
default_app_config = 'simple_sso.sso_server.apps.SimpleSSOServer'


================================================
FILE: simple_sso/sso_server/apps.py
================================================
from django.apps import AppConfig


class SimpleSSOServer(AppConfig):
    name = 'simple_sso.sso_server'


================================================
FILE: simple_sso/sso_server/migrations/0001_initial.py
================================================
from django.db import migrations, models
from django.utils import timezone
from django.conf import settings
import simple_sso.sso_server.models


class Migration(migrations.Migration):

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.CreateModel(
            name='Consumer',
            fields=[
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                ('name', models.CharField(unique=True, max_length=100)),
                ('private_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('private_key'), unique=True, max_length=64)),
                ('public_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('public_key'), unique=True, max_length=64)),
            ],
        ),
        migrations.CreateModel(
            name='Token',
            fields=[
                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                ('request_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('request_token'), unique=True, max_length=64)),
                ('access_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('access_token'), unique=True, max_length=64)),
                ('timestamp', models.DateTimeField(default=timezone.now)),
                ('redirect_to', models.CharField(max_length=255)),
                ('consumer', models.ForeignKey(related_name='tokens', to='sso_server.Consumer', on_delete=models.CASCADE)),
                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
            ],
        ),
    ]


================================================
FILE: simple_sso/sso_server/migrations/0002_consumer_name_max_length.py
================================================
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('sso_server', '0001_initial'),
    ]

    operations = [
        migrations.AlterField(
            model_name='consumer',
            name='name',
            field=models.CharField(unique=True, max_length=255),
        ),
    ]


================================================
FILE: simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py
================================================
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('sso_server', '0002_consumer_name_max_length'),
    ]

    operations = [
        migrations.AlterField(
            model_name='token',
            name='redirect_to',
            field=models.CharField(max_length=1023),
        ),
    ]


================================================
FILE: simple_sso/sso_server/migrations/__init__.py
================================================


================================================
FILE: simple_sso/sso_server/models.py
================================================
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.deconstruct import deconstructible

from ..utils import gen_secret_key


@deconstructible
class SecretKeyGenerator:
    """
    Helper to give default values to Client.secret and Client.key
    """

    def __init__(self, field):
        self.field = field

    def __call__(self):
        key = gen_secret_key(64)
        while self.get_model().objects.filter(**{self.field: key}).exists():
            key = gen_secret_key(64)
        return key


class ConsumerSecretKeyGenerator(SecretKeyGenerator):
    def get_model(self):
        return Consumer


class TokenSecretKeyGenerator(SecretKeyGenerator):
    def get_model(self):
        return Token


class Consumer(models.Model):
    name = models.CharField(max_length=255, unique=True)
    private_key = models.CharField(
        max_length=64, unique=True,
        default=ConsumerSecretKeyGenerator('private_key')
    )
    public_key = models.CharField(
        max_length=64, unique=True,
        default=ConsumerSecretKeyGenerator('public_key')
    )

    def __unicode__(self):
        return self.name

    def rotate_keys(self):
        self.secret = ConsumerSecretKeyGenerator('private_key')()
        self.key = ConsumerSecretKeyGenerator('public_key')()
        self.save()


class Token(models.Model):
    consumer = models.ForeignKey(
        Consumer,
        related_name='tokens',
        on_delete=models.CASCADE,
    )
    request_token = models.CharField(
        unique=True, max_length=64,
        default=TokenSecretKeyGenerator('request_token')
    )
    access_token = models.CharField(
        unique=True, max_length=64,
        default=TokenSecretKeyGenerator('access_token')
    )
    timestamp = models.DateTimeField(default=timezone.now)
    redirect_to = models.CharField(max_length=1023)
    user = models.ForeignKey(
        getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),
        null=True,
        on_delete=models.CASCADE,
    )

    def refresh(self):
        self.timestamp = timezone.now()
        self.save()


================================================
FILE: simple_sso/sso_server/server.py
================================================
import datetime
from urllib.parse import urlparse, urlencode, urlunparse

from django.contrib import admin
from django.contrib.admin.options import ModelAdmin
from django.http import (HttpResponseForbidden, HttpResponseBadRequest, HttpResponseRedirect, QueryDict)
from django.urls import re_path
from django.urls import reverse
from django.utils import timezone
from django.views.generic.base import View
from itsdangerous import URLSafeTimedSerializer

from simple_sso.sso_server.models import Token, Consumer
from simple_sso.utils import BaseProvider, provider_wrapper


class Provider(BaseProvider):
    max_age = 5

    def __init__(self, server):
        self.server = server

    def get_private_key(self, public_key):
        try:
            self.consumer = Consumer.objects.get(public_key=public_key)
        except Consumer.DoesNotExist:
            return None
        return self.consumer.private_key


class RequestTokenProvider(Provider):
    def provide(self, data):
        redirect_to = data['redirect_to']
        token = Token.objects.create(consumer=self.consumer, redirect_to=redirect_to)
        return {'request_token': token.request_token}


class AuthorizeView(View):
    """
    The client get's redirected to this view with the `request_token` obtained
    by the Request Token Request by the client application beforehand.

    This view checks if the user is logged in on the server application and if
    that user has the necessary rights.

    If the user is not logged in, the user is prompted to log in.
    """
    server = None

    def get(self, request):
        request_token = request.GET.get('token', None)
        if not request_token:
            return self.missing_token_argument()
        try:
            self.token = Token.objects.select_related('consumer').get(request_token=request_token)
        except Token.DoesNotExist:
            return self.token_not_found()
        if not self.check_token_timeout():
            return self.token_timeout()
        self.token.refresh()
        if request.user.is_authenticated:
            return self.handle_authenticated_user()
        else:
            return self.handle_unauthenticated_user()

    def missing_token_argument(self):
        return HttpResponseBadRequest('Token missing')

    def token_not_found(self):
        return HttpResponseForbidden('Token not found')

    def token_timeout(self):
        return HttpResponseForbidden('Token timed out')

    def check_token_timeout(self):
        delta = timezone.now() - self.token.timestamp
        if delta > self.server.token_timeout:
            self.token.delete()
            return False
        else:
            return True

    def handle_authenticated_user(self):
        if self.server.has_access(self.request.user, self.token.consumer):
            return self.success()
        else:
            return self.access_denied()

    def handle_unauthenticated_user(self):
        next_ = '%s?%s' % (self.request.path, urlencode([('token', self.token.request_token)]))
        url = '%s?%s' % (reverse(self.server.auth_view_name), urlencode([('next', next_)]))
        return HttpResponseRedirect(url)

    def access_denied(self):
        return HttpResponseForbidden("Access denied")

    def success(self):
        self.token.user = self.request.user
        self.token.save()
        serializer = URLSafeTimedSerializer(self.token.consumer.private_key)
        parse_result = urlparse(self.token.redirect_to)
        query_dict = QueryDict(parse_result.query, mutable=True)
        query_dict['access_token'] = serializer.dumps(self.token.access_token)
        url = urlunparse((parse_result.scheme, parse_result.netloc, parse_result.path, '', query_dict.urlencode(), ''))
        return HttpResponseRedirect(url)


class VerificationProvider(Provider, AuthorizeView):
    def provide(self, data):
        token = data['access_token']
        try:
            self.token = Token.objects.select_related('user').get(access_token=token, consumer=self.consumer)
        except Token.DoesNotExist:
            return self.token_not_found()
        if not self.check_token_timeout():
            return self.token_timeout()
        if not self.token.user:
            return self.token_not_bound()
        extra_data = data.get('extra_data', None)
        return self.server.get_user_data(
            self.token.user, self.consumer, extra_data=extra_data)

    def token_not_bound(self):
        return HttpResponseForbidden("Invalid token")


class ConsumerAdmin(ModelAdmin):
    readonly_fields = ['public_key', 'private_key']


class Server:
    request_token_provider = RequestTokenProvider
    authorize_view = AuthorizeView
    verification_provider = VerificationProvider
    token_timeout = datetime.timedelta(minutes=5)
    client_admin = ConsumerAdmin
    auth_view_name = 'login'

    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            setattr(self, key, value)
        self.register_admin()

    def register_admin(self):
        admin.site.register(Consumer, self.client_admin)

    def has_access(self, user, consumer):
        return True

    def get_user_extra_data(self, user, consumer, extra_data):
        raise NotImplementedError()

    def get_user_data(self, user, consumer, extra_data=None):
        user_data = {
            'username': user.username,
            'email': user.email,
            'first_name': user.first_name,
            'last_name': user.last_name,
            'is_staff': False,
            'is_superuser': False,
            'is_active': user.is_active,
        }
        if extra_data:
            user_data['extra_data'] = self.get_user_extra_data(
                user, consumer, extra_data)
        return user_data

    def get_urls(self):
        return [
            re_path(r'^request-token/$', provider_wrapper(self.request_token_provider(server=self)),
                    name='simple-sso-request-token'),
            re_path(r'^authorize/$', self.authorize_view.as_view(server=self), name='simple-sso-authorize'),
            re_path(r'^verify/$', provider_wrapper(
                    self.verification_provider(server=self)), name='simple-sso-verify'),
        ]


================================================
FILE: simple_sso/utils.py
================================================
import string
from random import SystemRandom
from urllib.parse import urlparse, urlunparse, urljoin

import requests
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from itsdangerous import TimedSerializer, SignatureExpired, BadSignature

from simple_sso.exceptions import BadRequest, WebserviceError

random = SystemRandom()

KEY_CHARACTERS = string.ascii_letters + string.digits
PUBLIC_KEY_HEADER = 'x-services-public-key'


def default_gen_secret_key(length=40):
    return ''.join([random.choice(KEY_CHARACTERS) for _ in range(length)])


def gen_secret_key(length=40):
    generator = getattr(settings, 'SIMPLE_SSO_KEYGENERATOR', default_gen_secret_key)
    return generator(length)


def _split_dsn(dsn):
    parse_result = urlparse(dsn)
    host = parse_result.hostname
    if parse_result.port:
        host += ':%s' % parse_result.port
    base_url = urlunparse((
        parse_result.scheme,
        host,
        parse_result.path,
        parse_result.params,
        parse_result.query,
        parse_result.fragment,
    ))
    return base_url, parse_result.username, parse_result.password


class BaseConsumer(object):
    def __init__(self, base_url, public_key, private_key):
        self.base_url = base_url
        self.public_key = public_key
        self.signer = TimedSerializer(private_key)

    @classmethod
    def from_dsn(cls, dsn):
        base_url, public_key, private_key = _split_dsn(dsn)
        return cls(base_url, public_key, private_key)

    def consume(self, path, data, max_age=None):
        if not path.startswith('/'):
            raise ValueError("Paths must start with a slash")
        signed_data = self.signer.dumps(data)
        headers = {
            PUBLIC_KEY_HEADER: self.public_key,
            'Content-Type': 'application/json',
        }
        url = self.build_url(path)
        body = self.send_request(url, data=signed_data, headers=headers)
        return self.handle_response(body, max_age)

    def handle_response(self, body, max_age):
        return self.signer.loads(body, max_age=max_age)

    def send_request(self, url, data, headers):
        raise NotImplementedError(
            'Implement send_request on BaseConsumer subclasses')

    @staticmethod
    def raise_for_status(status_code, message):
        if status_code == 400:
            raise BadRequest(message)
        elif status_code >= 300:
            raise WebserviceError(message)

    def build_url(self, path):
        path = path.lstrip('/')
        return urljoin(self.base_url, path)


class SyncConsumer(BaseConsumer):
    def __init__(self, base_url, public_key, private_key):
        super(SyncConsumer, self).__init__(base_url, public_key, private_key)
        self.session = requests.session()

    def send_request(self, url, data, headers):  # pragma: no cover
        response = self.session.post(url, data=data, headers=headers)
        self.raise_for_status(response.status_code, response.content)
        return response.content


class BaseProvider(object):
    max_age = None

    def provide(self, data):
        raise NotImplementedError(
            'Subclasses of services.models.Provider must implement '
            'the provide method'
        )

    def get_private_key(self, public_key):
        raise NotImplementedError(
            'Subclasses of services.models.Provider must implement '
            'the get_private_key method'
        )

    def report_exception(self):
        pass

    def get_response(self, method, signed_data, get_header):
        if method != 'POST':
            return 405, ['POST']
        public_key = get_header(PUBLIC_KEY_HEADER, None)
        if not public_key:
            return 400, "No public key"
        private_key = self.get_private_key(public_key)
        if not private_key:
            return 400, "Invalid public key"
        signer = TimedSerializer(private_key)
        try:
            data = signer.loads(signed_data, max_age=self.max_age)
        except SignatureExpired:
            return 400, "Signature expired"
        except BadSignature:
            return 400, "Bad Signature"
        try:
            raw_response_data = self.provide(data)
        except:
            self.report_exception()
            return 400, "Failed to process the request"
        response_data = signer.dumps(raw_response_data)
        return 200, response_data


def provider_wrapper(provider):
    def provider_view(request):
        def get_header(key, default):
            django_key = 'HTTP_%s' % key.upper().replace('-', '_')
            return request.META.get(django_key, default)

        method = request.method
        if getattr(request, 'body', None):
            signed_data = request.body
        else:
            signed_data = request.raw_post_data
        status_code, data = provider.get_response(
            method,
            signed_data,
            get_header,
        )
        return HttpResponse(data, status=status_code)

    return csrf_exempt(provider_view)


================================================
FILE: tests/__init__.py
================================================


================================================
FILE: tests/requirements.txt
================================================
tox
coverage
flake8


================================================
FILE: tests/settings.py
================================================
import os
import sys


urlpatterns = []

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': ':memory:'
    }
}

INSTALLED_APPS = [
    'django.contrib.contenttypes',
    'django.contrib.auth',
    'django.contrib.sessions',
    'django.contrib.admin',
    'django.contrib.messages',
    'simple_sso.sso_server',
    'simple_sso',
    'tests',
]

ROOT_URLCONF = 'tests.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(os.path.dirname(__file__), 'templates')
        ],
        'OPTIONS': {
            'debug': True,
            'context_processors': [
                'django.contrib.auth.context_processors.auth',
                'django.template.context_processors.request',
                'django.contrib.messages.context_processors.messages',
            ],
            'loaders': (
                'django.template.loaders.filesystem.Loader',
                'django.template.loaders.app_directories.Loader',
            )
        },
    },
]

MIDDLEWARES = [
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
]


def runtests():
    from django import setup
    from django.conf import settings
    from django.test.utils import get_runner

    settings.configure(
        INSTALLED_APPS=INSTALLED_APPS,
        ROOT_URLCONF=ROOT_URLCONF,
        DATABASES=DATABASES,
        TEST_RUNNER='django.test.runner.DiscoverRunner',
        TEMPLATES=TEMPLATES,
        MIDDLEWARE=MIDDLEWARES,
        SSO_PRIVATE_KEY='private',
        SSO_PUBLIC_KEY='public',
        SSO_SERVER='http://localhost/server/',
        SECRET_KEY="secret-key-for-tests",
    )
    setup()

    # Run the test suite, including the extra validation tests.
    TestRunner = get_runner(settings)

    test_runner = TestRunner(verbosity=1, interactive=False, failfast=False)
    failures = test_runner.run_tests(INSTALLED_APPS)
    return failures


def run():
    failures = runtests()
    sys.exit(failures)


if __name__ == '__main__':
    run()


================================================
FILE: tests/test_core.py
================================================
from urllib.parse import urlparse

from django.conf import settings
from django.contrib.auth import get_user
from django.contrib.auth.hashers import is_password_usable
from django.contrib.auth.models import User
from django.http import HttpResponseRedirect, HttpResponse
from django.test.testcases import TestCase
from django.urls import reverse

from simple_sso.sso_server.models import Token, Consumer
from simple_sso.utils import gen_secret_key, SyncConsumer
from tests.urls import test_client
from tests.utils.context_managers import (SettingsOverride,
                                          UserLoginContext)


class TestingConsumer(SyncConsumer):
    def __init__(self, test_client_, base_url, public_key, private_key):
        self.test_client = test_client_
        super(SyncConsumer, self).__init__(base_url, public_key, private_key)

    def build_url(self, path):
        return path

    def send_request(self, url, data, headers):
        headers = {
            'HTTP_%s' % header.upper().replace('-', '_'): value
            for header, value in headers.items()
        }
        response = self.test_client.post(
            url,
            data=data,
            content_type='application/json',
            **headers
        )
        self.raise_for_status(response.status_code, response.content)
        return response.content


class SimpleSSOTests(TestCase):
    urls = 'simple_sso.test_urls'

    def setUp(self):
        import requests

        def get(url, params={}, headers={}, cookies=None, auth=None, **kwargs):
            return self.client.get(url, params)
        requests.get = get
        test_client.consumer = TestingConsumer(
            self.client, test_client.server_url, test_client.public_key, test_client.private_key)

    def _get_consumer(self):
        return Consumer.objects.create(
            name='test',
            private_key=settings.SSO_PRIVATE_KEY,
            public_key=settings.SSO_PUBLIC_KEY,
        )

    def test_walkthrough(self):
        USERNAME = PASSWORD = 'myuser'
        server_user = User.objects.create_user(USERNAME, 'my@user.com', PASSWORD)
        self._get_consumer()
        # verify theres no tokens yet
        self.assertEqual(Token.objects.count(), 0)
        response = self.client.get(reverse('simple-sso-login'))
        # there should be a token now
        self.assertEqual(Token.objects.count(), 1)
        # this should be a HttpResponseRedirect
        self.assertEqual(response.status_code, HttpResponseRedirect.status_code)
        # check that it's the URL we expect
        url = urlparse(response['Location'])
        path = url.path
        self.assertEqual(path, reverse('simple-sso-authorize'))
        # follow that redirect
        response = self.client.get(response['Location'])
        # now we should have another redirect to the login
        self.assertEqual(response.status_code, HttpResponseRedirect.status_code, response.content)
        # check that the URL is correct
        url = urlparse(response['Location'])
        path = url.path
        self.assertEqual(path, reverse('login'))
        # follow that redirect
        login_url = response['Location']
        response = self.client.get(login_url)
        # now we should have a 200
        self.assertEqual(response.status_code, HttpResponse.status_code)
        # and log in using the username/password from above
        response = self.client.post(login_url, {'username': USERNAME, 'password': PASSWORD})
        # now we should have a redirect back to the authorize view
        self.assertEqual(response.status_code, HttpResponseRedirect.status_code)
        # check that it's the URL we expect
        url = urlparse(response['Location'])
        path = url.path
        self.assertEqual(path, reverse('simple-sso-authorize'))
        # follow that redirect
        response = self.client.get(response['Location'])
        # this should again be a redirect
        self.assertEqual(response.status_code, HttpResponseRedirect.status_code)
        # this time back to the client app, confirm that!
        url = urlparse(response['Location'])
        path = url.path
        self.assertEqual(path, reverse('simple-sso-authenticate'))
        # follow it again
        response = self.client.get(response['Location'])
        # again a redirect! This time to /
        url = urlparse(response['Location'])
        path = url.path
        self.assertEqual(path, reverse('root'))
        # if we follow to root now, we should be logged in
        response = self.client.get(response['Location'])
        client_user = get_user(self.client)
        self.assertFalse(is_password_usable(client_user.password))
        self.assertTrue(is_password_usable(server_user.password))
        for key in ['username', 'email', 'first_name', 'last_name']:
            self.assertEqual(getattr(client_user, key), getattr(server_user, key))

    def test_user_already_logged_in(self):
        USERNAME = PASSWORD = 'myuser'
        server_user = User.objects.create_user(USERNAME, 'my@user.com', PASSWORD)
        self._get_consumer()
        with UserLoginContext(self, server_user):
            # try logging in and auto-follow all 302s
            self.client.get(reverse('simple-sso-login'), follow=True)
            # check the user
            client_user = get_user(self.client)
            self.assertFalse(is_password_usable(client_user.password))
            self.assertTrue(is_password_usable(server_user.password))
            for key in ['username', 'email', 'first_name', 'last_name']:
                self.assertEqual(getattr(client_user, key), getattr(server_user, key))

    def test_user_data_updated(self):
        """ User data update test

        Tests whether sso server user data changes will be forwared to the client on the user's next login.

        """
        USERNAME = PASSWORD = 'myuser'
        extra_data = {
            "first_name": "bob",
            "last_name": "bobster",
        }
        server_user = User.objects.create_user(
            USERNAME,
            'bob@bobster.org',
            PASSWORD,
            **extra_data,
        )
        self._get_consumer()

        with UserLoginContext(self, server_user):
            # First login
            # try logging in and auto-follow all 302s
            self.client.get(reverse('simple-sso-login'), follow=True)
            # check the user
            client_user = get_user(self.client)
            for key in ['username', 'email', 'first_name', 'last_name']:
                self.assertEqual(getattr(client_user, key), getattr(server_user, key))

        # User data changes
        server_user.first_name = "Alice"
        server_user.email = "alice@bobster.org"
        server_user.save()

        with UserLoginContext(self, server_user):
            # Second login
            self.client.get(reverse('simple-sso-login'), follow=True)
            client_user = get_user(self.client)
            for key in ['username', 'email', 'first_name', 'last_name']:
                self.assertEqual(getattr(client_user, key), getattr(server_user, key))

    def test_custom_keygen(self):
        # WARNING: The following test uses a key generator function that is
        # highly insecure and should never under any circumstances be used in
        # a production enivornment
        with SettingsOverride(SIMPLE_SSO_KEYGENERATOR=lambda length: 'test'):
            self.assertEqual(gen_secret_key(40), 'test')


================================================
FILE: tests/test_migrations.py
================================================
# original from
# http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html
from io import StringIO

from django.core.management import call_command
from django.test import TestCase, override_settings


class MigrationTestCase(TestCase):

    @override_settings(MIGRATION_MODULES={})
    def test_for_missing_migrations(self):
        output = StringIO()
        options = {
            'interactive': False,
            'dry_run': True,
            'stdout': output,
            'check_changes': True,
        }

        try:
            call_command('makemigrations', **options)
        except SystemExit as e:
            status_code = str(e)
        else:
            # the "no changes" exit code is 0
            status_code = '0'

        if status_code == '1':
            self.fail('There are missing migrations:\n {}'.format(output.getvalue()))


================================================
FILE: tests/urls.py
================================================
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.views import LoginView
from django.http import HttpResponse
from django.urls import re_path, include

from simple_sso.sso_client.client import Client
from simple_sso.sso_server.server import Server

test_server = Server()
test_client = Client(settings.SSO_SERVER, settings.SSO_PUBLIC_KEY, settings.SSO_PRIVATE_KEY)

urlpatterns = [
    re_path(r'^admin/', admin.site.urls),
    re_path(r'^server/', include(test_server.get_urls())),
    re_path(r'^client/', include(test_client.get_urls())),
    re_path(r'^login/$', LoginView.as_view(template_name='admin/login.html'), name="login"),
    re_path('^$', lambda request: HttpResponse('home'), name='root')
]


================================================
FILE: tests/utils/__init__.py
================================================


================================================
FILE: tests/utils/context_managers.py
================================================
from django.conf import settings


class NULL:
    pass


class SettingsOverride:
    """
    Overrides Django settings within a context and resets them to their inital
    values on exit.

    Example:

        with SettingsOverride(DEBUG=True):
            # do something
    """

    def __init__(self, **overrides):
        self.overrides = overrides

    def __enter__(self):
        self.old = {}
        for key, value in self.overrides.items():
            self.old[key] = getattr(settings, key, NULL)
            setattr(settings, key, value)

    def __exit__(self, type, value, traceback):
        for key, value in self.old.items():
            if value is not NULL:
                setattr(settings, key, value)
            else:
                delattr(settings, key)  # do not pollute the context!


class UserLoginContext:
    def __init__(self, testcase, user):
        self.testcase = testcase
        self.user = user

    def __enter__(self):
        loginok = self.testcase.client.login(username=self.user.username,
                                             password=self.user.username)
        self.old_user = getattr(self.testcase, 'user', None)
        self.testcase.user = self.user
        self.testcase.assertTrue(loginok)

    def __exit__(self, exc, value, tb):
        self.testcase.user = self.old_user
        if not self.testcase.user:
            delattr(self.testcase, 'user')
        self.testcase.client.logout()


================================================
FILE: tox.ini
================================================
[tox]
envlist =
    flake8
    isort
    py{35,36,37,38}-dj{22}
    py{36,37,38}-dj{30,31}

skip_missing_interpreters=True

[flake8]
max-line-length = 119
exclude =
    *.egg-info,
    .eggs,
    .git,
    .settings,
    .tox,
    build,
    data,
    dist,
    docs,
    *migrations*,
    requirements,
    tmp

[isort]
line_length = 79
skip = manage.py, *migrations*, .tox, .eggs, data
include_trailing_comma = true
multi_line_output = 5
not_skip = __init__.py
lines_after_imports = 2
default_section = THIRDPARTY
sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER
known_first_party = simple_sso
known_django = django

[testenv]
deps =
    -r{toxinidir}/tests/requirements.txt
    dj22: Django>=2.2,<3.0
    dj30: Django>=3.0,<3.1
    dj31: Django>=3.1,<3.2
commands =
    {envpython} --version
    {env:COMMAND:coverage} erase
    {env:COMMAND:coverage} run setup.py test
    {env:COMMAND:coverage} report

[testenv:flake8]
deps = flake8
commands = flake8

[testenv:isort]
deps = isort
commands = isort -c -rc -df simple_sso
skip_install = true


================================================
FILE: travis.yml
================================================
language: python

dist: xenial

matrix:
  include:
    - python: 3.5
      env: TOX_ENV='flake8'
    - python: 3.5
      env: TOX_ENV='isort'
    # Django 2.2, run all supported versions for LTS releases
    - python: 3.5
      env: DJANGO='dj22'
    - python: 3.6
      env: DJANGO='dj22'
    - python: 3.7
      env: DJANGO='dj22'
    - python: 3.8
      env: DJANGO='dj22'
    # Django 3.0, always run the lowest supported version
    - python: 3.6
      env: DJANGO='dj30'
    # Django 3.1, always run the lowest supported version
    - python: 3.6
      env: DJANGO='dj31'

install:
  - pip install coverage isort tox
  - "if [[ $TRAVIS_PYTHON_VERSION == '3.5' ]]; then export PY_VER=py35; fi"
  - "if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then export PY_VER=py36; fi"
  - "if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then export PY_VER=py37; fi"
  - "if [[ $TRAVIS_PYTHON_VERSION == '3.8' ]]; then export PY_VER=py38; fi"
  - "if [[ ${DJANGO}z != 'z' ]]; then export TOX_ENV=$PY_VER; fi"

script:
  - tox -e $TOX_ENV

after_success:
  - bash <(curl -s https://codecov.io/bash)
Download .txt
gitextract_r9bcdcde/

├── .coveragerc
├── .editorconfig
├── .gitignore
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── setup.py
├── simple_sso/
│   ├── __init__.py
│   ├── exceptions.py
│   ├── models.py
│   ├── sso_client/
│   │   ├── __init__.py
│   │   └── client.py
│   ├── sso_server/
│   │   ├── __init__.py
│   │   ├── apps.py
│   │   ├── migrations/
│   │   │   ├── 0001_initial.py
│   │   │   ├── 0002_consumer_name_max_length.py
│   │   │   ├── 0003_token_redirect_to_max_length.py
│   │   │   └── __init__.py
│   │   ├── models.py
│   │   └── server.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── requirements.txt
│   ├── settings.py
│   ├── test_core.py
│   ├── test_migrations.py
│   ├── urls.py
│   └── utils/
│       ├── __init__.py
│       └── context_managers.py
├── tox.ini
└── travis.yml
Download .txt
SYMBOL INDEX (100 symbols across 13 files)

FILE: simple_sso/exceptions.py
  class WebserviceError (line 1) | class WebserviceError(Exception):
  class BadRequest (line 5) | class BadRequest(WebserviceError):

FILE: simple_sso/sso_client/client.py
  class LoginView (line 16) | class LoginView(View):
    method get (line 19) | def get(self, request):
    method get_next (line 31) | def get_next(self):
  class AuthenticateView (line 48) | class AuthenticateView(LoginView):
    method get (line 51) | def get(self, request):
  class Client (line 61) | class Client:
    method __init__ (line 67) | def __init__(self, server_url, public_key, private_key,
    method from_dsn (line 77) | def from_dsn(cls, dsn):
    method get_request_token (line 88) | def get_request_token(self, redirect_to):
    method get_user (line 96) | def get_user(self, access_token):
    method build_user (line 110) | def build_user(self, user_data):
    method get_urls (line 125) | def get_urls(self):

FILE: simple_sso/sso_server/apps.py
  class SimpleSSOServer (line 4) | class SimpleSSOServer(AppConfig):

FILE: simple_sso/sso_server/migrations/0001_initial.py
  class Migration (line 7) | class Migration(migrations.Migration):

FILE: simple_sso/sso_server/migrations/0002_consumer_name_max_length.py
  class Migration (line 4) | class Migration(migrations.Migration):

FILE: simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py
  class Migration (line 4) | class Migration(migrations.Migration):

FILE: simple_sso/sso_server/models.py
  class SecretKeyGenerator (line 10) | class SecretKeyGenerator:
    method __init__ (line 15) | def __init__(self, field):
    method __call__ (line 18) | def __call__(self):
  class ConsumerSecretKeyGenerator (line 25) | class ConsumerSecretKeyGenerator(SecretKeyGenerator):
    method get_model (line 26) | def get_model(self):
  class TokenSecretKeyGenerator (line 30) | class TokenSecretKeyGenerator(SecretKeyGenerator):
    method get_model (line 31) | def get_model(self):
  class Consumer (line 35) | class Consumer(models.Model):
    method __unicode__ (line 46) | def __unicode__(self):
    method rotate_keys (line 49) | def rotate_keys(self):
  class Token (line 55) | class Token(models.Model):
    method refresh (line 77) | def refresh(self):

FILE: simple_sso/sso_server/server.py
  class Provider (line 17) | class Provider(BaseProvider):
    method __init__ (line 20) | def __init__(self, server):
    method get_private_key (line 23) | def get_private_key(self, public_key):
  class RequestTokenProvider (line 31) | class RequestTokenProvider(Provider):
    method provide (line 32) | def provide(self, data):
  class AuthorizeView (line 38) | class AuthorizeView(View):
    method get (line 50) | def get(self, request):
    method missing_token_argument (line 66) | def missing_token_argument(self):
    method token_not_found (line 69) | def token_not_found(self):
    method token_timeout (line 72) | def token_timeout(self):
    method check_token_timeout (line 75) | def check_token_timeout(self):
    method handle_authenticated_user (line 83) | def handle_authenticated_user(self):
    method handle_unauthenticated_user (line 89) | def handle_unauthenticated_user(self):
    method access_denied (line 94) | def access_denied(self):
    method success (line 97) | def success(self):
  class VerificationProvider (line 108) | class VerificationProvider(Provider, AuthorizeView):
    method provide (line 109) | def provide(self, data):
    method token_not_bound (line 123) | def token_not_bound(self):
  class ConsumerAdmin (line 127) | class ConsumerAdmin(ModelAdmin):
  class Server (line 131) | class Server:
    method __init__ (line 139) | def __init__(self, **kwargs):
    method register_admin (line 144) | def register_admin(self):
    method has_access (line 147) | def has_access(self, user, consumer):
    method get_user_extra_data (line 150) | def get_user_extra_data(self, user, consumer, extra_data):
    method get_user_data (line 153) | def get_user_data(self, user, consumer, extra_data=None):
    method get_urls (line 168) | def get_urls(self):

FILE: simple_sso/utils.py
  function default_gen_secret_key (line 19) | def default_gen_secret_key(length=40):
  function gen_secret_key (line 23) | def gen_secret_key(length=40):
  function _split_dsn (line 28) | def _split_dsn(dsn):
  class BaseConsumer (line 44) | class BaseConsumer(object):
    method __init__ (line 45) | def __init__(self, base_url, public_key, private_key):
    method from_dsn (line 51) | def from_dsn(cls, dsn):
    method consume (line 55) | def consume(self, path, data, max_age=None):
    method handle_response (line 67) | def handle_response(self, body, max_age):
    method send_request (line 70) | def send_request(self, url, data, headers):
    method raise_for_status (line 75) | def raise_for_status(status_code, message):
    method build_url (line 81) | def build_url(self, path):
  class SyncConsumer (line 86) | class SyncConsumer(BaseConsumer):
    method __init__ (line 87) | def __init__(self, base_url, public_key, private_key):
    method send_request (line 91) | def send_request(self, url, data, headers):  # pragma: no cover
  class BaseProvider (line 97) | class BaseProvider(object):
    method provide (line 100) | def provide(self, data):
    method get_private_key (line 106) | def get_private_key(self, public_key):
    method report_exception (line 112) | def report_exception(self):
    method get_response (line 115) | def get_response(self, method, signed_data, get_header):
  function provider_wrapper (line 140) | def provider_wrapper(provider):

FILE: tests/settings.py
  function runtests (line 55) | def runtests():
  function run (line 82) | def run():

FILE: tests/test_core.py
  class TestingConsumer (line 18) | class TestingConsumer(SyncConsumer):
    method __init__ (line 19) | def __init__(self, test_client_, base_url, public_key, private_key):
    method build_url (line 23) | def build_url(self, path):
    method send_request (line 26) | def send_request(self, url, data, headers):
  class SimpleSSOTests (line 41) | class SimpleSSOTests(TestCase):
    method setUp (line 44) | def setUp(self):
    method _get_consumer (line 53) | def _get_consumer(self):
    method test_walkthrough (line 60) | def test_walkthrough(self):
    method test_user_already_logged_in (line 118) | def test_user_already_logged_in(self):
    method test_user_data_updated (line 132) | def test_user_data_updated(self):
    method test_custom_keygen (line 172) | def test_custom_keygen(self):

FILE: tests/test_migrations.py
  class MigrationTestCase (line 9) | class MigrationTestCase(TestCase):
    method test_for_missing_migrations (line 12) | def test_for_missing_migrations(self):

FILE: tests/utils/context_managers.py
  class NULL (line 4) | class NULL:
  class SettingsOverride (line 8) | class SettingsOverride:
    method __init__ (line 19) | def __init__(self, **overrides):
    method __enter__ (line 22) | def __enter__(self):
    method __exit__ (line 28) | def __exit__(self, type, value, traceback):
  class UserLoginContext (line 36) | class UserLoginContext:
    method __init__ (line 37) | def __init__(self, testcase, user):
    method __enter__ (line 41) | def __enter__(self):
    method __exit__ (line 48) | def __exit__(self, exc, value, tb):
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (51K chars).
[
  {
    "path": ".coveragerc",
    "chars": 298,
    "preview": "[run]\nbranch = True\nsource = simple_sso\nomit =\n    migrations/*\n    tests/*\n\n[report]\nexclude_lines =\n    pragma: no cov"
  },
  {
    "path": ".editorconfig",
    "chars": 426,
    "preview": "# editorconfig.org\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\ntrim_trailing"
  },
  {
    "path": ".gitignore",
    "chars": 175,
    "preview": "*.py[cod]\n*$py.class\n*.egg-info\n*.log\n*.pot\n.DS_Store\n.coverage/\n.eggs/\n.idea/\n.project/\n.pydevproject/\n.vscode/\n.settin"
  },
  {
    "path": "CHANGELOG.rst",
    "chars": 702,
    "preview": "=========\nChangelog\n=========\n\n1.3.0 (2025-05-05)\n==================\n\n* Remove the abandoned dependency `webservices`, c"
  },
  {
    "path": "LICENSE",
    "chars": 1486,
    "preview": "Copyright (c) 2011, Jonas Obrist\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or withou"
  },
  {
    "path": "MANIFEST.in",
    "chars": 64,
    "preview": "include LICENSE\ninclude README.rst\nrecursive-exclude * *.py[co]\n"
  },
  {
    "path": "README.rst",
    "chars": 6805,
    "preview": "=================\ndjango-simple-sso\n=================\n\n|pypi| |build| |coverage|\n\n\nDocumentation\n=============\n\nSee ``RE"
  },
  {
    "path": "setup.py",
    "chars": 1467,
    "preview": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nfrom simple_sso import __version__\n\n\nREQUIREMENTS = ["
  },
  {
    "path": "simple_sso/__init__.py",
    "chars": 22,
    "preview": "__version__ = '1.3.0'\n"
  },
  {
    "path": "simple_sso/exceptions.py",
    "chars": 89,
    "preview": "class WebserviceError(Exception):\n    pass\n\n\nclass BadRequest(WebserviceError):\n    pass\n"
  },
  {
    "path": "simple_sso/models.py",
    "chars": 45,
    "preview": "\"\"\"\nThis is only here so I can run tests\n\"\"\"\n"
  },
  {
    "path": "simple_sso/sso_client/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "simple_sso/sso_client/client.py",
    "chars": 4713,
    "preview": "from copy import copy\nfrom urllib.parse import urlparse, urlunparse, urljoin, urlencode\n\nfrom django.urls import re_path"
  },
  {
    "path": "simple_sso/sso_server/__init__.py",
    "chars": 66,
    "preview": "default_app_config = 'simple_sso.sso_server.apps.SimpleSSOServer'\n"
  },
  {
    "path": "simple_sso/sso_server/apps.py",
    "chars": 105,
    "preview": "from django.apps import AppConfig\n\n\nclass SimpleSSOServer(AppConfig):\n    name = 'simple_sso.sso_server'\n"
  },
  {
    "path": "simple_sso/sso_server/migrations/0001_initial.py",
    "chars": 1833,
    "preview": "from django.db import migrations, models\nfrom django.utils import timezone\nfrom django.conf import settings\nimport simpl"
  },
  {
    "path": "simple_sso/sso_server/migrations/0002_consumer_name_max_length.py",
    "chars": 343,
    "preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('sso_se"
  },
  {
    "path": "simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py",
    "chars": 352,
    "preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('sso_se"
  },
  {
    "path": "simple_sso/sso_server/migrations/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "simple_sso/sso_server/models.py",
    "chars": 2121,
    "preview": "from django.conf import settings\nfrom django.db import models\nfrom django.utils import timezone\nfrom django.utils.decons"
  },
  {
    "path": "simple_sso/sso_server/server.py",
    "chars": 6222,
    "preview": "import datetime\nfrom urllib.parse import urlparse, urlencode, urlunparse\n\nfrom django.contrib import admin\nfrom django.c"
  },
  {
    "path": "simple_sso/utils.py",
    "chars": 5067,
    "preview": "import string\nfrom random import SystemRandom\nfrom urllib.parse import urlparse, urlunparse, urljoin\n\nimport requests\nfr"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/requirements.txt",
    "chars": 20,
    "preview": "tox\ncoverage\nflake8\n"
  },
  {
    "path": "tests/settings.py",
    "chars": 2192,
    "preview": "import os\nimport sys\n\n\nurlpatterns = []\n\nDATABASES = {\n    'default': {\n        'ENGINE': 'django.db.backends.sqlite3',\n"
  },
  {
    "path": "tests/test_core.py",
    "chars": 7471,
    "preview": "from urllib.parse import urlparse\n\nfrom django.conf import settings\nfrom django.contrib.auth import get_user\nfrom django"
  },
  {
    "path": "tests/test_migrations.py",
    "chars": 885,
    "preview": "# original from\n# http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html\nfrom io impor"
  },
  {
    "path": "tests/urls.py",
    "chars": 747,
    "preview": "from django.conf import settings\nfrom django.contrib import admin\nfrom django.contrib.auth.views import LoginView\nfrom d"
  },
  {
    "path": "tests/utils/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/utils/context_managers.py",
    "chars": 1453,
    "preview": "from django.conf import settings\n\n\nclass NULL:\n    pass\n\n\nclass SettingsOverride:\n    \"\"\"\n    Overrides Django settings "
  },
  {
    "path": "tox.ini",
    "chars": 1073,
    "preview": "[tox]\nenvlist =\n    flake8\n    isort\n    py{35,36,37,38}-dj{22}\n    py{36,37,38}-dj{30,31}\n\nskip_missing_interpreters=Tr"
  },
  {
    "path": "travis.yml",
    "chars": 1082,
    "preview": "language: python\n\ndist: xenial\n\nmatrix:\n  include:\n    - python: 3.5\n      env: TOX_ENV='flake8'\n    - python: 3.5\n     "
  }
]

About this extraction

This page contains the full source code of the divio/django-simple-sso GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (46.2 KB), approximately 11.5k tokens, and a symbol index with 100 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!