pax_global_header00006660000000000000000000000064142015415660014516gustar00rootroot0000000000000052 comment=eff8a1b2ec5f128a2e2a26250fea70ee937f72a1 microsoft-authentication-library-for-python-1.17.0/000077500000000000000000000000001420154156600223735ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/.github/000077500000000000000000000000001420154156600237335ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001420154156600261165ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000023351420154156600306130ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to our [off-the-shelf samples](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample) and pick one that is closest to your usage scenario. You should not need to modify the sample. 2. Follow the description of the sample, typically at the beginning of it, to prepare a `config.json` containing your test configurations 3. Run such sample, typically by `python sample.py config.json` 4. See the error 5. In this bug report, tell us the sample you choose, paste the content of the config.json with your test setup (which you can choose to skip your credentials, and/or mail it to our developer's email). **Expected behavior** A clear and concise description of what you expected to happen. **What you see instead** Paste the sample output, or add screenshots to help explain your problem. **The MSAL Python version you are using** Paste the output of this `python -c "import msal; print(msal.__version__)"` **Additional context** Add any other context about the problem here. microsoft-authentication-library-for-python-1.17.0/.github/workflows/000077500000000000000000000000001420154156600257705ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/.github/workflows/python-package.yml000066400000000000000000000067021420154156600314320ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: CI/CD on: push: pull_request: branches: [ dev ] # This guards against unknown PR until a community member vet it and label it. types: [ labeled ] jobs: ci: env: # Fake a TRAVIS env so that the pre-existing test cases would behave like before TRAVIS: true LAB_APP_CLIENT_ID: ${{ secrets.LAB_APP_CLIENT_ID }} LAB_APP_CLIENT_SECRET: ${{ secrets.LAB_APP_CLIENT_SECRET }} LAB_OBO_CLIENT_SECRET: ${{ secrets.LAB_OBO_CLIENT_SECRET }} LAB_OBO_CONFIDENTIAL_CLIENT_ID: ${{ secrets.LAB_OBO_CONFIDENTIAL_CLIENT_ID }} LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }} # Derived from https://docs.github.com/en/actions/guides/building-and-testing-python#starting-with-the-python-workflow-template runs-on: ubuntu-latest strategy: matrix: python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} # Derived from https://github.com/actions/cache/blob/main/examples.md#using-pip-to-get-cache-location # However, a before-and-after test shows no improvement in this repo, # possibly because the bottlenect was not in downloading those small python deps. - name: Get pip cache dir from pip 20.1+ id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: pip cache uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('**/setup.py', '**/requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide #flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest cd: needs: ci if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/main') runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3.9 uses: actions/setup-python@v2 with: python-version: 3.9 - name: Build a package for release run: | python -m pip install build --user python -m build --sdist --wheel --outdir dist/ . - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@v1.4.2 if: github.ref == 'refs/heads/main' with: user: __token__ password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@v1.4.2 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} microsoft-authentication-library-for-python-1.17.0/.gitignore000066400000000000000000000013151420154156600243630ustar00rootroot00000000000000# Python cache __pycache__/ *.pyc # PTVS analysis .ptvs/ *.pyproj # Build results /bin/ /obj/ /dist/ /MANIFEST # Result of running python setup.py install/pip install -e /build/ /msal.egg-info/ # Test results /TestResults/ # User-specific files *.suo *.user *.sln.docstates /tests/config.py # Windows image file caches Thumbs.db ehthumbs.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Mac desktop service store files .DS_Store .idea src/build *.iml /doc/_build # Virtual Environments /env* .venv/ docs/_build/ # Visual Studio Files /.vs/* /tests/.vs/* # vim files *.swp # The test configuration file(s) could potentially contain credentials tests/config.json .envmicrosoft-authentication-library-for-python-1.17.0/.travis.yml000066400000000000000000000043721420154156600245120ustar00rootroot00000000000000sudo: false language: python python: - "2.7" - "3.5" - "3.6" # Borrowed from https://github.com/travis-ci/travis-ci/issues/9815 # Enable 3.7 without globally enabling sudo and dist: xenial for other build jobs matrix: include: - python: 3.7 dist: xenial sudo: true - python: 3.8 dist: xenial sudo: true install: - pip install -r requirements.txt script: - python -m unittest discover -s tests deploy: - # test pypi provider: pypi distributions: "sdist bdist_wheel" server: https://test.pypi.org/legacy/ user: "nugetaad" password: secure: KkjKySJujYxx31B15mlAZr2Jo4P99LcrMj3uON/X/WMXAqYVcVsYJ6JSzUvpNnCAgk+1hc24Qp6nibQHV824yiK+eG4qV+lpzkEEedkRx6NOW/h09OkT+pOSVMs0kcIhz7FzqChpl+jf6ZZpb13yJpQg2LoZIA4g8UdYHHFidWt4m5u1FZ9LPCqQ0OT3gnKK4qb0HIDaECfz5GYzrelLLces0PPwj1+X5eb38xUVtbkA1UJKLGKI882D8Rq5eBdbnDGsfDnF6oU+EBnGZ7o6HVQLdBgagDoVdx7yoXyntULeNxTENMTOZJEJbncQwxRgeEqJWXTTEW57O6Jo5uiHEpJA9lAePlRbS+z6BPDlnQogqOdTsYS0XMfOpYE0/r3cbtPUjETOmGYQxjQzfrFBfM7jaWnUquymZRYqCQ66VDo3I/ykNOCoM9qTmWt5L/MFfOZyoxLHnDThZBdJ3GXHfbivg+v+vOfY1gG8e2H2lQY+/LIMIJibF+MS4lJgrB81dcNdBzyxMNByuWQjSL1TY7un0QzcRcZz2NLrFGg8+9d67LQq4mK5ySimc6zdgnanuROU02vGr1EApT6D/qUItiulFgWqInNKrFXE9q74UP/WSooZPoLa3Du8y5s4eKerYYHQy5eSfIC8xKKDU8MSgoZhwQhCUP46G9Nsty0PYQc= on: branch: main tags: false condition: $TRAVIS_PYTHON_VERSION = "2.7" - # production pypi provider: pypi distributions: "sdist bdist_wheel" user: "nugetaad" password: secure: KkjKySJujYxx31B15mlAZr2Jo4P99LcrMj3uON/X/WMXAqYVcVsYJ6JSzUvpNnCAgk+1hc24Qp6nibQHV824yiK+eG4qV+lpzkEEedkRx6NOW/h09OkT+pOSVMs0kcIhz7FzqChpl+jf6ZZpb13yJpQg2LoZIA4g8UdYHHFidWt4m5u1FZ9LPCqQ0OT3gnKK4qb0HIDaECfz5GYzrelLLces0PPwj1+X5eb38xUVtbkA1UJKLGKI882D8Rq5eBdbnDGsfDnF6oU+EBnGZ7o6HVQLdBgagDoVdx7yoXyntULeNxTENMTOZJEJbncQwxRgeEqJWXTTEW57O6Jo5uiHEpJA9lAePlRbS+z6BPDlnQogqOdTsYS0XMfOpYE0/r3cbtPUjETOmGYQxjQzfrFBfM7jaWnUquymZRYqCQ66VDo3I/ykNOCoM9qTmWt5L/MFfOZyoxLHnDThZBdJ3GXHfbivg+v+vOfY1gG8e2H2lQY+/LIMIJibF+MS4lJgrB81dcNdBzyxMNByuWQjSL1TY7un0QzcRcZz2NLrFGg8+9d67LQq4mK5ySimc6zdgnanuROU02vGr1EApT6D/qUItiulFgWqInNKrFXE9q74UP/WSooZPoLa3Du8y5s4eKerYYHQy5eSfIC8xKKDU8MSgoZhwQhCUP46G9Nsty0PYQc= on: branch: main tags: true condition: $TRAVIS_PYTHON_VERSION = "2.7" microsoft-authentication-library-for-python-1.17.0/LICENSE000066400000000000000000000022001420154156600233720ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) Microsoft Corporation. All rights reserved. This code is licensed under the MIT License. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and / or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions : The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.microsoft-authentication-library-for-python-1.17.0/README.md000066400000000000000000000216741420154156600236640ustar00rootroot00000000000000# Microsoft Authentication Library (MSAL) for Python | `dev` branch | Reference Docs | # of Downloads per different platforms | # of Downloads per recent MSAL versions | |---------------|---------------|----------------------------------------|-----------------------------------------| [![Build status](https://github.com/AzureAD/microsoft-authentication-library-for-python/actions/workflows/python-package.yml/badge.svg?branch=dev)](https://github.com/AzureAD/microsoft-authentication-library-for-python/actions) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) | [![Downloads](https://pepy.tech/badge/msal)](https://pypistats.org/packages/msal) | [![Download monthly](https://pepy.tech/badge/msal/month)](https://pepy.tech/project/msal) The Microsoft Authentication Library for Python enables applications to integrate with the [Microsoft identity platform](https://aka.ms/aaddevv2). It allows you to sign in users or apps with Microsoft identities ([Azure AD](https://azure.microsoft.com/services/active-directory/), [Microsoft Accounts](https://account.microsoft.com) and [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/) accounts) and obtain tokens to call Microsoft APIs such as [Microsoft Graph](https://graph.microsoft.io/) or your own APIs registered with the Microsoft identity platform. It is built using industry standard OAuth2 and OpenID Connect protocols Not sure whether this is the SDK you are looking for your app? There are other Microsoft Identity SDKs [here](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Microsoft-Authentication-Client-Libraries). Quick links: | [Getting Started](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-python-webapp) | [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) | | --- | --- | --- | --- | --- | ## Scenarios supported Click on the following thumbnail to visit a large map with clickable links to proper samples. [![Map effect won't work inside github's markdown file, so we have to use a thumbnail here to lure audience to a real static website](docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/) ## Installation You can find MSAL Python on [Pypi](https://pypi.org/project/msal/). 1. If you haven't already, [install and/or upgrade the pip](https://pip.pypa.io/en/stable/installing/) of your Python environment to a recent version. We tested with pip 18.1. 2. As usual, just run `pip install msal`. ## Versions This library follows [Semantic Versioning](http://semver.org/). You can find the changes for each version under [Releases](https://github.com/AzureAD/microsoft-authentication-library-for-python/releases). ## Usage Before using MSAL Python (or any MSAL SDKs, for that matter), you will have to [register your application with the Microsoft identity platform](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-register-an-app). Acquiring tokens with MSAL Python follows this 3-step pattern. (Note: That is the high level conceptual pattern. There will be some variations for different flows. They are demonstrated in [runnable samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). ) 1. MSAL proposes a clean separation between [public client applications, and confidential client applications](https://tools.ietf.org/html/rfc6749#section-2.1). So you will first create either a `PublicClientApplication` or a `ConfidentialClientApplication` instance, and ideally reuse it during the lifecycle of your app. The following example shows a `PublicClientApplication`: ```python from msal import PublicClientApplication app = PublicClientApplication( "your_client_id", authority="https://login.microsoftonline.com/Enter_the_Tenant_Name_Here") ``` Later, each time you would want an access token, you start by: ```python result = None # It is just an initial value. Please follow instructions below. ``` 2. The API model in MSAL provides you explicit control on how to utilize token cache. This cache part is technically optional, but we highly recommend you to harness the power of MSAL cache. It will automatically handle the token refresh for you. ```python # We now check the cache to see # whether we already have some accounts that the end user already used to sign in before. accounts = app.get_accounts() if accounts: # If so, you could then somehow display these accounts and let end user choose print("Pick the account you want to use to proceed:") for a in accounts: print(a["username"]) # Assuming the end user chose this one chosen = accounts[0] # Now let's try to find a token in cache for this account result = app.acquire_token_silent(["your_scope"], account=chosen) ``` 3. Either there is no suitable token in the cache, or you chose to skip the previous step, now it is time to actually send a request to AAD to obtain a token. There are different methods based on your client type and scenario. Here we demonstrate a placeholder flow. ```python if not result: # So no suitable token exists in cache. Let's get a new one from AAD. result = app.acquire_token_by_one_of_the_actual_method(..., scopes=["User.Read"]) if "access_token" in result: print(result["access_token"]) # Yay! else: print(result.get("error")) print(result.get("error_description")) print(result.get("correlation_id")) # You may need this when reporting a bug ``` Refer the [Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) pages for more details on the MSAL Python functionality and usage. ## Migrating from ADAL If your application is using ADAL Python, we recommend you to update to use MSAL Python. No new feature work will be done in ADAL Python. See the [ADAL to MSAL migration](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Migrate-to-MSAL-Python) guide. ## Roadmap You can follow the latest updates and plans for MSAL Python in the [Roadmap](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Roadmap) published on our Wiki. ## Samples and Documentation MSAL Python supports multiple [application types and authentication scenarios](https://docs.microsoft.com/azure/active-directory/develop/authentication-flows-app-scenarios). The generic documents on [Auth Scenarios](https://docs.microsoft.com/azure/active-directory/develop/authentication-scenarios) and [Auth protocols](https://docs.microsoft.com/azure/active-directory/develop/active-directory-v2-protocols) are recommended reading. We provide a [full suite of sample applications](https://aka.ms/aaddevsamplesv2) and [documentation](https://aka.ms/aaddevv2) to help you get started with learning the Microsoft identity platform. ## Community Help and Support We leverage Stack Overflow to work with the community on supporting Azure Active Directory and its SDKs, including this one! We highly recommend you ask your questions on Stack Overflow (we're all on there!) Also browser existing issues to see if someone has had your question before. We recommend you use the "msal" tag so we can see it! Here is the latest Q&A on Stack Overflow for MSAL: [http://stackoverflow.com/questions/tagged/msal](http://stackoverflow.com/questions/tagged/msal) ## Submit Feedback We'd like your thoughts on this library. Please complete [this short survey.](https://forms.office.com/r/TMjZkDbzjY) ## Security Reporting If you find a security issue with our libraries or services please report it to [secure@microsoft.com](mailto:secure@microsoft.com) with as much detail as possible. Your submission may be eligible for a bounty through the [Microsoft Bounty](http://aka.ms/bugbounty) program. Please do not post security issues to GitHub Issues or any other public site. We will contact you shortly upon receiving the information. We encourage you to get notifications of when security incidents occur by visiting [this page](https://technet.microsoft.com/security/dd252948) and subscribing to Security Advisory Alerts. ## Contributing All code is licensed under the MIT license and we triage actively on GitHub. We enthusiastically welcome contributions and feedback. Please read the [contributing guide](./contributing.md) before starting. ## We Value and Adhere to the Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. microsoft-authentication-library-for-python-1.17.0/contributing.md000066400000000000000000000075241420154156600254340ustar00rootroot00000000000000# CONTRIBUTING Azure Active Directory SDK projects welcomes new contributors. This document will guide you through the process. ### CONTRIBUTOR LICENSE AGREEMENT Please visit [https://cla.microsoft.com/](https://cla.microsoft.com/) and sign the Contributor License Agreement. You only need to do that once. We can not look at your code until you've submitted this request. ### FORK Fork this project on GitHub and check out your copy. Example for Project Foo (which can be any ADAL or MSAL or just any library): ``` $ git clone git@github.com:username/project-foo.git $ cd project-foo $ git remote add upstream git@github.com:AzureAD/project-foo.git ``` No need to decide if you want your feature or bug fix to go into the dev branch or the master branch. **All bug fixes and new features should go into the dev branch.** The master branch is effectively frozen; patches that change the SDKs protocols or API surface area or affect the run-time behavior of the SDK will be rejected. Some of our SDKs have bundled dependencies that are not part of the project proper. Any changes to files in those directories or its subdirectories should be sent to their respective projects. Do not send your patch to us, we cannot accept it. In case of doubt, open an issue in the [issue tracker](issues). Especially do so if you plan to work on a major change in functionality. Nothing is more frustrating than seeing your hard work go to waste because your vision does not align with our goals for the SDK. ### BRANCH Okay, so you have decided on the proper branch. Create a feature branch and start hacking: ``` $ git checkout -b my-feature-branch ``` ### COMMIT Make sure git knows your name and email address: ``` $ git config --global user.name "J. Random User" $ git config --global user.email "j.random.user@example.com" ``` Writing good commit logs is important. A commit log should describe what changed and why. Follow these guidelines when writing one: 1. The first line should be 50 characters or less and contain a short description of the change prefixed with the name of the changed subsystem (e.g. "net: add localAddress and localPort to Socket"). 2. Keep the second line blank. 3. Wrap all other lines at 72 columns. A good commit log looks like this: ``` fix: explaining the commit in one line Body of commit message is a few lines of text, explaining things in more detail, possibly giving some background about the issue being fixed, etc etc. The body of the commit message can be several paragraphs, and please do proper word-wrap and keep columns shorter than about 72 characters or so. That way `git log` will show things nicely even when it is indented. ``` The header line should be meaningful; it is what other people see when they run `git shortlog` or `git log --oneline`. Check the output of `git log --oneline files_that_you_changed` to find out what directories your changes touch. ### REBASE Use `git rebase` (not `git merge`) to sync your work from time to time. ``` $ git fetch upstream $ git rebase upstream/v0.1 # or upstream/master ``` ### TEST Bug fixes and features should come with tests. Add your tests in the test directory. This varies by repository but often follows the same convention of /src/test. Look at other tests to see how they should be structured (license boilerplate, common includes, etc.). Make sure that all tests pass. ### PUSH ``` $ git push origin my-feature-branch ``` Go to https://github.com/username/microsoft-authentication-library-for-***.git and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. If there are comments to address, apply your changes in a separate commit and push that to your feature branch. Post a comment in the pull request afterwards; GitHub does not send out notifications when you add commits. microsoft-authentication-library-for-python-1.17.0/docs/000077500000000000000000000000001420154156600233235ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/docs/Makefile000066400000000000000000000011041420154156600247570ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)microsoft-authentication-library-for-python-1.17.0/docs/conf.py000066400000000000000000000130021420154156600246160ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # from datetime import date import os import sys sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- project = u'MSAL Python' copyright = u'{0}, Microsoft'.format(date.today().year) author = u'Microsoft' # The short X.Y version from msal import __version__ as version # The full version, including alpha/beta/rc tags release = version # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.githubpages', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # # html_theme = 'alabaster' html_theme = 'furo' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { "light_css_variables": { "font-stack": "'Segoe UI', SegoeUI, 'Helvetica Neue', Helvetica, Arial, sans-serif", "font-stack--monospace": "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace", }, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'MSALPythondoc' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'MSALPython.tex', u'MSAL Python Documentation', u'Microsoft', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'msalpython', u'MSAL Python Documentation', [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'MSALPython', u'MSAL Python Documentation', author, 'MSALPython', 'One line description of project.', 'Miscellaneous'), ] # -- Options for Epub output ------------------------------------------------- # Bibliographic Dublin Core info. epub_title = project # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # -- Extension configuration -------------------------------------------------microsoft-authentication-library-for-python-1.17.0/docs/daemon-app.svg000066400000000000000000001050111420154156600260630ustar00rootroot00000000000000 image/svg+xml Page-1 Web app (Was Websites).1277 Daemon Web app Sheet.1002 Sheet.1003 Sheet.1004 Sheet.1005 Sheet.1006 Sheet.1007 Sheet.1008 Sheet.1009 Sheet.1010 Sheet.1011 Sheet.1012 Sheet.1013 Sheet.1014 Sheet.1015 Sheet.1016 Sheet.1017 Sheet.1018 Sheet.1019 Sheet.1020 Sheet.1021 Sheet.1022 Sheet.1023 Sheet.1024 Sheet.1025 Sheet.1026 Sheet.1027 Sheet.1028 DaemonWeb app API App.1305 Daemon API App DaemonAPI App Microsoft Enterprise desktop virtualization.1317 Daemon Desktop App Sheet.1031 Sheet.1032 Sheet.1033 Sheet.1034 Sheet.1035 Sheet.1036 Sheet.1037 Sheet.1038 DaemonDesktop App Certificate.1337 Secret Sheet.1040 Sheet.1041 Sheet.1042 Sheet.1043 Sheet.1044 Sheet.1045 Sheet.1046 Sheet.1047 Secret Arrow (Azure Poster Style).1346 Arrow (Azure Poster Style).1348 API App.1350 Daemon Web API Daemon Web API Certificate.1385 Secret Sheet.1052 Sheet.1053 Sheet.1054 Sheet.1055 Sheet.1056 Sheet.1057 Sheet.1058 Sheet.1059 Secret Certificate.1416 Secret Sheet.1061 Sheet.1062 Sheet.1063 Sheet.1064 Sheet.1065 Sheet.1066 Sheet.1067 Sheet.1068 Secret Arrow (Azure Poster Style).1507 Sheet.1215 Client Credentials flow Client Credentials flow microsoft-authentication-library-for-python-1.17.0/docs/index.rst000066400000000000000000000116541420154156600251730ustar00rootroot00000000000000MSAL Python documentation ========================= .. toctree:: :maxdepth: 2 :caption: Contents: :hidden: MSAL Documentation GitHub Repository You can find high level conceptual documentations in the project `README `_. Scenarios ========= There are many `different application scenarios `_. MSAL Python supports some of them. **The following diagram serves as a map. Locate your application scenario on the map.** **If the corresponding icon is clickable, it will bring you to an MSAL Python sample for that scenario.** * Most authentication scenarios acquire tokens on behalf of signed-in users. .. raw:: html Web app Web app Desktop App Browserless app * There are also daemon apps. In these scenarios, applications acquire tokens on behalf of themselves with no user. .. raw:: html Daemon App acquires token for themselves * There are other less common samples, such for ADAL-to-MSAL migration, `available inside the project code base `_. API === The following section is the API Reference of MSAL Python. .. note:: Only APIs and their parameters documented in this section are part of public API, with guaranteed backward compatibility for the entire 1.x series. Other modules in the source code are all considered as internal helpers, which could change at anytime in the future, without prior notice. MSAL proposes a clean separation between `public client applications and confidential client applications `_. They are implemented as two separated classes, with different methods for different authentication scenarios. PublicClientApplication ----------------------- .. autoclass:: msal.PublicClientApplication :members: :inherited-members: .. automethod:: __init__ ConfidentialClientApplication ----------------------------- .. autoclass:: msal.ConfidentialClientApplication :members: :inherited-members: .. automethod:: __init__ TokenCache ---------- One of the parameters accepted by both `PublicClientApplication` and `ConfidentialClientApplication` is the `TokenCache`. .. autoclass:: msal.TokenCache :members: You can subclass it to add new behavior, such as, token serialization. See `SerializableTokenCache` for example. .. autoclass:: msal.SerializableTokenCache :members: microsoft-authentication-library-for-python-1.17.0/docs/make.bat000066400000000000000000000014231420154156600247300ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd microsoft-authentication-library-for-python-1.17.0/docs/requirements.txt000066400000000000000000000000331420154156600266030ustar00rootroot00000000000000furo -r ../requirements.txtmicrosoft-authentication-library-for-python-1.17.0/docs/scenarios-with-users.svg000066400000000000000000002651301420154156600301510ustar00rootroot00000000000000 image/svg+xml Page-1 Web app (Was Websites).1073 Single Page Application Sheet.1074 Sheet.1075 Sheet.1076 Sheet.1077 Sheet.1078 Sheet.1079 Sheet.1080 Sheet.1081 Sheet.1082 Sheet.1083 Sheet.1084 Sheet.1085 Sheet.1086 Sheet.1087 Sheet.1088 Sheet.1089 Sheet.1090 Sheet.1091 Sheet.1092 Sheet.1093 Sheet.1094 Sheet.1095 Sheet.1096 Sheet.1097 Sheet.1098 Sheet.1099 Sheet.1100 Single Page Application Web app (Was Websites).1101 Web app Sheet.1102 Sheet.1103 Sheet.1104 Sheet.1105 Sheet.1106 Sheet.1107 Sheet.1108 Sheet.1109 Sheet.1110 Sheet.1111 Sheet.1112 Sheet.1113 Sheet.1114 Sheet.1115 Sheet.1116 Sheet.1117 Sheet.1118 Sheet.1119 Sheet.1120 Sheet.1121 Sheet.1122 Sheet.1123 Sheet.1124 Sheet.1125 Sheet.1126 Sheet.1127 Sheet.1128 Web app API App.1129 API App API App IoT Hub.1130 Browserless app Sheet.1131 Sheet.1132 Sheet.1133 Sheet.1134 Sheet.1135 Sheet.1136 Browserlessapp Mobile App (Was Mobile Services).1137 Mobile App Sheet.1138 Sheet.1139 MobileApp Arrow (Azure Poster Style).1140 Microsoft Enterprise desktop virtualization.1141 Desktop App Sheet.1142 Sheet.1143 Sheet.1144 Sheet.1145 Sheet.1146 Sheet.1147 Sheet.1148 Sheet.1149 Desktop App User Permissions.1150 Sheet.1151 Sheet.1152 Sheet.1153 Sheet.1154 Sheet.1155 Sheet.1156 Sheet.1157 Sheet.1158 Sheet.1159 Sheet.1160 Arrow (Azure Poster Style).1170 Arrow (Azure Poster Style).1171 Arrow (Azure Poster Style).1172 Arrow (Azure Poster Style).1173 API App.1174 API App API App Arrow (Azure Poster Style).1175 User Permissions.1176 Sheet.1177 Sheet.1178 Sheet.1179 Sheet.1180 Sheet.1181 Sheet.1182 Sheet.1183 Sheet.1184 Sheet.1185 Sheet.1186 User Permissions.1187 Sheet.1188 Sheet.1189 Sheet.1190 Sheet.1191 Sheet.1192 Sheet.1193 Sheet.1194 Sheet.1195 Sheet.1196 Sheet.1197 User Permissions.1198 Sheet.1199 Sheet.1200 Sheet.1201 Sheet.1202 Sheet.1203 Sheet.1204 Sheet.1205 Sheet.1206 Sheet.1207 Sheet.1208 User Permissions.1218 Sheet.1219 Sheet.1220 Sheet.1221 Sheet.1222 Sheet.1223 Sheet.1224 Sheet.1225 Sheet.1226 Sheet.1227 Sheet.1228 Web app (Was Websites).1425 Single Page Application Sheet.1426 Sheet.1427 Sheet.1428 Sheet.1429 Sheet.1430 Sheet.1431 Sheet.1432 Sheet.1433 Sheet.1434 Sheet.1435 Sheet.1436 Sheet.1437 Sheet.1438 Sheet.1439 Sheet.1440 Sheet.1441 Sheet.1442 Sheet.1443 Sheet.1444 Sheet.1445 Sheet.1446 Sheet.1447 Sheet.1448 Sheet.1449 Sheet.1450 Sheet.1451 Sheet.1452 Single Page Application User Permissions.1453 Sheet.1454 Sheet.1455 Sheet.1456 Sheet.1457 Sheet.1458 Sheet.1459 Sheet.1460 Sheet.1461 Sheet.1462 Sheet.1463 Web app (Was Websites).1464 Web app Sheet.1465 Sheet.1466 Sheet.1467 Sheet.1468 Sheet.1469 Sheet.1470 Sheet.1471 Sheet.1472 Sheet.1473 Sheet.1474 Sheet.1475 Sheet.1476 Sheet.1477 Sheet.1478 Sheet.1479 Sheet.1480 Sheet.1481 Sheet.1482 Sheet.1483 Sheet.1484 Sheet.1485 Sheet.1486 Sheet.1487 Sheet.1488 Sheet.1489 Sheet.1490 Sheet.1491 Web app User Permissions.1492 Sheet.1493 Sheet.1494 Sheet.1495 Sheet.1496 Sheet.1497 Sheet.1498 Sheet.1499 Sheet.1500 Sheet.1501 Sheet.1502 Pentagon.1683 User Permissions.1684 Sheet.1685 Sheet.1686 Sheet.1687 Sheet.1688 Sheet.1689 Sheet.1690 Sheet.1691 Sheet.1692 Sheet.1693 Sheet.1694 microsoft-authentication-library-for-python-1.17.0/docs/thumbnail.png000066400000000000000000001056251420154156600260250ustar00rootroot00000000000000‰PNG  IHDRMõý ;‹sBITÛáOà IDATxœìÝy`eú8ðçÍ}6MÒ¦é}ß…–´”SnEQTTT{ø—-–¾þ×Ñí«ÎôÜŒp@SQ¸§LuÕUçŽl>Zo¼F<íå…ï}²Öuµ5E–­ÞuåòÛýëWµÛ/¼¾y‡féCžïüùë|ú-ýúËåo}ÿóµòü‡·_<¢»þ{:·¼ôŸ§ªü§ß—çÿ|é¹V€è!#‚¸×yO`ŽHý=ï9 ˜’ð¬øà›ñÎA±Ã"¥ìk¸°¿—:¶mõMÊó®Êý»#ÏëJ6ýVžÓñ|ûþ»É&0¯Vàƒ·—2âÊå×Ýý«:°nµ;ÏoÞ¡DXW.r:K—.Õh4o¾ùf@@€{9ƒ§ì>\a„çÑZW”” `n}î©El…²]‹—}ñŸíï>~Ä éiWýé//­\»9Œ›»x°´zäùÂÂd~Æ7ÿ|ü¡©~Uð»rõý«ð=ŒµûŽðNÄN®?¼ãÕE‡–Ÿÿfóš‚eK×VÈÀ8ú©wfKOx«èîü˜â#§ß{ïÏk~Ùn²Uyryä«ÛaÓË?ž²Xµº×?ùªò³ù;;EÎÎö¹KVt¯^±ïïÄX×ëË«7-üÄ‚÷Ê€vKèÊÿν7ï‘ ³§V9pÇßÜ5TÆs…O½¾B¡õø ÿêi/>µÈ!ý†¯\2ó¥'¸Ê£ü“/þöçÉÃ3¦Ìi­(J˜óÞc©]½vç™G'ç*bsîºsø§'„ŸÌòŸÿæÊ0¥ G:æ‹¿M{iÞ H¡hWÁ¾^ÄX½ä wœãšŸ^´TÀcpÒæ,½[6gþ‡II' ö¿·Ï÷^üˆæìiÉ}¿å?÷éÂg=¾´uí«…çEVö•¿ÈTò`íŠûŽðNÜ•¹õjAX§JûÆ'+èC¶í£%A?Ý´èƒÍ 9åÏÏ%ÖMüõó\¶fÕ÷!û¿ÿÞ/mÕî‹»ð|ܘ{þuÏŒ¼#EG~±6OyáÛRôËg‹NAU+Íÿþåí§ÝñÀ¾µ×ml=ãHÛüÙã‹~ÆÒ®±}üÕç`ª;¼õxCíÆíw,~ˆ `ï,wš%O9Zפܼ?cÞt8ùó¿?ØÜàOibf¼œÔ¸¼%÷ý˜Ê¿Ql‰ £Ž5o}9zB¯]¨úæå¯ŽX8æž‡ÞøpŒ_ÍCó?LÊH:ZÓ=õ×ï­¥zóâíÂG„»¾mŽÊ”it(Ö~ú ]¿-¼{ÆŸ7n‰Ó—L}nß—ó#}²G!²ó2|ovø¼ùoÊ”Š~úŠ>=>ÄsgºìׯÊÒ'>o¦«äùÖ­[ßzë-‰Dï½÷^¯5è¾;‡®^w|ºý‡¼Yîßz¶fÛWhԳλýýy«ö6ž=U7å•e“3c€%œ˜‘2vþÜÀÔ9÷LjêÐûƒêŸ÷ÞÖøñã÷&f‡uù'åÿãƒùß¼rwq½yë >Ù¹]I›qßÇ÷-.ŽË~åÕ§¾yõž2*'/%þéyÓ9¹¨ÖO·ê·n]¡k8Õî°˜9·çà©vkýÆ‚â·Çߘ˜Æ€Ýß.úø?_™þÙ‚)[jî³qC^\ôJûVÖß‹Êï:ºNeéK_FÆsÖlùyþ“§Fœ8x°jÛJGÎüÏ^¿éÿý¡X ñƒ//Rž‰¸ç¿ÅO›Õkw惉ùüòOʾ€Ÿ>ûðÞ·7ÌNæ¾r÷mÇ;‡V¶Ã;¯¿’"àÿú1÷Ž`cÆ#ÿ~a’òÐÁ£;ø|ü‹Ë_˜¤üsqþUå°Ý÷Ú§c™‡ÆU‡þ¸D[üßVnûîõYîÏ0¾s[©ìöÿx´ví;Ë*N\¾áï-¶‰ŸÍÉ]|Ïâ…+wçû[çÜþ„ù½îϳ¸Þqäâ!кdŒï2Ò¾òêAÑ‚GÛóËÄäßùÁ+Ó?[0e[qáW½âyX{cvܼiwh˜ g?zωÚMÕ·»Ï˜@;bjF”ÑõyïCS&|)3eÈ ó¦Óï_{¬$|ä/ß—ÏpÂá•`ÿê§ÚÿíÙÌ;»>ÿoÕ ãâ.œküßê-{¿a´ï¼kÉ·öØSã^\þÂ$å‚â½`·[ô}cƒ:æ¾WîŽ}búxEqÅ'ÜTyB8añówò€Úý¿E©sßíΘæß¾»õOô!fì]æþªüf>y««œ·GEE±X,„PZZÚe«Òg=X¶ù»v5=0> ôz­¿DR©¿^oúð«oô¥[çξ¿ B pñ¶|ñ×oîê ü™ù)L €ëÒ÷2©X,–“²éÔçÞY¸`Á¢eñ9™ÀO,ƒá¤zýƒKï`ø!itzj°iÑÂ÷GÝõðÜ;FRTïBЭï‘JÄ ö÷3l±ˆ À`1(ê¡ Ÿ´àí{cW.}áÑ×Vvëu~B1dmïÖß¾rÁ‚»:ü"…1ÄbPuùîøÉ=Õë¬r)¤"¦‰9ì›åÏ\³ìþ‡4Э ”ªwœúnP"`Ž=Â`0Š%Bä „«~D,©\ŠÀÐÕRU¸`Á‚7לÎK ì½§–.ƒŸTb±¤ÇÐ `Ûµ}ŸÅ‰@§ëøâ,xU‘šé¸äóD—²+vÁ|? p¬Þl€<$þ~–¶¶Ëâ‘JňÅcÍ–7¾«~à‘''d„]vP®zhz¯šóæŠÑ܆ןšóÏõGÀdD~bn`¨zï‚CßÝP¾hÁ‚…ïmÎÉŒ×÷þqÇ’ þüÊú_ßV,ƒqE4ÀÈ'–>žîøÇ+ó^Z¾Eß­ÛõÝ?,XPÇŠ—c}ˆ{Uì0È\%ÏÓÓÓkjjJKKzè¡Ë×ñÇù­ ›ÌHÉs|Ƕó—=U£ò”_|ñÓ„‡æ/š=äð©ËÒÚª¡?P½¡'8<´ãtÁɋ֌ÐÕn¦Š”P `°XºN•ÉÖ»iFœ7#§zçO*á°çîO«l¢»+ûûïÖ‡çÜ=b¸ºl?/";,""5JRƦÝ}çø£›¿/ªí™1s†LÀbˆ•îUÃD{ªò‡Å‡% ›=5«à§Ÿ\‰Ó¿}Ô°p×›Kf>û2u®4ùöÙ–Šý¼ˆìP…rÌôÙ!–ÒuÛJîzþoÙab„¸C†$ „¸òð„P)„GœÝ·qû¡Š¼:$ux†¼kýö#÷üù¥´äÌaúUë¶Š’o›‚JšÆDñwÝsG¯Ýq ‰ ¦W1ý‚§Þ}?®Ý¹åPÝ3û±aÑÌóÇ×nÚ=éÉ{sB0ü3zÅ™~ß‚DKÙ¦ƒuO.z!#o ãì¾¢$µÕ§Ýõð9áî(%Z‘"áĦ 2Dócׯþ¡Õqç”,.@©.ÛÏOºóÁ¢ïÖl óļñ ¡°„ô;¦æ–9ûèsO·ØPPR?áî»”~¼^Ÿç°Ì ‹ûÑï™5Á½ Ñþ<ÄöšŽ’„ÆGð#¹Ó/Y½<>9êÒx<<)TÊA¥O}  ýðžçŸ(+;32+1,a¨œ‹¢R‡Š™Òø—”<$N©ˆˆÀm?®9Qï\ôÊ32+ "ùîI™›Z¯Hˆ©:c{讑½v!ý9ÛÖ¬.ïdß=sBLFóì¾uøž{ÆÄ§¤ÑB1ý‚S"ݱ% €ŒÌ´­«VA숼˜ÈISò‹~Ys ´ýÙW^I6&ÔRþÃ/ûãÆÜ>4\N}E¯¯J¢k64þ&¤äMH¿W¯²tδ©_nÏz:/³ã‹W¿>näôtN|îŸsGG{:œÁ‡ä9Aø>ÒŽ |És‚ð}W¹îmôz½Ùl‰ÄӱĠä½yÞÒÒrôèѺº:«ÕJ/a±X±±±yyy‘‘‘ž ol‡³ÛíÛ·o///çr¹yyyééé~~~F£Q¥RUUU•——ÇÅÅÍœ9S x:R‚¼.ÏívûêÕ«­Vë¬Y³ÊËËËËËŸ~úi‘HD¯5™LÛ¶m€G}Ô½œ ˆk¸ñ<7:@ÔçqD×÷Ýwß †§žzŠÍf»\®ÿýïv»}úôé•••UUUf³!Äd2Y,–L&{â‰' Ò”H×q#y^£…¹ÛðqŽ’ Ï'¡)Q͉'vîÜùàƒÒKt:ÝçŸît:B.—+:::%%%,,ŒÍfõÕW£FÊÍͰ̈́º‘v8:É QïÝ„«g„ Äé3EQÅÅÅéééuuuô£Ñ¸víZ‡Ã\.wîܹ½{ãNœ8qçÎ999¤J'ˆkës†¨L@'9 “~¬˜+üææf£Ñ˜››ÛÝÝ­Ñhº»»¿ùæ›ööv‹5aÂ@°}ûv»ýâH¡´´4Š¢Îž=; ['ÖçúœÏ„ÆXŒYŽ“©þ¶ãÐ7­ÌGïšÐÏPÚÚÚBBBär¹H$úôÓOé ‘HôðÃ¥§§ÿý÷«W¯¾ÿþû1ÆB¡ÉdÆÄÄ´¶¶&&&ösÓáÛú\ŸK¸@_ËqW#3’#8ÅËÞÑtÕ¹€úF£Ñøûû›Íf“Éän59rdPP…ÂÇ{L$ýë_ÿú׿þµcÇÉd¦ÿ›&ßv#W¶_MEiˆ¬µ$Ö˜ýÌÈsðóóëîîî=-Årq@6›@ÿ;v X,‹å½]}ÂKô-IÎéàƒc8L ‡BµÚÈæÓÅ'Ï—œ3pÞ™>3. ­V«T*E"‘Ñxaf©TÚ»ŒR©d0c„P]]Ùlæp8ýß4Aø¶>ä¹Î£×`• À—§áÅ,PŠÂâÍõÛšÙg\!ý¬T*kjj˜LfDDDuuµR©ìééÙµk—P(Œ¿0 ?((èÙgŸíì쌈ˆ(((hnn5jT¿·L>®çíÿ8z!É Q_Ü‹ÿrc6v¼v`šÜãâât:]{{{{{{ppðŸþô§ùóç+Š5kÖ¬_¿þÇ,))¹\žœœ, Gm0®ûÎñׇúüç+n`9 Xe¸±E]eþµ>àóùiii{öìÑëõIII  yä‘“'Onß¾ÝårÕÔÔ°X¬¨¨(©Tj³Ù ãããÉ 6‚¸®>ä¹ÕQté²õªN]@BBu:ègžÀĉ—/_Îãñx<½!”••UVVÖÖÖûöí3›ÍÀãñÇ3Ï<ÓßMÄ€×c©¬¬Ü°aC^^Þ¤I“Ü õz}yyyDDDdd¤Ýn?~üøž={f̘1lØ0†Jƒ…×å9ÔÖÖnܸ122rܸqAAA!„PuþüùÔÕÕÍœ9355ÕÓ‘ÄààyZ­vÏž=µµµr¹\¡Pðx<›ÍÖÙÙ©Ñhbcc§L™"“É®ÿ.A€×æ9M§Ó;wN­VÓ=g‚‚‚bbbH†D_yuž1 ȈN‚ð}$Ï Â÷y2Ï].EQF­V_¹Öl6·´´\ù/.—‹\kDŸxl°—Z­>v옟Ÿ_zz:Ƙ¢(Œ1ƒÁ ç‡Â›Íæöööððp—ËE/€õëׇ††šL¦É“'»—Óå™L&Ƙ~á©"ïä±<×jµAAAÆ kllt8UUUƒ!..îܹs cÇŽ€šššÎÎN3f ‚ÜÜÜ-[¶tvvÖÖÖÚíöÄÄDºü´iÓÊÊʸ\nFFÆe£ÜâÎcç퉉‰`Ó¦MôI8—ËÍÊʲZ­,Ëáp$&&úûû@uuµD"éîîv:`6›KJJrss¥R©T*U«Õîò]]],K(677{j§Â;y¬>ïèènllì=«8N³Ù,‘H\.Èd²èèèððpz> ¡P8räH(,,6lX]]»¼D"±ÙlÉÉÉäê .Ã|ë­·<²a‹U]]ïïï/D"‘T*e0ííí¹¹¹*•ŠNÝ„„„êêjŠ¢är90™LºŸŒŸŸ_kkkhhhGG]>44Ôårµ´´(•J2É Aôæuýd,KUU•¿¿lì¦¯å âÈëòœ ˆçá~2—]œ_¦¶¶Öh4^yÝnœ#âÚ<œç›6m¢´@Q}œ¾—N7ÂÕ×׳Ùl>ŸôZ÷ªÓ§Oï³¾ºR¿òà„òd{•Õj‹Åuuuñññ›7o×jµQQQÍÍÍ.—+-- L&S]]]}}½Íf‹‹‹kiiéêêJHH(--µÙlL&ÓåríÚµ‹Ïç+ŠŽŽ‰DÒÞÞ>cÆ º_ AàÙú¼ªª*66ÖårÙív‡“™™I?J-"""//¯¾¾Þ]R£ÑŒ=:888<<\(êt:…B‘­ÕjU*Uppðرc\.Wzz:ý°ÏíAxOæycccgg'‡Ã©©©±ÙlV«•¾Þ6™L—õi³X,jµº¼¼<::0Æô¹ºŸŸ_WWWOO—Ë¥KÒ…ºå{CÞËc÷Ï1Æ\.wÈ!111&“©««Ëb± :Ôjµž?žÍf2„¾[Îårccc«ªªè‡¥2™L¹\.‘Ht:H$ e±X---999G*•2™L‰DBz¹„›·T}õõõ111ÐÝÝ¢;½1 ¼%Ï ‚¸yÈ<áûHž„ï#yN¾ä9Aø>’çáûHž„ï#yN¾oÌ»B@ä7‰ n÷æ¹ CMl©Çåjpa`"H‘ÃÄH”¥ŠF}á¥ýáN©áåB¼·§ÊQn Ê eøäy òC$;AüN^—çàë xa/¾/ªº CO@Ì_sº^ÿ>ŽÿwÞþ2˜$× âw¸ñóv 7¥F]]/ìÅŸND§@i'ŒZƒCDø/9è¬~>‹«µ`sAœ?¼yˆb3/gß„ÂçÜH}î `ÃØßŠeè‰! bX4ÕZ¾Šš‰6̼ðòm<¹ƒšŽö4c|Šôƒl%„Ѳ“øðCh˜bÀ¶N¾êFêó·ŠáÿŽÐs°á_΢í÷"îõ~ý ˆT¦ ÒÍol;š°„÷'¡'‡ lå…µj ,*Ä{ï#çîq}¾Wev²'ZÜׂ·×_£x´`K=ž? •«±ÉN Þ+Áo¥¬.ˆ• F¹Á(Ky±üëyh ®×ÌÖ Â‡õ9Ï»,`rÐÿ‰\gs¨S‡Ž—ž­kè({›!V‚fÆ“BOíij7ã7Š0‹žÉ@§Cÿ‹ìÁ_W…Á…býax0ZwÆ»Ú  õù¼=\ ~¨¹‡Pmll0]°µñÑŽ³Ÿ<ÝÏPŽª`XðY åÁšê ÙûÔPød€gÒ!D„žÜ´‚ƒ‚…Y(+2è\7És‚¸Žécöî(Äa"¸ÌHø~…d¯)¦ÛÉé(f ¡B*ÓÅÔ ],pg,¼7}]WWá{7aP¡ÍØÿ-„ë[ž;)¨Õ„Høaš˜0FÐȱG3;GLjû ‡ 'H¹Àb\lZã]z‹\Êz^öv#6:€Â$èÿ– ÂÇõá¼X¸//Å~\xsãýÉbM{|IeýÏ ÜYãGô?”xØZab˜ÛêÃD. ÊÕØè@î[w3bàÙ tâ<Ü÷oÂä‡ôËáãúpÿüçsp÷Ï[Úƒ…(…Ù…~wzr'iò KƆÁчPô Ün„6#Î^C RlßTâ÷Æ0Hw‚¸®>äùÊ)WVþB„ïÈA,¸´aü†Ý“«ªÑžFÌí‡ ‹sKŠq~Üš P§ƒõµT~(zbÈl” |ž×W3ØáîŸ±Ê ŸLDù!À¾ô·ÅAÁÉóð§˜Ç‚­³PßCQÄ âuyf'üe?þ¶F‡Á¬x”¡€P1œ7AY'¬?ƒ [à¾Døð6$€{öñ‡àyN+ë„•§ñÏç@o»°D·©ÑðÄ”äÑÈb°ñÞŸOÿÙÓÓ3uêÔ²²²¦¦&£ÑÈ`0ø|>›ÍæóùóæÍc³ÙžŽš ¼wåùáÇ:òàƒÒK´ZíçŸN_¢3™Ì„„„¸¸¸   .—ûõ×_çäääçç{6f‚ð~^ÔšEQTIIIzzzMÍ…g)j4šuëÖ¹\.„“ɼÿþû£¢¢Üå'Mš´yóæáÇ“69‚¸6/º>oll´ÙlYYYÝÝÝ---íííßÿýùóç…Bá´iÓd2ÙÎ;F£»|bb"ƒÁ8{ö¬c&ˆAákÂ=UA’ RåÀ ßŠÖÖÖ™L& ¿úê+ú‚B.—ßÿý)))ëׯ_³fͬY³œNg@@“ÉŒ‰‰immMNN˜ÂGÝHžo®ƒ‡¶R;°ðZ.z{$‡›uwwûûû[,‹Åân5ÈÎÎ >ŸÿÀlÞ¼ù“O>ÁgggOŸ>]*•ªÕê#GŽ8Þõœt&“)•Jår9—Ëåp8<ÏÓh}Îs Ãü]Ø`pRðîa|g,ÊQL4@§Ó¹\.÷«Õê~Íb±âââÊËË ´´túôéL&!tôèQoËs§Ó©ÕjS¹œ IDATµZ-ƒÁˆÍÌÌLHHàóùžø#êsžw˜ Íˆˆq;·œF‰¶ŸŸ_?C‰DZ­V¡PH¥Rº¯+ˆÅâÞe‚ƒƒÝnçóùF£Q(>ÿüóýÜôÍc4›ššª««×­[g·Û§L™’ŸŸOªwâc¾õÖ[}ú6>.› äX›ê¬z2JuºI}®Y5nhT?C1›ÍåååyyyMMMZ­6&&†Édž9sF$bŒ †@ HII‰7nÜ©S§jkk‡ ÜÏMß<G¡P¤¤¤Œ?>..n×®]\.744”Áð¢FP·ÝHž÷ØáP;(¨NòkÁ'œ±KÛ=yQý ÅÏÏoÿþýÑÑÑ%%%R©tÞ¼y©©©jµº¸¸X¥RUVVêõúˆˆ>Ÿ/“É8ŽX,>zôèÔ©SKõ(•JGøõ×_×××gdd;‚Ä­Ñç<×ÛaDrRФsźêË\1N ¦ÈÏOHìg(,«§§§²²R­V2$66–Ãᤤ¤ÈåòÇ«Õꆆ&“Iw˜±X,»víR*•Æ ëçvo1¥R9bĈ¢¢¢¬¬,.—ëéˆß×·þp«ªàÝ#8\ KF¢¡P¦æPMÇNѧ¥Hý„ýÆd2}öÙg.—kĈcÆŒq/_½zu]]‡Ã‰DV«•¢(Œ1BèOúÓ Íb·ÛW®\ÙÜÜü·¿ý4Î7[êó25Üý ¥2A½VWáSj8Ûi‰âè¶·pí’È‘¡psÃáÈåòªª*™Lí¾‚‰‰‘Ëåùùù'NLOOÇ·¶¶Î˜1#""¢ÿõ&“™]^^~âĉáÇ“kuâ¦êCž/,Ä¥ç/¼va¨ÕÂy­~¼¼ûh'{GwèÂlĈÛèÅÅÅMMM‰„Ïç3 .—Äãñ:::víÚU[[{ûí·2d¶ç9¡aÆmݺU¯×§¦¦z:—õá¼=ðJc¹d‰ ë^ Øÿƒ*ü$;óØ\FvЀ…ÕÝݽwïÞºº:‘H  ƒV«5 qqqãÆ“Ëå¶1R«Õ/¿üò;ï¼3xÏMïׇ<ŸWp•’”FËS€žÍ°Þ2n===---Æjµòx<™L>H/ȯaãÆÕÕÕ‹/FÓ± .ç]ãRÿ˜l6Û /¼ðüóϧ¤¤x:Â7‘æÏãr¹wÞyç®]»<á³Hž{…¼¼¼²²²Þû by2ÏF£ÅbéììT©TW®5™LõõõWþ‹^¯·Ûí·$À[G&“Éår÷1°<–ç%%%•••<Ïç[­V£Ñh·Û1ƃA¯×FµZMÿéNì‚‚‚†††½{÷RÕÓÓÓ»¼Óé4›Í&“ÉS{ÔOôítOGAø&õ¯¶X, 55µ££ÃápTUU%&&ªTªØØØŽŽŽsçÎ=NŸ>íp8ŒFãØ±c€ÃáDGG777wuu577wwwÇÇÇÓå'L˜PYY)âããé!ëƒKXXXYY™§£ |“Çê󘘥R¹sçNú \.7%%…žÂÙ`0$%%Ñ#RëëëY,–Éd¢¯]ív{CCC~~¾@ `³ÙÆ]^«Õ"„Øl¶Z­öÔNõ‡X,ÖëõžŽ‚ðM«ÏÛÚÚ8‡Ã¹ìÆžÍf³Z­6›Íáp€B¡ R(ôã–ø|~FF8p ))©¾¾Þ]>$$Äh4FGGÒîâb±X§Óy: Â7y¬>ÐétYYYJ¥2888==† ¢R©rsséÑæ±±±Ã‡ïîî¦sèb‘‘ÑÓÓ“í./‰BCCÕj5‡ÃñÔNõŸÏ7›ÍžŽ‚ðM«Ï¹\nïNÝôt4¡¡¡õõõQQQîkìÞÓ<†……¹ËÓÿH—w/¤"}–ˆ›Åëæ9àr¹}š¿µ¯å âÈÃýd,Ë5:‡”—— ††††+WÑ­wƒá&G¾ÂÃy^PPP[[ îûçN§Ób±Ð ÜÖÖÆf³E"‘Ëåêééq:V«•^uêÔ)8wîØl6ƒÁ€1¶Ùl‹…\åÄe ¼çþ9ÒpŃ–‚è?Ò(M¾ä9Aø>’çáûHž„ï#yN¾ä9Aø>’çáû¼åþù5Ø]€‡ééPbpòÞ<7:à„ ñi5¸00DKà®8” ’A91AxŒ7æ¹ ÃávX|—¨ðˆ`4&±ठ¬?´ ‡‹aI>š ,òÐA‚ø}¼.Ï]–—ÂÛ‡ñ} Àf¢h ¼18šP½þsØ‚_È‚7F i^ ˆßÁëòü“Rx£9Ý—§Ô0þG,çÁëypJ ›ëpuXœ àÿŽb ·F©Ô âºn<Ï›{àÃXciÑèÞDªµ´þvˆ†îKH„SÐÜ­Ô‘vT¢ÂvPœ?ÜJãÝjj cDðl— |Û æyn߀+5ÖÔàãÕáÍE8/© ÆNA›(Œµc!f1æ¦@jÀ…µ=è•B|p"U:A\Û ÖÂ[ë€NrpQð%”ÙÙßPÎé`wžŸN«±Ñv À ÷QNŒ³ƒ‡‰Rä,¿Xþ¯y¨¤×tõw»áón°>o5^x!ÆÆWƒ Ùÿ·ÙþÈÄt?±è†C)l)š н¿`¶Öc ½˜oæ£uµðÊ~¬¶ ç3ÂÀeB¸rƒÑ¦:œ,'5:A\Ë æùØp`  0¤;NÕ±bÿ_¶fQIPj@ùm£óo8”çah ° `ÃŽÆ ' ÏfÀ{£<œ!"ôèv|¸LøËpt[8d¡3ÝØh4zÛ„pô9"W„¸Á<®„‡“ÑêÌJÅP.< ­ %v5ö'«P¡åbÒJ¸ Œ€7óÑS;((WCÛ3H·Æ.û|@Oë=B‡ËåúùùEGG§¦¦Êd²Aú€GÂÜHžw˜ \ oD¹!è\CL@sq 3a:«:,ò*“·ý~|˜$ YRÝŸ{I},¼pÑeÁfäpA€çÉ'Ÿ¼ÆC]<‚¢(ƒÁ`4µZmeeåÖ­[e2YFFƸqã$‰§£#þpúœç=0}=®ê‰2´$Ý3%¼pwõ1C “'Š‹ ëO( RX[ J!LŒ„-uàÇA6µáû’Rp¡Ìä(XœË8¥Æ£CÑS;°Æ “#/Î-ç¦OŸn±X‹ŠŠÞ{ï½1cÆŒ5jP?ò‘túœçÏïÁU]jµø-8NŠ2»qP0{Ç9<¦¦÷£F¿-^;€Ï›Q«´a&:¥†¿“~ƒx,ˆòƒXxw 8¦Bc~ þoÔ èÇçó““““““Ö¯_¿{÷îGy„~;AÜ}K’vlk¸¤Åë\7ÖÚ°)ÇÊÓýj K„Z^ µZ<1F†Â³°ó^ȇ™©‰?Qã~Äçt`qP [ Òå{u]~¹èèèE‹Mœ8qùòåûöíó¶æCÂWõ­>?¡.àÒñ¡*Ù]¯æåš:éñ£7îÝQè‘mA‚ôÂ’Ì Øv/JZ‰›z Íˆ'þ±þ ›ðþXÄÕù%B·ß~{hhèçŸn4ï¸ãOGDø¾¾åy޶Ü}ebÅ"ù€®òwÆÂ=ñèÇZÜûn ïŽf¼{ ƒ±¨Å§ÕðMugš›ÒÿmzFzzú믿¾téÒ€€€#Fx:ÂÇõ-9•BP à}®á£ñ¨ÓßTB†&D€ ðp2<˜„LØÛ ËNàñ‘è‹Éˆ1˜oN‡……=÷ÜsË–-S(±±±ž‡ðe^7^M̵w¢wã?íÄÉ2˜ ÙA(HmF¨Ðà-uPÕóÒàÕáH<øg›HMM½ÿþû—-[öþûï{:—y]ž}¢> ÍI‚jà‡üñIlu |˜…>ºíâP0a„C‡äææz:ÂgycžÓRàQðÎ(¤¶@·¤<ó`PŸ¨ÿ–¹sç.]º499ÙÓ>Ë{óÜ->Ýa4::zÈ!{öìñt „Ïlw¥|Ô¤I“***<á³¼:ÏÕøƒô#IJJ2™LžŽ‚ðYžÏóOJŠÚਠà‹SWýûø…Þo>Á`n°ÄÍãùëóJ 6;ÑÞfìÂçêõxK=ªÕ¬x€ŸjÁ`‡?gÁ™nØpòBÀAÎ -˜Ÿ_W`Fi0.ÜÓ{Ò? UUUžŽ‚ðMž¯ÏÇE Â` p¸ÐÞfÈ FëÏà1ðm%€±a`sB€Ç„»à‡|®BÅë…-p¸ægÀOµƒþ_(zÛ(zÂgx>Ï'F²x¸ „o*p^8]Ðj€‰‘¤<°Á…  ¦ L1è‡7ÙÐß^õÞA 8OGAø&Ï繌 Œ ‡ÉQ"‚`!Œ ƒ£*` P aM5tš!U€ë톸¬=Emx|´áïG`rÔ ÏtGêsâ&ñüõ9|w;¾˜Œà™ô I;"ä’bO_XŽÖÔÀÔhˆ– È F¯çÝÒho&“I†©7‰Wäy_ÍIºøÚ7’œ n*ÏŸ·q³‘<'ßGòœ |És‚ð}$Ï Â÷‘<'nDcccgg§§£ ~/’çDß8Φ¦&¡PHQT[[›·= ‡¸ªAyÿœð£ÑØÙÙÂãñè?›››•J%ytœ—#yNü.Euvvº\®èèh÷C`E"ŸÏokk>4kŸÏ!çíÄõÙl¶ææf>ŸzÙ“ž™LfDDB¨©©‰ŒÃñZ¤>÷B¡Ð;¯uµZmOOOhh(›Í¦—h,°ò4®ÐÀ¤H4;ø,Ëå"‘¨¥¥% €<"Ò ‘<÷!!!.—‹¢(Ã[N²œNg{{;Ç‹ŠŠº¸‚û6ã}ÍÖÔàÓôþX\.7&&¦££Ãd2yÏ^@ÎÛ½›ÍFyÏÍ*³ÙÜÒÒ¨P(z/?¦:ÉÀEÁ²“Xe¾¸688X(677Ûl¶[-qm$Ͻˆ@ hhhðt444DFF^ÙÞ¨¿ð‚¶gu¦ëôª‚£ç/þ<ùùù………Õ××ß²P‰ë"yîEÒÓÓÏœ9ãé(.àp®þ\«üPà0†:Ê{~/çÀ—gý•Öö.ãp8„«?ˆð’ç^$##£¸¸ØÓQ\ P(®zéO§#&Øàì`¿zRQËL°».™!ãüùóAAA·*Râú¼±®±±ñºWwl6;&&æÖÄsËäåå}þùçÁÁÁžŽ$‰J¥R*•½êlpN‹²QZ:q.6¢ýp NÍ©‹ ½äÞjµr¹Ü[/q-^—ç‹å»ï¾£(Šþ“Åb………ѯ[[[Ý3¨1Œ… ŠD"ÏDysˆD¢ôôô 6üùÏöt, £wû· îùïkÁIr´x8z{ªòÄᳩM»Ö”=ôâäóV«•tó6^—çv»!4a„+«twÂóùüÂÂBŸœ5ñ¡‡úë_ÿ:cÆŒÈÈHOÇjµÚ}þîaØ×‚ ¦ ?¾ÇKÑP¤Ä²µ£ÕU0/í‘“v/äuyb±Øßßÿþý¿U`äÈ‘—Ýìñ111£FZµjÕâÅ‹=~ Úßß¿¶¶–NZ;+O_¼wQPӅ唊];¿8…ç¥]è'gµZéÞï@QÔ©S§ìvûïÙœH$JMMè¸)ÌfóéÓ§gáàààˆˆˆ›Ïuycž@ww·F£ù­µÆÝ7Ë÷<ðÀ¯½öÚæÍ›gΜééX.žºWh@o»|:ÚzfLK]Y 3§§›HÀ›ÍÖûÊÜh4nÚ´éwn‹Åb –í—»±Éƒn<Ïm.(=z;¤Ê!L<€!]]«¸ÿ¼l(…ïIHHxê©§¾üòK‡“™™éÁH¤Rimm­B¡óafÜU‹\r›½÷I»[DD„@ ¸ö†4Í5Îà¼SllìuÏ+›››oM0×vƒyî°¨ÿ·;\-AßÝŽF†l`0ŒˆˆˆìììÒÒÒÆÆFïéq3Œ5Ên·öÙg³fÍš2eНÕBãßóÛj·Û¯z;í¶ÛnëÝCþªöïß_XXxCzÌŒ3üýý¯]æ›o¾¹5Á\Û æùÁVø¤ô©Kƒ?³ ÊEÌ®eE"ѸqãÒÓÓY,VRRReeåÞ½{õz½oŸ·»?>88ø£>R«Õ3gΔH$ ƒ>u ¼nI•JEZÚ½Ó æù1•ûÿ]þXw^ËÐH9‹9ÕNTT”¿¿?]“0 ??¿èèè²²²Ü„—KNN^²dÉ×_ýöÛoOž<9??_,¾É×HWÉd:îº%N'›Íîìì¤G¤ß‚Øn™ÖÖV??¿Á;äöó<úת%ÙY ;Ÿ]ë÷Ÿ©êœô”Š ¦L™R[[[XX˜˜˜XWW'¦NzêÔ)û]›R©|íµ×JKK×®]»{÷î„„„üüüÀÀ@ÿßê‚>àRSSÏ9BèÇ\»vmNNÎìÙ³ccc{¯¥(ª²²²¾¾Þn·+•ÊÄÄÄAtstÇŽ§OŸž1cF^^Þe½³ìv{iiikk+ƒÁ INNöŸƒÌóé1'E纱ŒÒâŒÜ­çgÆ´‡2ÏÙlvVV–^¯ß´iÓôéÓe2™{•Ëå:sæLEEÅnîæ‘ËåL&³Ÿo’““SZZzðàÁíÛ·K¥Ò‘HÄd29ŽËåò’Æê¶¶6Š¢JJJŽ?ž——wÏ=÷¸WaŒ‹ŠŠ0ÆL&³¹¹ùðáÃcÆŒé}C¢¨-[¶x"êëÓjµMMMŸ~úéöíÛï¸ãŽÞgU‹åСCtCã™3gJJJ¦M›ï¹`¯âFòÜ…Á䀟î@/ía§$ÂÖ±µ;JÁÐ ü¤H$™LæNrŒ1ÆØ`00™ÌAT!€ÕjíéééÏ;DEEEEEõôô466ÖÖÖº\.‰D"‹™L&ƒÁð†ÓƒÁà~ÝÕÕe6_›Îd2çÌ™ÃçóY,–V«-**Úµk—D"INN¦ PÕÕÕåM­zý…á¸f³¹«««÷h<‰DòØc‰Åb„PGGÇ®]»6lØðÄOxÕ„y}Îs‹Þ(Â;›`Z4|91aèþ[XÂcüYéi×ÿÿ¾(--6l˜» ×n·÷®Àccc³²²v‹^Îh4–””lß¾ÉdNœ81333888((ˆÅò–~«V­úå—_âããï½÷ÞÌÌLwzÐ(ŠÚ¶m›ÑhLOO¿ãŽ;´Zíž={’’’è_(‹õè£z(ðëøïÿ«Õj'Mš4nÜ8©TzêÔ©Þku:]AABhäÈ‘÷Þ{ïŠ+ÿùçŸËÊÊ–.]ê 3ÌôŸÁ`:t(‹Åêîî^·nÁ`xðÁ=Ôp8#GŽ€¶¶¶Ý»w …ÂQ£Fy:¨KxK;íe®=<À‡¥öV\\¼qãÆÅ‹ûF’SµnÝ:»ÝÎápzzz$ɬY³¼ªÒ»1fõêÕîÎ…„„\vƒÝxcžÛl¶ððð‰'þÖüÑÑÑÇŽ»ÅQÝbf³ùË/¿œ3gÎ`’}]¡{î¹§££Ãjµ„‡‡{CCtÿI$’Ù³gÓ÷  Exx¸÷Üætóº€Àb±¬[·Ž¡%“Él6›T*íîîjµÊËËM&“§Ã¼¹6oÞ,‰¦Nêé@ B($$$$$ÄÓ 06›{Y'_oãuyÎårÙl¶{ԛͶX,\.W¯×»\.­VK/g±X^ø«9PŒFãæÍ›Ÿþyï¼IN :^—*<ïᇾî­r‹5ˆnÃôÕ¡C‡ärùðáÃ=á#¼.ÏÀã“æy܉'FŒáé(ú«««K&“ѽ¾1Æ2™Ìétòù|ú$Åáp˜ÍfO ªïµZèt:ÏŸ?Ïd2 ƒ{ £ÑÈd2½­/y‹×±Ûí§Nt]b.ƒ1Þ²e ÝËõàÁƒF£±¨¨èôéÓî‘|8&&†ÇãiµÚêêêÔÔTú›‘––ÖØØè%ó¢ ¬?ÔÀ[???÷€oNò¾rï ›ÍöøÌèõ•~üñGŒñ<àé@ßáEõ9A7 És‚ð}Þ›çuuuôÍU‚ úÉ»ÚÛ{«««£(*55µ¼¼œÉdöôôdgg—””°X¬ÐÐP˜Ÿ€ n/­Ï- “É”H$]]]QQQ,K­V÷ôô >œÔóÑ'^ZŸWWW[­Vú‰\Àd2é;êîAü~^šçf³yÚ´i ãàÁƒpêÔ)—Ë Õj:4tèPOHƒ‰—æ¹{ÌÃèÑ£;–™™Éd2].W|||vv¶gc#ˆAÇKó¼·œœú“É$IN7ÀKÛá‚@$ϽýD>‚@$ϽŽ{®[‚($ϽNww·§C | Ésï‚1îèèðt„¯!yî]Z[[é™O b‘<÷.'Nœè=gA ò•ò.ÇŽ‹‹‹ót„¯ñº~2 Îi®þ˜TZˆKÂóÍŸ§ªª*»Ýž‘‘áé@_ãuy®28WÓýV&s™(RÊž“îáÙ¶n—˵jÕªû¾¾žLæE ,¯Ës ÃP%wáÖΫ®}eŒÌæôÍ(,,¤;öÖ××{:Â×x]žÓtü<7̽ä?ÅÝ{ëLvMòsçÎmܸñ¥—^¢'±&ˆå¥y%å@aƒÙ½ÄIá()GÌõÁ+sµZ½|ùò¹sçÆÄÄx:Â7yiž‹áïµ:-FÆŽ‘±ƒÄ,§Ë§ªôööö?þxòäɹ¹¹žŽ…ðY^šç_ŸÐ÷þ3#„wWŠÂ%ljl¯¨¨øú믧L™2qâDOÇBø2oÌs ðDŽï%ùüdz%žŠçfÐëõ;wî,))yì±ÇÒÒÒ<áã¼1Ï6j½¹÷‹¡2º–ìÖXךNÄa¼»‚Çòê¦,«ÕzäÈ‘‚‚‚E‹{:"Â÷õ+Ïët`vB”ˆ9B(PÈì½ÄǨUÛ(ïyqäÅ[”ÕýùK¾µ[½h´,JÊÈhú¢(³Ùl2™T*UUUUKK Bè±ÇKLL$­ëÄ­qãyþá üïã`vâ$úr2J–XLVyi®†IXàÏc¼9AN jÔöŠó6&‚h;FÆ‘ñðÖnõÑ’#›Ï– X(ýf±X¬V+“Éd0l6;;;{ìØ±J¥’tb'n¥Ìó²Nxí¶»ŠÛðŸvÂÁ9V5íÔ§zz/á±P²‚ëþSet–¶Y@ç ZõNÉøÖ)¹^t¹+‰d2™H$‹ÅÀÓáP7˜ç{š€Nr ƆŠ6¨lq¥†Ë®÷ÃE˜ÇÀ0€ÝåêÝC.2**Jk ˆÁâÏ /’œ5I®šB*ÞY{âtõÙ‹ëšL9ŸÉ@à¢p´”­¸ôbž ˆËÜ`žÏˆÕYÆÊ°±$¨±jUÛ€Æv9'M:G“Ρ1;C$,¥˜¥³âäãÂò›ºu‚¼nð¼]ʃf ç÷Ö$¸ç_͉bdaónâõ§ŒÏ°ÑckU×('爹¤n'ˆËõ9Ï>>‰7ƒûÑ–YÈ`Z´oW‘=!UdÊ̸‰CJ âî~2‚êÕçõ£"­?ŸñXÖÅ5,Ȥ› .×ç<ÿ¦^.Ć­8?=ÈÓ;9lûN•è)Ì^ÿ ~¯()Ggu¹»¸#áXøX«•Âø‡ò¥˜5.FÀb Üp>‹$8Aü†¾%‡‚·Š1]©RŠZñs{¨ÂÀç'¥9Âä­‰òq1W¹8otÖ› ë-ÏäJïJÖ[ ëÍz«ëÊ’AÐúVŸ—uB«áòd6#æ·2B·ÔÃ’‘ŸÍ#üúDŒ‹€Õ‰—ì逼pxcüÀuÊ!ˆ?€¾å¹Áã".ï#ÀYvÜň&0 5v;®Z“@aƒy\4émB}Ó·<Ÿ "¯ì÷&H¨€‚DÌÏî ¨w#¼|^ç³]ä–8A çù“ëUoîÖ|^¢»êxÓe‡´ô‹Ëº»_©­Çùò¶«OI„‡ÇŸ³ðÆø€¯Nèv59)¨VÛFFòŠ-w§Šàh«µ£Ç¹â˜Þ…!Âÿÿ³wßñQTkÀgfûfÓ{o„„$HBï ˆQÑkA½zírDõ¾Ök»ö‚AAé½$„H!½×Íîfûœ÷ ! $Î÷ã³³³gž™ggæÌ™sÄ M>öâQÁе§MVzg„*ÒC`]†¦^/Té¬ùuæÔ2CµÎúXœÃštBLäbòЀÕGÃ\¯Î?oq¬;Ô9!_ÿIBÝÄ0»ÎýŽ ÓéHwâ§°Á’^n˜q™>Vlàâ}e>?RÿìP§›]{­Y³†R:{öì΄é9ºb?Wà( p¼üQzŸ¬åïî˜ä s#tþõ9Ã07Ës†éùXž3LÏÇòœaz>–ç Óó±9B§GÞ®xã–scéõú„„„­[·z{{ßsÏ=ìšœ¹ Ú˜–áΠ¢-§4Dµ8ªâé­Î_›2‡ÄvØm¡üz¨¤ÎÕô÷Ä‘bº=—ª¤dÛ2Ì¿£ucY­VN§ÕjËÊÊ233333årùC=-uûLwׯýlb ú¹“´*ªOJbV– “Ö7&tTX'+pç*êa‡qÁh0À* J³FÐé§ëŽwÔân½^¯V« Çqvvv¡¡¡÷Ýw_DD„L&»ú‡¦ƒ´%Ï-ÊuX3•¼žK™kí¹Ÿ@¾ÒÞÙ½£ÂªÓ£´‘6ȫɯ=bHEŠÿ ­ÖÀ££üìšuÔân‘H$•Je2™J¥rss“J¥s+ºî<טðônšPŠ‘¾xc(q‘Fîݹ-Q«õu’öŽè¨°Fb㜓C|/L<\„Á¾dáHòe­ùLîÓQ‹c˜žíºóü£tÅi  @=…ˆ÷">"ÎA¼:G4µžëÓAý#ð“{]4åh M&}=àcOî\E§…•¤cÇ0=ÛõÝ S›ðõÉ /Kµt}Í®§0™,_¤vL»´ŸSqßZz¸è””rLø…&S?ðµÇ„ÌYKËì2Lw}y~¼jã¥É\˹òêâ:ÎygAÇÄt €®>Esj/,H.‚B Ÿoý]§ÇƳôÑõ´º©cÊ0=Øõ·+EøgÌ¥Zðè§ÔÓ9g¾CÛÉä7 ¹A¬7"ÜpðQ"åa¾ËÃŽÔY)ß‘ e˜©ídn¨Gþ¤?§Q¹bAý‚Ëü~hM8P€ç ±1 s%]±F¨ x¾š¿ÂcšvL »i1L÷Öçf+LÖæ¿ BÜ©Ñ0L÷×óœa˜ŽÕÍìdæ°ä w%‘‹`'… qÞuÆ¢½ˆtÇpÖ©3Ã\«îqÿœZxÙ@¹ž*\¡;)†a.£{ä9Ã0íÑ¥¯Ï†é,ϦçcyÎ0=Ës†éùXž3LÏÇòœaz>–ç Óó±ßõ(9´zùÞ|£^÷7 •G>ùõ¨U§» }ôNó÷½wýðñ)ÍÅ“O¾ùåúõ£7ÿï ±+ï¶xÌyóý(_nDá’óÿ·Mz~\ñË?¯V]]­×ëýýý[¦{¿iÓü hÖñ‚~Ó¦MJMÁ§ï½™z:î[_„›N,ýt­HLb§?u_ýÃÏÿàÚ÷ß{ッx‘Økð¬îj+'mÓÿ¾Ù™/t·=±¨OùŸK·UôŽùH¼øÂÇÇD,«ÞýDÕhä_zë]Ûg·~óÖî<­±‰>þÚ[Šœõ_¬;!%úø{_š54͇ æWI¹ù%w¿ô–8}ÙŠC%¼A?åé7n‹tý˪¥¯Ì½¿÷€!9Y\væsw7-ýð{ƒÑu_HÌÀ¢üsƒç¼8±kOàøÒÝ_¬8P PéËKÞÌ[ûÞ¦,Uo~xþÒ‚>Z~X22ú‰5Û FG)ß|ë3/…sÌ[óÇ?×*fÏÒ--_󾑽@_öÆüwšÄ2j¶tñ“'7\(?ç·wÖŸ1E¸ 9\Üsá%[1ùù©½¾xáññï½q͆!qŽ]i/UŸýäËo½j¼—Nq~ÿüzxzX4E¯½þù³ï¾}ðË7[6ÁžŸmÙ[æOqýËê6ÿï½Z‹žÎ]øfÃöÿl­ôv1•‹">š?§9 Me_üß›ygÎM|æ;Ä%KþOOdÊ Q‹ÿ5 õ.qÑ& ë–å”—žÉ¯Œ p¸î‹U‡Kx“yÚ¿4®_VZ¦Î­Ô{Úý‹ÜÜܰ°0Ÿ½{÷^òVþŸïÍýd¥”Òâ1qw–ª›N®XüÜ· ï<4i}v­¾±èžié«Å~RÝd\¹höç{ MõO›Qdn.!/iǯ¿­ýåýg¦Íû)ç÷·ï^¸\o²\ôñæ-‡6¬ùý÷ß_{hÜ›Ïmúàá¯U=6yô®gKò2‹j4s'OþÁ¸÷óçÞÛ³`æð%ú‚MïO›¿Lô÷Lº«*sí”~¤7š¾zyþ²ã_=3ñëC•‚¹xÒíOÐ+ÄóïŽ ‚ñù;ÇŸÔäïwGÑZ•ðõ=¯¯|sÎmËœ¬*Î9[\wñÊÓÜ1jºžÒ9Ãe5ššR—Ýóê/¶7„œ?¦=ÿ­påUZwzí¸ûæçWf¤T_ü]l%,Ÿ÷Ûk+* Oe—®\xï·GËuµ¹+×¼x4½Îh9ûû;Ï}› -=µâ—Õ¿®ü*nÜÃôü&Ëúõù?¥¾=g¶sjƒ¶â®qw§nýxæK_•W§¥g^²3´Þ”šäŸ†<ò¾YþxëÁOwÌ—Ùh2í˜øà»¶2?f⦢ª™S¨Õ6eüóH^É=þúû8ë™Ïw—íüø±·Òê]ç~pà›ç¬H1õK¿¥ ¿uÌ­¿fsÆÚu+YûÛªC†&×·*?'còmsêõƬ?Þ¹cÞO§–/\¸ü¥ô»'¦êͳ'Þõ÷{é»L>R'´^uÕ»&>¸ðÝçݘZzÉîÔzo)øëêª;~ÛŒ ÆüíÍ^ºvÛ'sçÿ|‚ Æ&M¯mžÃ<;.&©RSæÏqÿ'á»_ùþ¨Ñ {ãSªŠZïo²]ç~ 4&M¼÷-Á–BÅÔ‰÷UkõgÖMšû¡pzÕ´—Îo¬Ëœ·§¤¤dgg—––îܹó²G{…‡··½ÜÙÅÉdÔlüú…Kþ+±wÔ.!örIiqÁ¾5Ÿ.xým£Òñüé˜eÙ—_Y¼"#L€€ÀÞ21ÉÇ@—óße›]|ƒBýÝ zۇɢw'­ÿqá›ÿͯ«)3¨‚œär×±®ÌДW8ûùK%Ò€@_ø¶†wêJôMêÒââ¬#‹^[ðç©F%USâàå!S:ÈÝ<Ü ‘)xKmq™K@°L" ò/+/d¾îDd'ΟOý%ÿ@B$^ŽB¥J/_ çæåY[Uûô’÷ªüþÚâw2ʯp¡kçé«Ëí&cóJ! „\y•ZÃ&Ï»3èÛÞú|Íîªâ‹¾‹­„Ï¿ãT°kÉÂų«JKªC=Î!÷ÏqÉ&Pºz:Ix…ÂdÔïÿýëSIXŸ©Õ€ó›ÌVZI™9,À^ªôp±ÖÙ}ä¾ã§K_ÿiKšå¢¯qé¦ôõóâííZ][{/_•Xêácªmue©Rg¥<|Äô!A·_þ¢põ õ3ô„r~Øââ²àÐ`±DèëX^¥osë¯i›¹êÄæÕGÊB#¼]8ƒ¾UùB“ÔÝQ& êÐzo}|å½¶xZ¯‡àÜ¡í›OÕG…x^²;µÞ[üþººêJ਒JüCƒkJÊøøxƒHä¼ù¹º“ow;Ç P¡ªª¸°8u÷ò¯-*T&¹õ.qÉ&³Å‰–5g©iä=]•Ç 0CEYó[ç—p™<Ÿ0a¬Y³&L˜ðÈ#\~Oý‹~Ñ‘Ãg<µðßOß}ï®h*%*²oÔ˜Ù ¼<ýîY!JÛŒæÌbã˜A}kJK@A@.óqêâ:iH\„oIqY“NÐ;Uõļ×a¿þ@q¤³6!¯¦*ëˆài»:q .;y¢V]›”œ +«døè–ª’½Á¾Wd¨ļ¯?2kÚ¸‘ç7æ…ÿ»ôéS™žT¯Õ&K‰Šês™oø—xŽKÖª+O•K‚јwúT…úäñÔ€£Ç³î{ú•Wf÷[»þ°º4;§ìz±ã9½¶Î`º(Z¯IAšÎcØÂ7‹NîTû^æ»O<>î¡g½8cÓoÛûD†ÜŸRq.aá{+.· šåfåÅŽ­ÔWU© z^݈ ·OH.j¨8Y¢ ²œ:j?`æëK^-Ø·®‘ê’SΞŸëÒM™™–R«ÕMËéâúœ¤ìªÒãGÁa)öó´äd–ÖìùùÝßœ>§³VRVªÕëÁó:uƒÉBôŠL>xX«©9~Z`×:æÖ_Ó6¥âÜY÷ˆÁA.¤´´Nkö¼P~ ¯ÒääVÕ:pT”övÕµ•]eF^݅⮸—‚çµµšÈVëÁ ð1óÇ×&-|óUØåv'@qëÕe›ä!)J)kÐ&H ŒºüP—s<»¦4å˜}p¯è~}CL\øúü™Óîõ¿¨+•K7Ù%Ä~>–¼¬*u^ÂA·ˆ¾—¼É/Y²ä’I2™lÚ´i÷Þ{¯§§ç%oY´u¹ol/OÀRRÒ8lØ‹®¾Qæ3göÄ£®Þ—tÊ;zp¸—¢¤Ž[°ç‡’Uýúõò& öuÐüúÇ>ß!SUEÉŠ•gHß §Èøþ>îëv^\ñá­Gs'MŸžžœÓÏßÁ/ÎUúëŸ[‹,þ?xçøaá®\•t¦þ_/?ãé  r R֜ذ'5´O¤oDü„.«Wm’…ޤÙBø]#½Ö¬ß^Ø$6´¿¾º,lð0¥¡¡ž÷ŒíåYQR?yv)\ùǃÏȧïÑTSê5Ô[!”–Ô÷×xÜdåuÄ3íÐŽè;Ÿ©ücÃ1WZq PöâÓ³ísW¬ù#½‚ìñ9¹ij÷>þZZ\1tÔÐÊ’ÒøáCEfM©N54&”Ž%G¶™ÜûÈ\v•F{&nX±eRÈm÷N5:DRÑò]”bg¬X±â·”³uÌ}dìØgö¬Þs,ÊìûFŽÑj¸—¶Úd3ÆF¯_µZg=>H[B<”r—áqa&M­Ñ!dö]£Ö.ßw<÷g_ŠòQl_³|ϱô3*^ôæÏÓ¦ŽÁ_6¥ÜÝåd±A—sLë5zî´¸ ë¶ø;›·&V=óü“ΜÖèâ+×zF›Òßþ×k‹ùÀÙwÞîKs×í<>`ò]ÕiɱSïÌÞ³Ñ1´?ˆræì{Õ™[6í<yÇ“·÷÷hs¬¯Ðò5Ýí¸¥ïøíT¹kJÜñÓs³bšË¿c|¬ŸuÙo[íD§ë]žxtzöžuÇrë{…ùÆ ÞT^>pHLù÷RØKLöd?õâ?Ž_¡.|™F:ýî{¬Ù»…È»†:×´ls]yËÞ2fLÿ «ËVšÈ¹¯‡ù—5ëKøÐæN…¶JæÓ/Ä]VYRÒoØ0 ¬N#®;³éXõÓÏ=;PwzçÆ]‡á:0Ú¿¼ôJ›ÌM©p>À¿¤J>ÈV¥%tþuåšÌ Å /<äÌëK Cû4§î_¯Ï™«ÚôÁÃ_ª>ÿ*Ê´g:3š›Ç°mëžË¾¡IþiöÛ¿Ÿež=ñ.ýeç»ù²~6ï§Î¢ó±þdÚBWWa–»9Êy€¥¤´Ö×Ç£“cêTÔÐX®#Þ.*Û˲’2/_ï.Ñ·—ISª¦>nöG'cyÎ0=k÷Ê0=Ës†éù®;ÏóRJu°àœ”eúv• )ÊHÏo°ý­-9v®¾Âb˜žè:òü§Þ­výôaru,xÇOï¥Ô¶«J£=¿€ IDAT%4ãÿnÊ1UýcåÞ¼‹az¢kE_xô«–å»÷ó=¸æ³„Ò4e¿Þ|0ö“7ß*1óUÄâ…~ýÒ¥íÆ?ü×Ð+5Ì ;½é¹UÆÏ^¹»ôàßž ›êròÇ]<ýãß ƒ´ÇÞúj³Xdõ9繃|óú³Ã|.Þÿ¿7IW½õèÒ_œ0.4ÁÜ/ç³ïË"ÈâqÈ>¶éõÜUe&Ÿ/?~YøäÅÇøÈøº³§-YZe[¤ž‹½ä(€_Þ|<ø_ß w­yê©Oÿ÷áÜ+…ºüÕÇ4nî#gÌápc¶ÃÜX×z<— ôøƒ·¦€þÓ–¾÷Zâo¿•ì_žÊ zçí·Ã´;7œTWeGL~䥩}öçY_^°¨xߺڢãG+Tÿ|þ•LéoÈÞ¾<×ñw޹çôûí¹”½‡”ܤ6ü¾uÐð€w>;¸è¥o<óþgk2n6{ùµ×FE4߯ ÷‘YQ]t&lÂ?½öÀÆU› ÷.ÏTûí¥ý­Ik+m3ÔUk-,ÅÅÆ+‡Z]˜Ñwæ¿GôfIÎtW×>¾!„#€4 ,@¦lPÀTYT}üøÂIúzÑ(î¯íÆEa“çÝYûíGo5ȃç“gYôZ•YÛØËO ¼ëÔ(Ë®Óy*<ó×,ÎËûpÉBçƒïûwþ²_Þ}ãO·AÄ„4nÄšuû’ûƒ„Ý;÷yÄ!Ø!àïßK.•»ÙózssÄ”8ùzËy‹‚šÌ¥…>Aã%Ih×–Ò õínJ)dWU͹††:ó¬Ê’é¶®cç%ÔT¯¾¨¯€Àèþ~aq .œ5cúÐ~çɶj7Þxµ†ÙwÜ3é×Åó¼FÜ%•øEG<üÂüà® #Ï$í}Çco,øWÂú5¶9e½FÒ´o—#…|ôŸ?Gd›Îƒ¨«ë\eĵ È~§“j´êCÇÏõò³M´SÚÕVjÕ%º¿•0ÕeäW^ûêb˜®ã2íÛ¯DN«¶§4öó·kiÍ;îžTuélÝ]Ç{‹‹l¬(¹´Ýø˜‘'®Ð0Û\Wæ5<8( {ÂŒ§ÿ壒ÇöqY³j݉³Uq#†ù9˜[¾21%wúÃOö¶µx'r}YFȘÙúùžH«{ä¡ñ¼¶Öè2¼Ç–û¢ãÂ9‡ ¾ANuå%qÃÅPSV6x˜=Œ%Ö‰3g(ª’×nÙç:pæì1‘¶Çï<¶®YSbrõr=jЕÚÛÚÃsIÛ²MqaÞ7fC0Ì ÄÚÃ1LÏÇ.:¦çcyÎ0=Ës†éùXž3LÏwí÷Ï»Š¼¤U!·:ê©$áΈó„ÝÕ?È0·¬îTßž]÷ŽÑ]pÁÇ.2Ôדw‡a^@}T0Yak¬*á0%˜ÔñõIZÝDÇú“ÕwpÇ$Ûî"k§…ïëW" sÓ´½¾=77×jµöïß?((¨ebMMMVV–X,¦”šL¦ÊÊJvu„š×€íùô½‘ÜéÍÙûi %P‹)x‚wF¸VKX2”<¾“¾9 2¾=‹e˜¥íÇs‹Å2`À€ÖI^__Ÿ‘‘áììl6›-‹››[VVVMMM{âÛrÑ®dR N×Òÿ¥aÁ!Ì?H5&zg(’æp£ýÉìMôt«~iîì/jÏ2¦§éÈz¸”””   úúúøøøøøxÛˆ«iiií)óh9ì@ˆ9<³GøÏ1Á`¡÷%Ën'¡Žøi™Ù Ó×ÓoÓñi *tpèïÔ*vêÎ0td;JieeeDD„L&ž™™Éóí:.×¢·ŽWÀÐjôìÁ^PŠÀNŒwG­‰þs—@)Ž”’5S‰—™ešo¿]-†Í#„ØÛÛûûû÷íÛW¥Rµ'$†évÚ•çËECÿB<<<233GŒàôéÓ¾¾¾¹¹¹íY„„‡LþâN$¸Vƒþ‰8„:ÁVåŸXf N iXXXë”ÒÒÒÒ={ö¬Zµê‰'žˆ‰‰iOT Ó½´ý¾šZ­>räHëcf¯^½ üüü***x{{ÇÆÆ:99µ9¾î¢ÅÿÆ“Þ?Òs Í¡~2†{!öÂ<•M˜±žÖÐÛ½p¨w‡áå¸Ët0c2™>ü믿>õÔSýû÷osT Ó½´ëþ9¥´užs—™™¹wï^___eeeS§N ¸rW÷s–¥g#a?Ð&3]4„ÛWD7ÃÑX<„è-𳇄ƒÖ “J1¾OǼ¡Ù\Ü¥c½^pàÀ+V|öÙgr¹¼=±1LwÑ®z8Bߊ )))Z­–RZWW×ÐÐ’’ÒÎvµ3z¡²‰nÌEšN %O÷ÃòÉäí¡dm6b—ÓÁ«èô?©•ÂN g¤<ÌüíÉ€¿½—7bÄÿ¤¤¤öÆ0ÝÈuôwU‚ „††ŠÅâaÆõîÝ[$ 2D*•Òö‘3e"¨äóTÚ`ÀcÑ$Γˆ8 õÁ!äÃãTgBžIå8[OŠ58R†wуHü•æ!uuu%%% hs` Ótä}5‘H¤R©FŒaooïììLÇ«Já©„Ú„ÉAH*§䙽Ú±R ¥¾¬é*Ã\³¶\Ÿ‡;ãÿF’œz<¶ƒÖñSõT"£¹ ØtŽÎêG¢HÈx<Ù±§ˆº+±íu–#ØíIr†aÚ -y~¼Z3ž€ÞNPá*GWÜN<ÍÅ•h@!G஀“ ‘.x$šHxp¥Z˜¬»†a:T[ò<À'*°ú †ø{ÌHöÁh…óâÉog¡5£XO%ÎÔÁQЇ"ÉþbÈEà€Id_DW[lbf¬§«²ða2-b÷¹¦}ºh¯ÿØFWN!å:¼´þ|;9ZB0Äb'*¡6b ŠQ¦…ÎŒh7dÕ"ÄáÎÈW£¨~öPIp  ‚á×>|811qÞ¼yôýæ¦êê÷Ͻ”¨ÔÁhEA#Òª°ú R«°·ˆ†8âíDê«ÂûÉ4ÆÏí¡‘®øï j°â£dÚÇ%S1‡UYÔEŽŸ3¨‰5Šana]=ÏVHy.…UÀh?ä«iz5­iÂñ ø©ˆ½ö´‡—í J179¤"4á"C¸* 3wö7a˜ÎÓEÇ]:[‡Ê&ì)BJ%?˜ˆ9$–Qž#ùj<EŠ4p•C (Ò B‡¼†æË´à8ÈDØ_  *t(Ò ¸y ˆkW‡” ÓuÑës j$Ä<œ¤ @¥bF+¼íÐ`„Y€½VŠz¥h06ÿë"‡U@“ ¤<*›à$Cƒ* T’ å³ësæ–ÒEç—¤%<•^¶†Aa ¹»8Û¿àÏÿæñ¬Ñ;skëê×ç ô_—Ëó" ¦üAsê‘U‡×(€E‡éÙº 3¼q„&UtZx Óu¹óvB!áp)léîL°¯ 1y¡—#²jQÝ„‘¾ÍçöF+Zî®6"¯üTpWàh9TbplUæVÖåŽçÆÝ…0 rDnDK©§§jP z ìñvbs bë»ëžJü_qÃÇÇ)Oðuõ·GbŽ•wæ7b˜ÎÕó|´vP™ãÈûIt€Òªpº<•@˜¢\‘ÛÐ<ë»ëvbÚÃS GŒpW È}\pº¶+ÞV`˜›£Ë·°— FA^p•㵃u¬ބ#PŠá©À¾"äÔ£T‹‰ÍÝK:H/Ü]§@¾; `°ÀßZ3¶åã@ ýWÿ¶÷EÉ0Ý]WÌs¿ÞA\å°ínB€9}Pk¸ÈñT?ÂX¸*šgâ`"æp[!@°#ú¹c¨7á ¥ˆ÷D¼gsi skê¢yÞ’–nràHó<—ÎÜúîzZ|ì ã¡’ L O%ê èÕöñ`¦'è¢yÞfýÜÑϽùÝÛo c§ë Ó%ëá†éX,ϦçcyÎ0=Ës†éùnÑ<—H$‹¥³£`˜›¤§Õ·_#‡†††K&6Yq¨Öb¡ð“s^2’Pká%J¬·qºŽQ ŽÕ[ëÍTÌa˜³Hvå_ÑTµ Pëx•’Õ”ê…>ª ek…r#år™î­±šj/&×0s+¸EçŽŽŽ—ä9æŸ6DÙóƒùÏÏÄdS…€‹äïîÌí«±l­¼p^°¬Ø\k¢ƒDUFº·úïÎìÅØ\ùw3ü;Ó@ÊA%º(·²Øô×ùÔY‹š„({~m)ë"‹¹Ô-z<·å¹ -ƒºfj)G|dÀÇÑrˆ@gÅâ,ãOä[*Ìz%zan€äÑTý4Oqrƒå“(ùñ¡Â „*¹0;ÀšRÓ–ÁJÌò›(æŸ6ð„T…Gü%IõV Üé%V›…ÍW  ±à³sF_9ç&!Š5¥æ~¼‹„Lr¥ª­”[œÅ$¡ÎòttC¹ÉJ1ØY¥âT›èÏEf{†:‹¢í9;ª,FŽr°P|œk Týø÷s ß÷S¬(1=(¹Ò az¶[ôx.‘H\\\²³³[¦˜´ ¨¬:Ú«äá%#”⛳§”4˜éiªä¦{‰"U|®Nˆ¶ç†¹ð¶$`¥°|y9‡ÁN|?òeÅf/ç,!kJM_˜ÿ(™à.°¥Ò¬³ÂKFV•˜GºðN2/Tr´Îâ-#Á n¦—¨Ÿ§±ÐF3-3P“Û)³€ƒÁíãã³ÿ~‡øøxöh-ó7:¾=†-· !7(É„¸ˆ_åÜò²Xmé÷Yþ«£\^éü7Ÿúééé[·n]¼xq×lôÚš““ÓìÙ³m·lݺ5??êÔ©,É™¿× Ú]õq—.çúÖžšÓU—>¤±ö”æ×ôF£…ÊDܺ ͱb½¯½øÓ©î—-çJJKK¿úê«—_~¹ë'¹Õjµ=£*“ÉjjjôzýèÑ£ûõë×Ùq1]]7Èó’FËו46_ ¦•7ei„¹IF+Æ…¶ëŠ”RúÅ_Üÿý¡¡¡ë F)íÛ·oQQ‘ÕjŒŒŒ‰‰ñôôdsæªÚÒº«ÞˆoÒh I*§¹¾ì` F^s³ñŸO¨+4Vé¢.DlUlÕ:ë¿Gº8¶ë§*11qÛ¶mo¾ùf7Ê[Ÿ­[3ÌßkK’œ¬f‡#Ò•(Ó"£>*Dº L‹ŒZH8øÛ#½«nr4"Ôvüç5 dÜUž°lvG¸]‘Úr¥ü³Rê¥jW’[­ÖÕ«W?ýôÓÝ(ÉÁ2œ¹~mÙc{CkÆý›éáRxí÷§¨Ú„E 4ÞåZxÛᛓ´¿;4&|Lã=ñŸcpÀ]¸kn£éªäxKû_á¿8™¤}-PSSS=<¢iê©*VŠsu¦ÙÚ0W‰ÙJe¼ŸƒÈNÂE¸K\•¼Ö$äÕ™ýD¡.âÌ*“TDl§uM—ö‹\YYÙõO‰9Ž£”FF#Bvvvvv¶ÙlV(™™™vvvǯ¯¯£-n°ÌˆT¹*x OöäérjL¥jË´•TDŒ ‚bµùLµ©¨Á|¦Ú”]cΪ2ÞcÿKJci£eV´jc–F&â,ͪ¾tG¯¯¯¿q-ö:JzzzDDDqq±›››««ëéÓ§U*•^¯ ´=gëaÊö/¥T&“™Íf&“I&“Ù¤å8î»gzª.:î’E@“Y°uçj»®6X¨•‚RjkÙn²Rž@ÌI‰8ƒ¥ù_‰ˆHx¢7SÈŤÉLpÚI.:yY¹r¥L&»ë®»:ãû]Ennnuuµ]`` ÅbIOOW( ƒ ÊËË3›Ížžžz½¾°°0$$$''§W¯^¹¹¹ýúõkhhP«Õööö...'NœÏÉÉñ÷÷ogGL·ÖEóü&øî»ï|}}o¿ýö΄an¸[·ÅÅ-ûÇÜ‚:?ÏufúÀšòÏŽÔ—ÜðÕц+Ív²Üøü¦æÎÞ¬ò¯u˜¤‡/?^zMƒv5eee¶ÇËõz}jjjvvvVV–Õj-,,=\×ËEb²RÅö³º‘AŠHàLW IDAT©¯ƒ¨¯§ôÓ#õ. ÞEÁÿ÷pˆ#‰E†ñ¡vó·_èãuOžî™!N¥j³ÑJÝ•üº ͤ0åâÕ®J^cÂ\%YU¦C]qÅÆÆÆÐÐÐŠŠ Û˜6ljÅb[͹íîzËœf³Y¥R™Íf“ÉTWW·bÅŠ“'OîÚµ«©©é×_MKKÛ½{w;{Ýeº».”ç²kLan’ìSr‰ž#‹¹g‡8¯IoÜ|Fc{÷X±^!!RsD*"•Æ^®’Þn’ŒJ£„'ÁÎb§1 ­ ܘ¥õ²m;«“ð$ÐI¬’r¥àd"ÒËUœ[Ó»OÌÎÎ6™LR©´°°Ð6åôéÓ:nÈ!—Ì™››kµZU*ÕéÓ§•JeLLŒJ¥²ÕØGGGGEE:uꦦ éüûçz3Í­5í?×t¼Ä`²Ò ½”Z£ 7S«@åÜ–³ÚþÞ²ýùMgkL w?‡'ÿ¨øüNYø>Yýð‡©jÄ:H.1ˆ8 ñ—ÛJÞ“×ä¢àïí«òw/ÞUýÑd÷ŒJãštMˆ‹ØÖëÔÉrc…µ©Í½ÐÜP"‘È6>ÄÁƒôéÓç²=ØÆÈÈȰ°° 6ôë×Ï`0PJu:­¯¸¦¦&­Vkgg÷×2·ŽÎÏs™˜|;Ó#€\L8‚»£U-7ÀÄVб¡ Žßï÷–‰¸Áþ2¥„ûlªO ‘8_…˜$èã}e3£ìåçÛ±  ˆó•­›ãÓ úzJ§„ÛÍŒjî:.Â]òÜD·KÚ½w¶v¯ÊÊÊ\]]‹ŠŠ‚‚‚ÔÕÕÉår[›õõõ¶n^M&“‡‡Guuµ\.ÏÌÌôõõµuSUU¥ÑhºàÈÌÍÔùyN€–þ[¦´äžTt! ÅR€˜çÐj†–?*µ‹@íZõß.;ÿY1±”;QjPI9™ˆˆ8”5Zxé‚IÀÓÓÓÖ&×ÑÑqÔ¨QlCYÙ„††¶ô~ãäädë!_©TŽ=º¡¡A«ÕÙºs·5˜ éŒ/Át!·n;™o¿ýÖÏϯ‡µ“Ávx·õœa2™.©´cnM–ç Óó±–ç Óóµëyµ²²²C‡]¶¯ŽãÜÜÜlF·ß¢ÃØœwMÏÏÚI°|2 슃¦1L§i{ž—””¬X±bðàÁ¶ŽZTWWçååèÀ±~Vž¦ùêk}N¾@Íòœa.ÒöóöšššY³f5Š´rèСââbwwwwwwžç÷ïßß!ýX܈Î0 ƒmÈQ†éñÚuÞn¦¯ååÉ“'}}}³²²lùSRR’’ÛÞ0!Þä‘W™§V… ×ú‹PTT4vìØö†Å0ÝAGö'SYYI‰ŽŽö÷÷àääTVVÖÐpÅ!V®Kog<s•¾Ü ¯5Ïkkk+**ÂÃÃ;"4†éê:²¾RÚúšœbT ÑQ~øá‡I“&Ù†f˜¯í;ºB¡8{ö¬V«m™¢R©”Jå©S§E"QnnndddWË%Jé²e˪««_xá…ÎŽ…an’¶'aXX˜J¥Òë/ Zäçç—ššêììÌó<¥ÔÓÓ³°°pôèÑf+$ãi´B;1f‡ã•ÄOu­ŸÍÎÎ^¾|¹Õj]°`ë#‘¹u´ë`ëååuÉŽãvíÚeggg4ÍfóìÙ³ÛSþe¹)0ÒÁ(ÕbÅiúÛY츛ôs¿0Ão_½»¶±ì¯4 <ÏOš4éŽ;îh=!ÃôxyRm4F£««ëðáÃSRRÎ;Ò±—èÿˆÄ½½I…¾*Ì‹'·¯£³6ѬG ¾’nâ}ÿŒs¾ÌЈ¶¦;]í:‚an‚ŽÜé¥Rém·Ýãîî 44Ô6¨HÇ*PcÂZZÔ{ü1|9ŽÜ¾NØ”G¦7PGgŸ_,Ãtcp³%9€ŽMòÄ2<µ’Êin=[ÿKÂòÉÄAŠwÒDcºEÇ–a˜¿×=NbïêMŽ•#£†¨Ô]˜®5•MÍïŽö#¬Ñ+Ã\¬{Œ¯&P´D™TŽÛ× j#ìÄØv7§#v¹ðÝîá¨æø®82"Ãt¦¶äy­Ÿ§Òpg²¿˜~}ÛõeÕö|L ºÞ^ªª ©Uˆr…£·ýN‹‘=—(ºÇ© Ãt‚¶$Gz5Df‡#Γ(jĉJ8`€;Š‘R) $W€'ðPÂS‰œzôqJ‚S³@¦¶c ÞÝ…H«B€=~?‹oNÒܺõ.Ž%9Ãü¶´{á Lû“þ‘óÒ)!XžIëxí ƒaNX}†Þ žàãd:%ÿ9wüíÛ{<ßQ@_;$Ü·Exi¿à©DÂ}Ümí*az¼¶«ôx¶?QÄ‹ûèéZR؈M¹èãBšÌ0Z!á01œ¤r@•sïIÍáÄíkSÿŸ‘äõÁ¤BG\åìjœa®®-y^Ý„uÙ4Æ(DvÄÌ0‚F\åè‰?sPЈ{z£Pìz„9!Î"F+‘ñp”âããxªäm=Óæ ¤p¶ñã s jc}»YŽ€'«péßÙ Ž@ °Òæ¬mï!a˜ëÒ=î«1 Ó7ûÀúIxjý%KŽ@kî°bk ÷=ZŽ:¦­§–¥ÇÊ/Ìð¿4¬ÌºÌïßL“Ê ££VÓ_Ïà_»iUžÞMß;Öaá1Lçâ—,Yr3—g°@&£ф'X‘…^NØW„2-‚`°ùrêˆ?s‘§Æ¾"8ÊðûYøª`'ÁÁœ¨„»™58R†ceà8x*@!B£ NR’SôjLëEÊuä¶€ š¤VÒ" q•Ãþü©5zÔèq “ƒˆ»ÛòñÖ0R؈Zq’!™„8ÞÌuÃ07J§](Ǹ#½šjMÐ[ðÛYšU‡ŽÃÇöü˜{(¦½ð~½#Ÿ¦ ± '«èÄ@ÌÛOxà»tzo8þ{âÂEÇ„@²«jL¸=ˆ*èE°RLÅ«.|dožîO*t0Z Î€µÙ8[‡Q¾7{m0Ì ÕiyžU‹pg|•†a>êª&œ­£™5(×!À[^-]+)-----END CERTIFICATE-----', public_cert_content, re.I) if public_certificates: return [cert.strip() for cert in public_certificates] # The public cert tags are not found in the input, # let's make best effort to exclude a private key pem file. if "PRIVATE KEY" in public_cert_content: raise ValueError( "We expect your public key but detect a private key instead") return [public_cert_content.strip()] def _merge_claims_challenge_and_capabilities(capabilities, claims_challenge): # Represent capabilities as {"access_token": {"xms_cc": {"values": capabilities}}} # and then merge/add it into incoming claims if not capabilities: return claims_challenge claims_dict = json.loads(claims_challenge) if claims_challenge else {} for key in ["access_token"]: # We could add "id_token" if we'd decide to claims_dict.setdefault(key, {}).update(xms_cc={"values": capabilities}) return json.dumps(claims_dict) def _str2bytes(raw): # A conversion based on duck-typing rather than six.text_type try: return raw.encode(encoding="utf-8") except: return raw def _clean_up(result): if isinstance(result, dict): result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not return result def _preferred_browser(): """Register Edge and return a name suitable for subsequent webbrowser.get(...) when appropriate. Otherwise return None. """ # On Linux, only Edge will provide device-based Conditional Access support if sys.platform != "linux": # On other platforms, we have no browser preference return None browser_path = "/usr/bin/microsoft-edge" # Use a full path owned by sys admin # Note: /usr/bin/microsoft-edge, /usr/bin/microsoft-edge-stable, etc. # are symlinks that point to the actual binaries which are found under # /opt/microsoft/msedge/msedge or /opt/microsoft/msedge-beta/msedge. # Either method can be used to detect an Edge installation. user_has_no_preference = "BROWSER" not in os.environ user_wont_mind_edge = "microsoft-edge" in os.environ.get("BROWSER", "") # Note: # BROWSER could contain "microsoft-edge" or "/path/to/microsoft-edge". # Python documentation (https://docs.python.org/3/library/webbrowser.html) # does not document the name being implicitly register, # so there is no public API to know whether the ENV VAR browser would work. # Therefore, we would not bother examine the env var browser's type. # We would just register our own Edge instance. if (user_has_no_preference or user_wont_mind_edge) and os.path.exists(browser_path): try: import webbrowser # Lazy import. Some distro may not have this. browser_name = "msal-edge" # Avoid popular name "microsoft-edge" # otherwise `BROWSER="microsoft-edge"; webbrowser.get("microsoft-edge")` # would return a GenericBrowser instance which won't work. try: registration_available = isinstance( webbrowser.get(browser_name), webbrowser.BackgroundBrowser) except webbrowser.Error: registration_available = False if not registration_available: logger.debug("Register %s with %s", browser_name, browser_path) # By registering our own browser instance with our own name, # rather than populating a process-wide BROWSER enn var, # this approach does not have side effect on non-MSAL code path. webbrowser.register( # Even double-register happens to work fine browser_name, None, webbrowser.BackgroundBrowser(browser_path)) return browser_name except ImportError: pass # We may still proceed return None class _ClientWithCcsRoutingInfo(Client): def initiate_auth_code_flow(self, **kwargs): if kwargs.get("login_hint"): # eSTS could have utilized this as-is, but nope kwargs["X-AnchorMailbox"] = "UPN:%s" % kwargs["login_hint"] return super(_ClientWithCcsRoutingInfo, self).initiate_auth_code_flow( client_info=1, # To be used as CSS Routing info **kwargs) def obtain_token_by_auth_code_flow( self, auth_code_flow, auth_response, **kwargs): # Note: the obtain_token_by_browser() is also covered by this assert isinstance(auth_code_flow, dict) and isinstance(auth_response, dict) headers = kwargs.pop("headers", {}) client_info = json.loads( decode_part(auth_response["client_info"]) ) if auth_response.get("client_info") else {} if "uid" in client_info and "utid" in client_info: # Note: The value of X-AnchorMailbox is also case-insensitive headers["X-AnchorMailbox"] = "Oid:{uid}@{utid}".format(**client_info) return super(_ClientWithCcsRoutingInfo, self).obtain_token_by_auth_code_flow( auth_code_flow, auth_response, headers=headers, **kwargs) def obtain_token_by_username_password(self, username, password, **kwargs): headers = kwargs.pop("headers", {}) headers["X-AnchorMailbox"] = "upn:{}".format(username) return super(_ClientWithCcsRoutingInfo, self).obtain_token_by_username_password( username, password, headers=headers, **kwargs) class ClientApplication(object): ACQUIRE_TOKEN_SILENT_ID = "84" ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85" ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301" ACQUIRE_TOKEN_ON_BEHALF_OF_ID = "523" ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID = "622" ACQUIRE_TOKEN_FOR_CLIENT_ID = "730" ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832" ACQUIRE_TOKEN_INTERACTIVE = "169" GET_ACCOUNTS_ID = "902" REMOVE_ACCOUNT_ID = "903" ATTEMPT_REGION_DISCOVERY = True # "TryAutoDetect" def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, token_cache=None, http_client=None, verify=True, proxies=None, timeout=None, client_claims=None, app_name=None, app_version=None, client_capabilities=None, azure_region=None, # Note: We choose to add this param in this base class, # despite it is currently only needed by ConfidentialClientApplication. # This way, it holds the same positional param place for PCA, # when we would eventually want to add this feature to PCA in future. exclude_scopes=None, http_cache=None, ): """Create an instance of application. :param str client_id: Your app has a client_id after you register it on AAD. :param Union[str, dict] client_credential: For :class:`PublicClientApplication`, you simply use `None` here. For :class:`ConfidentialClientApplication`, it can be a string containing client secret, or an X509 certificate container in this form:: { "private_key": "...-----BEGIN PRIVATE KEY-----...", "thumbprint": "A1B2C3D4E5F6...", "public_certificate": "...-----BEGIN CERTIFICATE-----... (Optional. See below.)", "passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)", } *Added in version 0.5.0*: public_certificate (optional) is public key certificate which will be sent through 'x5c' JWT header only for subject name and issuer authentication to support cert auto rolls. Per `specs `_, "the certificate containing the public key corresponding to the key used to digitally sign the JWS MUST be the first certificate. This MAY be followed by additional certificates, with each subsequent certificate being the one used to certify the previous one." However, your certificate's issuer may use a different order. So, if your attempt ends up with an error AADSTS700027 - "The provided signature value did not match the expected signature value", you may try use only the leaf cert (in PEM/str format) instead. *Added in version 1.13.0*: It can also be a completely pre-signed assertion that you've assembled yourself. Simply pass a container containing only the key "client_assertion", like this:: { "client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..." } :param dict client_claims: *Added in version 0.5.0*: It is a dictionary of extra claims that would be signed by by this :class:`ConfidentialClientApplication` 's private key. For example, you can use {"client_ip": "x.x.x.x"}. You may also override any of the following default claims:: { "aud": the_token_endpoint, "iss": self.client_id, "sub": same_as_issuer, "exp": now + 10_min, "iat": now, "jti": a_random_uuid } :param str authority: A URL that identifies a token authority. It should be of the format ``https://login.microsoftonline.com/your_tenant`` By default, we will use ``https://login.microsoftonline.com/common`` *Changed in version 1.17*: you can also use predefined constant and a builder like this:: from msal.authority import ( AuthorityBuilder, AZURE_US_GOVERNMENT, AZURE_CHINA, AZURE_PUBLIC) my_authority = AuthorityBuilder(AZURE_PUBLIC, "contoso.onmicrosoft.com") # Now you get an equivalent of # "https://login.microsoftonline.com/contoso.onmicrosoft.com" # You can feed such an authority to msal's ClientApplication from msal import PublicClientApplication app = PublicClientApplication("my_client_id", authority=my_authority, ...) :param bool validate_authority: (optional) Turns authority validation on or off. This parameter default to true. :param TokenCache cache: Sets the token cache used by this ClientApplication instance. By default, an in-memory cache will be created and used. :param http_client: (optional) Your implementation of abstract class HttpClient Defaults to a requests session instance. Since MSAL 1.11.0, the default session would be configured to attempt one retry on connection error. If you are providing your own http_client, it will be your http_client's duty to decide whether to perform retry. :param verify: (optional) It will be passed to the `verify parameter in the underlying requests library `_ This does not apply if you have chosen to pass your own Http client :param proxies: (optional) It will be passed to the `proxies parameter in the underlying requests library `_ This does not apply if you have chosen to pass your own Http client :param timeout: (optional) It will be passed to the `timeout parameter in the underlying requests library `_ This does not apply if you have chosen to pass your own Http client :param app_name: (optional) You can provide your application name for Microsoft telemetry purposes. Default value is None, means it will not be passed to Microsoft. :param app_version: (optional) You can provide your application version for Microsoft telemetry purposes. Default value is None, means it will not be passed to Microsoft. :param list[str] client_capabilities: (optional) Allows configuration of one or more client capabilities, e.g. ["CP1"]. Client capability is meant to inform the Microsoft identity platform (STS) what this client is capable for, so STS can decide to turn on certain features. For example, if client is capable to handle *claims challenge*, STS can then issue CAE access tokens to resources knowing when the resource emits *claims challenge* the client will be capable to handle. Implementation details: Client capability is implemented using "claims" parameter on the wire, for now. MSAL will combine them into `claims parameter `_. 4. An app which already onboard to the region's allow-list. This parameter defaults to None, which means region behavior remains off. App developer can opt in to a regional endpoint, by provide its region name, such as "westus", "eastus2". You can find a full list of regions by running ``az account list-locations -o table``, or referencing to `this doc `_. An app running inside Azure Functions and Azure VM can use a special keyword ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. .. note:: Setting ``azure_region`` to non-``None`` for an app running outside of Azure Function/VM could hang indefinitely. You should consider opting in/out region behavior on-demand, by loading ``azure_region=None`` or ``azure_region="westus"`` or ``azure_region=True`` (which means opt-in and auto-detect) from your per-deployment configuration, and then do ``app = ConfidentialClientApplication(..., azure_region=azure_region)``. Alternatively, you can configure a short timeout, or provide a custom http_client which has a short timeout. That way, the latency would be under your control, but still less performant than opting out of region feature. New in version 1.12.0. :param list[str] exclude_scopes: (optional) Historically MSAL hardcodes `offline_access` scope, which would allow your app to have prolonged access to user's data. If that is unnecessary or undesirable for your app, now you can use this parameter to supply an exclusion list of scopes, such as ``exclude_scopes = ["offline_access"]``. :param dict http_cache: MSAL has long been caching tokens in the ``token_cache``. Recently, MSAL also introduced a concept of ``http_cache``, by automatically caching some finite amount of non-token http responses, so that *long-lived* ``PublicClientApplication`` and ``ConfidentialClientApplication`` would be more performant and responsive in some situations. This ``http_cache`` parameter accepts any dict-like object. If not provided, MSAL will use an in-memory dict. If your app is a command-line app (CLI), you would want to persist your http_cache across different CLI runs. The following recipe shows a way to do so:: # Just add the following lines at the beginning of your CLI script import sys, atexit, pickle http_cache_filename = sys.argv[0] + ".http_cache" try: with open(http_cache_filename, "rb") as f: persisted_http_cache = pickle.load(f) # Take a snapshot except ( FileNotFoundError, # Or IOError in Python 2 pickle.UnpicklingError, # A corrupted http cache file ): persisted_http_cache = {} # Recover by starting afresh atexit.register(lambda: pickle.dump( # When exit, flush it back to the file. # It may occasionally overwrite another process's concurrent write, # but that is fine. Subsequent runs will reach eventual consistency. persisted_http_cache, open(http_cache_file, "wb"))) # And then you can implement your app as you normally would app = msal.PublicClientApplication( "your_client_id", ..., http_cache=persisted_http_cache, # Utilize persisted_http_cache ..., #token_cache=..., # You may combine the old token_cache trick # Please refer to token_cache recipe at # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) app.acquire_token_interactive(["your", "scope"], ...) Content inside ``http_cache`` are cheap to obtain. There is no need to share them among different apps. Content inside ``http_cache`` will contain no tokens nor Personally Identifiable Information (PII). Encryption is unnecessary. New in version 1.16.0. """ self.client_id = client_id self.client_credential = client_credential self.client_claims = client_claims self._client_capabilities = client_capabilities if exclude_scopes and not isinstance(exclude_scopes, list): raise ValueError( "Invalid exclude_scopes={}. It need to be a list of strings.".format( repr(exclude_scopes))) self._exclude_scopes = frozenset(exclude_scopes or []) if "openid" in self._exclude_scopes: raise ValueError( 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format( repr(exclude_scopes))) if http_client: self.http_client = http_client else: import requests # Lazy load self.http_client = requests.Session() self.http_client.verify = verify self.http_client.proxies = proxies # Requests, does not support session - wide timeout # But you can patch that (https://github.com/psf/requests/issues/3341): self.http_client.request = functools.partial( self.http_client.request, timeout=timeout) # Enable a minimal retry. Better than nothing. # https://github.com/psf/requests/blob/v2.25.1/requests/adapters.py#L94-L108 a = requests.adapters.HTTPAdapter(max_retries=1) self.http_client.mount("http://", a) self.http_client.mount("https://", a) self.http_client = ThrottledHttpClient( self.http_client, {} if http_cache is None else http_cache, # Default to an in-memory dict ) self.app_name = app_name self.app_version = app_version # Here the self.authority will not be the same type as authority in input try: self.authority = Authority( authority or "https://login.microsoftonline.com/common/", self.http_client, validate_authority=validate_authority) except ValueError: # Those are explicit authority validation errors raise except Exception: # The rest are typically connection errors if validate_authority and azure_region: # Since caller opts in to use region, here we tolerate connection # errors happened during authority validation at non-region endpoint self.authority = Authority( authority or "https://login.microsoftonline.com/common/", self.http_client, validate_authority=False) else: raise self.token_cache = token_cache or TokenCache() self._region_configured = azure_region self._region_detected = None self.client, self._regional_client = self._build_client( client_credential, self.authority) self.authority_groups = None self._telemetry_buffer = {} self._telemetry_lock = Lock() def _decorate_scope( self, scopes, reserved_scope=frozenset(['openid', 'profile', 'offline_access'])): if not isinstance(scopes, (list, set, tuple)): raise ValueError("The input scopes should be a list, tuple, or set") scope_set = set(scopes) # Input scopes is typically a list. Copy it to a set. if scope_set & reserved_scope: # These scopes are reserved for the API to provide good experience. # We could make the developer pass these and then if they do they will # come back asking why they don't see refresh token or user information. raise ValueError( "API does not accept {} value as user-provided scopes".format( reserved_scope)) if self.client_id in scope_set: if len(scope_set) > 1: # We make developers pass their client id, so that they can express # the intent that they want the token for themselves (their own # app). # If we do not restrict them to passing only client id then they # could write code where they expect an id token but end up getting # access_token. raise ValueError("Client Id can only be provided as a single scope") decorated = set(reserved_scope) # Make a writable copy else: decorated = scope_set | reserved_scope decorated -= self._exclude_scopes return list(decorated) def _build_telemetry_context( self, api_id, correlation_id=None, refresh_reason=None): return msal.telemetry._TelemetryContext( self._telemetry_buffer, self._telemetry_lock, api_id, correlation_id=correlation_id, refresh_reason=refresh_reason) def _get_regional_authority(self, central_authority): self._region_detected = self._region_detected or _detect_region( self.http_client if self._region_configured is not None else None) if (self._region_configured != self.ATTEMPT_REGION_DISCOVERY and self._region_configured != self._region_detected): logger.warning('Region configured ({}) != region detected ({})'.format( repr(self._region_configured), repr(self._region_detected))) region_to_use = ( self._region_detected if self._region_configured == self.ATTEMPT_REGION_DISCOVERY else self._region_configured) # It will retain the None i.e. opted out logger.debug('Region to be used: {}'.format(repr(region_to_use))) if region_to_use: regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use) if central_authority.instance in ( # The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328 "login.microsoftonline.com", "login.windows.net", "sts.windows.net", ) else "{}.{}".format(region_to_use, central_authority.instance)) return Authority( "https://{}/{}".format(regional_host, central_authority.tenant), self.http_client, validate_authority=False) # The central_authority has already been validated return None def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None default_headers = { "x-client-sku": "MSAL.Python", "x-client-ver": __version__, "x-client-os": sys.platform, "x-client-cpu": "x64" if sys.maxsize > 2 ** 32 else "x86", "x-ms-lib-capability": "retry-after, h429", } if self.app_name: default_headers['x-app-name'] = self.app_name if self.app_version: default_headers['x-app-ver'] = self.app_version default_body = {"client_info": 1} if isinstance(client_credential, dict): assert (("private_key" in client_credential and "thumbprint" in client_credential) or "client_assertion" in client_credential) client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT if 'client_assertion' in client_credential: client_assertion = client_credential['client_assertion'] else: headers = {} if 'public_certificate' in client_credential: headers["x5c"] = extract_certs(client_credential['public_certificate']) if not client_credential.get("passphrase"): unencrypted_private_key = client_credential['private_key'] else: from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend unencrypted_private_key = serialization.load_pem_private_key( _str2bytes(client_credential["private_key"]), _str2bytes(client_credential["passphrase"]), backend=default_backend(), # It was a required param until 2020 ) assertion = JwtAssertionCreator( unencrypted_private_key, algorithm="RS256", sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) client_assertion = assertion.create_regenerative_assertion( audience=authority.token_endpoint, issuer=self.client_id, additional_claims=self.client_claims or {}) else: default_body['client_secret'] = client_credential central_configuration = { "authorization_endpoint": authority.authorization_endpoint, "token_endpoint": authority.token_endpoint, "device_authorization_endpoint": authority.device_authorization_endpoint or urljoin(authority.token_endpoint, "devicecode"), } central_client = _ClientWithCcsRoutingInfo( central_configuration, self.client_id, http_client=self.http_client, default_headers=default_headers, default_body=default_body, client_assertion=client_assertion, client_assertion_type=client_assertion_type, on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=authority.instance)), on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) regional_client = None if (client_credential # Currently regional endpoint only serves some CCA flows and not skip_regional_client): regional_authority = self._get_regional_authority(authority) if regional_authority: regional_configuration = { "authorization_endpoint": regional_authority.authorization_endpoint, "token_endpoint": regional_authority.token_endpoint, "device_authorization_endpoint": regional_authority.device_authorization_endpoint or urljoin(regional_authority.token_endpoint, "devicecode"), } regional_client = _ClientWithCcsRoutingInfo( regional_configuration, self.client_id, http_client=self.http_client, default_headers=default_headers, default_body=default_body, client_assertion=client_assertion, client_assertion_type=client_assertion_type, on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=authority.instance)), on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) return central_client, regional_client def initiate_auth_code_flow( self, scopes, # type: list[str] redirect_uri=None, state=None, # Recommended by OAuth2 for CSRF protection prompt=None, login_hint=None, # type: Optional[str] domain_hint=None, # type: Optional[str] claims_challenge=None, max_age=None, ): """Initiate an auth code flow. Later when the response reaches your redirect_uri, you can use :func:`~acquire_token_by_auth_code_flow()` to complete the authentication/authorization. :param list scopes: It is a list of case-sensitive strings. :param str redirect_uri: Optional. If not specified, server will use the pre-registered one. :param str state: An opaque value used by the client to maintain state between the request and callback. If absent, this library will automatically generate one internally. :param str prompt: By default, no prompt value will be sent, not even "none". You will have to specify a value explicitly. Its valid values are defined in Open ID Connect specs https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest :param str login_hint: Optional. Identifier of the user. Generally a User Principal Name (UPN). :param domain_hint: Can be one of "consumers" or "organizations" or your tenant domain "contoso.com". If included, it will skip the email-based discovery process that user goes through on the sign-in page, leading to a slightly more streamlined user experience. More information on possible values `here `_ and `here `_. :param int max_age: OPTIONAL. Maximum Authentication Age. Specifies the allowable elapsed time in seconds since the last time the End-User was actively authenticated. If the elapsed time is greater than this value, Microsoft identity platform will actively re-authenticate the End-User. MSAL Python will also automatically validate the auth_time in ID token. New in version 1.15. :return: The auth code flow. It is a dict in this form:: { "auth_uri": "https://...", // Guide user to visit this "state": "...", // You may choose to verify it by yourself, // or just let acquire_token_by_auth_code_flow() // do that for you. "...": "...", // Everything else are reserved and internal } The caller is expected to:: 1. somehow store this content, typically inside the current session, 2. guide the end user (i.e. resource owner) to visit that auth_uri, 3. and then relay this dict and subsequent auth response to :func:`~acquire_token_by_auth_code_flow()`. """ client = _ClientWithCcsRoutingInfo( {"authorization_endpoint": self.authority.authorization_endpoint}, self.client_id, http_client=self.http_client) flow = client.initiate_auth_code_flow( redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, scope=self._decorate_scope(scopes), domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), max_age=max_age, ) flow["claims_challenge"] = claims_challenge return flow def get_authorization_request_url( self, scopes, # type: list[str] login_hint=None, # type: Optional[str] state=None, # Recommended by OAuth2 for CSRF protection redirect_uri=None, response_type="code", # Could be "token" if you use Implicit Grant prompt=None, nonce=None, domain_hint=None, # type: Optional[str] claims_challenge=None, **kwargs): """Constructs a URL for you to start a Authorization Code Grant. :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param str state: Recommended by OAuth2 for CSRF protection. :param str login_hint: Identifier of the user. Generally a User Principal Name (UPN). :param str redirect_uri: Address to return to upon receiving a response from the authority. :param str response_type: Default value is "code" for an OAuth2 Authorization Code grant. You could use other content such as "id_token" or "token", which would trigger an Implicit Grant, but that is `not recommended `_. :param str prompt: By default, no prompt value will be sent, not even "none". You will have to specify a value explicitly. Its valid values are defined in Open ID Connect specs https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest :param nonce: A cryptographically random value used to mitigate replay attacks. See also `OIDC specs `_. :param domain_hint: Can be one of "consumers" or "organizations" or your tenant domain "contoso.com". If included, it will skip the email-based discovery process that user goes through on the sign-in page, leading to a slightly more streamlined user experience. More information on possible values `here `_ and `here `_. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: The authorization url as a string. """ authority = kwargs.pop("authority", None) # Historically we support this if authority: warnings.warn( "We haven't decided if this method will accept authority parameter") # The previous implementation is, it will use self.authority by default. # Multi-tenant app can use new authority on demand the_authority = Authority( authority, self.http_client ) if authority else self.authority client = _ClientWithCcsRoutingInfo( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id, http_client=self.http_client) warnings.warn( "Change your get_authorization_request_url() " "to initiate_auth_code_flow()", DeprecationWarning) with warnings.catch_warnings(record=True): return client.build_auth_request_uri( response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, scope=self._decorate_scope(scopes), nonce=nonce, domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), ) def acquire_token_by_auth_code_flow( self, auth_code_flow, auth_response, scopes=None, **kwargs): """Validate the auth response being redirected back, and obtain tokens. It automatically provides nonce protection. :param dict auth_code_flow: The same dict returned by :func:`~initiate_auth_code_flow()`. :param dict auth_response: A dict of the query string received from auth server. :param list[str] scopes: Scopes requested to access a protected API (a resource). Most of the time, you can leave it empty. If you requested user consent for multiple resources, here you will need to provide a subset of what you required in :func:`~initiate_auth_code_flow()`. OAuth2 was designed mostly for singleton services, where tokens are always meant for the same resource and the only changes are in the scopes. In AAD, tokens can be issued for multiple 3rd party resources. You can ask authorization code for multiple resources, but when you redeem it, the token is for only one intended recipient, called audience. So the developer need to specify a scope so that we can restrict the token to be issued for the corresponding audience. :return: * A dict containing "access_token" and/or "id_token", among others, depends on what scope was used. (See https://tools.ietf.org/html/rfc6749#section-5.1) * A dict containing "error", optionally "error_description", "error_uri". (It is either `this `_ or `that `_) * Most client-side data error would result in ValueError exception. So the usage pattern could be without any protocol details:: def authorize(): # A controller in a web app try: result = msal_app.acquire_token_by_auth_code_flow( session.get("flow", {}), request.args) if "error" in result: return render_template("error.html", result) use(result) # Token(s) are available in result and cache except ValueError: # Usually caused by CSRF pass # Simply ignore them return redirect(url_for("index")) """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) response =_clean_up(self.client.obtain_token_by_auth_code_flow( auth_code_flow, auth_response, scope=self._decorate_scope(scopes) if scopes else None, headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, auth_code_flow.pop("claims_challenge", None))), **kwargs)) telemetry_context.update_telemetry(response) return response def acquire_token_by_authorization_code( self, code, scopes, # Syntactically required. STS accepts empty value though. redirect_uri=None, # REQUIRED, if the "redirect_uri" parameter was included in the # authorization request as described in Section 4.1.1, and their # values MUST be identical. nonce=None, claims_challenge=None, **kwargs): """The second half of the Authorization Code Grant. :param code: The authorization code returned from Authorization Server. :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). If you requested user consent for multiple resources, here you will typically want to provide a subset of what you required in AuthCode. OAuth2 was designed mostly for singleton services, where tokens are always meant for the same resource and the only changes are in the scopes. In AAD, tokens can be issued for multiple 3rd party resources. You can ask authorization code for multiple resources, but when you redeem it, the token is for only one intended recipient, called audience. So the developer need to specify a scope so that we can restrict the token to be issued for the corresponding audience. :param nonce: If you provided a nonce when calling :func:`get_authorization_request_url`, same nonce should also be provided here, so that we'll validate it. An exception will be raised if the nonce in id token mismatches. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ # If scope is absent on the wire, STS will give you a token associated # to the FIRST scope sent during the authorization request. # So in theory, you can omit scope here when you were working with only # one scope. But, MSAL decorates your scope anyway, so they are never # really empty. assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) warnings.warn( "Change your acquire_token_by_authorization_code() " "to acquire_token_by_auth_code_flow()", DeprecationWarning) with warnings.catch_warnings(record=True): telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) response = _clean_up(self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), nonce=nonce, **kwargs)) telemetry_context.update_telemetry(response) return response def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. An account can later be used in :func:`~acquire_token_silent` to find its tokens. :param username: Filter accounts with this username only. Case insensitive. :return: A list of account objects. Each account is a dict. For now, we only document its "username" field. Your app can choose to display those information to end user, and allow user to choose one of his/her accounts to proceed. """ accounts = self._find_msal_accounts(environment=self.authority.instance) if not accounts: # Now try other aliases of this authority instance for alias in self._get_authority_aliases(self.authority.instance): accounts = self._find_msal_accounts(environment=alias) if accounts: break if username: # Federated account["username"] from AAD could contain mixed case lowercase_username = username.lower() accounts = [a for a in accounts if a["username"].lower() == lowercase_username] if not accounts: logger.debug(( # This would also happen when the cache is empty "get_accounts(username='{}') finds no account. " "If tokens were acquired without 'profile' scope, " "they would contain no username for filtering. " "Consider calling get_accounts(username=None) instead." ).format(username)) # Does not further filter by existing RTs here. It probably won't matter. # Because in most cases Accounts and RTs co-exist. # Even in the rare case when an RT is revoked and then removed, # acquire_token_silent() would then yield no result, # apps would fall back to other acquire methods. This is the standard pattern. return accounts def _find_msal_accounts(self, environment): grouped_accounts = { a.get("home_account_id"): # Grouped by home tenant's id { # These are minimal amount of non-tenant-specific account info "home_account_id": a.get("home_account_id"), "environment": a.get("environment"), "username": a.get("username"), # The following fields for backward compatibility, for now "authority_type": a.get("authority_type"), "local_account_id": a.get("local_account_id"), # Tenant-specific "realm": a.get("realm"), # Tenant-specific } for a in self.token_cache.find( TokenCache.CredentialType.ACCOUNT, query={"environment": environment}) if a["authority_type"] in ( TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS) } return list(grouped_accounts.values()) def _get_authority_aliases(self, instance): if not self.authority_groups: resp = self.http_client.get( "https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize", headers={'Accept': 'application/json'}) resp.raise_for_status() self.authority_groups = [ set(group['aliases']) for group in json.loads(resp.text)['metadata']] for group in self.authority_groups: if instance in group: return [alias for alias in group if alias != instance] return [] def remove_account(self, account): """Sign me out and forget me from token cache""" self._forget_me(account) def _sign_out(self, home_account): # Remove all relevant RTs and ATs from token cache owned_by_home_account = { "environment": home_account["environment"], "home_account_id": home_account["home_account_id"],} # realm-independent app_metadata = self._get_app_metadata(home_account["environment"]) # Remove RTs/FRTs, and they are realm-independent for rt in [rt for rt in self.token_cache.find( TokenCache.CredentialType.REFRESH_TOKEN, query=owned_by_home_account) # Do RT's app ownership check as a precaution, in case family apps # and 3rd-party apps share same token cache, although they should not. if rt["client_id"] == self.client_id or ( app_metadata.get("family_id") # Now let's settle family business and rt.get("family_id") == app_metadata["family_id"]) ]: self.token_cache.remove_rt(rt) for at in self.token_cache.find( # Remove ATs # Regardless of realm, b/c we've removed realm-independent RTs anyway TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_home_account): # To avoid the complexity of locating sibling family app's AT, # we skip AT's app ownership check. # It means ATs for other apps will also be removed, it is OK because: # * non-family apps are not supposed to share token cache to begin with; # * Even if it happens, we keep other app's RT already, so SSO still works self.token_cache.remove_at(at) def _forget_me(self, home_account): # It implies signout, and then also remove all relevant accounts and IDTs self._sign_out(home_account) owned_by_home_account = { "environment": home_account["environment"], "home_account_id": home_account["home_account_id"],} # realm-independent for idt in self.token_cache.find( # Remove IDTs, regardless of realm TokenCache.CredentialType.ID_TOKEN, query=owned_by_home_account): self.token_cache.remove_idt(idt) for a in self.token_cache.find( # Remove Accounts, regardless of realm TokenCache.CredentialType.ACCOUNT, query=owned_by_home_account): self.token_cache.remove_account(a) def acquire_token_silent( self, scopes, # type: List[str] account, # type: Optional[Account] authority=None, # See get_authorization_request_url() force_refresh=False, # type: Optional[boolean] claims_challenge=None, **kwargs): """Acquire an access token for given account, without user interaction. It is done either by finding a valid access token from cache, or by finding a valid refresh token from cache and then automatically use it to redeem a new access token. This method will combine the cache empty and refresh error into one return value, `None`. If your app does not care about the exact token refresh error during token cache look-up, then this method is easier and recommended. Internally, this method calls :func:`~acquire_token_silent_with_error`. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: - A dict containing no "error" key, and typically contains an "access_token" key, if cache lookup succeeded. - None when cache lookup does not yield a token. """ result = self.acquire_token_silent_with_error( scopes, account, authority=authority, force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs) return result if result and "error" not in result else None def acquire_token_silent_with_error( self, scopes, # type: List[str] account, # type: Optional[Account] authority=None, # See get_authorization_request_url() force_refresh=False, # type: Optional[boolean] claims_challenge=None, **kwargs): """Acquire an access token for given account, without user interaction. It is done either by finding a valid access token from cache, or by finding a valid refresh token from cache and then automatically use it to redeem a new access token. This method will differentiate cache empty from token refresh error. If your app cares the exact token refresh error during token cache look-up, then this method is suitable. Otherwise, the other method :func:`~acquire_token_silent` is recommended. :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param account: one of the account object returned by :func:`~get_accounts`, or use None when you want to find an access token for this client. :param force_refresh: If True, it will skip Access Token look-up, and try to find a Refresh Token to obtain a new Access Token. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: - A dict containing no "error" key, and typically contains an "access_token" key, if cache lookup succeeded. - None when there is simply no token in the cache. - A dict containing an "error" key, when token refresh failed. """ assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) correlation_id = msal.telemetry._get_new_correlation_id() if authority: warnings.warn("We haven't decided how/if this method will accept authority parameter") # the_authority = Authority( # authority, # self.http_client, # ) if authority else self.authority result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, self.authority, force_refresh=force_refresh, claims_challenge=claims_challenge, correlation_id=correlation_id, **kwargs) if result and "error" not in result: return result final_result = result for alias in self._get_authority_aliases(self.authority.instance): if not self.token_cache.find( self.token_cache.CredentialType.REFRESH_TOKEN, # target=scopes, # MUST NOT filter by scopes, because: # 1. AAD RTs are scope-independent; # 2. therefore target is optional per schema; query={"environment": alias}): # Skip heavy weight logic when RT for this alias doesn't exist continue the_authority = Authority( "https://" + alias + "/" + self.authority.tenant, self.http_client, validate_authority=False) result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, force_refresh=force_refresh, claims_challenge=claims_challenge, correlation_id=correlation_id, **kwargs) if result: if "error" not in result: return result final_result = result if final_result and final_result.get("suberror"): final_result["classification"] = { # Suppress these suberrors, per #57 "bad_token": "", "token_expired": "", "protection_policy_required": "", "client_mismatch": "", "device_authentication_failed": "", }.get(final_result["suberror"], final_result["suberror"]) return final_result def _acquire_token_silent_from_cache_and_possibly_refresh_it( self, scopes, # type: List[str] account, # type: Optional[Account] authority, # This can be different than self.authority force_refresh=False, # type: Optional[boolean] claims_challenge=None, **kwargs): access_token_from_cache = None if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims query={ "client_id": self.client_id, "environment": authority.instance, "realm": authority.tenant, "home_account_id": (account or {}).get("home_account_id"), } key_id = kwargs.get("data", {}).get("key_id") if key_id: # Some token types (SSH-certs, POP) are bound to a key query["key_id"] = key_id matches = self.token_cache.find( self.token_cache.CredentialType.ACCESS_TOKEN, target=scopes, query=query) now = time.time() refresh_reason = msal.telemetry.AT_ABSENT for entry in matches: expires_in = int(entry["expires_on"]) - now if expires_in < 5*60: # Then consider it expired refresh_reason = msal.telemetry.AT_EXPIRED continue # Removal is not necessary, it will be overwritten logger.debug("Cache hit an AT") access_token_from_cache = { # Mimic a real response "access_token": entry["secret"], "token_type": entry.get("token_type", "Bearer"), "expires_in": int(expires_in), # OAuth2 specs defines it as int } if "refresh_on" in entry and int(entry["refresh_on"]) < now: # aging refresh_reason = msal.telemetry.AT_AGING break # With a fallback in hand, we break here to go refresh self._build_telemetry_context(-1).hit_an_access_token() return access_token_from_cache # It is still good as new else: refresh_reason = msal.telemetry.FORCE_REFRESH # TODO: It could also mean claims_challenge assert refresh_reason, "It should have been established at this point" try: result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, self._decorate_scope(scopes), account, refresh_reason=refresh_reason, claims_challenge=claims_challenge, **kwargs)) if (result and "error" not in result) or (not access_token_from_cache): return result except: # The exact HTTP exception is transportation-layer dependent # Typically network error. Potential AAD outage? if not access_token_from_cache: # It means there is no fall back option raise # We choose to bubble up the exception return access_token_from_cache def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): query = { "environment": authority.instance, "home_account_id": (account or {}).get("home_account_id"), # "realm": authority.tenant, # AAD RTs are tenant-independent } app_metadata = self._get_app_metadata(authority.instance) if not app_metadata: # Meaning this app is now used for the first time. # When/if we have a way to directly detect current app's family, # we'll rewrite this block, to support multiple families. # For now, we try existing RTs (*). If it works, we are in that family. # (*) RTs of a different app/family are not supposed to be # shared with or accessible by us in the first place. at = self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, dict(query, family_id="1"), # A hack, we have only 1 family for now rt_remover=lambda rt_item: None, # NO-OP b/c RTs are likely not mine break_condition=lambda response: # Break loop when app not in family # Based on an AAD-only behavior mentioned in internal doc here # https://msazure.visualstudio.com/One/_git/ESTS-Docs/pullrequest/1138595 "client_mismatch" in response.get("error_additional_info", []), **kwargs) if at and "error" not in at: return at last_resp = None if app_metadata.get("family_id"): # Meaning this app belongs to this family last_resp = at = self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, dict(query, family_id=app_metadata["family_id"]), **kwargs) if at and "error" not in at: return at # Either this app is an orphan, so we will naturally use its own RT; # or all attempts above have failed, so we fall back to non-foci behavior. return self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, dict(query, client_id=self.client_id), **kwargs) or last_resp def _get_app_metadata(self, environment): apps = self.token_cache.find( # Use find(), rather than token_cache.get(...) TokenCache.CredentialType.APP_METADATA, query={ "environment": environment, "client_id": self.client_id}) return apps[0] if apps else {} def _acquire_token_silent_by_finding_specific_refresh_token( self, authority, scopes, query, rt_remover=None, break_condition=lambda response: False, refresh_reason=None, correlation_id=None, claims_challenge=None, **kwargs): matches = self.token_cache.find( self.token_cache.CredentialType.REFRESH_TOKEN, # target=scopes, # AAD RTs are scope-independent query=query) logger.debug("Found %d RTs matching %s", len(matches), query) response = None # A distinguishable value to mean cache is empty if not matches: # Then exit early to avoid expensive operations return response client, _ = self._build_client( # Potentially expensive if building regional client self.client_credential, authority, skip_regional_client=True) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_SILENT_ID, correlation_id=correlation_id, refresh_reason=refresh_reason) for entry in sorted( # Since unfit RTs would not be aggressively removed, # we start from newer RTs which are more likely fit. matches, key=lambda e: int(e.get("last_modification_time", "0")), reverse=True): logger.debug("Cache attempts an RT") headers = telemetry_context.generate_headers() if "home_account_id" in query: # Then use it as CCS Routing info headers["X-AnchorMailbox"] = "Oid:{}".format( # case-insensitive value query["home_account_id"].replace(".", "@")) response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], on_removing_rt=lambda rt_item: None, # Disable RT removal, # because an invalid_grant could be caused by new MFA policy, # the RT could still be useful for other MFA-less scope or tenant on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=authority.instance, skip_account_creation=True, # To honor a concurrent remove_account() )), scope=scopes, headers=headers, data=dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), **kwargs) telemetry_context.update_telemetry(response) if "error" not in response: return response logger.debug("Refresh failed. {error}: {error_description}".format( error=response.get("error"), error_description=response.get("error_description"), )) if break_condition(response): break return response # Returns the latest error (if any), or just None def _validate_ssh_cert_input_data(self, data): if data.get("token_type") == "ssh-cert": if not data.get("req_cnf"): raise ValueError( "When requesting an SSH certificate, " "you must include a string parameter named 'req_cnf' " "containing the public key in JWK format " "(https://tools.ietf.org/html/rfc7517).") if not data.get("key_id"): raise ValueError( "When requesting an SSH certificate, " "you must include a string parameter named 'key_id' " "which identifies the key in the 'req_cnf' argument.") def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): """Acquire token(s) based on a refresh token (RT) obtained from elsewhere. You use this method only when you have old RTs from elsewhere, and now you want to migrate them into MSAL. Calling this method results in new tokens automatically storing into MSAL. You do NOT need to use this method if you are already using MSAL. MSAL maintains RT automatically inside its token cache, and an access token can be retrieved when you call :func:`~acquire_token_silent`. :param str refresh_token: The old refresh token, as a string. :param list scopes: The scopes associate with this old RT. Each scope needs to be in the Microsoft identity platform (v2) format. See `Scopes not resources `_. :return: * A dict contains "error" and some other keys, when error happened. * A dict contains no "error" key means migration was successful. """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_REFRESH_TOKEN, refresh_reason=msal.telemetry.FORCE_REFRESH) response = _clean_up(self.client.obtain_token_by_refresh_token( refresh_token, scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), rt_getter=lambda rt: rt, on_updating_rt=False, on_removing_rt=lambda rt_item: None, # No OP **kwargs)) telemetry_context.update_telemetry(response) return response def acquire_token_by_username_password( self, username, password, scopes, claims_challenge=None, **kwargs): """Gets a token for a given resource via user credentials. See this page for constraints of Username Password Flow. https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication :param str username: Typically a UPN in the form of an email address. :param str password: The password. :param list[str] scopes: Scopes requested to access a protected API (a resource). :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID) headers = telemetry_context.generate_headers() data = dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)) if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]) if user_realm_result.get("account_type") == "Federated": response = _clean_up(self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, data=data, headers=headers, **kwargs)) telemetry_context.update_telemetry(response) return response response = _clean_up(self.client.obtain_token_by_username_password( username, password, scope=scopes, headers=headers, data=data, **kwargs)) telemetry_context.update_telemetry(response) return response def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): wstrust_endpoint = {} if user_realm_result.get("federation_metadata_url"): wstrust_endpoint = mex_send_request( user_realm_result["federation_metadata_url"], self.http_client) if wstrust_endpoint is None: raise ValueError("Unable to find wstrust endpoint from MEX. " "This typically happens when attempting MSA accounts. " "More details available here. " "https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication") logger.debug("wstrust_endpoint = %s", wstrust_endpoint) wstrust_result = wst_send_request( username, password, user_realm_result.get("cloud_audience_urn", "urn:federation:MicrosoftOnline"), wstrust_endpoint.get("address", # Fallback to an AAD supplied endpoint user_realm_result.get("federation_active_auth_url")), wstrust_endpoint.get("action"), self.http_client) if not ("token" in wstrust_result and "type" in wstrust_result): raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result) GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer' grant_type = { SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1, SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2, WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1, WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2 }.get(wstrust_result.get("type")) if not grant_type: raise RuntimeError( "RSTR returned unknown token type: %s", wstrust_result.get("type")) self.client.grant_assertion_encoders.setdefault( # Register a non-standard type grant_type, self.client.encode_saml_assertion) return self.client.obtain_token_by_assertion( wstrust_result["token"], grant_type, scope=scopes, on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=self.authority.instance, username=username, # Useful in case IDT contains no such info )), **kwargs) class PublicClientApplication(ClientApplication): # browser app or mobile app DEVICE_FLOW_CORRELATION_ID = "_correlation_id" def __init__(self, client_id, client_credential=None, **kwargs): if client_credential is not None: raise ValueError("Public Client should not possess credentials") super(PublicClientApplication, self).__init__( client_id, client_credential=None, **kwargs) def acquire_token_interactive( self, scopes, # type: list[str] prompt=None, login_hint=None, # type: Optional[str] domain_hint=None, # type: Optional[str] claims_challenge=None, timeout=None, port=None, extra_scopes_to_consent=None, max_age=None, **kwargs): """Acquire token interactively i.e. via a local browser. Prerequisite: In Azure Portal, configure the Redirect URI of your "Mobile and Desktop application" as ``http://localhost``. :param list scopes: It is a list of case-sensitive strings. :param str prompt: By default, no prompt value will be sent, not even "none". You will have to specify a value explicitly. Its valid values are defined in Open ID Connect specs https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest :param str login_hint: Optional. Identifier of the user. Generally a User Principal Name (UPN). :param domain_hint: Can be one of "consumers" or "organizations" or your tenant domain "contoso.com". If included, it will skip the email-based discovery process that user goes through on the sign-in page, leading to a slightly more streamlined user experience. More information on possible values `here `_ and `here `_. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :param int timeout: This method will block the current thread. This parameter specifies the timeout value in seconds. Default value ``None`` means wait indefinitely. :param int port: The port to be used to listen to an incoming auth response. By default we will use a system-allocated port. (The rest of the redirect_uri is hard coded as ``http://localhost``.) :param list extra_scopes_to_consent: "Extra scopes to consent" is a concept only available in AAD. It refers to other resources you might want to prompt to consent for, in the same interaction, but for which you won't get back a token for in this particular operation. :param int max_age: OPTIONAL. Maximum Authentication Age. Specifies the allowable elapsed time in seconds since the last time the End-User was actively authenticated. If the elapsed time is greater than this value, Microsoft identity platform will actively re-authenticate the End-User. MSAL Python will also automatically validate the auth_time in ID token. New in version 1.15. :return: - A dict containing no "error" key, and typically contains an "access_token" key. - A dict containing an "error" key, when token refresh failed. """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) response = _clean_up(self.client.obtain_token_by_browser( scope=self._decorate_scope(scopes) if scopes else None, extra_scope_to_consent=extra_scopes_to_consent, redirect_uri="http://localhost:{port}".format( # Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway port=port or 0), prompt=prompt, login_hint=login_hint, max_age=max_age, timeout=timeout, auth_params={ "claims": claims, "domain_hint": domain_hint, }, data=dict(kwargs.pop("data", {}), claims=claims), headers=telemetry_context.generate_headers(), browser_name=_preferred_browser(), **kwargs)) telemetry_context.update_telemetry(response) return response def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, which will be used in :func:`~acquire_token_by_device_flow`. :param list[str] scopes: Scopes requested to access a protected API (a resource). :return: A dict representing a newly created Device Flow object. - A successful response would contain "user_code" key, among others - an error response would contain some other readable key/value pairs. """ correlation_id = msal.telemetry._get_new_correlation_id() flow = self.client.initiate_device_flow( scope=self._decorate_scope(scopes or []), headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id}, **kwargs) flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id return flow def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): """Obtain token by a device flow object, with customizable polling effect. :param dict flow: A dict previously generated by :func:`~initiate_device_flow`. By default, this method's polling effect will block current thread. You can abort the polling loop at any time, by changing the value of the flow's "expires_at" key to 0. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID, correlation_id=flow.get(self.DEVICE_FLOW_CORRELATION_ID)) response = _clean_up(self.client.obtain_token_by_device_flow( flow, data=dict( kwargs.pop("data", {}), code=flow["device_code"], # 2018-10-4 Hack: # during transition period, # service seemingly need both device_code and code parameter. claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), ), headers=telemetry_context.generate_headers(), **kwargs)) telemetry_context.update_telemetry(response) return response class ConfidentialClientApplication(ClientApplication): # server-side web app def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): """Acquires token for the current confidential client, not for an end user. :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ # TBD: force_refresh behavior self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_FOR_CLIENT_ID) client = self._regional_client or self.client response = _clean_up(client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), **kwargs)) telemetry_context.update_telemetry(response) return response def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=None, **kwargs): """Acquires token using on-behalf-of (OBO) flow. The current app is a middle-tier service which was called with a token representing an end user. The current app can use such token (a.k.a. a user assertion) to request another token to access downstream web API, on behalf of that user. See `detail docs here `_ . The current middle-tier app has no user interaction to obtain consent. See how to gain consent upfront for your middle-tier app from this article. https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#gaining-consent-for-the-middle-tier-application :param str user_assertion: The incoming token already received by this app :param list[str] scopes: Scopes required by downstream API (a resource). :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider in the form of a claims_challenge directive in the www-authenticate header to be returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token. It is a string of a JSON object which contains lists of claims being requested from these locations. :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID) # The implementation is NOT based on Token Exchange # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs scope=self._decorate_scope(scopes), # Decoration is used for: # 1. Explicitly requesting an RT, without relying on AAD default # behavior, even though it currently still issues an RT. # 2. Requesting an IDT (which would otherwise be unavailable) # so that the calling app could use id_token_claims to implement # their own cache mapping, which is likely needed in web apps. data=dict( kwargs.pop("data", {}), requested_token_use="on_behalf_of", claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), headers=telemetry_context.generate_headers(), # TBD: Expose a login_hint (or ccs_routing_hint) param for web app **kwargs)) telemetry_context.update_telemetry(response) return response microsoft-authentication-library-for-python-1.17.0/msal/authority.py000066400000000000000000000201121420154156600257250ustar00rootroot00000000000000import json try: from urllib.parse import urlparse except ImportError: # Fall back to Python 2 from urlparse import urlparse import logging from .exceptions import MsalServiceError logger = logging.getLogger(__name__) # Endpoints were copied from here # https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints AZURE_US_GOVERNMENT = "login.microsoftonline.us" AZURE_CHINA = "login.chinacloudapi.cn" AZURE_PUBLIC = "login.microsoftonline.com" WORLD_WIDE = 'login.microsoftonline.com' # There was an alias login.windows.net WELL_KNOWN_AUTHORITY_HOSTS = set([ WORLD_WIDE, AZURE_CHINA, 'login-us.microsoftonline.com', AZURE_US_GOVERNMENT, ]) WELL_KNOWN_B2C_HOSTS = [ "b2clogin.com", "b2clogin.cn", "b2clogin.us", "b2clogin.de", ] class AuthorityBuilder(object): def __init__(self, instance, tenant): """A helper to save caller from doing string concatenation. Usage is documented in :func:`application.ClientApplication.__init__`. """ self._instance = instance.rstrip("/") self._tenant = tenant.strip("/") def __str__(self): return "https://{}/{}".format(self._instance, self._tenant) class Authority(object): """This class represents an (already-validated) authority. Once constructed, it contains members named "*_endpoint" for this instance. TODO: It will also cache the previously-validated authority instances. """ _domains_without_user_realm_discovery = set([]) @property def http_client(self): # Obsolete. We will remove this eventually warnings.warn( "authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning) return self._http_client def __init__(self, authority_url, http_client, validate_authority=True): """Creates an authority instance, and also validates it. :param validate_authority: The Authority validation process actually checks two parts: instance (a.k.a. host) and tenant. We always do a tenant discovery. This parameter only controls whether an instance discovery will be performed. """ self._http_client = http_client if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) parts = authority.path.split('/') is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or ( len(parts) == 3 and parts[2].lower().startswith("b2c_")) if (tenant != "adfs" and (not is_b2c) and validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): payload = instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( self.instance, authority.path), self._http_client) if payload.get("error") == "invalid_instance": raise ValueError( "invalid_instance: " "The authority you provided, %s, is not whitelisted. " "If it is indeed your legit customized domain name, " "you can turn off this check by passing in " "validate_authority=False" % authority_url) tenant_discovery_endpoint = payload['tenant_discovery_endpoint'] else: tenant_discovery_endpoint = ( 'https://{}{}{}/.well-known/openid-configuration'.format( self.instance, authority.path, # In B2C scenario, it is "/tenant/policy" "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint )) try: openid_config = tenant_discovery( tenant_discovery_endpoint, self._http_client) except ValueError: raise ValueError( "Unable to get authority configuration for {}. " "Authority would typically be in a format of " "https://login.microsoftonline.com/your_tenant " "Also please double check your tenant name or GUID is correct.".format( authority_url)) logger.debug("openid_config = %s", openid_config) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint') _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID self.is_adfs = self.tenant.lower() == 'adfs' def user_realm_discovery(self, username, correlation_id=None, response=None): # It will typically return a dict containing "ver", "account_type", # "federation_protocol", "cloud_audience_urn", # "federation_metadata_url", "federation_active_auth_url", etc. if self.instance not in self.__class__._domains_without_user_realm_discovery: resp = response or self._http_client.get( "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( netloc=self.instance, username=username), headers={'Accept': 'application/json', 'client-request-id': correlation_id},) if resp.status_code != 404: resp.raise_for_status() return json.loads(resp.text) self.__class__._domains_without_user_realm_discovery.add(self.instance) return {} # This can guide the caller to fall back normal ROPC flow def canonicalize(authority_url): # Returns (url_parsed_result, hostname_in_lowercase, tenant) authority = urlparse(authority_url) parts = authority.path.split("/") if authority.scheme != "https" or len(parts) < 2 or not parts[1]: raise ValueError( "Your given address (%s) should consist of " "an https url with a minimum of one segment in a path: e.g. " "https://login.microsoftonline.com/ " "or https://.b2clogin.com/.onmicrosoft.com/policy" % authority_url) return authority, authority.hostname, parts[1] def instance_discovery(url, http_client, **kwargs): resp = http_client.get( # Note: This URL seemingly returns V1 endpoint only 'https://{}/common/discovery/instance'.format( WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too # See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103 # and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33 ), params={'authorization_endpoint': url, 'api-version': '1.0'}, **kwargs) return json.loads(resp.text) def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs): # Returns Openid Configuration resp = http_client.get(tenant_discovery_endpoint, **kwargs) if resp.status_code == 200: payload = json.loads(resp.text) # It could raise ValueError if 'authorization_endpoint' in payload and 'token_endpoint' in payload: return payload # Happy path raise ValueError("OIDC Discovery does not provide enough information") if 400 <= resp.status_code < 500: # Nonexist tenant would hit this path # e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration raise ValueError( "OIDC Discovery endpoint rejects our request. Error: {}".format( resp.text # Expose it as-is b/c OIDC defines no error response format )) # Transient network error would hit this path resp.raise_for_status() raise RuntimeError( # A fallback here, in case resp.raise_for_status() is no-op "Unable to complete OIDC Discovery: %d, %s" % (resp.status_code, resp.text)) microsoft-authentication-library-for-python-1.17.0/msal/exceptions.py000066400000000000000000000032461420154156600260670ustar00rootroot00000000000000#------------------------------------------------------------------------------ # # Copyright (c) Microsoft Corporation. # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # #------------------------------------------------------------------------------ class MsalError(Exception): # Define the template in Unicode to accommodate possible Unicode variables msg = u'An unspecified error' def __init__(self, *args, **kwargs): super(MsalError, self).__init__(self.msg.format(**kwargs), *args) self.kwargs = kwargs class MsalServiceError(MsalError): msg = u"{error}: {error_description}" microsoft-authentication-library-for-python-1.17.0/msal/individual_cache.py000066400000000000000000000314301420154156600271550ustar00rootroot00000000000000from functools import wraps import time try: from collections.abc import MutableMapping # Python 3.3+ except ImportError: from collections import MutableMapping # Python 2.7+ import heapq from threading import Lock class _ExpiringMapping(MutableMapping): _INDEX = "_index_" def __init__(self, mapping=None, capacity=None, expires_in=None, lock=None, *args, **kwargs): """Items in this mapping can have individual shelf life, just like food items in your refrigerator have their different shelf life determined by each food, not by the refrigerator. Expired items will be automatically evicted. The clean-up will be done at each time when adding a new item, or when looping or counting the entire mapping. (This is better than being done indecisively by a background thread, which might not always happen before your accessing the mapping.) This implementation uses no dependency other than Python standard library. :param MutableMapping mapping: A dict-like key-value mapping, which needs to support __setitem__(), __getitem__(), __delitem__(), get(), pop(). The default mapping is an in-memory dict. You could potentially supply a file-based dict-like object, too. This implementation deliberately avoid mapping.__iter__(), which could be slow on a file-based mapping. :param int capacity: How many items this mapping will hold. When you attempt to add new item into a full mapping, it will automatically delete the item that is expiring soonest. The default value is None, which means there is no capacity limit. :param int expires_in: How many seconds an item would expire and be purged from this mapping. Also known as time-to-live (TTL). You can also use :func:`~set()` to provide per-item expires_in value. :param Lock lock: A locking mechanism with context manager interface. If no lock is provided, a threading.Lock will be used. But you may want to supply a different lock, if your customized mapping is being shared differently. """ super(_ExpiringMapping, self).__init__(*args, **kwargs) self._mapping = mapping if mapping is not None else {} self._capacity = capacity self._expires_in = expires_in self._lock = Lock() if lock is None else lock def _validate_key(self, key): if key == self._INDEX: raise ValueError("key {} is a reserved keyword in {}".format( key, self.__class__.__name__)) def set(self, key, value, expires_in): # This method's name was chosen so that it matches its cousin __setitem__(), # and it also complements the counterpart get(). # The downside is such a name shadows the built-in type set in this file, # but you can overcome that by defining a global alias for set. """It sets the key-value pair into this mapping, with its per-item expires_in. It will take O(logN) time, because it will run some maintenance. This worse-than-constant time is acceptable, because in a cache scenario, __setitem__() would only be called during a cache miss, which would already incur an expensive target function call anyway. By the way, most other methods of this mapping still have O(1) constant time. """ with self._lock: self._set(key, value, expires_in) def _set(self, key, value, expires_in): # This internal implementation powers both set() and __setitem__(), # so that they don't depend on each other. self._validate_key(key) sequence, timestamps = self._mapping.get(self._INDEX, ([], {})) self._maintenance(sequence, timestamps) # O(logN) now = int(time.time()) expires_at = now + expires_in entry = [expires_at, now, key] is_new_item = key not in timestamps is_beyond_capacity = self._capacity and len(timestamps) >= self._capacity if is_new_item and is_beyond_capacity: self._drop_indexed_entry(timestamps, heapq.heappushpop(sequence, entry)) else: # Simply add new entry. The old one would become a harmless orphan. heapq.heappush(sequence, entry) timestamps[key] = [expires_at, now] # It overwrites existing key, if any self._mapping[key] = value self._mapping[self._INDEX] = sequence, timestamps def _maintenance(self, sequence, timestamps): # O(logN) """It will modify input sequence and timestamps in-place""" now = int(time.time()) while sequence: # Clean up expired items expires_at, created_at, key = sequence[0] if created_at <= now < expires_at: # Then all remaining items are fresh break self._drop_indexed_entry(timestamps, sequence[0]) # It could error out heapq.heappop(sequence) # Only pop it after a successful _drop_indexed_entry() while self._capacity is not None and len(timestamps) > self._capacity: self._drop_indexed_entry(timestamps, sequence[0]) # It could error out heapq.heappop(sequence) # Only pop it after a successful _drop_indexed_entry() def _drop_indexed_entry(self, timestamps, entry): """For an entry came from index, drop it from timestamps and self._mapping""" expires_at, created_at, key = entry if [expires_at, created_at] == timestamps.get(key): # So it is not an orphan self._mapping.pop(key, None) # It could raise exception timestamps.pop(key, None) # This would probably always succeed def __setitem__(self, key, value): """Implements the __setitem__(). Same characteristic as :func:`~set()`, but use class-wide expires_in which was specified by :func:`~__init__()`. """ if self._expires_in is None: raise ValueError("Need a numeric value for expires_in during __init__()") with self._lock: self._set(key, value, self._expires_in) def __getitem__(self, key): # O(1) """If the item you requested already expires, KeyError will be raised.""" self._validate_key(key) with self._lock: # Skip self._maintenance(), because it would need O(logN) time sequence, timestamps = self._mapping.get(self._INDEX, ([], {})) expires_at, created_at = timestamps[key] # Would raise KeyError accordingly now = int(time.time()) if not created_at <= now < expires_at: self._mapping.pop(key, None) timestamps.pop(key, None) self._mapping[self._INDEX] = sequence, timestamps raise KeyError("{} {}".format( key, "expired" if now >= expires_at else "created in the future?", )) return self._mapping[key] # O(1) def __delitem__(self, key): # O(1) """If the item you requested already expires, KeyError will be raised.""" self._validate_key(key) with self._lock: # Skip self._maintenance(), because it would need O(logN) time self._mapping.pop(key, None) # O(1) sequence, timestamps = self._mapping.get(self._INDEX, ([], {})) del timestamps[key] # O(1) self._mapping[self._INDEX] = sequence, timestamps def __len__(self): # O(logN) """Drop all expired items and return the remaining length""" with self._lock: sequence, timestamps = self._mapping.get(self._INDEX, ([], {})) self._maintenance(sequence, timestamps) # O(logN) self._mapping[self._INDEX] = sequence, timestamps return len(timestamps) # Faster than iter(self._mapping) when it is on disk def __iter__(self): """Drop all expired items and return an iterator of the remaining items""" with self._lock: sequence, timestamps = self._mapping.get(self._INDEX, ([], {})) self._maintenance(sequence, timestamps) # O(logN) self._mapping[self._INDEX] = sequence, timestamps return iter(timestamps) # Faster than iter(self._mapping) when it is on disk class _IndividualCache(object): # The code structure below can decorate both function and method. # It is inspired by https://stackoverflow.com/a/9417088 # We may potentially switch to build upon # https://github.com/micheles/decorator/blob/master/docs/documentation.md#statement-of-the-problem def __init__(self, mapping=None, key_maker=None, expires_in=None): """Constructs a cache decorator that allows item-by-item control on how to cache the return value of the decorated function. :param MutableMapping mapping: The cached items will be stored inside. You'd want to use a ExpiringMapping if you plan to utilize the ``expires_in`` behavior. If nothing is provided, an in-memory dict will be used, but it will provide no expiry functionality. .. note:: When using this class as a decorator, your mapping needs to be available at "compile" time, so it would typically be a global-, module- or class-level mapping:: module_mapping = {} @IndividualCache(mapping=module_mapping, ...) def foo(): ... If you want to use a mapping available only at run-time, you have to manually decorate your function at run-time, too:: def foo(): ... def bar(runtime_mapping): foo = IndividualCache(mapping=runtime_mapping...)(foo) :param callable key_maker: A callable which should have signature as ``lambda function, args, kwargs: "return a string as key"``. If key_maker happens to return ``None``, the cache will be bypassed, the underlying function will be invoked directly, and the invoke result will not be cached either. :param callable expires_in: The default value is ``None``, which means the content being cached has no per-item expiry, and will subject to the underlying mapping's global expiry time. It can be an integer indicating how many seconds the result will be cached. In particular, if the value is 0, it means the result expires after zero second (i.e. immediately), therefore the result will *not* be cached. (Mind the difference between ``expires_in=0`` and ``expires_in=None``.) Or it can be a callable with the signature as ``lambda function=function, args=args, kwargs=kwargs, result=result: 123`` to calculate the expiry on the fly. Its return value will be interpreted in the same way as above. """ self._mapping = mapping if mapping is not None else {} self._key_maker = key_maker or (lambda function, args, kwargs: ( function, # This default implementation uses function as part of key, # so that the cache is partitioned by function. # However, you could have many functions to use same namespace, # so different decorators could share same cache. args, tuple(kwargs.items()), # raw kwargs is not hashable )) self._expires_in = expires_in def __call__(self, function): @wraps(function) def wrapper(*args, **kwargs): key = self._key_maker(function, args, kwargs) if key is None: # Then bypass the cache return function(*args, **kwargs) now = int(time.time()) try: return self._mapping[key] except KeyError: # We choose to NOT call function(...) in this block, otherwise # potential exception from function(...) would become a confusing # "During handling of the above exception, another exception occurred" pass value = function(*args, **kwargs) expires_in = self._expires_in( function=function, args=args, kwargs=kwargs, result=value, ) if callable(self._expires_in) else self._expires_in if expires_in == 0: return value if expires_in is None: self._mapping[key] = value else: self._mapping.set(key, value, expires_in) return value return wrapper microsoft-authentication-library-for-python-1.17.0/msal/mex.py000066400000000000000000000144531420154156600245010ustar00rootroot00000000000000#------------------------------------------------------------------------------ # # Copyright (c) Microsoft Corporation. # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # #------------------------------------------------------------------------------ try: from urllib.parse import urlparse except: from urlparse import urlparse try: from xml.etree import cElementTree as ET except ImportError: from xml.etree import ElementTree as ET import logging logger = logging.getLogger(__name__) def _xpath_of_root(route_to_leaf): # Construct an xpath suitable to find a root node which has a specified leaf return '/'.join(route_to_leaf + ['..'] * (len(route_to_leaf)-1)) def send_request(mex_endpoint, http_client, **kwargs): mex_resp = http_client.get(mex_endpoint, **kwargs) mex_resp.raise_for_status() try: return Mex(mex_resp.text).get_wstrust_username_password_endpoint() except ET.ParseError: logger.exception( "Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text) raise class Mex(object): NS = { # Also used by wstrust_*.py 'wsdl': 'http://schemas.xmlsoap.org/wsdl/', 'sp': 'http://docs.oasis-open.org/ws-sx/ws-securitypolicy/200702', 'sp2005': 'http://schemas.xmlsoap.org/ws/2005/07/securitypolicy', 'wsu': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', 'wsa': 'http://www.w3.org/2005/08/addressing', # Duplicate? 'wsa10': 'http://www.w3.org/2005/08/addressing', 'http': 'http://schemas.microsoft.com/ws/06/2004/policy/http', 'soap12': 'http://schemas.xmlsoap.org/wsdl/soap12/', 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy', 's': 'http://www.w3.org/2003/05/soap-envelope', 'wst': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512', 'trust': "http://docs.oasis-open.org/ws-sx/ws-trust/200512", # Duplicate? 'saml': "urn:oasis:names:tc:SAML:1.0:assertion", 'wst2005': 'http://schemas.xmlsoap.org/ws/2005/02/trust', # was named "t" } ACTION_13 = 'http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue' ACTION_2005 = 'http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue' def __init__(self, mex_document): self.dom = ET.fromstring(mex_document) def _get_policy_ids(self, components_to_leaf, binding_xpath): id_attr = '{%s}Id' % self.NS['wsu'] return set(["#{}".format(policy.get(id_attr)) for policy in self.dom.findall(_xpath_of_root(components_to_leaf), self.NS) # If we did not find any binding, this is potentially bad. if policy.find(binding_xpath, self.NS) is not None]) def _get_username_password_policy_ids(self): path = ['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All', 'sp:SignedEncryptedSupportingTokens', 'wsp:Policy', 'sp:UsernameToken', 'wsp:Policy', 'sp:WssUsernameToken10'] policies = self._get_policy_ids(path, './/sp:TransportBinding') path2005 = ['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All', 'sp2005:SignedSupportingTokens', 'wsp:Policy', 'sp2005:UsernameToken', 'wsp:Policy', 'sp2005:WssUsernameToken10'] policies.update(self._get_policy_ids(path2005, './/sp2005:TransportBinding')) return policies def _get_iwa_policy_ids(self): return self._get_policy_ids( ['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All', 'http:NegotiateAuthentication'], './/sp2005:TransportBinding') def _get_bindings(self): bindings = {} # {binding_name: {"policy_uri": "...", "version": "..."}} for binding in self.dom.findall("wsdl:binding", self.NS): if (binding.find('soap12:binding', self.NS).get("transport") != 'http://schemas.xmlsoap.org/soap/http'): continue action = binding.find( 'wsdl:operation/soap12:operation', self.NS).get("soapAction") for pr in binding.findall("wsp:PolicyReference", self.NS): bindings[binding.get("name")] = { "policy_uri": pr.get("URI"), "action": action} return bindings def _get_endpoints(self, bindings, policy_ids): endpoints = [] for port in self.dom.findall('wsdl:service/wsdl:port', self.NS): binding_name = port.get("binding").split(':')[-1] # Should have 2 parts binding = bindings.get(binding_name) if binding and binding["policy_uri"] in policy_ids: address = port.find('wsa10:EndpointReference/wsa10:Address', self.NS) if address is not None and address.text.lower().startswith("https://"): endpoints.append( {"address": address.text, "action": binding["action"]}) return endpoints def get_wstrust_username_password_endpoint(self): """Returns {"address": "https://...", "action": "the soapAction value"}""" endpoints = self._get_endpoints( self._get_bindings(), self._get_username_password_policy_ids()) for e in endpoints: if e["action"] == self.ACTION_13: return e # Historically, we prefer ACTION_13 a.k.a. WsTrust13 return endpoints[0] if endpoints else None microsoft-authentication-library-for-python-1.17.0/msal/oauth2cli/000077500000000000000000000000001420154156600252215ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/msal/oauth2cli/__init__.py000066400000000000000000000003151420154156600273310ustar00rootroot00000000000000__version__ = "0.4.0" from .oidc import Client from .assertion import JwtAssertionCreator from .assertion import JwtSigner # Obsolete. For backward compatibility. from .authcode import AuthCodeReceiver microsoft-authentication-library-for-python-1.17.0/msal/oauth2cli/assertion.py000066400000000000000000000122741420154156600276100ustar00rootroot00000000000000import time import binascii import base64 import uuid import logging logger = logging.getLogger(__name__) def _str2bytes(raw): # A conversion based on duck-typing rather than six.text_type try: # Assuming it is a string return raw.encode(encoding="utf-8") except: # Otherwise we treat it as bytes and return it as-is return raw class AssertionCreator(object): def create_normal_assertion( self, audience, issuer, subject, expires_at=None, expires_in=600, issued_at=None, assertion_id=None, **kwargs): """Create an assertion in bytes, based on the provided claims. All parameter names are defined in https://tools.ietf.org/html/rfc7521#section-5 except the expires_in is defined here as lifetime-in-seconds, which will be automatically translated into expires_at in UTC. """ raise NotImplementedError("Will be implemented by sub-class") def create_regenerative_assertion( self, audience, issuer, subject=None, expires_in=600, **kwargs): """Create an assertion as a callable, which will then compute the assertion later when necessary. This is a useful optimization to reuse the client assertion. """ return AutoRefresher( # Returns a callable lambda a=audience, i=issuer, s=subject, e=expires_in, kwargs=kwargs: self.create_normal_assertion(a, i, s, expires_in=e, **kwargs), expires_in=max(expires_in-60, 0)) class AutoRefresher(object): """Cache the output of a factory, and auto-refresh it when necessary. Usage:: r = AutoRefresher(time.time, expires_in=5) for i in range(15): print(r()) # the timestamp change only after every 5 seconds time.sleep(1) """ def __init__(self, factory, expires_in=540): self._factory = factory self._expires_in = expires_in self._buf = {} def __call__(self): EXPIRES_AT, VALUE = "expires_at", "value" now = time.time() if self._buf.get(EXPIRES_AT, 0) <= now: logger.debug("Regenerating new assertion") self._buf = {VALUE: self._factory(), EXPIRES_AT: now + self._expires_in} else: logger.debug("Reusing still valid assertion") return self._buf.get(VALUE) class JwtAssertionCreator(AssertionCreator): def __init__(self, key, algorithm, sha1_thumbprint=None, headers=None): """Construct a Jwt assertion creator. Args: key (str): An unencrypted private key for signing, in a base64 encoded string. It can also be a cryptography ``PrivateKey`` object, which is how you can work with a previously-encrypted key. See also https://github.com/jpadilla/pyjwt/pull/525 algorithm (str): "RS256", etc.. See https://pyjwt.readthedocs.io/en/latest/algorithms.html RSA and ECDSA algorithms require "pip install cryptography". sha1_thumbprint (str): The x5t aka X.509 certificate SHA-1 thumbprint. headers (dict): Additional headers, e.g. "kid" or "x5c" etc. """ self.key = key self.algorithm = algorithm self.headers = headers or {} if sha1_thumbprint: # https://tools.ietf.org/html/rfc7515#section-4.1.7 self.headers["x5t"] = base64.urlsafe_b64encode( binascii.a2b_hex(sha1_thumbprint)).decode() def create_normal_assertion( self, audience, issuer, subject=None, expires_at=None, expires_in=600, issued_at=None, assertion_id=None, not_before=None, additional_claims=None, **kwargs): """Create a JWT Assertion. Parameters are defined in https://tools.ietf.org/html/rfc7523#section-3 Key-value pairs in additional_claims will be added into payload as-is. """ import jwt # Lazy loading now = time.time() payload = { 'aud': audience, 'iss': issuer, 'sub': subject or issuer, 'exp': expires_at or (now + expires_in), 'iat': issued_at or now, 'jti': assertion_id or str(uuid.uuid4()), } if not_before: payload['nbf'] = not_before payload.update(additional_claims or {}) try: str_or_bytes = jwt.encode( # PyJWT 1 returns bytes, PyJWT 2 returns str payload, self.key, algorithm=self.algorithm, headers=self.headers) return _str2bytes(str_or_bytes) # We normalize them into bytes except: if self.algorithm.startswith("RS") or self.algorithm.starswith("ES"): logger.exception( 'Some algorithms requires "pip install cryptography". ' 'See https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional') raise # Obsolete. For backward compatibility. They will be removed in future versions. Signer = AssertionCreator # For backward compatibility JwtSigner = JwtAssertionCreator # For backward compatibility JwtSigner.sign_assertion = JwtAssertionCreator.create_normal_assertion # For backward compatibility microsoft-authentication-library-for-python-1.17.0/msal/oauth2cli/authcode.py000066400000000000000000000415711420154156600273770ustar00rootroot00000000000000# Note: This docstring is also used by this script's command line help. """A one-stop helper for desktop app to acquire an authorization code. It starts a web server to listen redirect_uri, waiting for auth code. It optionally opens a browser window to guide a human user to manually login. After obtaining an auth code, the web server will automatically shut down. """ import logging import socket import sys from string import Template import threading import time try: # Python 3 from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs, urlencode except ImportError: # Fall back to Python 2 from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler from urlparse import urlparse, parse_qs from urllib import urlencode logger = logging.getLogger(__name__) def obtain_auth_code(listen_port, auth_uri=None): # Historically only used in testing with AuthCodeReceiver(port=listen_port) as receiver: return receiver.get_auth_response( auth_uri=auth_uri, welcome_template=""" Open this link to Sign In (You may want to use incognito window)
Abort """, ).get("code") def is_wsl(): # "Official" way of detecting WSL: https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364 # Run `uname -a` to get 'release' without python # - WSL 1: '4.4.0-19041-Microsoft' # - WSL 2: '4.19.128-microsoft-standard' import platform uname = platform.uname() platform_name = getattr(uname, 'system', uname[0]).lower() release = getattr(uname, 'release', uname[2]).lower() return platform_name == 'linux' and 'microsoft' in release def _browse(auth_uri, browser_name=None): # throws ImportError, webbrowser.Error """Browse uri with named browser. Default browser is customizable by $BROWSER""" import webbrowser # Lazy import. Some distro may not have this. if browser_name: browser_opened = webbrowser.get(browser_name).open(auth_uri) else: # This one can survive BROWSER=nonexist, while get(None).open(...) can not browser_opened = webbrowser.open(auth_uri) # In WSL which doesn't have www-browser, try launching browser with PowerShell if not browser_opened and is_wsl(): try: import subprocess # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe # Ampersand (&) should be quoted exit_code = subprocess.call( ['powershell.exe', '-NoProfile', '-Command', 'Start-Process "{}"'.format(auth_uri)]) browser_opened = exit_code == 0 except FileNotFoundError: # WSL might be too old pass return browser_opened def _qs2kv(qs): """Flatten parse_qs()'s single-item lists into the item itself""" return {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in qs.items()} class _AuthCodeHandler(BaseHTTPRequestHandler): def do_GET(self): # For flexibility, we choose to not check self.path matching redirect_uri #assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP') qs = parse_qs(urlparse(self.path).query) if qs.get('code') or qs.get("error"): # So, it is an auth response self.server.auth_response = _qs2kv(qs) logger.debug("Got auth response: %s", self.server.auth_response) template = (self.server.success_template if "code" in qs else self.server.error_template) self._send_full_response( template.safe_substitute(**self.server.auth_response)) # NOTE: Don't do self.server.shutdown() here. It'll halt the server. else: self._send_full_response(self.server.welcome_page) def _send_full_response(self, body, is_ok=True): self.send_response(200 if is_ok else 400) content_type = 'text/html' if body.startswith('<') else 'text/plain' self.send_header('Content-type', content_type) self.end_headers() self.wfile.write(body.encode("utf-8")) def log_message(self, format, *args): logger.debug(format, *args) # To override the default log-to-stderr behavior class _AuthCodeHttpServer(HTTPServer, object): def __init__(self, server_address, *args, **kwargs): _, port = server_address if port and (sys.platform == "win32" or is_wsl()): # The default allow_reuse_address is True. It works fine on non-Windows. # On Windows, it undesirably allows multiple servers listening on same port, # yet the second server would not receive any incoming request. # So, we need to turn it off. self.allow_reuse_address = False super(_AuthCodeHttpServer, self).__init__(server_address, *args, **kwargs) def handle_timeout(self): # It will be triggered when no request comes in self.timeout seconds. # See https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.handle_timeout raise RuntimeError("Timeout. No auth response arrived.") # Terminates this server # We choose to not call self.server_close() here, # because it would cause a socket.error exception in handle_request(), # and likely end up the server being server_close() twice. class _AuthCodeHttpServer6(_AuthCodeHttpServer): address_family = socket.AF_INET6 class AuthCodeReceiver(object): # This class has (rather than is) an _AuthCodeHttpServer, so it does not leak API def __init__(self, port=None, scheduled_actions=None): """Create a Receiver waiting for incoming auth response. :param port: The local web server will listen at http://...: You need to use the same port when you register with your app. If your Identity Provider supports dynamic port, you can use port=0 here. Port 0 means to use an arbitrary unused port, per this official example: https://docs.python.org/2.7/library/socketserver.html#asynchronous-mixins :param scheduled_actions: For example, if the input is ``[(10, lambda: print("Got stuck during sign in? Call 800-000-0000"))]`` then the receiver would call that lambda function after waiting the response for 10 seconds. """ address = "127.0.0.1" # Hardcode, for now, Not sure what to expose, yet. # Per RFC 8252 (https://tools.ietf.org/html/rfc8252#section-8.3): # * Clients should listen on the loopback network interface only. # (It is not recommended to use "" shortcut to bind all addr.) # * the use of localhost is NOT RECOMMENDED. # (Use) the loopback IP literal # rather than localhost avoids inadvertently listening on network # interfaces other than the loopback interface. # Note: # When this server physically listens to a specific IP (as it should), # you will still be able to specify your redirect_uri using either # IP (e.g. 127.0.0.1) or localhost, whichever matches your registration. self._scheduled_actions = sorted(scheduled_actions or []) # Make a copy Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServer # TODO: But, it would treat "localhost" or "" as IPv4. # If pressed, we might just expose a family parameter to caller. self._server = Server((address, port or 0), _AuthCodeHandler) self._closing = False def get_port(self): """The port this server actually listening to""" # https://docs.python.org/2.7/library/socketserver.html#SocketServer.BaseServer.server_address return self._server.server_address[1] def get_auth_response(self, timeout=None, **kwargs): """Wait and return the auth response. Raise RuntimeError when timeout. :param str auth_uri: If provided, this function will try to open a local browser. :param int timeout: In seconds. None means wait indefinitely. :param str state: You may provide the state you used in auth_uri, then we will use it to validate incoming response. :param str welcome_template: If provided, your end user will see it instead of the auth_uri. When present, it shall be a plaintext or html template following `Python Template string syntax `_, and include some of these placeholders: $auth_uri and $abort_uri. :param str success_template: The page will be displayed when authentication was largely successful. Placeholders can be any of these: https://tools.ietf.org/html/rfc6749#section-5.1 :param str error_template: The page will be displayed when authentication encountered error. Placeholders can be any of these: https://tools.ietf.org/html/rfc6749#section-5.2 :param callable auth_uri_callback: A function with the shape of lambda auth_uri: ... When a browser was unable to be launch, this function will be called, so that the app could tell user to manually visit the auth_uri. :param str browser_name: If you did ``webbrowser.register("xyz", None, BackgroundBrowser("/path/to/browser"))`` beforehand, you can pass in the name "xyz" to use that browser. The default value ``None`` means using default browser, which is customizable by env var $BROWSER. :return: The auth response of the first leg of Auth Code flow, typically {"code": "...", "state": "..."} or {"error": "...", ...} See https://tools.ietf.org/html/rfc6749#section-4.1.2 and https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse Returns None when the state was mismatched, or when timeout occurred. """ # Historically, the _get_auth_response() uses HTTPServer.handle_request(), # because its handle-and-retry logic is conceptually as easy as a while loop. # Also, handle_request() honors server.timeout setting, and CTRL+C simply works. # All those are true when running on Linux. # # However, the behaviors on Windows turns out to be different. # A socket server waiting for request would freeze the current thread. # Neither timeout nor CTRL+C would work. End user would have to do CTRL+BREAK. # https://stackoverflow.com/questions/1364173/stopping-python-using-ctrlc # # The solution would need to somehow put the http server into its own thread. # This could be done by the pattern of ``http.server.test()`` which internally # use ``ThreadingHTTPServer.serve_forever()`` (only available in Python 3.7). # Or create our own thread to wrap the HTTPServer.handle_request() inside. result = {} # A mutable object to be filled with thread's return value t = threading.Thread( target=self._get_auth_response, args=(result,), kwargs=kwargs) t.daemon = True # So that it won't prevent the main thread from exiting t.start() begin = time.time() while (time.time() - begin < timeout) if timeout else True: time.sleep(1) # Short detection interval to make happy path responsive if not t.is_alive(): # Then the thread has finished its job and exited break while (self._scheduled_actions and time.time() - begin > self._scheduled_actions[0][0]): _, callback = self._scheduled_actions.pop(0) callback() return result or None def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None, welcome_template=None, success_template=None, error_template=None, auth_uri_callback=None, browser_name=None, ): welcome_uri = "http://localhost:{p}".format(p=self.get_port()) abort_uri = "{loc}?error=abort".format(loc=welcome_uri) logger.debug("Abort by visit %s", abort_uri) self._server.welcome_page = Template(welcome_template or "").safe_substitute( auth_uri=auth_uri, abort_uri=abort_uri) if auth_uri: # Now attempt to open a local browser to visit it _uri = welcome_uri if welcome_template else auth_uri logger.info("Open a browser on this device to visit: %s" % _uri) browser_opened = False try: browser_opened = _browse(_uri, browser_name=browser_name) except: # Had to use broad except, because the potential # webbrowser.Error is purposely undefined outside of _browse(). # Absorb and proceed. Because browser could be manually run elsewhere. logger.exception("_browse(...) unsuccessful") if not browser_opened: if not auth_uri_callback: logger.warning( "Found no browser in current environment. " "If this program is being run inside a container " "which has access to host network " "(i.e. started by `docker run --net=host -it ...`), " "you can use browser on host to visit the following link. " "Otherwise, this auth attempt would either timeout " "(current timeout setting is {timeout}) " "or be aborted by CTRL+C. Auth URI: {auth_uri}".format( auth_uri=_uri, timeout=timeout)) else: # Then it is the auth_uri_callback()'s job to inform the user auth_uri_callback(_uri) self._server.success_template = Template(success_template or "Authentication completed. You can close this window now.") self._server.error_template = Template(error_template or "Authentication failed. $error: $error_description. ($error_uri)") self._server.timeout = timeout # Otherwise its handle_timeout() won't work self._server.auth_response = {} # Shared with _AuthCodeHandler while not self._closing: # Otherwise, the handle_request() attempt # would yield noisy ValueError trace # Derived from # https://docs.python.org/2/library/basehttpserver.html#more-examples self._server.handle_request() if self._server.auth_response: if state and state != self._server.auth_response.get("state"): logger.debug("State mismatch. Ignoring this noise.") else: break result.update(self._server.auth_response) # Return via writable result param def close(self): """Either call this eventually; or use the entire class as context manager""" self._closing = True self._server.server_close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() # Note: Manually use or test this module by: # python -m path.to.this.file -h if __name__ == '__main__': import argparse, json from .oauth2 import Client logging.basicConfig(level=logging.INFO) p = parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, description=__doc__ + "The auth code received will be shown at stdout.") p.add_argument( '--endpoint', help="The auth endpoint for your app.", default="https://login.microsoftonline.com/common/oauth2/v2.0/authorize") p.add_argument('client_id', help="The client_id of your application") p.add_argument('--port', type=int, default=0, help="The port in redirect_uri") p.add_argument('--host', default="127.0.0.1", help="The host of redirect_uri") p.add_argument('--scope', default=None, help="The scope list") args = parser.parse_args() client = Client({"authorization_endpoint": args.endpoint}, args.client_id) with AuthCodeReceiver(port=args.port) as receiver: flow = client.initiate_auth_code_flow( scope=args.scope.split() if args.scope else None, redirect_uri="http://{h}:{p}".format(h=args.host, p=receiver.get_port()), ) print(json.dumps(receiver.get_auth_response( auth_uri=flow["auth_uri"], welcome_template= "Sign In, or Abort= 3 else (basestring, ) class BaseClient(object): # This low-level interface works. Yet you'll find its sub-class # more friendly to remind you what parameters are needed in each scenario. # More on Client Types at https://tools.ietf.org/html/rfc6749#section-2.1 @staticmethod def encode_saml_assertion(assertion): return base64.urlsafe_b64encode(assertion).rstrip(b'=') # Per RFC 7522 CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" CLIENT_ASSERTION_TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer" client_assertion_encoders = {CLIENT_ASSERTION_TYPE_SAML2: encode_saml_assertion} @property def session(self): warnings.warn("Will be gone in next major release", DeprecationWarning) return self._http_client @session.setter def session(self, value): warnings.warn("Will be gone in next major release", DeprecationWarning) self._http_client = value def __init__( self, server_configuration, # type: dict client_id, # type: str http_client=None, # We insert it here to match the upcoming async API client_secret=None, # type: Optional[str] client_assertion=None, # type: Union[bytes, callable, None] client_assertion_type=None, # type: Optional[str] default_headers=None, # type: Optional[dict] default_body=None, # type: Optional[dict] verify=None, # type: Union[str, True, False, None] proxies=None, # type: Optional[dict] timeout=None, # type: Union[tuple, float, None] ): """Initialize a client object to talk all the OAuth2 grants to the server. Args: server_configuration (dict): It contains the configuration (i.e. metadata) of the auth server. The actual content typically contains keys like "authorization_endpoint", "token_endpoint", etc.. Based on RFC 8414 (https://tools.ietf.org/html/rfc8414), you can probably fetch it online from either https://example.com/.../.well-known/oauth-authorization-server or https://example.com/.../.well-known/openid-configuration client_id (str): The client's id, issued by the authorization server http_client (http.HttpClient): Your implementation of abstract class :class:`http.HttpClient`. Defaults to a requests session instance. There is no session-wide `timeout` parameter defined here. Timeout behavior is determined by the actual http client you use. If you happen to use Requests, it disallows session-wide timeout (https://github.com/psf/requests/issues/3341). The workaround is: s = requests.Session() s.request = functools.partial(s.request, timeout=3) and then feed that patched session instance to this class. client_secret (str): Triggers HTTP AUTH for Confidential Client client_assertion (bytes, callable): The client assertion to authenticate this client, per RFC 7521. It can be a raw SAML2 assertion (we will base64 encode it for you), or a raw JWT assertion in bytes (which we will relay to http layer). It can also be a callable (recommended), so that we will do lazy creation of an assertion. client_assertion_type (str): The type of your :attr:`client_assertion` parameter. It is typically the value of :attr:`CLIENT_ASSERTION_TYPE_SAML2` or :attr:`CLIENT_ASSERTION_TYPE_JWT`, the only two defined in RFC 7521. default_headers (dict): A dict to be sent in each request header. It is not required by OAuth2 specs, but you may use it for telemetry. default_body (dict): A dict to be sent in each token request body. For example, you could choose to set this as {"client_secret": "your secret"} if your authorization server wants it to be in the request body (rather than in the request header). verify (boolean): It will be passed to the `verify parameter in the underlying requests library `_. When leaving it with default value (None), we will use True instead. This does not apply if you have chosen to pass your own Http client. proxies (dict): It will be passed to the `proxies parameter in the underlying requests library `_. This does not apply if you have chosen to pass your own Http client. timeout (object): It will be passed to the `timeout parameter in the underlying requests library `_. This does not apply if you have chosen to pass your own Http client. """ if not server_configuration: raise ValueError("Missing input parameter server_configuration") if not client_id: raise ValueError("Missing input parameter client_id") self.configuration = server_configuration self.client_id = client_id self.client_secret = client_secret self.client_assertion = client_assertion self.default_headers = default_headers or {} self.default_body = default_body or {} if client_assertion_type is not None: self.default_body["client_assertion_type"] = client_assertion_type self.logger = logging.getLogger(__name__) if http_client: if verify is not None or proxies is not None or timeout is not None: raise ValueError( "verify, proxies, or timeout is not allowed " "when http_client is in use") self._http_client = http_client else: import requests # Lazy loading self._http_client = requests.Session() self._http_client.verify = True if verify is None else verify self._http_client.proxies = proxies self._http_client.request = functools.partial( # A workaround for requests not supporting session-wide timeout self._http_client.request, timeout=timeout) def _build_auth_request_params(self, response_type, **kwargs): # response_type is a string defined in # https://tools.ietf.org/html/rfc6749#section-3.1.1 # or it can be a space-delimited string as defined in # https://tools.ietf.org/html/rfc6749#section-8.4 response_type = self._stringify(response_type) params = {'client_id': self.client_id, 'response_type': response_type} params.update(kwargs) # Note: None values will override params params = {k: v for k, v in params.items() if v is not None} # clean up if params.get('scope'): params['scope'] = self._stringify(params['scope']) return params # A dict suitable to be used in http request def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 self, grant_type, params=None, # a dict to be sent as query string to the endpoint data=None, # All relevant data, which will go into the http body headers=None, # a dict to be sent as request headers post=None, # A callable to replace requests.post(), for testing. # Such as: lambda url, **kwargs: # Mock(status_code=200, text='{}') **kwargs # Relay all extra parameters to underlying requests ): # Returns the json object came from the OAUTH2 response _data = {'client_id': self.client_id, 'grant_type': grant_type} if self.default_body.get("client_assertion_type") and self.client_assertion: # See https://tools.ietf.org/html/rfc7521#section-4.2 encoder = self.client_assertion_encoders.get( self.default_body["client_assertion_type"], lambda a: a) _data["client_assertion"] = encoder( self.client_assertion() # Do lazy on-the-fly computation if callable(self.client_assertion) else self.client_assertion ) # The type is bytes, which is preferable. See also: # https://github.com/psf/requests/issues/4503#issuecomment-455001070 _data.update(self.default_body) # It may contain authen parameters _data.update(data or {}) # So the content in data param prevails _data = {k: v for k, v in _data.items() if v} # Clean up None values if _data.get('scope'): _data['scope'] = self._stringify(_data['scope']) _headers = {'Accept': 'application/json'} _headers.update(self.default_headers) _headers.update(headers or {}) # Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1 # Clients in possession of a client password MAY use the HTTP Basic # authentication. # Alternatively, (but NOT RECOMMENDED,) # the authorization server MAY support including the # client credentials in the request-body using the following # parameters: client_id, client_secret. if self.client_secret and self.client_id: _headers["Authorization"] = "Basic " + base64.b64encode("{}:{}".format( # Per https://tools.ietf.org/html/rfc6749#section-2.3.1 # client_id and client_secret needs to be encoded by # "application/x-www-form-urlencoded" # https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1 # BEFORE they are fed into HTTP Basic Authentication quote_plus(self.client_id), quote_plus(self.client_secret) ).encode("ascii")).decode("ascii") if "token_endpoint" not in self.configuration: raise ValueError("token_endpoint not found in configuration") resp = (post or self._http_client.post)( self.configuration["token_endpoint"], headers=_headers, params=params, data=_data, **kwargs) if resp.status_code >= 500: resp.raise_for_status() # TODO: Will probably retry here try: # The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says # even an error response will be a valid json structure, # so we simply return it here, without needing to invent an exception. return json.loads(resp.text) except ValueError: self.logger.exception( "Token response is not in json format: %s", resp.text) raise def obtain_token_by_refresh_token(self, refresh_token, scope=None, **kwargs): # type: (str, Union[str, list, set, tuple]) -> dict """Obtain an access token via a refresh token. :param refresh_token: The refresh token issued to the client :param scope: If omitted, is treated as equal to the scope originally granted by the resource owner, according to https://tools.ietf.org/html/rfc6749#section-6 """ assert isinstance(refresh_token, string_types) data = kwargs.pop('data', {}) data.update(refresh_token=refresh_token, scope=scope) return self._obtain_token("refresh_token", data=data, **kwargs) def _stringify(self, sequence): if isinstance(sequence, (list, set, tuple)): return ' '.join(sorted(sequence)) # normalizing it, ascendingly return sequence # as-is def _scope_set(scope): assert scope is None or isinstance(scope, (list, set, tuple)) return set(scope) if scope else set([]) def _generate_pkce_code_verifier(length=43): assert 43 <= length <= 128 verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1 random.sample(string.ascii_letters + string.digits + "-._~", length)) code_challenge = ( # https://tools.ietf.org/html/rfc7636#section-4.2 base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) .rstrip(b"=")) # Required by https://tools.ietf.org/html/rfc7636#section-3 return { "code_verifier": verifier, "transformation": "S256", # In Python, sha256 is always available "code_challenge": code_challenge, } class Client(BaseClient): # We choose to implement all 4 grants in 1 class """This is the main API for oauth2 client. Its methods define and document parameters mentioned in OAUTH2 RFC 6749. """ DEVICE_FLOW = { # consts for device flow, that can be customized by sub-class "GRANT_TYPE": "urn:ietf:params:oauth:grant-type:device_code", "DEVICE_CODE": "device_code", } DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down") GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522 GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523 grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion} def initiate_device_flow(self, scope=None, **kwargs): # type: (list, **dict) -> dict # The naming of this method is following the wording of this specs # https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.1 """Initiate a device flow. Returns the data defined in Device Flow specs. https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.2 You should then orchestrate the User Interaction as defined in here https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.3 And possibly here https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.3.1 """ DAE = "device_authorization_endpoint" if not self.configuration.get(DAE): raise ValueError("You need to provide device authorization endpoint") resp = self._http_client.post(self.configuration[DAE], data={"client_id": self.client_id, "scope": self._stringify(scope or [])}, headers=dict(self.default_headers, **kwargs.pop("headers", {})), **kwargs) flow = json.loads(resp.text) flow["interval"] = int(flow.get("interval", 5)) # Some IdP returns string flow["expires_in"] = int(flow.get("expires_in", 1800)) flow["expires_at"] = time.time() + flow["expires_in"] # We invent this return flow def _obtain_token_by_device_flow(self, flow, **kwargs): # type: (dict, **dict) -> dict # This method updates flow during each run. And it is non-blocking. now = time.time() skew = 1 if flow.get("latest_attempt_at", 0) + flow.get("interval", 5) - skew > now: warnings.warn('Attempted too soon. Please do time.sleep(flow["interval"])') data = kwargs.pop("data", {}) data.update({ "client_id": self.client_id, self.DEVICE_FLOW["DEVICE_CODE"]: flow["device_code"], }) result = self._obtain_token( self.DEVICE_FLOW["GRANT_TYPE"], data=data, **kwargs) if result.get("error") == "slow_down": # Respecting https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.5 flow["interval"] = flow.get("interval", 5) + 5 flow["latest_attempt_at"] = now return result def obtain_token_by_device_flow(self, flow, exit_condition=lambda flow: flow.get("expires_at", 0) < time.time(), **kwargs): # type: (dict, Callable) -> dict """Obtain token by a device flow object, with customizable polling effect. Args: flow (dict): An object previously generated by initiate_device_flow(...). Its content WILL BE CHANGED by this method during each run. We share this object with you, so that you could implement your own loop, should you choose to do so. exit_condition (Callable): This method implements a loop to provide polling effect. The loop's exit condition is calculated by this callback. The default callback makes the loop run until the flow expires. Therefore, one of the ways to exit the polling early, is to change the flow["expires_at"] to a small number such as 0. In case you are doing async programming, you may want to completely turn off the loop. You can do so by using a callback as: exit_condition = lambda flow: True to make the loop run only once, i.e. no polling, hence non-block. """ while True: result = self._obtain_token_by_device_flow(flow, **kwargs) if result.get("error") not in self.DEVICE_FLOW_RETRIABLE_ERRORS: return result for i in range(flow.get("interval", 5)): # Wait interval seconds if exit_condition(flow): return result time.sleep(1) # Shorten each round, to make exit more responsive def _build_auth_request_uri( self, response_type, redirect_uri=None, scope=None, state=None, **kwargs): if "authorization_endpoint" not in self.configuration: raise ValueError("authorization_endpoint not found in configuration") authorization_endpoint = self.configuration["authorization_endpoint"] params = self._build_auth_request_params( response_type, redirect_uri=redirect_uri, scope=scope, state=state, **kwargs) sep = '&' if '?' in authorization_endpoint else '?' return "%s%s%s" % (authorization_endpoint, sep, urlencode(params)) def build_auth_request_uri( self, response_type, redirect_uri=None, scope=None, state=None, **kwargs): # This method could be named build_authorization_request_uri() instead, # but then there would be a build_authentication_request_uri() in the OIDC # subclass doing almost the same thing. So we use a loose term "auth" here. """Generate an authorization uri to be visited by resource owner. Parameters are the same as another method :func:`initiate_auth_code_flow()`, whose functionality is a superset of this method. :return: The auth uri as a string. """ warnings.warn("Use initiate_auth_code_flow() instead. ", DeprecationWarning) return self._build_auth_request_uri( response_type, redirect_uri=redirect_uri, scope=scope, state=state, **kwargs) def initiate_auth_code_flow( # The name is influenced by OIDC # https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth self, scope=None, redirect_uri=None, state=None, **kwargs): """Initiate an auth code flow. Later when the response reaches your redirect_uri, you can use :func:`~obtain_token_by_auth_code_flow()` to complete the authentication/authorization. This method also provides PKCE protection automatically. :param list scope: It is a list of case-sensitive strings. Some ID provider can accept empty string to represent default scope. :param str redirect_uri: Optional. If not specified, server will use the pre-registered one. :param str state: An opaque value used by the client to maintain state between the request and callback. If absent, this library will automatically generate one internally. :param kwargs: Other parameters, typically defined in OpenID Connect. :return: The auth code flow. It is a dict in this form:: { "auth_uri": "https://...", // Guide user to visit this "state": "...", // You may choose to verify it by yourself, // or just let obtain_token_by_auth_code_flow() // do that for you. "...": "...", // Everything else are reserved and internal } The caller is expected to:: 1. somehow store this content, typically inside the current session, 2. guide the end user (i.e. resource owner) to visit that auth_uri, 3. and then relay this dict and subsequent auth response to :func:`~obtain_token_by_auth_code_flow()`. """ response_type = kwargs.pop("response_type", "code") # Auth Code flow # Must be "code" when you are using Authorization Code Grant. # The "token" for Implicit Grant is not applicable thus not allowed. # It could theoretically be other # (possibly space-delimited) strings as registered extension value. # See https://tools.ietf.org/html/rfc6749#section-3.1.1 if "token" in response_type: # Implicit grant would cause auth response coming back in #fragment, # but fragment won't reach a web service. raise ValueError('response_type="token ..." is not allowed') pkce = _generate_pkce_code_verifier() flow = { # These data are required by obtain_token_by_auth_code_flow() "state": state or "".join(random.sample(string.ascii_letters, 16)), "redirect_uri": redirect_uri, "scope": scope, } auth_uri = self._build_auth_request_uri( response_type, code_challenge=pkce["code_challenge"], code_challenge_method=pkce["transformation"], **dict(flow, **kwargs)) flow["auth_uri"] = auth_uri flow["code_verifier"] = pkce["code_verifier"] return flow def obtain_token_by_auth_code_flow( self, auth_code_flow, auth_response, scope=None, **kwargs): """With the auth_response being redirected back, validate it against auth_code_flow, and then obtain tokens. Internally, it implements PKCE to mitigate the auth code interception attack. :param dict auth_code_flow: The same dict returned by :func:`~initiate_auth_code_flow()`. :param dict auth_response: A dict based on query string received from auth server. :param scope: You don't usually need to use scope parameter here. Some Identity Provider allows you to provide a subset of what you specified during :func:`~initiate_auth_code_flow`. :type scope: collections.Iterable[str] :return: * A dict containing "access_token" and/or "id_token", among others, depends on what scope was used. (See https://tools.ietf.org/html/rfc6749#section-5.1) * A dict containing "error", optionally "error_description", "error_uri". (It is either `this `_ or `that `_ * Most client-side data error would result in ValueError exception. So the usage pattern could be without any protocol details:: def authorize(): # A controller in a web app try: result = client.obtain_token_by_auth_code_flow( session.get("flow", {}), auth_resp) if "error" in result: return render_template("error.html", result) store_tokens() except ValueError: # Usually caused by CSRF pass # Simply ignore them return redirect(url_for("index")) """ assert isinstance(auth_code_flow, dict) and isinstance(auth_response, dict) # This is app developer's error which we do NOT want to map to ValueError if not auth_code_flow.get("state"): # initiate_auth_code_flow() already guarantees a state to be available. # This check will also allow a web app to blindly call this method with # obtain_token_by_auth_code_flow(session.get("flow", {}), auth_resp) # which further simplifies their usage. raise ValueError("state missing from auth_code_flow") if auth_code_flow.get("state") != auth_response.get("state"): raise ValueError("state mismatch: {} vs {}".format( auth_code_flow.get("state"), auth_response.get("state"))) if scope and set(scope) - set(auth_code_flow.get("scope", [])): raise ValueError( "scope must be None or a subset of %s" % auth_code_flow.get("scope")) if auth_response.get("code"): # i.e. the first leg was successful return self._obtain_token_by_authorization_code( auth_response["code"], redirect_uri=auth_code_flow.get("redirect_uri"), # Required, if "redirect_uri" parameter was included in the # authorization request, and their values MUST be identical. scope=scope or auth_code_flow.get("scope"), # It is both unnecessary and harmless, per RFC 6749. # We use the same scope already used in auth request uri, # thus token cache can know what scope the tokens are for. data=dict( # Extract and update the data kwargs.pop("data", {}), code_verifier=auth_code_flow["code_verifier"], ), **kwargs) if auth_response.get("error"): # It means the first leg encountered error # Here we do NOT return original auth_response as-is, to prevent a # potential {..., "access_token": "attacker's AT"} input being leaked error = {"error": auth_response["error"]} if auth_response.get("error_description"): error["error_description"] = auth_response["error_description"] if auth_response.get("error_uri"): error["error_uri"] = auth_response["error_uri"] return error raise ValueError('auth_response must contain either "code" or "error"') def obtain_token_by_browser( # Name influenced by RFC 8252: "native apps should (use) ... user's browser" self, redirect_uri=None, auth_code_receiver=None, **kwargs): """A native app can use this method to obtain token via a local browser. Internally, it implements PKCE to mitigate the auth code interception attack. :param scope: A list of scopes that you would like to obtain token for. :type scope: collections.Iterable[str] :param extra_scope_to_consent: Some IdP allows you to include more scopes for end user to consent. The access token returned by this method will NOT include those scopes, but the refresh token would record those extra consent, so that your future :func:`~obtain_token_by_refresh_token()` call would be able to obtain token for those additional scopes, silently. :type scope: collections.Iterable[str] :param string redirect_uri: The redirect_uri to be sent via auth request to Identity Provider (IdP), to indicate where an auth response would come back to. Such as ``http://127.0.0.1:0`` (default) or ``http://localhost:1234``. If port 0 is specified, this method will choose a system-allocated port, then the actual redirect_uri will contain that port. To use this behavior, your IdP would need to accept such dynamic port. Per HTTP convention, if port number is absent, it would mean port 80, although you probably want to specify port 0 in this context. :param dict auth_params: These parameters will be sent to authorization_endpoint. :param int timeout: In seconds. None means wait indefinitely. :param str browser_name: If you did ``webbrowser.register("xyz", None, BackgroundBrowser("/path/to/browser"))`` beforehand, you can pass in the name "xyz" to use that browser. The default value ``None`` means using default browser, which is customizable by env var $BROWSER. :return: Same as :func:`~obtain_token_by_auth_code_flow()` """ if auth_code_receiver: # Then caller already knows the listen port return self._obtain_token_by_browser( # Use all input param as-is auth_code_receiver, redirect_uri=redirect_uri, **kwargs) # Otherwise we will listen on _redirect_uri.port _redirect_uri = urlparse(redirect_uri or "http://127.0.0.1:0") if not _redirect_uri.hostname: raise ValueError("redirect_uri should contain hostname") listen_port = ( # Conventionally, port-less uri would mean port 80 80 if _redirect_uri.port is None else _redirect_uri.port) try: with _AuthCodeReceiver(port=listen_port) as receiver: uri = redirect_uri if _redirect_uri.port != 0 else urlunparse(( _redirect_uri.scheme, "{}:{}".format(_redirect_uri.hostname, receiver.get_port()), _redirect_uri.path, _redirect_uri.params, _redirect_uri.query, _redirect_uri.fragment, )) # It could be slightly different than raw redirect_uri self.logger.debug("Using {} as redirect_uri".format(uri)) return self._obtain_token_by_browser( receiver, redirect_uri=uri, **kwargs) except PermissionError: raise ValueError( "Can't listen on port %s. You may try port 0." % listen_port) def _obtain_token_by_browser( self, auth_code_receiver, scope=None, extra_scope_to_consent=None, redirect_uri=None, timeout=None, welcome_template=None, success_template=None, error_template=None, auth_params=None, auth_uri_callback=None, browser_name=None, **kwargs): # Internally, it calls self.initiate_auth_code_flow() and # self.obtain_token_by_auth_code_flow(). # # Parameters are documented in public method obtain_token_by_browser(). flow = self.initiate_auth_code_flow( redirect_uri=redirect_uri, scope=_scope_set(scope) | _scope_set(extra_scope_to_consent), **(auth_params or {})) auth_response = auth_code_receiver.get_auth_response( auth_uri=flow["auth_uri"], state=flow["state"], # Optional but we choose to do it upfront timeout=timeout, welcome_template=welcome_template, success_template=success_template, error_template=error_template, auth_uri_callback=auth_uri_callback, browser_name=browser_name, ) return self.obtain_token_by_auth_code_flow( flow, auth_response, scope=scope, **kwargs) @staticmethod def parse_auth_response(params, state=None): """Parse the authorization response being redirected back. :param params: A string or dict of the query string :param state: REQUIRED if the state parameter was present in the client authorization request. This function will compare it with response. """ warnings.warn( "Use obtain_token_by_auth_code_flow() instead", DeprecationWarning) if not isinstance(params, dict): params = parse_qs(params) if params.get('state') != state: raise ValueError('state mismatch') return params def obtain_token_by_authorization_code( self, code, redirect_uri=None, scope=None, **kwargs): """Get a token via authorization code. a.k.a. Authorization Code Grant. This is typically used by a server-side app (Confidential Client), but it can also be used by a device-side native app (Public Client). See more detail at https://tools.ietf.org/html/rfc6749#section-4.1.3 You are encouraged to use its higher level method :func:`~obtain_token_by_auth_code_flow` instead. :param code: The authorization code received from authorization server. :param redirect_uri: Required, if the "redirect_uri" parameter was included in the authorization request, and their values MUST be identical. :param scope: It is both unnecessary and harmless to use scope here, per RFC 6749. We suggest to use the same scope already used in auth request uri, so that this library can link the obtained tokens with their scope. """ warnings.warn( "Use obtain_token_by_auth_code_flow() instead", DeprecationWarning) return self._obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=scope, **kwargs) def _obtain_token_by_authorization_code( self, code, redirect_uri=None, scope=None, **kwargs): data = kwargs.pop("data", {}) data.update(code=code, redirect_uri=redirect_uri) if scope: data["scope"] = scope if not self.client_secret: # client_id is required, if the client is not authenticating itself. # See https://tools.ietf.org/html/rfc6749#section-4.1.3 data["client_id"] = self.client_id return self._obtain_token("authorization_code", data=data, **kwargs) def obtain_token_by_username_password( self, username, password, scope=None, **kwargs): """The Resource Owner Password Credentials Grant, used by legacy app.""" data = kwargs.pop("data", {}) data.update(username=username, password=password, scope=scope) return self._obtain_token("password", data=data, **kwargs) def obtain_token_for_client(self, scope=None, **kwargs): """Obtain token for this client (rather than for an end user), a.k.a. the Client Credentials Grant, used by Backend Applications. We don't name it obtain_token_by_client_credentials(...) because those credentials are typically already provided in class constructor, not here. You can still explicitly provide an optional client_secret parameter, or you can provide such extra parameters as `default_body` during the class initialization. """ data = kwargs.pop("data", {}) data.update(scope=scope) return self._obtain_token("client_credentials", data=data, **kwargs) def __init__(self, server_configuration, client_id, on_obtaining_tokens=lambda event: None, # event is defined in _obtain_token(...) on_removing_rt=lambda token_item: None, on_updating_rt=lambda token_item, new_rt: None, **kwargs): super(Client, self).__init__(server_configuration, client_id, **kwargs) self.on_obtaining_tokens = on_obtaining_tokens self.on_removing_rt = on_removing_rt self.on_updating_rt = on_updating_rt def _obtain_token( self, grant_type, params=None, data=None, also_save_rt=False, on_obtaining_tokens=None, *args, **kwargs): _data = data.copy() # to prevent side effect resp = super(Client, self)._obtain_token( grant_type, params, _data, *args, **kwargs) if "error" not in resp: _resp = resp.copy() RT = "refresh_token" if grant_type == RT and RT in _resp and not also_save_rt: # Then we skip it from on_obtaining_tokens(); # Leave it to self.obtain_token_by_refresh_token() _resp.pop(RT, None) if "scope" in _resp: scope = _resp["scope"].split() # It is conceptually a set, # but we represent it as a list which can be persisted to JSON else: # Note: The scope will generally be absent in authorization grant, # but our obtain_token_by_authorization_code(...) encourages # app developer to still explicitly provide a scope here. scope = _data.get("scope") (on_obtaining_tokens or self.on_obtaining_tokens)({ "client_id": self.client_id, "scope": scope, "token_endpoint": self.configuration["token_endpoint"], "grant_type": grant_type, # can be used to know an IdToken-less # response is for an app or for a user "response": _resp, "params": params, "data": _data, }) return resp def obtain_token_by_refresh_token(self, token_item, scope=None, rt_getter=lambda token_item: token_item["refresh_token"], on_removing_rt=None, on_updating_rt=None, **kwargs): # type: (Union[str, dict], Union[str, list, set, tuple], Callable) -> dict """This is an overload which will trigger token storage callbacks. :param token_item: A refresh token (RT) item, in flexible format. It can be a string, or a whatever data structure containing RT string and its metadata, in such case the `rt_getter` callable must be able to extract the RT string out from the token item data structure. Either way, this token_item will be passed into other callbacks as-is. :param scope: If omitted, is treated as equal to the scope originally granted by the resource owner, according to https://tools.ietf.org/html/rfc6749#section-6 :param rt_getter: A callable to translate the token_item to a raw RT string :param on_removing_rt: If absent, fall back to the one defined in initialization :param on_updating_rt: Default to None, it will fall back to the one defined in initialization. This is the most common case. As a special case, you can pass in a False, then this function will NOT trigger on_updating_rt() for RT UPDATE, instead it will allow the RT to be added by on_obtaining_tokens(). This behavior is useful when you are migrating RTs from elsewhere into a token storage managed by this library. """ resp = super(Client, self).obtain_token_by_refresh_token( rt_getter(token_item) if not isinstance(token_item, string_types) else token_item, scope=scope, also_save_rt=on_updating_rt is False, **kwargs) if resp.get('error') == 'invalid_grant': (on_removing_rt or self.on_removing_rt)(token_item) # Discard old RT RT = "refresh_token" if on_updating_rt is not False and RT in resp: (on_updating_rt or self.on_updating_rt)(token_item, resp[RT]) return resp def obtain_token_by_assertion( self, assertion, grant_type, scope=None, **kwargs): # type: (bytes, Union[str, None], Union[str, list, set, tuple]) -> dict """This method implements Assertion Framework for OAuth2 (RFC 7521). See details at https://tools.ietf.org/html/rfc7521#section-4.1 :param assertion: The assertion bytes can be a raw SAML2 assertion, or a JWT assertion. :param grant_type: It is typically either the value of :attr:`GRANT_TYPE_SAML2`, or :attr:`GRANT_TYPE_JWT`, the only two profiles defined in RFC 7521. :param scope: Optional. It must be a subset of previously granted scopes. """ encoder = self.grant_assertion_encoders.get(grant_type, lambda a: a) data = kwargs.pop("data", {}) data.update(scope=scope, assertion=encoder(assertion)) return self._obtain_token(grant_type, data=data, **kwargs) microsoft-authentication-library-for-python-1.17.0/msal/oauth2cli/oidc.py000066400000000000000000000315211420154156600265130ustar00rootroot00000000000000import json import base64 import time import random import string import warnings import hashlib from . import oauth2 def decode_part(raw, encoding="utf-8"): """Decode a part of the JWT. JWT is encoded by padding-less base64url, based on `JWS specs `_. :param encoding: If you are going to decode the first 2 parts of a JWT, i.e. the header or the payload, the default value "utf-8" would work fine. If you are going to decode the last part i.e. the signature part, it is a binary string so you should use `None` as encoding here. """ raw += '=' * (-len(raw) % 4) # https://stackoverflow.com/a/32517907/728675 raw = str( # On Python 2.7, argument of urlsafe_b64decode must be str, not unicode. # This is not required on Python 3. raw) output = base64.urlsafe_b64decode(raw) if encoding: output = output.decode(encoding) return output base64decode = decode_part # Obsolete. For backward compatibility only. def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None): """Decodes and validates an id_token and returns its claims as a dictionary. ID token claims would at least contain: "iss", "sub", "aud", "exp", "iat", per `specs `_ and it may contain other optional content such as "preferred_username", `maybe more `_ """ decoded = json.loads(decode_part(id_token.split('.')[1])) err = None # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation _now = int(now or time.time()) skew = 120 # 2 minutes TIME_SUGGESTION = "Make sure your computer's time and time zone are both correct." if _now + skew < decoded.get("nbf", _now - 1): # nbf is optional per JWT specs # This is not an ID token validation, but a JWT validation # https://tools.ietf.org/html/rfc7519#section-4.1.5 err = "0. The ID token is not yet valid. " + TIME_SUGGESTION if issuer and issuer != decoded["iss"]: # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse err = ('2. The Issuer Identifier for the OpenID Provider, "%s", ' "(which is typically obtained during Discovery), " "MUST exactly match the value of the iss (issuer) Claim.") % issuer if client_id: valid_aud = client_id in decoded["aud"] if isinstance( decoded["aud"], list) else client_id == decoded["aud"] if not valid_aud: err = ( "3. The aud (audience) claim must contain this client's client_id " '"%s", case-sensitively. Was your client_id in wrong casing?' # Some IdP accepts wrong casing request but issues right casing IDT ) % client_id # Per specs: # 6. If the ID Token is received via direct communication between # the Client and the Token Endpoint (which it is during _obtain_token()), # the TLS server validation MAY be used to validate the issuer # in place of checking the token signature. if _now - skew > decoded["exp"]: err = "9. The ID token already expires. " + TIME_SUGGESTION if nonce and nonce != decoded.get("nonce"): err = ("11. Nonce must be the same value " "as the one that was sent in the Authentication Request.") if err: raise RuntimeError("%s Current epoch = %s. The id_token was: %s" % ( err, _now, json.dumps(decoded, indent=2))) return decoded def _nonce_hash(nonce): # https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes return hashlib.sha256(nonce.encode("ascii")).hexdigest() class Prompt(object): """This class defines the constant strings for prompt parameter. The values are based on https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ NONE = "none" LOGIN = "login" CONSENT = "consent" SELECT_ACCOUNT = "select_account" CREATE = "create" # Defined in https://openid.net/specs/openid-connect-prompt-create-1_0.html#PromptParameter class Client(oauth2.Client): """OpenID Connect is a layer on top of the OAuth2. See its specs at https://openid.net/connect/ """ def decode_id_token(self, id_token, nonce=None): """See :func:`~decode_id_token`.""" return decode_id_token( id_token, nonce=nonce, client_id=self.client_id, issuer=self.configuration.get("issuer")) def _obtain_token(self, grant_type, *args, **kwargs): """The result will also contain one more key "id_token_claims", whose value will be a dictionary returned by :func:`~decode_id_token`. """ ret = super(Client, self)._obtain_token(grant_type, *args, **kwargs) if "id_token" in ret: ret["id_token_claims"] = self.decode_id_token(ret["id_token"]) return ret def build_auth_request_uri(self, response_type, nonce=None, **kwargs): """Generate an authorization uri to be visited by resource owner. Return value and all other parameters are the same as :func:`oauth2.Client.build_auth_request_uri`, plus new parameter(s): :param nonce: A hard-to-guess string used to mitigate replay attacks. See also `OIDC specs `_. """ warnings.warn("Use initiate_auth_code_flow() instead", DeprecationWarning) return super(Client, self).build_auth_request_uri( response_type, nonce=nonce, **kwargs) def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs): """Get a token via authorization code. a.k.a. Authorization Code Grant. Return value and all other parameters are the same as :func:`oauth2.Client.obtain_token_by_authorization_code`, plus new parameter(s): :param nonce: If you provided a nonce when calling :func:`build_auth_request_uri`, same nonce should also be provided here, so that we'll validate it. An exception will be raised if the nonce in id token mismatches. """ warnings.warn( "Use obtain_token_by_auth_code_flow() instead", DeprecationWarning) result = super(Client, self).obtain_token_by_authorization_code( code, **kwargs) nonce_in_id_token = result.get("id_token_claims", {}).get("nonce") if "id_token_claims" in result and nonce and nonce != nonce_in_id_token: raise ValueError( 'The nonce in id token ("%s") should match your nonce ("%s")' % (nonce_in_id_token, nonce)) return result def initiate_auth_code_flow( self, scope=None, **kwargs): """Initiate an auth code flow. It provides nonce protection automatically. :param list scope: A list of strings, e.g. ["profile", "email", ...]. This method will automatically send ["openid"] to the wire, although it won't modify your input list. See :func:`oauth2.Client.initiate_auth_code_flow` in parent class for descriptions on other parameters and return value. """ if "id_token" in kwargs.get("response_type", ""): # Implicit grant would cause auth response coming back in #fragment, # but fragment won't reach a web service. raise ValueError('response_type="id_token ..." is not allowed') _scope = list(scope) if scope else [] # We won't modify input parameter if "openid" not in _scope: # "If no openid scope value is present, # the request may still be a valid OAuth 2.0 request, # but is not an OpenID Connect request." -- OIDC Core Specs, 3.1.2.2 # https://openid.net/specs/openid-connect-core-1_0.html#AuthRequestValidation # Here we just automatically add it. If the caller do not want id_token, # they should simply go with oauth2.Client. _scope.append("openid") nonce = "".join(random.sample(string.ascii_letters, 16)) flow = super(Client, self).initiate_auth_code_flow( scope=_scope, nonce=_nonce_hash(nonce), **kwargs) flow["nonce"] = nonce if kwargs.get("max_age") is not None: flow["max_age"] = kwargs["max_age"] return flow def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs): """Validate the auth_response being redirected back, and then obtain tokens, including ID token which can be used for user sign in. Internally, it implements nonce to mitigate replay attack. It also implements PKCE to mitigate the auth code interception attack. See :func:`oauth2.Client.obtain_token_by_auth_code_flow` in parent class for descriptions on other parameters and return value. """ result = super(Client, self).obtain_token_by_auth_code_flow( auth_code_flow, auth_response, **kwargs) if "id_token_claims" in result: nonce_in_id_token = result.get("id_token_claims", {}).get("nonce") expected_hash = _nonce_hash(auth_code_flow["nonce"]) if nonce_in_id_token != expected_hash: raise RuntimeError( 'The nonce in id token ("%s") should match our nonce ("%s")' % (nonce_in_id_token, expected_hash)) if auth_code_flow.get("max_age") is not None: auth_time = result.get("id_token_claims", {}).get("auth_time") if not auth_time: raise RuntimeError( "13. max_age was requested, ID token should contain auth_time") now = int(time.time()) skew = 120 # 2 minutes. Hardcoded, for now if now - skew > auth_time + auth_code_flow["max_age"]: raise RuntimeError( "13. auth_time ({auth_time}) was requested, " "by using max_age ({max_age}) parameter, " "and now ({now}) too much time has elasped " "since last end-user authentication. " "The ID token was: {id_token}".format( auth_time=auth_time, max_age=auth_code_flow["max_age"], now=now, id_token=json.dumps(result["id_token_claims"], indent=2), )) return result def obtain_token_by_browser( self, display=None, prompt=None, max_age=None, ui_locales=None, id_token_hint=None, # It is relevant, # because this library exposes raw ID token login_hint=None, acr_values=None, **kwargs): """A native app can use this method to obtain token via a local browser. Internally, it implements nonce to mitigate replay attack. It also implements PKCE to mitigate the auth code interception attack. :param string display: Defined in `OIDC `_. :param string prompt: Defined in `OIDC `_. You can find the valid string values defined in :class:`oidc.Prompt`. :param int max_age: Defined in `OIDC `_. :param string ui_locales: Defined in `OIDC `_. :param string id_token_hint: Defined in `OIDC `_. :param string login_hint: Defined in `OIDC `_. :param string acr_values: Defined in `OIDC `_. See :func:`oauth2.Client.obtain_token_by_browser` in parent class for descriptions on other parameters and return value. """ filtered_params = {k:v for k, v in dict( prompt=" ".join(prompt) if isinstance(prompt, (list, tuple)) else prompt, display=display, max_age=max_age, ui_locales=ui_locales, id_token_hint=id_token_hint, login_hint=login_hint, acr_values=acr_values, ).items() if v is not None} # Filter out None values return super(Client, self).obtain_token_by_browser( auth_params=dict(kwargs.pop("auth_params", {}), **filtered_params), **kwargs) microsoft-authentication-library-for-python-1.17.0/msal/region.py000066400000000000000000000033121420154156600251630ustar00rootroot00000000000000import os import logging logger = logging.getLogger(__name__) def _detect_region(http_client=None): region = os.environ.get("REGION_NAME", "").replace(" ", "").lower() # e.g. westus2 if region: return region if http_client: return _detect_region_of_azure_vm(http_client) # It could hang for minutes return None def _detect_region_of_azure_vm(http_client): url = ( "http://169.254.169.254/metadata/instance" # Utilize the "route parameters" feature to obtain region as a string # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#route-parameters "/compute/location?format=text" # Location info is available since API version 2017-04-02 # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#response-1 "&api-version=2021-01-01" ) logger.info( "Connecting to IMDS {}. " "It may take a while if you are running outside of Azure. " "You should consider opting in/out region behavior on-demand, " 'by loading a boolean flag "is_deployed_in_azure" ' 'from your per-deployment config and then do ' '"app = ConfidentialClientApplication(..., ' 'azure_region=is_deployed_in_azure)"'.format(url)) try: # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#instance-metadata resp = http_client.get(url, headers={"Metadata": "true"}) except: logger.info( "IMDS {} unavailable. Perhaps not running in Azure VM?".format(url)) return None else: return resp.text.strip() microsoft-authentication-library-for-python-1.17.0/msal/telemetry.py000066400000000000000000000061701420154156600257170ustar00rootroot00000000000000import uuid import logging logger = logging.getLogger(__name__) CLIENT_REQUEST_ID = 'client-request-id' CLIENT_CURRENT_TELEMETRY = "x-client-current-telemetry" CLIENT_LAST_TELEMETRY = "x-client-last-telemetry" NON_SILENT_CALL = 0 FORCE_REFRESH = 1 AT_ABSENT = 2 AT_EXPIRED = 3 AT_AGING = 4 RESERVED = 5 def _get_new_correlation_id(): return str(uuid.uuid4()) class _TelemetryContext(object): """It is used for handling the telemetry context for current OAuth2 "exchange".""" # https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=%2FTelemetry%2FMSALServerSideTelemetry.md&_a=preview _SUCCEEDED = "succeeded" _FAILED = "failed" _FAILURE_SIZE = "failure_size" _CURRENT_HEADER_SIZE_LIMIT = 100 _LAST_HEADER_SIZE_LIMIT = 350 def __init__(self, buffer, lock, api_id, correlation_id=None, refresh_reason=None): self._buffer = buffer self._lock = lock self._api_id = api_id self._correlation_id = correlation_id or _get_new_correlation_id() self._refresh_reason = refresh_reason or NON_SILENT_CALL logger.debug("Generate or reuse correlation_id: %s", self._correlation_id) def generate_headers(self): with self._lock: current = "4|{api_id},{cache_refresh}|".format( api_id=self._api_id, cache_refresh=self._refresh_reason) if len(current) > self._CURRENT_HEADER_SIZE_LIMIT: logger.warning( "Telemetry header greater than {} will be truncated by AAD".format( self._CURRENT_HEADER_SIZE_LIMIT)) failures = self._buffer.get(self._FAILED, []) return { CLIENT_REQUEST_ID: self._correlation_id, CLIENT_CURRENT_TELEMETRY: current, CLIENT_LAST_TELEMETRY: "4|{succeeded}|{failed_requests}|{errors}|".format( succeeded=self._buffer.get(self._SUCCEEDED, 0), failed_requests=",".join("{a},{c}".format(**f) for f in failures), errors=",".join(f["e"] for f in failures), ) } def hit_an_access_token(self): with self._lock: self._buffer[self._SUCCEEDED] = self._buffer.get(self._SUCCEEDED, 0) + 1 def update_telemetry(self, auth_result): if auth_result: with self._lock: if "error" in auth_result: self._record_failure(auth_result["error"]) else: # Telemetry sent successfully. Reset buffer self._buffer.clear() # This won't work: self._buffer = {} def _record_failure(self, error): simulation = len(",{api_id},{correlation_id},{error}".format( api_id=self._api_id, correlation_id=self._correlation_id, error=error)) if self._buffer.get(self._FAILURE_SIZE, 0) + simulation < self._LAST_HEADER_SIZE_LIMIT: self._buffer[self._FAILURE_SIZE] = self._buffer.get( self._FAILURE_SIZE, 0) + simulation self._buffer.setdefault(self._FAILED, []).append({ "a": self._api_id, "c": self._correlation_id, "e": error}) microsoft-authentication-library-for-python-1.17.0/msal/throttled_http_client.py000066400000000000000000000155521420154156600303170ustar00rootroot00000000000000from threading import Lock from hashlib import sha256 from .individual_cache import _IndividualCache as IndividualCache from .individual_cache import _ExpiringMapping as ExpiringMapping # https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 DEVICE_AUTH_GRANT = "urn:ietf:params:oauth:grant-type:device_code" def _hash(raw): return sha256(repr(raw).encode("utf-8")).hexdigest() def _parse_http_429_5xx_retry_after(result=None, **ignored): """Return seconds to throttle""" assert result is not None, """ The signature defines it with a default value None, only because the its shape is already decided by the IndividualCache's.__call__(). In actual code path, the result parameter here won't be None. """ response = result lowercase_headers = {k.lower(): v for k, v in getattr( # Historically, MSAL's HttpResponse does not always have headers response, "headers", {}).items()} if not (response.status_code == 429 or response.status_code >= 500 or "retry-after" in lowercase_headers): return 0 # Quick exit default = 60 # Recommended at the end of # https://identitydivision.visualstudio.com/devex/_git/AuthLibrariesApiReview?version=GBdev&path=%2FService%20protection%2FIntial%20set%20of%20protection%20measures.md&_a=preview retry_after = int(lowercase_headers.get("retry-after", default)) try: # AAD's retry_after uses integer format only # https://stackoverflow.microsoft.com/questions/264931/264932 delay_seconds = int(retry_after) except ValueError: delay_seconds = default return min(3600, delay_seconds) def _extract_data(kwargs, key, default=None): data = kwargs.get("data", {}) # data is usually a dict, but occasionally a string return data.get(key) if isinstance(data, dict) else default class ThrottledHttpClient(object): def __init__(self, http_client, http_cache): """Throttle the given http_client by storing and retrieving data from cache. This wrapper exists so that our patching post() and get() would prevent re-patching side effect when/if same http_client being reused. """ expiring_mapping = ExpiringMapping( # It will automatically clean up mapping=http_cache if http_cache is not None else {}, capacity=1024, # To prevent cache blowing up especially for CCA lock=Lock(), # TODO: This should ideally also allow customization ) _post = http_client.post # We'll patch _post, and keep original post() intact _post = IndividualCache( # Internal specs requires throttling on at least token endpoint, # here we have a generic patch for POST on all endpoints. mapping=expiring_mapping, key_maker=lambda func, args, kwargs: "POST {} client_id={} scope={} hash={} 429/5xx/Retry-After".format( args[0], # It is the url, typically containing authority and tenant _extract_data(kwargs, "client_id"), # Per internal specs _extract_data(kwargs, "scope"), # Per internal specs _hash( # The followings are all approximations of the "account" concept # to support per-account throttling. # TODO: We may want to disable it for confidential client, though _extract_data(kwargs, "refresh_token", # "account" during refresh _extract_data(kwargs, "code", # "account" of auth code grant _extract_data(kwargs, "username")))), # "account" of ROPC ), expires_in=_parse_http_429_5xx_retry_after, )(_post) _post = IndividualCache( # It covers the "UI required cache" mapping=expiring_mapping, key_maker=lambda func, args, kwargs: "POST {} hash={} 400".format( args[0], # It is the url, typically containing authority and tenant _hash( # Here we use literally all parameters, even those short-lived # parameters containing timestamps (WS-Trust or POP assertion), # because they will automatically be cleaned up by ExpiringMapping. # # Furthermore, there is no need to implement # "interactive requests would reset the cache", # because acquire_token_silent()'s would be automatically unblocked # due to token cache layer operates on top of http cache layer. # # And, acquire_token_silent(..., force_refresh=True) will NOT # bypass http cache, because there is no real gain from that. # We won't bother implement it, nor do we want to encourage # acquire_token_silent(..., force_refresh=True) pattern. str(kwargs.get("params")) + str(kwargs.get("data"))), ), expires_in=lambda result=None, kwargs=None, **ignored: 60 if result.status_code == 400 # Here we choose to cache exact HTTP 400 errors only (rather than 4xx) # because they are the ones defined in OAuth2 # (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) # Other 4xx errors might have different requirements e.g. # "407 Proxy auth required" would need a key including http headers. and not( # Exclude Device Flow whose retry is expected and regulated isinstance(kwargs.get("data"), dict) and kwargs["data"].get("grant_type") == DEVICE_AUTH_GRANT ) and "retry-after" not in set( # Leave it to the Retry-After decorator h.lower() for h in getattr(result, "headers", {}).keys()) else 0, )(_post) self.post = _post self.get = IndividualCache( # Typically those discovery GETs mapping=expiring_mapping, key_maker=lambda func, args, kwargs: "GET {} hash={} 2xx".format( args[0], # It is the url, sometimes containing inline params _hash(kwargs.get("params", "")), ), expires_in=lambda result=None, **ignored: 3600*24 if 200 <= result.status_code < 300 else 0, )(http_client.get) self._http_client = http_client # The following 2 methods have been defined dynamically by __init__() #def post(self, *args, **kwargs): pass #def get(self, *args, **kwargs): pass def close(self): """MSAL won't need this. But we allow throttled_http_client.close() anyway""" return self._http_client.close() microsoft-authentication-library-for-python-1.17.0/msal/token_cache.py000066400000000000000000000354361420154156600261570ustar00rootroot00000000000000import json import threading import time import logging from .authority import canonicalize from .oauth2cli.oidc import decode_part, decode_id_token logger = logging.getLogger(__name__) def is_subdict_of(small, big): return dict(big, **small) == big class TokenCache(object): """This is considered as a base class containing minimal cache behavior. Although it maintains tokens using unified schema across all MSAL libraries, this class does not serialize/persist them. See subclass :class:`SerializableTokenCache` for details on serialization. """ class CredentialType: ACCESS_TOKEN = "AccessToken" REFRESH_TOKEN = "RefreshToken" ACCOUNT = "Account" # Not exactly a credential type, but we put it here ID_TOKEN = "IdToken" APP_METADATA = "AppMetadata" class AuthorityType: ADFS = "ADFS" MSSTS = "MSSTS" # MSSTS means AAD v2 for both AAD & MSA def __init__(self): self._lock = threading.RLock() self._cache = {} self.key_makers = { self.CredentialType.REFRESH_TOKEN: lambda home_account_id=None, environment=None, client_id=None, target=None, **ignored_payload_from_a_real_token: "-".join([ home_account_id or "", environment or "", self.CredentialType.REFRESH_TOKEN, client_id or "", "", # RT is cross-tenant in AAD target or "", # raw value could be None if deserialized from other SDK ]).lower(), self.CredentialType.ACCESS_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, target=None, **ignored_payload_from_a_real_token: "-".join([ home_account_id or "", environment or "", self.CredentialType.ACCESS_TOKEN, client_id or "", realm or "", target or "", ]).lower(), self.CredentialType.ID_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, **ignored_payload_from_a_real_token: "-".join([ home_account_id or "", environment or "", self.CredentialType.ID_TOKEN, client_id or "", realm or "", "" # Albeit irrelevant, schema requires an empty scope here ]).lower(), self.CredentialType.ACCOUNT: lambda home_account_id=None, environment=None, realm=None, **ignored_payload_from_a_real_entry: "-".join([ home_account_id or "", environment or "", realm or "", ]).lower(), self.CredentialType.APP_METADATA: lambda environment=None, client_id=None, **kwargs: "appmetadata-{}-{}".format(environment or "", client_id or ""), } def find(self, credential_type, target=None, query=None): target = target or [] assert isinstance(target, list), "Invalid parameter type" target_set = set(target) with self._lock: # Since the target inside token cache key is (per schema) unsorted, # there is no point to attempt an O(1) key-value search here. # So we always do an O(n) in-memory search. return [entry for entry in self._cache.get(credential_type, {}).values() if is_subdict_of(query or {}, entry) and (target_set <= set(entry.get("target", "").split()) if target else True) ] def add(self, event, now=None): # type: (dict) -> None """Handle a token obtaining event, and add tokens into cache. Known side effects: This function modifies the input event in place. """ def wipe(dictionary, sensitive_fields): # Masks sensitive info for sensitive in sensitive_fields: if sensitive in dictionary: dictionary[sensitive] = "********" wipe(event.get("data", {}), ("password", "client_secret", "refresh_token", "assertion")) try: return self.__add(event, now=now) finally: wipe(event.get("response", {}), ( # These claims were useful during __add() "access_token", "refresh_token", "id_token", "username")) wipe(event, ["username"]) # Needed for federated ROPC logger.debug("event=%s", json.dumps( # We examined and concluded that this log won't have Log Injection risk, # because the event payload is already in JSON so CR/LF will be escaped. event, indent=4, sort_keys=True, default=str, # A workaround when assertion is in bytes in Python 3 )) def __parse_account(self, response, id_token_claims): """Return client_info and home_account_id""" if "client_info" in response: # It happens when client_info and profile are in request client_info = json.loads(decode_part(response["client_info"])) if "uid" in client_info and "utid" in client_info: return client_info, "{uid}.{utid}".format(**client_info) # https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/387 if id_token_claims: # This would be an end user on ADFS-direct scenario sub = id_token_claims["sub"] # "sub" always exists, per OIDC specs return {"uid": sub}, sub # client_credentials flow will reach this code path return {}, None def __add(self, event, now=None): # event typically contains: client_id, scope, token_endpoint, # response, params, data, grant_type environment = realm = None if "token_endpoint" in event: _, environment, realm = canonicalize(event["token_endpoint"]) if "environment" in event: # Always available unless in legacy test cases environment = event["environment"] # Set by application.py response = event.get("response", {}) data = event.get("data", {}) access_token = response.get("access_token") refresh_token = response.get("refresh_token") id_token = response.get("id_token") id_token_claims = ( decode_id_token(id_token, client_id=event["client_id"]) if id_token else {}) client_info, home_account_id = self.__parse_account(response, id_token_claims) target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it with self._lock: now = int(time.time() if now is None else now) if access_token: expires_in = int( # AADv1-like endpoint returns a string response.get("expires_in", 3599)) ext_expires_in = int( # AADv1-like endpoint returns a string response.get("ext_expires_in", expires_in)) at = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), "target": target, "realm": realm, "token_type": response.get("token_type", "Bearer"), "cached_at": str(now), # Schema defines it as a string "expires_on": str(now + expires_in), # Same here "extended_expires_on": str(now + ext_expires_in) # Same here } if data.get("key_id"): # It happens in SSH-cert or POP scenario at["key_id"] = data.get("key_id") if "refresh_in" in response: refresh_in = response["refresh_in"] # It is an integer at["refresh_on"] = str(now + refresh_in) # Schema wants a string self.modify(self.CredentialType.ACCESS_TOKEN, at, at) if client_info and not event.get("skip_account_creation"): account = { "home_account_id": home_account_id, "environment": environment, "realm": realm, "local_account_id": id_token_claims.get( "oid", id_token_claims.get("sub")), "username": id_token_claims.get("preferred_username") # AAD or id_token_claims.get("upn") # ADFS 2019 or data.get("username") # Falls back to ROPC username or event.get("username") # Falls back to Federated ROPC username or "", # The schema does not like null "authority_type": self.AuthorityType.ADFS if realm == "adfs" else self.AuthorityType.MSSTS, # "client_info": response.get("client_info"), # Optional } self.modify(self.CredentialType.ACCOUNT, account, account) if id_token: idt = { "credential_type": self.CredentialType.ID_TOKEN, "secret": id_token, "home_account_id": home_account_id, "environment": environment, "realm": realm, "client_id": event.get("client_id"), # "authority": "it is optional", } self.modify(self.CredentialType.ID_TOKEN, idt, idt) if refresh_token: rt = { "credential_type": self.CredentialType.REFRESH_TOKEN, "secret": refresh_token, "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), "target": target, # Optional per schema though "last_modification_time": str(now), # Optional. Schema defines it as a string. } if "foci" in response: rt["family_id"] = response["foci"] self.modify(self.CredentialType.REFRESH_TOKEN, rt, rt) app_metadata = { "client_id": event.get("client_id"), "environment": environment, } if "foci" in response: app_metadata["family_id"] = response.get("foci") self.modify(self.CredentialType.APP_METADATA, app_metadata, app_metadata) def modify(self, credential_type, old_entry, new_key_value_pairs=None): # Modify the specified old_entry with new_key_value_pairs, # or remove the old_entry if the new_key_value_pairs is None. # This helper exists to consolidate all token add/modify/remove behaviors, # so that the sub-classes will have only one method to work on, # instead of patching a pair of update_xx() and remove_xx() per type. # You can monkeypatch self.key_makers to support more types on-the-fly. key = self.key_makers[credential_type](**old_entry) with self._lock: if new_key_value_pairs: # Update with them entries = self._cache.setdefault(credential_type, {}) entries[key] = dict( old_entry, # Do not use entries[key] b/c it might not exist **new_key_value_pairs) else: # Remove old_entry self._cache.setdefault(credential_type, {}).pop(key, None) def remove_rt(self, rt_item): assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN return self.modify(self.CredentialType.REFRESH_TOKEN, rt_item) def update_rt(self, rt_item, new_rt): assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN return self.modify(self.CredentialType.REFRESH_TOKEN, rt_item, { "secret": new_rt, "last_modification_time": str(int(time.time())), # Optional. Schema defines it as a string. }) def remove_at(self, at_item): assert at_item.get("credential_type") == self.CredentialType.ACCESS_TOKEN return self.modify(self.CredentialType.ACCESS_TOKEN, at_item) def remove_idt(self, idt_item): assert idt_item.get("credential_type") == self.CredentialType.ID_TOKEN return self.modify(self.CredentialType.ID_TOKEN, idt_item) def remove_account(self, account_item): assert "authority_type" in account_item return self.modify(self.CredentialType.ACCOUNT, account_item) class SerializableTokenCache(TokenCache): """This serialization can be a starting point to implement your own persistence. This class does NOT actually persist the cache on disk/db/etc.. Depending on your need, the following simple recipe for file-based persistence may be sufficient:: import os, atexit, msal cache = msal.SerializableTokenCache() if os.path.exists("my_cache.bin"): cache.deserialize(open("my_cache.bin", "r").read()) atexit.register(lambda: open("my_cache.bin", "w").write(cache.serialize()) # Hint: The following optional line persists only when state changed if cache.has_state_changed else None ) app = msal.ClientApplication(..., token_cache=cache) ... :var bool has_state_changed: Indicates whether the cache state in the memory has changed since last :func:`~serialize` or :func:`~deserialize` call. """ has_state_changed = False def add(self, event, **kwargs): super(SerializableTokenCache, self).add(event, **kwargs) self.has_state_changed = True def modify(self, credential_type, old_entry, new_key_value_pairs=None): super(SerializableTokenCache, self).modify( credential_type, old_entry, new_key_value_pairs) self.has_state_changed = True def deserialize(self, state): # type: (Optional[str]) -> None """Deserialize the cache from a state previously obtained by serialize()""" with self._lock: self._cache = json.loads(state) if state else {} self.has_state_changed = False # reset def serialize(self): # type: () -> str """Serialize the current cache state into a string.""" with self._lock: self.has_state_changed = False return json.dumps(self._cache, indent=4) microsoft-authentication-library-for-python-1.17.0/msal/wstrust_request.py000066400000000000000000000137221420154156600272110ustar00rootroot00000000000000#------------------------------------------------------------------------------ # # Copyright (c) Microsoft Corporation. # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # #------------------------------------------------------------------------------ import uuid from datetime import datetime, timedelta import logging from .mex import Mex from .wstrust_response import parse_response logger = logging.getLogger(__name__) def send_request( username, password, cloud_audience_urn, endpoint_address, soap_action, http_client, **kwargs): if not endpoint_address: raise ValueError("WsTrust endpoint address can not be empty") if soap_action is None: if '/trust/2005/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_2005 elif '/trust/13/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_13 if soap_action not in (Mex.ACTION_13, Mex.ACTION_2005): raise ValueError("Unsupported soap action: %s. " "Contact your administrator to check your ADFS's MEX settings." % soap_action) data = _build_rst( username, password, cloud_audience_urn, endpoint_address, soap_action) resp = http_client.post(endpoint_address, data=data, headers={ 'Content-type':'application/soap+xml; charset=utf-8', 'SOAPAction': soap_action, }, **kwargs) if resp.status_code >= 400: logger.debug("Unsuccessful WsTrust request receives: %s", resp.text) # It turns out ADFS uses 5xx status code even with client-side incorrect password error # resp.raise_for_status() return parse_response(resp.text) def escape_password(password): return (password.replace('&', '&').replace('"', '"') .replace("'", ''') # the only one not provided by cgi.escape(s, True) .replace('<', '<').replace('>', '>')) def wsu_time_format(datetime_obj): # WsTrust (http://docs.oasis-open.org/ws-sx/ws-trust/v1.4/ws-trust.html) # does not seem to define timestamp format, but we see YYYY-mm-ddTHH:MM:SSZ # here (https://www.ibm.com/developerworks/websphere/library/techarticles/1003_chades/1003_chades.html) # It avoids the uncertainty of the optional ".ssssss" in datetime.isoformat() # https://docs.python.org/2/library/datetime.html#datetime.datetime.isoformat return datetime_obj.strftime('%Y-%m-%dT%H:%M:%SZ') def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_action): now = datetime.utcnow() return """ {soap_action} urn:uuid:{message_id} http://www.w3.org/2005/08/addressing/anonymous {endpoint_address} {time_now} {time_expire} {username} {password} {applies_to} {key_type} {request_type} """.format( s=Mex.NS["s"], wsu=Mex.NS["wsu"], wsa=Mex.NS["wsa10"], soap_action=soap_action, message_id=str(uuid.uuid4()), endpoint_address=endpoint_address, time_now=wsu_time_format(now), time_expire=wsu_time_format(now + timedelta(minutes=10)), username=username, password=escape_password(password), wst=Mex.NS["wst"] if soap_action == Mex.ACTION_13 else Mex.NS["wst2005"], applies_to=cloud_audience_urn, key_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer' if soap_action == Mex.ACTION_13 else 'http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey', request_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue' if soap_action == Mex.ACTION_13 else 'http://schemas.xmlsoap.org/ws/2005/02/trust/Issue', ) microsoft-authentication-library-for-python-1.17.0/msal/wstrust_response.py000066400000000000000000000107671420154156600273650ustar00rootroot00000000000000#------------------------------------------------------------------------------ # # Copyright (c) Microsoft Corporation. # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # #------------------------------------------------------------------------------ try: from xml.etree import cElementTree as ET except ImportError: from xml.etree import ElementTree as ET import re from .mex import Mex SAML_TOKEN_TYPE_V1 = 'urn:oasis:names:tc:SAML:1.0:assertion' SAML_TOKEN_TYPE_V2 = 'urn:oasis:names:tc:SAML:2.0:assertion' # http://docs.oasis-open.org/wss-m/wss/v1.1.1/os/wss-SAMLTokenProfile-v1.1.1-os.html#_Toc307397288 WSS_SAML_TOKEN_PROFILE_V1_1 = "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" WSS_SAML_TOKEN_PROFILE_V2 = "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0" def parse_response(body): # Returns {"token": "", "type": "..."} token = parse_token_by_re(body) if token: return token error = parse_error(body) raise RuntimeError("WsTrust server returned error in RSTR: %s" % (error or body)) def parse_error(body): # Returns error as a dict. See unit test case for an example. dom = ET.fromstring(body) reason_text_node = dom.find('s:Body/s:Fault/s:Reason/s:Text', Mex.NS) subcode_value_node = dom.find('s:Body/s:Fault/s:Code/s:Subcode/s:Value', Mex.NS) if reason_text_node is not None or subcode_value_node is not None: return {"reason": reason_text_node.text, "code": subcode_value_node.text} def findall_content(xml_string, tag): """ Given a tag name without any prefix, this function returns a list of the raw content inside this tag as-is. >>> findall_content(" what ever content ", "foo") [" what ever content "] Motivation: Usually we would use XML parser to extract the data by xpath. However the ElementTree in Python will implicitly normalize the output by "hoisting" the inner inline namespaces into the outmost element. The result will be a semantically equivalent XML snippet, but not fully identical to the original one. While this effect shouldn't become a problem in all other cases, it does not seem to fully comply with Exclusive XML Canonicalization spec (https://www.w3.org/TR/xml-exc-c14n/), and void the SAML token signature. SAML signature algo needs the "XML -> C14N(XML) -> Signed(C14N(Xml))" order. The binary extention lxml is probably the canonical way to solve this (https://stackoverflow.com/questions/22959577/python-exclusive-xml-canonicalization-xml-exc-c14n) but here we use this workaround, based on Regex, to return raw content as-is. """ # \w+ is good enough for https://www.w3.org/TR/REC-xml/#NT-NameChar pattern = r"<(?:\w+:)?%(tag)s(?:[^>]*)>(.*)=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', 'cryptography>=0.6,<39', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # # And we will use the cryptography (X+3).0.0 as the upper bound, # based on their latest deprecation policy # https://cryptography.io/en/latest/api-stability/#deprecation "mock;python_version<'3.3'", ] ) microsoft-authentication-library-for-python-1.17.0/tests/000077500000000000000000000000001420154156600235355ustar00rootroot00000000000000microsoft-authentication-library-for-python-1.17.0/tests/__init__.py000066400000000000000000000015621420154156600256520ustar00rootroot00000000000000import sys import logging if sys.version_info[:2] < (2, 7): # The unittest module got a significant overhaul in Python 2.7, # so if we're in 2.6 we can use the backported version unittest2. import unittest2 as unittest else: import unittest class Oauth2TestCase(unittest.TestCase): logger = logging.getLogger(__file__) def assertLoosely(self, response, assertion=None, skippable_errors=("invalid_grant", "interaction_required")): if response.get("error") in skippable_errors: self.logger.debug("Response = %s", response) # Some of these errors are configuration issues, not library issues raise unittest.SkipTest(response.get("error_description")) else: if assertion is None: assertion = lambda: self.assertIn("access_token", response) assertion() microsoft-authentication-library-for-python-1.17.0/tests/archan.us.mex.xml000066400000000000000000001457541420154156600267510ustar00rootroot00000000000000 http://schemas.xmlsoap.org/ws/2005/02/trust/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://schemas.xmlsoap.org/ws/2005/02/trust/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc https://arvmserver2012.archan.us/adfs/services/trust/2005/windowstransport host/ARVMServer2012.archan.us https://arvmserver2012.archan.us/adfs/services/trust/2005/certificatemixed https://arvmserver2012.archan.us/adfs/services/trust/2005/certificatetransport https://arvmserver2012.archan.us/adfs/services/trust/2005/usernamemixed https://arvmserver2012.archan.us/adfs/services/trust/2005/kerberosmixed https://arvmserver2012.archan.us/adfs/services/trust/2005/issuedtokenmixedasymmetricbasic256 https://arvmserver2012.archan.us/adfs/services/trust/2005/issuedtokenmixedsymmetricbasic256 https://arvmserver2012.archan.us/adfs/services/trust/13/kerberosmixed https://arvmserver2012.archan.us/adfs/services/trust/13/certificatemixed https://arvmserver2012.archan.us/adfs/services/trust/13/usernamemixed https://arvmserver2012.archan.us/adfs/services/trust/13/issuedtokenmixedasymmetricbasic256 https://arvmserver2012.archan.us/adfs/services/trust/13/issuedtokenmixedsymmetricbasic256 https://arvmserver2012.archan.us/adfs/services/trust/13/windowstransport host/ARVMServer2012.archan.us microsoft-authentication-library-for-python-1.17.0/tests/arupela.mex.xml000066400000000000000000001404651420154156600265120ustar00rootroot00000000000000 http://schemas.xmlsoap.org/ws/2005/02/trust/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://schemas.xmlsoap.org/ws/2005/02/trust/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc https://fs.arupela.com/adfs/services/trust/2005/windowstransport host/fs.arupela.com https://fs.arupela.com/adfs/services/trust/2005/certificatemixed https://fs.arupela.com:49443/adfs/services/trust/2005/certificatetransport https://fs.arupela.com/adfs/services/trust/2005/usernamemixed https://fs.arupela.com/adfs/services/trust/2005/kerberosmixed https://fs.arupela.com/adfs/services/trust/2005/issuedtokenmixedasymmetricbasic256 https://fs.arupela.com/adfs/services/trust/2005/issuedtokenmixedsymmetricbasic256 https://fs.arupela.com/adfs/services/trust/13/kerberosmixed https://fs.arupela.com/adfs/services/trust/13/certificatemixed https://fs.arupela.com/adfs/services/trust/13/usernamemixed https://fs.arupela.com/adfs/services/trust/13/issuedtokenmixedasymmetricbasic256 https://fs.arupela.com/adfs/services/trust/13/issuedtokenmixedsymmetricbasic256 microsoft-authentication-library-for-python-1.17.0/tests/http_client.py000066400000000000000000000027331420154156600264310ustar00rootroot00000000000000import requests class MinimalHttpClient: def __init__(self, verify=True, proxies=None, timeout=None): self.session = requests.Session() self.session.verify = verify self.session.proxies = proxies self.timeout = timeout def post(self, url, params=None, data=None, headers=None, **kwargs): assert not kwargs, "Our stack shouldn't leak extra kwargs: %s" % kwargs return MinimalResponse(requests_resp=self.session.post( url, params=params, data=data, headers=headers, timeout=self.timeout)) def get(self, url, params=None, headers=None, **kwargs): assert not kwargs, "Our stack shouldn't leak extra kwargs: %s" % kwargs return MinimalResponse(requests_resp=self.session.get( url, params=params, headers=headers, timeout=self.timeout)) def close(self): # Not required, but we use it to avoid a warning in unit test self.session.close() class MinimalResponse(object): # Not for production use def __init__(self, requests_resp=None, status_code=None, text=None): self.status_code = status_code or requests_resp.status_code self.text = text or requests_resp.text self._raw_resp = requests_resp def raise_for_status(self): if self._raw_resp is not None: # Turns out `if requests.response` won't work # cause it would be True when 200<=status<400 self._raw_resp.raise_for_status() microsoft-authentication-library-for-python-1.17.0/tests/microsoft.mex.xml000066400000000000000000001461141420154156600270630ustar00rootroot00000000000000 http://schemas.xmlsoap.org/ws/2005/02/trust/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://schemas.xmlsoap.org/ws/2005/02/trust/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/PublicKey http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2000/09/xmldsig#rsa-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc http://docs.oasis-open.org/ws-sx/ws-trust/200512/SymmetricKey 256 http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p http://www.w3.org/2001/04/xmlenc#aes256-cbc http://www.w3.org/2000/09/xmldsig#hmac-sha1 http://www.w3.org/2001/10/xml-exc-c14n# http://www.w3.org/2001/04/xmlenc#aes256-cbc https://corp.sts.microsoft.com/adfs/services/trust/2005/windowstransport iamfed@redmond.corp.microsoft.com https://corp.sts.microsoft.com/adfs/services/trust/2005/certificatemixed https://corp.sts.microsoft.com/adfs/services/trust/2005/usernamemixed https://corp.sts.microsoft.com/adfs/services/trust/2005/kerberosmixed https://corp.sts.microsoft.com/adfs/services/trust/2005/issuedtokenmixedasymmetricbasic256 https://corp.sts.microsoft.com/adfs/services/trust/2005/issuedtokenmixedsymmetricbasic256 https://corp.sts.microsoft.com/adfs/services/trust/13/kerberosmixed https://corp.sts.microsoft.com/adfs/services/trust/13/certificatemixed https://corp.sts.microsoft.com/adfs/services/trust/13/usernamemixed https://corp.sts.microsoft.com/adfs/services/trust/13/issuedtokenmixedasymmetricbasic256 https://corp.sts.microsoft.com/adfs/services/trust/13/issuedtokenmixedsymmetricbasic256 https://corp.sts.microsoft.com/adfs/services/trust/13/windowstransport iamfed@redmond.corp.microsoft.com microsoft-authentication-library-for-python-1.17.0/tests/rst_response.xml000066400000000000000000000212151420154156600270060ustar00rootroot00000000000000 http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTRC/IssueFinal 2013-11-15T03:08:25.221Z 2013-11-15T03:13:25.221Z 2013-11-15T03:08:25.205Z 2013-11-15T04:08:25.205Z https://login.microsoftonline.com/extSTS.srf https://login.microsoftonline.com/extSTS.srf 1TIu064jGEmmf+hnI+F0Jg== urn:oasis:names:tc:SAML:1.0:cm:bearer frizzo@richard-randall.com 1TIu064jGEmmf+hnI+F0Jg== 1TIu064jGEmmf+hnI+F0Jg== urn:oasis:names:tc:SAML:1.0:cm:bearer 3i95D+nRbsyRitSPeT7ZtEr5vbM= aVNmmKLNdAlBxxcNciWVfxynZUPR9ql8ZZSZt/qpqL/GB3HX/cL/QnfG2OOKrmhgEaR0Ul4grZhGJxlxMPDL0fhnBz+VJ5HwztMFgMYs3Md8A2sZd9n4dfu7+CByAna06lCwwfdFWlNV1MBFvlWvYtCLNkpYVr/aglmb9zpMkNxEOmHe/cwxUtYlzH4RpIsIT5pruoJtUxKcqTRDEeeYdzjBAiJuguQTChLmHNoMPdX1RmtJlPsrZ1s9R/IJky7fHLjB7jiTDceRCS5QUbgUqYbLG1MjFXthY2Hr7K9kpYjxxIk6xmM7mFQE3Hts3bj6UU7ElUvHpX9bxxk3pqzlhg== MIIC6DCCAdCgAwIBAgIQaztYF2TpvZZG6yreA3NRpzANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBmcy5yaWNoYXJkLXJhbmRhbGwuY29tMB4XDTEzMTExMTAzNTMwMFoXDTE0MTExMTAzNTMwMFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZnMucmljaGFyZC1yYW5kYWxsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO+1VWY/sYDdN3hdsvT+mWHTcOwjp2G9e0AEZdmgh7bS54WUJw9y0cMxJmGB0jAAW40zomzIbS8/o3iuxcJyFgBVtMFfXwFjVQJnZJ7IMXFs1V/pJHrwWHxePz/WzXFtMaqEIe8QummJ07UBg9UsYZUYTGO9NDGw1Yr/oRNsl7bLA0S/QlW6yryf6l3snHzIgtO2xiWn6q3vCJTTVNMROkI2YKNKdYiD5fFD77kFACfJmOwP8MN9u+HM2IN6g0Nv5s7rMyw077Co/xKefamWQCB0jLpv89jo3hLgkwIgWX4cMVgHSNmdzXSgC3owG8ivRuJDATh83GiqI6jzA1+x4rkCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAxA5MQZHw9lJYDpU4f45EYrWPEaAPnncaoxIeLE9fG14gA01frajRfdyoO0AKqb+ZG6sePKngsuq4QHA2EnEI4Di5uWKsXy1Id0AXUSUhLpe63alZ8OwiNKDKn71nwpXnlGwKqljnG3xBMniGtGKrFS4WM+joEHzaKpvgtGRGoDdtXF4UXZJcn2maw6d/kiHrQ3kWoQcQcJ9hVIo8bC0BPvxV0Qh4TF3Nb3tKhaXsY68eMxMGbHok9trVHQ3Vew35FuTg1JzsfCFSDF8sxu7FJ4iZ7VLM8MQLnvIMcubLJvc57EHSsNyeiqBFQIYkdg7MSf+Ot2qJjfExgo+NOtWN+g== _9bd2b280-f153-471a-9b73-c1df0d555075 _9bd2b280-f153-471a-9b73-c1df0d555075 urn:oasis:names:tc:SAML:1.0:assertion http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer microsoft-authentication-library-for-python-1.17.0/tests/test_application.py000066400000000000000000000740411420154156600274570ustar00rootroot00000000000000# Note: Since Aug 2019 we move all e2e tests into test_e2e.py, # so this test_application file contains only unit tests without dependency. from msal.application import * from msal.application import _str2bytes import msal from msal.application import _merge_claims_challenge_and_capabilities from tests import unittest from tests.test_token_cache import build_id_token, build_response from tests.http_client import MinimalHttpClient, MinimalResponse from msal.telemetry import CLIENT_CURRENT_TELEMETRY, CLIENT_LAST_TELEMETRY logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) class TestHelperExtractCerts(unittest.TestCase): # It is used by SNI scenario def test_extract_a_tag_less_public_cert(self): pem = "my_cert" self.assertEqual(["my_cert"], extract_certs(pem)) def test_extract_a_tag_enclosed_cert(self): pem = """ -----BEGIN CERTIFICATE----- my_cert -----END CERTIFICATE----- """ self.assertEqual(["my_cert"], extract_certs(pem)) def test_extract_multiple_tag_enclosed_certs(self): pem = """ -----BEGIN CERTIFICATE----- my_cert1 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- my_cert2 -----END CERTIFICATE----- """ self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem)) class TestBytesConversion(unittest.TestCase): def test_string_to_bytes(self): self.assertEqual(type(_str2bytes("some string")), type(b"bytes")) def test_bytes_to_bytes(self): self.assertEqual(type(_str2bytes(b"some bytes")), type(b"bytes")) class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase): def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" self.authority = msal.authority.Authority( self.authority_url, MinimalHttpClient()) self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" self.account = {"home_account_id": "{}.{}".format(self.uid, self.utid)} self.rt = "this is a rt" self.cache = msal.SerializableTokenCache() self.client_id = "my_app" self.cache.add({ # Pre-populate the cache "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": build_response( access_token="an expired AT to trigger refresh", expires_in=-99, uid=self.uid, utid=self.utid, refresh_token=self.rt), }) # The add(...) helper populates correct home_account_id for future searching self.app = ClientApplication( self.client_id, authority=self.authority_url, token_cache=self.cache) def test_cache_empty_will_be_returned_as_None(self): self.app.token_cache = msal.SerializableTokenCache() # Reset it to empty self.assertEqual( None, self.app.acquire_token_silent_with_error(['cache_miss'], self.account)) def test_acquire_token_silent_will_suppress_error(self): error_response = '{"error": "invalid_grant", "suberror": "xyz"}' def tester(url, **kwargs): return MinimalResponse(status_code=400, text=error_response) self.assertEqual(None, self.app.acquire_token_silent( self.scopes, self.account, post=tester)) def test_acquire_token_silent_with_error_will_return_error(self): error_response = '{"error": "invalid_grant", "error_description": "xyz"}' def tester(url, **kwargs): return MinimalResponse(status_code=400, text=error_response) self.assertEqual(json.loads(error_response), self.app.acquire_token_silent_with_error( self.scopes, self.account, post=tester)) def test_atswe_will_map_some_suberror_to_classification_as_is(self): error_response = '{"error": "invalid_grant", "suberror": "basic_action"}' def tester(url, **kwargs): return MinimalResponse(status_code=400, text=error_response) result = self.app.acquire_token_silent_with_error( self.scopes, self.account, post=tester) self.assertEqual("basic_action", result.get("classification")) def test_atswe_will_map_some_suberror_to_classification_to_empty_string(self): error_response = '{"error": "invalid_grant", "suberror": "client_mismatch"}' def tester(url, **kwargs): return MinimalResponse(status_code=400, text=error_response) result = self.app.acquire_token_silent_with_error( self.scopes, self.account, post=tester) self.assertEqual("", result.get("classification")) class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase): def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" self.authority = msal.authority.Authority( self.authority_url, MinimalHttpClient()) self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" self.account = {"home_account_id": "{}.{}".format(self.uid, self.utid)} self.frt = "what the frt" self.cache = msal.SerializableTokenCache() self.preexisting_family_app_id = "preexisting_family_app" self.cache.add({ # Pre-populate a FRT "client_id": self.preexisting_family_app_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": build_response( access_token="Siblings won't share AT. test_remove_account() will.", id_token=build_id_token(aud=self.preexisting_family_app_id), uid=self.uid, utid=self.utid, refresh_token=self.frt, foci="1"), }) # The add(...) helper populates correct home_account_id for future searching def test_unknown_orphan_app_will_attempt_frt_and_not_remove_it(self): app = ClientApplication( "unknown_orphan", authority=self.authority_url, token_cache=self.cache) logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) error_response = '{"error": "invalid_grant","error_description": "Was issued to another client"}' def tester(url, data=None, **kwargs): self.assertEqual(self.frt, data.get("refresh_token"), "Should attempt the FRT") return MinimalResponse(status_code=400, text=error_response) app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self.authority, self.scopes, self.account, post=tester) self.assertNotEqual([], app.token_cache.find( msal.TokenCache.CredentialType.REFRESH_TOKEN, query={"secret": self.frt}), "The FRT should not be removed from the cache") def test_known_orphan_app_will_skip_frt_and_only_use_its_own_rt(self): app = ClientApplication( "known_orphan", authority=self.authority_url, token_cache=self.cache) rt = "RT for this orphan app. We will check it being used by this test case." self.cache.add({ # Populate its RT and AppMetadata, so it becomes a known orphan app "client_id": app.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": build_response(uid=self.uid, utid=self.utid, refresh_token=rt), }) logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) def tester(url, data=None, **kwargs): self.assertEqual(rt, data.get("refresh_token"), "Should attempt the RT") return MinimalResponse(status_code=200, text='{}') app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self.authority, self.scopes, self.account, post=tester) def test_unknown_family_app_will_attempt_frt_and_join_family(self): def tester(url, data=None, **kwargs): self.assertEqual( self.frt, data.get("refresh_token"), "Should attempt the FRT") return MinimalResponse( status_code=200, text=json.dumps(build_response( uid=self.uid, utid=self.utid, foci="1", access_token="at"))) app = ClientApplication( "unknown_family_app", authority=self.authority_url, token_cache=self.cache) at = app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self.authority, self.scopes, self.account, post=tester) logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) self.assertEqual("at", at.get("access_token"), "New app should get a new AT") app_metadata = app.token_cache.find( msal.TokenCache.CredentialType.APP_METADATA, query={"client_id": app.client_id}) self.assertNotEqual([], app_metadata, "Should record new app's metadata") self.assertEqual("1", app_metadata[0].get("family_id"), "The new family app should be recorded as in the same family") # Known family app will simply use FRT, which is largely the same as this one # Will not test scenario of app leaving family. Per specs, it won't happen. def test_preexisting_family_app_will_attempt_frt_and_return_error(self): error_response = '{"error": "invalid_grant", "error_description": "xyz"}' def tester(url, data=None, **kwargs): self.assertEqual( self.frt, data.get("refresh_token"), "Should attempt the FRT") return MinimalResponse(status_code=400, text=error_response) app = ClientApplication( "preexisting_family_app", authority=self.authority_url, token_cache=self.cache) resp = app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self.authority, self.scopes, self.account, post=tester) logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) self.assertEqual(json.loads(error_response), resp, "Error raised will be returned") def test_family_app_remove_account(self): logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) app = ClientApplication( self.preexisting_family_app_id, authority=self.authority_url, token_cache=self.cache) account = app.get_accounts()[0] mine = {"home_account_id": account["home_account_id"]} self.assertNotEqual([], self.cache.find( self.cache.CredentialType.ACCESS_TOKEN, query=mine)) self.assertNotEqual([], self.cache.find( self.cache.CredentialType.REFRESH_TOKEN, query=mine)) self.assertNotEqual([], self.cache.find( self.cache.CredentialType.ID_TOKEN, query=mine)) self.assertNotEqual([], self.cache.find( self.cache.CredentialType.ACCOUNT, query=mine)) app.remove_account(account) self.assertEqual([], self.cache.find( self.cache.CredentialType.ACCESS_TOKEN, query=mine)) self.assertEqual([], self.cache.find( self.cache.CredentialType.REFRESH_TOKEN, query=mine)) self.assertEqual([], self.cache.find( self.cache.CredentialType.ID_TOKEN, query=mine)) self.assertEqual([], self.cache.find( self.cache.CredentialType.ACCOUNT, query=mine)) class TestClientApplicationForAuthorityMigration(unittest.TestCase): @classmethod def setUp(self): self.environment_in_cache = "sts.windows.net" self.authority_url_in_app = "https://login.microsoftonline.com/common" self.scopes = ["s1", "s2"] uid = "uid" utid = "utid" self.account = {"home_account_id": "{}.{}".format(uid, utid)} self.client_id = "my_app" self.access_token = "access token for testing authority aliases" self.cache = msal.SerializableTokenCache() self.cache.add({ "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "https://{}/common/oauth2/v2.0/token".format( self.environment_in_cache), "response": build_response( uid=uid, utid=utid, access_token=self.access_token, refresh_token="some refresh token"), }) # The add(...) helper populates correct home_account_id for future searching self.app = ClientApplication( self.client_id, authority=self.authority_url_in_app, token_cache=self.cache) def test_get_accounts_should_find_accounts_under_different_alias(self): accounts = self.app.get_accounts() self.assertNotEqual([], accounts) self.assertEqual(self.environment_in_cache, accounts[0].get("environment"), "We should be able to find an account under an authority alias") def test_acquire_token_silent_should_find_at_under_different_alias(self): result = self.app.acquire_token_silent(self.scopes, self.account) self.assertNotEqual(None, result) self.assertEqual(self.access_token, result.get('access_token')) def test_acquire_token_silent_should_find_rt_under_different_alias(self): self.cache._cache["AccessToken"] = {} # A hacky way to clear ATs class ExpectedBehavior(Exception): pass def helper(scopes, account, authority, *args, **kwargs): if authority.instance == self.environment_in_cache: raise ExpectedBehavior("RT of different alias being attempted") self.app._acquire_token_silent_from_cache_and_possibly_refresh_it = helper with self.assertRaises(ExpectedBehavior): self.app.acquire_token_silent(["different scope"], self.account) class TestApplicationForClientCapabilities(unittest.TestCase): def test_capabilities_and_id_token_claims_merge(self): client_capabilities = ["foo", "bar"] claims_challenge = '''{"id_token": {"auth_time": {"essential": true}}}''' merged_claims = '''{"id_token": {"auth_time": {"essential": true}}, "access_token": {"xms_cc": {"values": ["foo", "bar"]}}}''' # Comparing dictionaries as JSON object order differs based on python version self.assertEqual( json.loads(merged_claims), json.loads(_merge_claims_challenge_and_capabilities( client_capabilities, claims_challenge))) def test_capabilities_and_id_token_claims_and_access_token_claims_merge(self): client_capabilities = ["foo", "bar"] claims_challenge = '''{"id_token": {"auth_time": {"essential": true}}, "access_token": {"nbf":{"essential":true, "value":"1563308371"}}}''' merged_claims = '''{"id_token": {"auth_time": {"essential": true}}, "access_token": {"nbf": {"essential": true, "value": "1563308371"}, "xms_cc": {"values": ["foo", "bar"]}}}''' # Comparing dictionaries as JSON object order differs based on python version self.assertEqual( json.loads(merged_claims), json.loads(_merge_claims_challenge_and_capabilities( client_capabilities, claims_challenge))) def test_no_capabilities_only_claims_merge(self): claims_challenge = '''{"id_token": {"auth_time": {"essential": true}}}''' self.assertEqual( json.loads(claims_challenge), json.loads(_merge_claims_challenge_and_capabilities(None, claims_challenge))) def test_only_client_capabilities_no_claims_merge(self): client_capabilities = ["foo", "bar"] merged_claims = '''{"access_token": {"xms_cc": {"values": ["foo", "bar"]}}}''' self.assertEqual( json.loads(merged_claims), json.loads(_merge_claims_challenge_and_capabilities(client_capabilities, None))) def test_both_claims_and_capabilities_none(self): self.assertEqual(_merge_claims_challenge_and_capabilities(None, None), None) class TestApplicationForRefreshInBehaviors(unittest.TestCase): """The following test cases were based on design doc here https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=%2FRefreshAtExpirationPercentage%2Foverview.md&version=GBdev&_a=preview&anchor=scenarios """ authority_url = "https://login.microsoftonline.com/common" scopes = ["s1", "s2"] uid = "my_uid" utid = "my_utid" account = {"home_account_id": "{}.{}".format(uid, utid)} rt = "this is a rt" client_id = "my_app" @classmethod def setUpClass(cls): # Initialization at runtime, not interpret-time cls.app = ClientApplication(cls.client_id, authority=cls.authority_url) def setUp(self): self.app.token_cache = self.cache = msal.SerializableTokenCache() def populate_cache(self, access_token="at", expires_in=86400, refresh_in=43200): self.cache.add({ "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": build_response( access_token=access_token, expires_in=expires_in, refresh_in=refresh_in, uid=self.uid, utid=self.utid, refresh_token=self.rt), }) def test_fresh_token_should_be_returned_from_cache(self): # a.k.a. Return unexpired token that is not above token refresh expiration threshold access_token = "An access token prepopulated into cache" self.populate_cache(access_token=access_token, expires_in=900, refresh_in=450) result = self.app.acquire_token_silent( ['s1'], self.account, post=lambda url, *args, **kwargs: # Utilize the undocumented test feature self.fail("I/O shouldn't happen in cache hit AT scenario") ) self.assertEqual(access_token, result.get("access_token")) self.assertNotIn("refresh_in", result, "Customers need not know refresh_in") def test_aging_token_and_available_aad_should_return_new_token(self): # a.k.a. Attempt to refresh unexpired token when AAD available self.populate_cache(access_token="old AT", expires_in=3599, refresh_in=-1) new_access_token = "new AT" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|84,4|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({ "access_token": new_access_token, "refresh_in": 123, })) result = self.app.acquire_token_silent(['s1'], self.account, post=mock_post) self.assertEqual(new_access_token, result.get("access_token")) self.assertNotIn("refresh_in", result, "Customers need not know refresh_in") def test_aging_token_and_unavailable_aad_should_return_old_token(self): # a.k.a. Attempt refresh unexpired token when AAD unavailable old_at = "old AT" self.populate_cache(access_token=old_at, expires_in=3599, refresh_in=-1) def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|84,2|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=400, text=json.dumps({"error": error})) result = self.app.acquire_token_silent(['s1'], self.account, post=mock_post) self.assertEqual(old_at, result.get("access_token")) def test_expired_token_and_unavailable_aad_should_return_error(self): # a.k.a. Attempt refresh expired token when AAD unavailable self.populate_cache(access_token="expired at", expires_in=-1, refresh_in=-900) error = "something went wrong" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|84,3|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=400, text=json.dumps({"error": error})) result = self.app.acquire_token_silent_with_error( ['s1'], self.account, post=mock_post) self.assertEqual(error, result.get("error"), "Error should be returned") def test_expired_token_and_available_aad_should_return_new_token(self): # a.k.a. Attempt refresh expired token when AAD available self.populate_cache(access_token="expired at", expires_in=-1, refresh_in=-900) new_access_token = "new AT" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|84,3|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({ "access_token": new_access_token, "refresh_in": 123, })) result = self.app.acquire_token_silent(['s1'], self.account, post=mock_post) self.assertEqual(new_access_token, result.get("access_token")) self.assertNotIn("refresh_in", result, "Customers need not know refresh_in") class TestTelemetryMaintainingOfflineState(unittest.TestCase): authority_url = "https://login.microsoftonline.com/common" scopes = ["s1", "s2"] uid = "my_uid" utid = "my_utid" account = {"home_account_id": "{}.{}".format(uid, utid)} rt = "this is a rt" client_id = "my_app" def populate_cache(self, cache, access_token="at"): cache.add({ "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": build_response( access_token=access_token, uid=self.uid, utid=self.utid, refresh_token=self.rt), }) def test_maintaining_offline_state_and_sending_them(self): app = PublicClientApplication( self.client_id, authority=self.authority_url, token_cache=msal.SerializableTokenCache()) cached_access_token = "cached_at" self.populate_cache(app.token_cache, access_token=cached_access_token) result = app.acquire_token_silent( self.scopes, self.account, post=lambda url, *args, **kwargs: # Utilize the undocumented test feature self.fail("I/O shouldn't happen in cache hit AT scenario") ) self.assertEqual(cached_access_token, result.get("access_token")) error1 = "error_1" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|622,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) self.assertEqual("4|1|||", (headers or {}).get(CLIENT_LAST_TELEMETRY), "The previous cache hit should result in success counter value as 1") return MinimalResponse(status_code=400, text=json.dumps({"error": error1})) result = app.acquire_token_by_device_flow({ # It allows customizing correlation_id "device_code": "123", PublicClientApplication.DEVICE_FLOW_CORRELATION_ID: "id_1", }, post=mock_post) self.assertEqual(error1, result.get("error")) error2 = "error_2" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|622,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) self.assertEqual("4|1|622,id_1|error_1|", (headers or {}).get(CLIENT_LAST_TELEMETRY), "The previous error should result in same success counter plus latest error info") return MinimalResponse(status_code=400, text=json.dumps({"error": error2})) result = app.acquire_token_by_device_flow({ "device_code": "123", PublicClientApplication.DEVICE_FLOW_CORRELATION_ID: "id_2", }, post=mock_post) self.assertEqual(error2, result.get("error")) at = "ensures the successful path (which includes the mock) been used" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|622,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) self.assertEqual("4|1|622,id_1,622,id_2|error_1,error_2|", (headers or {}).get(CLIENT_LAST_TELEMETRY), "The previous error should result in same success counter plus latest error info") return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = app.acquire_token_by_device_flow({"device_code": "123"}, post=mock_post) self.assertEqual(at, result.get("access_token")) def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|622,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) self.assertEqual("4|0|||", (headers or {}).get(CLIENT_LAST_TELEMETRY), "The previous success should reset all offline telemetry counters") return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = app.acquire_token_by_device_flow({"device_code": "123"}, post=mock_post) self.assertEqual(at, result.get("access_token")) class TestTelemetryOnClientApplication(unittest.TestCase): @classmethod def setUpClass(cls): # Initialization at runtime, not interpret-time cls.app = ClientApplication( "client_id", authority="https://login.microsoftonline.com/common") def test_acquire_token_by_auth_code_flow(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|832,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) state = "foo" result = self.app.acquire_token_by_auth_code_flow( {"state": state, "code_verifier": "bar"}, {"state": state, "code": "012"}, post=mock_post) self.assertEqual(at, result.get("access_token")) def test_acquire_token_by_refresh_token(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|85,1|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = self.app.acquire_token_by_refresh_token("rt", ["s"], post=mock_post) self.assertEqual(at, result.get("access_token")) class TestTelemetryOnPublicClientApplication(unittest.TestCase): @classmethod def setUpClass(cls): # Initialization at runtime, not interpret-time cls.app = PublicClientApplication( "client_id", authority="https://login.microsoftonline.com/common") # For now, acquire_token_interactive() is verified by code review. def test_acquire_token_by_device_flow(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|622,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = self.app.acquire_token_by_device_flow( {"device_code": "123"}, post=mock_post) self.assertEqual(at, result.get("access_token")) def test_acquire_token_by_username_password(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|301,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = self.app.acquire_token_by_username_password( "username", "password", ["scope"], post=mock_post) self.assertEqual(at, result.get("access_token")) class TestTelemetryOnConfidentialClientApplication(unittest.TestCase): @classmethod def setUpClass(cls): # Initialization at runtime, not interpret-time cls.app = ConfidentialClientApplication( "client_id", client_credential="secret", authority="https://login.microsoftonline.com/common") def test_acquire_token_for_client(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|730,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = self.app.acquire_token_for_client(["scope"], post=mock_post) self.assertEqual(at, result.get("access_token")) def test_acquire_token_on_behalf_of(self): at = "this is an access token" def mock_post(url, headers=None, *args, **kwargs): self.assertEqual("4|523,0|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) return MinimalResponse(status_code=200, text=json.dumps({"access_token": at})) result = self.app.acquire_token_on_behalf_of("assertion", ["s"], post=mock_post) self.assertEqual(at, result.get("access_token")) class TestClientApplicationWillGroupAccounts(unittest.TestCase): def test_get_accounts(self): client_id = "my_app" scopes = ["scope_1", "scope_2"] environment = "login.microsoftonline.com" uid = "home_oid" utid = "home_tenant_guid" username = "Jane Doe" cache = msal.SerializableTokenCache() for tenant in ["contoso", "fabrikam"]: cache.add({ "client_id": client_id, "scope": scopes, "token_endpoint": "https://{}/{}/oauth2/v2.0/token".format(environment, tenant), "response": build_response( uid=uid, utid=utid, access_token="at", refresh_token="rt", id_token=build_id_token( aud=client_id, sub="oid_in_" + tenant, preferred_username=username, ), ), }) app = ClientApplication( client_id, authority="https://{}/common".format(environment), token_cache=cache) accounts = app.get_accounts() self.assertEqual(1, len(accounts), "Should return one grouped account") account = accounts[0] self.assertEqual("{}.{}".format(uid, utid), account["home_account_id"]) self.assertEqual(environment, account["environment"]) self.assertEqual(username, account["username"]) self.assertIn("authority_type", account, "Backward compatibility") self.assertIn("local_account_id", account, "Backward compatibility") self.assertIn("realm", account, "Backward compatibility") microsoft-authentication-library-for-python-1.17.0/tests/test_assertion.py000066400000000000000000000010101420154156600271450ustar00rootroot00000000000000import json from msal.oauth2cli import JwtAssertionCreator from msal.oauth2cli.oidc import decode_part from tests import unittest class AssertionTestCase(unittest.TestCase): def test_extra_claims(self): assertion = JwtAssertionCreator(key=None, algorithm="none").sign_assertion( "audience", "issuer", additional_claims={"client_ip": "1.2.3.4"}) payload = json.loads(decode_part(assertion.split(b'.')[1].decode('utf-8'))) self.assertEqual("1.2.3.4", payload.get("client_ip")) microsoft-authentication-library-for-python-1.17.0/tests/test_authcode.py000066400000000000000000000017011420154156600267410ustar00rootroot00000000000000import unittest import socket import sys from msal.oauth2cli.authcode import AuthCodeReceiver class TestAuthCodeReceiver(unittest.TestCase): def test_setup_at_a_given_port_and_teardown(self): port = 12345 # Assuming this port is available with AuthCodeReceiver(port=port) as receiver: self.assertEqual(port, receiver.get_port()) def test_setup_at_a_ephemeral_port_and_teardown(self): port = 0 with AuthCodeReceiver(port=port) as receiver: self.assertNotEqual(port, receiver.get_port()) def test_no_two_concurrent_receivers_can_listen_on_same_port(self): port = 12345 # Assuming this port is available with AuthCodeReceiver(port=port) as receiver: expected_error = OSError if sys.version_info[0] > 2 else socket.error with self.assertRaises(expected_error): with AuthCodeReceiver(port=port) as receiver2: pass microsoft-authentication-library-for-python-1.17.0/tests/test_authority.py000066400000000000000000000130101420154156600271710ustar00rootroot00000000000000import os from msal.authority import * from tests import unittest from tests.http_client import MinimalHttpClient @unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release") class TestAuthority(unittest.TestCase): def _test_given_host_and_tenant(self, host, tenant): c = MinimalHttpClient() a = Authority('https://{}/{}'.format(host, tenant), c) self.assertEqual( a.authorization_endpoint, 'https://{}/{}/oauth2/v2.0/authorize'.format(host, tenant)) self.assertEqual( a.token_endpoint, 'https://{}/{}/oauth2/v2.0/token'.format(host, tenant)) c.close() def _test_authority_builder(self, host, tenant): c = MinimalHttpClient() a = Authority(AuthorityBuilder(host, tenant), c) self.assertEqual( a.authorization_endpoint, 'https://{}/{}/oauth2/v2.0/authorize'.format(host, tenant)) self.assertEqual( a.token_endpoint, 'https://{}/{}/oauth2/v2.0/token'.format(host, tenant)) c.close() def test_wellknown_host_and_tenant(self): # Assert all well known authority hosts are using their own "common" tenant for host in WELL_KNOWN_AUTHORITY_HOSTS: self._test_given_host_and_tenant(host, "common") def test_wellknown_host_and_tenant_using_new_authority_builder(self): self._test_authority_builder(AZURE_PUBLIC, "consumers") self._test_authority_builder(AZURE_CHINA, "organizations") self._test_authority_builder(AZURE_US_GOVERNMENT, "common") @unittest.skip("As of Jan 2017, the server no longer returns V1 endpoint") def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): # This is an observation for current (2016-10) server-side behavior. # It is probably not a strict API contract. I simply mention it here. less_known = 'login.windows.net' # less.known.host/ v1_token_endpoint = 'https://{}/common/oauth2/token'.format(less_known) a = Authority( 'https://{}/common'.format(less_known), MinimalHttpClient()) self.assertEqual(a.token_endpoint, v1_token_endpoint) self.assertNotIn('v2.0', a.token_endpoint) def test_unknown_host_wont_pass_instance_discovery(self): _assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp) # Hack with _assert(ValueError, "invalid_instance"): Authority('https://example.com/tenant_doesnt_matter_in_this_case', MinimalHttpClient()) def test_invalid_host_skipping_validation_can_be_turned_off(self): try: Authority( 'https://example.com/invalid', MinimalHttpClient(), validate_authority=False) except ValueError as e: if "invalid_instance" in str(e): # Imprecise but good enough self.fail("validate_authority=False should turn off validation") except: # Could be requests...RequestException, json...JSONDecodeError, etc. pass # Those are expected for this unittest case class TestAuthorityInternalHelperCanonicalize(unittest.TestCase): def test_canonicalize_tenant_followed_by_extra_paths(self): _, i, t = canonicalize("https://example.com/tenant/subpath?foo=bar#fragment") self.assertEqual("example.com", i) self.assertEqual("tenant", t) def test_canonicalize_tenant_followed_by_extra_query(self): _, i, t = canonicalize("https://example.com/tenant?foo=bar#fragment") self.assertEqual("example.com", i) self.assertEqual("tenant", t) def test_canonicalize_tenant_followed_by_extra_fragment(self): _, i, t = canonicalize("https://example.com/tenant#fragment") self.assertEqual("example.com", i) self.assertEqual("tenant", t) def test_canonicalize_rejects_non_https(self): with self.assertRaises(ValueError): canonicalize("http://non.https.example.com/tenant") def test_canonicalize_rejects_tenantless(self): with self.assertRaises(ValueError): canonicalize("https://no.tenant.example.com") def test_canonicalize_rejects_tenantless_host_with_trailing_slash(self): with self.assertRaises(ValueError): canonicalize("https://no.tenant.example.com/") @unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release") class TestAuthorityInternalHelperUserRealmDiscovery(unittest.TestCase): def test_memorize(self): # We use a real authority so the constructor can finish tenant discovery authority = "https://login.microsoftonline.com/common" self.assertNotIn(authority, Authority._domains_without_user_realm_discovery) a = Authority(authority, MinimalHttpClient(), validate_authority=False) try: # We now pretend this authority supports no User Realm Discovery class MockResponse(object): status_code = 404 a.user_realm_discovery("john.doe@example.com", response=MockResponse()) self.assertIn( "login.microsoftonline.com", Authority._domains_without_user_realm_discovery, "user_realm_discovery() should memorize domains not supporting URD") a.user_realm_discovery("john.doe@example.com", response="This would cause exception if memorization did not work") finally: # MUST NOT let the previous test changes affect other test cases Authority._domains_without_user_realm_discovery = set([]) microsoft-authentication-library-for-python-1.17.0/tests/test_ccs.py000066400000000000000000000067531420154156600257310ustar00rootroot00000000000000import unittest try: from unittest.mock import patch, ANY except: from mock import patch, ANY from tests.http_client import MinimalResponse from tests.test_token_cache import build_response import msal class TestCcsRoutingInfoTestCase(unittest.TestCase): def test_acquire_token_by_auth_code_flow(self): app = msal.ClientApplication("client_id") state = "foo" flow = app.initiate_auth_code_flow( ["some", "scope"], login_hint="johndoe@contoso.com", state=state) with patch.object(app.http_client, "post", return_value=MinimalResponse( status_code=400, text='{"error": "mock"}')) as mocked_method: app.acquire_token_by_auth_code_flow(flow, { "state": state, "code": "bar", "client_info": # MSAL asks for client_info, so it would be available "eyJ1aWQiOiJhYTkwNTk0OS1hMmI4LTRlMGEtOGFlYS1iMzJlNTNjY2RiNDEiLCJ1dGlkIjoiNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3In0", }) self.assertEqual( "Oid:aa905949-a2b8-4e0a-8aea-b32e53ccdb41@72f988bf-86f1-41af-91ab-2d7cd011db47", mocked_method.call_args[1].get("headers", {}).get('X-AnchorMailbox'), "CSS routing info should be derived from client_info") # I've manually tested acquire_token_interactive. No need to automate it, # because it and acquire_token_by_auth_code_flow() share same code path. def test_acquire_token_silent(self): uid = "foo" utid = "bar" client_id = "my_client_id" scopes = ["some", "scope"] authority_url = "https://login.microsoftonline.com/common" token_cache = msal.TokenCache() token_cache.add({ # Pre-populate the cache "client_id": client_id, "scope": scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(authority_url), "response": build_response( access_token="an expired AT to trigger refresh", expires_in=-99, uid=uid, utid=utid, refresh_token="this is a RT"), }) # The add(...) helper populates correct home_account_id for future searching app = msal.ClientApplication( client_id, authority=authority_url, token_cache=token_cache) with patch.object(app.http_client, "post", return_value=MinimalResponse( status_code=400, text='{"error": "mock"}')) as mocked_method: account = {"home_account_id": "{}.{}".format(uid, utid)} app.acquire_token_silent(["scope"], account) self.assertEqual( "Oid:{}@{}".format( # Server accepts case-insensitive value uid, utid), # It would look like "Oid:foo@bar" mocked_method.call_args[1].get("headers", {}).get('X-AnchorMailbox'), "CSS routing info should be derived from home_account_id") def test_acquire_token_by_username_password(self): app = msal.ClientApplication("client_id") username = "johndoe@contoso.com" with patch.object(app.http_client, "post", return_value=MinimalResponse( status_code=400, text='{"error": "mock"}')) as mocked_method: app.acquire_token_by_username_password(username, "password", ["scope"]) self.assertEqual( "upn:" + username, mocked_method.call_args[1].get("headers", {}).get('X-AnchorMailbox'), "CSS routing info should be derived from client_info") microsoft-authentication-library-for-python-1.17.0/tests/test_client.py000066400000000000000000000320131420154156600264230ustar00rootroot00000000000000import os import json import logging try: # Python 2 from urlparse import urljoin except: # Python 3 from urllib.parse import urljoin import time import requests from msal.oauth2cli import Client, JwtSigner, AuthCodeReceiver from msal.oauth2cli.authcode import obtain_auth_code from tests import unittest, Oauth2TestCase from tests.http_client import MinimalHttpClient, MinimalResponse logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__file__) CONFIG_FILENAME = "config.json" def load_conf(filename): """ Example of a configuration file: { "Note": "the OpenID Discovery will be updated by following optional content", "additional_openid_configuration": { "authorization_endpoint": "https://example.com/tenant/oauth2/authorize", "token_endpoint": "https://example.com/tenant/oauth2/token", "device_authorization_endpoint": "device_authorization" }, "client_id": "289a413d-284b-4303-9c79-94380abe5d22", "client_secret": "your_secret", "scope": ["your_scope"], "resource": "Some IdP needs this", "oidp": "https://example.com/tenant/", "username": "you@example.com", "password": "I could tell you but then I would have to kill you", "placeholder": null } """ conf = {} if os.path.exists(filename): with open(filename) as f: conf = json.load(f) else: # Do not raise unittest.SkipTest(...) here, # because it would still be considered as Test Error in Python 2 logger.warning("Unable to locate JSON configuration %s" % filename) openid_configuration = {} if "oidp" in conf: try: # The following line may duplicate a '/' at the joining point, # but requests.get(...) would still work. # Besides, standard urljoin(...) is picky on insisting oidp ends with '/' discovery_uri = conf["oidp"] + '/.well-known/openid-configuration' openid_configuration.update(requests.get(discovery_uri).json()) except: logger.warning( "openid configuration uri not accesible: %s", discovery_uri) openid_configuration.update(conf.get("additional_openid_configuration", {})) if openid_configuration.get("device_authorization_endpoint"): # The following urljoin(..., ...) trick allows a "path_name" shorthand openid_configuration["device_authorization_endpoint"] = urljoin( openid_configuration.get("token_endpoint", ""), openid_configuration.get("device_authorization_endpoint", "")) conf["openid_configuration"] = openid_configuration return conf THIS_FOLDER = os.path.dirname(__file__) CONFIG = load_conf(os.path.join(THIS_FOLDER, CONFIG_FILENAME)) or {} # Since the OAuth2 specs uses snake_case, this test config also uses snake_case @unittest.skipUnless("client_id" in CONFIG, "client_id missing") @unittest.skipUnless(CONFIG.get("openid_configuration"), "openid_configuration missing") class TestClient(Oauth2TestCase): @classmethod def setUpClass(cls): http_client = MinimalHttpClient() if "client_assertion" in CONFIG: cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], http_client=http_client, client_assertion=CONFIG["client_assertion"], client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, ) elif "client_certificate" in CONFIG: private_key_path = CONFIG["client_certificate"]["private_key_path"] with open(os.path.join(THIS_FOLDER, private_key_path)) as f: private_key = f.read() # Expecting PEM format cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], http_client=http_client, client_assertion=JwtSigner( private_key, algorithm="RS256", sha1_thumbprint=CONFIG["client_certificate"]["thumbprint"] ).sign_assertion( audience=CONFIG["openid_configuration"]["token_endpoint"], issuer=CONFIG["client_id"], ), client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, ) else: cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], http_client=http_client, client_secret=CONFIG.get('client_secret')) @unittest.skipIf( "token_endpoint" not in CONFIG.get("openid_configuration", {}), "token_endpoint missing") @unittest.skipIf("client_secret" not in CONFIG, "client_secret missing") def test_client_credentials(self): result = self.client.obtain_token_for_client(CONFIG.get('scope')) self.assertIn('access_token', result) @unittest.skipIf( "token_endpoint" not in CONFIG.get("openid_configuration", {}), "token_endpoint missing") @unittest.skipIf( not ("username" in CONFIG and "password" in CONFIG), "username/password missing") def test_username_password(self): result = self.client.obtain_token_by_username_password( CONFIG["username"], CONFIG["password"], data={"resource": CONFIG.get("resource")}, # MSFT AAD V1 only scope=CONFIG.get("scope")) self.assertLoosely(result) @unittest.skipUnless( "authorization_endpoint" in CONFIG.get("openid_configuration", {}), "authorization_endpoint missing") def test_auth_code(self): port = CONFIG.get("listen_port", 44331) redirect_uri = "http://localhost:%s" % port nonce = "nonce should contain sufficient entropy" auth_request_uri = self.client.build_auth_request_uri( "code", nonce=nonce, redirect_uri=redirect_uri, scope=CONFIG.get("scope")) ac = obtain_auth_code(port, auth_uri=auth_request_uri) self.assertNotEqual(ac, None) result = self.client.obtain_token_by_authorization_code( ac, data={ "scope": CONFIG.get("scope"), "resource": CONFIG.get("resource"), }, # MSFT AAD only nonce=nonce, redirect_uri=redirect_uri) self.assertLoosely(result, lambda: self.assertIn('access_token', result)) @unittest.skipUnless( "authorization_endpoint" in CONFIG.get("openid_configuration", {}), "authorization_endpoint missing") def test_auth_code_flow(self): with AuthCodeReceiver(port=CONFIG.get("listen_port")) as receiver: flow = self.client.initiate_auth_code_flow( redirect_uri="http://localhost:%d" % receiver.get_port(), scope=CONFIG.get("scope"), login_hint=CONFIG.get("username"), # To skip the account selector ) auth_response = receiver.get_auth_response( auth_uri=flow["auth_uri"], state=flow["state"], # Optional but recommended timeout=120, welcome_template=""" authorization_endpoint = {a}, client_id = {i} Sign In or Abort """.format( a=CONFIG["openid_configuration"]["authorization_endpoint"], i=CONFIG.get("client_id")), ) self.assertIsNotNone( auth_response.get("code"), "Error: {}, Detail: {}".format( auth_response.get("error"), auth_response)) result = self.client.obtain_token_by_auth_code_flow(flow, auth_response) #TBD: data={"resource": CONFIG.get("resource")}, # MSFT AAD v1 only self.assertLoosely(result, lambda: self.assertIn('access_token', result)) def test_auth_code_flow_error_response(self): with self.assertRaisesRegexp(ValueError, "state missing"): self.client.obtain_token_by_auth_code_flow({}, {"code": "foo"}) with self.assertRaisesRegexp(ValueError, "state mismatch"): self.client.obtain_token_by_auth_code_flow({"state": "1"}, {"state": "2"}) with self.assertRaisesRegexp(ValueError, "scope"): self.client.obtain_token_by_auth_code_flow( {"state": "s", "scope": ["foo"]}, {"state": "s"}, scope=["bar"]) self.assertEqual( {"error": "foo", "error_uri": "bar"}, self.client.obtain_token_by_auth_code_flow( {"state": "s"}, {"state": "s", "error": "foo", "error_uri": "bar", "access_token": "fake"}), "We should not leak malicious input into our output") @unittest.skipUnless( "authorization_endpoint" in CONFIG.get("openid_configuration", {}), "authorization_endpoint missing") def test_obtain_token_by_browser(self): result = self.client.obtain_token_by_browser( scope=CONFIG.get("scope"), redirect_uri=CONFIG.get("redirect_uri"), welcome_template=""" authorization_endpoint = {a}, client_id = {i} Sign In or Abort """.format( a=CONFIG["openid_configuration"]["authorization_endpoint"], i=CONFIG.get("client_id")), success_template="Done. You can close this window now.", login_hint=CONFIG.get("username"), # To skip the account selector timeout=60, ) self.assertLoosely(result, lambda: self.assertIn('access_token', result)) @unittest.skipUnless( CONFIG.get("openid_configuration", {}).get("device_authorization_endpoint"), "device_authorization_endpoint is missing") def test_device_flow(self): flow = self.client.initiate_device_flow(scope=CONFIG.get("scope")) try: msg = ("Use a web browser to open the page {verification_uri} and " "enter the code {user_code} to authenticate.".format(**flow)) except KeyError: # Some IdP might not be standard compliant msg = flow["message"] # Not a standard parameter though logger.warning(msg) # Avoid print(...) b/c its output would be buffered duration = 30 logger.warning("We will wait up to %d seconds for you to sign in" % duration) flow["expires_at"] = time.time() + duration # Shorten the time for quick test result = self.client.obtain_token_by_device_flow(flow) self.assertLoosely( result, assertion=lambda: self.assertIn('access_token', result), skippable_errors=self.client.DEVICE_FLOW_RETRIABLE_ERRORS) class TestRefreshTokenCallbacks(unittest.TestCase): def _dummy(self, url, **kwargs): return MinimalResponse(status_code=200, text='{"refresh_token": "new"}') def test_rt_being_added(self): client = Client( {"token_endpoint": "http://example.com/token"}, "client_id", http_client=MinimalHttpClient(), on_obtaining_tokens=lambda event: self.assertEqual("new", event["response"].get("refresh_token")), on_updating_rt=lambda rt_item, new_rt: self.fail("This should not be called here"), ) client.obtain_token_by_authorization_code("code", post=self._dummy) def test_rt_being_updated(self): old_rt = {"refresh_token": "old"} client = Client( {"token_endpoint": "http://example.com/token"}, "client_id", http_client=MinimalHttpClient(), on_obtaining_tokens=lambda event: self.assertNotIn("refresh_token", event["response"]), on_updating_rt=lambda old, new: # TODO: ensure it being called (self.assertEqual(old_rt, old), self.assertEqual("new", new)), ) client.obtain_token_by_refresh_token( {"refresh_token": "old"}, post=self._dummy) def test_rt_being_migrated(self): old_rt = {"refresh_token": "old"} client = Client( {"token_endpoint": "http://example.com/token"}, "client_id", http_client=MinimalHttpClient(), on_obtaining_tokens=lambda event: self.assertEqual("new", event["response"].get("refresh_token")), on_updating_rt=lambda rt_item, new_rt: self.fail("This should not be called here"), ) client.obtain_token_by_refresh_token( {"refresh_token": "old"}, on_updating_rt=False, post=self._dummy) class TestSessionAccessibility(unittest.TestCase): def test_accessing_session_property_for_backward_compatibility(self): client = Client({"token_endpoint": "https://example.com"}, "client_id") client.session client.session.close() client.session = "something" microsoft-authentication-library-for-python-1.17.0/tests/test_e2e.py000066400000000000000000001352271420154156600256330ustar00rootroot00000000000000"""If the following ENV VAR are available, many end-to-end test cases would run. LAB_APP_CLIENT_SECRET=... LAB_OBO_CLIENT_SECRET=... LAB_APP_CLIENT_ID=... LAB_OBO_PUBLIC_CLIENT_ID=... LAB_OBO_CONFIDENTIAL_CLIENT_ID=... """ try: from dotenv import load_dotenv # Use this only in local dev machine load_dotenv() # take environment variables from .env. except: pass import logging import os import json import time import unittest import sys try: from unittest.mock import patch, ANY except: from mock import patch, ANY import requests import msal from tests.http_client import MinimalHttpClient, MinimalResponse from msal.oauth2cli import AuthCodeReceiver logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG if "-v" in sys.argv else logging.INFO) def _get_app_and_auth_code( client_id, client_secret=None, authority="https://login.microsoftonline.com/common", port=44331, scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph **kwargs): from msal.oauth2cli.authcode import obtain_auth_code if client_secret: app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, http_client=MinimalHttpClient()) else: app = msal.PublicClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) redirect_uri = "http://localhost:%d" % port ac = obtain_auth_code(port, auth_uri=app.get_authorization_request_url( scopes, redirect_uri=redirect_uri, **kwargs)) assert ac is not None return (app, ac, redirect_uri) @unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip e2e tests during tagged release") class E2eTestCase(unittest.TestCase): def assertLoosely(self, response, assertion=None, skippable_errors=("invalid_grant", "interaction_required")): if response.get("error") in skippable_errors: logger.debug("Response = %s", response) # Some of these errors are configuration issues, not library issues raise unittest.SkipTest(response.get("error_description")) else: if assertion is None: assertion = lambda: self.assertIn( "access_token", response, "{error}: {error_description}".format( # Do explicit response.get(...) rather than **response error=response.get("error"), error_description=response.get("error_description"))) assertion() def assertCacheWorksForUser( self, result_from_wire, scope, username=None, data=None): logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4), json.dumps(result_from_wire.get("id_token_claims"), indent=4), ) # You can filter by predefined username, or let end user to choose one accounts = self.app.get_accounts(username=username) self.assertNotEqual(0, len(accounts)) account = accounts[0] if ("scope" not in result_from_wire # This is the usual case or # Authority server could reject some scopes set(scope) <= set(result_from_wire["scope"].split(" ")) ): # Going to test acquire_token_silent(...) to locate an AT from cache result_from_cache = self.app.acquire_token_silent( scope, account=account, data=data or {}) self.assertIsNotNone(result_from_cache) self.assertIsNone( result_from_cache.get("refresh_token"), "A cache hit returns no RT") self.assertEqual( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") if "refresh_token" in result_from_wire: # Going to test acquire_token_silent(...) to obtain an AT by a RT from cache self.app.token_cache._cache["AccessToken"] = {} # A hacky way to clear ATs result_from_cache = self.app.acquire_token_silent( scope, account=account, data=data or {}) if "refresh_token" not in result_from_wire: self.assertEqual( result_from_cache["access_token"], result_from_wire["access_token"], "The previously cached AT should be returned") self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") self.assertIsNotNone( # We used to assert it this way: # result_from_wire['access_token'] != result_from_cache['access_token'] # but ROPC in B2C tends to return the same AT we obtained seconds ago. # Now looking back, "refresh_token grant would return a brand new AT" # was just an empirical observation but never a committment in specs, # so we adjust our way to assert here. (result_from_cache or {}).get("access_token"), "We should get an AT from acquire_token_silent(...) call") def assertCacheWorksForApp(self, result_from_wire, scope): logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4), json.dumps(result_from_wire.get("id_token_claims"), indent=4), ) # Going to test acquire_token_silent(...) to locate an AT from cache result_from_cache = self.app.acquire_token_silent(scope, account=None) self.assertIsNotNone(result_from_cache) self.assertEqual( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") def _test_username_password(self, authority=None, client_id=None, username=None, password=None, scope=None, client_secret=None, # Since MSAL 1.11, confidential client has ROPC too azure_region=None, http_client=None, **ignored): assert authority and client_id and username and password and scope self.app = msal.ClientApplication( client_id, authority=authority, http_client=http_client or MinimalHttpClient(), azure_region=azure_region, # Regional endpoint does not support ROPC. # Here we just use it to test a regional app won't break ROPC. client_credential=client_secret) result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) self.assertCacheWorksForUser( result, scope, username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C ) def _test_device_flow( self, client_id=None, authority=None, scope=None, **ignored): assert client_id and authority and scope self.app = msal.PublicClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) flow = self.app.initiate_device_flow(scopes=scope) assert "user_code" in flow, "DF does not seem to be provisioned: %s".format( json.dumps(flow, indent=4)) logger.info(flow["message"]) duration = 60 logger.info("We will wait up to %d seconds for you to sign in" % duration) flow["expires_at"] = min( # Shorten the time for quick test flow["expires_at"], time.time() + duration) result = self.app.acquire_token_by_device_flow(flow) self.assertLoosely( # It will skip this test if there is no user interaction result, assertion=lambda: self.assertIn('access_token', result), skippable_errors=self.app.client.DEVICE_FLOW_RETRIABLE_ERRORS) if "access_token" not in result: self.skipTest("End user did not complete Device Flow in time") self.assertCacheWorksForUser(result, scope, username=None) result["access_token"] = result["refresh_token"] = "************" logger.info( "%s obtained tokens: %s", self.id(), json.dumps(result, indent=4)) def _test_acquire_token_interactive( self, client_id=None, authority=None, scope=None, port=None, username_uri="", # But you would want to provide one data=None, # Needed by ssh-cert feature **ignored): assert client_id and authority and scope self.app = msal.PublicClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) result = self.app.acquire_token_interactive( scope, timeout=120, port=port, welcome_template= # This is an undocumented feature for testing """

{id}

  1. Get a username from the upn shown at here
  2. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ (replace the lab name with the labName from the link above).
  3. Sign In or Abort
""".format(id=self.id(), username_uri=username_uri), data=data or {}, ) self.assertIn( "access_token", result, "{error}: {error_description}".format( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None, data=data or {}) return result # For further testing class SshCertTestCase(E2eTestCase): _JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}""" _JWK2 = """{"kty":"RSA", "n":"72u07mew8rw-ssw3tUs9clKstGO2lvD7ZNxJU7OPNKz5PGYx3gjkhUmtNah4I4FP0DuF1ogb_qSS5eD86w10Wb1ftjWcoY8zjNO9V3ph-Q2tMQWdDW5kLdeU3-EDzc0HQeou9E0udqmfQoPbuXFQcOkdcbh3eeYejs8sWn3TQprXRwGh_TRYi-CAurXXLxQ8rp-pltUVRIr1B63fXmXhMeCAGwCPEFX9FRRs-YHUszUJl9F9-E0nmdOitiAkKfCC9LhwB9_xKtjmHUM9VaEC9jWOcdvXZutwEoW2XPMOg0Ky-s197F9rfpgHle2gBrXsbvVMvS0D-wXg6vsq6BAHzQ", "e":"AQAB"}""" DATA1 = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1} DATA2 = {"token_type": "ssh-cert", "key_id": "key2", "req_cnf": _JWK2} _SCOPE_USER = ["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] _SCOPE_SP = ["https://pas.windows.net/CheckMyAccess/Linux/.default"] SCOPE = _SCOPE_SP # Historically there was a separation, at 2021 it is unified def test_ssh_cert_for_service_principal(self): # Any SP can obtain an ssh-cert. Here we use the lab app. result = get_lab_app().acquire_token_for_client(self.SCOPE, data=self.DATA1) self.assertIsNotNone(result.get("access_token"), "Encountered {}: {}".format( result.get("error"), result.get("error_description"))) self.assertEqual("ssh-cert", result["token_type"]) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_ssh_cert_for_user(self): result = self._test_acquire_token_interactive( client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI is one # of the only 2 clients that are PreAuthz to use ssh cert feature authority="https://login.microsoftonline.com/common", scope=self.SCOPE, data=self.DATA1, username_uri="https://msidlab.com/api/user?usertype=cloud", ) # It already tests reading AT from cache, and using RT to refresh # acquire_token_silent() would work because we pass in the same key self.assertIsNotNone(result.get("access_token"), "Encountered {}: {}".format( result.get("error"), result.get("error_description"))) self.assertEqual("ssh-cert", result["token_type"]) logger.debug("%s.cache = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4)) # refresh_token grant can fetch an ssh-cert bound to a different key account = self.app.get_accounts()[0] refreshed_ssh_cert = self.app.acquire_token_silent( self.SCOPE, account=account, data=self.DATA2) self.assertIsNotNone(refreshed_ssh_cert) self.assertEqual(refreshed_ssh_cert["token_type"], "ssh-cert") self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) THIS_FOLDER = os.path.dirname(__file__) CONFIG = os.path.join(THIS_FOLDER, "config.json") @unittest.skipUnless(os.path.exists(CONFIG), "Optional %s not found" % CONFIG) class FileBasedTestCase(E2eTestCase): # This covers scenarios that are not currently available for test automation. # So they mean to be run on maintainer's machine for semi-automated tests. @classmethod def setUpClass(cls): with open(CONFIG) as f: cls.config = json.load(f) def skipUnlessWithConfig(self, fields): for field in fields: if field not in self.config: self.skipTest('Skipping due to lack of configuration "%s"' % field) def test_username_password(self): self.skipUnlessWithConfig(["client_id", "username", "password", "scope"]) self._test_username_password(**self.config) def _get_app_and_auth_code(self, scopes=None, **kwargs): return _get_app_and_auth_code( self.config["client_id"], client_secret=self.config.get("client_secret"), authority=self.config.get("authority"), port=self.config.get("listen_port", 44331), scopes=scopes or self.config["scope"], **kwargs) def _test_auth_code(self, auth_kwargs, token_kwargs): self.skipUnlessWithConfig(["client_id", "scope"]) (self.app, ac, redirect_uri) = self._get_app_and_auth_code(**auth_kwargs) result = self.app.acquire_token_by_authorization_code( ac, self.config["scope"], redirect_uri=redirect_uri, **token_kwargs) logger.debug("%s.cache = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4)) self.assertIn( "access_token", result, "{error}: {error_description}".format( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, self.config["scope"], username=None) def test_auth_code(self): self._test_auth_code({}, {}) def test_auth_code_with_matching_nonce(self): self._test_auth_code({"nonce": "foo"}, {"nonce": "foo"}) def test_auth_code_with_mismatching_nonce(self): self.skipUnlessWithConfig(["client_id", "scope"]) (self.app, ac, redirect_uri) = self._get_app_and_auth_code(nonce="foo") with self.assertRaises(ValueError): self.app.acquire_token_by_authorization_code( ac, self.config["scope"], redirect_uri=redirect_uri, nonce="bar") def test_client_secret(self): self.skipUnlessWithConfig(["client_id", "client_secret"]) self.app = msal.ConfidentialClientApplication( self.config["client_id"], client_credential=self.config.get("client_secret"), authority=self.config.get("authority"), http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) def test_client_certificate(self): self.skipUnlessWithConfig(["client_id", "client_certificate"]) client_cert = self.config["client_certificate"] assert "private_key_path" in client_cert and "thumbprint" in client_cert with open(os.path.join(THIS_FOLDER, client_cert['private_key_path'])) as f: private_key = f.read() # Should be in PEM format self.app = msal.ConfidentialClientApplication( self.config['client_id'], {"private_key": private_key, "thumbprint": client_cert["thumbprint"]}, http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) def test_subject_name_issuer_authentication(self): self.skipUnlessWithConfig(["client_id", "client_certificate"]) client_cert = self.config["client_certificate"] assert "private_key_path" in client_cert and "thumbprint" in client_cert if not "public_certificate" in client_cert: self.skipTest("Skipping SNI test due to lack of public_certificate") with open(os.path.join(THIS_FOLDER, client_cert['private_key_path'])) as f: private_key = f.read() # Should be in PEM format with open(os.path.join(THIS_FOLDER, client_cert['public_certificate'])) as f: public_certificate = f.read() self.app = msal.ConfidentialClientApplication( self.config['client_id'], authority=self.config["authority"], client_credential={ "private_key": private_key, "thumbprint": self.config["thumbprint"], "public_certificate": public_certificate, }, http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) def test_client_assertion(self): self.skipUnlessWithConfig(["client_id", "client_assertion"]) self.app = msal.ConfidentialClientApplication( self.config['client_id'], authority=self.config["authority"], client_credential={"client_assertion": self.config["client_assertion"]}, http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) @unittest.skipUnless(os.path.exists(CONFIG), "Optional %s not found" % CONFIG) class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once @classmethod def setUpClass(cls): with open(CONFIG) as f: cls.config = json.load(f) def test_device_flow(self): self._test_device_flow(**self.config) def get_lab_app( env_client_id="LAB_APP_CLIENT_ID", env_client_secret="LAB_APP_CLIENT_SECRET", authority="https://login.microsoftonline.com/" "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID timeout=None, **kwargs): """Returns the lab app as an MSAL confidential client. Get it from environment variables if defined, otherwise fall back to use MSI. """ logger.info( "Reading ENV variables %s and %s for lab app defined at " "https://docs.msidlab.com/accounts/confidentialclient.html", env_client_id, env_client_secret) if os.getenv(env_client_id) and os.getenv(env_client_secret): # A shortcut mainly for running tests on developer's local development machine # or it could be setup on Travis CI # https://docs.travis-ci.com/user/environment-variables/#defining-variables-in-repository-settings # Data came from here # https://docs.msidlab.com/accounts/confidentialclient.html client_id = os.getenv(env_client_id) client_secret = os.getenv(env_client_secret) else: logger.info("ENV variables %s and/or %s are not defined. Fall back to MSI.", env_client_id, env_client_secret) # See also https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API's.aspx raise unittest.SkipTest("MSI-based mechanism has not been implemented yet") return msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, http_client=MinimalHttpClient(timeout=timeout), **kwargs) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow logger.info("Creating session") result = lab_app.acquire_token_for_client(scopes) assert result.get("access_token"), \ "Unable to obtain token for lab. Encountered {}: {}".format( result.get("error"), result.get("error_description")) session = requests.Session() session.headers.update({"Authorization": "Bearer %s" % result["access_token"]}) session.hooks["response"].append(lambda r, *args, **kwargs: r.raise_for_status()) return session class LabBasedTestCase(E2eTestCase): _secrets = {} adfs2019_scopes = ["placeholder"] # Need this to satisfy MSAL API surface. # Internally, MSAL will also append more scopes like "openid" etc.. # ADFS 2019 will issue tokens for valid scope only, by default "openid". # https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-faq#what-permitted-scopes-are-supported-by-ad-fs @classmethod def setUpClass(cls): # https://docs.msidlab.com/accounts/apiaccess.html#code-snippet cls.session = get_session(get_lab_app(), ["https://msidlab.com/.default"]) @classmethod def tearDownClass(cls): cls.session.close() @classmethod def get_lab_app_object(cls, **query): # https://msidlab.com/swagger/index.html url = "https://msidlab.com/api/app" resp = cls.session.get(url, params=query) result = resp.json()[0] result["scopes"] = [ # Raw data has extra space, such as "s1, s2" s.strip() for s in result["defaultScopes"].split(',')] return result @classmethod def get_lab_user_secret(cls, lab_name="msidlab4"): lab_name = lab_name.lower() if lab_name not in cls._secrets: logger.info("Querying lab user password for %s", lab_name) url = "https://msidlab.com/api/LabUserSecret?secret=%s" % lab_name resp = cls.session.get(url) cls._secrets[lab_name] = resp.json()["value"] return cls._secrets[lab_name] @classmethod def get_lab_user(cls, **query): # https://docs.msidlab.com/labapi/userapi.html resp = cls.session.get("https://msidlab.com/api/user", params=query) result = resp.json()[0] _env = query.get("azureenvironment", "").lower() authority_base = { "azureusgovernment": "https://login.microsoftonline.us/" }.get(_env, "https://login.microsoftonline.com/") scope = { "azureusgovernment": ["https://graph.microsoft.us/.default"], }.get(_env, ["https://graph.microsoft.com/.default"]) return { # Mapping lab API response to our simplified configuration format "authority": authority_base + result["tenantID"], "client_id": result["appId"], "username": result["upn"], "lab_name": result["labName"], "scope": scope, } def _test_acquire_token_by_auth_code( self, client_id=None, authority=None, port=None, scope=None, **ignored): assert client_id and authority and port and scope (self.app, ac, redirect_uri) = _get_app_and_auth_code( client_id, authority=authority, port=port, scopes=scope) result = self.app.acquire_token_by_authorization_code( ac, scope, redirect_uri=redirect_uri) logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4), json.dumps(result.get("id_token_claims"), indent=4), ) self.assertIn( "access_token", result, "{error}: {error_description}".format( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) def _test_acquire_token_by_auth_code_flow( self, client_id=None, authority=None, port=None, scope=None, username_uri="", # But you would want to provide one **ignored): assert client_id and authority and scope self.app = msal.ClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) with AuthCodeReceiver(port=port) as receiver: flow = self.app.initiate_auth_code_flow( scope, redirect_uri="http://localhost:%d" % receiver.get_port(), ) auth_response = receiver.get_auth_response( auth_uri=flow["auth_uri"], state=flow["state"], timeout=60, welcome_template="""

{id}

  1. Get a username from the upn shown at here
  2. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ (replace the lab name with the labName from the link above).
  3. Sign In or Abort
""".format(id=self.id(), username_uri=username_uri), ) if auth_response is None: self.skipTest("Timed out. Did not have test settings in hand? Prepare and retry.") self.assertIsNotNone( auth_response.get("code"), "Error: {}, Detail: {}".format( auth_response.get("error"), auth_response)) result = self.app.acquire_token_by_auth_code_flow(flow, auth_response) logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4), json.dumps(result.get("id_token_claims"), indent=4), ) self.assertIn( "access_token", result, "{error}: {error_description}".format( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) def _test_acquire_token_obo(self, config_pca, config_cca, azure_region=None, # Regional endpoint does not really support OBO. # Here we just test regional apps won't adversely break OBO http_client=None, ): # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( config_pca["client_id"], authority=config_pca["authority"], azure_region=azure_region, http_client=http_client or MinimalHttpClient()) pca_result = pca.acquire_token_by_username_password( config_pca["username"], config_pca["password"], scopes=config_pca["scope"], ) self.assertIsNotNone( pca_result.get("access_token"), "PCA failed to get AT because %s" % json.dumps(pca_result, indent=2)) # 2. Our mid-tier service uses OBO to obtain a token for downstream service cca = msal.ConfidentialClientApplication( config_cca["client_id"], client_credential=config_cca["client_secret"], authority=config_cca["authority"], azure_region=azure_region, http_client=http_client or MinimalHttpClient(), # token_cache= ..., # Default token cache is all-tokens-store-in-memory. # That's fine if OBO app uses short-lived msal instance per session. # Otherwise, the OBO app need to implement a one-cache-per-user setup. ) cca_result = cca.acquire_token_on_behalf_of( pca_result['access_token'], config_cca["scope"]) self.assertNotEqual(None, cca_result.get("access_token"), str(cca_result)) # 3. Now the OBO app can simply store downstream token(s) in same session. # Alternatively, if you want to persist the downstream AT, and possibly # the RT (if any) for prolonged access even after your own AT expires, # now it is the time to persist current cache state for current user. # Assuming you already did that (which is not shown in this test case), # the following part shows one of the ways to obtain an AT from cache. username = cca_result.get("id_token_claims", {}).get("preferred_username") if username: # It means CCA have requested an IDT w/ "profile" scope self.assertEqual(config_cca["username"], username) accounts = cca.get_accounts(username=username) assert len(accounts) == 1, "App is expected to partition token cache per user" account = accounts[0] result = cca.acquire_token_silent(config_cca["scope"], account) self.assertEqual(cca_result["access_token"], result["access_token"]) def _test_acquire_token_by_client_secret( self, client_id=None, client_secret=None, authority=None, scope=None, **ignored): assert client_id and client_secret and authority and scope app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, http_client=MinimalHttpClient()) result = app.acquire_token_for_client(scope) self.assertIsNotNone(result.get("access_token"), "Got %s instead" % result) class WorldWideTestCase(LabBasedTestCase): def test_aad_managed_user(self): # Pure cloud config = self.get_lab_user(usertype="cloud") config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) def test_adfs4_fed_user(self): config = self.get_lab_user(usertype="federated", federationProvider="ADFSv4") config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) def test_adfs3_fed_user(self): config = self.get_lab_user(usertype="federated", federationProvider="ADFSv3") config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) def test_adfs2_fed_user(self): config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2") config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) def test_adfs2019_fed_user(self): try: config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2019") config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) except requests.exceptions.HTTPError: if os.getenv("TRAVIS"): self.skipTest("MEX endpoint in our test environment tends to fail") raise @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_cloud_acquire_token_interactive(self): config = self.get_lab_user(usertype="cloud") self._test_acquire_token_interactive( username_uri="https://msidlab.com/api/user?usertype=cloud", **config) def test_ropc_adfs2019_onprem(self): # Configuration is derived from https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.7.0/tests/Microsoft.Identity.Test.Common/TestConstants.cs#L250-L259 config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] config["scope"] = self.adfs2019_scopes config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_adfs2019_onprem_acquire_token_by_auth_code(self): """When prompted, you can manually login using this account: # https://msidlab.com/api/user?usertype=onprem&federationprovider=ADFSv2019 username = "..." # The upn from the link above password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ """ config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] config["scope"] = self.adfs2019_scopes config["port"] = 8080 self._test_acquire_token_by_auth_code(**config) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self): config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] config["scope"] = self.adfs2019_scopes config["port"] = 8080 self._test_acquire_token_by_auth_code_flow( username_uri="https://msidlab.com/api/user?usertype=onprem&federationprovider=ADFSv2019", **config) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_adfs2019_onprem_acquire_token_interactive(self): config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] config["scope"] = self.adfs2019_scopes config["port"] = 8080 self._test_acquire_token_interactive( username_uri="https://msidlab.com/api/user?usertype=onprem&federationprovider=ADFSv2019", **config) @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") @unittest.skipUnless( os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") @unittest.skipUnless( os.getenv("LAB_OBO_PUBLIC_CLIENT_ID"), "Need LAB_OBO_PUBLIC_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") def test_acquire_token_obo(self): config = self.get_lab_user(usertype="cloud") config_cca = {} config_cca.update(config) config_cca["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") config_cca["scope"] = ["https://graph.microsoft.com/.default"] config_cca["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") config_pca = {} config_pca.update(config) config_pca["client_id"] = os.getenv("LAB_OBO_PUBLIC_CLIENT_ID") config_pca["password"] = self.get_lab_user_secret(config_pca["lab_name"]) config_pca["scope"] = ["api://%s/read" % config_cca["client_id"]] self._test_acquire_token_obo(config_pca, config_cca) def test_acquire_token_by_client_secret(self): # Vastly different than ArlingtonCloudTestCase.test_acquire_token_by_client_secret() _app = self.get_lab_app_object( publicClient="no", signinAudience="AzureAdMyOrg") self._test_acquire_token_by_client_secret( client_id=_app["appId"], client_secret=self.get_lab_user_secret( _app["clientSecret"].split("/")[-1]), authority="{}{}.onmicrosoft.com".format( _app["authority"], _app["labName"].lower().rstrip(".com")), scope=["https://graph.microsoft.com/.default"], ) @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") @unittest.skipUnless( os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") def test_confidential_client_acquire_token_by_username_password(self): # This approach won't work: # config = self.get_lab_user(usertype="cloud", publicClient="no") # so we repurpose the obo confidential app to test ROPC config = self.get_lab_user(usertype="cloud") config["password"] = self.get_lab_user_secret(config["lab_name"]) # Swap in the OBO confidential app config["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") config["scope"] = ["https://graph.microsoft.com/.default"] config["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") self._test_username_password(**config) def _build_b2c_authority(self, policy): base = "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com" return base + "/" + policy # We do not support base + "?p=" + policy @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_b2c_acquire_token_by_auth_code(self): """ When prompted, you can manually login using this account: username="b2clocal@msidlabb2c.onmicrosoft.com" # This won't work https://msidlab.com/api/user?usertype=b2c password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabb2c """ config = self.get_lab_app_object(azureenvironment="azureb2ccloud") self._test_acquire_token_by_auth_code( authority=self._build_b2c_authority("B2C_1_SignInPolicy"), client_id=config["appId"], port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] scope=config["scopes"], ) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_b2c_acquire_token_by_auth_code_flow(self): config = self.get_lab_app_object(azureenvironment="azureb2ccloud") self._test_acquire_token_by_auth_code_flow( authority=self._build_b2c_authority("B2C_1_SignInPolicy"), client_id=config["appId"], port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] scope=config["scopes"], username_uri="https://msidlab.com/api/user?usertype=b2c&b2cprovider=local", ) def test_b2c_acquire_token_by_ropc(self): config = self.get_lab_app_object(azureenvironment="azureb2ccloud") self._test_username_password( authority=self._build_b2c_authority("B2C_1_ROPC_Auth"), client_id=config["appId"], username="b2clocal@msidlabb2c.onmicrosoft.com", password=self.get_lab_user_secret("msidlabb2c"), scope=config["scopes"], ) class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" timeout = 2 # Short timeout makes this test case responsive on non-VM def _test_acquire_token_for_client(self, configured_region, expected_region): """This is the only grant supported by regional endpoint, for now""" self.app = get_lab_app( # Regional endpoint only supports confidential client ## FWIW, the MSAL<1.12 versions could use this to achieve similar result #authority="https://westus.login.microsoft.com/microsoft.onmicrosoft.com", #validate_authority=False, authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", azure_region=configured_region, timeout=2, # Short timeout makes this test case responsive on non-VM ) scopes = ["https://graph.microsoft.com/.default"] with patch.object( # Test the request hit the regional endpoint self.app.http_client, "post", return_value=MinimalResponse( status_code=400, text='{"error": "mock"}')) as mocked_method: self.app.acquire_token_for_client(scopes) expected_host = '{}.r.login.microsoftonline.com'.format( expected_region) if expected_region else 'login.microsoftonline.com' mocked_method.assert_called_with( 'https://{}/{}/oauth2/v2.0/token'.format( expected_host, self.app.authority.tenant), params=ANY, data=ANY, headers=ANY) result = self.app.acquire_token_for_client( scopes, params={"AllowEstsRNonMsi": "true"}, # For testing regional endpoint. It will be removed once MSAL Python 1.12+ has been onboard to ESTS-R ) self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scopes) def test_acquire_token_for_client_should_hit_global_endpoint_by_default(self): self._test_acquire_token_for_client(None, None) def test_acquire_token_for_client_should_ignore_env_var_by_default(self): os.environ["REGION_NAME"] = "eastus" self._test_acquire_token_for_client(None, None) del os.environ["REGION_NAME"] def test_acquire_token_for_client_should_use_a_specified_region(self): self._test_acquire_token_for_client("westus", "westus") def test_acquire_token_for_client_should_use_an_env_var_with_short_region_name(self): os.environ["REGION_NAME"] = "eastus" self._test_acquire_token_for_client( msal.ConfidentialClientApplication.ATTEMPT_REGION_DISCOVERY, "eastus") del os.environ["REGION_NAME"] def test_acquire_token_for_client_should_use_an_env_var_with_long_region_name(self): os.environ["REGION_NAME"] = "East Us 2" self._test_acquire_token_for_client( msal.ConfidentialClientApplication.ATTEMPT_REGION_DISCOVERY, "eastus2") del os.environ["REGION_NAME"] @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") @unittest.skipUnless( os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") @unittest.skipUnless( os.getenv("LAB_OBO_PUBLIC_CLIENT_ID"), "Need LAB_OBO_PUBLIC_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") def test_cca_obo_should_bypass_regional_endpoint_therefore_still_work(self): """We test OBO because it is implemented in sub class ConfidentialClientApplication""" config = self.get_lab_user(usertype="cloud") config_cca = {} config_cca.update(config) config_cca["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") config_cca["scope"] = ["https://graph.microsoft.com/.default"] config_cca["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") config_pca = {} config_pca.update(config) config_pca["client_id"] = os.getenv("LAB_OBO_PUBLIC_CLIENT_ID") config_pca["password"] = self.get_lab_user_secret(config_pca["lab_name"]) config_pca["scope"] = ["api://%s/read" % config_cca["client_id"]] self._test_acquire_token_obo( config_pca, config_cca, azure_region=self.region, http_client=MinimalHttpClient(timeout=self.timeout), ) @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") @unittest.skipUnless( os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") def test_cca_ropc_should_bypass_regional_endpoint_therefore_still_work(self): """We test ROPC because it is implemented in base class ClientApplication""" config = self.get_lab_user(usertype="cloud") config["password"] = self.get_lab_user_secret(config["lab_name"]) # We repurpose the obo confidential app to test ROPC # Swap in the OBO confidential app config["client_id"] = os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID") config["scope"] = ["https://graph.microsoft.com/.default"] config["client_secret"] = os.getenv("LAB_OBO_CLIENT_SECRET") self._test_username_password( azure_region=self.region, http_client=MinimalHttpClient(timeout=self.timeout), **config) class ArlingtonCloudTestCase(LabBasedTestCase): environment = "azureusgovernment" def test_acquire_token_by_ropc(self): config = self.get_lab_user(azureenvironment=self.environment) config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) def test_acquire_token_by_client_secret(self): config = self.get_lab_user(usertype="cloud", azureenvironment=self.environment, publicClient="no") config["client_secret"] = self.get_lab_user_secret("ARLMSIDLAB1-IDLASBS-App-CC-Secret") self._test_acquire_token_by_client_secret(**config) def test_acquire_token_obo(self): config_cca = self.get_lab_user( usertype="cloud", azureenvironment=self.environment, publicClient="no") config_cca["scope"] = ["https://graph.microsoft.us/.default"] config_cca["client_secret"] = self.get_lab_user_secret("ARLMSIDLAB1-IDLASBS-App-CC-Secret") config_pca = self.get_lab_user(usertype="cloud", azureenvironment=self.environment, publicClient="yes") obo_app_object = self.get_lab_app_object( usertype="cloud", azureenvironment=self.environment, publicClient="no") config_pca["password"] = self.get_lab_user_secret(config_pca["lab_name"]) config_pca["scope"] = ["{app_uri}/files.read".format(app_uri=obo_app_object.get("identifierUris"))] self._test_acquire_token_obo(config_pca, config_cca) def test_acquire_token_device_flow(self): config = self.get_lab_user(usertype="cloud", azureenvironment=self.environment, publicClient="yes") config["scope"] = ["user.read"] self._test_device_flow(**config) def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): config = self.get_lab_user( usertype="cloud", azureenvironment=self.environment, publicClient="no") app = msal.ConfidentialClientApplication( config['client_id'], authority=config['authority'], http_client=MinimalHttpClient()) result = app.acquire_token_silent(scopes=config['scope'], account=None) self.assertEqual(result, None) # Note: An alias in this region is no longer accepting HTTPS traffic. # If this test case passes without exception, # it means MSAL Python is not affected by that. if __name__ == "__main__": unittest.main() microsoft-authentication-library-for-python-1.17.0/tests/test_individual_cache.py000066400000000000000000000067541420154156600304350ustar00rootroot00000000000000from time import sleep from random import random import unittest from msal.individual_cache import _ExpiringMapping as ExpiringMapping from msal.individual_cache import _IndividualCache as IndividualCache class TestExpiringMapping(unittest.TestCase): def setUp(self): self.mapping = {} self.m = ExpiringMapping(mapping=self.mapping, capacity=2, expires_in=1) def test_should_disallow_accessing_reserved_keyword(self): with self.assertRaises(ValueError): self.m.get(ExpiringMapping._INDEX) def test_setitem(self): self.assertEqual(0, len(self.m)) self.m["thing one"] = "one" self.assertIn(ExpiringMapping._INDEX, self.mapping, "Index created") self.assertEqual(1, len(self.m), "It contains one item (excluding index)") self.assertEqual("one", self.m["thing one"]) self.assertEqual(["thing one"], list(self.m)) def test_set(self): self.assertEqual(0, len(self.m)) self.m.set("thing two", "two", 2) self.assertIn(ExpiringMapping._INDEX, self.mapping, "Index created") self.assertEqual(1, len(self.m), "It contains one item (excluding index)") self.assertEqual("two", self.m["thing two"]) self.assertEqual(["thing two"], list(self.m)) def test_len_should_purge(self): self.m["thing one"] = "one" sleep(1) self.assertEqual(0, len(self.m)) def test_iter_should_purge(self): self.m["thing one"] = "one" sleep(1) self.assertEqual([], list(self.m)) def test_get_should_purge(self): self.m["thing one"] = "one" sleep(1) with self.assertRaises(KeyError): self.m["thing one"] def test_various_expiring_time(self): self.assertEqual(0, len(self.m)) self.m["thing one"] = "one" self.m.set("thing two", "two", 2) self.assertEqual(2, len(self.m), "It contains 2 items") sleep(1) self.assertEqual(["thing two"], list(self.m), "One expires, another remains") def test_old_item_can_be_updated_with_new_expiry_time(self): self.assertEqual(0, len(self.m)) self.m["thing"] = "one" self.m.set("thing", "two", 2) self.assertEqual(1, len(self.m), "It contains 1 item") self.assertEqual("two", self.m["thing"], 'Already been updated to "two"') sleep(1) self.assertEqual("two", self.m["thing"], "Not yet expires") sleep(1) self.assertEqual(0, len(self.m)) def test_oversized_input_should_purge_most_aging_item(self): self.assertEqual(0, len(self.m)) self.m["thing one"] = "one" self.m.set("thing two", "two", 2) self.assertEqual(2, len(self.m), "It contains 2 items") self.m["thing three"] = "three" self.assertEqual(2, len(self.m), "It contains 2 items") self.assertNotIn("thing one", self.m) class TestIndividualCache(unittest.TestCase): mapping = {} @IndividualCache(mapping=mapping) def foo(self, a, b, c=None, d=None): return random() # So that we'd know whether a new response is received def test_memorize_a_function_call(self): self.assertNotEqual(self.foo(1, 1), self.foo(2, 2)) self.assertEqual( self.foo(1, 2, c=3, d=4), self.foo(1, 2, c=3, d=4), "Subsequent run should obtain same result from cache") # Note: In Python 3.7+, dict is ordered, so the following is typically True: #self.assertNotEqual(self.foo(a=1, b=2), self.foo(b=2, a=1)) microsoft-authentication-library-for-python-1.17.0/tests/test_mex.py000066400000000000000000000016471420154156600257470ustar00rootroot00000000000000import os from tests import unittest from msal.mex import * THIS_FOLDER = os.path.dirname(__file__) class TestMex(unittest.TestCase): def _test_parser(self, sample, expected_endpoint): with open(os.path.join(THIS_FOLDER, sample)) as sample_file: endpoint = Mex(mex_document=sample_file.read() ).get_wstrust_username_password_endpoint()["address"] self.assertEqual(expected_endpoint, endpoint) def test_happy_path_1(self): self._test_parser("microsoft.mex.xml", 'https://corp.sts.microsoft.com/adfs/services/trust/13/usernamemixed') def test_happy_path_2(self): self._test_parser('arupela.mex.xml', 'https://fs.arupela.com/adfs/services/trust/13/usernamemixed') def test_happy_path_3(self): self._test_parser('archan.us.mex.xml', 'https://arvmserver2012.archan.us/adfs/services/trust/13/usernamemixed') microsoft-authentication-library-for-python-1.17.0/tests/test_throttled_http_client.py000066400000000000000000000207321420154156600315600ustar00rootroot00000000000000# Test cases for https://identitydivision.visualstudio.com/devex/_git/AuthLibrariesApiReview?version=GBdev&path=%2FService%20protection%2FIntial%20set%20of%20protection%20measures.md&_a=preview&anchor=common-test-cases from time import sleep from random import random import logging from msal.throttled_http_client import ThrottledHttpClient from tests import unittest from tests.http_client import MinimalResponse logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) class DummyHttpResponse(MinimalResponse): def __init__(self, headers=None, **kwargs): self.headers = {} if headers is None else headers super(DummyHttpResponse, self).__init__(**kwargs) class DummyHttpClient(object): def __init__(self, status_code=None, response_headers=None): self._status_code = status_code self._response_headers = response_headers def _build_dummy_response(self): return DummyHttpResponse( status_code=self._status_code, headers=self._response_headers, text=random(), # So that we'd know whether a new response is received ) def post(self, url, params=None, data=None, headers=None, **kwargs): return self._build_dummy_response() def get(self, url, params=None, headers=None, **kwargs): return self._build_dummy_response() def close(self): raise CloseMethodCalled("Not used by MSAL, but our customers may use it") class CloseMethodCalled(Exception): pass class TestHttpDecoration(unittest.TestCase): def test_throttled_http_client_should_not_alter_original_http_client(self): http_cache = {} original_http_client = DummyHttpClient() original_get = original_http_client.get original_post = original_http_client.post throttled_http_client = ThrottledHttpClient(original_http_client, http_cache) goal = """The implementation should wrap original http_client and keep it intact, instead of monkey-patching it""" self.assertNotEqual(throttled_http_client, original_http_client, goal) self.assertEqual(original_post, original_http_client.post) self.assertEqual(original_get, original_http_client.get) def _test_RetryAfter_N_seconds_should_keep_entry_for_N_seconds( self, http_client, retry_after): http_cache = {} http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.post("https://example.com") # We implemented POST only resp2 = http_client.post("https://example.com") # We implemented POST only logger.debug(http_cache) self.assertEqual(resp1.text, resp2.text, "Should return a cached response") sleep(retry_after + 1) resp3 = http_client.post("https://example.com") # We implemented POST only self.assertNotEqual(resp1.text, resp3.text, "Should return a new response") def test_429_with_RetryAfter_N_seconds_should_keep_entry_for_N_seconds(self): retry_after = 1 self._test_RetryAfter_N_seconds_should_keep_entry_for_N_seconds( DummyHttpClient( status_code=429, response_headers={"Retry-After": retry_after}), retry_after) def test_5xx_with_RetryAfter_N_seconds_should_keep_entry_for_N_seconds(self): retry_after = 1 self._test_RetryAfter_N_seconds_should_keep_entry_for_N_seconds( DummyHttpClient( status_code=503, response_headers={"Retry-After": retry_after}), retry_after) def test_400_with_RetryAfter_N_seconds_should_keep_entry_for_N_seconds(self): """Retry-After is supposed to only shown in http 429/5xx, but we choose to support Retry-After for arbitrary http response.""" retry_after = 1 self._test_RetryAfter_N_seconds_should_keep_entry_for_N_seconds( DummyHttpClient( status_code=400, response_headers={"Retry-After": retry_after}), retry_after) def test_one_RetryAfter_request_should_block_a_similar_request(self): http_cache = {} http_client = DummyHttpClient( status_code=429, response_headers={"Retry-After": 2}) http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.post("https://example.com", data={ "scope": "one", "claims": "bar", "grant_type": "authorization_code"}) resp2 = http_client.post("https://example.com", data={ "scope": "one", "claims": "foo", "grant_type": "password"}) logger.debug(http_cache) self.assertEqual(resp1.text, resp2.text, "Should return a cached response") def test_one_RetryAfter_request_should_not_block_a_different_request(self): http_cache = {} http_client = DummyHttpClient( status_code=429, response_headers={"Retry-After": 2}) http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.post("https://example.com", data={"scope": "one"}) resp2 = http_client.post("https://example.com", data={"scope": "two"}) logger.debug(http_cache) self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") def test_one_invalid_grant_should_block_a_similar_request(self): http_cache = {} http_client = DummyHttpClient( status_code=400) # It covers invalid_grant and interaction_required http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.post("https://example.com", data={"claims": "foo"}) logger.debug(http_cache) resp1_again = http_client.post("https://example.com", data={"claims": "foo"}) self.assertEqual(resp1.text, resp1_again.text, "Should return a cached response") resp2 = http_client.post("https://example.com", data={"claims": "bar"}) self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") resp2_again = http_client.post("https://example.com", data={"claims": "bar"}) self.assertEqual(resp2.text, resp2_again.text, "Should return a cached response") def test_one_foci_app_recovering_from_invalid_grant_should_also_unblock_another(self): """ Need not test multiple FOCI app's acquire_token_silent() here. By design, one FOCI app's successful populating token cache would result in another FOCI app's acquire_token_silent() to hit a token without invoking http request. """ def test_forcefresh_behavior(self): """ The implementation let token cache and http cache operate in different layers. They do not couple with each other. Therefore, acquire_token_silent(..., force_refresh=True) would bypass the token cache yet technically still hit the http cache. But that is OK, cause the customer need no force_refresh in the first place. After a successful AT/RT acquisition, AT/RT will be in the token cache, and a normal acquire_token_silent(...) without force_refresh would just work. This was discussed in https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview/pullrequest/3618?_a=files """ def test_http_get_200_should_be_cached(self): http_cache = {} http_client = DummyHttpClient( status_code=200) # It covers UserRealm discovery and OIDC discovery http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.get("https://example.com?foo=bar") resp2 = http_client.get("https://example.com?foo=bar") logger.debug(http_cache) self.assertEqual(resp1.text, resp2.text, "Should return a cached response") def test_device_flow_retry_should_not_be_cached(self): DEVICE_AUTH_GRANT = "urn:ietf:params:oauth:grant-type:device_code" http_cache = {} http_client = DummyHttpClient(status_code=400) http_client = ThrottledHttpClient(http_client, http_cache) resp1 = http_client.post( "https://example.com", data={"grant_type": DEVICE_AUTH_GRANT}) resp2 = http_client.post( "https://example.com", data={"grant_type": DEVICE_AUTH_GRANT}) logger.debug(http_cache) self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") def test_throttled_http_client_should_provide_close(self): http_cache = {} http_client = DummyHttpClient(status_code=200) http_client = ThrottledHttpClient(http_client, http_cache) with self.assertRaises(CloseMethodCalled): http_client.close() microsoft-authentication-library-for-python-1.17.0/tests/test_token_cache.py000066400000000000000000000262731420154156600274230ustar00rootroot00000000000000import logging import base64 import json import time from msal.token_cache import * from tests import unittest logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) # NOTE: These helpers were once implemented as static methods in TokenCacheTestCase. # That would cause other test files' "from ... import TokenCacheTestCase" # to re-run all test cases in this file. # Now we avoid that, by defining these helpers in module level. def build_id_token( iss="issuer", sub="subject", aud="my_client_id", exp=None, iat=None, **claims): # AAD issues "preferred_username", ADFS issues "upn" return "header.%s.signature" % base64.b64encode(json.dumps(dict({ "iss": iss, "sub": sub, "aud": aud, "exp": exp or (time.time() + 100), "iat": iat or time.time(), }, **claims)).encode()).decode('utf-8') def build_response( # simulate a response from AAD uid=None, utid=None, # If present, they will form client_info access_token=None, expires_in=3600, token_type="some type", **kwargs # Pass-through: refresh_token, foci, id_token, error, refresh_in, ... ): response = {} if uid and utid: # Mimic the AAD behavior for "client_info=1" request response["client_info"] = base64.b64encode(json.dumps({ "uid": uid, "utid": utid, }).encode()).decode('utf-8') if access_token: response.update({ "access_token": access_token, "expires_in": expires_in, "token_type": token_type, }) response.update(kwargs) # Pass-through key-value pairs as top-level fields return response class TokenCacheTestCase(unittest.TestCase): def setUp(self): self.cache = TokenCache() def testAddByAad(self): client_id = "my_client_id" id_token = build_id_token( oid="object1234", preferred_username="John Doe", aud=client_id) self.cache.add({ "client_id": client_id, "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", "response": build_response( uid="uid", utid="utid", # client_info expires_in=3600, access_token="an access token", id_token=id_token, refresh_token="a refresh token"), }, now=1000) self.assertEqual( { 'cached_at': "1000", 'client_id': 'my_client_id', 'credential_type': 'AccessToken', 'environment': 'login.example.com', 'expires_on': "4600", 'extended_expires_on': "4600", 'home_account_id': "uid.utid", 'realm': 'contoso', 'secret': 'an access token', 'target': 's2 s1 s3', 'token_type': 'some type', }, self.cache._cache["AccessToken"].get( 'uid.utid-login.example.com-accesstoken-my_client_id-contoso-s2 s1 s3') ) self.assertEqual( { 'client_id': 'my_client_id', 'credential_type': 'RefreshToken', 'environment': 'login.example.com', 'home_account_id': "uid.utid", 'last_modification_time': '1000', 'secret': 'a refresh token', 'target': 's2 s1 s3', }, self.cache._cache["RefreshToken"].get( 'uid.utid-login.example.com-refreshtoken-my_client_id--s2 s1 s3') ) self.assertEqual( { 'home_account_id': "uid.utid", 'environment': 'login.example.com', 'realm': 'contoso', 'local_account_id': "object1234", 'username': "John Doe", 'authority_type': "MSSTS", }, self.cache._cache["Account"].get('uid.utid-login.example.com-contoso') ) self.assertEqual( { 'credential_type': 'IdToken', 'secret': id_token, 'home_account_id': "uid.utid", 'environment': 'login.example.com', 'realm': 'contoso', 'client_id': 'my_client_id', }, self.cache._cache["IdToken"].get( 'uid.utid-login.example.com-idtoken-my_client_id-contoso-') ) self.assertEqual( { "client_id": "my_client_id", 'environment': 'login.example.com', }, self.cache._cache.get("AppMetadata", {}).get( "appmetadata-login.example.com-my_client_id") ) def testAddByAdfs(self): client_id = "my_client_id" id_token = build_id_token(aud=client_id, upn="JaneDoe@example.com") self.cache.add({ "client_id": client_id, "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://fs.msidlab8.com/adfs/oauth2/token", "response": build_response( uid=None, utid=None, # ADFS will provide no client_info expires_in=3600, access_token="an access token", id_token=id_token, refresh_token="a refresh token"), }, now=1000) self.assertEqual( { 'cached_at': "1000", 'client_id': 'my_client_id', 'credential_type': 'AccessToken', 'environment': 'fs.msidlab8.com', 'expires_on': "4600", 'extended_expires_on': "4600", 'home_account_id': "subject", 'realm': 'adfs', 'secret': 'an access token', 'target': 's2 s1 s3', 'token_type': 'some type', }, self.cache._cache["AccessToken"].get( 'subject-fs.msidlab8.com-accesstoken-my_client_id-adfs-s2 s1 s3') ) self.assertEqual( { 'client_id': 'my_client_id', 'credential_type': 'RefreshToken', 'environment': 'fs.msidlab8.com', 'home_account_id': "subject", 'last_modification_time': "1000", 'secret': 'a refresh token', 'target': 's2 s1 s3', }, self.cache._cache["RefreshToken"].get( 'subject-fs.msidlab8.com-refreshtoken-my_client_id--s2 s1 s3') ) self.assertEqual( { 'home_account_id': "subject", 'environment': 'fs.msidlab8.com', 'realm': 'adfs', 'local_account_id': "subject", 'username': "JaneDoe@example.com", 'authority_type': "ADFS", }, self.cache._cache["Account"].get('subject-fs.msidlab8.com-adfs') ) self.assertEqual( { 'credential_type': 'IdToken', 'secret': id_token, 'home_account_id': "subject", 'environment': 'fs.msidlab8.com', 'realm': 'adfs', 'client_id': 'my_client_id', }, self.cache._cache["IdToken"].get( 'subject-fs.msidlab8.com-idtoken-my_client_id-adfs-') ) self.assertEqual( { "client_id": "my_client_id", 'environment': 'fs.msidlab8.com', }, self.cache._cache.get("AppMetadata", {}).get( "appmetadata-fs.msidlab8.com-my_client_id") ) def test_key_id_is_also_recorded(self): my_key_id = "some_key_id_123" self.cache.add({ "data": {"key_id": my_key_id}, "client_id": "my_client_id", "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", "response": build_response( uid="uid", utid="utid", # client_info expires_in=3600, access_token="an access token", refresh_token="a refresh token"), }, now=1000) cached_key_id = self.cache._cache["AccessToken"].get( 'uid.utid-login.example.com-accesstoken-my_client_id-contoso-s2 s1 s3', {}).get("key_id") self.assertEqual(my_key_id, cached_key_id, "AT should be bound to the key") def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep. self.cache.add({ "client_id": "my_client_id", "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", "response": build_response( uid="uid", utid="utid", # client_info expires_in=3600, refresh_in=1800, access_token="an access token", ), #refresh_token="a refresh token"), }, now=1000) refresh_on = self.cache._cache["AccessToken"].get( 'uid.utid-login.example.com-accesstoken-my_client_id-contoso-s2 s1 s3', {}).get("refresh_on") self.assertEqual("2800", refresh_on, "Should save refresh_on") def test_old_rt_data_with_wrong_key_should_still_be_salvaged_into_new_rt(self): sample = { 'client_id': 'my_client_id', 'credential_type': 'RefreshToken', 'environment': 'login.example.com', 'home_account_id': "uid.utid", 'secret': 'a refresh token', 'target': 's2 s1 s3', } new_rt = "this is a new RT" self.cache._cache["RefreshToken"] = {"wrong-key": sample} self.cache.modify( self.cache.CredentialType.REFRESH_TOKEN, sample, {"secret": new_rt}) self.assertEqual( dict(sample, secret=new_rt), self.cache._cache["RefreshToken"].get( 'uid.utid-login.example.com-refreshtoken-my_client_id--s2 s1 s3') ) class SerializableTokenCacheTestCase(TokenCacheTestCase): # Run all inherited test methods, and have extra check in tearDown() def setUp(self): self.cache = SerializableTokenCache() self.cache.deserialize(""" { "AccessToken": { "an-entry": { "foo": "bar" } }, "customized": "whatever" } """) def test_has_state_changed(self): cache = SerializableTokenCache() self.assertFalse(cache.has_state_changed) cache.add({}) # An NO-OP add() still counts as a state change. Good enough. self.assertTrue(cache.has_state_changed) def tearDown(self): state = self.cache.serialize() logger.debug("serialize() = %s", state) # Now assert all extended content are kept intact output = json.loads(state) self.assertEqual(output.get("customized"), "whatever", "Undefined cache keys and their values should be intact") self.assertEqual( output.get("AccessToken", {}).get("an-entry"), {"foo": "bar"}, "Undefined token keys and their values should be intact") microsoft-authentication-library-for-python-1.17.0/tests/test_wstrust.py000066400000000000000000000104041420154156600267000ustar00rootroot00000000000000#------------------------------------------------------------------------------ # # Copyright (c) Microsoft Corporation. # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # #------------------------------------------------------------------------------ try: from xml.etree import cElementTree as ET except ImportError: from xml.etree import ElementTree as ET import os from msal.wstrust_response import * from tests import unittest class Test_WsTrustResponse(unittest.TestCase): def test_findall_content_with_comparison(self): content = """ foo """ sample = ('' + content + '') # Demonstrating how XML-based parser won't give you the raw content as-is element = ET.fromstring(sample).findall('{SAML:assertion}Assertion')[0] assertion_via_xml_parser = ET.tostring(element) self.assertNotEqual(content, assertion_via_xml_parser) self.assertNotIn(b"", assertion_via_xml_parser) # The findall_content() helper, based on Regex, will return content as-is. self.assertEqual([content], findall_content(sample, "Wrapper")) def test_parse_error(self): error_response = ''' http://www.w3.org/2005/08/addressing/soap/fault 2013-07-30T00:32:21.989Z 2013-07-30T00:37:21.989Z s:Sender a:RequestFailed MSIS3127: The specified request failed. ''' self.assertEqual({ "reason": "MSIS3127: The specified request failed.", "code": "a:RequestFailed", }, parse_error(error_response)) def test_token_parsing_happy_path(self): with open(os.path.join(os.path.dirname(__file__), "rst_response.xml")) as f: rst_body = f.read() result = parse_token_by_re(rst_body) self.assertEqual(result.get("type"), SAML_TOKEN_TYPE_V1) self.assertIn(b"