├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── test-build-publish.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── okta_oauth2 ├── __init__.py ├── apps.py ├── backend.py ├── conf.py ├── decorators.py ├── exceptions.py ├── middleware.py ├── tests │ ├── settings.py │ ├── templates │ │ └── okta_oauth2 │ │ │ └── login.html │ ├── test_backend.py │ ├── test_conf.py │ ├── test_decorator.py │ ├── test_middleware.py │ ├── test_token_validator.py │ ├── test_views.py │ ├── urls.py │ └── utils.py ├── tokens.py ├── urls.py └── views.py ├── pyproject.toml └── pytest.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = okta_oauth2/tests/* 3 | source = okta_oauth2 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 88 3 | -------------------------------------------------------------------------------- /.github/workflows/test-build-publish.yml: -------------------------------------------------------------------------------- 1 | name: Test, Build and Publish (on tags) 2 | 3 | on: [push] 4 | 5 | jobs: 6 | style: 7 | name: Check code style 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.10 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.10" 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install poetry 18 | poetry install 19 | - name: Check styles with pre-commit 20 | run: | 21 | poetry run pre-commit run --all-files 22 | 23 | build: 24 | name: Test and build python distribution 25 | runs-on: "ubuntu-22.04" 26 | strategy: 27 | matrix: 28 | python-version: ["3.8", "3.9", "3.10", "3.11"] 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install poetry 38 | poetry install 39 | - name: Test with pytest 40 | run: | 41 | poetry run pytest 42 | publish: 43 | name: Publish the built distribution 44 | runs-on: ubuntu-latest 45 | if: startsWith(github.event.ref, 'refs/tags') 46 | needs: build 47 | steps: 48 | - uses: actions/checkout@v2 49 | - name: Set up Python 3.10 50 | uses: actions/setup-python@v2 51 | with: 52 | python-version: "3.10" 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install poetry 56 | poetry install 57 | - name: Build the sdist and wheel 58 | run: | 59 | poetry build 60 | - name: Publish distribution 📦 to Test PyPI 61 | uses: pypa/gh-action-pypi-publish@master 62 | with: 63 | user: __token__ 64 | password: ${{ secrets.PYPI_TEST_API_KEY }} 65 | repository_url: https://test.pypi.org/legacy/ 66 | - name: Publish distribution 📦 to PyPI 67 | uses: pypa/gh-action-pypi-publish@master 68 | with: 69 | user: __token__ 70 | password: ${{ secrets.PYPI_API_KEY }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | setup.py 2 | dist 3 | __pycache__ 4 | .pytest_cache 5 | .env 6 | poetry.lock 7 | .python-version 8 | .vscode 9 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | combine_as_imports=True 6 | line_length=88 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 23.1.0 4 | hooks: 5 | - id: black 6 | language_version: python3.10 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.4.0 9 | hooks: 10 | - id: check-added-large-files 11 | args: ["--maxkb=1024"] 12 | - id: check-case-conflict 13 | - id: check-merge-conflict 14 | - id: debug-statements 15 | - id: end-of-file-fixer 16 | - id: mixed-line-ending 17 | args: ["--fix=lf"] 18 | - id: trailing-whitespace 19 | - id: requirements-txt-fixer 20 | exclude: migrations\/[^/]*\.py$|settings\.py$|vendor\/[^/]*\.*$|assets\/[^/]*\.*$ 21 | - repo: https://github.com/pycqa/isort 22 | rev: 5.12.0 23 | hooks: 24 | - id: isort 25 | name: isort (python) 26 | exclude: migrations\/[^/]*\.py$ 27 | - repo: https://github.com/Lucas-C/pre-commit-hooks 28 | rev: v1.4.2 29 | hooks: 30 | - id: forbid-tabs 31 | exclude: Makefile|\.bat$|vendor\/|assets\/ 32 | - id: remove-tabs 33 | - repo: https://github.com/PyCQA/bandit 34 | rev: 1.7.5 35 | hooks: 36 | - id: bandit 37 | args: ["-c", "pyproject.toml"] 38 | additional_dependencies: ["bandit[toml]"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Magin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Okta Auth 2 | 3 | ## Overview 4 | 5 | Django Okta Auth is a library that acts as a client for the Okta OpenID Connect provider. 6 | 7 | The library provides a set of views for login, logout and callback, an auth backend for authentication, a middleware for token verification in requests, and a decorator that can be selectively applied to individual views. 8 | 9 | It's heavily influenced by [okta-django-samples](https://github.com/zeekhoo-okta/okta-django-samples) but there's a few fundamental changes and further implementation of things like refresh tokens which weren't initially implemented. 10 | 11 | This project is in no way affiliated with Okta. 12 | 13 | ## Installation 14 | 15 | Install from PyPI: 16 | 17 | pip install django-okta-auth 18 | 19 | ## Configuration 20 | 21 | ### Install the App 22 | 23 | Add `okta_oauth2.apps.OktaOauth2Config` to `INSTALLED_APPS`: 24 | 25 | ```python 26 | INSTALLED_APPS = ( 27 | "...", 28 | 'okta_oauth2.apps.OktaOauth2Config', 29 | "..." 30 | ) 31 | ``` 32 | 33 | ### Authentication Backend 34 | 35 | You will need to install the authentication backend. This extends Django's default `ModelBackend` which uses the configured database for user storage, but overrides the `authenticate` method to accept the `auth_code` returned by Okta's `/authorize` API endpoint [as documented here](https://developer.okta.com/docs/reference/api/oidc/#authorize). 36 | 37 | The Authentication Backend should be configured as so: 38 | 39 | ```python 40 | AUTHENTICATION_BACKENDS = ("okta_oauth2.backend.OktaBackend",) 41 | ``` 42 | 43 | ### Using the middleware 44 | 45 | You can use the middleware to check for valid tokens during ever refresh and automatically refresh tokens when they expire. By using the middleware you are defaulting to requiring authentication on all your views unless they have been marked as public in `PUBLIC_NAMED_URLS` or `PUBLIC_URLS`. 46 | 47 | The order of middleware is important and the `OktaMiddleware` must be below the `SessionMiddleware` and `AuthenticationMiddleware` to ensure that the session and the user are both on the request: 48 | 49 | ```python 50 | MIDDLEWARE = ( 51 | 'django.middleware.security.SecurityMiddleware', 52 | 'django.contrib.sessions.middleware.SessionMiddleware', 53 | 'django.middleware.common.CommonMiddleware', 54 | 'django.middleware.csrf.CsrfViewMiddleware', 55 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 56 | 'django.contrib.messages.middleware.MessageMiddleware', 57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 58 | 'okta_oauth2.middleware.OktaMiddleware' 59 | ) 60 | ``` 61 | 62 | ### Using the decorator 63 | 64 | The alternative to using the middleware is to selectively apply the `okta_oauth2.decorators.okta_login_required` decorator to views you wish to protect. When the view is accessed the decorator will check that valid tokens exist on the session, and if they don't then it will redirect to the login. 65 | 66 | The decorator is applied to a view like so: 67 | 68 | ```python 69 | from okta_oauth2.decorators import okta_login_required 70 | 71 | @okta_login_required 72 | def decorated_view(request): 73 | return HttpResponse("i am a protected view") 74 | ``` 75 | 76 | ### Update urls.py 77 | 78 | Add the `django-okta-auth` views to your `urls.py`. This will provide the `login`, `logout` and `callback` views which are required by the login flows. 79 | 80 | ```python 81 | from django.urls import include, path 82 | 83 | urlpatterns = [ 84 | path('accounts/', include(("okta_oauth2.urls", "okta_oauth2"), namespace="okta_oauth2")), 85 | ] 86 | ``` 87 | 88 | ### Setup your Okta Application 89 | 90 | In the Okta admin console create your application with the following steps: 91 | 92 | 1. Click `Create New Create App Integration` 93 | 2. Choose the `OIDC - OpenID Connect` Sign on method 94 | 3. Choose the `Web Application` type 95 | 4. Click the `Next` button 96 | 5. Give the application a name and choose a logo if desired 97 | 6. Add the URL to the login view as defined in the previous section, eg. `http://localhost:8000/accounts/login/` 98 | 7. Select your preferred Controlled access type 99 | 8. Click the `Save` button 100 | 9. In the General Settings of the application click edit and check `Authorization Code` and the `Refresh Token` under `Grant type`. 101 | 10. Save the settings 102 | 11. Take note of the `Client ID` and the `Client secret` in the Client Credentials for use in the next section. It is important to note that the `Client secret` is confidential and under no circumstances should be exposed publicly. 103 | 104 | ### Django Okta Settings 105 | 106 | Django Okta Auth settings should be specified in your django `settings.py` as follows: 107 | 108 | ```python 109 | OKTA_AUTH = { 110 | "ORG_URL": "https://your-org.okta.com/", 111 | "ISSUER": "https://your-org.okta.com/oauth2/default", 112 | "CLIENT_ID": "yourclientid", 113 | "CLIENT_SECRET": "yourclientsecret", 114 | "SCOPES": "openid profile email offline_access", # this is the default and can be omitted 115 | "REDIRECT_URI": "http://localhost:8000/accounts/oauth2/callback", 116 | "LOGIN_REDIRECT_URL": "/", # default 117 | "CACHE_PREFIX": "okta", # default 118 | "CACHE_ALIAS": "default", # default 119 | "PUBLIC_NAMED_URLS": (), # default 120 | "PUBLIC_URLS": (), # default 121 | "USE_USERNAME": False, # default 122 | } 123 | ``` 124 | 125 | ### Login Template 126 | 127 | The login view will render the `okta_oauth2/login.html` template. It will be passed the following information in the `config` template context variable: 128 | 129 | ```python 130 | { 131 | "clientId": settings.OKTA_AUTH["CLIENT_ID"], 132 | "url": settings.OKTA_AUTH["ORG_URL"], 133 | "redirectUri": settings.OKTA_AUTH["REDIRECT_URI"], 134 | "scope": settings.OKTA_AUTH["SCOPES"], 135 | "issuer": settings.OKTA_AUTH["ISSUER"] 136 | } 137 | ``` 138 | 139 | The easiest way to use this is to implement the [Okta Sign-In Widget](https://developer.okta.com/code/javascript/okta_sign-in_widget/) in your template. 140 | 141 | A minimal template for the login could be: 142 | 143 | ```html 144 | 145 | 146 |
147 | 148 | 149 | 153 | 158 | 159 | 160 | 161 | 162 | 180 | 181 | 182 | ``` 183 | 184 | If you use this template, then you also need to add your server as a Trusted Origin in the Okta admin console. Navigate to `Security/API/Trusted Origins`, click `Add origin` and select at least `CORS` and `Redirect`. 185 | 186 | ## Settings Reference 187 | 188 | **_ORG_URL_**: 189 | 190 | _str_. URL Okta provides for your organization account. This is the URL that you log in to for the admin panel, minus the `-admin`. eg, if your admin URL is https://myorg-admin.okta.com/ then your `ORG_URL` should be: https://myorg.okta.com/ 191 | 192 | **_ISSUER_** 193 | 194 | _str_. This is the URL for your Authorization Server. If you're using the default authorization server then this will be: `https://{ORG_URL}/oauth2/default` 195 | 196 | **_CLIENT_ID_** 197 | 198 | _str_. The Client ID provided by your Okta Application. 199 | 200 | **_CLIENT_SECRET_** 201 | 202 | _str_. The Client Secret provided by your Okta Application. 203 | 204 | **_SCOPES_** 205 | 206 | _str_. The scopes requested from the OpenID Authorization server. At the very least this needs to be `"openid profile email"` but if you want to use refresh tokens you will need `"openid profile email offline_access"`. This is the default. 207 | 208 | If you want Okta to manage your groups then you should also include `groups` in your scopes. In that case, make sure your authorization server has the `groups` scope enabled. You can do so by navigating to `Security/API/Authorization Servers`, editing the default server, and adding the `groups` scope. 209 | 210 | **_REDIRECT_URI_** 211 | 212 | _str_. This is the URL to the `callback` view that the okta Sign-In Widget will redirect the browser to after the username and password have been authorized. If the directions in the `urls.py` section of the documentation were followed and your django server is running on `localhost:8000` then this will be: http://localhost:8000/accounts/callback/ 213 | 214 | **_LOGIN_REDIRECT_URL_** 215 | 216 | _str_. This is the URL to redirect to from the `callback` after a successful login. Defaults to `/`. 217 | 218 | **_CACHE_PREFIX_** 219 | 220 | _str_. The application will utilise the django cache to store public keys requested from Okta in an effort to minimise network round-trips and speed up authorization. This setting will control the prefix for the cache keys. Defaults to `okta`. 221 | 222 | **_CACHE_ALIAS_** 223 | 224 | _str_. Specify which django cache should be utilised for storing public keys. Defaults to `default`. 225 | 226 | **_PUBLIC_NAMED_URLS_** 227 | 228 | _List[str]_. A list or tuple of URL names that should be accessible without tokens. If you add a URL in this setting the middleware won't check for tokens. Default is: `[]` 229 | 230 | **_PUBLIC_URLS_** 231 | 232 | _List[str]_. A list or tuple of URL regular expressions that should be accessible without tokens. If you add a regex in this setting the middleware won't check matching paths for tokens. Default is `[]`. 233 | 234 | **_SUPERUSER_GROUP_** 235 | 236 | _str_. Members of this group will have the django `is_superuser` user flags set. If this is unset or set to None a user's superuser flag will not be managed. Default is `None`. 237 | 238 | **_STAFF_GROUP_** 239 | 240 | _str_. Members of this group will have the django `is_staff` user flags set. If this is unset or set to None a user's staff flag will not be managed. Default is `None`. 241 | 242 | **_MANAGE_GROUPS_** 243 | 244 | _bool_. If true the authentication backend will manage django groups for you. 245 | 246 | ***USE_USERNAME*** 247 | 248 | *bool*. If true the authentication backend will lookup django users by username rather than email. 249 | 250 | ## License 251 | 252 | MIT License 253 | 254 | Copyright (c) 2020 Matt Magin 255 | 256 | Permission is hereby granted, free of charge, to any person obtaining a copy 257 | of this software and associated documentation files (the "Software"), to deal 258 | in the Software without restriction, including without limitation the rights 259 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 260 | copies of the Software, and to permit persons to whom the Software is 261 | furnished to do so, subject to the following conditions: 262 | 263 | The above copyright notice and this permission notice shall be included in all 264 | copies or substantial portions of the Software. 265 | 266 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 267 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 268 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 269 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 270 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 271 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 272 | SOFTWARE. 273 | -------------------------------------------------------------------------------- /okta_oauth2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzMoo/django-okta-auth/f5d3801c68a3a75a12cb48af2b7391024df50921/okta_oauth2/__init__.py -------------------------------------------------------------------------------- /okta_oauth2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OktaOauth2Config(AppConfig): 5 | name = "okta_oauth2" 6 | -------------------------------------------------------------------------------- /okta_oauth2/backend.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | 3 | from .conf import Config 4 | from .tokens import TokenValidator 5 | 6 | config = Config() 7 | 8 | 9 | class OktaBackend(ModelBackend): 10 | """ 11 | Uses the same user store as the django ModelBackend but actually 12 | does its authentication using Okta's OIDC authorization servers. 13 | 14 | The Okta sign in widget will accept a username and password, 15 | validate them, and if successful return an authorization code. 16 | 17 | We take that code and use it to obtain an Access Token, an 18 | ID Token and a Refresh Token from Okta, set them in the session, 19 | and get the user from the Django database. 20 | """ 21 | 22 | def authenticate(self, request, auth_code=None, nonce=None): 23 | if auth_code is None or nonce is None: 24 | return 25 | 26 | validator = TokenValidator(config, nonce, request) 27 | user, tokens = validator.tokens_from_auth_code(auth_code) 28 | 29 | if self.user_can_authenticate(user): 30 | return user 31 | -------------------------------------------------------------------------------- /okta_oauth2/conf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.urls import NoReverseMatch, reverse 6 | from jwt.algorithms import get_default_algorithms 7 | 8 | # We can't check for tokens on these URL's 9 | # because we won't have them. 10 | DEFAULT_PUBLIC_NAMED_URLS = ( 11 | "okta_oauth2:login", 12 | "okta_oauth2:logout", 13 | "okta_oauth2:callback", 14 | ) 15 | 16 | 17 | class Config: 18 | def __init__(self): 19 | try: 20 | # Configuration object 21 | self.org_url = settings.OKTA_AUTH["ORG_URL"] 22 | # Make users in this okta group superusers 23 | self.superuser_group = settings.OKTA_AUTH.get("SUPERUSER_GROUP", None) 24 | # Make users in this okta group staff 25 | self.staff_group = settings.OKTA_AUTH.get("STAFF_GROUP", None) 26 | # Allow django-okta-auth to add groups 27 | self.manage_groups = settings.OKTA_AUTH.get("MANAGE_GROUPS", False) 28 | # Timeout in seconds for requests to Okta 29 | self.request_timeout = settings.OKTA_AUTH.get("REQUEST_TIMEOUT", 10) 30 | 31 | # OpenID Specific 32 | self.client_id = settings.OKTA_AUTH["CLIENT_ID"] 33 | self.client_secret = settings.OKTA_AUTH["CLIENT_SECRET"] 34 | self.issuer = settings.OKTA_AUTH["ISSUER"] 35 | self.scopes = settings.OKTA_AUTH.get( 36 | "SCOPES", "openid profile email offline_access" 37 | ) 38 | self.redirect_uri = settings.OKTA_AUTH["REDIRECT_URI"] 39 | self.login_redirect_url = settings.OKTA_AUTH.get("LOGIN_REDIRECT_URL", "/") 40 | 41 | # Django Specific 42 | self.cache_prefix = settings.OKTA_AUTH.get("CACHE_PREFIX", "okta") 43 | self.cache_alias = settings.OKTA_AUTH.get("CACHE_ALIAS", "default") 44 | self.cache_timeout = settings.OKTA_AUTH.get("CACHE_TIMEOUT", 600) 45 | self.use_username = settings.OKTA_AUTH.get("USE_USERNAME", False) 46 | self.public_urls = self.build_public_urls() 47 | except (AttributeError, KeyError): 48 | raise ImproperlyConfigured("Missing Okta authentication settings") 49 | 50 | def build_public_urls(self): 51 | named_urls = [] 52 | 53 | # Get any user-specified named urls and concat the default named urls 54 | # so that we can reverse them all at once. 55 | public_named_urls = ( 56 | settings.OKTA_AUTH.get("PUBLIC_NAMED_URLS", ()) + DEFAULT_PUBLIC_NAMED_URLS 57 | ) 58 | 59 | for name in public_named_urls: 60 | try: 61 | named_urls.append(reverse(name)) 62 | except NoReverseMatch: 63 | pass 64 | 65 | # Concatenate user-specified regex URL's with a tuple of reversed named 66 | # url's that have been converted to a regex, so we can use a regex 67 | # to match against a url in every case. 68 | public_urls = tuple(settings.OKTA_AUTH.get("PUBLIC_URLS", ())) + tuple( 69 | ["^%s$" % url for url in named_urls] 70 | ) 71 | 72 | return [re.compile(u) for u in public_urls] 73 | -------------------------------------------------------------------------------- /okta_oauth2/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from .conf import Config 4 | from .tokens import validate_or_redirect 5 | 6 | 7 | def okta_login_required(view_func): 8 | @wraps(view_func) 9 | def _wrapped_view(request, *args, **kwargs): 10 | config = Config() 11 | response = validate_or_redirect(config, request) 12 | if response: 13 | return response 14 | else: 15 | return view_func(request, *args, **kwargs) 16 | 17 | return _wrapped_view 18 | -------------------------------------------------------------------------------- /okta_oauth2/exceptions.py: -------------------------------------------------------------------------------- 1 | class DjangoOktaAuthException(Exception): 2 | pass 3 | 4 | 5 | class InvalidToken(DjangoOktaAuthException): 6 | """Base exception for an invalid token""" 7 | 8 | def __init__(self, message=None): 9 | if message: 10 | self.message = message 11 | 12 | pass 13 | 14 | 15 | class InvalidTokenSignature(InvalidToken): 16 | """Token signatures doesn't validate""" 17 | 18 | pass 19 | 20 | 21 | class IssuerDoesNotMatch(InvalidToken): 22 | """Token Issuer doesn't match expected issuer""" 23 | 24 | pass 25 | 26 | 27 | class InvalidClientID(InvalidToken): 28 | """Token ClientID doesn't match expected Client ID""" 29 | 30 | pass 31 | 32 | 33 | class TokenExpired(InvalidToken): 34 | """Token expiration time is in the past""" 35 | 36 | pass 37 | 38 | 39 | class TokenTooFarAway(InvalidToken): 40 | """The received token is not valid until too far in the future.""" 41 | 42 | pass 43 | 44 | 45 | class NonceDoesNotMatch(InvalidToken): 46 | """Token nonce does not match expected nonce""" 47 | 48 | pass 49 | 50 | 51 | class TokenRequestFailed(DjangoOktaAuthException): 52 | """The request to the token api endpoint has failed.""" 53 | 54 | pass 55 | 56 | 57 | class MissingAuthTokens(DjangoOktaAuthException): 58 | pass 59 | -------------------------------------------------------------------------------- /okta_oauth2/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .conf import Config 4 | from .tokens import validate_or_redirect 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class OktaMiddleware: 10 | """ 11 | Middleware to validate JWT tokens set by Okta for authentication. 12 | """ 13 | 14 | def __init__(self, get_response): 15 | self.config = Config() 16 | self.get_response = get_response 17 | 18 | def __call__(self, request): 19 | logger.debug("Entering Okta Middleware") 20 | 21 | if self.is_public_url(request.path_info): 22 | # We don't need tokens for public url's so just do nothing 23 | return self.get_response(request) 24 | 25 | redirect_response = validate_or_redirect(self.config, request) 26 | 27 | if redirect_response: 28 | return redirect_response 29 | 30 | return self.get_response(request) 31 | 32 | def is_public_url(self, url): 33 | return any(public_url.match(url) for public_url in self.config.public_urls) 34 | -------------------------------------------------------------------------------- /okta_oauth2/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SECRET_KEY = "imasecretlol" 4 | 5 | DATABASES = {"default": {"NAME": "test.db", "ENGINE": "django.db.backends.sqlite3"}} 6 | 7 | INSTALLED_APPS = ( 8 | "django.contrib.admin", 9 | "django.contrib.auth", 10 | "django.contrib.contenttypes", 11 | "django.contrib.sessions", 12 | "django.contrib.sites", 13 | "okta_oauth2.apps.OktaOauth2Config", 14 | ) 15 | 16 | OKTA_AUTH = { 17 | "ORG_URL": "https://test.okta.notreal/", 18 | "ISSUER": "https://test.okta.notreal/oauth2/default", 19 | "CLIENT_ID": "not-a-real-id", 20 | "CLIENT_SECRET": "not-a-real-secret", 21 | "REDIRECT_URI": "http://localhost:8000/accounts/callback/", 22 | } 23 | 24 | ROOT_URLCONF = "okta_oauth2.tests.urls" 25 | 26 | AUTHENTICATION_BACKENDS = ("okta_oauth2.backend.OktaBackend",) 27 | 28 | MIDDLEWARE = [ 29 | "django.middleware.security.SecurityMiddleware", 30 | "django.contrib.sessions.middleware.SessionMiddleware", 31 | "django.middleware.common.CommonMiddleware", 32 | "django.middleware.csrf.CsrfViewMiddleware", 33 | "django.contrib.auth.middleware.AuthenticationMiddleware", 34 | "django.contrib.messages.middleware.MessageMiddleware", 35 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 36 | ] 37 | 38 | 39 | TEMPLATES = [ 40 | { 41 | "BACKEND": "django.template.backends.django.DjangoTemplates", 42 | "APP_DIRS": True, 43 | "DIRS": [ 44 | os.path.join(os.path.dirname(__file__), "templates"), 45 | ], 46 | "OPTIONS": { 47 | "context_processors": [ 48 | # Django builtin 49 | "django.template.context_processors.debug", 50 | "django.template.context_processors.media", 51 | "django.template.context_processors.request", 52 | "django.contrib.auth.context_processors.auth", 53 | "django.contrib.messages.context_processors.messages", 54 | ] 55 | }, 56 | }, 57 | ] 58 | 59 | USE_TZ = True 60 | -------------------------------------------------------------------------------- /okta_oauth2/tests/templates/okta_oauth2/login.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzMoo/django-okta-auth/f5d3801c68a3a75a12cb48af2b7391024df50921/okta_oauth2/tests/templates/okta_oauth2/login.html -------------------------------------------------------------------------------- /okta_oauth2/tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from okta_oauth2.backend import OktaBackend 4 | 5 | 6 | def test_backend_authenticate_requires_code_and_nonce(rf): 7 | """ 8 | the authenticate method on the custom backend requires both 9 | an auth code and a nonce. If either aren't provided then 10 | authenitcate should return None 11 | """ 12 | backend = OktaBackend() 13 | assert backend.authenticate(rf) is None 14 | 15 | 16 | def test_authenticate_returns_a_user(rf, django_user_model): 17 | """ 18 | We can't do the real authentication but we do need to make sure a 19 | real user is returned from the backend authenticate method if the 20 | TokenValidator succeeds, so fake success and see what happens. 21 | """ 22 | user = django_user_model.objects.create_user("testuser", "testuser@example.com") 23 | 24 | with patch( 25 | "okta_oauth2.backend.TokenValidator.tokens_from_auth_code", 26 | Mock(return_value=(user, None)), 27 | ): 28 | backend = OktaBackend() 29 | assert backend.authenticate(rf, auth_code="123456", nonce="imanonce") == user 30 | -------------------------------------------------------------------------------- /okta_oauth2/tests/test_conf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from okta_oauth2.conf import Config 7 | from okta_oauth2.tests.utils import update_okta_settings 8 | 9 | 10 | def test_conf_raises_error_if_no_settings(settings): 11 | """ 12 | if there's no OKTA_AUTH in settings then we should 13 | be raising an ImproperlyConfigured exception. 14 | """ 15 | del settings.OKTA_AUTH 16 | with pytest.raises(ImproperlyConfigured): 17 | Config() 18 | 19 | 20 | def test_public_named_urls_are_built(settings): 21 | """ 22 | We should have reversed url regexes to match against 23 | in our config objects. 24 | """ 25 | settings.OKTA_AUTH = update_okta_settings( 26 | settings.OKTA_AUTH, "PUBLIC_NAMED_URLS", ("named-url",) 27 | ) 28 | config = Config() 29 | assert config.public_urls == [ 30 | re.compile("^/named/$"), 31 | re.compile("^/accounts/login/$"), 32 | re.compile("^/accounts/logout/$"), 33 | re.compile("^/accounts/oauth2/callback/$"), 34 | ] 35 | 36 | 37 | def test_invalid_public_named_urls_are_ignored(settings): 38 | """ 39 | We don't want to crash if our public named urls don't 40 | exist, instead just skip it. 41 | """ 42 | settings.OKTA_AUTH = update_okta_settings( 43 | settings.OKTA_AUTH, "PUBLIC_NAMED_URLS", ("not-a-valid-url",) 44 | ) 45 | config = Config() 46 | assert config.public_urls == [ 47 | re.compile("^/accounts/login/$"), 48 | re.compile("^/accounts/logout/$"), 49 | re.compile("^/accounts/oauth2/callback/$"), 50 | ] 51 | -------------------------------------------------------------------------------- /okta_oauth2/tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from django.test import Client 5 | 6 | from okta_oauth2.tests.utils import TEST_PUBLIC_KEY, build_id_token 7 | 8 | 9 | def test_decorator_prevents_unauthenticated_access(client: Client): 10 | """If we're not authenticated we should return a redirect to the login""" 11 | response = client.get("/decorated/") 12 | assert response.status_code == 302 13 | assert response.url == "/accounts/login/" 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_decorator_allows_access_to_valid_token(client: Client): 18 | """ 19 | If we have a valid token then we should allow access to the view. 20 | """ 21 | nonce = "123456" 22 | token = build_id_token(nonce=nonce) 23 | 24 | client.cookies.load({"okta-oauth-nonce": nonce}) 25 | 26 | session = client.session 27 | session["tokens"] = {"id_token": token} 28 | session.save() 29 | 30 | with patch( 31 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 32 | ): 33 | response = client.get("/decorated/") 34 | assert response.status_code == 200 35 | 36 | 37 | @pytest.mark.django_db 38 | def test_decorator_disallows_access_to_invalid_token(client: Client): 39 | """When an invalid token is supplied the decorator should reject.""" 40 | nonce = "123456" 41 | token = "notvalid" 42 | 43 | client.cookies.load({"okta-oauth-nonce": nonce}) 44 | 45 | session = client.session 46 | session["tokens"] = {"id_token": token} 47 | session.save() 48 | 49 | with patch("okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value="secret")): 50 | response = client.get("/decorated/") 51 | assert response.status_code == 302 52 | assert response.url == "/accounts/login/" 53 | -------------------------------------------------------------------------------- /okta_oauth2/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from django.http import HttpResponse 4 | from django.urls import reverse 5 | 6 | from okta_oauth2.exceptions import TokenExpired 7 | from okta_oauth2.middleware import OktaMiddleware 8 | from okta_oauth2.tests.utils import ( 9 | TEST_PUBLIC_KEY, 10 | build_id_token, 11 | update_okta_settings, 12 | ) 13 | 14 | 15 | def test_no_token_redirects_to_login(rf): 16 | """ 17 | If there's no token in the session then we should be 18 | redirecting to the login. 19 | """ 20 | request = rf.get("/") 21 | request.session = {} 22 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 23 | response = mw(request) 24 | assert response.status_code == 302 25 | assert response.url == reverse("okta_oauth2:login") 26 | 27 | 28 | def test_invalid_token_redirects_to_login(rf): 29 | """ 30 | It there's a token but it's invalid we should be 31 | redirecting to the login. 32 | """ 33 | request = rf.get("/") 34 | request.COOKIES["okta-oauth-nonce"] = "123456" 35 | request.session = {"tokens": {}} 36 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 37 | response = mw(request) 38 | assert response.status_code == 302 39 | assert response.url == reverse("okta_oauth2:login") 40 | 41 | 42 | def test_valid_token_returns_response(rf): 43 | """ 44 | If we have a valid token we should be returning the normal 45 | response from the middleware. 46 | """ 47 | 48 | nonce = "123456" 49 | # We're building a token here that we know will be valid 50 | token = build_id_token(nonce=nonce) 51 | 52 | with patch( 53 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 54 | ): 55 | request = rf.get("/") 56 | request.COOKIES["okta-oauth-nonce"] = nonce 57 | request.session = {"tokens": {"id_token": token}} 58 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 59 | response = mw(request) 60 | assert response.status_code == 200 61 | 62 | 63 | def test_token_expired_triggers_refresh(rf): 64 | """ 65 | Test that an expired token triggers 66 | an attempt at refreshing the token. 67 | """ 68 | raises_token_expired = Mock() 69 | raises_token_expired.side_effect = TokenExpired 70 | 71 | with patch( 72 | "okta_oauth2.tokens.TokenValidator.validate_token", raises_token_expired 73 | ), patch("okta_oauth2.tokens.TokenValidator.tokens_from_refresh_token"): 74 | request = rf.get("/") 75 | request.COOKIES["okta-oauth-nonce"] = "123456" 76 | request.session = { 77 | "tokens": { 78 | "id_token": "imanexpiredtoken", 79 | "refresh_token": "imsorefreshing", 80 | } 81 | } 82 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 83 | response = mw(request) 84 | assert response.status_code == 200 85 | 86 | 87 | def test_token_expired_triggers_refresh_with_no_refresh(rf): 88 | """ 89 | Test that an expired token triggers 90 | an attempt at refreshing the token. In this situation we 91 | don't have a refresh token so we should be redirecting back 92 | to login. 93 | """ 94 | raises_token_expired = Mock() 95 | raises_token_expired.side_effect = TokenExpired 96 | 97 | with patch( 98 | "okta_oauth2.tokens.TokenValidator.validate_token", raises_token_expired 99 | ), patch("okta_oauth2.tokens.TokenValidator.tokens_from_refresh_token"): 100 | request = rf.get("/") 101 | request.COOKIES["okta-oauth-nonce"] = "123456" 102 | request.session = {"tokens": {"id_token": "imanexpiredtoken"}} 103 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 104 | response = mw(request) 105 | assert response.status_code == 302 106 | assert response.url == reverse("okta_oauth2:login") 107 | 108 | 109 | def test_middleware_allows_public_url(settings, rf): 110 | """ 111 | A URL that has been defined as a public url 112 | should just pass through our middleware. 113 | """ 114 | settings.OKTA_AUTH = update_okta_settings( 115 | settings.OKTA_AUTH, "PUBLIC_NAMED_URLS", ("named-url",) 116 | ) 117 | request = rf.get("/named/") 118 | request.session = {} 119 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 120 | response = mw(request) 121 | assert response.status_code == 200 122 | 123 | 124 | def test_unauthorized_post_returns_401(settings, rf): 125 | """ 126 | redirecting a POST is bad form so just return 127 | a 401 Unauthorized response if no token is there. 128 | """ 129 | request = rf.post("/named/") 130 | request.session = {} 131 | mw = OktaMiddleware(Mock(return_value=HttpResponse())) 132 | response = mw(request) 133 | assert response.status_code == 401 134 | -------------------------------------------------------------------------------- /okta_oauth2/tests/test_token_validator.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock, patch 2 | 3 | import pytest 4 | from django.contrib.auth.models import Group 5 | from django.contrib.sessions.middleware import SessionMiddleware 6 | from django.core.cache import caches 7 | from django.utils.timezone import now 8 | from rsa import newkeys 9 | 10 | from okta_oauth2.conf import Config 11 | from okta_oauth2.exceptions import ( 12 | InvalidClientID, 13 | InvalidTokenSignature, 14 | IssuerDoesNotMatch, 15 | NonceDoesNotMatch, 16 | TokenExpired, 17 | TokenRequestFailed, 18 | TokenTooFarAway, 19 | ) 20 | from okta_oauth2.tests.utils import ( 21 | TEST_PUBLIC_KEY, 22 | build_access_token, 23 | build_id_token, 24 | update_okta_settings, 25 | ) 26 | from okta_oauth2.tokens import DiscoveryDocument, TokenValidator 27 | 28 | SUPERUSER_GROUP = "Superusers" 29 | STAFF_GROUP = "Staff" 30 | 31 | KEY_1 = { 32 | "alg": "HS256", 33 | "e": "AQAB", 34 | "n": """iKqiD4cr7FZKm6f05K4r-GQOvjRqjOeFmOho9V7SAXYwCyJluaGBLVvDWO1XlduPLOrsG_Wgs67SOG5qeLPR8T1zDK4bfJAo1Tvbw 35 | YeTwVSfd_0mzRq8WaVc_2JtEK7J-4Z0MdVm_dJmcMHVfDziCRohSZthN__WM2NwGnbewWnla0wpEsU3QMZ05_OxvbBdQZaDUsNSx4 36 | 6is29eCdYwhkAfFd_cFRq3DixLEYUsRwmOqwABwwDjBTNvgZOomrtD8BRFWSTlwsbrNZtJMYU33wuLO9ynFkZnY6qRKVHr3YToIrq 37 | NBXw0RWCheTouQ-snfAB6wcE2WDN3N5z760ejqQ""", 38 | "kid": "U5R8cHbGw445Qbq8zVO1PcCpXL8yG6IcovVa3laCoxM", 39 | "kty": "RSA", 40 | "use": "sig", 41 | } 42 | 43 | KEY_2 = { 44 | "alg": "HS256", 45 | "e": "AQAB", 46 | "n": """l1hZ_g2sgBE3oHvu34T-5XP18FYJWgtul_nRNg-5xra5ySkaXEOJUDRERUG0HrR42uqf9jYrUTwg9fp-SqqNIdHRaN8EwRSDRsKAwK 47 | 3HIJ2NJfgmrrO2ABkeyUq6rzHxAumiKv1iLFpSawSIiTEBJERtUCDcjbbqyHVFuivIFgH8L37-XDIDb0XG-R8DOoOHLJPTpsgH-rJe 48 | M5w96VIRZInsGC5OGWkFdtgk6OkbvVd7_TXcxLCpWeg1vlbmX-0TmG5yjSj7ek05txcpxIqYu-7FIGT0KKvXge_BOSEUlJpBhLKU28 49 | OtsOnmc3NLIGXB-GeDiUZiBYQdPR-myB4ZoQ""", 50 | "kid": "Y3vBOdYT-l-I0j-gRQ26XjutSX00TeWiSguuDhW3ngo", 51 | "kty": "RSA", 52 | "use": "sig", 53 | } 54 | 55 | 56 | def mock_request_userinfo(self): 57 | return {"email": "email@email.com", "groups": [SUPERUSER_GROUP, STAFF_GROUP]} 58 | 59 | 60 | def mock_request_jwks(self): 61 | return {"keys": [KEY_1, KEY_2]} 62 | 63 | 64 | def get_token_result(self, code): 65 | return { 66 | "access_token": build_access_token(), 67 | "id_token": build_id_token(), 68 | "refresh_token": "refresh", 69 | } 70 | 71 | 72 | def get_superuser_token_result(self, code): 73 | return { 74 | "access_token": build_access_token(), 75 | "id_token": build_id_token(groups=[SUPERUSER_GROUP]), 76 | "refresh_token": "refresh", 77 | } 78 | 79 | 80 | def get_staff_token_result(self, code): 81 | return { 82 | "access_token": build_access_token(), 83 | "id_token": build_id_token(groups=[STAFF_GROUP]), 84 | "refresh_token": "refresh", 85 | } 86 | 87 | 88 | def get_normal_user_with_groups_token(self, code): 89 | return { 90 | "access_token": build_access_token(), 91 | "id_token": build_id_token(groups=["one", "two"]), 92 | "refresh_token": "refresh", 93 | } 94 | 95 | 96 | def add_session(req): 97 | mw = SessionMiddleware("response") 98 | mw.process_request(req) 99 | req.session.save() 100 | 101 | 102 | @patch("okta_oauth2.tokens.requests.get") 103 | def test_discovery_document_sets_json(mock_get): 104 | mock_get.return_value = Mock(ok=True) 105 | mock_get.return_value.json.return_value = {"key": "value"} 106 | 107 | d = DiscoveryDocument(Config()) 108 | assert d.getJson() == {"key": "value"} 109 | 110 | 111 | def test_token_validator_gets_token_from_auth_code(rf, django_user_model): 112 | """ 113 | We should get our tokens back with a user. 114 | """ 115 | c = Config() 116 | req = rf.get("/") 117 | add_session(req) 118 | 119 | with patch( 120 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", get_token_result 121 | ), patch( 122 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 123 | ): 124 | tv = TokenValidator(c, "defaultnonce", req) 125 | user, tokens = tv.tokens_from_auth_code("authcode") 126 | assert "access_token" in tokens 127 | assert "id_token" in tokens 128 | assert isinstance(user, django_user_model) 129 | 130 | 131 | def test_token_validator_gets_token_from_refresh_token(rf, django_user_model): 132 | """ 133 | We should get our tokens back with a user. 134 | """ 135 | c = Config() 136 | req = rf.get("/") 137 | add_session(req) 138 | 139 | with patch( 140 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", get_token_result 141 | ), patch( 142 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 143 | ): 144 | tv = TokenValidator(c, "defaultnonce", req) 145 | user, tokens = tv.tokens_from_refresh_token("refresh") 146 | assert "access_token" in tokens 147 | assert "id_token" in tokens 148 | assert isinstance(user, django_user_model) 149 | 150 | 151 | def test_handle_token_result_handles_missing_tokens(rf): 152 | """ 153 | If we didn't get any tokens back, don't return a user 154 | and return the empty token dict so we can check why later. 155 | """ 156 | c = Config() 157 | req = rf.get("/") 158 | 159 | tv = TokenValidator(c, "defaultnonce", req) 160 | result = tv.handle_token_result({}) 161 | assert result == (None, {}) 162 | 163 | 164 | @pytest.mark.django_db 165 | def test_created_user_if_part_of_superuser_group(rf, settings, django_user_model): 166 | """ 167 | If the user is part of the superuser group defined 168 | in settings make sure that the created user is a superuser. 169 | """ 170 | settings.OKTA_AUTH = update_okta_settings( 171 | settings.OKTA_AUTH, "SUPERUSER_GROUP", SUPERUSER_GROUP 172 | ) 173 | 174 | c = Config() 175 | req = rf.get("/") 176 | add_session(req) 177 | 178 | with patch( 179 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 180 | get_superuser_token_result, 181 | ), patch( 182 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 183 | ): 184 | tv = TokenValidator(c, "defaultnonce", req) 185 | user, tokens = tv.tokens_from_refresh_token("refresh") 186 | assert isinstance(user, django_user_model) 187 | assert user.is_superuser 188 | 189 | 190 | @pytest.mark.django_db 191 | def test_superuser_not_removed_if_no_superuser_group(rf, settings, django_user_model): 192 | """ 193 | If there is no superuser group set, ensure an existing 194 | superuser does not have their superuser or staff status revoked. 195 | """ 196 | settings.OKTA_AUTH = update_okta_settings( 197 | settings.OKTA_AUTH, "SUPERUSER_GROUP", None 198 | ) 199 | 200 | c = Config() 201 | req = rf.get("/") 202 | add_session(req) 203 | 204 | django_user_model.objects.create_superuser("fakemail@notreal.com") 205 | 206 | with patch( 207 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 208 | get_token_result, 209 | ), patch( 210 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 211 | ): 212 | tv = TokenValidator(c, "defaultnonce", req) 213 | user, tokens = tv.tokens_from_refresh_token("refresh") 214 | assert isinstance(user, django_user_model) 215 | assert user.is_superuser == True 216 | assert user.is_staff == True 217 | 218 | 219 | @pytest.mark.django_db 220 | def test_staff_not_removed_if_no_staff_group(rf, settings, django_user_model): 221 | """ 222 | If there is no staff group set, ensure an existing 223 | staff user does not have their staff status revoked. 224 | """ 225 | settings.OKTA_AUTH = update_okta_settings(settings.OKTA_AUTH, "STAFF_GROUP", None) 226 | 227 | c = Config() 228 | req = rf.get("/") 229 | add_session(req) 230 | 231 | django_user_model.objects.create_user("fakemail@notreal.com", is_staff=True) 232 | 233 | with patch( 234 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 235 | get_token_result, 236 | ), patch( 237 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 238 | ): 239 | tv = TokenValidator(c, "defaultnonce", req) 240 | user, tokens = tv.tokens_from_refresh_token("refresh") 241 | assert isinstance(user, django_user_model) 242 | assert user.is_staff == True 243 | 244 | 245 | @patch("okta_oauth2.tokens.requests.post") 246 | def test_call_token_endpoint_returns_tokens(mock_post, rf): 247 | """ 248 | when we call the token endpoint with valid data we expect 249 | to receive a bunch of tokens. See assertions to understand which. 250 | """ 251 | mock_post.return_value = Mock(ok=True) 252 | mock_post.return_value.json.return_value = { 253 | "access_token": build_access_token(), 254 | "id_token": build_id_token(), 255 | "refresh_token": "refresh", 256 | } 257 | endpoint_data = {"grant_type": "authorization_code", "code": "imacode"} 258 | 259 | c = Config() 260 | MockDiscoveryDocument = MagicMock() 261 | 262 | with patch("okta_oauth2.tokens.DiscoveryDocument", MockDiscoveryDocument): 263 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 264 | tokens = tv.call_token_endpoint(endpoint_data) 265 | assert "access_token" in tokens 266 | assert "id_token" in tokens 267 | assert "refresh_token" in tokens 268 | 269 | 270 | @patch("okta_oauth2.tokens.requests.post") 271 | def test_call_token_endpoint_handles_error(mock_post, rf): 272 | """ 273 | When we get an error back from the API we should be 274 | raising an TokenRequestFailed error. 275 | """ 276 | mock_post.return_value = Mock(ok=True) 277 | mock_post.return_value.json.return_value = { 278 | "error": "failure", 279 | "error_description": "something went wrong", 280 | } 281 | endpoint_data = {"grant_type": "authorization_code", "code": "imacode"} 282 | 283 | c = Config() 284 | MockDiscoveryDocument = MagicMock() 285 | 286 | with patch( 287 | "okta_oauth2.tokens.DiscoveryDocument", MockDiscoveryDocument 288 | ), pytest.raises(TokenRequestFailed): 289 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 290 | tv.call_token_endpoint(endpoint_data) 291 | 292 | 293 | @patch("okta_oauth2.tokens.requests.post") 294 | def test_request_userinfo(mock_post, rf): 295 | """Test jwks method returns json""" 296 | mock_post.return_value = Mock(ok=True) 297 | mock_post.return_value.json.return_value = mock_request_jwks(None) 298 | 299 | c = Config() 300 | 301 | with patch("okta_oauth2.tokens.TokenValidator._discovery_document", MagicMock()): 302 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 303 | result = tv.request_userinfo("access_token") 304 | assert result == mock_request_jwks(None) 305 | 306 | 307 | def test_jwks_returns_cached_key(rf): 308 | """ 309 | _jwks method should return a cached key if 310 | there's one in the cache with a matching ID. 311 | """ 312 | c = Config() 313 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 314 | cache = caches[c.cache_alias] 315 | cache.set(tv.cache_key, [KEY_1], c.cache_timeout) 316 | key = tv._jwks(KEY_1["kid"]) 317 | assert key == KEY_1 318 | 319 | 320 | def test_jwks_sets_cache_and_returns(rf): 321 | """ 322 | _jwks method should request keys from okta, 323 | and if they match the key we're looking for, 324 | cache and return it. 325 | """ 326 | c = Config() 327 | 328 | with patch( 329 | "okta_oauth2.tokens.TokenValidator.request_jwks", mock_request_jwks 330 | ), patch("okta_oauth2.tokens.DiscoveryDocument", MagicMock()): 331 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 332 | key = tv._jwks(KEY_2["kid"]) 333 | cache = caches[c.cache_alias] 334 | cached_keys = cache.get(tv.cache_key) 335 | assert key == KEY_2 336 | assert KEY_2 in cached_keys 337 | 338 | 339 | @patch("okta_oauth2.tokens.requests.get") 340 | def test_request_jwks(mock_get, rf): 341 | """Test jwks method returns json""" 342 | mock_get.return_value = Mock(ok=True) 343 | mock_get.return_value.json.return_value = mock_request_jwks(None) 344 | 345 | c = Config() 346 | 347 | with patch("okta_oauth2.tokens.TokenValidator._discovery_document", MagicMock()): 348 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 349 | result = tv.request_jwks() 350 | assert result == mock_request_jwks(None) 351 | 352 | 353 | def test_jwks_returns_if_none_found(rf): 354 | """The _jwks method should return None if no key is found.""" 355 | c = Config() 356 | 357 | with patch( 358 | "okta_oauth2.tokens.TokenValidator.request_jwks", mock_request_jwks 359 | ), patch("okta_oauth2.tokens.DiscoveryDocument", MagicMock()): 360 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 361 | assert tv._jwks("notakey") is None 362 | 363 | 364 | def test_validate_token_successfully_validates(rf): 365 | """A valid token should return the decoded token.""" 366 | token = build_id_token() 367 | c = Config() 368 | with patch( 369 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 370 | ): 371 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 372 | decoded_token = tv.validate_token(token) 373 | assert decoded_token["jti"] == "randomid" 374 | 375 | 376 | def test_wrong_key_raises_invalid_token(rf): 377 | """ 378 | If we get the wrong key then we should be raising an InvalidTokenSignature. 379 | """ 380 | token = build_id_token() 381 | c = Config() 382 | with patch( 383 | "okta_oauth2.tokens.TokenValidator._jwks", 384 | Mock(return_value=newkeys(16)[0].save_pkcs1("PEM")), 385 | ), pytest.raises(InvalidTokenSignature): 386 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 387 | tv.validate_token(token) 388 | 389 | 390 | def test_no_key_raises_invalid_token(rf): 391 | """ 392 | If we dont' have a key at all we should be raising an InvalidTokenSignature. 393 | """ 394 | token = build_id_token() 395 | c = Config() 396 | with patch( 397 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=None) 398 | ), pytest.raises(InvalidTokenSignature): 399 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 400 | tv.validate_token(token) 401 | 402 | 403 | def test_invalid_issuer_in_decoded_token(rf): 404 | """ 405 | If our issuers don't match we should raise an IssuerDoesNotMatch. 406 | """ 407 | token = build_id_token(iss="invalid-issuer") 408 | c = Config() 409 | 410 | with patch( 411 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 412 | ), pytest.raises(IssuerDoesNotMatch): 413 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 414 | tv.validate_token(token) 415 | 416 | 417 | def test_invalid_audience_in_decoded_token(rf): 418 | """ 419 | If our audience doesn't match our client id we should raise an InvalidClientID 420 | """ 421 | token = build_id_token(aud="invalid-aud") 422 | c = Config() 423 | 424 | with patch( 425 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 426 | ), pytest.raises(InvalidClientID): 427 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 428 | tv.validate_token(token) 429 | 430 | 431 | def test_expired_token_raises_error(rf): 432 | """ 433 | If our token is expired then we should raise an TokenExpired. 434 | """ 435 | token = build_id_token(exp=now().timestamp() - 3600) 436 | c = Config() 437 | 438 | with patch( 439 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 440 | ), pytest.raises(TokenExpired): 441 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 442 | tv.validate_token(token) 443 | 444 | 445 | def test_issue_time_is_too_far_in_the_past_raises_error(rf): 446 | """ 447 | If our token was issued more than about 24 hours ago 448 | we want to raise a TokenTooFarAway. 449 | """ 450 | token = build_id_token(iat=now().timestamp() - 200000) 451 | c = Config() 452 | 453 | with patch( 454 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 455 | ), pytest.raises(TokenTooFarAway): 456 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 457 | tv.validate_token(token) 458 | 459 | 460 | def test_unmatching_nonce_raises_error(rf): 461 | """ 462 | If our token has the wrong nonce then raise a NonceDoesNotMatch 463 | """ 464 | token = build_id_token(nonce="wrong-nonce") 465 | c = Config() 466 | 467 | with patch( 468 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 469 | ), pytest.raises(NonceDoesNotMatch): 470 | tv = TokenValidator(c, "defaultnonce", rf.get("/")) 471 | tv.validate_token(token) 472 | 473 | 474 | @pytest.mark.django_db 475 | def test_groups_are_created_and_user_added(rf, settings, django_user_model): 476 | """ 477 | If MANAGE_GROUPS is true the groups should be created and the user 478 | should be added to them. 479 | """ 480 | settings.OKTA_AUTH = update_okta_settings(settings.OKTA_AUTH, "MANAGE_GROUPS", True) 481 | 482 | c = Config() 483 | req = rf.get("/") 484 | add_session(req) 485 | 486 | with patch( 487 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 488 | get_normal_user_with_groups_token, 489 | ), patch( 490 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 491 | ): 492 | tv = TokenValidator(c, "defaultnonce", req) 493 | user, tokens = tv.tokens_from_refresh_token("refresh") 494 | 495 | groups = Group.objects.all() 496 | assert [("one",), ("two",)] == list(groups.values_list("name")) 497 | assert list(user.groups.all()) == list(Group.objects.all()) 498 | 499 | 500 | @pytest.mark.django_db 501 | def test_user_is_removed_from_groups(rf, settings, django_user_model): 502 | """ 503 | When MANAGE_GROUPS is true a user should be removed from a 504 | group if it's not included in the token response. 505 | """ 506 | settings.OKTA_AUTH = update_okta_settings(settings.OKTA_AUTH, "MANAGE_GROUPS", True) 507 | 508 | user = django_user_model._default_manager.create_user( 509 | username="fakemail@notreal.com", email="fakemail@notreal.com" 510 | ) 511 | group = Group.objects.create(name="test-group") 512 | 513 | user.groups.add(group) 514 | 515 | c = Config() 516 | req = rf.get("/") 517 | add_session(req) 518 | 519 | with patch( 520 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 521 | get_normal_user_with_groups_token, 522 | ), patch( 523 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 524 | ): 525 | tv = TokenValidator(c, "defaultnonce", req) 526 | user, tokens = tv.tokens_from_refresh_token("refresh") 527 | 528 | groups = user.groups.all() 529 | assert [("one",), ("two",)] == list(groups.values_list("name")) 530 | 531 | 532 | @pytest.mark.django_db 533 | def test_existing_user_is_escalated_to_superuser_group(rf, settings, django_user_model): 534 | """ 535 | If an existing user is added to a superuser group they should 536 | be escalated to a superuser. 537 | """ 538 | settings.OKTA_AUTH = update_okta_settings( 539 | settings.OKTA_AUTH, "SUPERUSER_GROUP", SUPERUSER_GROUP 540 | ) 541 | 542 | user = django_user_model._default_manager.create_user( 543 | username="fakemail@notreal.com", email="fakemail@notreal.com" 544 | ) 545 | 546 | c = Config() 547 | req = rf.get("/") 548 | add_session(req) 549 | 550 | with patch( 551 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 552 | get_superuser_token_result, 553 | ), patch( 554 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 555 | ): 556 | tv = TokenValidator(c, "defaultnonce", req) 557 | user, tokens = tv.tokens_from_refresh_token("refresh") 558 | assert isinstance(user, django_user_model) 559 | assert user.is_superuser 560 | 561 | 562 | @pytest.mark.django_db 563 | def test_existing_superuser_is_deescalated_from_superuser_group( 564 | rf, settings, django_user_model 565 | ): 566 | """ 567 | If an existing user is removed from a superuser group they should 568 | be deescalated from a superuser. 569 | """ 570 | settings.OKTA_AUTH = update_okta_settings( 571 | settings.OKTA_AUTH, "SUPERUSER_GROUP", SUPERUSER_GROUP 572 | ) 573 | 574 | user = django_user_model._default_manager.create_user( 575 | username="fakemail@notreal.com", 576 | email="fakemail@notreal.com", 577 | is_staff=True, 578 | is_superuser=True, 579 | ) 580 | 581 | c = Config() 582 | req = rf.get("/") 583 | add_session(req) 584 | 585 | with patch( 586 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 587 | get_normal_user_with_groups_token, 588 | ), patch( 589 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 590 | ): 591 | tv = TokenValidator(c, "defaultnonce", req) 592 | user, tokens = tv.tokens_from_refresh_token("refresh") 593 | assert isinstance(user, django_user_model) 594 | assert user.is_superuser is False 595 | 596 | 597 | @pytest.mark.django_db 598 | def test_existing_user_is_escalated_to_staff_group(rf, settings, django_user_model): 599 | """ 600 | If an existing user is added to a staff group they should 601 | be escalated to a superuser. 602 | """ 603 | settings.OKTA_AUTH = update_okta_settings( 604 | settings.OKTA_AUTH, "STAFF_GROUP", STAFF_GROUP 605 | ) 606 | 607 | user = django_user_model._default_manager.create_user( 608 | username="fakemail@notreal.com", email="fakemail@notreal.com" 609 | ) 610 | 611 | c = Config() 612 | req = rf.get("/") 613 | add_session(req) 614 | 615 | with patch( 616 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 617 | get_staff_token_result, 618 | ), patch( 619 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 620 | ): 621 | tv = TokenValidator(c, "defaultnonce", req) 622 | user, tokens = tv.tokens_from_refresh_token("refresh") 623 | assert isinstance(user, django_user_model) 624 | assert user.is_staff 625 | 626 | 627 | @pytest.mark.django_db 628 | def test_existing_superuser_is_deescalated_from_staff_group( 629 | rf, settings, django_user_model 630 | ): 631 | """ 632 | If an existing user is removed from a staff group they should 633 | have the staff flag removed. 634 | """ 635 | settings.OKTA_AUTH = update_okta_settings( 636 | settings.OKTA_AUTH, "STAFF_GROUP", STAFF_GROUP 637 | ) 638 | 639 | user = django_user_model._default_manager.create_user( 640 | username="fakemail@notreal.com", 641 | email="fakemail@notreal.com", 642 | is_staff=True, 643 | ) 644 | 645 | c = Config() 646 | req = rf.get("/") 647 | add_session(req) 648 | 649 | with patch( 650 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", 651 | get_normal_user_with_groups_token, 652 | ), patch( 653 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 654 | ): 655 | tv = TokenValidator(c, "defaultnonce", req) 656 | user, tokens = tv.tokens_from_refresh_token("refresh") 657 | assert isinstance(user, django_user_model) 658 | assert user.is_staff is False 659 | 660 | 661 | @pytest.mark.django_db 662 | def test_user_username_setting_returns_user_by_username_and_not_email( 663 | rf, settings, django_user_model 664 | ): 665 | settings.OKTA_AUTH = update_okta_settings(settings.OKTA_AUTH, "USE_USERNAME", True) 666 | 667 | c = Config() 668 | req = rf.get("/") 669 | add_session(req) 670 | 671 | with patch( 672 | "okta_oauth2.tokens.TokenValidator.call_token_endpoint", get_token_result 673 | ), patch( 674 | "okta_oauth2.tokens.TokenValidator._jwks", Mock(return_value=TEST_PUBLIC_KEY) 675 | ): 676 | tv = TokenValidator(c, "defaultnonce", req) 677 | user, tokens = tv.tokens_from_auth_code("authcode") 678 | assert isinstance(user, django_user_model) 679 | assert user.username == "fakemail" 680 | assert user.username != "fakemail@notreal.com" 681 | -------------------------------------------------------------------------------- /okta_oauth2/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from http.cookies import SimpleCookie 2 | from unittest.mock import Mock, patch 3 | 4 | from django.test import Client, override_settings 5 | from django.urls import reverse 6 | 7 | 8 | @override_settings(MIDDLEWARE=[]) 9 | def test_callback_without_messages(): 10 | """ 11 | The okta callback function should 12 | return a 500 with the error message from okta if 13 | the messages framework is not enabled. 14 | """ 15 | c = Client() 16 | 17 | response = c.get( 18 | reverse("okta_oauth2:callback"), 19 | { 20 | "error": "invalid_scope", 21 | "error_description": "One or more scopes are not " 22 | "configured for the authorization server resource.", 23 | }, 24 | ) 25 | 26 | assert response.status_code == 500 27 | assert ( 28 | response.content == b"One or more scopes are not configured for " 29 | b"the authorization server resource." 30 | ) 31 | 32 | 33 | def test_callback_redirects_on_error(settings): 34 | """ 35 | The okta callback function should set a message 36 | and return a redirect if it gets an error. 37 | """ 38 | 39 | # We need to set up django messages to actually set a message. 40 | settings.INSTALLED_APPS = settings.INSTALLED_APPS + ("django.contrib.messages",) 41 | settings.MIDDLEWARE = ( 42 | "django.contrib.sessions.middleware.SessionMiddleware", 43 | "django.contrib.messages.middleware.MessageMiddleware", 44 | ) 45 | settings.TEMPLATES = [ 46 | { 47 | "BACKEND": "django.template.backends.django.DjangoTemplates", 48 | "DIRS": [], 49 | "APP_DIRS": True, 50 | "OPTIONS": { 51 | "context_processors": [ 52 | # Django builtin 53 | "django.template.context_processors.debug", 54 | "django.template.context_processors.media", 55 | "django.template.context_processors.request", 56 | "django.contrib.auth.context_processors.auth", 57 | "django.contrib.messages.context_processors.messages", 58 | ] 59 | }, 60 | } 61 | ] 62 | 63 | c = Client() 64 | 65 | response = c.get( 66 | reverse("okta_oauth2:callback"), 67 | { 68 | "error": "invalid_scope", 69 | "error_description": "One or more scopes are not configured" 70 | " for the authorization server resource.", 71 | }, 72 | ) 73 | 74 | assert response.status_code == 302 75 | assert response.url == reverse("okta_oauth2:login") 76 | 77 | 78 | def test_callback_success(settings, django_user_model): 79 | """ 80 | the callback method should authenticate successfully with 81 | an auth_code and nonce. We have to fake this because we can't hit 82 | okta with a fake auth code. 83 | """ 84 | 85 | settings.MIDDLEWARE = ("django.contrib.sessions.middleware.SessionMiddleware",) 86 | 87 | nonce = "123456" 88 | 89 | user = django_user_model.objects.create_user("testuser", "testuser@example.com") 90 | 91 | with patch( 92 | "okta_oauth2.backend.TokenValidator.tokens_from_auth_code", 93 | Mock(return_value=(user, None)), 94 | ): 95 | c = Client() 96 | 97 | c.cookies = SimpleCookie( 98 | {"okta-oauth-state": "cookie-state", "okta-oauth-nonce": nonce} 99 | ) 100 | 101 | response = c.get( 102 | reverse("okta_oauth2:callback"), {"code": "123456", "state": "cookie-state"} 103 | ) 104 | 105 | assert response.status_code == 302 106 | assert response.url == "/" 107 | 108 | 109 | def test_login_view(client): 110 | response = client.get(reverse("okta_oauth2:login")) 111 | assert response.status_code == 200 112 | assert "config" in response.context 113 | 114 | 115 | def test_login_view_deletes_cookies(client): 116 | client.cookies = SimpleCookie( 117 | {"okta-oauth-state": "cookie-state", "okta-oauth-nonce": "123456"} 118 | ) 119 | 120 | response = client.get(reverse("okta_oauth2:login")) 121 | 122 | assert response.status_code == 200 123 | assert response.cookies["okta-oauth-state"].value == "" 124 | assert ( 125 | response.cookies["okta-oauth-state"]["expires"] 126 | == "Thu, 01 Jan 1970 00:00:00 GMT" 127 | ) 128 | assert response.cookies["okta-oauth-nonce"].value == "" 129 | assert ( 130 | response.cookies["okta-oauth-nonce"]["expires"] 131 | == "Thu, 01 Jan 1970 00:00:00 GMT" 132 | ) 133 | 134 | 135 | def test_callback_rejects_post(client): 136 | response = client.post(reverse("okta_oauth2:callback")) 137 | assert response.status_code == 400 138 | 139 | 140 | def test_invalid_states_is_a_bad_request(client): 141 | client.cookies = SimpleCookie( 142 | {"okta-oauth-state": "cookie-state", "okta-oauth-nonce": "nonce"} 143 | ) 144 | 145 | response = client.get( 146 | reverse("okta_oauth2:callback"), {"code": "123456", "state": "wrong-state"} 147 | ) 148 | 149 | assert response.status_code == 400 150 | 151 | 152 | def test_failed_authentication_redirects_to_login(client, settings, django_user_model): 153 | settings.MIDDLEWARE = ("django.contrib.sessions.middleware.SessionMiddleware",) 154 | 155 | nonce = "123456" 156 | 157 | # Creating a user to make sure there's actually one that *could* be returned. 158 | django_user_model.objects.create_user("testuser", "testuser@example.com") 159 | 160 | with patch("okta_oauth2.views.authenticate", Mock(return_value=None)): 161 | c = Client() 162 | 163 | c.cookies = SimpleCookie( 164 | {"okta-oauth-state": "cookie-state", "okta-oauth-nonce": nonce} 165 | ) 166 | 167 | response = c.get( 168 | reverse("okta_oauth2:callback"), {"code": "123456", "state": "cookie-state"} 169 | ) 170 | 171 | assert response.status_code == 302 172 | assert response.url == reverse("okta_oauth2:login") 173 | 174 | 175 | def test_logout_view_returns_200(client, settings): 176 | settings.MIDDLEWARE = ("django.contrib.sessions.middleware.SessionMiddleware",) 177 | 178 | response = client.get(reverse("okta_oauth2:logout")) 179 | assert response.status_code == 302 180 | assert response.url == reverse("okta_oauth2:login") 181 | -------------------------------------------------------------------------------- /okta_oauth2/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.urls import include, path 3 | 4 | from okta_oauth2.decorators import okta_login_required 5 | 6 | 7 | def test_view(request): 8 | return HttpResponse("not a real view") 9 | 10 | 11 | @okta_login_required 12 | def decorated_view(request): 13 | return HttpResponse("i am decorated") 14 | 15 | 16 | urlpatterns = [ 17 | path("", test_view), 18 | path("decorated/", decorated_view), 19 | path("named/", test_view, name="named-url"), 20 | path( 21 | "accounts/", 22 | include(("okta_oauth2.urls", "okta_oauth2"), namespace="okta_oauth2"), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /okta_oauth2/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | from jose import jwt 3 | 4 | from okta_oauth2.conf import Config 5 | 6 | TEST_PRIVATE_KEY = """ 7 | -----BEGIN RSA PRIVATE KEY----- 8 | MIICXQIBAAKBgQC3UnFAPMb32q3IcBTbG46qLamnh0CbqPwEQKn9wrzBFhJ3nhtB 9 | FQStNI8TXUPLN87i7hZt8tX4iWkUYJskwz98qZj8vc7LhT6/MBulyLyMP2VYva2D 10 | ufezz5qX6n/xBo6bgv1XsTyS4EdBN9QL2oc23bu7MUoDsor0tNGuy5xKjQIDAQAB 11 | AoGBAJ1qAmtJhQSBV2Zcr9vxTtDcguii4ByJv1WbfRy0okYewN7L+dUpyik8j3ru 12 | Q+91TYZZMRNaSNewjnV7+txXd+QM5mLorfmYEIuvxVf6edXgzRuNfol1UO0gjWl3 13 | wpoNUQXHqSp7f2pluLw/wTFAgFOWTMmE4tdFe4KCImmkljW1AkEA2Saus/usqA4r 14 | QfroAdLn2dKR6kO2LXdORpaNZ1NgXw4+nqDMYNBPilOUh5L+fq8VzNF0o50PfO83 15 | u74oknT4twJBANgea6Xp37V2QLnU2G98DGd9E24EqvQkRopDq0kYdXzNpsVLx7fu 16 | 76QXSPO4OqxzlIq3HguWlK+oKEBOZ3iuqtsCQQDNaj07Puk99GFRQfM0vnjaYcns 17 | LG9qJQDj30kWJBX29XehAQU00/laJeRMN24NErzxinXmzA05puU28RRaLtKTAkAi 18 | 5H5y0hipPodiuWecUEXca4g4ig5jznuJFTXRXl6RoM5dKkf7fVs5ffzsRIFMmHiS 19 | ENCMBGrLFXYyM7Zm+KRjAkAJFILd+qtdADilYbyf4KPU766TT1dtljAoTBzxZ0T9 20 | 2VblbIa1tmvs35apo6fxFf4dXOZNiz85OMFvxglvmIus 21 | -----END RSA PRIVATE KEY----- 22 | """ 23 | 24 | TEST_PUBLIC_KEY = """ 25 | -----BEGIN PUBLIC KEY----- 26 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3UnFAPMb32q3IcBTbG46qLamn 27 | h0CbqPwEQKn9wrzBFhJ3nhtBFQStNI8TXUPLN87i7hZt8tX4iWkUYJskwz98qZj8 28 | vc7LhT6/MBulyLyMP2VYva2Dufezz5qX6n/xBo6bgv1XsTyS4EdBN9QL2oc23bu7 29 | MUoDsor0tNGuy5xKjQIDAQAB 30 | -----END PUBLIC KEY----- 31 | """ 32 | 33 | 34 | def update_okta_settings(okta_settings, k, v): 35 | """ 36 | Pytest-django does a shallow compare to determine which parts 37 | of its settings fixture to roll back, so if we don't replace 38 | the OKTA_AUTH dict entirely settings don't roll back between tests. 39 | """ 40 | new_settings = okta_settings.copy() 41 | new_settings.update({k: v}) 42 | return new_settings 43 | 44 | 45 | def build_id_token( 46 | aud=None, 47 | auth_time=None, 48 | exp=None, 49 | iat=None, 50 | iss=None, 51 | sub=None, 52 | nonce="defaultnonce", 53 | groups=[], 54 | ): 55 | config = Config() 56 | 57 | current_timestamp = now().timestamp() 58 | iat_offset = 2 59 | 60 | claims = { 61 | "amr": ["pwd"], 62 | "at_hash": "notarealhash", 63 | "aud": aud if aud else config.client_id, 64 | "auth_time": auth_time if auth_time else current_timestamp, 65 | "email": "fakemail@notreal.com", 66 | "exp": exp if exp else current_timestamp + iat_offset + 3600, 67 | "iat": iat if iat else current_timestamp + iat_offset, 68 | "idp": aud if aud else config.client_id, 69 | "iss": iss if iss else config.issuer, 70 | "jti": "randomid", 71 | "name": "A User", 72 | "nonce": nonce, 73 | "preferred_username": "auser", 74 | "sub": sub if sub else config.client_id, 75 | "ver": 1, 76 | "groups": groups, 77 | } 78 | 79 | headers = {"kid": "1A234567890"} 80 | 81 | return jwt.encode(claims, TEST_PRIVATE_KEY, headers=headers, algorithm="RS256") 82 | 83 | 84 | def build_access_token( 85 | aud=None, auth_time=None, exp=None, iat=None, iss=None, sub=None, uid=None 86 | ): 87 | config = Config() 88 | 89 | current_timestamp = now().timestamp() 90 | iat_offset = 2 91 | 92 | headers = {"alg": "RS256", "kid": "abcdefg"} 93 | 94 | claims = { 95 | "ver": 1, 96 | "jti": "randomid", 97 | "iss": iss if iss else config.issuer, 98 | "aud": aud if aud else config.client_id, 99 | "sub": sub if sub else config.client_id, 100 | "iat": iat if iat else current_timestamp + iat_offset, 101 | "exp": exp if exp else current_timestamp + iat_offset + 3600, 102 | "uid": uid if uid else config.client_id, 103 | "scp": ["openid", "email", "offline_access", "groups"], 104 | } 105 | 106 | return jwt.encode(claims, TEST_PRIVATE_KEY, headers=headers, algorithm="RS256") 107 | -------------------------------------------------------------------------------- /okta_oauth2/tokens.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import time 4 | from typing import Optional 5 | 6 | import jwt as jwt_python 7 | import requests 8 | from django.contrib.auth import get_user_model 9 | from django.contrib.auth.models import Group 10 | from django.core.cache import caches 11 | from django.db.models import Q 12 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 13 | from django.urls import reverse 14 | from jose import jws, jwt 15 | from jose.exceptions import JWSError, JWTError 16 | 17 | from .conf import Config 18 | from .exceptions import ( 19 | InvalidClientID, 20 | InvalidToken, 21 | InvalidTokenSignature, 22 | IssuerDoesNotMatch, 23 | MissingAuthTokens, 24 | NonceDoesNotMatch, 25 | TokenExpired, 26 | TokenRequestFailed, 27 | TokenTooFarAway, 28 | ) 29 | 30 | UserModel = get_user_model() 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | class DiscoveryDocument: 36 | # Find the OIDC metadata through discovery 37 | def __init__(self, config): 38 | r = requests.get( 39 | config.issuer + "/.well-known/openid-configuration", 40 | timeout=config.request_timeout, 41 | ) 42 | self.json = r.json() 43 | 44 | def getJson(self): 45 | return self.json 46 | 47 | 48 | class TokenValidator: 49 | _discovery_document = None 50 | 51 | def __init__(self, config, nonce, request): 52 | self.config = config 53 | self.cache = caches[config.cache_alias] 54 | self.cache_key = "{}-keys".format(config.cache_prefix) 55 | self.request = request 56 | self.nonce = nonce 57 | 58 | @property 59 | def discovery_document(self): 60 | if self._discovery_document is None: 61 | self._discovery_document = DiscoveryDocument(self.config) 62 | return self._discovery_document 63 | 64 | def tokens_from_auth_code(self, code): 65 | data = {"grant_type": "authorization_code", "code": str(code)} 66 | 67 | result = self.call_token_endpoint(data) 68 | return self.handle_token_result(result) 69 | 70 | def tokens_from_refresh_token(self, refresh_token): 71 | data = {"grant_type": "refresh_token", "refresh_token": str(refresh_token)} 72 | 73 | result = self.call_token_endpoint(data) 74 | return self.handle_token_result(result) 75 | 76 | def manage_groups(self, user, groups): 77 | for group in groups: 78 | group, _ = Group.objects.get_or_create(name=group) 79 | user.groups.add(group) 80 | 81 | removed_groups = user.groups.filter(~Q(name__in=groups)) 82 | 83 | for group in removed_groups: 84 | user.groups.remove(group) 85 | 86 | def handle_token_result(self, token_result): 87 | tokens = {} 88 | user = None 89 | 90 | if token_result is None or "id_token" not in token_result: 91 | return None, tokens 92 | 93 | if self.using_org_authorization_server: 94 | self.validate_token(token_result["id_token"]) 95 | claims = self.request_userinfo(token_result["access_token"]) 96 | else: 97 | claims = self.validate_token(token_result["id_token"]) 98 | 99 | if claims: 100 | tokens["id_token"] = token_result["id_token"] 101 | tokens["claims"] = claims 102 | username = claims["email"] 103 | if self.config.use_username: 104 | last_at = claims["email"].rfind("@") 105 | username = claims["email"][:last_at] 106 | 107 | try: 108 | user = UserModel._default_manager.get_by_natural_key(username) 109 | except UserModel.DoesNotExist: 110 | user = UserModel._default_manager.create_user( 111 | username=username, email=claims["email"] 112 | ) 113 | 114 | if self.config.superuser_group: 115 | user.is_superuser = bool( 116 | self.config.superuser_group 117 | and "groups" in claims 118 | and self.config.superuser_group in claims["groups"] 119 | ) 120 | 121 | if self.config.staff_group: 122 | user.is_staff = bool( 123 | self.config.staff_group 124 | and "groups" in claims 125 | and self.config.staff_group in claims["groups"] 126 | ) 127 | 128 | user.save() 129 | 130 | if self.config.manage_groups: 131 | self.manage_groups(user, claims["groups"]) 132 | 133 | if "access_token" in token_result: 134 | tokens["access_token"] = token_result["access_token"] 135 | 136 | if "refresh_token" in token_result: 137 | tokens["refresh_token"] = token_result["refresh_token"] 138 | 139 | if user: 140 | self.request.session["tokens"] = tokens 141 | self.request.session.modified = True 142 | 143 | return user, tokens 144 | 145 | @property 146 | def using_org_authorization_server(self): 147 | return self.config.issuer.endswith("okta.com") 148 | 149 | def call_token_endpoint(self, endpoint_data): 150 | """Call /token endpoint 151 | Returns access_token, id_token, and/or refresh_token 152 | """ 153 | discovery_doc = self.discovery_document.getJson() 154 | token_endpoint = discovery_doc["token_endpoint"] 155 | 156 | basic_auth_str = "{0}:{1}".format( 157 | self.config.client_id, self.config.client_secret 158 | ) 159 | authorization_header = base64.b64encode(basic_auth_str.encode()) 160 | header = { 161 | "Authorization": "Basic: " + authorization_header.decode("utf-8"), 162 | "Content-Type": "application/x-www-form-urlencoded", 163 | } 164 | 165 | data = {"scope": self.config.scopes, "redirect_uri": self.config.redirect_uri} 166 | 167 | data.update(endpoint_data) 168 | # Send token request 169 | r = requests.post( 170 | token_endpoint, 171 | headers=header, 172 | params=data, 173 | timeout=self.config.request_timeout, 174 | ) 175 | response = r.json() 176 | 177 | # Return object 178 | result = {} 179 | if "error" not in response: 180 | if "access_token" in response: 181 | result["access_token"] = response["access_token"] 182 | if "id_token" in response: 183 | result["id_token"] = response["id_token"] 184 | if "refresh_token" in response: 185 | result["refresh_token"] = response["refresh_token"] 186 | else: 187 | raise TokenRequestFailed( 188 | response["error"], response.get("error_description", None) 189 | ) 190 | 191 | return result if len(result.keys()) > 0 else None 192 | 193 | def request_userinfo(self, access_token): 194 | discovery_doc = self.discovery_document.getJson() 195 | r = requests.post( 196 | discovery_doc["userinfo_endpoint"], 197 | headers={ 198 | "Authorization": "Bearer " + access_token, 199 | }, 200 | timeout=self.config.request_timeout, 201 | ) 202 | return r.json() 203 | 204 | def request_jwks(self): 205 | discovery_doc = self.discovery_document.getJson() 206 | r = requests.get(discovery_doc["jwks_uri"], timeout=self.config.request_timeout) 207 | return r.json() 208 | 209 | def _jwks(self, kid): 210 | """ 211 | Internal: 212 | Fetch public key from jwks_uri and caches it until the key rotates 213 | :param kid: "key Id" 214 | :return: key from jwks_uri having the kid key 215 | """ 216 | 217 | cached_keys = self.cache.get(self.cache_key) or [] 218 | 219 | for key in cached_keys: 220 | if key["kid"] == kid: 221 | return key 222 | 223 | # lookup the key from jwks_uri if key is not in cache 224 | jwks = self.request_jwks() 225 | 226 | for key in jwks["keys"]: 227 | if kid == key["kid"]: 228 | cached_keys.append(key) 229 | self.cache.set(self.cache_key, cached_keys, self.config.cache_timeout) 230 | return key 231 | 232 | return None 233 | 234 | def validate_token(self, token): 235 | """ 236 | Validate token 237 | (Taken from 238 | http://openid.net/specs/openid-connect-core-1_0.html#TokenResponseValidation) 239 | """ 240 | 241 | """ Step 1: 242 | If encrypted, decrypt it using the keys and algorithms specified 243 | in the meta_data 244 | 245 | If encryption was negotiated but not provided, REJECT 246 | 247 | Skipping Okta has not implemented encrypted JWT 248 | """ 249 | 250 | try: 251 | decoded_token = jwt_python.decode( 252 | token, options={"verify_signature": False} 253 | ) 254 | except jwt_python.exceptions.DecodeError: 255 | raise InvalidToken("Unable to decode jwt") 256 | 257 | dirty_alg = jwt.get_unverified_header(token)["alg"] 258 | dirty_kid = jwt.get_unverified_header(token)["kid"] 259 | 260 | key = self._jwks(dirty_kid) 261 | if key: 262 | # Validate the key using jose-jws 263 | try: 264 | jws.verify(token, key, algorithms=[dirty_alg]) 265 | except (JWTError, JWSError) as err: 266 | raise InvalidTokenSignature("Invalid token signature") from err 267 | else: 268 | raise InvalidTokenSignature("Unable to fetch public signing key") 269 | 270 | """ Step 2: 271 | Issuer Identifier for the OpenID Provider (which is typically 272 | obtained during Discovery) MUST exactly match the value of the 273 | iss (issuer) Claim. 274 | Redundant, since we will validate in Step 3, the "iss" claim matches 275 | host we requested the token from 276 | """ 277 | 278 | if decoded_token["iss"] != self.config.issuer: 279 | raise IssuerDoesNotMatch("Issuer does not match") 280 | 281 | """ Step 3: 282 | Client MUST validate: 283 | aud (audience) contains the same `client_id` registered 284 | iss (issuer) identified as the aud (audience) 285 | aud (audience) Claim MAY contain an array with more than one 286 | element (Currently NOT IMPLEMENTED by Okta) 287 | The ID Token MUST be rejected if the ID Token does not list the 288 | Client as a valid audience, or if it contains additional audiences 289 | not trusted by the Client. 290 | """ 291 | 292 | if decoded_token["aud"] != self.config.client_id: 293 | raise InvalidClientID("Audience does not match client_id") 294 | 295 | """ Step 6: TLS server validation not implemented by Okta 296 | If ID Token is received via direct communication between Client and 297 | Token Endpoint, TLS server validation may be used to validate the 298 | issuer in place of checking token 299 | signature. MUST validate according to JWS algorithm specialized in JWT 300 | alg Header. MUST use keys provided. 301 | """ 302 | 303 | """ Step 7: 304 | The alg value SHOULD default to RS256 or sent in 305 | id_token_signed_response_alg param during Registration 306 | 307 | We don't need to test this. Okta always signs in RS256 308 | """ 309 | 310 | """ Step 8: Not implemented due to Okta configuration 311 | 312 | If JWT alg Header uses MAC based algorithm (HS256, HS384, etc) the 313 | octets of UTF-8 of the client_secret corresponding to the client_id 314 | are contained in the aud (audience) are used to validate the signature. 315 | For MAC based, if aud is multi-valued or if azp value is different 316 | than aud value - behavior is unspecified. 317 | """ 318 | 319 | if decoded_token["exp"] < int(time.time()): 320 | """Step 9: 321 | 322 | The current time MUST be before the time represented by exp 323 | """ 324 | raise TokenExpired 325 | 326 | if decoded_token["iat"] < (int(time.time()) - 100000): 327 | """Step 10 - Defined 'too far away time' : approx 24hrs 328 | The iat can be used to reject tokens that were issued too far away 329 | from current time, limiting the time that nonces need to be stored 330 | to prevent attacks. 331 | """ 332 | raise TokenTooFarAway("iat too far in the past ( > 1 day)") 333 | 334 | if self.nonce is not None and "nonce" in decoded_token: 335 | """Step 11 336 | If a nonce value is sent in the Authentication Request, 337 | a nonce MUST be present and be the same value as the one 338 | sent in the Authentication Request. Client SHOULD check for 339 | nonce value to prevent replay attacks. 340 | """ 341 | if self.nonce != decoded_token["nonce"]: 342 | raise NonceDoesNotMatch( 343 | "nonce value does not match Authentication Request nonce" 344 | ) 345 | 346 | """ Step 12: Not implemented by Okta 347 | If acr was requested, check that the asserted Claim Value is appropriate 348 | """ 349 | 350 | """ Step 13 351 | If auth_time was requested, check claim value and request 352 | re-authentication if too much time elapsed 353 | 354 | We relax this requirement during jwt validation. The Okta Session 355 | should be handled inside Okta 356 | 357 | See https://developer.okta.com/docs/api/resources/sessions 358 | """ 359 | 360 | return decoded_token 361 | 362 | 363 | def validate_tokens(config: Config, request: HttpRequest): 364 | """ 365 | Take a config and a request and validate the auth tokens 366 | that are in the session. 367 | 368 | Raises an InvalidToken error if there's something wrong with the 369 | token, or a django ImproperlyConfigured exception if there's 370 | something wrong with the configuration. 371 | """ 372 | if "tokens" not in request.session or "id_token" not in request.session["tokens"]: 373 | # There must be an id token in the session to validate against. 374 | raise MissingAuthTokens("Tokens missing from the session") 375 | 376 | try: 377 | nonce = request.COOKIES["okta-oauth-nonce"] 378 | except KeyError: 379 | # If we don't have a nonce in the cookie then we can't 380 | # validate the token, so just raise an invalid token here. 381 | raise InvalidToken("Missing nonce in cookie") 382 | 383 | try: 384 | validator = TokenValidator(config, nonce, request) 385 | # If we don't raise an exception we assume that we've got a valid token 386 | validator.validate_token(request.session["tokens"]["id_token"]) 387 | except TokenExpired: 388 | # Check for a refresh token, to refresh the authentication automatically. 389 | if "refresh_token" in request.session["tokens"]: 390 | validator = TokenValidator(config, None, request) 391 | # If we don't raise an exception we assume that we've got a valid token 392 | validator.tokens_from_refresh_token( 393 | request.session["tokens"]["refresh_token"] 394 | ) 395 | else: 396 | raise InvalidToken("Token has expired and no refresh token available") 397 | 398 | 399 | def validate_or_redirect( 400 | config: Config, request: HttpRequest 401 | ) -> Optional[HttpResponse]: 402 | """Take a config and a request. If tokens dont' validate, 403 | return the appropriate HttpResponse, otherwise return None""" 404 | try: 405 | validate_tokens(config, request) 406 | except MissingAuthTokens: 407 | # If we don't have any tokens then we want to just deny straight 408 | # up. We should always have tokens in the session when we're not 409 | # requesting a public view. 410 | if request.method == "POST": 411 | # Posting shouldn't redirect, it should just say no. 412 | response = HttpResponse() 413 | response.status_code = 401 414 | return response 415 | # Take us to the login so we can get some tokens. 416 | return HttpResponseRedirect(reverse("okta_oauth2:login")) 417 | except InvalidToken: 418 | return HttpResponseRedirect(reverse("okta_oauth2:login")) 419 | return None 420 | -------------------------------------------------------------------------------- /okta_oauth2/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("login/", views.login, name="login"), 7 | path("oauth2/callback/", views.callback, name="callback"), 8 | path("logout/", views.logout, name="logout"), 9 | ] 10 | -------------------------------------------------------------------------------- /okta_oauth2/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib import messages 4 | from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout 5 | from django.contrib.messages.api import MessageFailure 6 | from django.http import ( 7 | HttpResponseBadRequest, 8 | HttpResponseRedirect, 9 | HttpResponseServerError, 10 | ) 11 | from django.shortcuts import redirect, render 12 | from django.urls import reverse 13 | from django.urls.exceptions import NoReverseMatch 14 | 15 | from .conf import Config 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def login(request): 21 | config = Config() 22 | 23 | okta_config = { 24 | "clientId": config.client_id, 25 | "url": config.org_url, 26 | "redirectUri": str(config.redirect_uri), 27 | "scope": config.scopes, 28 | "issuer": config.issuer, 29 | } 30 | response = render(request, "okta_oauth2/login.html", {"config": okta_config}) 31 | 32 | _delete_cookies(response) 33 | 34 | return response 35 | 36 | 37 | def callback(request): 38 | config = Config() 39 | 40 | if request.method == "POST": 41 | return HttpResponseBadRequest("Method not supported") 42 | 43 | if "error" in request.GET: 44 | error_description = request.GET.get( 45 | "error_description", "An unknown error occurred." 46 | ) 47 | try: 48 | messages.error(request, error_description) 49 | except MessageFailure: 50 | return HttpResponseServerError(error_description) 51 | return HttpResponseRedirect(reverse("okta_oauth2:login")) 52 | 53 | code = request.GET["code"] 54 | state = request.GET["state"] 55 | 56 | # Get state and nonce from cookie 57 | cookie_state = request.COOKIES["okta-oauth-state"] 58 | cookie_nonce = request.COOKIES["okta-oauth-nonce"] 59 | 60 | # Verify state 61 | if state != cookie_state: 62 | return HttpResponseBadRequest( 63 | "Value {} does not match the assigned state".format(state) 64 | ) 65 | 66 | user = authenticate(request, auth_code=code, nonce=cookie_nonce) 67 | 68 | if user is None: 69 | return redirect(reverse("okta_oauth2:login")) 70 | 71 | auth_login(request, user) 72 | 73 | try: 74 | redirect_url = reverse(config.login_redirect_url) 75 | except NoReverseMatch: 76 | redirect_url = config.login_redirect_url 77 | 78 | return redirect(redirect_url) 79 | 80 | 81 | def logout(request): 82 | auth_logout(request) 83 | return HttpResponseRedirect(reverse("okta_oauth2:login")) 84 | 85 | 86 | def _delete_cookies(response): 87 | # The Okta Signin Widget/Javascript SDK aka "Auth-JS" automatically generates 88 | # state and nonce and stores them in cookies. Delete authJS/widget cookies 89 | response.delete_cookie("okta-oauth-state") 90 | response.delete_cookie("okta-oauth-nonce") 91 | response.delete_cookie("okta-oauth-redirect-params") 92 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-okta-auth" 3 | version = "0.8.0" 4 | description = "Django Authentication for Okta OpenID" 5 | authors = ["Matt Magin