├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── aad.b2c.config.json ├── aad.django.config.json ├── aad.flask.config.json ├── ms_identity_web ├── __init__.py ├── adapters.py ├── configuration.py ├── constants.py ├── context.py ├── django │ ├── __init__.py │ ├── adapter.py │ ├── middleware.py │ └── msal_views_and_urls.py ├── errors.py └── flask_blueprint │ └── __init__.py ├── requirements.txt └── setup.py /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | flask_session/ 2 | 3 | .DS_STORE 4 | .vscode/ 5 | /.vs 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MS Identity Python Common 2 | 3 | This repository contains a set of code that is shared amongst the various Python samples for the Microsoft Identity Platform. This is a work in progress and we'd love to hear your feedback, comments and contributions. 4 | 5 | ## Features 6 | 7 | The code present makes available or aims to make available the following features to the developers: 8 | - Allow for (but not require) automatic Flask/Django/other framework integration (implemented) 9 | - Allow for (but not require) automatic endpoint protection (implemented) 10 | - Catch AAD errors and handle them properly, e.g.: 11 | - password reset flow and edit profile flow (implemented) 12 | - insufficient / incremental consent (needs implementation) 13 | - Token cache handling (implemeted) 14 | - authN enforcement by decorator (implemented) 15 | - Allow multiple identity sessions per user browser session (i.e., multiple logged in users in one browser session) (not yet implemented) 16 | - Abstract authN and authZ implementation details away from developer (implemented) 17 | - authZ enforcement by decorator (not yet implented) 18 | 19 | ## Getting Started 20 | 21 | ### Prerequisites 22 | 23 | - Python 3.8 24 | - A virtual env for your own webapp project 25 | - A flask project or django project (impelemented) or other web framework (not yet implemented) or desktop app (not yet implemented) 26 | 27 | ### Installation 28 | 29 | ##### 1. Activate a virtual environment 30 | 31 |
Linux/OSX: 32 | Open a terminal and type the following: 33 | 34 | ```Shell 35 | # go to your web app directory on dev machine 36 | cd your-flask-app-root-directory 37 | python3 -m venv path-to-venv # only required if you don't have a venv already 38 | # activate your virtual env 39 | source path-to-venv/bin/activate 40 | ``` 41 | 42 |
43 | 44 |
Windows: 45 | Open a terminal and type the following: 46 | 47 | ```PowerShell 48 | # go to your web app directory on dev machine 49 | cd your-flask-app-root-directory 50 | python3 -m venv path-to-venv # only required if you don't have a venv already 51 | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force 52 | . path-to-venv\Scripts\Activate.ps1 53 | pip install -r requirements.txt 54 | ``` 55 | 56 |
57 | 58 | ##### 2. Now install the utils: 59 | Use **only one** of the following two options: 60 | - via https:// 61 | ``` 62 | pip install git+https://github.com/azure-samples/ms-identity-python-samples-common 63 | ``` 64 | - via ssh:// 65 | ``` 66 | pip install git+ssh://git@github.com/azure-samples/ms-identity-python-samples-common 67 | ``` 68 | 69 | ##### 3. copy a config template (e.g. `aad.config.json`) from the repo and in to your project root dir, and fill in the details 70 | 71 | ### Quickstart (Flask) 72 | 73 | 74 | don't forget to import the required modules into your application as necessary: 75 | ``` 76 | from ms_identity_web import IdentityWebPython 77 | from ms_identity_web.adapters import FlaskContextAdapter 78 | from ms_identity_web.configuration import AADConfig 79 | ``` 80 | 81 | hook up the utils to your flask app: 82 | ``` 83 | adapter = FlaskContextAdapter(app) # we are using flask 84 | ms_identity_web = IdentityWebPython(AADConfig.parse_json('aad.config.json'), adapter) # instantiate utils 85 | ``` 86 | 87 | add the @ms_identity_web.login_required decorator to protect your routes: 88 | ``` 89 | @app.route('/my_protected_route') 90 | @ms_identity_web.login_required # <-- developer only needs to hook up this decorator to any login_required endpoint like this 91 | def my_protected_route(): 92 | return render_template('my_protected_route.html') 93 | ``` 94 | 95 | ## Demo 96 | 97 | see: https://github.com/azure-samples/ms-identity-python-flask-tutorial or https://github.com/azure-samples/ms-identity-python-django-tutorial for a demo with any of the apps there 98 | 99 | ## Project Structure 100 | #### __init__.py 101 | - main common code API is here. 102 | #### adapters.py 103 | - FlaskContextAdapter for handling interaction between the API and flask context (e.g. session, request) 104 | - An ABC defining the interface for writing more adapters 105 | - Should be re-organised into folders on a per-framework basis? 106 | #### flask_blueprint 107 | - a class that implements all aad-specific endpoints. support for multiple instances with different prefixes if necessary 108 | - all bindings are automatic with flaskcontextadapter 109 | #### django adapter 110 | - `django.adapter` is used to integrate with Django apps 111 | - need to use `django.middleware` as middleware in Django apps 112 | #### django endpoints 113 | - `django.msal_views_and_urls.py` implements all aad-specific endpoints. support for multiple instances with different prefixes if necessary 114 | #### context.py 115 | - IdentityContext class that holds ID-specific info (simple class with attributes and has_changed function for write-to-session decision) 116 | #### configuration.py 117 | - simple configuration parser and sanity checker 118 | #### constants.py 119 | - AAD constants 120 | #### errors.py 121 | - AAd error classes 122 | 123 | ## Resources 124 | 125 | 126 | -------------------------------------------------------------------------------- /aad.b2c.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": { 3 | "client_type": "CONFIDENTIAL", 4 | "authority_type": "B2C", 5 | "framework": "FLASK" 6 | }, 7 | "client": { 8 | "client_id": "e5c690fe-05df-4035-8f95-f6820851c584", 9 | "client_credential": "BpQ.A3ntu20jkUCcR-QluvN359DU--71ef", 10 | "authority": "https://fabrikamb2c.b2clogin.com/fabrikamb2c.onmicrosoft.com" 11 | }, 12 | "b2c": { 13 | "susi": "/b2c_1_susi", 14 | "profile": "/b2c_1_edit_profile", 15 | "password": "/b2c_1_reset" 16 | }, 17 | "auth_request": { 18 | "redirect_uri": null, 19 | "scopes": [], 20 | "response_type": "code" 21 | }, 22 | "flask": { 23 | "id_web_configs": "MS_ID_WEB_CONFIGS", 24 | "auth_endpoints": { 25 | "prefix": "/auth", 26 | "sign_in": "/sign_in", 27 | "edit_profile": "/edit_profile", 28 | "redirect": "/redirect", 29 | "sign_out": "/sign_out", 30 | "post_sign_out": "/post_sign_out" 31 | } 32 | }, 33 | "django": null 34 | } 35 | -------------------------------------------------------------------------------- /aad.django.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": { 3 | "client_type": "CONFIDENTIAL", 4 | "authority_type": "SINGLE_TENANT", 5 | "framework": "DJANGO" 6 | }, 7 | "client": { 8 | "client_id": "enter-your-client-id-here", 9 | "client_credential": "enter-your-client-secret-here", 10 | "authority": "https://login.microsoftonline.com/enter-your-tenant-id-here" 11 | }, 12 | "auth_request": { 13 | "redirect_uri": null, 14 | "scopes": [], 15 | "response_type": "code" 16 | }, 17 | "flask": null, 18 | "django": { 19 | "id_web_configs": "MS_ID_WEB_CONFIGS", 20 | "auth_endpoints": { 21 | "prefix": "auth", 22 | "sign_in": "sign_in", 23 | "edit_profile": "edit_profile", 24 | "redirect": "redirect", 25 | "sign_out": "sign_out", 26 | "post_sign_out": "post_sign_out" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /aad.flask.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": { 3 | "client_type": "CONFIDENTIAL", 4 | "authority_type": "SINGLE_TENANT", 5 | "framework": "FLASK" 6 | }, 7 | "client": { 8 | "client_id": "enter-your-client-id-here", 9 | "client_credential": "enter-your-client-secret-here", 10 | "authority": "https://login.microsoftonline.com/enter-your-tenant-id-here" 11 | }, 12 | "auth_request": { 13 | "redirect_uri": null, 14 | "scopes": [], 15 | "response_type": "code" 16 | }, 17 | "flask": { 18 | "id_web_configs": "MS_ID_WEB_CONFIGS", 19 | "auth_endpoints": { 20 | "prefix": "/auth", 21 | "sign_in": "/sign_in", 22 | "edit_profile": "/edit_profile", 23 | "redirect": "/redirect", 24 | "sign_out": "/sign_out", 25 | "post_sign_out": "/post_sign_out" 26 | } 27 | }, 28 | "django": null 29 | } -------------------------------------------------------------------------------- /ms_identity_web/__init__.py: -------------------------------------------------------------------------------- 1 | from msal import ConfidentialClientApplication, SerializableTokenCache 2 | 3 | from uuid import uuid4 4 | from logging import Logger 5 | from typing import Any 6 | from functools import wraps 7 | from .context import IdentityContextData 8 | from .constants import * 9 | from .adapters import IdentityWebContextAdapter 10 | from .errors import * 11 | 12 | # TODO: 13 | # ##### IMPORTANT ##### 14 | # features: 15 | # - do configurations work on multi-threaded flask environment? if not, attach them to current_app. configurations aren't stateful so this may be a moot point? 16 | # - edit profile interaction required error on edit profile if no token_cache or expired? 17 | # - password reset should use login hint/no interaction? 18 | # - decorator for filter by security groups 19 | # - decorator for app roles RBAC 20 | # - auth failure handler to handle un-auth access? 21 | # - implement more client_type options (single and multi tenant) 22 | # - define django adapter: factor common adapter methods to a parent class that flask and django adapters inherit 23 | # 24 | # code quality: 25 | # - more try catch blocks around sensitive failure-prone methods for gracefule error-handling 26 | # 27 | # ###### LOWER PRIORITY ########## 28 | # - remove any print statements 29 | # - replace is comparators with == ? 30 | # - cleanup / refactor constants file 31 | 32 | def require_context_adapter(f): 33 | @wraps(f) 34 | def assert_adapter(self, *args, **kwargs): 35 | if not isinstance(self._adapter, IdentityWebContextAdapter) or not self._adapter.has_context: 36 | if self._logger: 37 | self._logger.info(f"{self.__class__.__name__}.{f.__name__}: invalid adapter or no request context, aborting") 38 | else: 39 | print(f"{self.__class__.__name__}.{f.__name__}: invalid adapter or no request context, aborting") 40 | return f(self, *args, **kwargs) 41 | return assert_adapter 42 | 43 | class IdentityWebPython(object): 44 | 45 | def __init__(self, aad_config: 'AADConfig', adapter: IdentityWebContextAdapter = None, logger: Logger = None) -> None: 46 | self._logger = logger or Logger('IdentityWebPython') 47 | self._adapter = None 48 | self.aad_config = aad_config 49 | if adapter is not None: 50 | self.set_adapter(adapter) 51 | 52 | @property 53 | @require_context_adapter 54 | def id_data(self) -> IdentityContextData: 55 | return self._adapter.identity_context_data 56 | 57 | # TODO: make the call from the adapter to this and reverse the config process? 58 | def set_adapter(self, adapter: IdentityWebContextAdapter) -> None: 59 | # if isinstance(adapter, FlaskContextAdapter): 60 | self._adapter = adapter 61 | adapter.attach_identity_web_util(self) 62 | # else: 63 | # raise NotImplementedError(f"Currently, only the following adapters are supoprted: FlaskContextAdapter") 64 | 65 | def set_logger(self, logger: Logger) -> None: 66 | self._logger = logger 67 | 68 | def _client_factory(self, token_cache: SerializableTokenCache = None, b2c_policy: str = None, **msal_client_kwargs) -> ConfidentialClientApplication: 69 | client_config = self.aad_config.client.__dict__.copy() # need to make a copy since contents must be mutated 70 | client_config['authority'] = f'{self.aad_config.client.authority}{b2c_policy or ""}' 71 | if token_cache: 72 | client_config['token_cache'] = token_cache 73 | client_config.update(**msal_client_kwargs) 74 | 75 | return ConfidentialClientApplication(**client_config) 76 | 77 | @require_context_adapter 78 | def get_auth_url(self, redirect_uri:str = None, b2c_policy: str = None, **msal_auth_url_kwargs): 79 | """ Gets the auth URL that the user must be redirected to. Automatically 80 | configures B2C if app type is set to B2C.""" 81 | auth_req_options = self.aad_config.auth_request.__dict__.copy() 82 | auth_req_options.update(**msal_auth_url_kwargs) 83 | if redirect_uri: 84 | auth_req_options['redirect_uri'] = redirect_uri 85 | self._generate_and_append_state_to_context_and_request(auth_req_options) 86 | 87 | if self.id_data.authenticated: 88 | auth_req_options['login_hint'] = self.id_data._id_token_claims.get('preferred_username', None) 89 | 90 | if self.aad_config.type.authority_type == str(AuthorityType.B2C): 91 | if not b2c_policy: 92 | b2c_policy = self.aad_config.b2c.susi 93 | self._adapter.identity_context_data.last_used_b2c_policy = b2c_policy 94 | return self._client_factory(b2c_policy=b2c_policy).get_authorization_request_url(**auth_req_options) 95 | 96 | return self._client_factory().get_authorization_request_url(**auth_req_options) 97 | 98 | @require_context_adapter 99 | def process_auth_redirect(self, redirect_uri: str = None, response_type: str = None, afterwards_go_to_url: str = None) -> Any: 100 | req_params = self._adapter.get_request_params_as_dict() # grab the incoming request params 101 | try: 102 | # CSRF protection: make sure to check that state matches the one placed in the session in the previous step. 103 | # This check ensures this app + this same user session made the /authorize request that resulted in this redirect 104 | # This should always be the first thing verified on redirect. 105 | self._verify_state(req_params) 106 | 107 | self._logger.info("process_auth_redirect: state matches. continuing.") 108 | self._parse_redirect_errors(req_params) 109 | self._logger.info("process_auth_redirect: no errors found in request params. continuing.") 110 | 111 | # get the response_type that was requested, and extract the payload: 112 | resp_type = response_type or self.aad_config.auth_request.response_type or str(ResponseType.CODE) 113 | payload = self._extract_auth_response_payload(req_params, resp_type) 114 | cache = self._adapter.identity_context_data.token_cache 115 | redirect_uri = redirect_uri or self.aad_config.auth_request.redirect_uri or None 116 | 117 | if resp_type == str(ResponseType.CODE): # code request is default for msal-python if there is no response type specified 118 | # we should have a code. Now we must exchange the code for tokens. 119 | result = self._x_change_auth_code_for_token(payload, cache, redirect_uri) 120 | else: 121 | raise NotImplementedError(f"response_type {resp_type} is not yet implemented by ms_identity_web_python") 122 | self._process_result(result, cache) 123 | # self._verify_nonce() # one of the last steps TODO - is this required? msal python takes care of it? 124 | except AuthSecurityError as ase: 125 | self.remove_user() 126 | self._logger.error(f"process_auth_redirect: security violation {ase.args}") 127 | raise ase 128 | except OtherAuthError as oae: 129 | self.remove_user() 130 | self._logger.error(f"process_auth_redirect: other auth error {oae.args}") 131 | raise oae 132 | except B2CPasswordError as b2cpwe: 133 | self.remove_user() 134 | self._logger.error(f"process_auth_redirect: b2c pwd {b2cpwe.args}") 135 | pw_reset_url = self.get_auth_url(redirect_uri=redirect_uri, b2c_policy = self.aad_config.b2c.password) 136 | return self._adapter.redirect_to_absolute_url(pw_reset_url) 137 | # don't raise 138 | except TokenExchangeError as ter: 139 | self.remove_user() 140 | self._logger.error(f"process_auth_redirect: token xchange {ter.args}") 141 | raise ter 142 | except BaseException as other: 143 | self.remove_user() 144 | self._logger.error(f"process_auth_redirect: unknown error{other.args}") 145 | raise other 146 | 147 | #TODO: GET /auth/redirect?error=interaction_required&error_description=AADB2C90077%3a+User+does+not+have+an+existing+session+and+request+prompt+parameter+has+a+value+of+%27None%27. 148 | self._logger.info("process_auth_redirect: exiting auth code method. redirecting... ") 149 | return self._adapter.redirect_to_absolute_url(afterwards_go_to_url) 150 | 151 | @require_context_adapter 152 | def _x_change_auth_code_for_token(self, code: str, token_cache: SerializableTokenCache = None, redirect_uri = None) -> dict: 153 | # use the same policy that got us here: depending on /authorize request initiation 154 | id_context = self._adapter.identity_context_data 155 | if self.aad_config.type.authority_type == str(AuthorityType.B2C): 156 | b2c_policy = id_context.last_used_b2c_policy or self.aad_config.b2c.susi 157 | client = self._client_factory(token_cache=token_cache, b2c_policy=b2c_policy) 158 | else: 159 | client = self._client_factory(token_cache=token_cache) 160 | 161 | result = client.acquire_token_by_authorization_code(code, 162 | self.aad_config.auth_request.scopes, 163 | redirect_uri, 164 | id_context.nonce) 165 | return result 166 | 167 | @require_context_adapter 168 | def acquire_token_silently(self, scopes=None, account=None, authority=None, token_cache=None, **kwargs): 169 | # the params take precedence over settings file. 170 | id_data = self.id_data 171 | token_cache = token_cache or id_data.token_cache 172 | client = self._client_factory(token_cache=token_cache) 173 | 174 | silent_opts = dict() 175 | silent_opts.update(**kwargs) 176 | silent_opts['scopes'] = scopes or self.aad_config.auth_request.scopes 177 | silent_opts['account'] = account or client.get_accounts()[0] 178 | 179 | result = client.acquire_token_silent_with_error(**silent_opts) 180 | 181 | self._process_result(result, token_cache) 182 | 183 | @require_context_adapter 184 | def _process_result(self, result: dict, token_cache: SerializableTokenCache) -> None: 185 | if "error" not in result: 186 | self._logger.debug("process result: successful token response result!") 187 | # now we will place the token(s) and auth status into the context for later use: 188 | # self._logger.debug(json.dumps(result, indent=4, sort_keys=True)) 189 | id_context = self._adapter.identity_context_data 190 | id_context.authenticated = True 191 | if 'id_token_claims' in result: 192 | id_context._id_token_claims = result['id_token_claims'] # TODO: if this is to stay in ctxt, use proper getter/setter 193 | id_context.username = id_context._id_token_claims.get('name', 'anonymous') 194 | if 'access_token' in result: 195 | id_context._access_token = result['access_token'] 196 | id_context.has_changed = True #TODO: update id_context to automatically do this when _id_token and accesstoken is assigned!!!! 197 | id_context.token_cache = token_cache 198 | else: 199 | raise TokenExchangeError("_process_result: auth failed: token request resulted in error\n" 200 | f"{result['error']}: {result.get('error_description', None)}") 201 | 202 | def _parse_redirect_errors(self, req_params: dict) -> None: 203 | # TODO implement all errors which affect program behaviour 204 | # https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow 205 | if str(AADErrorResponse.ERROR_CODE_PARAM_KEY) in req_params: 206 | # we have an error. get the error code to interpret it: 207 | error_code = req_params.get(str(AADErrorResponse.ERROR_CODE_PARAM_KEY), None) 208 | if error_code.startswith(str(AADErrorResponse.B2C_FORGOT_PASSWORD_ERROR_CODE)): 209 | # it's a b2c password reset error 210 | raise B2CPasswordError("B2C password reset request") 211 | else: 212 | # ??? TODO: add more error types 213 | raise OtherAuthError("Unknown error while parsing redirect") 214 | 215 | def _extract_auth_response_payload(self, req_params: dict, expected_response_type: str) -> str: 216 | if expected_response_type == str(ResponseType.CODE): 217 | # if no response type in config, default response type of 'code' will have been assumed. 218 | return req_params.get(str(ResponseType.CODE), None) 219 | else: 220 | raise NotImplementedError("Only 'code' response is currently supported by identity utils") 221 | 222 | @require_context_adapter 223 | def sign_out(self, post_sign_out_url:str = None, username: str = None) -> Any: 224 | authority_type = self.aad_config.type.authority_type 225 | sign_out_url = self.aad_config.client.authority 226 | if authority_type == str(AuthorityType.B2C): 227 | sign_out_url = f'{sign_out_url}{self.aad_config.b2c.susi}{SignOut.ENDPOINT.value}' 228 | else: 229 | sign_out_url = f'{sign_out_url}{SignOut.ENDPOINT.value}' 230 | 231 | if post_sign_out_url: 232 | sign_out_url = f'{sign_out_url}?{SignOut.REDIRECT_PARAM_KEY.value}={post_sign_out_url}' 233 | return self._adapter.redirect_to_absolute_url(sign_out_url) 234 | 235 | @require_context_adapter 236 | def remove_user(self, username: str = None) -> None: #TODO: complete this so it doesn't just clear the session but removes user 237 | self._adapter.clear_session() 238 | # TODO e.g. if active username in id_context_'s username is not anonymous, remove it 239 | # remove id token 240 | # remote AT 241 | # remove token_cache 242 | # TODO: set auth_state_changed flag here 243 | 244 | @require_context_adapter 245 | def _generate_and_append_state_to_context_and_request(self, req_param_dict: dict) -> str: 246 | state = str(uuid4()) 247 | req_param_dict[RequestParameter.STATE.value] = state 248 | self._adapter.identity_context_data.state = state 249 | return state 250 | 251 | @require_context_adapter 252 | def _verify_state(self, req_params: dict) -> None: 253 | state = req_params.get('state', None) 254 | session_state = self._adapter.identity_context_data.state 255 | # don't allow re-use of state 256 | self._adapter.identity_context_data.state = None 257 | # reject states that don't match 258 | if state is None or session_state != state: 259 | raise AuthSecurityError("Failed to match request state with session state") 260 | 261 | @require_context_adapter 262 | def _generate_and_append_nonce_to_context_and_request(self, req_param_dict: dict) -> str: 263 | nonce = str(uuid4()) 264 | req_param_dict[RequestParameter.NONCE.value] = nonce 265 | self._adapter.identity_context_data.nonce = nonce 266 | return nonce 267 | 268 | @require_context_adapter 269 | def _verify_nonce(self, req_params: dict) -> None: 270 | nonce = req_params.get('nonce', None) 271 | session_nonce = self._adapter.identity_context_data.nonce 272 | # don't allow re-use of nonce 273 | self._adapter.identity_context_data.nonce = None 274 | # reject nonces that don't match 275 | if nonce is None or session_nonce != nonce: 276 | raise AuthSecurityError("Failed to match ID token nonce with session nonce") 277 | 278 | # TODO: enforce ID token expiry. 279 | # @decorator to ensure the user is authenticated 280 | # wrap this around your route 281 | def login_required(self,f): 282 | @wraps(f) 283 | def assert_login(*args, **kwargs): 284 | if not self._adapter.identity_context_data.authenticated: 285 | raise NotAuthenticatedError 286 | # TODO: check if ID token is expired 287 | # if it is, take user to get re-authenticated. 288 | # TODO: upon returning from re-auth, user should get back to 289 | # where they were trying to go. 290 | return f(*args, **kwargs) 291 | return assert_login 292 | 293 | 294 | -------------------------------------------------------------------------------- /ms_identity_web/adapters.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | try: 3 | from flask import ( 4 | Flask as flask_app, 5 | has_request_context as flask_has_request_context, 6 | session as flask_session, 7 | request as flask_request, 8 | redirect as flask_redirect, 9 | g as flask_g, 10 | url_for as flask_url_for 11 | ) 12 | from .flask_blueprint import FlaskAADEndpoints # this is where our auth-related endpoints are defined 13 | except: 14 | pass 15 | 16 | from .context import IdentityContextData 17 | from typing import Any 18 | from functools import wraps 19 | 20 | # decorator to make sure access within request context 21 | def require_request_context(f): 22 | @wraps(f) 23 | def assert_context(self, *args, **kwargs): 24 | if not flask_has_request_context(): 25 | self.logger.info(f"{self.__class__.__name__}.{f.__name__}: No request context, aborting") 26 | else: 27 | return f(self, *args, **kwargs) 28 | return assert_context 29 | 30 | class IdentityWebContextAdapter(metaclass=ABCMeta): 31 | """Context Adapter abstract base class. Extend this to enable IdentityWebPython to 32 | work within any environment (e.g. Flask, Django, Windows Desktop app, etc) """ 33 | @abstractmethod 34 | def __init__(self) -> None: 35 | pass 36 | 37 | @abstractmethod 38 | def _on_request_init(self) -> None: 39 | pass 40 | 41 | @abstractmethod 42 | def _on_request_end(self) -> None: 43 | pass 44 | 45 | # TODO: make this dictionary key name configurable on app init 46 | @abstractmethod 47 | def attach_identity_web_util(self, identity_web: 'IdentityWebPython') -> None: 48 | pass 49 | 50 | @abstractmethod # @property 51 | def has_context(self) -> bool: 52 | pass 53 | 54 | @abstractmethod # @property 55 | @require_request_context 56 | def identity_context_data(self) -> 'IdentityContextData': 57 | pass 58 | 59 | 60 | @abstractmethod # @property 61 | @require_request_context 62 | def session(self) -> None: 63 | # TODO: set session attr so can concrete implement here? 64 | pass 65 | 66 | @abstractmethod 67 | @require_request_context 68 | def clear_session(self) -> None: 69 | # TODO: clear ONLY msidweb session stuff 70 | # TODO: concrete implement here instead? 71 | pass 72 | 73 | @require_request_context 74 | def get_value_from_session(self, key: str, default: Any = None) -> Any: 75 | return self.session.get(key, default) 76 | 77 | 78 | @require_request_context 79 | def get_request_param(self, key: str, default: Any = None) -> Any: 80 | return self._get_request_params_as_dict(key, default) 81 | 82 | @abstractmethod 83 | @require_request_context 84 | def redirect_to_absolute_url(self, absolute_url: str) -> None: 85 | #TODO: set attr redirect on init, so concrete method can be used for all frmwrks 86 | pass 87 | 88 | @abstractmethod 89 | @require_request_context 90 | def get_request_params_as_dict(self) -> dict: 91 | pass 92 | 93 | @abstractmethod 94 | @require_request_context 95 | def _deserialize_identity_context_data_from_session(self) -> 'IdentityContextData': 96 | pass 97 | 98 | @abstractmethod 99 | @require_request_context 100 | def _serialize_identity_context_data_to_session(self) -> None: 101 | pass 102 | 103 | class FlaskContextAdapter(IdentityWebContextAdapter): 104 | """Context Adapter to enable IdentityWebPython to work within the Flask environment""" 105 | def __init__(self, app) -> None: 106 | assert isinstance(app, flask_app) 107 | super().__init__() 108 | self.app = app 109 | with self.app.app_context(): 110 | self.logger = app.logger 111 | app.before_request(self._on_request_init) 112 | app.after_request(self._on_request_end) 113 | 114 | @property 115 | @require_request_context 116 | def identity_context_data(self) -> 'IdentityContextData': 117 | # TODO: make the key name configurable 118 | self.logger.debug("Getting identity_context from g") 119 | identity_context_data = flask_g.get(IdentityContextData.SESSION_KEY) 120 | if not identity_context_data: 121 | identity_context_data = self._deserialize_identity_context_data_from_session() 122 | setattr(flask_g, IdentityContextData.SESSION_KEY, identity_context_data) 123 | return identity_context_data 124 | 125 | # method is called when flask gets an app/request context 126 | # Flask-specific startup here? 127 | def _on_request_init(self) -> None: 128 | try: 129 | idx = self.identity_context_data # initialize it so it is available to request context 130 | except Exception as ex: 131 | self.logger.error(f'Adapter failed @ _on_request_init\n{ex}') 132 | 133 | # this is for saving any changes to the identity_context_data 134 | def _on_request_end(self, response_to_return=None) -> None: 135 | try: 136 | if IdentityContextData.SESSION_KEY in flask_g: 137 | self._serialize_identity_context_data_to_session() 138 | except Exception as ex: 139 | self.logger.error(f'flask adapter failed @ _on_request_ended\n{ex}') 140 | 141 | return response_to_return 142 | 143 | # TODO: order is reveresed? create id web first, then attach flask adapter to it!? 144 | def attach_identity_web_util(self, identity_web: 'IdentityWebPython') -> None: 145 | """attach the identity web instance to session so it is accessible everywhere. 146 | e.g., ms_id_web = current_app.config.get("ms_identity_web")\n 147 | Also attaches the application logger.""" 148 | aad_config = identity_web.aad_config 149 | config_key = aad_config.flask.id_web_configs 150 | 151 | with self.app.app_context(): 152 | self.app.config[config_key] = aad_config 153 | 154 | identity_web.set_logger(self.logger) 155 | auth_endpoints = FlaskAADEndpoints(identity_web) 156 | self.app.context_processor(lambda: dict(ms_id_url_for=auth_endpoints.url_for)) 157 | self.app.register_blueprint(auth_endpoints) 158 | 159 | @property 160 | def has_context(self) -> bool: 161 | return flask_has_request_context() 162 | 163 | ### not sure if this method needs to be public yet :/ 164 | @property 165 | @require_request_context 166 | def session(self) -> None: 167 | return flask_session 168 | 169 | # TODO: only clear IdWebPy vars 170 | @require_request_context 171 | def clear_session(self) -> None: 172 | """this function clears the session and refreshes context. TODO: only clear IdWebPy vars""" 173 | self.identity_context_data.clear() 174 | 175 | @require_request_context 176 | def redirect_to_absolute_url(self, absolute_url: str) -> None: 177 | """this function redirects to an absolute url""" 178 | return flask_redirect(absolute_url) 179 | 180 | @require_request_context 181 | def get_request_params_as_dict(self) -> dict: 182 | """this function returns the params dict from any flask request""" 183 | try: 184 | # this is query and form-post params merged, 185 | # preferring query param if there is a key collision 186 | return flask_request.values 187 | except: 188 | if self.logger is not None: 189 | self.logger.warning("failed to get param dict from request, substituting empty dict instead") 190 | return dict() 191 | 192 | # does this need to be public method? 193 | @require_request_context 194 | def _deserialize_identity_context_data_from_session(self) -> 'IdentityContextData': 195 | new_id_context_data = IdentityContextData() 196 | try: 197 | id_context_from_session = self.session.get(IdentityContextData.SESSION_KEY, dict()) 198 | new_id_context_data.__dict__.update(id_context_from_session) 199 | except Exception as exception: 200 | self.logger.warning(f"failed to deserialize identity context from session: creating empty one\n{exception}") 201 | return new_id_context_data 202 | 203 | # does this need to be public method? 204 | @require_request_context 205 | def _serialize_identity_context_data_to_session(self) -> None: 206 | try: 207 | identity_context = self.identity_context_data 208 | if identity_context.has_changed: 209 | identity_context.has_changed = False 210 | identity_context = identity_context.__dict__ 211 | self.session[IdentityContextData.SESSION_KEY] = identity_context 212 | except Exception as exception: 213 | self.logger.error(f"failed to serialize identity context to session.\n{exception}") 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | # the following class is incomplete 223 | class DjangoContextAdapter(object): 224 | """Context Adapter to enable IdentityWebPython to work within the Django environment""" 225 | def __init__(self): 226 | raise NotImplementedError("not yet implemented") 227 | 228 | # method is called when getting app/request context 229 | def _on_context_init(self) -> None: 230 | self._has_context = True 231 | 232 | def _on_context_teardown(self, exception) -> None: 233 | self._has_context = False 234 | if self.identity_context_data.has_changed: 235 | self.identity_context_data._save_to_session() 236 | 237 | # this function returns Django's request params. 238 | @require_request_context 239 | def get_request_params_as_dict(self, request: 'request' = None) -> dict: 240 | try: 241 | if request.method == "GET": 242 | return request.GET.dict() 243 | elif request.method == "POST" : 244 | return request.POST.dict() 245 | else: 246 | raise ValueError("Django request must be POST or GET") 247 | except: 248 | if self.logger is not None: 249 | self.logger.warning("Failed to get param dict, substituting empty dict instead") 250 | return dict() 251 | -------------------------------------------------------------------------------- /ms_identity_web/configuration.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | import os 3 | from .constants import AuthorityType, ClientType 4 | from types import SimpleNamespace 5 | 6 | class AADConfig(SimpleNamespace): # faster access to attributes with slots. 7 | @staticmethod 8 | def parse_json(file_path: str): 9 | import json 10 | from types import SimpleNamespace 11 | with open(file_path, 'r') as cfg: 12 | parsed_config = json.load(cfg, object_hook=lambda d: SimpleNamespace(**d)) 13 | AADConfig.sanity_check_configs(parsed_config) 14 | return parsed_config 15 | 16 | @staticmethod 17 | def parse_yml(file_path: str): 18 | raise NotImplementedError 19 | try: 20 | import yaml 21 | except: 22 | print("can't import yaml") 23 | raise ImportError 24 | 25 | @staticmethod 26 | def sanity_check_configs(parsed_config) -> None: 27 | required = ('type', 'client', 'auth_request') 28 | for req in required: assert hasattr(parsed_config, req) 29 | assert (parsed_config.flask or parsed_config.django) 30 | 31 | assert ClientType.has_key(parsed_config.type.client_type), "'client_type' must be non-empty string" 32 | assert AuthorityType.has_key(parsed_config.type.authority_type), "'authority_type' must be non-empty string" 33 | assert parsed_config.type.framework == 'FLASK' or parsed_config.type.framework == 'DJANGO', "only FLASK & DJANGO supported right now" 34 | 35 | assert str(parsed_config.client.client_id), "'client_id' must be non-empty string" 36 | assert str(parsed_config.client.authority), "'authority' must be non-empty string" 37 | 38 | required = ['redirect_uri', 'scopes', 'response_type'] 39 | for req in required: assert hasattr(parsed_config.auth_request, req) 40 | 41 | # if ClientType(parsed_config.type.client_type) is ClientType.CONFIDENTIAL: 42 | # assert parsed_config.client.client_credential, ( 43 | # "'client_credential' must be non-empty string if " 44 | # "'client_type' is ClientType.CONFIDENTIAL") 45 | 46 | if AuthorityType(parsed_config.type.authority_type) is AuthorityType.B2C: 47 | assert parsed_config.b2c, ( 48 | "config must contain 'b2c' section if 'authority_type' is AuthorityType.B2C") 49 | 50 | # assert b2c has required keys: 51 | required_keys = ['susi','password', 'profile'] 52 | for key in required_keys: 53 | assert getattr(parsed_config.b2c, key).startswith('/'), ( 54 | f"`{key}` value under b2c must be non-empty string if " 55 | "'authority_type' is AuthorityType.B2C") 56 | else: 57 | setattr(parsed_config, 'b2c', None) 58 | 59 | if parsed_config.type.framework == 'FLASK': 60 | assert parsed_config.flask.id_web_configs 61 | required_keys = ['prefix', 'sign_in', 'edit_profile', 'redirect', 'sign_out', 'post_sign_out'] 62 | for key in required_keys: 63 | assert getattr(parsed_config.flask.auth_endpoints, key).startswith('/'), ( 64 | f"The `{key}` value under 'flask.auth_endpoints must be string starting with / if " 65 | "'framework' is FLASK") 66 | elif parsed_config.type.framework == 'DJANGO': 67 | assert parsed_config.django.id_web_configs 68 | required_keys = ['prefix', 'sign_in', 'edit_profile', 'redirect', 'sign_out', 'post_sign_out'] 69 | for key in required_keys: 70 | assert getattr(parsed_config.django.auth_endpoints, key), (f"The `{key}` value under 'django.auth_endpoints'" 71 | "must be non-empty string if 'framework' is DJANGO") 72 | -------------------------------------------------------------------------------- /ms_identity_web/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | # class Policy(Enum): 4 | # def __str__(self): 5 | # return str(self.value) 6 | # @staticmethod 7 | # def set_enum_values(config: dict): 8 | # Policy.SIGN_UP_SIGN_IN = config['susi'] 9 | # Policy.PASSWORD_RESET = config[str(Policy.PASSWORD_KEY)] 10 | # Policy.EDIT_PROFILE = config[str(Policy.PROFILE_KEY)] 11 | # SUSI_KEY = 'susi' 12 | # PASSWORD_KEY = 'password' 13 | # PROFILE_KEY = 'profile' 14 | # NONE = '' 15 | 16 | ### AZURE AD AUTH OPTIONS ### 17 | class ResponseType(Enum): 18 | def __str__(self): 19 | return str(self.value) 20 | PARAM_KEY = 'response_type' 21 | CODE = 'code' # this is the default ResponseType used by MSAL Python 22 | TOKEN = 'token' 23 | ID_TOKEN = 'id_token' 24 | ID_TOKEN_TOKEN = 'id_token token' 25 | CODE_TOKEN = 'code token' 26 | CODE_ID_TOKEN_TOKEN = 'code id_token token' 27 | NONE = 'none' 28 | 29 | class ResponseMode(Enum): 30 | def __str__(self): 31 | return str(self.value) 32 | QUERY = 'query' # this is the default ResponseMode for ResponseType.CODE 33 | FRAGMENT = 'fragment' 34 | FORM_POST = 'form_post' 35 | 36 | 37 | class RequestParameter(Enum): 38 | def __str__(self): 39 | return str(self.value) 40 | RESPONSE_TYPE = 'response_type' 41 | PROMPT = 'prompt' 42 | REDIRECT_URI = 'redirect_uri' 43 | STATE = 'state' 44 | NONCE = 'nonce' 45 | SCOPE = 'scope' 46 | CLIENT_ID = 'client_id' 47 | 48 | 49 | class Prompt(Enum): 50 | def __str__(self): 51 | return str(self.value) 52 | PARAM_KEY = 'prompt' 53 | LOGIN = 'login' # causes user to re-enter credentials even if logged in already - negates sso 54 | NONE = 'none' # opposite of prompt=login - no prompt displayed if user is already logged in 55 | SELECT_ACCOUNT = 'select_account' # user must select their account from picker 56 | CONSENT = 'consent' # user is asked for consent, even if they have given it previously 57 | 58 | ### Client Type ### 59 | class ClientType(Enum): 60 | def __str__(self): 61 | return str(self.value) 62 | @classmethod 63 | def has_key(cls, name): 64 | return name in cls.__members__ 65 | CONFIDENTIAL = 'CONFIDENTIAL' 66 | PUBLIC = 'B2C' 67 | 68 | 69 | ### Client Tenant Type ### 70 | class AuthorityType(Enum): 71 | def __str__(self): 72 | return str(self.value) 73 | @classmethod 74 | def has_key(cls, name): 75 | return name in cls.__members__ 76 | SINGLE_TENANT = 'SINGLE_TENANT' 77 | MULTI_TENANT = 'MULTI_TENANT' 78 | B2C = 'B2C' 79 | 80 | 81 | ### AZURE ACTIVE DIRECTORY ERROR HANDLING CONSTANTS ### 82 | class AADErrorResponse(Enum): 83 | def __str__(self): 84 | return str(self.value) 85 | #AAD B2C forgot password error code, found in description of redirect request: 86 | B2C_FORGOT_PASSWORD_ERROR_CODE='AADB2C90118' 87 | #The parameter under which the error codes are found (in requests to redirect endpoint): 88 | ERROR_CODE_PARAM_KEY='error_description' 89 | #The parameter that indicates error: 90 | ERROR_PARAM_KEY='error' 91 | 92 | 93 | ### AZURE ACTIVE DIRECTORY SIGN-OUT ### 94 | class SignOut(Enum): 95 | # The AAD endpoint to log your user out 96 | ENDPOINT = '/oauth2/v2.0/logout' 97 | # post-logout param key that tells AAD to redirect the user back to the app 98 | REDIRECT_PARAM_KEY = f'post_logout_redirect_uri' 99 | 100 | 101 | -------------------------------------------------------------------------------- /ms_identity_web/context.py: -------------------------------------------------------------------------------- 1 | from msal import SerializableTokenCache 2 | import json 3 | 4 | # TODO: make this a @dataclass ? 5 | 6 | class IdentityContextData(object): 7 | SESSION_KEY='identity_context_data' #TODO: make configurable 8 | 9 | def __init__(self) -> None: 10 | self.clear() 11 | self.has_changed = False 12 | 13 | def clear(self) -> None: 14 | self._authenticated = False 15 | self._username = "anonymous" 16 | self._token_cache = None 17 | self._nonce = None 18 | self._state = None 19 | self._id_token_claims = {} # does this belong here? yes, Token/claims customization. TODO: if it does, add getter/setter, # ID tokens aren't cached so store this here? 20 | self._access_token = None 21 | self._last_used_b2c_policy = [] 22 | self._post_sign_in_url = None 23 | self.has_changed = True 24 | 25 | @property 26 | def authenticated(self) -> bool: 27 | return self._authenticated 28 | 29 | @authenticated.setter 30 | def authenticated(self, value: bool) -> None: 31 | self._authenticated = value 32 | self.has_changed = True 33 | 34 | @property 35 | def username(self) -> str: 36 | return self._username 37 | 38 | @username.setter 39 | def username(self, value: str) -> None: 40 | self._username = value 41 | self.has_changed = True 42 | 43 | @property 44 | def token_cache(self) -> str: 45 | cache = SerializableTokenCache() 46 | if self._token_cache: 47 | cache.deserialize(self._token_cache) 48 | return cache 49 | 50 | @token_cache.setter 51 | def token_cache(self, value: SerializableTokenCache) -> None: 52 | if value.has_state_changed: 53 | self._token_cache = value.serialize() 54 | self.has_changed = True 55 | 56 | @property 57 | def state(self) -> str: 58 | return self._state 59 | 60 | @state.setter 61 | def state(self, value: str) -> None: 62 | self._state = value 63 | self.has_changed = True 64 | 65 | @property 66 | def nonce(self) -> str: 67 | return self._nonce 68 | 69 | @nonce.setter 70 | def nonce(self, value: str) -> None: 71 | self._nonce = value 72 | self.has_changed = True 73 | 74 | # TODO: talk to MSIDWEB team 75 | # or browse the code about how to implement the following: 76 | @property 77 | def last_used_b2c_policy(self) -> str: 78 | if len(self._last_used_b2c_policy): 79 | return self._last_used_b2c_policy.pop() 80 | return None 81 | 82 | @last_used_b2c_policy.setter 83 | def last_used_b2c_policy(self, value: str) -> None: 84 | self._last_used_b2c_policy = [value] 85 | self.has_changed = True 86 | 87 | @property 88 | def post_sign_in_url(self) -> str: 89 | return self._post_sign_in_url 90 | 91 | @post_sign_in_url.setter 92 | def post_sign_in_url(self, value: str) -> None: 93 | self._post_sign_in_url = value 94 | self.has_changed = True 95 | -------------------------------------------------------------------------------- /ms_identity_web/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/ms-identity-python-samples-common/9b32cef36c6c0b73c1b3237fdd66128d89f90a62/ms_identity_web/django/__init__.py -------------------------------------------------------------------------------- /ms_identity_web/django/adapter.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ms_identity_web import IdentityWebPython 3 | from ms_identity_web.context import IdentityContextData 4 | from ms_identity_web.adapters import IdentityWebContextAdapter 5 | from django.http.request import HttpRequest as DjangoHttpRequest 6 | from django.shortcuts import redirect as django_redirect 7 | import logging 8 | except: 9 | pass 10 | 11 | class DjangoContextAdapter(IdentityWebContextAdapter): 12 | """Context Adapter to enable IdentityWebPython to work within the Django environment""" 13 | def __init__(self, request: 'DjangoHttpRequest') -> None: 14 | # TODO: remove the following and add a middleware loaded before this one for global request/session context? 15 | self.request = request 16 | self._session = request.session 17 | self.logger = logging.getLogger('MsalMiddleWareLogger') 18 | 19 | @property 20 | def identity_context_data(self) -> 'IdentityContextData': 21 | # TODO: make the key name configurable 22 | self.logger.debug("Getting identity_context from request/session") 23 | identity_context_data = getattr(self.request, IdentityContextData.SESSION_KEY, None) 24 | if not identity_context_data: 25 | identity_context_data = self._deserialize_identity_context_data_from_session() 26 | setattr(self.request, IdentityContextData.SESSION_KEY, identity_context_data) 27 | return identity_context_data 28 | 29 | def _on_request_init(self) -> None: 30 | try: 31 | idx = self.identity_context_data # initialize it so it is available to request context 32 | except Exception as ex: 33 | self.logger.error(f'MsalMiddleware failed @ _on_request_init\n{ex}') 34 | 35 | # this is for saving any changes to the identity_context_data 36 | def _on_request_end(self) -> None: 37 | try: 38 | if getattr(self.request, IdentityContextData.SESSION_KEY, None): 39 | self._serialize_identity_context_data_to_session() 40 | except Exception as ex: 41 | self.logger.error(f'MsalMiddleware failed @ _on_request_ended\n{ex}') 42 | 43 | # TODO: order is reveresed? create id web first, then attach django adapter to it!? 44 | def attach_identity_web_util(self, identity_web: 'IdentityWebPython') -> None: 45 | """attach the identity web instance somewhere so it is accessible everywhere. 46 | e.g., ms_identity_web = current_app.config.get("ms_identity_web")\n 47 | Also attaches the application logger.""" 48 | aad_config = identity_web.aad_config 49 | config_key = aad_config.django.id_web_configs 50 | 51 | setattr(self.request, config_key, aad_config) 52 | 53 | @property 54 | def has_context(self) -> bool: 55 | return True # TODO: remove this? not relevant for django? 56 | 57 | @property 58 | def session(self) -> None: 59 | return self._session 60 | 61 | # TODO: only clear IdWebPy vars 62 | def clear_session(self) -> None: 63 | """this function clears the session and refreshes context. TODO: only clear IdWebPy vars""" 64 | # TODO: clear ONLY msidweb session stuff 65 | self.session.flush() 66 | 67 | def redirect_to_absolute_url(self, absolute_url: str) -> None: 68 | """this function redirects to an absolute url""" 69 | return django_redirect(absolute_url) 70 | 71 | def get_request_params_as_dict(self) -> dict: 72 | try: 73 | if self.request.method == "GET": 74 | return self.request.GET.dict() 75 | elif self.request.method == "POST" : 76 | return self.request.POST.dict() 77 | else: 78 | raise ValueError("Django request must be POST or GET") 79 | except: 80 | if self.logger is not None: 81 | self.logger.warning("Failed to get param dict, substituting empty dict instead") 82 | return dict() 83 | 84 | # does this need to be public method? 85 | def _deserialize_identity_context_data_from_session(self) -> 'IdentityContextData': 86 | blank_id_context_data = IdentityContextData() 87 | try: 88 | id_context_from_session = self.session.get(IdentityContextData.SESSION_KEY, dict()) 89 | blank_id_context_data.__dict__.update(id_context_from_session) 90 | except Exception as exception: 91 | self.logger.warning(f"failed to deserialize identity context from session: creating empty one\n{exception}") 92 | return blank_id_context_data 93 | 94 | # does this need to be public method? 95 | def _serialize_identity_context_data_to_session(self) -> None: 96 | try: 97 | identity_context = self.identity_context_data 98 | if identity_context.has_changed: 99 | identity_context.has_changed = False 100 | identity_context = identity_context.__dict__ 101 | self.session[IdentityContextData.SESSION_KEY] = identity_context 102 | except Exception as exception: 103 | self.logger.error(f"failed to serialize identity context to session.\n{exception}") 104 | -------------------------------------------------------------------------------- /ms_identity_web/django/middleware.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ms_identity_web.errors import NotAuthenticatedError 3 | from django.conf import settings 4 | from django.shortcuts import render 5 | except: 6 | pass 7 | 8 | from .adapter import DjangoContextAdapter 9 | 10 | ms_identity_web = settings.MS_IDENTITY_WEB 11 | 12 | class MsalMiddleware: 13 | def __init__(self, get_response): 14 | self.get_response = get_response 15 | # One-time configuration and initialization. 16 | self.ms_identity_web = ms_identity_web 17 | 18 | def process_exception(self, request, exception): 19 | if isinstance(exception, NotAuthenticatedError): 20 | if hasattr(settings, 'ERROR_TEMPLATE'): 21 | return render(request, settings.ERROR_TEMPLATE.format(exception.code)) 22 | return None 23 | 24 | def __call__(self, request): 25 | # Code to be executed for each request before 26 | # the view (and later middleware) are called. 27 | 28 | django_context_adapter = DjangoContextAdapter(request) 29 | self.ms_identity_web.set_adapter(django_context_adapter) 30 | django_context_adapter._on_request_init() 31 | 32 | response = self.get_response(request) 33 | 34 | # Code to be executed for each request/response after 35 | # the view is called. 36 | 37 | django_context_adapter._on_request_end() 38 | 39 | return response -------------------------------------------------------------------------------- /ms_identity_web/django/msal_views_and_urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import path 3 | from django.shortcuts import redirect 4 | from django.urls import reverse 5 | except: 6 | pass 7 | import logging 8 | 9 | class MsalViews: 10 | def __init__(self, ms_identity_web): 11 | self.logger = logging.getLogger('MsalViewsLogger') 12 | self.ms_identity_web = ms_identity_web 13 | self.prefix = self.ms_identity_web.aad_config.django.auth_endpoints.prefix + "/" 14 | self.endpoints = self.ms_identity_web.aad_config.django.auth_endpoints 15 | 16 | def url_patterns(self): 17 | return [ 18 | path(self.endpoints.sign_in, self.sign_in, name=self.endpoints.sign_in), 19 | path(self.endpoints.edit_profile, self.edit_profile, name=self.endpoints.edit_profile), 20 | path(self.endpoints.redirect, self.aad_redirect, name=self.endpoints.redirect), 21 | path(self.endpoints.sign_out, self.sign_out, name=self.endpoints.sign_out), 22 | path(self.endpoints.post_sign_out, self.post_sign_out, name=self.endpoints.post_sign_out), 23 | ] 24 | 25 | def sign_in(self, request): 26 | self.logger.debug(f"{self.prefix}{self.endpoints.sign_in}: request received. will redirect browser to login") 27 | auth_url = self.ms_identity_web.get_auth_url(redirect_uri=request.build_absolute_uri(reverse(self.endpoints.redirect))) 28 | return redirect(auth_url) 29 | 30 | def edit_profile(self, request): 31 | self.logger.debug(f"{self.prefix}{self.endpoints.edit_profile}: request received. will redirect browser to edit profile") 32 | auth_url = self.ms_identity_web.get_auth_url( 33 | redirect_uri=request.build_absolute_uri(reverse(self.endpoints.redirect)), 34 | b2c_policy=self.ms_identity_web.aad_config.b2c.profile) 35 | return redirect(auth_url) 36 | 37 | def aad_redirect(self, request): 38 | self.logger.debug(f"{self.prefix}{self.endpoints.redirect}: request received. will process params") 39 | return self.ms_identity_web.process_auth_redirect( 40 | redirect_uri=request.build_absolute_uri(reverse(self.endpoints.redirect)), 41 | afterwards_go_to_url=reverse('index')) 42 | 43 | def sign_out(self, request): 44 | self.logger.debug(f"{self.prefix}{self.endpoints.sign_out}: signing out username: {request.identity_context_data.username}") 45 | return self.ms_identity_web.sign_out(request.build_absolute_uri(reverse(self.endpoints.post_sign_out))) # send the user to Azure AD logout endpoint 46 | 47 | def post_sign_out(self, request): 48 | self.logger.debug(f"{self.prefix}{self.endpoints.post_sign_out}: clearing session for username: {request.identity_context_data.username}") 49 | self.ms_identity_web.remove_user(request.identity_context_data.username) # remove user auth from session on successful logout 50 | return redirect(reverse('index')) # take us back to the home page -------------------------------------------------------------------------------- /ms_identity_web/errors.py: -------------------------------------------------------------------------------- 1 | class AuthError(Exception): 2 | # basic auth exception 3 | pass 4 | class AuthSecurityError(AuthError): 5 | # security check failed, abort auth attempt 6 | code = 400 7 | status = 400 8 | description = "security check failed (state or nonce)" 9 | class OtherAuthError(AuthError): 10 | # unknown aad error, abort auth attempt 11 | code = 500 12 | status = 500 13 | description = "unknown error" 14 | class TokenExchangeError(AuthError): 15 | # unknown aad error, abort auth attempt 16 | code = 500 17 | status = 500 18 | description = "failed to exchange auth code for token(s)" 19 | class B2CPasswordError(AuthError): 20 | # login interrupted, must do password reset 21 | code = 300 22 | status = 300 23 | description = "password reset/redirect" 24 | 25 | try: 26 | from werkzeug.exceptions import HTTPException 27 | from flask import request 28 | class NotAuthenticatedError(HTTPException, AuthError): 29 | """Flask HTTPException Error + IdWebPy AuthError: User is not authenticated.""" 30 | code = 401 31 | status = 401 32 | description = 'User is not authenticated' 33 | except: 34 | class NotAuthenticatedError(AuthError): 35 | """IdWebPy AuthError: User is not authenticated.""" 36 | code = 401 37 | status = 401 38 | description = 'User is not authenticated' 39 | -------------------------------------------------------------------------------- /ms_identity_web/flask_blueprint/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import ( 2 | Blueprint, redirect, 3 | url_for, 4 | current_app, 5 | g, 6 | session, 7 | request, 8 | ) 9 | 10 | # TODO: redirect(url_for('index')) is too opinionated. user must be able to choose 11 | 12 | class FlaskAADEndpoints(Blueprint): 13 | def __init__(self, id_web): 14 | config = id_web.aad_config 15 | logger = id_web._logger 16 | endpoints = config.flask.auth_endpoints 17 | prefix = endpoints.prefix 18 | name = prefix.strip('/') 19 | super().__init__(name, __name__, url_prefix=prefix) 20 | 21 | @self.route(endpoints.sign_in) 22 | def sign_in(): 23 | post_sign_in_url = request.values.get('post_sign_in_url', None) 24 | logger.debug(f"{name}{endpoints.sign_in}: request received. will redirect browser to login") 25 | if post_sign_in_url: 26 | id_web.id_data.post_sign_in_url = post_sign_in_url 27 | logger.debug(f"{name}{endpoints.sign_in}: will redirect to {post_sign_in_url} afterwards") 28 | auth_url = id_web.get_auth_url(redirect_uri=url_for('.aad_redirect', _external=True)) 29 | return redirect(auth_url) 30 | 31 | @self.route(endpoints.edit_profile) 32 | def edit_profile(): 33 | logger.debug(f"{name}{endpoints.edit_profile}: request received. will redirect browser to edit profile") 34 | auth_url = id_web.get_auth_url( 35 | redirect_uri=url_for('.aad_redirect', _external=True), 36 | b2c_policy=config.b2c.profile) 37 | return redirect(auth_url) 38 | 39 | @self.route(endpoints.redirect) 40 | def aad_redirect(): 41 | post_sign_in_url = id_web.id_data.post_sign_in_url or url_for('index') 42 | logger.debug(f"{name}{endpoints.redirect}: request received. will process params") 43 | logger.debug(f"{name}{endpoints.redirect}: will redirect to {post_sign_in_url} afterwards") 44 | return id_web.process_auth_redirect(redirect_uri=url_for('.aad_redirect',_external=True), 45 | afterwards_go_to_url=post_sign_in_url) 46 | 47 | @self.route(endpoints.sign_out) 48 | def sign_out(): 49 | logger.debug(f"{name}{endpoints.sign_out}: signing out username: {g.identity_context_data.username}") 50 | return id_web.sign_out(url_for('.post_sign_out', _external = True)) # send the user to Azure AD logout endpoint 51 | 52 | @self.route(endpoints.post_sign_out) 53 | def post_sign_out(): 54 | logger.debug(f"{name}{endpoints.post_sign_out}: clearing session for username: {g.identity_context_data.username}") 55 | id_web.remove_user(g.identity_context_data.username) # remove user auth from session on successful logout 56 | return redirect(url_for('index')) # take us back to the home page 57 | 58 | def url_for(self, destination, _external=False): 59 | return url_for(f'{self.name}.{destination}', _external=_external) 60 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | msal>=1.6.0,<2 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name='ms_identity_web', 4 | version='0.16.6', 5 | description='MSAL Identity Utilities', 6 | author='Azure Samples', 7 | url='https://github.com/azure-samples/ms-identity-python-utilities', 8 | packages=find_packages(), 9 | install_requires=['msal>=1.6.0,<2'], 10 | ) 11 | 12 | 13 | --------------------------------------------------------------------------------