├── .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 |
--------------------------------------------------------------------------------