From ccd7ab4d505c13ff874268b5522d8fe0203c7cf8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 Dec 2018 23:29:53 -0800 Subject: [PATCH 001/440] Auto detect (sub)modules --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 96c8706b..3b4a5b83 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ # #------------------------------------------------------------------------------ -from setuptools import setup +from setuptools import setup, find_packages import re, io # setup.py shall not import main package @@ -59,7 +59,7 @@ 'Programming Language :: Python :: 3.7', 'License :: OSI Approved :: MIT License', ], - packages=['msal'], + packages=find_packages(), install_requires=[ 'requests>=2.0.0', 'PyJWT[crypto]>=1.0.0', From 68acfee6d45f38527598c23f263cecf4fd996121 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 7 Dec 2018 11:28:59 -0800 Subject: [PATCH 002/440] Create README.md --- README.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..d0a4b772 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Microsoft Authentication Library (MSAL) for Python + +The MSAL library for Python gives your app the ability to begin using the [Microsoft Cloud](https://cloud.microsoft.com) +by supporting [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/services/active-directory/) +and [Microsoft Accounts](https://account.microsoft.com) in a converged experience using industry standard OAuth2 and OpenID Connect. +Soon MSAL Python will also support [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/). + +More and more detail about MSAL Python functionality and usage will be documented in the +[Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki). + +## Installation + +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. For now, you can install from our latest dev branch, by `pip install https://github.com/AzureAD/microsoft-authentication-library-for-python.git@dev` + +## Usage + +Before using MSAL Python (or any MSAL SDKs, for that matter), you will have to +[register your application with the AAD 2.0 endpoint](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-register-an-app). + +Acquiring tokens with MSAL Python is somewhat different than ADAL Python. You will need to follow this 3-step pattern. + +1. Contrary to ADAL (which proposes the notion of `AuthenticationContext`, which is a connection to Azure AD), + 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. For example: + + ```python + from msal import PublicClientApplication + app = PublicClientApplication("your_client_id", authority="...") + ``` + + Later, each time you would want an access token, you start by: + ```python + result = None + ``` + +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. + + ```python + # We now check the cache to see if we have some end users already signed 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(config["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. Here we demonstrate the username password 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_username_password( + "johndoe@contoso.com", "fakepassword", 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 + ``` + +That is it. There will be some variations for different flows. +You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). + + +## Samples and Documentation +We provide a full suite of [sample applications on GitHub](https://github.com/azure-samples?utf8=%E2%9C%93&q=active-directory&type=&language=) to help you get started with learning the Azure Identity system. This includes tutorials for native clients and web applications. We also provide full walkthroughs for authentication flows such as OAuth2, OpenID Connect and for calling APIs such as the Graph API. + +You can find the relevant samples by scenarios listed in this [wiki page for acquiring tokens using ADAL Python](https://github.com/AzureAD/azure-activedirectory-library-for-python/wiki/Acquire-tokens#adal-python-apis-for-corresponding-flows). + +The generic documents on +[Auth Scenarios](https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-scenarios) +and +[Auth protocols](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) +are recommended reading. + +The API reference of MSAL Python is coming soon. + +## 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). + +## 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) + +## 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/en-us/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. From 3835bfb87952b0abc29adb8e8944841b1b0446cd Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 7 Dec 2018 11:43:57 -0800 Subject: [PATCH 003/440] Minor editorial change --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index d0a4b772..5f80944c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Acquiring tokens with MSAL Python is somewhat different than ADAL Python. You wi 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 if we have some end users already signed in before. @@ -76,9 +77,6 @@ You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft ## Samples and Documentation -We provide a full suite of [sample applications on GitHub](https://github.com/azure-samples?utf8=%E2%9C%93&q=active-directory&type=&language=) to help you get started with learning the Azure Identity system. This includes tutorials for native clients and web applications. We also provide full walkthroughs for authentication flows such as OAuth2, OpenID Connect and for calling APIs such as the Graph API. - -You can find the relevant samples by scenarios listed in this [wiki page for acquiring tokens using ADAL Python](https://github.com/AzureAD/azure-activedirectory-library-for-python/wiki/Acquire-tokens#adal-python-apis-for-corresponding-flows). The generic documents on [Auth Scenarios](https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-scenarios) From 25be1ec464707edcd9c438fa6c84d4d0d45f3af1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Dec 2018 10:55:55 -0800 Subject: [PATCH 004/440] Addressing PR comments --- README.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5f80944c..393695ac 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,28 @@ -# Microsoft Authentication Library (MSAL) for Python +# Microsoft Authentication Library (MSAL) for Python Preview -The MSAL library for Python gives your app the ability to begin using the [Microsoft Cloud](https://cloud.microsoft.com) -by supporting [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/services/active-directory/) -and [Microsoft Accounts](https://account.microsoft.com) in a converged experience using industry standard OAuth2 and OpenID Connect. +The MSAL library for Python enables your app to access the +[Microsoft Cloud](https://cloud.microsoft.com) +by supporting authentication of users with +[Microsoft Azure Active Directory accounts](https://azure.microsoft.com/en-us/services/active-directory/) +and [Microsoft Accounts](https://account.microsoft.com) using industry standard OAuth2 and OpenID Connect. Soon MSAL Python will also support [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/). More and more detail about MSAL Python functionality and usage will be documented in the [Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki). +## Important Note about the MSAL Preview + +This library is suitable for use in a production environment. +We provide the same production level support for this library as we do our current production libraries. +During the preview we may make changes to the API, internal cache format, and other mechanisms of this library, +which you will be required to take along with bug fixes or feature improvements. +This may impact your application. +For instance, a change to the cache format may impact your users, such as requiring them to sign in again. +An API change may require you to update your code. +When we provide the General Availability release +we will require you to update to the General Availability version within six months, +as applications written using a preview version of library may no longer work. + ## Installation 1. If you haven't already, [install and/or upgrade the pip](https://pip.pypa.io/en/stable/installing/) @@ -19,10 +34,9 @@ More and more detail about MSAL Python functionality and usage will be documente Before using MSAL Python (or any MSAL SDKs, for that matter), you will have to [register your application with the AAD 2.0 endpoint](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-register-an-app). -Acquiring tokens with MSAL Python is somewhat different than ADAL Python. You will need to follow this 3-step pattern. +Acquiring tokens with MSAL Python need to follow this 3-step pattern. -1. Contrary to ADAL (which proposes the notion of `AuthenticationContext`, which is a connection to Azure AD), - MSAL proposes a clean separation between +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. For example: @@ -34,7 +48,7 @@ Acquiring tokens with MSAL Python is somewhat different than ADAL Python. You wi Later, each time you would want an access token, you start by: ```python - result = None + 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. @@ -73,7 +87,6 @@ Acquiring tokens with MSAL Python is somewhat different than ADAL Python. You wi ``` That is it. There will be some variations for different flows. -You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). ## Samples and Documentation @@ -86,6 +99,9 @@ are recommended reading. The API reference of MSAL Python is coming soon. +You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). + + ## Versions This library follows [Semantic Versioning](http://semver.org/). From 7d06daccc16173a6677ba72309bcb47c75fcb8c0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Dec 2018 10:56:57 -0800 Subject: [PATCH 005/440] Fix incorrect installation link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 393695ac..b0fbc5c5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ as applications written using a preview version of library may no longer work. 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. For now, you can install from our latest dev branch, by `pip install https://github.com/AzureAD/microsoft-authentication-library-for-python.git@dev` +2. For now, you can install from our latest dev branch, by `pip install git+https://github.com/AzureAD/microsoft-authentication-library-for-python.git@dev` ## Usage From b199bc91cf500197f8c3b788b439e27499857da5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Dec 2018 11:12:41 -0800 Subject: [PATCH 006/440] Creating contributing.md --- contributing.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 contributing.md diff --git a/contributing.md b/contributing.md new file mode 100644 index 00000000..e78c1ce1 --- /dev/null +++ b/contributing.md @@ -0,0 +1,122 @@ +# 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. From 3ce536f1ab2d5c5a91347446a873cfa0969920e9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Dec 2018 15:25:54 -0800 Subject: [PATCH 007/440] Choose to NOT promote username password flow See rationale here https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/8/files#r240791569 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b0fbc5c5..ce5930a0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. 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. For example: + and ideally reuse it during the lifecycle of your app. The following example shows a `PublicClientApplication`: ```python from msal import PublicClientApplication @@ -56,7 +56,8 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. It will automatically handle the token refresh for you. ```python - # We now check the cache to see if we have some end users already signed in before. + # 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 @@ -71,13 +72,12 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. 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. Here we demonstrate the username password flow. + 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_username_password( - "johndoe@contoso.com", "fakepassword", scopes=["user.read"]) + result = app.acquire_token_by_one_of_the_actual_method(..., scopes=["user.read"]) if "access_token" in result: print(result["access_token"]) # Yay! else: From ff3b3e3fe8323f838682d07671c161611bd86f58 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 Dec 2018 23:30:13 -0800 Subject: [PATCH 008/440] Prepare release pipeline --- .travis.yml | 26 ++++++++++++++++++++++++++ requirements.txt | 7 +------ setup.py | 4 ++-- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index e1ec1bf1..bebf2f33 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,33 @@ python: - "2.7" - "3.5" - "3.6" + - "3.7" 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: master + 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: master + tags: true + condition: $TRAVIS_PYTHON_VERSION = "2.7" + diff --git a/requirements.txt b/requirements.txt index c0c80b9c..9c558e35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1 @@ -requests>=2,<3 - -PyJWT>=1,<2 -#1.1.0 is the first that can be installed on windows -cryptography>=1.1,<2 - +. diff --git a/setup.py b/setup.py index 3b4a5b83..d628714d 100644 --- a/setup.py +++ b/setup.py @@ -61,8 +61,8 @@ ], packages=find_packages(), install_requires=[ - 'requests>=2.0.0', - 'PyJWT[crypto]>=1.0.0', + 'requests>=2.0.0,<3', + 'PyJWT[crypto]>=1.0.0,<2', ] ) From d2ecbe1e5362d868acf1cf6cab348a5dbc28dcd2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 12 Dec 2018 10:56:11 -0800 Subject: [PATCH 009/440] Travis requires a workaround for Python 3.7 --- .travis.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bebf2f33..c5dc9268 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,14 @@ python: - "2.7" - "3.5" - "3.6" - - "3.7" +# 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 + install: - pip install -r requirements.txt script: From c36d7552aff212108ec493a23694801d463ddb39 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 Dec 2018 16:28:58 -0800 Subject: [PATCH 010/440] Prepare release 0.1.0 --- LICENSE | 24 ++++++++++++++++++++++++ setup.py | 17 ++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e7a9ff04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +The 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. \ No newline at end of file diff --git a/setup.py b/setup.py index d628714d..c1e0b7ac 100644 --- a/setup.py +++ b/setup.py @@ -35,19 +35,25 @@ io.open('msal/application.py', encoding='utf_8_sig').read() ).group(1) +long_description = open('README.md').read() + setup( name='msal', version=__version__, - description=( - 'The MSAL for Python library makes it easy for python application ' - 'to authenticate to Azure Active Directory v2 ' - '(which serves Microsoft Account and AAD and B2C).'), + description=' '.join( + """The Microsoft Authentication Library (MSAL) for Python library + enables your app to access the Microsoft Cloud + by supporting authentication of users with + Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) + using industry standard OAuth2 and OpenID Connect.""".split()), + long_description=long_description, + long_description_content_type="text/markdown", license='MIT', author='Microsoft Corporation', author_email='nugetaad@microsoft.com', url='https://github.com/AzureAD/microsoft-authentication-library-for-python', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', @@ -58,6 +64,7 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', ], packages=find_packages(), install_requires=[ From 364a670562204ade83833a25d0f3e2797b152eb7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 17 Dec 2018 11:56:27 -0800 Subject: [PATCH 011/440] Simplify the installation method since we released on PyPI already --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce5930a0..4279dc6a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ as applications written using a preview version of library may no longer work. 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. For now, you can install from our latest dev branch, by `pip install git+https://github.com/AzureAD/microsoft-authentication-library-for-python.git@dev` +2. As usual, just run `pip install msal`. ## Usage @@ -45,7 +45,7 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. from msal import PublicClientApplication app = PublicClientApplication("your_client_id", authority="...") ``` - + 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. From 86eb9617fb1760f396ab28a5cf741a0ad1cb0eab Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Dec 2018 14:46:23 -0800 Subject: [PATCH 012/440] Initial sphinx settings --- docs/Makefile | 19 ++++++ docs/conf.py | 177 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 20 ++++++ docs/make.bat | 35 ++++++++++ 4 files changed, 251 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..298ea9e2 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# 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) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..5a3245b7 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,177 @@ +# -*- 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. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = u'MSAL Python' +copyright = u'2018, Microsoft' +author = u'Microsoft' + +# The short X.Y version +version = u'' +# The full version, including alpha/beta/rc tags +release = u'0.1.0' + + +# -- 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.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' + +# 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 = {} + +# 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 ------------------------------------------------- \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..1a118748 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,20 @@ +.. MSAL Python documentation master file, created by + sphinx-quickstart on Tue Dec 18 10:53:22 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to MSAL Python's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..27f573b8 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@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 From 971cc134a958827ab57febde64f89d9278b7051b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 19 Dec 2018 00:35:45 -0800 Subject: [PATCH 013/440] Compose index.rst to actually provide documentation --- docs/conf.py | 1 + docs/index.rst | 63 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 5a3245b7..a3355a30 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + 'sphinx.ext.autodoc', 'sphinx.ext.githubpages', ] diff --git a/docs/index.rst b/docs/index.rst index 1a118748..baad12fd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,6 +3,9 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +.. This file is also inspired by + https://pythonhosted.org/an_example_pypi_project/sphinx.html#full-code-example + Welcome to MSAL Python's documentation! ======================================= @@ -10,11 +13,69 @@ Welcome to MSAL Python's documentation! :maxdepth: 2 :caption: Contents: +You can find high level conceptual documentations in the project +`README `_ +and +`workable samples inside the project code base +`_ +. + +The documentation hosted here is for API Reference. + + +PublicClientApplication and ConfidentialClientApplication +========================================================= + +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: + +ConfidentialClientApplication +----------------------------- +.. autoclass:: msal.ConfidentialClientApplication + :members: + + +Shared Methods +-------------- +Both PublicClientApplication and ConfidentialClientApplication +have following methods inherited from their base class. +You typically do not need to initiate this base class, though. + +.. autoclass:: msal.ClientApplication + :members: + + .. automethod:: __init__ + + +TokenCache +========== + +One of the parameter 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: Indices and tables ================== * :ref:`genindex` -* :ref:`modindex` * :ref:`search` + From e53c08dba9f0eb4312c75d814aa29ee119b29ac0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 19 Dec 2018 23:24:13 -0800 Subject: [PATCH 014/440] Switch to sphinx_rtd_theme --- docs/conf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index a3355a30..9001352f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -76,7 +76,8 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +# html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' # 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 From d6ef92a9a54e16783171eed4db6d9de7259852a2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 19 Dec 2018 21:07:01 -0800 Subject: [PATCH 015/440] Document all the APIs --- msal/application.py | 114 +++++++++++++++++++++++++++++++++++--------- msal/token_cache.py | 9 ++-- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/msal/application.py b/msal/application.py index 31737c26..9e231582 100644 --- a/msal/application.py +++ b/msal/application.py @@ -55,14 +55,41 @@ def __init__( client_credential=None, authority=None, validate_authority=True, token_cache=None, verify=True, proxies=None, timeout=None): - """ - :param client_credential: It can be a string containing client secret, - or an X509 certificate container in this form: + """Create an instance of application. + + :param client_id: Your app has a clinet_id after you register it on AAD. + :param 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...", } + + :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 + :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 verify: (optional) + It will be passed to the + `verify parameter in the underlying requests library + `_ + :param proxies: (optional) + It will be passed to the + `proxies parameter in the underlying requests library + `_ + :param timeout: (optional) + It will be passed to the + `timeout parameter in the underlying requests library + `_ """ self.client_id = client_id self.client_credential = client_credential @@ -123,13 +150,14 @@ def get_authorization_request_url( **kwargs): """Constructs a URL for you to start a Authorization Code Grant. - :param scopes: + :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param str state: Recommended by OAuth2 for CSRF protection. - :param login_hint: + :param str login_hint: Identifier of the user. Generally a User Principal Name (UPN). - :param redirect_uri: + :param str redirect_uri: Address to return to upon receiving a response from the authority. + :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() :param additional_scope: Additional scope is a concept only in AAD. @@ -161,7 +189,8 @@ def acquire_token_by_authorization_code( """The second half of the Authorization Code Grant. :param code: The authorization code returned from Authorization Server. - :param scopes: + :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. @@ -175,6 +204,11 @@ def acquire_token_by_authorization_code( 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 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. @@ -190,13 +224,15 @@ def acquire_token_by_authorization_code( 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 acquire_token_silent() to find its tokens. - 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 them to choose one of them to proceed. + 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. """ # The following implementation finds accounts only from saved accounts, # but does NOT correlate them with saved RTs. It probably won't matter, @@ -224,15 +260,17 @@ def acquire_token_silent( or by finding a valid refresh token from cache and then automatically use it to redeem a new access token. - The return value will be an new or cached access token, or None. - - :param scopes: Scopes, represented as a list of strings + :param list[str] scopes: (Required) + Scopes requested to access a protected API (a resource). :param account: - one of the account object returned by get_accounts(), + 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. + :return: + - A dict containing "access_token" key, when cache lookup succeeds. + - None when cache lookup does not yield anything. """ assert isinstance(scopes, list), "Invalid parameter type" the_authority = Authority(authority) if authority else self.authority @@ -286,6 +324,13 @@ def __init__(self, client_id, client_credential=None, **kwargs): client_id, client_credential=None, **kwargs) 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. + """ return self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), **kwargs) @@ -293,11 +338,16 @@ def initiate_device_flow(self, scopes=None, **kwargs): def acquire_token_by_device_flow(self, flow, **kwargs): """Obtain token by a device flow object, with customizable polling effect. - Args: - flow (dict): - A dict previously generated by initiate_device_flow(...). - You can exit the polling loop early, by changing the value of - its "expires_at" key to 0, at any time. + :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. + + :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". """ return self.client.obtain_token_by_device_flow( flow, @@ -308,7 +358,18 @@ def acquire_token_by_device_flow(self, flow, **kwargs): def acquire_token_by_username_password( self, username, password, scopes=None, **kwargs): - """Gets a token for a given resource via user credentails.""" + """Gets a token for a given resource via user credentails. + + :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). + + :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 = decorate_scope(scopes, self.client_id) if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery(username) @@ -348,7 +409,16 @@ def _acquire_token_by_username_password_federated( class ConfidentialClientApplication(ClientApplication): # server-side web app def acquire_token_for_client(self, scopes, **kwargs): - """Acquires token from the service for the confidential client.""" + """Acquires token from the service for the confidential client. + + :param list[str] scopes: (Required) + Scopes requested to access a protected API (a resource). + + :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 return self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration diff --git a/msal/token_cache.py b/msal/token_cache.py index 7adcd333..2f6166ed 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -18,9 +18,9 @@ def base64decode(raw): # This can handle a padding-less raw input class TokenCache(object): """This is considered as a base class containing minimal cache behavior. - Although this class already maintains tokens using unified schema, - it does not serialize/persist them. See subclass SerializableTokenCache - for more details. + 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: @@ -169,7 +169,8 @@ 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.. - Depends on your need, the following file-based persistence may be sufficient: + Depending on your need, + the following simple recipe for file-based persistence may be sufficient:: import atexit cache = SerializableTokenCache() From 7e82070cf8e8d422fd6dc9724b451b80d7f248dc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 20 Dec 2018 12:05:31 -0800 Subject: [PATCH 016/440] Use msal.__version__ in doc --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9001352f..d0a02003 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,9 @@ author = u'Microsoft' # The short X.Y version -version = u'' +from msal import __version__ as version # The full version, including alpha/beta/rc tags -release = u'0.1.0' +release = version # -- General configuration --------------------------------------------------- @@ -176,4 +176,4 @@ epub_exclude_files = ['search.html'] -# -- Extension configuration ------------------------------------------------- \ No newline at end of file +# -- Extension configuration ------------------------------------------------- From 6256f1db9266f2dba2e875cdf6e893d932c86cf3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 20 Dec 2018 13:39:35 -0800 Subject: [PATCH 017/440] Also documenting an info var --- msal/token_cache.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/msal/token_cache.py b/msal/token_cache.py index 2f6166ed..e8f7939d 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -182,6 +182,10 @@ class SerializableTokenCache(TokenCache): ) app = ClientApplication(..., token_cache=cache) ... + + :var bool has_state_changed: + Indicates whether the cache state has changed since last + :func:`~serialize` or :func:`~deserialize` call. """ def add(self, event): super(SerializableTokenCache, self).add(event) From 3ae2272d51915c5e2ffac41119d35adefb4bf12f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 21 Dec 2018 15:44:45 -0800 Subject: [PATCH 018/440] Document the possible failure in Device Flow --- msal/application.py | 3 +++ tests/test_application.py | 1 + 2 files changed, 4 insertions(+) diff --git a/msal/application.py b/msal/application.py index 9e231582..7ddadde1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -330,6 +330,9 @@ def initiate_device_flow(self, scopes=None, **kwargs): :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. """ return self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), diff --git a/tests/test_application.py b/tests/test_application.py index b9ca02c8..180bef50 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -109,6 +109,7 @@ def test_device_flow(self): self.app = PublicClientApplication( CONFIG["client_id"], authority=CONFIG["authority"]) flow = self.app.initiate_device_flow(scopes=CONFIG.get("scope")) + assert "user_code" in flow, str(flow) # Provision or policy might block DF logging.warn(flow["message"]) duration = 30 From 06333a76b7dc4adba105b8d9492ff1821492112b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 14 Jan 2019 19:34:59 -0800 Subject: [PATCH 019/440] Update README.md with the latest API reference doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4279dc6a..a18c8354 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ and [Auth protocols](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) are recommended reading. -The API reference of MSAL Python is coming soon. +There is also the [API reference of MSAL Python](https://msal-python.rtfd.io). You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). From 4e11530f820edfe8895c942177b793cc33cae9ea Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 17 Jan 2019 15:12:38 -0800 Subject: [PATCH 020/440] Use a more precise way to encode SAML assertion --- msal/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 7ddadde1..93140263 100644 --- a/msal/application.py +++ b/msal/application.py @@ -4,7 +4,7 @@ except: # Python 3 from urllib.parse import urljoin import logging -from base64 import b64encode +from base64 import urlsafe_b64encode import sys from .oauth2cli import Client, JwtSigner @@ -405,7 +405,7 @@ def _acquire_token_by_username_password_federated( raise RuntimeError( "RSTR returned unknown token type: %s", wstrust_result.get("type")) return self.client.obtain_token_by_assertion( - b64encode(wstrust_result["token"]), + urlsafe_b64encode(wstrust_result["token"]).strip(b'='), grant_type=grant_type, scope=scopes, **kwargs) From 4c436cb1f0378a015b8afdd9cb1631af2d8ac651 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 17 Jan 2019 16:12:50 -0800 Subject: [PATCH 021/440] Fix a logger issue when using assertions in Python 3 --- msal/token_cache.py | 10 +++++++++- msal/wstrust_request.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index e8f7939d..556ecd60 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -7,6 +7,8 @@ from .authority import canonicalize +logger = logging.getLogger(__name__) + def is_subdict_of(small, big): return dict(big, **small) == big @@ -46,7 +48,13 @@ def add(self, event): # type: (dict) -> None # event typically contains: client_id, scope, token_endpoint, # resposne, params, data, grant_type - logging.debug("event=%s", json.dumps(event, indent=4)) + for sensitive in ("password", "client_secret"): + if sensitive in event.get("data", {}): + # Hide them from accidental exposure in logging + event["data"][sensitive] = "********" + logger.debug("event=%s", json.dumps(event, indent=4, sort_keys=True, + default=str, # A workaround when assertion is in bytes in Python 3 + )) response = event.get("response", {}) access_token = response.get("access_token", {}) refresh_token = response.get("refresh_token", {}) diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 52cbf3de..f62c2ae9 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -36,7 +36,7 @@ from .wstrust_response import parse_response -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) def send_request( username, password, cloud_audience_urn, endpoint_address, soap_action, From 379856e936094d58abf1f3c84e40f1e6a18872f2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 24 Jan 2019 14:42:32 -0800 Subject: [PATCH 022/440] Use the underlying encoder rather than ad-hoc one --- msal/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 93140263..ff5727ff 100644 --- a/msal/application.py +++ b/msal/application.py @@ -4,7 +4,6 @@ except: # Python 3 from urllib.parse import urljoin import logging -from base64 import urlsafe_b64encode import sys from .oauth2cli import Client, JwtSigner @@ -404,9 +403,10 @@ def _acquire_token_by_username_password_federated( 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( - urlsafe_b64encode(wstrust_result["token"]).strip(b'='), - grant_type=grant_type, scope=scopes, **kwargs) + wstrust_result["token"], grant_type, scope=scopes, **kwargs) class ConfidentialClientApplication(ClientApplication): # server-side web app From 6c8491f50641f33da6c302751d3b753ab72c63d9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 16 Jan 2019 16:58:30 -0800 Subject: [PATCH 023/440] Wire up verify and proxies for Authority --- msal/application.py | 16 ++++++++++------ msal/authority.py | 28 +++++++++++++++++++--------- tests/test_authority.py | 2 +- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/msal/application.py b/msal/application.py index ff5727ff..21de5e29 100644 --- a/msal/application.py +++ b/msal/application.py @@ -92,14 +92,14 @@ def __init__( """ self.client_id = client_id self.client_credential = client_credential + self.verify = verify + self.proxies = proxies + self.timeout = timeout self.authority = Authority( authority or "https://login.microsoftonline.com/common/", - validate_authority) + validate_authority, verify=self.verify, proxies=self.proxies) # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() - self.verify = verify - self.proxies = proxies - self.timeout = timeout self.client = self._build_client(client_credential, self.authority) def _build_client(self, client_credential, authority): @@ -166,7 +166,9 @@ def get_authorization_request_url( (Under the hood, we simply merge scope and additional_scope before sending them on the wire.) """ - the_authority = Authority(authority) if authority else self.authority + the_authority = Authority( + authority, verify=self.verify, proxies=self.proxies, + ) if authority else self.authority client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id) @@ -272,7 +274,9 @@ def acquire_token_silent( - None when cache lookup does not yield anything. """ assert isinstance(scopes, list), "Invalid parameter type" - the_authority = Authority(authority) if authority else self.authority + the_authority = Authority( + authority, verify=self.verify, proxies=self.proxies, + ) if authority else self.authority if not force_refresh: matches = self.token_cache.find( diff --git a/msal/authority.py b/msal/authority.py index 36d1ae43..baf86c2e 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -21,7 +21,9 @@ class Authority(object): Once constructed, it contains members named "*_endpoint" for this instance. TODO: It will also cache the previously-validated authority instances. """ - def __init__(self, authority_url, validate_authority=True): + def __init__(self, authority_url, validate_authority=True, + verify=True, proxies=None, + ): """Creates an authority instance, and also validates it. :param validate_authority: @@ -30,24 +32,29 @@ def __init__(self, authority_url, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ + self.verify = verify + self.proxies = proxies canonicalized, self.instance, tenant = canonicalize(authority_url) tenant_discovery_endpoint = ( # Hard code a V2 pattern as default value 'https://{}/{}/v2.0/.well-known/openid-configuration' .format(WORLD_WIDE, tenant)) if validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS: tenant_discovery_endpoint = instance_discovery( - canonicalized + "/oauth2/v2.0/authorize") - openid_config = tenant_discovery(tenant_discovery_endpoint) + canonicalized + "/oauth2/v2.0/authorize", + verify=verify, proxies=proxies) + openid_config = tenant_discovery( + tenant_discovery_endpoint, verify=verify, proxies=proxies) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID self.is_adfs = self.tenant.lower() == 'adfs' - def user_realm_discovery(self, username, **kwargs): + def user_realm_discovery(self, username): resp = requests.get( "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( netloc=self.instance, username=username), - headers={'Accept':'application/json'}, **kwargs) + headers={'Accept':'application/json'}, + verify=self.verify, proxies=self.proxies) resp.raise_for_status() return resp.json() # It will typically contain "ver", "account_type", @@ -64,17 +71,20 @@ def canonicalize(url): "https://login.microsoftonline.com/" % url) return match_object.group(0), match_object.group(1), match_object.group(2) -def instance_discovery(url, response=None): # Returns tenant discovery endpoint +def instance_discovery(url, response=None, verify=True, proxies=None): + # Returns tenant discovery endpoint resp = requests.get( # Note: This URL seemingly returns V1 endpoint only 'https://{}/common/discovery/instance'.format(WORLD_WIDE), - params={'authorization_endpoint': url, 'api-version': '1.0'}) + params={'authorization_endpoint': url, 'api-version': '1.0'}, + verify=verify, proxies=proxies) payload = response or resp.json() if 'tenant_discovery_endpoint' not in payload: raise MsalServiceError(status_code=resp.status_code, **payload) return payload['tenant_discovery_endpoint'] -def tenant_discovery(tenant_discovery_endpoint): # Returns Openid Configuration - resp = requests.get(tenant_discovery_endpoint) +def tenant_discovery(tenant_discovery_endpoint, verify=True, proxies=None): + # Returns Openid Configuration + resp = requests.get(tenant_discovery_endpoint, verify=verify, proxies=proxies) payload = resp.json() if 'authorization_endpoint' in payload and 'token_endpoint' in payload: return payload diff --git a/tests/test_authority.py b/tests/test_authority.py index 36583314..41714552 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -94,6 +94,6 @@ def test_instance_discovery_with_unknown_instance(self): def test_instance_discovery_with_mocked_response(self): mock_response = {'tenant_discovery_endpoint': 'http://a.com/t/openid'} endpoint = instance_discovery( - "https://login.microsoftonline.in/tenant.com", mock_response) + "https://login.microsoftonline.in/tenant.com", response=mock_response) self.assertEqual(endpoint, mock_response['tenant_discovery_endpoint']) From b235cb25e52e442e6460a9ac758502ef36ae7084 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 16 Jan 2019 17:06:48 -0800 Subject: [PATCH 024/440] Wire up verify and proxies for mex and wstrust --- msal/application.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 21de5e29..6a6ee37c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -387,17 +387,20 @@ def acquire_token_by_username_password( def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): + verify = kwargs.pop("verify", self.verify) + proxies = kwargs.pop("proxies", self.proxies) wstrust_endpoint = {} if user_realm_result.get("federation_metadata_url"): wstrust_endpoint = mex_send_request( - user_realm_result["federation_metadata_url"]) + user_realm_result["federation_metadata_url"], + verify=self.verify, proxies=self.proxies) logger.debug("wstrust_endpoint = %s", wstrust_endpoint) wstrust_result = wst_send_request( username, password, user_realm_result.get("cloud_audience_urn"), wstrust_endpoint.get("address", # Fallback to an AAD supplied endpoint user_realm_result.get("federation_active_auth_url")), - wstrust_endpoint.get("action"), **kwargs) + wstrust_endpoint.get("action"), verify=verify, proxies=proxies) if not ("token" in wstrust_result and "type" in wstrust_result): raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result) grant_type = { From 51835923fc1a1711cb6581ee0e7baa57f7eb6c7d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 29 Jan 2019 14:38:22 -0800 Subject: [PATCH 025/440] fixup! Wire up verify and proxies for Authority --- msal/application.py | 10 ++++++---- msal/authority.py | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/msal/application.py b/msal/application.py index 6a6ee37c..113fe51b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -97,7 +97,7 @@ def __init__( self.timeout = timeout self.authority = Authority( authority or "https://login.microsoftonline.com/common/", - validate_authority, verify=self.verify, proxies=self.proxies) + validate_authority, verify=verify, proxies=proxies, timeout=timeout) # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() self.client = self._build_client(client_credential, self.authority) @@ -167,7 +167,8 @@ def get_authorization_request_url( sending them on the wire.) """ the_authority = Authority( - authority, verify=self.verify, proxies=self.proxies, + authority, + verify=self.verify, proxies=self.proxies, timeout=self.timeout, ) if authority else self.authority client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, @@ -275,7 +276,8 @@ def acquire_token_silent( """ assert isinstance(scopes, list), "Invalid parameter type" the_authority = Authority( - authority, verify=self.verify, proxies=self.proxies, + authority, + verify=self.verify, proxies=self.proxies, timeout=self.timeout, ) if authority else self.authority if not force_refresh: @@ -393,7 +395,7 @@ def _acquire_token_by_username_password_federated( if user_realm_result.get("federation_metadata_url"): wstrust_endpoint = mex_send_request( user_realm_result["federation_metadata_url"], - verify=self.verify, proxies=self.proxies) + verify=verify, proxies=proxies) logger.debug("wstrust_endpoint = %s", wstrust_endpoint) wstrust_result = wst_send_request( username, password, user_realm_result.get("cloud_audience_urn"), diff --git a/msal/authority.py b/msal/authority.py index baf86c2e..e575fbe7 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -22,7 +22,7 @@ class Authority(object): TODO: It will also cache the previously-validated authority instances. """ def __init__(self, authority_url, validate_authority=True, - verify=True, proxies=None, + verify=True, proxies=None, timeout=None, ): """Creates an authority instance, and also validates it. @@ -34,6 +34,7 @@ def __init__(self, authority_url, validate_authority=True, """ self.verify = verify self.proxies = proxies + self.timeout = timeout canonicalized, self.instance, tenant = canonicalize(authority_url) tenant_discovery_endpoint = ( # Hard code a V2 pattern as default value 'https://{}/{}/v2.0/.well-known/openid-configuration' @@ -41,9 +42,10 @@ def __init__(self, authority_url, validate_authority=True, if validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS: tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", - verify=verify, proxies=proxies) + verify=verify, proxies=proxies, timeout=timeout) openid_config = tenant_discovery( - tenant_discovery_endpoint, verify=verify, proxies=proxies) + tenant_discovery_endpoint, + verify=verify, proxies=proxies, timeout=timeout) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID @@ -54,7 +56,7 @@ def user_realm_discovery(self, username): "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( netloc=self.instance, username=username), headers={'Accept':'application/json'}, - verify=self.verify, proxies=self.proxies) + verify=self.verify, proxies=self.proxies, timeout=self.timeout) resp.raise_for_status() return resp.json() # It will typically contain "ver", "account_type", @@ -71,20 +73,20 @@ def canonicalize(url): "https://login.microsoftonline.com/" % url) return match_object.group(0), match_object.group(1), match_object.group(2) -def instance_discovery(url, response=None, verify=True, proxies=None): +def instance_discovery(url, response=None, **kwargs): # Returns tenant discovery endpoint resp = requests.get( # Note: This URL seemingly returns V1 endpoint only 'https://{}/common/discovery/instance'.format(WORLD_WIDE), params={'authorization_endpoint': url, 'api-version': '1.0'}, - verify=verify, proxies=proxies) + **kwargs) payload = response or resp.json() if 'tenant_discovery_endpoint' not in payload: raise MsalServiceError(status_code=resp.status_code, **payload) return payload['tenant_discovery_endpoint'] -def tenant_discovery(tenant_discovery_endpoint, verify=True, proxies=None): +def tenant_discovery(tenant_discovery_endpoint, **kwargs): # Returns Openid Configuration - resp = requests.get(tenant_discovery_endpoint, verify=verify, proxies=proxies) + resp = requests.get(tenant_discovery_endpoint, **kwargs) payload = resp.json() if 'authorization_endpoint' in payload and 'token_endpoint' in payload: return payload From 103a52077ce3c54cde890524752d6aecee0ef8f1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 31 Jan 2019 18:06:16 -0800 Subject: [PATCH 026/440] Remove inaccurate RE which came from ADAL Python Porting ADAL Python PR 194 (https://github.com/AzureAD/azure-activedirectory-library-for-python/pull/194) --- msal/wstrust_request.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index f62c2ae9..519ab704 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -27,7 +27,6 @@ import uuid from datetime import datetime, timedelta -import re import logging import requests @@ -44,11 +43,9 @@ def send_request( if not endpoint_address: raise ValueError("WsTrust endpoint address can not be empty") if soap_action is None: - wstrust2005_regex = r'[/trust]?[2005][/usernamemixed]?' - wstrust13_regex = r'[/trust]?[13][/usernamemixed]?' - if re.search(wstrust2005_regex, endpoint_address): + if '/trust/2005/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_2005 - elif re.search(wstrust13_regex, endpoint_address): + elif '/trust/13/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_13 assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005) # A loose check here data = _build_rst( From 123d91edb20bf814aa15c78c009158c8339096f6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 7 Feb 2019 10:51:19 -0800 Subject: [PATCH 027/440] Handle potential race-condition in RT updating --- msal/token_cache.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 556ecd60..2318bc6f 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -56,9 +56,9 @@ def add(self, event): default=str, # A workaround when assertion is in bytes in Python 3 )) response = event.get("response", {}) - access_token = response.get("access_token", {}) - refresh_token = response.get("refresh_token", {}) - id_token = response.get("id_token", {}) + access_token = response.get("access_token") + refresh_token = response.get("refresh_token") + id_token = response.get("id_token") client_info = {} home_account_id = None if "client_info" in response: @@ -169,7 +169,8 @@ def remove_rt(self, rt_item): def update_rt(self, rt_item, new_rt): key = self._build_rt_key(**rt_item) with self._lock: - rt = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] + RTs = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}) + rt = RTs.get(key, {}) # key usually exists, but we'll survive its absence rt["secret"] = new_rt From 6dbd0173655553f01fde1231633d9af6e56c5687 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 8 Feb 2019 19:18:52 -0800 Subject: [PATCH 028/440] Per Unified Schema, change target to be an unsorted string --- msal/token_cache.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 2318bc6f..67d30fad 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -38,11 +38,17 @@ def __init__(self): 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 set(target) <= set(entry.get("target", []))] + and (target_set <= set(entry.get("target", "").split()) + if target else True) + ] def add(self, event): # type: (dict) -> None @@ -67,6 +73,7 @@ def add(self, event): environment = realm = None if "token_endpoint" in event: _, environment, realm = canonicalize(event["token_endpoint"]) + target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: @@ -77,7 +84,7 @@ def add(self, event): self.CredentialType.ACCESS_TOKEN, event.get("client_id", ""), realm or "", - ' '.join(sorted(event.get("scope", []))), + target, ]).lower() now = time.time() self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { @@ -86,7 +93,7 @@ def add(self, event): "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), - "target": event.get("scope"), + "target": target, "realm": realm, "cached_at": now, "expires_on": now + response.get("expires_in", 3599), @@ -132,7 +139,7 @@ def add(self, event): if refresh_token: key = self._build_rt_key( home_account_id, environment, - event.get("client_id", ""), event.get("scope", [])) + event.get("client_id", ""), target) rt = { "credential_type": self.CredentialType.REFRESH_TOKEN, "secret": refresh_token, @@ -140,7 +147,7 @@ def add(self, event): "environment": environment, "client_id": event.get("client_id"), # Fields below are considered optional - "target": event.get("scope"), + "target": target, "client_info": response.get("client_info"), } if "foci" in response: @@ -158,7 +165,7 @@ def _build_rt_key( cls.CredentialType.REFRESH_TOKEN, client_id or "", "", # RT is cross-tenant in AAD - ' '.join(sorted(target or [])), + target, ]).lower() def remove_rt(self, rt_item): From 2862f50c7807f6a412f1bf580fc8ec6a6340b4c2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 14 Feb 2019 17:20:51 -0800 Subject: [PATCH 029/440] Remove optional field client_info --- msal/token_cache.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 67d30fad..47392014 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -116,6 +116,7 @@ def add(self, event): "oid", decoded_id_token.get("sub")), "username": decoded_id_token.get("preferred_username"), "authority_type": "AAD", # Always AAD? + # "client_info": response.get("client_info"), # Optional } if id_token: @@ -146,9 +147,7 @@ def add(self, event): "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), - # Fields below are considered optional - "target": target, - "client_info": response.get("client_info"), + "target": target, # Optional per schema though } if "foci" in response: rt["family_id"] = response["foci"] From e551b340eb85725d3c08717d12c960c33bda24d1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sat, 9 Feb 2019 01:10:50 -0800 Subject: [PATCH 030/440] Add test cases for TokenCache and SerializableTokenCache --- msal/token_cache.py | 8 +-- tests/test_token_cache.py | 119 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/test_token_cache.py diff --git a/msal/token_cache.py b/msal/token_cache.py index 47392014..9bdb115d 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -50,7 +50,7 @@ def find(self, credential_type, target=None, query=None): if target else True) ] - def add(self, event): + def add(self, event, now=None): # type: (dict) -> None # event typically contains: client_id, scope, token_endpoint, # resposne, params, data, grant_type @@ -86,7 +86,7 @@ def add(self, event): realm or "", target, ]).lower() - now = time.time() + now = time.time() if now is None else now self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, @@ -202,8 +202,8 @@ class SerializableTokenCache(TokenCache): Indicates whether the cache state has changed since last :func:`~serialize` or :func:`~deserialize` call. """ - def add(self, event): - super(SerializableTokenCache, self).add(event) + def add(self, event, **kwargs): + super(SerializableTokenCache, self).add(event, **kwargs) self.has_state_changed = True def remove_rt(self, rt_item): diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py new file mode 100644 index 00000000..79fa4ab8 --- /dev/null +++ b/tests/test_token_cache.py @@ -0,0 +1,119 @@ +import logging +import base64 +import json + +from msal.token_cache import * +from tests import unittest + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + + +class TokenCacheTestCase(unittest.TestCase): + + def setUp(self): + self.cache = TokenCache() + + def testAdd(self): + client_info = base64.b64encode(b''' + {"uid": "uid", "utid": "utid"} + ''').decode('utf-8') + id_token = "header.%s.signature" % base64.b64encode(b'''{ + "sub": "subject", + "oid": "object1234", + "preferred_username": "John Doe" + }''').decode('utf-8') + 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": { + "access_token": "an access token", + "token_type": "some type", + "expires_in": 3600, + "refresh_token": "a refresh token", + "client_info": client_info, + "id_token": id_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': 1000, + 'home_account_id': "uid.utid", + 'realm': 'contoso', + 'secret': 'an access token', + 'target': 's2 s1 s3', + }, + 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", + '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': "AAD", + }, + 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') + ) + + +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 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") + From d97e6836712b00aee7d2cfdc0b7329157d51e567 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 21 Feb 2019 16:40:40 -0800 Subject: [PATCH 031/440] Adding SAML 1.1 token types (#20) --- msal/application.py | 7 +++++-- msal/wstrust_response.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 113fe51b..c2431f34 100644 --- a/msal/application.py +++ b/msal/application.py @@ -10,7 +10,7 @@ from .authority import Authority from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request -from .wstrust_response import SAML_TOKEN_TYPE_V1, SAML_TOKEN_TYPE_V2 +from .wstrust_response import * from .token_cache import TokenCache @@ -405,9 +405,12 @@ def _acquire_token_by_username_password_federated( wstrust_endpoint.get("action"), verify=verify, proxies=proxies) 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: 'urn:ietf:params:oauth:grant-type:saml1_1-bearer', + 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( diff --git a/msal/wstrust_response.py b/msal/wstrust_response.py index 00644b13..61458e04 100644 --- a/msal/wstrust_response.py +++ b/msal/wstrust_response.py @@ -37,6 +37,10 @@ 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: @@ -84,6 +88,5 @@ def parse_token_by_re(raw_response): # Returns the saml:assertion token_types = findall_content(rstr, "TokenType") tokens = findall_content(rstr, "RequestedSecurityToken") if token_types and tokens: - assert token_types[0] in (SAML_TOKEN_TYPE_V1, SAML_TOKEN_TYPE_V2) return {"token": tokens[0].encode('us-ascii'), "type": token_types[0]} From 26020da85f25924d53cb5316596fcea7c71d6113 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 26 Feb 2019 14:50:46 -0800 Subject: [PATCH 032/440] Adjusting IdToken key, RT target behavior, and authority_type value --- msal/token_cache.py | 7 +++++-- tests/test_token_cache.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 9bdb115d..2cdedcb3 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -115,7 +115,9 @@ def add(self, event, now=None): "local_account_id": decoded_id_token.get( "oid", decoded_id_token.get("sub")), "username": decoded_id_token.get("preferred_username"), - "authority_type": "AAD", # Always AAD? + "authority_type": + "ADFS" if realm == "adfs" + else "MSSTS", # MSSTS means AAD v2 for both AAD & MSA # "client_info": response.get("client_info"), # Optional } @@ -126,6 +128,7 @@ def add(self, event, now=None): self.CredentialType.ID_TOKEN, event.get("client_id", ""), realm or "", + "" # Albeit irrelevant, schema requires an empty scope here ]).lower() self._cache.setdefault(self.CredentialType.ID_TOKEN, {})[key] = { "credential_type": self.CredentialType.ID_TOKEN, @@ -164,7 +167,7 @@ def _build_rt_key( cls.CredentialType.REFRESH_TOKEN, client_id or "", "", # RT is cross-tenant in AAD - target, + target or "", # raw value could be None if deserialized from other SDK ]).lower() def remove_rt(self, rt_item): diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 79fa4ab8..40a86c07 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -72,7 +72,7 @@ def testAdd(self): 'realm': 'contoso', 'local_account_id': "object1234", 'username': "John Doe", - 'authority_type': "AAD", + 'authority_type': "MSSTS", }, self.cache._cache["Account"].get('uid.utid-login.example.com-contoso') ) @@ -86,7 +86,7 @@ def testAdd(self): 'client_id': 'my_client_id', }, self.cache._cache["IdToken"].get( - 'uid.utid-login.example.com-idtoken-my_client_id-contoso') + 'uid.utid-login.example.com-idtoken-my_client_id-contoso-') ) From c1f21195516618cd6ecdd202c5b8ed132ab88e52 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 26 Feb 2019 14:55:32 -0800 Subject: [PATCH 033/440] Schema defines cached_at, expires_on, ext_expires_on as string --- msal/application.py | 7 +++++-- msal/token_cache.py | 8 +++++--- tests/test_token_cache.py | 6 +++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/msal/application.py b/msal/application.py index 113fe51b..79547c16 100644 --- a/msal/application.py +++ b/msal/application.py @@ -292,12 +292,14 @@ def acquire_token_silent( }) now = time.time() for entry in matches: - if entry["expires_on"] - now < 5*60: + expires_in = int(entry["expires_on"]) - now + if expires_in < 5*60: continue # Removal is not necessary, it will be overwritten + logger.debug("Cache hit an AT") return { # Mimic a real response "access_token": entry["secret"], "token_type": "Bearer", - "expires_in": entry["expires_on"] - now, + "expires_in": int(expires_in), # OAuth2 specs defines it as int } matches = self.token_cache.find( @@ -311,6 +313,7 @@ def acquire_token_silent( }) client = self._build_client(self.client_credential, the_authority) for entry in matches: + logger.debug("Cache hit an RT") response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], scope=decorate_scope(scopes, self.client_id)) diff --git a/msal/token_cache.py b/msal/token_cache.py index 2cdedcb3..dc649919 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -87,6 +87,7 @@ def add(self, event, now=None): target, ]).lower() now = time.time() if now is None else now + expires_in = response.get("expires_in", 3599) self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, @@ -95,9 +96,10 @@ def add(self, event, now=None): "client_id": event.get("client_id"), "target": target, "realm": realm, - "cached_at": now, - "expires_on": now + response.get("expires_in", 3599), - "extended_expires_on": now + response.get("ext_expires_in", 0), + "cached_at": str(int(now)), # Schema defines it as a string + "expires_on": str(int(now + expires_in)), # Same here + "extended_expires_on": str(int( # Same here + now + response.get("ext_expires_in", expires_in))), } if client_info: diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 40a86c07..eebd751d 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -39,12 +39,12 @@ def testAdd(self): }, now=1000) self.assertEqual( { - 'cached_at': 1000, + 'cached_at': "1000", 'client_id': 'my_client_id', 'credential_type': 'AccessToken', 'environment': 'login.example.com', - 'expires_on': 4600, - 'extended_expires_on': 1000, + 'expires_on': "4600", + 'extended_expires_on': "4600", 'home_account_id': "uid.utid", 'realm': 'contoso', 'secret': 'an access token', From fc6d7bb5f1b033caac57ca3ee5fbdb1618c77f0a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 26 Feb 2019 14:57:25 -0800 Subject: [PATCH 034/440] Indentation in serialization for easier debugging --- msal/token_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index dc649919..116be878 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -231,5 +231,5 @@ def serialize(self): """Serialize the current cache state into a string.""" with self._lock: self.has_state_changed = False - return json.dumps(self._cache) + return json.dumps(self._cache, indent=4) From 13cf7a023b0de851adb004e5ba7c7e33e10c461e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 26 Feb 2019 19:10:35 -0800 Subject: [PATCH 035/440] Use case-sensitive scope, reference SerializableTokenCache, and log behaviors for debugging --- sample/client_credential_sample.py | 5 +++-- sample/device_flow_sample.py | 9 +++++---- sample/username_password_sample.py | 9 +++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/sample/client_credential_sample.py b/sample/client_credential_sample.py index cb5ccc26..5f539465 100644 --- a/sample/client_credential_sample.py +++ b/sample/client_credential_sample.py @@ -30,7 +30,8 @@ config["client_id"], authority=config["authority"], client_credential=config["secret"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -42,7 +43,7 @@ result = app.acquire_token_silent(config["scope"], account=None) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 182fcce9..8c46c6b0 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -4,7 +4,7 @@ { "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", - "scope": ["user.read"] + "scope": ["User.Read"] } You can then run this sample with a JSON configuration file: @@ -28,7 +28,8 @@ app = msal.PublicClientApplication( config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -39,7 +40,7 @@ # We now check the cache to see if we have some end users signed in before. accounts = app.get_accounts() if accounts: - # If so, you could then somehow display these accounts and let end user choose + logging.info("Account(s) exists in cache, probably with token too. Let's try.") print("Pick the account you want to use to proceed:") for a in accounts: print(a["username"]) @@ -49,7 +50,7 @@ result = app.acquire_token_silent(config["scope"], account=chosen) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") flow = app.initiate_device_flow(scopes=config["scope"]) print(flow["message"]) # Ideally you should wait here, in order to save some unnecessary polling diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index a34acaee..0137ae6e 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -5,7 +5,7 @@ "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", "username": "your_username@your_tenant.com", - "scope": ["user.read"], + "scope": ["User.Read"], "password": "This is a sample only. You better NOT persist your password." } @@ -30,7 +30,8 @@ app = msal.PublicClientApplication( config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -39,11 +40,11 @@ # Firstly, check the cache to see if this end user has signed in before accounts = app.get_accounts(username=config["username"]) if accounts: - # It means the account(s) exists in cache, probably with token too. Let's try. + logging.info("Account(s) exists in cache, probably with token too. Let's try.") result = app.acquire_token_silent(config["scope"], account=accounts[0]) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") result = app.acquire_token_by_username_password( config["username"], config["password"], scopes=config["scope"]) From e29aa3805a85da7a5e391af60e7bd7f83cc6b338 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 5 Mar 2019 11:41:04 -0800 Subject: [PATCH 036/440] MSAL Python 0.2.0 Bumping the version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 6bb482a5..b667a4a8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -15,7 +15,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.1.0" +__version__ = "0.2.0" logger = logging.getLogger(__name__) From bc26d48863073e56a3e52559e53c67ff5182b0f0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 6 Mar 2019 15:45:14 -0800 Subject: [PATCH 037/440] Mention import for an inline sample --- msal/token_cache.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 116be878..bdecef53 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -192,15 +192,15 @@ class SerializableTokenCache(TokenCache): Depending on your need, the following simple recipe for file-based persistence may be sufficient:: - import atexit - cache = SerializableTokenCache() + import atexit, msal + cache = msal.SerializableTokenCache() cache.deserialize(open("my_cache.bin", "rb").read()) atexit.register(lambda: open("my_cache.bin", "wb").write(cache.serialize()) # Hint: The following optional line persists only when state changed if cache.has_state_changed else None ) - app = ClientApplication(..., token_cache=cache) + app = msal.ClientApplication(..., token_cache=cache) ... :var bool has_state_changed: From 0f36a2d75d586b1348076480393502b4d63318d5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 7 Mar 2019 15:11:33 -0800 Subject: [PATCH 038/440] Fix bug of cache.has_state_changed not being initialized --- msal/token_cache.py | 2 ++ tests/test_token_cache.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/msal/token_cache.py b/msal/token_cache.py index bdecef53..9990db9d 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -207,6 +207,8 @@ class SerializableTokenCache(TokenCache): Indicates whether the cache state 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 diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index eebd751d..ce5c3063 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -106,6 +106,12 @@ def setUp(self): } """) + 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) From 3ccbd0fe5ccd071157bf706d5bd222e934fb9e48 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 7 Mar 2019 19:52:45 -0800 Subject: [PATCH 039/440] Fine tune documentation in SerializableTokenCache --- msal/token_cache.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 9990db9d..c945345a 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -192,11 +192,12 @@ class SerializableTokenCache(TokenCache): Depending on your need, the following simple recipe for file-based persistence may be sufficient:: - import atexit, msal + import os, atexit, msal cache = msal.SerializableTokenCache() - cache.deserialize(open("my_cache.bin", "rb").read()) + if os.path.exists("my_cache.bin"): + cache.deserialize(open("my_cache.bin", "r").read()) atexit.register(lambda: - open("my_cache.bin", "wb").write(cache.serialize()) + 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 ) From 5325137ce04942833b7f6abd66d624c785c97843 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 12 Mar 2019 10:49:05 -0700 Subject: [PATCH 040/440] Fix a missing comma in the inline documentation That line was presumably copied from here https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/v0.2.0/sample/username_password_sample.py So this time we switch the sequence of the 2 lines, so that it will be less likely to go wrong in future documentation copy & paste work flow. --- sample/client_credential_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/client_credential_sample.py b/sample/client_credential_sample.py index 5f539465..59d90b5c 100644 --- a/sample/client_credential_sample.py +++ b/sample/client_credential_sample.py @@ -4,8 +4,8 @@ { "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", + "scope": ["https://graph.microsoft.com/.default"], "secret": "This is a sample only. You better NOT persist your password." - "scope": ["https://graph.microsoft.com/.default"] } You can then run this sample with a JSON configuration file: From 9c792eaa9cf5dcea57689a8bde8b8795f5f07dbc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 20 Mar 2019 11:30:38 -0700 Subject: [PATCH 041/440] Add some convenient hint for username password flow --- sample/username_password_sample.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index 0137ae6e..e7555edd 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -57,4 +57,6 @@ print(result.get("error")) print(result.get("error_description")) print(result.get("correlation_id")) # You may need this when reporting a bug - + if 65001 in result.get("error_codes", []): # Not mean to be coded programatically, but... + # AAD requires user consent for U/P flow + print("Visit this to consent:", app.get_authorization_request_url(scope)) From 316ad08b29f87a085a42c9499e25ee08e9532107 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 21 Mar 2019 14:29:23 -0700 Subject: [PATCH 042/440] Tidy up test_application.py --- tests/test_application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 180bef50..359086ee 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -110,10 +110,10 @@ def test_device_flow(self): CONFIG["client_id"], authority=CONFIG["authority"]) flow = self.app.initiate_device_flow(scopes=CONFIG.get("scope")) assert "user_code" in flow, str(flow) # Provision or policy might block DF - logging.warn(flow["message"]) + logging.warning(flow["message"]) duration = 30 - logging.warn("We will wait up to %d seconds for you to sign in" % duration) + logging.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.app.acquire_token_by_device_flow(flow) self.assertLoosely( @@ -136,7 +136,7 @@ def setUpClass(cls): @unittest.skipUnless("scope" in CONFIG, "Missing scope") def test_auth_code(self): - from oauth2cli.authcode import obtain_auth_code + from msal.oauth2cli.authcode import obtain_auth_code port = CONFIG.get("listen_port", 44331) redirect_uri = "http://localhost:%s" % port auth_request_uri = self.app.get_authorization_request_url( From 2b25c6c504ce4cbf40e1507ce8bf727eabec34c6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 21 Mar 2019 14:31:10 -0700 Subject: [PATCH 043/440] Now get_accounts() ensures proper authority type --- msal/application.py | 12 ++++++++---- msal/token_cache.py | 8 ++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index b667a4a8..9e08227a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -236,17 +236,21 @@ def get_accounts(self, username=None): Your app can choose to display those information to end user, and allow user to choose one of his/her accounts to proceed. """ - # The following implementation finds accounts only from saved accounts, - # but does NOT correlate them with saved RTs. It probably won't matter, - # because in MSAL universe, there are always Accounts and RTs together. - accounts = self.token_cache.find( + accounts = [a for a in self.token_cache.find( # Find all useful accounts self.token_cache.CredentialType.ACCOUNT, query={"environment": self.authority.instance}) + if a["authority_type"] in ( + TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)] 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] + # 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 acquire_token_silent( diff --git a/msal/token_cache.py b/msal/token_cache.py index c945345a..0353f9d0 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -31,6 +31,10 @@ class CredentialType: ACCOUNT = "Account" # Not exactly a credential type, but we put it here ID_TOKEN = "IdToken" + class AuthorityType: + ADFS = "ADFS" + MSSTS = "MSSTS" # MSSTS means AAD v2 for both AAD & MSA + def __init__(self): self._lock = threading.RLock() self._cache = {} @@ -118,8 +122,8 @@ def add(self, event, now=None): "oid", decoded_id_token.get("sub")), "username": decoded_id_token.get("preferred_username"), "authority_type": - "ADFS" if realm == "adfs" - else "MSSTS", # MSSTS means AAD v2 for both AAD & MSA + self.AuthorityType.ADFS if realm == "adfs" + else self.AuthorityType.MSSTS, # "client_info": response.get("client_info"), # Optional } From d6a08568c34ee5ab381fd769fa4423603c38410c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 28 Mar 2019 15:12:59 -0700 Subject: [PATCH 044/440] Cache will record AppMetadata from now on --- msal/token_cache.py | 12 ++++++++++++ tests/test_token_cache.py | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/msal/token_cache.py b/msal/token_cache.py index 0353f9d0..8fd79e59 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -30,6 +30,7 @@ class CredentialType: 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" @@ -162,6 +163,17 @@ def add(self, event, now=None): rt["family_id"] = response["foci"] self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] = rt + key = self._build_appmetadata_key(environment, event.get("client_id")) + self._cache.setdefault(self.CredentialType.APP_METADATA, {})[key] = { + "client_id": event.get("client_id"), + "environment": environment, + "family_id": response.get("foci"), # None is also valid + } + + @staticmethod + def _build_appmetadata_key(environment, client_id): + return "appmetadata-{}-{}".format(environment or "", client_id or "") + @classmethod def _build_rt_key( cls, diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index ce5c3063..1fac231e 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -88,6 +88,15 @@ def testAdd(self): 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', + "family_id": None, + }, + self.cache._cache.get("AppMetadata", {}).get( + "appmetadata-login.example.com-my_client_id") + ) class SerializableTokenCacheTestCase(TokenCacheTestCase): From 0cd047f0b2534287b9c655a794b0e558f941c057 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 28 Mar 2019 15:20:50 -0700 Subject: [PATCH 045/440] Refactor tests to provide 2 helpers --- tests/test_token_cache.py | 59 ++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 1fac231e..f3771c38 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -12,30 +12,57 @@ class TokenCacheTestCase(unittest.TestCase): + @staticmethod + def build_id_token(sub="sub", oid="oid", preferred_username="me", **kwargs): + return "header.%s.signature" % base64.b64encode(json.dumps(dict({ + "sub": sub, + "oid": oid, + "preferred_username": preferred_username, + }, **kwargs)).encode()).decode('utf-8') + + @staticmethod + def build_response( # simulate a response from AAD + uid="uid", utid="utid", # They will form client_info + access_token=None, expires_in=3600, token_type="some type", + refresh_token=None, + foci=None, + id_token=None, # or something generated by build_id_token() + error=None, + ): + response = { + "client_info": base64.b64encode(json.dumps({ + "uid": uid, "utid": utid, + }).encode()).decode('utf-8'), + } + if error: + response["error"] = error + if access_token: + response.update({ + "access_token": access_token, + "expires_in": expires_in, + "token_type": token_type, + }) + if refresh_token: + response["refresh_token"] = refresh_token + if id_token: + response["id_token"] = id_token + if foci: + response["foci"] = foci + return response + def setUp(self): self.cache = TokenCache() def testAdd(self): - client_info = base64.b64encode(b''' - {"uid": "uid", "utid": "utid"} - ''').decode('utf-8') - id_token = "header.%s.signature" % base64.b64encode(b'''{ - "sub": "subject", - "oid": "object1234", - "preferred_username": "John Doe" - }''').decode('utf-8') + id_token = self.build_id_token(oid="object1234", preferred_username="John Doe") 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": { - "access_token": "an access token", - "token_type": "some type", - "expires_in": 3600, - "refresh_token": "a refresh token", - "client_info": client_info, - "id_token": id_token, - }, + "response": self.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( { From a0837c7a2790a12e3166a9c09eb5bd3fc3945339 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 28 Mar 2019 16:28:41 -0700 Subject: [PATCH 046/440] FOCI Single Sign On --- msal/application.py | 63 ++++++++++++++++++++++++----- requirements.txt | 1 + tests/test_application.py | 84 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/msal/application.py b/msal/application.py index 9e08227a..ee440a6d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -305,26 +305,71 @@ def acquire_token_silent( "token_type": "Bearer", "expires_in": int(expires_in), # OAuth2 specs defines it as int } + return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( + the_authority, decorate_scope(scopes, self.client_id), account, + **kwargs) + 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 + } + apps = self.token_cache.find( # Use find(), rather than token_cache.get(...) + TokenCache.CredentialType.APP_METADATA, query={ + "environment": authority.instance, "client_id": self.client_id}) + app_metadata = apps[0] if apps else {} + 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: + return at + if app_metadata.get("family_id"): # Meaning this app belongs to this family + at = self._acquire_token_silent_by_finding_specific_refresh_token( + authority, scopes, dict(query, family_id=app_metadata["family_id"]), + **kwargs) + if 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) + + def _acquire_token_silent_by_finding_specific_refresh_token( + self, authority, scopes, query, + rt_remover=None, break_condition=lambda response: False, **kwargs): matches = self.token_cache.find( self.token_cache.CredentialType.REFRESH_TOKEN, # target=scopes, # AAD RTs are scope-independent - query={ - "client_id": self.client_id, - "environment": the_authority.instance, - "home_account_id": (account or {}).get("home_account_id"), - # "realm": the_authority.tenant, # AAD RTs are tenant-independent - }) - client = self._build_client(self.client_credential, the_authority) + query=query) + logger.debug("Found %d RTs matching %s", len(matches), query) + client = self._build_client(self.client_credential, authority) for entry in matches: - logger.debug("Cache hit an RT") + logger.debug("Cache attempts an RT") response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], - scope=decorate_scope(scopes, self.client_id)) + on_removing_rt=rt_remover or self.token_cache.remove_rt, + scope=scopes, + **kwargs) if "error" not in response: return response logger.debug( "Refresh failed. {error}: {error_description}".format(**response)) + if break_condition(response): + break class PublicClientApplication(ClientApplication): # browser app or mobile app diff --git a/requirements.txt b/requirements.txt index 9c558e35..61a6510d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ . +mock; python_version < '3.3' diff --git a/tests/test_application.py b/tests/test_application.py index 359086ee..29707acd 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2,8 +2,15 @@ import json import logging +try: + from unittest.mock import * # Python 3 +except: + from mock import * # Need an external mock package + from msal.application import * +import msal from tests import unittest +from tests.test_token_cache import TokenCacheTestCase THIS_FOLDER = os.path.dirname(__file__) @@ -155,3 +162,80 @@ def test_auth_code(self): error_description=result.get("error_description"))) self.assertCacheWorks(result) + +class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase): + + def setUp(self): + self.authority_url = "https://login.microsoftonline.com/common" + self.authority = msal.authority.Authority(self.authority_url) + 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.cache.add({ # Pre-populate a FRT + "client_id": "preexisting_family_app", + "scope": self.scopes, + "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), + "response": TokenCacheTestCase.build_response( + 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()) + def tester(url, data=None, **kwargs): + self.assertEqual(self.frt, data.get("refresh_token"), "Should attempt the FRT") + return Mock(status_code=200, json=Mock(return_value={ + "error": "invalid_grant", + "error_description": "Was issued to another client"})) + 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": TokenCacheTestCase.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 Mock(status_code=200, json=Mock(return_value={})) + 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 Mock( + status_code=200, + json=Mock(return_value=TokenCacheTestCase.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. + From b2e154cad2b58552c853fd8a37af35d6341b69b2 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 2 Apr 2019 13:35:20 -0700 Subject: [PATCH 047/440] Reading Authority Aliases (#25) After testing it manually with .NET for cross platform sharing for token cache, we confirmed that the .NET test cases pass :) --- msal/application.py | 72 ++++++++++++++++++++++++++++++++------- tests/test_application.py | 40 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/msal/application.py b/msal/application.py index ee440a6d..624fbe3d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -5,6 +5,9 @@ from urllib.parse import urljoin import logging import sys +import warnings + +import requests from .oauth2cli import Client, JwtSigner from .authority import Authority @@ -101,6 +104,14 @@ def __init__( # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() self.client = self._build_client(client_credential, self.authority) + self.authority_groups = self._get_authority_aliases() + + def _get_authority_aliases(self): + resp = requests.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() + return [set(group['aliases']) for group in resp.json()['metadata']] def _build_client(self, client_credential, authority): client_assertion = None @@ -236,11 +247,15 @@ def get_accounts(self, username=None): Your app can choose to display those information to end user, and allow user to choose one of his/her accounts to proceed. """ - accounts = [a for a in self.token_cache.find( # Find all useful accounts - self.token_cache.CredentialType.ACCOUNT, - query={"environment": self.authority.instance}) - if a["authority_type"] in ( - TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)] + accounts = self._find_msal_accounts(environment=self.authority.instance) + if not accounts: # Now try other aliases of this authority instance + for group in self.authority_groups: + if self.authority.instance in group: + for alias in group: + if alias != 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() @@ -253,6 +268,12 @@ def get_accounts(self, username=None): # apps would fall back to other acquire methods. This is the standard pattern. return accounts + def _find_msal_accounts(self, environment): + return [a for a in self.token_cache.find( + TokenCache.CredentialType.ACCOUNT, query={"environment": environment}) + if a["authority_type"] in ( + TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)] + def acquire_token_silent( self, scopes, # type: List[str] @@ -279,19 +300,44 @@ def acquire_token_silent( - None when cache lookup does not yield anything. """ assert isinstance(scopes, list), "Invalid parameter type" - the_authority = Authority( - authority, - verify=self.verify, proxies=self.proxies, timeout=self.timeout, - ) if authority else self.authority - + if authority: + warnings.warn("We haven't decided how/if this method will accept authority parameter") + # the_authority = Authority( + # authority, + # verify=self.verify, proxies=self.proxies, timeout=self.timeout, + # ) if authority else self.authority + result = self._acquire_token_silent(scopes, account, self.authority, **kwargs) + if result: + return result + for group in self.authority_groups: + if self.authority.instance in group: + for alias in group: + if alias != self.authority.instance: + the_authority = Authority( + "https://" + alias + "/" + self.authority.tenant, + validate_authority=False, + verify=self.verify, proxies=self.proxies, + timeout=self.timeout,) + result = self._acquire_token_silent( + scopes, account, the_authority, **kwargs) + if result: + return result + + def _acquire_token_silent( + self, + scopes, # type: List[str] + account, # type: Optional[Account] + authority, # This can be different than self.authority + force_refresh=False, # type: Optional[boolean] + **kwargs): if not force_refresh: matches = self.token_cache.find( self.token_cache.CredentialType.ACCESS_TOKEN, target=scopes, query={ "client_id": self.client_id, - "environment": the_authority.instance, - "realm": the_authority.tenant, + "environment": authority.instance, + "realm": authority.tenant, "home_account_id": (account or {}).get("home_account_id"), }) now = time.time() @@ -306,7 +352,7 @@ def acquire_token_silent( "expires_in": int(expires_in), # OAuth2 specs defines it as int } return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( - the_authority, decorate_scope(scopes, self.client_id), account, + authority, decorate_scope(scopes, self.client_id), account, **kwargs) def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( diff --git a/tests/test_application.py b/tests/test_application.py index 29707acd..6346774a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -239,3 +239,43 @@ def tester(url, data=None, **kwargs): # Will not test scenario of app leaving family. Per specs, it won't happen. +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": TokenCacheTestCase.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 + + def test_get_accounts(self): + app = ClientApplication( + self.client_id, + authority=self.authority_url_in_app, token_cache=self.cache) + accounts = 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(self): + app = ClientApplication( + self.client_id, + authority=self.authority_url_in_app, token_cache=self.cache) + at = app.acquire_token_silent(self.scopes, self.account) + self.assertNotEqual(None, at) + self.assertEqual(self.access_token, at.get('access_token')) + From 3345c266aa115ba93e7a13704438863b4440b97c Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 2 Apr 2019 13:48:51 -0700 Subject: [PATCH 048/440] MSAL Python 0.3.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 624fbe3d..31560f45 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.2.0" +__version__ = "0.3.0" logger = logging.getLogger(__name__) From ac7519e9d155137aaba819cf7c556ec99532659d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 3 Apr 2019 12:22:16 -0700 Subject: [PATCH 049/440] Add an early link to other ADAL or MSAL SDKs --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a18c8354..8bcb1846 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ by supporting authentication of users with and [Microsoft Accounts](https://account.microsoft.com) using industry standard OAuth2 and OpenID Connect. Soon MSAL Python will also support [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/). -More and more detail about MSAL Python functionality and usage will be documented in the -[Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki). +Not sure whether this is the SDK you are looking for? There are other Microsoft Identity SDKs +[here](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Microsoft-Authentication-Client-Libraries). ## Important Note about the MSAL Preview @@ -29,7 +29,7 @@ as applications written using a preview version of library may no longer work. of your Python environment to a recent version. We tested with pip 18.1. 2. As usual, just run `pip install msal`. -## Usage +## Usage and Samples Before using MSAL Python (or any MSAL SDKs, for that matter), you will have to [register your application with the AAD 2.0 endpoint](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-register-an-app). @@ -86,10 +86,11 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. print(result.get("correlation_id")) # You may need this when reporting a bug ``` -That is it. There will be some variations for different flows. +That is the high level pattern. There will be some variations for different flows. They are demonstrated in +[samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). -## Samples and Documentation +## Documentation The generic documents on [Auth Scenarios](https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-scenarios) @@ -97,9 +98,11 @@ and [Auth protocols](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) are recommended reading. -There is also the [API reference of MSAL Python](https://msal-python.rtfd.io). +There is the [API reference of MSAL Python](https://msal-python.rtfd.io) which documents every parameter of each public method. + +More and more detail about MSAL Python functionality and usage will be documented in the +[Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki). -You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). ## Versions From bca13f63e0a34c853d7e18354d1f7934e9ce1b82 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Apr 2019 12:03:17 -0700 Subject: [PATCH 050/440] Refactor reading authority aliases --- msal/application.py | 55 ++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/msal/application.py b/msal/application.py index 31560f45..7deed189 100644 --- a/msal/application.py +++ b/msal/application.py @@ -104,14 +104,7 @@ def __init__( # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() self.client = self._build_client(client_credential, self.authority) - self.authority_groups = self._get_authority_aliases() - - def _get_authority_aliases(self): - resp = requests.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() - return [set(group['aliases']) for group in resp.json()['metadata']] + self.authority_groups = None def _build_client(self, client_credential, authority): client_assertion = None @@ -249,13 +242,10 @@ def get_accounts(self, username=None): """ accounts = self._find_msal_accounts(environment=self.authority.instance) if not accounts: # Now try other aliases of this authority instance - for group in self.authority_groups: - if self.authority.instance in group: - for alias in group: - if alias != self.authority.instance: - accounts = self._find_msal_accounts(environment=alias) - if accounts: - break + 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() @@ -274,6 +264,19 @@ def _find_msal_accounts(self, environment): if a["authority_type"] in ( TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)] + def _get_authority_aliases(self, instance): + if not self.authority_groups: + resp = requests.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 resp.json()['metadata']] + for group in self.authority_groups: + if instance in group: + return [alias for alias in group if alias != instance] + return [] + def acquire_token_silent( self, scopes, # type: List[str] @@ -309,19 +312,15 @@ def acquire_token_silent( result = self._acquire_token_silent(scopes, account, self.authority, **kwargs) if result: return result - for group in self.authority_groups: - if self.authority.instance in group: - for alias in group: - if alias != self.authority.instance: - the_authority = Authority( - "https://" + alias + "/" + self.authority.tenant, - validate_authority=False, - verify=self.verify, proxies=self.proxies, - timeout=self.timeout,) - result = self._acquire_token_silent( - scopes, account, the_authority, **kwargs) - if result: - return result + for alias in self._get_authority_aliases(self.authority.instance): + the_authority = Authority( + "https://" + alias + "/" + self.authority.tenant, + validate_authority=False, + verify=self.verify, proxies=self.proxies, timeout=self.timeout) + result = self._acquire_token_silent( + scopes, account, the_authority, **kwargs) + if result: + return result def _acquire_token_silent( self, From 5b1c7f943a5c423e24c99f9ba0b5072bf58738ab Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 10 Apr 2019 15:00:30 -0700 Subject: [PATCH 051/440] Improve supportability based on a real case --- msal/application.py | 8 ++++++++ msal/wstrust_request.py | 3 ++- sample/username_password_sample.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 31560f45..a516b9fd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -466,6 +466,9 @@ def acquire_token_by_username_password( self, username, password, scopes=None, **kwargs): """Gets a token for a given resource via user credentails. + 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: @@ -494,6 +497,11 @@ def _acquire_token_by_username_password_federated( wstrust_endpoint = mex_send_request( user_realm_result["federation_metadata_url"], verify=verify, proxies=proxies) + 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"), diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 519ab704..84c03848 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -47,7 +47,8 @@ def send_request( soap_action = Mex.ACTION_2005 elif '/trust/13/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_13 - assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005) # A loose check here + assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005), ( # A loose check here + "Unsupported soap action: %s" % soap_action) data = _build_rst( username, password, cloud_audience_urn, endpoint_address, soap_action) resp = requests.post(endpoint_address, data=data, headers={ diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index e7555edd..055e24d0 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -45,6 +45,8 @@ if not result: logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + # See this page for constraints of Username Password Flow. + # https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication result = app.acquire_token_by_username_password( config["username"], config["password"], scopes=config["scope"]) From 72b82708a8c0aeb96ac821cf2891e954ba5564cf Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 10 Apr 2019 15:14:57 -0700 Subject: [PATCH 052/440] Use case-sensitive scope in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bcb1846..6b0bf225 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. ```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"]) + result = app.acquire_token_by_one_of_the_actual_method(..., scopes=["User.Read"]) if "access_token" in result: print(result["access_token"]) # Yay! else: From 083d8f145d586c090d1f304dd930a6441063dd26 Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Wed, 10 Apr 2019 23:53:06 -0700 Subject: [PATCH 053/440] Adding client assertion type --- msal/application.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/application.py b/msal/application.py index 31560f45..0aeb4b5d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -115,6 +115,7 @@ def _get_authority_aliases(self): def _build_client(self, client_credential, authority): client_assertion = None + client_assertion_type = None default_body = {"client_info": 1} if isinstance(client_credential, dict): assert ("private_key" in client_credential @@ -124,6 +125,7 @@ def _build_client(self, client_credential, authority): sha1_thumbprint=client_credential.get("thumbprint")) client_assertion = signer.sign_assertion( audience=authority.token_endpoint, issuer=self.client_id) + client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: default_body['client_secret'] = client_credential server_configuration = { @@ -142,6 +144,7 @@ def _build_client(self, client_credential, authority): }, default_body=default_body, client_assertion=client_assertion, + client_assertion_type=client_assertion_type, on_obtaining_tokens=self.token_cache.add, on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt, From f835658761989ec24904ba3a215485af2b640678 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 15 Apr 2019 16:00:34 -0700 Subject: [PATCH 054/440] Adding sample for certificate credential flow (#36) --- .../confidential_client_certificate_sample.py | 64 +++++++++++++++++++ ...y => confidential_client_secret_sample.py} | 10 ++- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 sample/confidential_client_certificate_sample.py rename sample/{client_credential_sample.py => confidential_client_secret_sample.py} (74%) diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py new file mode 100644 index 00000000..4c21ee32 --- /dev/null +++ b/sample/confidential_client_certificate_sample.py @@ -0,0 +1,64 @@ +""" +The configuration file would look like this (sans those // comments): + +{ + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "your_client_id", + "scope": ["https://graph.microsoft.com/.default"], + // For more information about scopes for an app, refer: + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate" + + "thumbprint": "790E... The thumbprint generated by AAD when you upload your public cert", + "private_key_file": "filename.pem" + // For information about generating thumbprint and private key file, refer: + // https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate +} + +You can then run this sample with a JSON configuration file: + + python sample.py parameters.json +""" + +import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] +import json +import logging + +import msal + + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) + +config = json.load(open(sys.argv[1])) + +# Create a preferably long-lived app instance which maintains a token cache. +app = msal.ConfidentialClientApplication( + config["client_id"], authority=config["authority"], + client_credential={"thumbprint": config["thumbprint"], "private_key": open(config['private_key_file']).read()}, + # token_cache=... # Default cache is in memory only. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + ) + +# The pattern to acquire a token looks like this. +result = None + +# Firstly, looks up a token from cache +# Since we are looking for token for the current app, NOT for an end user, +# notice we give account parameter as None. +result = app.acquire_token_silent(config["scope"], account=None) + +if not result: + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + result = app.acquire_token_for_client(scopes=config["scope"]) + +if "access_token" in result: + print(result["access_token"]) + print(result["token_type"]) + print(result["expires_in"]) # You don't normally need to care about this. + # It will be good for at least 5 minutes. +else: + print(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug + diff --git a/sample/client_credential_sample.py b/sample/confidential_client_secret_sample.py similarity index 74% rename from sample/client_credential_sample.py rename to sample/confidential_client_secret_sample.py index 59d90b5c..3897c54c 100644 --- a/sample/client_credential_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -1,11 +1,17 @@ """ -The configuration file would look like this: +The configuration file would look like this (sans those // comments): { "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", "scope": ["https://graph.microsoft.com/.default"], - "secret": "This is a sample only. You better NOT persist your password." + // For more information about scopes for an app, refer: + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate" + + "secret": "The secret generated by AAD during your confidential app registration" + // For information about generating client secret, refer: + // https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#registering-client-secrets-using-the-application-registration-portal + } You can then run this sample with a JSON configuration file: From da58c93d98cb868a9e512e05a48e2eb9a8466a79 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 16 Apr 2019 10:58:59 -0700 Subject: [PATCH 055/440] MSAL Python 0.3.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index b055f640..391cd1ae 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.3.0" +__version__ = "0.3.1" logger = logging.getLogger(__name__) From 3661b0909a791cb3bb4a1f5b3620179f85016cbf Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 30 Apr 2019 11:59:57 -0700 Subject: [PATCH 056/440] Authorization code flow sample (#40) --- .../authorization_code_flow_sample.py | 80 +++++++++++++++++++ .../templates/display.html | 19 +++++ 2 files changed, 99 insertions(+) create mode 100644 sample/authorization-code-flow-sample/authorization_code_flow_sample.py create mode 100644 sample/authorization-code-flow-sample/templates/display.html diff --git a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py new file mode 100644 index 00000000..43cc87c2 --- /dev/null +++ b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py @@ -0,0 +1,80 @@ +""" +The configuration file would look like this: + +{ + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "your_client_id", + "scope": ["https://graph.microsoft.com/.default"], + "redirect_uri": "http://localhost:5000/getAToken", + // Configure this redirect uri for this sample + // redirect_uri should match what you've configured in here + // https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#add-redirect-uris-to-your-application + "client_secret": "yoursecret" +} + +You can then run this sample with a JSON configuration file: + python sample.py parameters.json + On the browser open http://localhost:5000/ + +""" + +import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] +import json +import logging +import uuid + +import flask + +import msal + +app = flask.Flask(__name__) +app.debug = True +app.secret_key = 'development' + + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) + +config = json.load(open(sys.argv[1])) + +application = msal.ConfidentialClientApplication( + config["client_id"], authority=config["authority"], + client_credential=config["client_secret"], + # token_cache=... # Default cache is in memory only. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + ) + + +@app.route("/") +def main(): + resp = flask.Response(status=307) + resp.headers['location'] = '/login' + return resp + + +@app.route("/login") +def login(): + auth_state = str(uuid.uuid4()) + flask.session['state'] = auth_state + authorization_url = application.get_authorization_request_url(config['scope'], state=auth_state, + redirect_uri=config['redirect_uri']) + resp = flask.Response(status=307) + resp.headers['location'] = authorization_url + return resp + + +@app.route("/getAToken") +def main_logic(): + code = flask.request.args['code'] + state = flask.request.args['state'] + if state != flask.session['state']: + raise ValueError("State does not match") + + result = application.acquire_token_by_authorization_code(code, scopes=config["scope"], + redirect_uri=config['redirect_uri']) + return flask.render_template('display.html', auth_result=result) + + +if __name__ == "__main__": + app.run() diff --git a/sample/authorization-code-flow-sample/templates/display.html b/sample/authorization-code-flow-sample/templates/display.html new file mode 100644 index 00000000..78522b70 --- /dev/null +++ b/sample/authorization-code-flow-sample/templates/display.html @@ -0,0 +1,19 @@ + + + + + Acquire Token Result + + +Acquire Token Result + +{% for key, value in auth_result.items() %} + + + + +{% endfor %} +
{{ key }} {{ value }}
+ + + \ No newline at end of file From 701ff848f1595616d5954e8b726a79252601b17c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 1 May 2019 16:19:23 -0700 Subject: [PATCH 057/440] WIP: Accurately remove ATs owned by sibling apps --- msal/application.py | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/msal/application.py b/msal/application.py index 391cd1ae..17749801 100644 --- a/msal/application.py +++ b/msal/application.py @@ -280,6 +280,37 @@ def _get_authority_aliases(self, instance): return [alias for alias in group if alias != instance] return [] + def sign_out(self, account): + """Remove all relevant RTs and ATs from token cache""" + owned_by_account = { + "environment": account["environment"], + "home_account_id": (account or {}).get("home_account_id"),} + + owned_by_account_and_app = dict(owned_by_account, client=self.client_id) + for rt in self.token_cache.find( # Remove RTs + TokenCache.CredentialType.REFRESH_TOKEN, + query=owned_by_account_and_app): + self.token_cache.remove_rt(rt) + for at in self.token_cache.find( # Remove ATs + TokenCache.CredentialType.ACCESS_TOKEN, + query=owned_by_account_and_app): # regardless of realm + self.token_cache.remove_at(at) # TODO + + app_metadata = self._get_app_metadata(account["environment"]) + if app_metadata.get("family_id"): # Now let's settle family business + for rt in self.token_cache.find( # Remove FRTs + TokenCache.CredentialType.REFRESH_TOKEN, query=dict( + owned_by_account, + family_id=app_metadata["family_id"])): + self.token_cache.remove_rt(rt) + for sibling_app in self.token_cache.find( # Remove siblings' ATs + TokenCache.CredentialType.APP_METADATA, + query={"family_id": app_metadata.get["family_id"]}): + for at in self.token_cache.find( # Remove ATs, regardless of realm + TokenCache.CredentialType.ACCESS_TOKEN, query=dict( + owned_by_account, client_id=sibling_app["client_id"])): + self.token_cache.remove_at(at) # TODO + def acquire_token_silent( self, scopes, # type: List[str] @@ -364,10 +395,7 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( "home_account_id": (account or {}).get("home_account_id"), # "realm": authority.tenant, # AAD RTs are tenant-independent } - apps = self.token_cache.find( # Use find(), rather than token_cache.get(...) - TokenCache.CredentialType.APP_METADATA, query={ - "environment": authority.instance, "client_id": self.client_id}) - app_metadata = apps[0] if apps else {} + 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. @@ -396,6 +424,12 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( return self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, dict(query, client_id=self.client_id), **kwargs) + 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, **kwargs): From 9e4018b3f269947706bbcbba5cba055220d0a383 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 8 May 2019 11:03:26 -0700 Subject: [PATCH 058/440] Remove all AT, RT, FRT belongs to current account --- msal/application.py | 41 +++++++------------ msal/token_cache.py | 85 +++++++++++++++++++++++++++++---------- tests/test_application.py | 30 ++++++++++++++ 3 files changed, 107 insertions(+), 49 deletions(-) diff --git a/msal/application.py b/msal/application.py index 17749801..4290cba6 100644 --- a/msal/application.py +++ b/msal/application.py @@ -280,36 +280,23 @@ def _get_authority_aliases(self, instance): return [alias for alias in group if alias != instance] return [] - def sign_out(self, account): + def remove_account(self, home_account): """Remove all relevant RTs and ATs from token cache""" owned_by_account = { - "environment": account["environment"], - "home_account_id": (account or {}).get("home_account_id"),} - - owned_by_account_and_app = dict(owned_by_account, client=self.client_id) - for rt in self.token_cache.find( # Remove RTs - TokenCache.CredentialType.REFRESH_TOKEN, - query=owned_by_account_and_app): + "environment": home_account["environment"], + "home_account_id": home_account["home_account_id"],} # realm-independent + for rt in self.token_cache.find( # Remove RTs, and RTs are realm-independent + TokenCache.CredentialType.REFRESH_TOKEN, query=owned_by_account): self.token_cache.remove_rt(rt) - for at in self.token_cache.find( # Remove ATs - TokenCache.CredentialType.ACCESS_TOKEN, - query=owned_by_account_and_app): # regardless of realm - self.token_cache.remove_at(at) # TODO - - app_metadata = self._get_app_metadata(account["environment"]) - if app_metadata.get("family_id"): # Now let's settle family business - for rt in self.token_cache.find( # Remove FRTs - TokenCache.CredentialType.REFRESH_TOKEN, query=dict( - owned_by_account, - family_id=app_metadata["family_id"])): - self.token_cache.remove_rt(rt) - for sibling_app in self.token_cache.find( # Remove siblings' ATs - TokenCache.CredentialType.APP_METADATA, - query={"family_id": app_metadata.get["family_id"]}): - for at in self.token_cache.find( # Remove ATs, regardless of realm - TokenCache.CredentialType.ACCESS_TOKEN, query=dict( - owned_by_account, client_id=sibling_app["client_id"])): - self.token_cache.remove_at(at) # TODO + for at in self.token_cache.find( # Remove ATs, regardless of realm + TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account): + self.token_cache.remove_at(at) + for idt in self.token_cache.find( # Remove IDTs, regardless of realm + TokenCache.CredentialType.ID_TOKEN, query=owned_by_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_account): + self.token_cache.remove_account(a) def acquire_token_silent( self, diff --git a/msal/token_cache.py b/msal/token_cache.py index 8fd79e59..cad8e722 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -83,14 +83,9 @@ def add(self, event, now=None): with self._lock: if access_token: - key = "-".join([ - home_account_id or "", - environment or "", - self.CredentialType.ACCESS_TOKEN, - event.get("client_id", ""), - realm or "", - target, - ]).lower() + key = self._build_at_key( + home_account_id, environment, event.get("client_id", ""), + realm, target) now = time.time() if now is None else now expires_in = response.get("expires_in", 3599) self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { @@ -110,11 +105,7 @@ def add(self, event, now=None): if client_info: decoded_id_token = json.loads( base64decode(id_token.split('.')[1])) if id_token else {} - key = "-".join([ - home_account_id or "", - environment or "", - realm or "", - ]).lower() + key = self._build_account_key(home_account_id, environment, realm) self._cache.setdefault(self.CredentialType.ACCOUNT, {})[key] = { "home_account_id": home_account_id, "environment": environment, @@ -129,14 +120,8 @@ def add(self, event, now=None): } if id_token: - key = "-".join([ - home_account_id or "", - environment or "", - self.CredentialType.ID_TOKEN, - event.get("client_id", ""), - realm or "", - "" # Albeit irrelevant, schema requires an empty scope here - ]).lower() + key = self._build_idt_key( + home_account_id, environment, event.get("client_id", ""), realm) self._cache.setdefault(self.CredentialType.ID_TOKEN, {})[key] = { "credential_type": self.CredentialType.ID_TOKEN, "secret": id_token, @@ -178,7 +163,7 @@ def _build_appmetadata_key(environment, client_id): def _build_rt_key( cls, home_account_id=None, environment=None, client_id=None, target=None, - **ignored): + **ignored_payload_from_a_real_token): return "-".join([ home_account_id or "", environment or "", @@ -189,17 +174,73 @@ def _build_rt_key( ]).lower() def remove_rt(self, rt_item): + assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN key = self._build_rt_key(**rt_item) with self._lock: self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}).pop(key, None) def update_rt(self, rt_item, new_rt): + assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN key = self._build_rt_key(**rt_item) with self._lock: RTs = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}) rt = RTs.get(key, {}) # key usually exists, but we'll survive its absence rt["secret"] = new_rt + @classmethod + def _build_at_key(cls, + home_account_id=None, environment=None, client_id=None, + realm=None, target=None, **ignored_payload_from_a_real_token): + return "-".join([ + home_account_id or "", + environment or "", + cls.CredentialType.ACCESS_TOKEN, + client_id, + realm or "", + target or "", + ]).lower() + + def remove_at(self, at_item): + assert at_item.get("credential_type") == self.CredentialType.ACCESS_TOKEN + key = self._build_at_key(**at_item) + with self._lock: + self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {}).pop(key, None) + + @classmethod + def _build_idt_key(cls, + home_account_id=None, environment=None, client_id=None, realm=None, + **ignored_payload_from_a_real_token): + return "-".join([ + home_account_id or "", + environment or "", + cls.CredentialType.ID_TOKEN, + client_id or "", + realm or "", + "" # Albeit irrelevant, schema requires an empty scope here + ]).lower() + + def remove_idt(self, idt_item): + assert idt_item.get("credential_type") == self.CredentialType.ID_TOKEN + key = self._build_idt_key(**idt_item) + with self._lock: + self._cache.setdefault(self.CredentialType.ID_TOKEN, {}).pop(key, None) + + @classmethod + def _build_account_key(cls, + home_account_id=None, environment=None, realm=None, + **ignored_payload_from_a_real_entry): + return "-".join([ + home_account_id or "", + environment or "", + realm or "", + ]).lower() + + def remove_account(self, account_item): + assert "authority_type" in account_item + key = self._build_account_key(**account_item) + with self._lock: + self._cache.setdefault(self.CredentialType.ACCOUNT, {}).pop(key, None) + class SerializableTokenCache(TokenCache): """This serialization can be a starting point to implement your own persistence. diff --git a/tests/test_application.py b/tests/test_application.py index 6346774a..a542245f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -179,6 +179,8 @@ def setUp(self): "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": TokenCacheTestCase.build_response( + access_token="Siblings won't share AT. test_remove_account() will.", + id_token=TokenCacheTestCase.build_id_token(), uid=self.uid, utid=self.utid, refresh_token=self.frt, foci="1"), }) # The add(...) helper populates correct home_account_id for future searching @@ -239,6 +241,34 @@ def tester(url, data=None, **kwargs): # Will not test scenario of app leaving family. Per specs, it won't happen. + def test_get_remove_account(self): + logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) + app = ClientApplication( + "family_app_2", 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 From fc33c8e59d776a6e521182e2442c20328b7f7c93 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 10 May 2019 14:40:50 -0700 Subject: [PATCH 059/440] Pivot to remove only RTs of this app or its family --- msal/application.py | 43 +++++++++++++++++++++++++++++++-------- tests/test_application.py | 8 +++++--- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/msal/application.py b/msal/application.py index 4290cba6..038badb6 100644 --- a/msal/application.py +++ b/msal/application.py @@ -280,22 +280,47 @@ def _get_authority_aliases(self, instance): return [alias for alias in group if alias != instance] return [] - def remove_account(self, home_account): - """Remove all relevant RTs and ATs from token cache""" - owned_by_account = { + 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 - for rt in self.token_cache.find( # Remove RTs, and RTs are realm-independent - TokenCache.CredentialType.REFRESH_TOKEN, query=owned_by_account): + 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 - TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account): + 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_account): + 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_account): + TokenCache.CredentialType.ACCOUNT, query=owned_by_home_account): self.token_cache.remove_account(a) def acquire_token_silent( diff --git a/tests/test_application.py b/tests/test_application.py index a542245f..75d5d27b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -174,8 +174,9 @@ def setUp(self): 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": "preexisting_family_app", + "client_id": self.preexisting_family_app_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": TokenCacheTestCase.build_response( @@ -241,10 +242,11 @@ def tester(url, data=None, **kwargs): # Will not test scenario of app leaving family. Per specs, it won't happen. - def test_get_remove_account(self): + def test_family_app_remove_account(self): logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) app = ClientApplication( - "family_app_2", authority=self.authority_url, token_cache=self.cache) + 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"]} From 5b5f0525e296e2b66700f26e708fd411945536db Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 13 May 2019 14:03:30 -0700 Subject: [PATCH 060/440] TokenCache now have one modify() to rule them all. --- msal/token_cache.py | 58 ++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index cad8e722..e802eddd 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -39,6 +39,12 @@ class AuthorityType: def __init__(self): self._lock = threading.RLock() self._cache = {} + self.key_makers = { + self.CredentialType.REFRESH_TOKEN: self._build_rt_key, + self.CredentialType.ACCESS_TOKEN: self._build_at_key, + self.CredentialType.ID_TOKEN: self._build_idt_key, + self.CredentialType.ACCOUNT: self._build_account_key, + } def find(self, credential_type, target=None, query=None): target = target or [] @@ -155,6 +161,24 @@ def add(self, event, now=None): "family_id": response.get("foci"), # None is also valid } + 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 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, {}) + entry = entries.get(key, {}) # key usually exists, but we'll survive its absence + entry.update(new_key_value_pairs) + else: # Remove old_entry + self._cache.setdefault(credential_type, {}).pop(key, None) + + @staticmethod def _build_appmetadata_key(environment, client_id): return "appmetadata-{}-{}".format(environment or "", client_id or "") @@ -175,17 +199,12 @@ def _build_rt_key( def remove_rt(self, rt_item): assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN - key = self._build_rt_key(**rt_item) - with self._lock: - self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}).pop(key, None) + 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 - key = self._build_rt_key(**rt_item) - with self._lock: - RTs = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}) - rt = RTs.get(key, {}) # key usually exists, but we'll survive its absence - rt["secret"] = new_rt + return self.modify( + self.CredentialType.REFRESH_TOKEN, rt_item, {"secret": new_rt}) @classmethod def _build_at_key(cls, @@ -202,9 +221,7 @@ def _build_at_key(cls, def remove_at(self, at_item): assert at_item.get("credential_type") == self.CredentialType.ACCESS_TOKEN - key = self._build_at_key(**at_item) - with self._lock: - self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {}).pop(key, None) + return self.modify(self.CredentialType.ACCESS_TOKEN, at_item) @classmethod def _build_idt_key(cls, @@ -221,9 +238,7 @@ def _build_idt_key(cls, def remove_idt(self, idt_item): assert idt_item.get("credential_type") == self.CredentialType.ID_TOKEN - key = self._build_idt_key(**idt_item) - with self._lock: - self._cache.setdefault(self.CredentialType.ID_TOKEN, {}).pop(key, None) + return self.modify(self.CredentialType.ID_TOKEN, idt_item) @classmethod def _build_account_key(cls, @@ -237,9 +252,7 @@ def _build_account_key(cls, def remove_account(self, account_item): assert "authority_type" in account_item - key = self._build_account_key(**account_item) - with self._lock: - self._cache.setdefault(self.CredentialType.ACCOUNT, {}).pop(key, None) + return self.modify(self.CredentialType.ACCOUNT, account_item) class SerializableTokenCache(TokenCache): @@ -262,7 +275,7 @@ class SerializableTokenCache(TokenCache): ... :var bool has_state_changed: - Indicates whether the cache state has changed since last + Indicates whether the cache state in the memory has changed since last :func:`~serialize` or :func:`~deserialize` call. """ has_state_changed = False @@ -271,12 +284,9 @@ def add(self, event, **kwargs): super(SerializableTokenCache, self).add(event, **kwargs) self.has_state_changed = True - def remove_rt(self, rt_item): - super(SerializableTokenCache, self).remove_rt(rt_item) - self.has_state_changed = True - - def update_rt(self, rt_item, new_rt): - super(SerializableTokenCache, self).update_rt(rt_item, new_rt) + 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): From 8efa851c9e5e8fcbebe8af2cfb8220319cab7033 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 22 May 2019 10:33:24 -0700 Subject: [PATCH 061/440] MSAL Python 0.4.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 038badb6..b5911c98 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.3.1" +__version__ = "0.4.0" logger = logging.getLogger(__name__) From 39e2322fa98271d985964178394e5a2d26ff8e4f Mon Sep 17 00:00:00 2001 From: Martin Strobel Date: Thu, 23 May 2019 16:53:07 -0700 Subject: [PATCH 062/440] Updating authority URL regexp to have escaped backslash --- msal/authority.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index e575fbe7..04463704 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -65,7 +65,7 @@ def user_realm_discovery(self, username): def canonicalize(url): # Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors. - match_object = re.match("https://([^/]+)/([^/\?#]+)", url.lower()) + match_object = re.match(r'https://([^/]+)/([^/?#\\]+)', url.lower()) if not match_object: raise ValueError( "Your given address (%s) should consist of " From d39558ec7f556f67042a07e232a55152a781b29f Mon Sep 17 00:00:00 2001 From: Martin Strobel Date: Fri, 24 May 2019 09:57:08 -0700 Subject: [PATCH 063/440] Removing backslash from pattern --- msal/authority.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index 04463704..689ea8cb 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -65,7 +65,7 @@ def user_realm_discovery(self, username): def canonicalize(url): # Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors. - match_object = re.match(r'https://([^/]+)/([^/?#\\]+)', url.lower()) + match_object = re.match(r'https://([^/]+)/([^/?#]+)', url.lower()) if not match_object: raise ValueError( "Your given address (%s) should consist of " From c66850dd3cc2536638fd7b0906708428486feb62 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 24 May 2019 15:22:14 -0700 Subject: [PATCH 064/440] Avoid hardcoding secret, not even a placeholder --- .../authorization_code_flow_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py index 43cc87c2..f583a16e 100644 --- a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py +++ b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py @@ -29,7 +29,7 @@ app = flask.Flask(__name__) app.debug = True -app.secret_key = 'development' +app.secret_key = sys.argv[2] # In this demo, we expect a secret from 2nd CLI param # Optional logging From bf4c08591078dedc4ad0ee54a154acc4b3c99675 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 31 May 2019 14:41:24 -0700 Subject: [PATCH 065/440] Concludes our Log Injection investigation --- msal/token_cache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index e802eddd..2fd8aca8 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -69,7 +69,10 @@ def add(self, event, now=None): if sensitive in event.get("data", {}): # Hide them from accidental exposure in logging event["data"][sensitive] = "********" - logger.debug("event=%s", json.dumps(event, indent=4, sort_keys=True, + 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 )) response = event.get("response", {}) From f8773ab2554fcebf1d07c249042175cde9dc5a7d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 31 May 2019 15:25:38 -0700 Subject: [PATCH 066/440] Older apps on macOS may crash on NSNull from JSON --- msal/token_cache.py | 6 ++++-- tests/test_token_cache.py | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 2fd8aca8..c6dcc40f 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -158,11 +158,13 @@ def add(self, event, now=None): self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] = rt key = self._build_appmetadata_key(environment, event.get("client_id")) - self._cache.setdefault(self.CredentialType.APP_METADATA, {})[key] = { + app_metadata = { "client_id": event.get("client_id"), "environment": environment, - "family_id": response.get("foci"), # None is also valid } + if "foci" in response: + app_metadata["family_id"] = response.get("foci") + self._cache.setdefault(self.CredentialType.APP_METADATA, {})[key] = app_metadata def modify(self, credential_type, old_entry, new_key_value_pairs=None): # Modify the specified old_entry with new_key_value_pairs, diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index f3771c38..c17bd687 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -119,7 +119,6 @@ def testAdd(self): { "client_id": "my_client_id", 'environment': 'login.example.com', - "family_id": None, }, self.cache._cache.get("AppMetadata", {}).get( "appmetadata-login.example.com-my_client_id") From 37eda8cb9de2f107cb82d09646b269890eb51714 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 3 Jun 2019 15:37:39 -0700 Subject: [PATCH 067/440] Also updating inline comments for the secret param --- .../authorization_code_flow_sample.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py index f583a16e..48d32e80 100644 --- a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py +++ b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py @@ -13,8 +13,10 @@ } You can then run this sample with a JSON configuration file: - python sample.py parameters.json - On the browser open http://localhost:5000/ + + python sample.py parameters.json your_flask_session_secret_here + +And the on the browser open http://localhost:5000/ """ From 53e9bf90fad0054ad71075ee2b96cafdb006134a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 3 Jun 2019 22:04:55 -0700 Subject: [PATCH 068/440] Add more test behavior to validate id token behaviors --- msal/token_cache.py | 10 +++------- tests/test_application.py | 2 +- tests/test_token_cache.py | 18 +++++++++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index c6dcc40f..4025a96e 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -2,9 +2,9 @@ import threading import time import logging -import base64 from .authority import canonicalize +from .oauth2cli.oidc import base64decode, decode_id_token logger = logging.getLogger(__name__) @@ -12,10 +12,6 @@ def is_subdict_of(small, big): return dict(big, **small) == big -def base64decode(raw): # This can handle a padding-less raw input - raw += '=' * (-len(raw) % 4) # https://stackoverflow.com/a/32517907/728675 - return base64.b64decode(raw).decode("utf-8") - class TokenCache(object): """This is considered as a base class containing minimal cache behavior. @@ -112,8 +108,8 @@ def add(self, event, now=None): } if client_info: - decoded_id_token = json.loads( - base64decode(id_token.split('.')[1])) if id_token else {} + decoded_id_token = decode_id_token( + id_token, client_id=event["client_id"]) if id_token else {} key = self._build_account_key(home_account_id, environment, realm) self._cache.setdefault(self.CredentialType.ACCOUNT, {})[key] = { "home_account_id": home_account_id, diff --git a/tests/test_application.py b/tests/test_application.py index 75d5d27b..3860c735 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -181,7 +181,7 @@ def setUp(self): "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), "response": TokenCacheTestCase.build_response( access_token="Siblings won't share AT. test_remove_account() will.", - id_token=TokenCacheTestCase.build_id_token(), + id_token=TokenCacheTestCase.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 diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index c17bd687..fecee707 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -1,6 +1,7 @@ import logging import base64 import json +import time from msal.token_cache import * from tests import unittest @@ -13,12 +14,17 @@ class TokenCacheTestCase(unittest.TestCase): @staticmethod - def build_id_token(sub="sub", oid="oid", preferred_username="me", **kwargs): + def build_id_token( + iss="issuer", sub="subject", aud="my_client_id", exp=None, iat=None, + preferred_username="me", **claims): return "header.%s.signature" % base64.b64encode(json.dumps(dict({ + "iss": iss, "sub": sub, - "oid": oid, + "aud": aud, + "exp": exp or (time.time() + 100), + "iat": iat or time.time(), "preferred_username": preferred_username, - }, **kwargs)).encode()).decode('utf-8') + }, **claims)).encode()).decode('utf-8') @staticmethod def build_response( # simulate a response from AAD @@ -54,9 +60,11 @@ def setUp(self): self.cache = TokenCache() def testAdd(self): - id_token = self.build_id_token(oid="object1234", preferred_username="John Doe") + client_id = "my_client_id" + id_token = self.build_id_token( + oid="object1234", preferred_username="John Doe", aud=client_id) self.cache.add({ - "client_id": "my_client_id", + "client_id": client_id, "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", "response": self.build_response( From 6c144c194e855ab2b39cd3cd140f4f7a8a79f322 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 4 Jun 2019 11:00:27 -0700 Subject: [PATCH 069/440] Refactor cache to consolidate the add behavior --- msal/token_cache.py | 130 ++++++++++++++++++-------------------------- 1 file changed, 54 insertions(+), 76 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 4025a96e..e726dc6e 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -36,10 +36,50 @@ def __init__(self): self._lock = threading.RLock() self._cache = {} self.key_makers = { - self.CredentialType.REFRESH_TOKEN: self._build_rt_key, - self.CredentialType.ACCESS_TOKEN: self._build_at_key, - self.CredentialType.ID_TOKEN: self._build_idt_key, - self.CredentialType.ACCOUNT: self._build_account_key, + 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, + 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): @@ -88,12 +128,9 @@ def add(self, event, now=None): with self._lock: if access_token: - key = self._build_at_key( - home_account_id, environment, event.get("client_id", ""), - realm, target) now = time.time() if now is None else now expires_in = response.get("expires_in", 3599) - self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { + at = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, "home_account_id": home_account_id, @@ -106,12 +143,12 @@ def add(self, event, now=None): "extended_expires_on": str(int( # Same here now + response.get("ext_expires_in", expires_in))), } + self.modify(self.CredentialType.ACCESS_TOKEN, at, at) if client_info: decoded_id_token = decode_id_token( id_token, client_id=event["client_id"]) if id_token else {} - key = self._build_account_key(home_account_id, environment, realm) - self._cache.setdefault(self.CredentialType.ACCOUNT, {})[key] = { + account = { "home_account_id": home_account_id, "environment": environment, "realm": realm, @@ -123,11 +160,10 @@ def add(self, event, now=None): else self.AuthorityType.MSSTS, # "client_info": response.get("client_info"), # Optional } + self.modify(self.CredentialType.ACCOUNT, account, account) if id_token: - key = self._build_idt_key( - home_account_id, environment, event.get("client_id", ""), realm) - self._cache.setdefault(self.CredentialType.ID_TOKEN, {})[key] = { + idt = { "credential_type": self.CredentialType.ID_TOKEN, "secret": id_token, "home_account_id": home_account_id, @@ -136,11 +172,9 @@ def add(self, event, now=None): "client_id": event.get("client_id"), # "authority": "it is optional", } + self.modify(self.CredentialType.ID_TOKEN, idt, idt) if refresh_token: - key = self._build_rt_key( - home_account_id, environment, - event.get("client_id", ""), target) rt = { "credential_type": self.CredentialType.REFRESH_TOKEN, "secret": refresh_token, @@ -151,22 +185,21 @@ def add(self, event, now=None): } if "foci" in response: rt["family_id"] = response["foci"] - self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] = rt + self.modify(self.CredentialType.REFRESH_TOKEN, rt, rt) - key = self._build_appmetadata_key(environment, event.get("client_id")) app_metadata = { "client_id": event.get("client_id"), "environment": environment, } if "foci" in response: app_metadata["family_id"] = response.get("foci") - self._cache.setdefault(self.CredentialType.APP_METADATA, {})[key] = app_metadata + 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 modify/remove behaviors, + # 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. @@ -174,30 +207,11 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): with self._lock: if new_key_value_pairs: # Update with them entries = self._cache.setdefault(credential_type, {}) - entry = entries.get(key, {}) # key usually exists, but we'll survive its absence + entry = entries.setdefault(key, {}) # Create it if not yet exist entry.update(new_key_value_pairs) else: # Remove old_entry self._cache.setdefault(credential_type, {}).pop(key, None) - - @staticmethod - def _build_appmetadata_key(environment, client_id): - return "appmetadata-{}-{}".format(environment or "", client_id or "") - - @classmethod - def _build_rt_key( - cls, - home_account_id=None, environment=None, client_id=None, target=None, - **ignored_payload_from_a_real_token): - return "-".join([ - home_account_id or "", - environment or "", - cls.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() - 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) @@ -207,50 +221,14 @@ def update_rt(self, rt_item, new_rt): return self.modify( self.CredentialType.REFRESH_TOKEN, rt_item, {"secret": new_rt}) - @classmethod - def _build_at_key(cls, - home_account_id=None, environment=None, client_id=None, - realm=None, target=None, **ignored_payload_from_a_real_token): - return "-".join([ - home_account_id or "", - environment or "", - cls.CredentialType.ACCESS_TOKEN, - client_id, - realm or "", - target or "", - ]).lower() - 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) - @classmethod - def _build_idt_key(cls, - home_account_id=None, environment=None, client_id=None, realm=None, - **ignored_payload_from_a_real_token): - return "-".join([ - home_account_id or "", - environment or "", - cls.CredentialType.ID_TOKEN, - client_id or "", - realm or "", - "" # Albeit irrelevant, schema requires an empty scope here - ]).lower() - 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) - @classmethod - def _build_account_key(cls, - home_account_id=None, environment=None, realm=None, - **ignored_payload_from_a_real_entry): - return "-".join([ - home_account_id or "", - environment or "", - realm or "", - ]).lower() - def remove_account(self, account_item): assert "authority_type" in account_item return self.modify(self.CredentialType.ACCOUNT, account_item) From 5b2492a3aa9b023fd7dc22e0760a42be6b6b78ac Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 6 Jun 2019 11:41:17 -0700 Subject: [PATCH 070/440] ensure addends are ints --- msal/token_cache.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index e726dc6e..6d241c30 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -1,4 +1,4 @@ -import json +import json import threading import time import logging @@ -128,8 +128,9 @@ def add(self, event, now=None): with self._lock: if access_token: - now = time.time() if now is None else now - expires_in = response.get("expires_in", 3599) + now = int(time.time()) if now is None else int(now) + expires_in = int(response.get("expires_in", 3599)) + ext_expires_in = int(response.get("ext_expires_in", expires_in)) at = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, @@ -138,10 +139,9 @@ def add(self, event, now=None): "client_id": event.get("client_id"), "target": target, "realm": realm, - "cached_at": str(int(now)), # Schema defines it as a string - "expires_on": str(int(now + expires_in)), # Same here - "extended_expires_on": str(int( # Same here - now + response.get("ext_expires_in", expires_in))), + "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 } self.modify(self.CredentialType.ACCESS_TOKEN, at, at) From 555a4741d2f230c05e747015bb02965bc20cefa5 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 6 Jun 2019 11:42:56 -0700 Subject: [PATCH 071/440] str.join raises when passed None --- msal/token_cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 6d241c30..ac96424c 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -1,4 +1,4 @@ -import json +import json import threading import time import logging @@ -54,7 +54,7 @@ def __init__(self): home_account_id or "", environment or "", self.CredentialType.ACCESS_TOKEN, - client_id, + client_id or "", realm or "", target or "", ]).lower(), From 53282b04942393108ed0e15883b705b6c5049c69 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Jun 2019 12:42:32 -0700 Subject: [PATCH 072/440] Explain the reason of this subtle change --- msal/token_cache.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index ac96424c..358b064b 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -128,9 +128,11 @@ def add(self, event, now=None): with self._lock: if access_token: - now = int(time.time()) if now is None else int(now) - expires_in = int(response.get("expires_in", 3599)) - ext_expires_in = int(response.get("ext_expires_in", expires_in)) + now = int(time.time() if now is None else now) + 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, From b802c1d2cc907a213fa8c31adf2601c39c37384b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 17 Jun 2019 13:50:53 -0700 Subject: [PATCH 073/440] Support sovereign cloud. Previous implementation was largely based on some hallway communication, which happened to not work in sovereign scenario. Neither did we test sovereign scenario for MSAL Python, until now. --- msal/authority.py | 11 +++++++++-- tests/test_authority.py | 32 ++++++++------------------------ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 689ea8cb..51289d2c 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -1,10 +1,12 @@ import re +import logging import requests from .exceptions import MsalServiceError +logger = logging.getLogger(__name__) WORLD_WIDE = 'login.microsoftonline.com' # There was an alias login.windows.net WELL_KNOWN_AUTHORITY_HOSTS = set([ WORLD_WIDE, @@ -38,7 +40,7 @@ def __init__(self, authority_url, validate_authority=True, canonicalized, self.instance, tenant = canonicalize(authority_url) tenant_discovery_endpoint = ( # Hard code a V2 pattern as default value 'https://{}/{}/v2.0/.well-known/openid-configuration' - .format(WORLD_WIDE, tenant)) + .format(self.instance, tenant)) if validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS: tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", @@ -46,6 +48,7 @@ def __init__(self, authority_url, validate_authority=True, openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) + logger.debug("openid_config = %s", openid_config) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID @@ -76,7 +79,11 @@ def canonicalize(url): def instance_discovery(url, response=None, **kwargs): # Returns tenant discovery endpoint resp = requests.get( # Note: This URL seemingly returns V1 endpoint only - 'https://{}/common/discovery/instance'.format(WORLD_WIDE), + '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) payload = response or resp.json() diff --git a/tests/test_authority.py b/tests/test_authority.py index 41714552..d7fc5cac 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -4,24 +4,16 @@ class TestAuthority(unittest.TestCase): - COMMON_AUTH_ENDPOINT = \ - 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' - COMMON_TOKEN_ENDPOINT = \ - 'https://login.microsoftonline.com/common/oauth2/v2.0/token' def test_wellknown_host_and_tenant(self): - # Test one specific sample in straightforward way, for readability - a = Authority('https://login.microsoftonline.com/common') - self.assertEqual(a.authorization_endpoint, self.COMMON_AUTH_ENDPOINT) - self.assertEqual(a.token_endpoint, self.COMMON_TOKEN_ENDPOINT) - - # Test all well known authority hosts, using same real "common" tenant + # Assert all well known authority hosts are using their own "common" tenant for host in WELL_KNOWN_AUTHORITY_HOSTS: a = Authority('https://{}/common'.format(host)) - # Note: this "common" tenant endpoints always point to its real host self.assertEqual( - a.authorization_endpoint, self.COMMON_AUTH_ENDPOINT) - self.assertEqual(a.token_endpoint, self.COMMON_TOKEN_ENDPOINT) + a.authorization_endpoint, + 'https://%s/common/oauth2/v2.0/authorize' % host) + self.assertEqual( + a.token_endpoint, 'https://%s/common/oauth2/v2.0/token' % host) @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): @@ -33,20 +25,12 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): self.assertEqual(a.token_endpoint, v1_token_endpoint) self.assertNotIn('v2.0', a.token_endpoint) - def test_unknown_host(self): + def test_unknown_host_wont_pass_instance_discovery(self): with self.assertRaisesRegexp(MsalServiceError, "invalid_instance"): Authority('https://unknown.host/tenant_doesnt_matter_in_this_case') - def test_unknown_host_valid_tenant_and_skip_host_validation(self): - # When skipping host (a.k.a. instance) validation, - # the Tenant Discovery will always use WORLD_WIDE service as instance, - # so, if the tenant happens to exist there, it will find some endpoints. - a = Authority('https://incorrect.host/common', validate_authority=False) - self.assertEqual(a.authorization_endpoint, self.COMMON_AUTH_ENDPOINT) - self.assertEqual(a.token_endpoint, self.COMMON_TOKEN_ENDPOINT) - - def test_unknown_host_unknown_tenant_and_skip_host_validation(self): - with self.assertRaisesRegexp(MsalServiceError, "invalid_tenant"): + def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(self): + with self.assertRaises(requests.exceptions.RequestException): Authority('https://unknown.host/invalid', validate_authority=False) From 628c9a91bd24656346753cce028b1a8d9f8b571d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 17 Jun 2019 16:14:01 -0700 Subject: [PATCH 074/440] MSAL Python 0.4.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index b5911c98..b1a7ab75 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.4.0" +__version__ = "0.4.1" logger = logging.getLogger(__name__) From dc859b4c492213b18184b36c2008572cc8aae715 Mon Sep 17 00:00:00 2001 From: Jann Roder Date: Mon, 20 May 2019 09:17:28 +0100 Subject: [PATCH 075/440] * Pass through verify, timeout and proxies flag --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index b5911c98..5b78d54c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -271,7 +271,7 @@ def _get_authority_aliases(self, instance): if not self.authority_groups: resp = requests.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'}) + headers={'Accept': 'application/json'}, verify=self.verify, proxies=self.proxies, timeout=self.timeout) resp.raise_for_status() self.authority_groups = [ set(group['aliases']) for group in resp.json()['metadata']] From bbaaedff96f9e21f8820faa211c45398233a2949 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Jun 2019 09:04:55 -0700 Subject: [PATCH 076/440] Shorten a line to within 80 chars Although not strictly enforced (by pylint) in this repo, we don't want to noticeably exceed that limit. https://www.python.org/dev/peps/pep-0008/#maximum-line-length --- msal/application.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 5b78d54c..c91587c7 100644 --- a/msal/application.py +++ b/msal/application.py @@ -271,7 +271,8 @@ def _get_authority_aliases(self, instance): if not self.authority_groups: resp = requests.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'}, verify=self.verify, proxies=self.proxies, timeout=self.timeout) + headers={'Accept': 'application/json'}, + verify=self.verify, proxies=self.proxies, timeout=self.timeout) resp.raise_for_status() self.authority_groups = [ set(group['aliases']) for group in resp.json()['metadata']] From 627a861a2a2b942588fcc14dff3f903ab95bb4f0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Jun 2019 09:28:12 -0700 Subject: [PATCH 077/440] Change an optional scopes parameter to be required The previous default None value would cause an exception in our code decorate_scope(). The fix is to change the previously optional scopes parameter to required. This kind of api surface change would usually be a breaking change, but in this case it is not, because the previous default value would cause exception so it was in fact required. FWIW, you can still explicitly use empty list [] as scope, and the response would contain id_token and refresh_token, but no access_token. --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index c91587c7..4dc24afa 100644 --- a/msal/application.py +++ b/msal/application.py @@ -512,7 +512,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs): **kwargs) def acquire_token_by_username_password( - self, username, password, scopes=None, **kwargs): + self, username, password, scopes, **kwargs): """Gets a token for a given resource via user credentails. See this page for constraints of Username Password Flow. From 814a5b72615ed4eb80d561a19d047076b353cc26 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Jun 2019 11:49:45 -0700 Subject: [PATCH 078/440] Fixing improper logger name in unit test --- tests/test_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_application.py b/tests/test_application.py index 3860c735..33231d3b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -20,7 +20,7 @@ with open(CONFIG_FILE) as conf: CONFIG = json.load(conf) -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) From 8922be25fadb3912df1926c5c3cda9ca1914fba9 Mon Sep 17 00:00:00 2001 From: Jann Roder Date: Mon, 24 Jun 2019 18:10:43 +0100 Subject: [PATCH 079/440] Exclude tests from pip package and add license file --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c1e0b7ac..06256333 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,8 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], - packages=find_packages(), + packages=find_packages(exclude=["tests"]), + data_files=[('', ['LICENSE'])], install_requires=[ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<2', From cd7dba0363c1a598c2a38477469456299510f435 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 25 Jun 2019 16:18:59 -0700 Subject: [PATCH 080/440] Mention how to customize the device flow block time --- sample/device_flow_sample.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 8c46c6b0..21923ef7 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -56,6 +56,10 @@ # Ideally you should wait here, in order to save some unnecessary polling # input("Press Enter after you successfully login from another device...") result = app.acquire_token_by_device_flow(flow) # By default it will block + # You can follow this instruction to shorten the block time + # https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.acquire_token_by_device_flow + # or you may even turn off the blocking behavior, + # and then keep calling acquire_token_by_device_flow(flow) in your own customized loop. if "access_token" in result: print(result["access_token"]) From 2e01dbd195085cadd2737b88a79fc66c9b835956 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Wed, 3 Jul 2019 09:56:36 -0700 Subject: [PATCH 081/440] Subject Name and Issuer Authentication (#71) --- msal/application.py | 27 ++++++++++++++++++++++++-- tests/test_application.py | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 6efde65b..42c12f96 100644 --- a/msal/application.py +++ b/msal/application.py @@ -50,6 +50,22 @@ def decorate_scope( return list(decorated) +def extract_certs(public_cert_content): + # Parses raw public certificate file contents and returns a list of strings + # Usage: headers = {"x5c": extract_certs(open("my_cert.pem").read())} + public_certificates = re.findall( + r'-----BEGIN CERTIFICATE-----(?P[^-]+)-----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()] + + class ClientApplication(object): def __init__( @@ -59,7 +75,7 @@ def __init__( verify=True, proxies=None, timeout=None): """Create an instance of application. - :param client_id: Your app has a clinet_id after you register it on AAD. + :param client_id: Your app has a client_id after you register it on AAD. :param client_credential: For :class:`PublicClientApplication`, you simply use `None` here. For :class:`ConfidentialClientApplication`, @@ -69,8 +85,12 @@ def __init__( { "private_key": "...-----BEGIN PRIVATE KEY-----...", "thumbprint": "A1B2C3D4E5F6...", + "public_certificate": "...-----BEGIN CERTIFICATE-----..." (Optional. See below.) } + public_certificate (optional) is public key certificate which is + sent through 'x5c' JWT header only for + subject name and issuer authentication to support cert auto rolls :param str authority: A URL that identifies a token authority. It should be of the format https://login.microsoftonline.com/your_tenant @@ -113,9 +133,12 @@ def _build_client(self, client_credential, authority): if isinstance(client_credential, dict): assert ("private_key" in client_credential and "thumbprint" in client_credential) + headers = {} + if 'public_certificate' in client_credential: + headers["x5c"] = extract_certs(client_credential['public_certificate']) signer = JwtSigner( client_credential["private_key"], algorithm="RS256", - sha1_thumbprint=client_credential.get("thumbprint")) + sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) client_assertion = signer.sign_assertion( audience=authority.token_endpoint, issuer=self.client_id) client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT diff --git a/tests/test_application.py b/tests/test_application.py index 33231d3b..5e4c3b3a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -99,6 +99,46 @@ def test_client_certificate(self): self.assertIn('access_token', result) self.assertCacheWorks(result, app.acquire_token_silent(scope, account=None)) + 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)) + + @unittest.skipUnless("public_certificate" in CONFIG, "Missing Public cert") + def test_subject_name_issuer_authentication(self): + assert ("private_key_file" in CONFIG + and "thumbprint" in CONFIG and "public_certificate" in CONFIG) + with open(os.path.join(THIS_FOLDER, CONFIG['private_key_file'])) as f: + pem = f.read() + with open(os.path.join(THIS_FOLDER, CONFIG['public_certificate'])) as f: + public_certificate = f.read() + app = ConfidentialClientApplication( + CONFIG['client_id'], authority=CONFIG["authority"], + client_credential={"private_key": pem, "thumbprint": CONFIG["thumbprint"], + "public_certificate": public_certificate}) + scope = CONFIG.get("scope", []) + result = app.acquire_token_for_client(scope) + self.assertIn('access_token', result) + self.assertCacheWorks(result, app.acquire_token_silent(scope, account=None)) @unittest.skipUnless("client_id" in CONFIG, "client_id missing") class TestPublicClientApplication(Oauth2TestCase): From b11bf7e08e2997d1780b7501c7ec8fdda3e48f28 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 3 Jul 2019 14:50:17 -0700 Subject: [PATCH 082/440] Flask sample reads secret from env var rather than cli --- .../authorization_code_flow_sample.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py index 48d32e80..eea11dff 100644 --- a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py +++ b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py @@ -24,6 +24,7 @@ import json import logging import uuid +import os import flask @@ -31,7 +32,8 @@ app = flask.Flask(__name__) app.debug = True -app.secret_key = sys.argv[2] # In this demo, we expect a secret from 2nd CLI param +app.secret_key = os.environ.get("FLASK_SECRET") +assert app.secret_key, "This sample requires a FLASK_SECRET env var to enable session" # Optional logging From 40857677a9fba237346df69e8ecc3a0e7e801245 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 3 Jul 2019 16:04:40 -0700 Subject: [PATCH 083/440] ConfidentialClientApplication(..., client_claims=...,) (#68) --- msal/application.py | 23 +++++++++++++++++++++-- tests/test_assertion.py | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/test_assertion.py diff --git a/msal/application.py b/msal/application.py index 42c12f96..84493340 100644 --- a/msal/application.py +++ b/msal/application.py @@ -72,7 +72,8 @@ def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, token_cache=None, - verify=True, proxies=None, timeout=None): + verify=True, proxies=None, timeout=None, + client_claims=None): """Create an instance of application. :param client_id: Your app has a client_id after you register it on AAD. @@ -91,6 +92,22 @@ def __init__( public_certificate (optional) is public key certificate which is sent through 'x5c' JWT header only for subject name and issuer authentication to support cert auto rolls + + :param dict client_claims: + 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 @@ -115,6 +132,7 @@ def __init__( """ self.client_id = client_id self.client_credential = client_credential + self.client_claims = client_claims self.verify = verify self.proxies = proxies self.timeout = timeout @@ -140,7 +158,8 @@ def _build_client(self, client_credential, authority): client_credential["private_key"], algorithm="RS256", sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) client_assertion = signer.sign_assertion( - audience=authority.token_endpoint, issuer=self.client_id) + audience=authority.token_endpoint, issuer=self.client_id, + additional_claims=self.client_claims or {}) client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT else: default_body['client_secret'] = client_credential diff --git a/tests/test_assertion.py b/tests/test_assertion.py new file mode 100644 index 00000000..a4921138 --- /dev/null +++ b/tests/test_assertion.py @@ -0,0 +1,15 @@ +import json + +from msal.oauth2cli import JwtSigner +from msal.oauth2cli.oidc import base64decode + +from tests import unittest + + +class AssertionTestCase(unittest.TestCase): + def test_extra_claims(self): + assertion = JwtSigner(key=None, algorithm="none").sign_assertion( + "audience", "issuer", additional_claims={"client_ip": "1.2.3.4"}) + payload = json.loads(base64decode(assertion.split(b'.')[1].decode('utf-8'))) + self.assertEqual("1.2.3.4", payload.get("client_ip")) + From eacd04a303d8ae5e725a05b135db5b1db2cf25de Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Jul 2019 11:31:49 -0700 Subject: [PATCH 084/440] ReST annoyingly needs double colons for code block --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 84493340..a768fe9c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -97,7 +97,7 @@ def __init__( 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: + You may also override any of the following default claims:: { "aud": the_token_endpoint, From ad78a8ba35001992a76454fea9659979f1e03a6b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Jul 2019 10:48:37 -0700 Subject: [PATCH 085/440] v0.3.0 with lazy and regenerative assertion creators --- msal/oauth2cli/__init__.py | 5 +-- msal/oauth2cli/assertion.py | 66 +++++++++++++++++++++++++++++++------ msal/oauth2cli/oauth2.py | 21 ++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/msal/oauth2cli/__init__.py b/msal/oauth2cli/__init__.py index 2129912b..b8941361 100644 --- a/msal/oauth2cli/__init__.py +++ b/msal/oauth2cli/__init__.py @@ -1,5 +1,6 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" from .oidc import Client -from .assertion import JwtSigner +from .assertion import JwtAssertionCreator +from .assertion import JwtSigner # Obsolete. For backward compatibility. diff --git a/msal/oauth2cli/assertion.py b/msal/oauth2cli/assertion.py index bd2373a7..e84400df 100644 --- a/msal/oauth2cli/assertion.py +++ b/msal/oauth2cli/assertion.py @@ -9,17 +9,57 @@ logger = logging.getLogger(__name__) -class Signer(object): - def sign_assertion( - self, audience, issuer, subject, expires_at, +class AssertionCreator(object): + def create_normal_assertion( + self, audience, issuer, subject, expires_at=None, expires_in=600, issued_at=None, assertion_id=None, **kwargs): - # Names are defined in https://tools.ietf.org/html/rfc7521#section-5 + """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:: -class JwtSigner(Signer): + 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): - """Create a signer. + """Construct a Jwt assertion creator. Args: @@ -37,11 +77,11 @@ def __init__(self, key, algorithm, sha1_thumbprint=None, headers=None): self.headers["x5t"] = base64.urlsafe_b64encode( binascii.a2b_hex(sha1_thumbprint)).decode() - def sign_assertion( - self, audience, issuer, subject=None, expires_at=None, + 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): - """Sign a JWT Assertion. + """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. @@ -51,7 +91,7 @@ def sign_assertion( 'aud': audience, 'iss': issuer, 'sub': subject or issuer, - 'exp': expires_at or (now + 10*60), # 10 minutes + 'exp': expires_at or (now + expires_in), 'iat': issued_at or now, 'jti': assertion_id or str(uuid.uuid4()), } @@ -68,3 +108,9 @@ def sign_assertion( '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 + diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index b9727cf5..918fb806 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -33,7 +33,7 @@ def __init__( server_configuration, # type: dict client_id, # type: str client_secret=None, # type: Optional[str] - client_assertion=None, # type: Optional[bytes] + 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] @@ -55,10 +55,12 @@ def __init__( https://example.com/.../.well-known/openid-configuration client_id (str): The client's id, issued by the authorization server client_secret (str): Triggers HTTP AUTH for Confidential Client - client_assertion (bytes): + client_assertion (bytes, callable): The client assertion to authenticate this client, per RFC 7521. It can be a raw SAML2 assertion (this method will encode it for you), or a raw JWT assertion. + 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 @@ -75,11 +77,9 @@ def __init__( self.configuration = server_configuration self.client_id = client_id self.client_secret = client_secret + self.client_assertion = client_assertion self.default_body = default_body or {} - if client_assertion is not None and client_assertion_type is not None: - # See https://tools.ietf.org/html/rfc7521#section-4.2 - encoder = self.client_assertion_encoders.get(client_assertion_type, lambda a: a) - self.default_body["client_assertion"] = encoder(client_assertion) + if client_assertion_type is not None: self.default_body["client_assertion_type"] = client_assertion_type self.logger = logging.getLogger(__name__) self.session = s = requests.Session() @@ -114,6 +114,15 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 **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) + _data.update(self.default_body) # It may contain authen parameters _data.update(data or {}) # So the content in data param prevails # We don't have to clean up None values here, because requests lib will. From ae0b032d60d8ec800a4a0334b12b448894fcad84 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Jul 2019 11:09:49 -0700 Subject: [PATCH 086/440] Refactor confidential client --- msal/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index a768fe9c..249da938 100644 --- a/msal/application.py +++ b/msal/application.py @@ -9,7 +9,7 @@ import requests -from .oauth2cli import Client, JwtSigner +from .oauth2cli import Client, JwtAssertionCreator from .authority import Authority from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request @@ -154,10 +154,10 @@ def _build_client(self, client_credential, authority): headers = {} if 'public_certificate' in client_credential: headers["x5c"] = extract_certs(client_credential['public_certificate']) - signer = JwtSigner( + assertion = JwtAssertionCreator( client_credential["private_key"], algorithm="RS256", sha1_thumbprint=client_credential.get("thumbprint"), headers=headers) - client_assertion = signer.sign_assertion( + client_assertion = assertion.create_regenerative_assertion( audience=authority.token_endpoint, issuer=self.client_id, additional_claims=self.client_claims or {}) client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT From 928776d4531873dc148881661f440fd5b54b6691 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Jul 2019 11:48:30 -0700 Subject: [PATCH 087/440] MSAL Python 0.5.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 249da938..cae73636 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.4.1" +__version__ = "0.5.0" logger = logging.getLogger(__name__) From c28f27ab5297a532aa471c31fa1dfc7946a1dd3e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Jul 2019 12:00:42 -0700 Subject: [PATCH 088/440] Update application.py --- msal/application.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index cae73636..0881c613 100644 --- a/msal/application.py +++ b/msal/application.py @@ -89,11 +89,13 @@ def __init__( "public_certificate": "...-----BEGIN CERTIFICATE-----..." (Optional. See below.) } - public_certificate (optional) is public key certificate which is - sent through 'x5c' JWT header only for - subject name and issuer authentication to support cert auto rolls + *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. :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"}. From b4fdec386735777c62d09c0c16b56c7c668ceb4b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 10 Jul 2019 13:57:35 -0700 Subject: [PATCH 089/440] Pack LICENSE in a different way (#74) We already did lots of experiments in a downstream repo to test this. --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 06256333..0c26e5da 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,10 @@ 'Operating System :: OS Independent', ], packages=find_packages(exclude=["tests"]), - data_files=[('', ['LICENSE'])], + package_data={'': ['LICENSE']}, # Do not use data_files=[...], + # which would cause the LICENSE being copied to /usr/local, + # and tend to fail because of insufficient permission. + # See https://stackoverflow.com/a/14211600/728675 for more detail install_requires=[ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<2', From 52dbe6b973f4316ef6d121a7c636439ee75eb5ec Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 10 Jul 2019 14:50:28 -0700 Subject: [PATCH 090/440] MSAL Python 0.5.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 0881c613..448d2343 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.5.0" +__version__ = "0.5.1" logger = logging.getLogger(__name__) From db3d41d94dd8297bfbc64439e624a0d95a151ffc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 15 Jul 2019 12:47:49 -0700 Subject: [PATCH 091/440] Adjusting token cache to work with ADFS2019 --- msal/token_cache.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 358b064b..a514de3c 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -111,18 +111,25 @@ def add(self, event, now=None): event, indent=4, sort_keys=True, default=str, # A workaround when assertion is in bytes in Python 3 )) + environment = realm = None + if "token_endpoint" in event: + _, environment, realm = canonicalize(event["token_endpoint"]) response = event.get("response", {}) access_token = response.get("access_token") refresh_token = response.get("refresh_token") id_token = response.get("id_token") - client_info = {} - home_account_id = None - if "client_info" in response: - client_info = json.loads(base64decode(response["client_info"])) - home_account_id = "{uid}.{utid}".format(**client_info) - environment = realm = None - if "token_endpoint" in event: - _, environment, realm = canonicalize(event["token_endpoint"]) + id_token_claims = ( + decode_id_token(id_token, client_id=event["client_id"]) + if id_token else {}) + client_info = ( + json.loads(base64decode(response["client_info"])) + if "client_info" in response + else { # ADFS scenario + "uid": id_token_claims.get("sub"), + "utid": environment, # TBD + } + ) + home_account_id = "{uid}.{utid}".format(**client_info) target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: @@ -148,15 +155,15 @@ def add(self, event, now=None): self.modify(self.CredentialType.ACCESS_TOKEN, at, at) if client_info: - decoded_id_token = decode_id_token( - id_token, client_id=event["client_id"]) if id_token else {} account = { "home_account_id": home_account_id, "environment": environment, "realm": realm, - "local_account_id": decoded_id_token.get( - "oid", decoded_id_token.get("sub")), - "username": decoded_id_token.get("preferred_username"), + "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 "", # The schema does not like null "authority_type": self.AuthorityType.ADFS if realm == "adfs" else self.AuthorityType.MSSTS, From 5dfea756b443db52f6928b00097cabe627c15f0c Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Fri, 19 Jul 2019 09:59:46 -0700 Subject: [PATCH 092/440] Changing tenant discovery endpoint for adfs tenants --- msal/authority.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/authority.py b/msal/authority.py index 51289d2c..151d2be5 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -45,6 +45,9 @@ def __init__(self, authority_url, validate_authority=True, tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", verify=verify, proxies=proxies, timeout=timeout) + if tenant.lower() == "adfs": + tenant_discovery_endpoint = ("https://{}/adfs/.well-known/openid-configuration" + .format(self.instance)) openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) From fc9542003e70833d08c91f16675665250cacb15b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 19 Jul 2019 16:36:59 -0700 Subject: [PATCH 093/440] Test case for ADFS-direct --- tests/test_token_cache.py | 87 +++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index fecee707..adbdcaaa 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -16,30 +16,29 @@ class TokenCacheTestCase(unittest.TestCase): @staticmethod def build_id_token( iss="issuer", sub="subject", aud="my_client_id", exp=None, iat=None, - preferred_username="me", **claims): + **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(), - "preferred_username": preferred_username, }, **claims)).encode()).decode('utf-8') @staticmethod def build_response( # simulate a response from AAD - uid="uid", utid="utid", # They will form client_info + uid=None, utid=None, # If present, they will form client_info access_token=None, expires_in=3600, token_type="some type", refresh_token=None, foci=None, id_token=None, # or something generated by build_id_token() error=None, ): - response = { - "client_info": base64.b64encode(json.dumps({ + 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'), - } + }).encode()).decode('utf-8') if error: response["error"] = error if access_token: @@ -59,7 +58,7 @@ def build_response( # simulate a response from AAD def setUp(self): self.cache = TokenCache() - def testAdd(self): + def testAddByAad(self): client_id = "my_client_id" id_token = self.build_id_token( oid="object1234", preferred_username="John Doe", aud=client_id) @@ -132,6 +131,78 @@ def testAdd(self): "appmetadata-login.example.com-my_client_id") ) + def testAddByAdfs(self): + client_id = "my_client_id" + id_token = self.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": self.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.adfs", + 'realm': 'adfs', + 'secret': 'an access token', + 'target': 's2 s1 s3', + }, + self.cache._cache["AccessToken"].get( + 'subject.adfs-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.adfs", + 'secret': 'a refresh token', + 'target': 's2 s1 s3', + }, + self.cache._cache["RefreshToken"].get( + 'subject.adfs-fs.msidlab8.com-refreshtoken-my_client_id--s2 s1 s3') + ) + self.assertEqual( + { + 'home_account_id': "subject.adfs", + 'environment': 'fs.msidlab8.com', + 'realm': 'adfs', + 'local_account_id': "subject", + 'username': "JaneDoe@example.com", + 'authority_type': "ADFS", + }, + self.cache._cache["Account"].get('subject.adfs-fs.msidlab8.com-adfs') + ) + self.assertEqual( + { + 'credential_type': 'IdToken', + 'secret': id_token, + 'home_account_id': "subject.adfs", + 'environment': 'fs.msidlab8.com', + 'realm': 'adfs', + 'client_id': 'my_client_id', + }, + self.cache._cache["IdToken"].get( + 'subject.adfs-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") + ) + class SerializableTokenCacheTestCase(TokenCacheTestCase): # Run all inherited test methods, and have extra check in tearDown() From 7db1128eb2369173d3c24d6a6dc68c571e4ad56c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 15 Jul 2019 12:47:49 -0700 Subject: [PATCH 094/440] Adjusting token cache to work with ADFS2019 --- msal/token_cache.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 358b064b..fc8841d6 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -111,18 +111,25 @@ def add(self, event, now=None): event, indent=4, sort_keys=True, default=str, # A workaround when assertion is in bytes in Python 3 )) + environment = realm = None + if "token_endpoint" in event: + _, environment, realm = canonicalize(event["token_endpoint"]) response = event.get("response", {}) access_token = response.get("access_token") refresh_token = response.get("refresh_token") id_token = response.get("id_token") - client_info = {} - home_account_id = None - if "client_info" in response: - client_info = json.loads(base64decode(response["client_info"])) - home_account_id = "{uid}.{utid}".format(**client_info) - environment = realm = None - if "token_endpoint" in event: - _, environment, realm = canonicalize(event["token_endpoint"]) + id_token_claims = ( + decode_id_token(id_token, client_id=event["client_id"]) + if id_token else {}) + client_info = ( + json.loads(base64decode(response["client_info"])) + if "client_info" in response + else { # ADFS scenario + "uid": id_token_claims.get("sub"), + "utid": realm, # which, in ADFS scenario, would typically be "adfs" + } + ) + home_account_id = "{uid}.{utid}".format(**client_info) target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: @@ -148,15 +155,15 @@ def add(self, event, now=None): self.modify(self.CredentialType.ACCESS_TOKEN, at, at) if client_info: - decoded_id_token = decode_id_token( - id_token, client_id=event["client_id"]) if id_token else {} account = { "home_account_id": home_account_id, "environment": environment, "realm": realm, - "local_account_id": decoded_id_token.get( - "oid", decoded_id_token.get("sub")), - "username": decoded_id_token.get("preferred_username"), + "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 "", # The schema does not like null "authority_type": self.AuthorityType.ADFS if realm == "adfs" else self.AuthorityType.MSSTS, From be474a3f94d8428b51f0511b34e9825fa048291a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 19 Jul 2019 18:34:03 -0700 Subject: [PATCH 095/440] Fix regression on credential client grant --- msal/token_cache.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index fc8841d6..8dcf0ffb 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -121,15 +121,16 @@ def add(self, event, now=None): id_token_claims = ( decode_id_token(id_token, client_id=event["client_id"]) if id_token else {}) - client_info = ( - json.loads(base64decode(response["client_info"])) - if "client_info" in response - else { # ADFS scenario + client_info = {} + if "client_info" in response: # We asked for it, and AAD will provide it + client_info = json.loads(base64decode(response["client_info"])) + elif id_token_claims: # This would be an end user on ADFS-direct scenario + client_info = { "uid": id_token_claims.get("sub"), "utid": realm, # which, in ADFS scenario, would typically be "adfs" } - ) - home_account_id = "{uid}.{utid}".format(**client_info) + home_account_id = ( # It would remain None in client_credentials flow + "{uid}.{utid}".format(**client_info) if client_info else None) target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: From 0fe2ca1ef5b62b92f5951b096979a479a5a130b5 Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Fri, 19 Jul 2019 09:59:46 -0700 Subject: [PATCH 096/440] Changing tenant discovery endpoint for adfs tenants --- msal/authority.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/authority.py b/msal/authority.py index 51289d2c..151d2be5 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -45,6 +45,9 @@ def __init__(self, authority_url, validate_authority=True, tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", verify=verify, proxies=proxies, timeout=timeout) + if tenant.lower() == "adfs": + tenant_discovery_endpoint = ("https://{}/adfs/.well-known/openid-configuration" + .format(self.instance)) openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) From 9bb10b0b93d77786911aa2d7758d6f514dfc748a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 19 Jul 2019 18:36:03 -0700 Subject: [PATCH 097/440] Refactor authority implementation --- msal/authority.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 151d2be5..2e3a8185 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -38,16 +38,17 @@ def __init__(self, authority_url, validate_authority=True, self.proxies = proxies self.timeout = timeout canonicalized, self.instance, tenant = canonicalize(authority_url) - tenant_discovery_endpoint = ( # Hard code a V2 pattern as default value - 'https://{}/{}/v2.0/.well-known/openid-configuration' - .format(self.instance, tenant)) - if validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS: + tenant_discovery_endpoint = ( + 'https://{}/{}{}/.well-known/openid-configuration'.format( + self.instance, + tenant, + "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint + )) + if (tenant != "adfs" and validate_authority + and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", verify=verify, proxies=proxies, timeout=timeout) - if tenant.lower() == "adfs": - tenant_discovery_endpoint = ("https://{}/adfs/.well-known/openid-configuration" - .format(self.instance)) openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) From 5e8d530373a2930c9090a30a736062c8b4266ea9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 25 Jul 2019 10:29:06 -0700 Subject: [PATCH 098/440] UX improvement based on recent Device Flow tests --- sample/device_flow_sample.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 21923ef7..8b51d764 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -51,10 +51,18 @@ if not result: logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + flow = app.initiate_device_flow(scopes=config["scope"]) + if "user_code" not in flow: + raise ValueError( + "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) + print(flow["message"]) + sys.stdout.flush() # Some terminal needs this to ensure the message is shown + # Ideally you should wait here, in order to save some unnecessary polling - # input("Press Enter after you successfully login from another device...") + # input("Press Enter after signing in from another device to proceed, CTRL+C to abort.") + result = app.acquire_token_by_device_flow(flow) # By default it will block # You can follow this instruction to shorten the block time # https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.acquire_token_by_device_flow From a9bcebd23eb518c8a58fb0b6e3ad1e03482faf45 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 29 Jul 2019 14:50:43 -0700 Subject: [PATCH 099/440] Rename a helper inspired by an internal conversation In a recent intense discussion during api review, different teams have different interpretation on "silent". One team attmpted to add some non-interactive flows into acquire_token_silent(). That attempt to changing public API behavior was rejected. Nonetheless, this commit aims to discourage such interpretation in the first place by giving a more precise name to an internal helper. --- msal/application.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 448d2343..af3e2818 100644 --- a/msal/application.py +++ b/msal/application.py @@ -400,7 +400,8 @@ def acquire_token_silent( # authority, # verify=self.verify, proxies=self.proxies, timeout=self.timeout, # ) if authority else self.authority - result = self._acquire_token_silent(scopes, account, self.authority, **kwargs) + result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( + scopes, account, self.authority, **kwargs) if result: return result for alias in self._get_authority_aliases(self.authority.instance): @@ -408,12 +409,12 @@ def acquire_token_silent( "https://" + alias + "/" + self.authority.tenant, validate_authority=False, verify=self.verify, proxies=self.proxies, timeout=self.timeout) - result = self._acquire_token_silent( + result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, **kwargs) if result: return result - def _acquire_token_silent( + def _acquire_token_silent_from_cache_and_possibly_refresh_it( self, scopes, # type: List[str] account, # type: Optional[Account] From ba5c426eb55a42f1f3b714ac8acd2faf5d7dbf41 Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Tue, 30 Jul 2019 16:52:22 -0700 Subject: [PATCH 100/440] Changing home account id to sub from idtoken claims --- msal/token_cache.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 8dcf0ffb..0ae63d41 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -124,13 +124,16 @@ def add(self, event, now=None): client_info = {} if "client_info" in response: # We asked for it, and AAD will provide it client_info = json.loads(base64decode(response["client_info"])) + home_account_id = ( + # It would remain None in client_credentials flow + "{uid}.{utid}".format(**client_info) if client_info else None) elif id_token_claims: # This would be an end user on ADFS-direct scenario client_info = { "uid": id_token_claims.get("sub"), - "utid": realm, # which, in ADFS scenario, would typically be "adfs" } - home_account_id = ( # It would remain None in client_credentials flow - "{uid}.{utid}".format(**client_info) if client_info else None) + home_account_id = ( + "{uid}".format(**client_info)) + target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: From 666117feac363b7d7e9747cda81a3cf422a92d63 Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Tue, 30 Jul 2019 17:13:41 -0700 Subject: [PATCH 101/440] Changing tests for the token cache change --- tests/test_token_cache.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index adbdcaaa..85f44576 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -151,48 +151,48 @@ def testAddByAdfs(self): 'environment': 'fs.msidlab8.com', 'expires_on': "4600", 'extended_expires_on': "4600", - 'home_account_id': "subject.adfs", + 'home_account_id': "subject", 'realm': 'adfs', 'secret': 'an access token', 'target': 's2 s1 s3', }, self.cache._cache["AccessToken"].get( - 'subject.adfs-fs.msidlab8.com-accesstoken-my_client_id-adfs-s2 s1 s3') + '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.adfs", + 'home_account_id': "subject", 'secret': 'a refresh token', 'target': 's2 s1 s3', }, self.cache._cache["RefreshToken"].get( - 'subject.adfs-fs.msidlab8.com-refreshtoken-my_client_id--s2 s1 s3') + 'subject-fs.msidlab8.com-refreshtoken-my_client_id--s2 s1 s3') ) self.assertEqual( { - 'home_account_id': "subject.adfs", + '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.adfs-fs.msidlab8.com-adfs') + self.cache._cache["Account"].get('subject-fs.msidlab8.com-adfs') ) self.assertEqual( { 'credential_type': 'IdToken', 'secret': id_token, - 'home_account_id': "subject.adfs", + 'home_account_id': "subject", 'environment': 'fs.msidlab8.com', 'realm': 'adfs', 'client_id': 'my_client_id', }, self.cache._cache["IdToken"].get( - 'subject.adfs-fs.msidlab8.com-idtoken-my_client_id-adfs-') + 'subject-fs.msidlab8.com-idtoken-my_client_id-adfs-') ) self.assertEqual( { From 38dbfc575eb68d57dc22c0656760f591e68cb712 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 30 Jul 2019 17:29:46 -0700 Subject: [PATCH 102/440] refactor --- msal/token_cache.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 0ae63d41..37338364 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -122,17 +122,13 @@ def add(self, event, now=None): decode_id_token(id_token, client_id=event["client_id"]) if id_token else {}) client_info = {} + home_account_id = None # It would remain None in client_credentials flow if "client_info" in response: # We asked for it, and AAD will provide it client_info = json.loads(base64decode(response["client_info"])) - home_account_id = ( - # It would remain None in client_credentials flow - "{uid}.{utid}".format(**client_info) if client_info else None) + home_account_id = "{uid}.{utid}".format(**client_info) elif id_token_claims: # This would be an end user on ADFS-direct scenario - client_info = { - "uid": id_token_claims.get("sub"), - } - home_account_id = ( - "{uid}".format(**client_info)) + client_info["uid"] = id_token_claims.get("sub") + home_account_id = id_token_claims.get("sub") target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it From c2929eedff6b15f27854823f184b08e58551fd70 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 31 Jul 2019 11:43:44 -0700 Subject: [PATCH 103/440] Remove redundant snippet --- msal/authority.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 75dc69f1..2e3a8185 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -49,9 +49,6 @@ def __init__(self, authority_url, validate_authority=True, tenant_discovery_endpoint = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", verify=verify, proxies=proxies, timeout=timeout) - if tenant.lower() == "adfs": - tenant_discovery_endpoint = ("https://{}/adfs/.well-known/openid-configuration" - .format(self.instance)) openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) From 211135ac9b465576b15c2fd82606eed133aafadc Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Wed, 31 Jul 2019 11:14:46 -0700 Subject: [PATCH 104/440] MSAL Python 0.6.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index af3e2818..977cf948 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.5.1" +__version__ = "0.6.0" logger = logging.getLogger(__name__) From 9d5ce9ad1af4c913ad489482337ba1b850e3f2e4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 12 Aug 2019 18:17:53 -0700 Subject: [PATCH 105/440] Refactor to use new helper names --- msal/token_cache.py | 4 ++-- tests/test_assertion.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 37338364..1ad97428 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -4,7 +4,7 @@ import logging from .authority import canonicalize -from .oauth2cli.oidc import base64decode, decode_id_token +from .oauth2cli.oidc import decode_part, decode_id_token logger = logging.getLogger(__name__) @@ -124,7 +124,7 @@ def add(self, event, now=None): client_info = {} home_account_id = None # It would remain None in client_credentials flow if "client_info" in response: # We asked for it, and AAD will provide it - client_info = json.loads(base64decode(response["client_info"])) + client_info = json.loads(decode_part(response["client_info"])) home_account_id = "{uid}.{utid}".format(**client_info) elif id_token_claims: # This would be an end user on ADFS-direct scenario client_info["uid"] = id_token_claims.get("sub") diff --git a/tests/test_assertion.py b/tests/test_assertion.py index a4921138..7885afe8 100644 --- a/tests/test_assertion.py +++ b/tests/test_assertion.py @@ -1,15 +1,15 @@ import json -from msal.oauth2cli import JwtSigner -from msal.oauth2cli.oidc import base64decode +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 = JwtSigner(key=None, algorithm="none").sign_assertion( + assertion = JwtAssertionCreator(key=None, algorithm="none").sign_assertion( "audience", "issuer", additional_claims={"client_ip": "1.2.3.4"}) - payload = json.loads(base64decode(assertion.split(b'.')[1].decode('utf-8'))) + payload = json.loads(decode_part(assertion.split(b'.')[1].decode('utf-8'))) self.assertEqual("1.2.3.4", payload.get("client_ip")) From 4d63b1ea5cd93f237f880075693d2acf7660b496 Mon Sep 17 00:00:00 2001 From: KITAGAWA Yasutaka Date: Tue, 13 Aug 2019 10:19:01 +0900 Subject: [PATCH 106/440] Use urlsafe_b64decode instead of b64decode (#84) * Use urlsafe_b64decode instead of b64decode * Fix decoding error on Python 2.7 --- msal/oauth2cli/oidc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/oauth2cli/oidc.py b/msal/oauth2cli/oidc.py index 7fdc5573..3e2dfe0f 100644 --- a/msal/oauth2cli/oidc.py +++ b/msal/oauth2cli/oidc.py @@ -8,7 +8,10 @@ def base64decode(raw): """A helper can handle a padding-less raw input""" raw += '=' * (-len(raw) % 4) # https://stackoverflow.com/a/32517907/728675 - return base64.b64decode(raw).decode("utf-8") + # On Python 2.7, argument of urlsafe_b64decode must be str, not unicode. + # This is not required on Python 3. + raw = str(raw) + return base64.urlsafe_b64decode(raw).decode("utf-8") def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None): From 14e8e5e9f3a7751951cfe1015981afa050463474 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 13 Aug 2019 13:20:42 -0700 Subject: [PATCH 107/440] MSAL Python 0.6.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 977cf948..f7f92d2a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.6.0" +__version__ = "0.6.1" logger = logging.getLogger(__name__) From 2967f815c75ecc4f1d48aab3d18d5674ab939088 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 28 Aug 2019 12:07:41 -0700 Subject: [PATCH 108/440] Refactor unittest logs We already hide some most sensitive informatino before, and PEN test team loves it. This time we hide some extra information. --- msal/token_cache.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 1ad97428..f3f647fe 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -99,18 +99,30 @@ def find(self, credential_type, target=None, query=None): def add(self, event, now=None): # type: (dict) -> None - # event typically contains: client_id, scope, token_endpoint, - # resposne, params, data, grant_type - for sensitive in ("password", "client_secret"): - if sensitive in event.get("data", {}): - # Hide them from accidental exposure in logging - event["data"][sensitive] = "********" - logger.debug("event=%s", json.dumps( + """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", {}), ("access_token", "refresh_token")) + 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 - )) + event, indent=4, sort_keys=True, + default=str, # A workaround when assertion is in bytes in Python 3 + )) + + 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"]) From abf169d3d4b70301f1fa2c3317a36fc558c497ff Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 4 Sep 2019 10:24:58 -0700 Subject: [PATCH 109/440] The more generic common can now support DF for MSA --- sample/device_flow_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 8b51d764..c2a3e572 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -2,7 +2,7 @@ The configuration file would look like this: { - "authority": "https://login.microsoftonline.com/organizations", + "authority": "https://login.microsoftonline.com/common", "client_id": "your_client_id", "scope": ["User.Read"] } From f1c91b36ffcc48d1fd4ace413c5dbe8bbad5d06a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 20 Aug 2019 16:59:47 -0700 Subject: [PATCH 110/440] WIP: Building blocks are ready --- tests/test_e2e.py | 193 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/test_e2e.py diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 00000000..b8f9b993 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,193 @@ +import logging +import os +import json + +import requests + +import msal +from tests import unittest + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +def get_lab_user(query): + # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx + user = requests.get("https://api.msidlab.com/api/user", params=query).json() + return { # Mapping lab API response to our expected configuration format + "authority": user["Authority"][0] + user["Users"]["tenantId"], + "client_id": user["AppID"], + "username": user["Users"]["upn"], + "password": "TBD", # TODO + "scope": ["https://graph.microsoft.com/.default"], + } + +def get_lab_app( + env_client_id="LAB_APP_CLIENT_ID", + env_client_secret="LAB_APP_CLIENT_SECRET", + ): + """Returns the lab app as an MSAL confidential client. + + Get it from environment variables if defined, otherwise fall back to use MSI. + """ + 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://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Rese.aspx#programmatic-access-info-for-lab-request-api + logger.info("Using lap app defined by ENV variables %s and %s", + env_client_id, env_client_secret) + 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 NotImplementedError("MSI-based mechanism has not been implemented yet") + return msal.ConfidentialClientApplication(client_id, client_secret, + authority="https://login.microsoftonline.com/" + "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID + ) + +def get_lab_user_secret(access_token, lab_name="msidlab4"): + return requests.get( + # Note: Short link won't work "https://aka.ms/GetLabUserSecret?Secret=%s" + "https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&Secret=%s" + % lab_name, + headers={"Authorization": "Bearer %s" % access_token}, + ).json()["Value"] + + +@unittest.skip("for now") +class E2eTestCase(unittest.TestCase): + """ + lab_token = get_lab_app().acquire_token_for_client( + "https://request.msidlab.com/.default" + ) # BTW, this infrastructure tests the confidential client flow + lab_password = get_lab_user_secret(lab_token["access_token"]) + """ + + def setUp(self): + pass + # client_id, client_secret = get_lab_app() + # self.lab_app = msal.ConfidentialClientApplication(client_id, client_secret) + + def test_bar(self): + self.assertEqual("********", self.lab_password) + + +class BaseMixin(object): + + def skipIfNotConfigured(self, fields): + if not all(map(self.config.get, fields)): + self.skipTest("Configuration not sufficient") + for field in fields: + if not self.config.get(field): + self.skipTest('"%s" not defined in configuration' % field) + + 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 assertCacheWorks(self, result_from_wire): + result = result_from_wire + # You can filter by predefined username, or let end user to choose one + accounts = self.app.get_accounts(username=self.config.get("username")) + self.assertNotEqual(0, len(accounts)) + account = accounts[0] + # Going to test acquire_token_silent(...) to locate an AT from cache + result_from_cache = self.app.acquire_token_silent( + self.config["scope"], account=account) + self.assertIsNotNone(result_from_cache) + self.assertEqual(result['access_token'], result_from_cache['access_token'], + "We should get a cached AT") + + # 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( + self.config["scope"], account=account) + self.assertIsNotNone(result_from_cache, + "We should get a result from acquire_token_silent(...) call") + self.assertNotEqual(result['access_token'], result_from_cache['access_token'], + "We should get a fresh AT (via RT)") + + +class UsernamePasswordMixin(object): + def test_username_password(self): + self.skipIfNotConfigured([ + "authority", "client_id", "username", "password", "scope"]) + self.app = msal.PublicClientApplication( + self.config["client_id"], authority=self.config["authority"]) + result = self.app.acquire_token_by_username_password( + self.config["username"], self.config["password"], + scopes=self.config.get("scope")) + self.assertLoosely(result) + self.assertCacheWorks(result) + + +DEFAULT_QUERY = {"mam": False, "mfa": False} + +# Note: the following semi-parameterized testing approach is inspired from +# https://bugs.python.org/msg151444 + +@unittest.skip("for now") +class AadManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict(DEFAULT_QUERY, isFederated=False)) + +@unittest.skip("for now") +class Adfs4FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv4")) + +@unittest.skip("for now") +class Adfs4ManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv4")) + +@unittest.skip("for now") # TODO: Need to pick up the real password +class Adfs3FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv3")) + +@unittest.skip("for now") # TODO: Need to pick up the real password +class Adfs3ManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv3")) + +@unittest.skip("for now") # TODO: Need to pick up the real password +class Adfs2FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2")) + +@unittest.skip("Lab API returns nothing. We might need to switch to beta api") +class Adfs2019FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + self.config = get_lab_user(dict( + DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2019")) + +CONFIG = os.path.join(os.path.dirname(__file__), "config.json") +@unittest.skipIf(not os.path.exists(CONFIG), "Optional %s not found" % CONFIG) +class FileBasedTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): + def setUp(self): + with open(CONFIG) as f: + self.config = json.load(f) + From 61ae4bbf6b982278a3b35c183e75c95d2ec82b7a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 22 Aug 2019 16:27:12 -0700 Subject: [PATCH 111/440] Use old school single inheritance to replace MixIn --- tests/test_e2e.py | 240 +++++++++++++++++++++++++--------------------- 1 file changed, 131 insertions(+), 109 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b8f9b993..4544f452 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -9,82 +9,16 @@ logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) -def get_lab_user(query): - # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx - user = requests.get("https://api.msidlab.com/api/user", params=query).json() - return { # Mapping lab API response to our expected configuration format - "authority": user["Authority"][0] + user["Users"]["tenantId"], - "client_id": user["AppID"], - "username": user["Users"]["upn"], - "password": "TBD", # TODO - "scope": ["https://graph.microsoft.com/.default"], - } - -def get_lab_app( - env_client_id="LAB_APP_CLIENT_ID", - env_client_secret="LAB_APP_CLIENT_SECRET", - ): - """Returns the lab app as an MSAL confidential client. - Get it from environment variables if defined, otherwise fall back to use MSI. - """ - 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://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Rese.aspx#programmatic-access-info-for-lab-request-api - logger.info("Using lap app defined by ENV variables %s and %s", - env_client_id, env_client_secret) - 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 NotImplementedError("MSI-based mechanism has not been implemented yet") - return msal.ConfidentialClientApplication(client_id, client_secret, - authority="https://login.microsoftonline.com/" - "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID - ) - -def get_lab_user_secret(access_token, lab_name="msidlab4"): - return requests.get( - # Note: Short link won't work "https://aka.ms/GetLabUserSecret?Secret=%s" - "https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&Secret=%s" - % lab_name, - headers={"Authorization": "Bearer %s" % access_token}, - ).json()["Value"] - - -@unittest.skip("for now") class E2eTestCase(unittest.TestCase): - """ - lab_token = get_lab_app().acquire_token_for_client( - "https://request.msidlab.com/.default" - ) # BTW, this infrastructure tests the confidential client flow - lab_password = get_lab_user_secret(lab_token["access_token"]) - """ - - def setUp(self): - pass - # client_id, client_secret = get_lab_app() - # self.lab_app = msal.ConfidentialClientApplication(client_id, client_secret) - - def test_bar(self): - self.assertEqual("********", self.lab_password) - - -class BaseMixin(object): + config = {} def skipIfNotConfigured(self, fields): - if not all(map(self.config.get, fields)): - self.skipTest("Configuration not sufficient") for field in fields: if not self.config.get(field): - self.skipTest('"%s" not defined in configuration' % field) + self.skipTest('"%s" not found in configuration' % field) def assertLoosely(self, response, assertion=None, skippable_errors=("invalid_grant", "interaction_required")): @@ -124,8 +58,6 @@ def assertCacheWorks(self, result_from_wire): self.assertNotEqual(result['access_token'], result_from_cache['access_token'], "We should get a fresh AT (via RT)") - -class UsernamePasswordMixin(object): def test_username_password(self): self.skipIfNotConfigured([ "authority", "client_id", "username", "password", "scope"]) @@ -135,59 +67,149 @@ def test_username_password(self): self.config["username"], self.config["password"], scopes=self.config.get("scope")) self.assertLoosely(result) + # self.assertEqual(None, result.get("error"), str(result)) self.assertCacheWorks(result) -DEFAULT_QUERY = {"mam": False, "mfa": False} +CONFIG = os.path.join(os.path.dirname(__file__), "config.json") +@unittest.skipIf(not os.path.exists(CONFIG), "Optional %s not found" % CONFIG) +class FileBasedTestCase(E2eTestCase): + def setUp(self): + with open(CONFIG) as f: + self.config = json.load(f) -# Note: the following semi-parameterized testing approach is inspired from -# https://bugs.python.org/msg151444 -@unittest.skip("for now") -class AadManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict(DEFAULT_QUERY, isFederated=False)) +def get_lab_user(query): # This API requires no authorization + # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx + user = requests.get("https://api.msidlab.com/api/user", params=query).json() + return { # Mapping lab API response to our simplified configuration format + "authority": user["Authority"][0] + user["Users"]["tenantId"], + "client_id": user["AppID"], + "username": user["Users"]["upn"], + "lab": {"labname": user["Users"]["upn"].split('@')[1].split('.')[0]}, # :( + "scope": ["https://graph.microsoft.com/.default"], + } -@unittest.skip("for now") -class Adfs4FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( +def get_lab_app( + env_client_id="LAB_APP_CLIENT_ID", + env_client_secret="LAB_APP_CLIENT_SECRET", + ): + """Returns the lab app as an MSAL confidential client. + + Get it from environment variables if defined, otherwise fall back to use MSI. + """ + 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://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Rese.aspx#programmatic-access-info-for-lab-request-api + logger.info("Using lab app defined by ENV variables %s and %s", + env_client_id, env_client_secret) + 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 NotImplementedError("MSI-based mechanism has not been implemented yet") + return msal.ConfidentialClientApplication(client_id, client_secret, + authority="https://login.microsoftonline.com/" + "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID + ) + +def get_session(lab_app): # BTW, this infrastructure tests the confidential client flow + logger.info("Creating session") + lab_token = lab_app.acquire_token_for_client("https://request.msidlab.com/.default") + session = requests.Session() + session.headers.update({"Authorization": "Bearer %s" % lab_token["access_token"]}) + session.hooks["response"].append(lambda r, *args, **kwargs: r.raise_for_status()) + return session + + +class LabBasedTestCase(E2eTestCase): + session = get_session(get_lab_app()) # It will run even all test cases are skipped + _secrets = {} + + @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) + # Note: Short link won't work "https://aka.ms/GetLabUserSecret?Secret=%s" + # So we use the official link written in here + # https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API%27s.aspx + url = ("https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&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): # The query format is in lab team's Aug 9 email + resp = cls.session.get("https://user.msidlab.com/api/user", params=query) + result = resp.json()[0] + return { # Mapping lab API response to our simplified configuration format + "authority": result["lab"]["authority"] + result["lab"]["tenantid"], + "client_id": result["app"]["objectid"], + "username": result["user"]["upn"], + "lab": result["lab"], + "scope": ["https://graph.microsoft.com/.default"], + } + +DEFAULT_QUERY = {"mam": False, "mfa": False} + +class AadManagedUserTestCase(LabBasedTestCase): + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict(DEFAULT_QUERY, + isFederated=False, # Supposed to find a pure managed user, + # but lab still gives us a idlab@msidlab4.onmicrosoft.com + )) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) + +class Adfs4FedUserTestCase(LabBasedTestCase): + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv4")) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) -@unittest.skip("for now") -class Adfs4ManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( +class Adfs4ManagedUserTestCase(LabBasedTestCase): # a.k.a. the hybrid + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv4")) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) -@unittest.skip("for now") # TODO: Need to pick up the real password -class Adfs3FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( +class Adfs3FedUserTestCase(LabBasedTestCase): + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv3")) - -@unittest.skip("for now") # TODO: Need to pick up the real password -class Adfs3ManagedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( + #cls.config = cls.get_lab_user({ + # "MFA": "none", "UserType": "federated", "FederationProvider": "adfsv3"}) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) + +class Adfs3ManagedUserTestCase(LabBasedTestCase): # a.k.a. the hybrid + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv3")) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) -@unittest.skip("for now") # TODO: Need to pick up the real password -class Adfs2FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( +class Adfs2FedUserTestCase(LabBasedTestCase): + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2")) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) @unittest.skip("Lab API returns nothing. We might need to switch to beta api") -class Adfs2019FedUserPassTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - self.config = get_lab_user(dict( +class Adfs2019FedUserTestCase(LabBasedTestCase): + @classmethod + def setUpClass(cls): + cls.config = get_lab_user(dict( DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2019")) - -CONFIG = os.path.join(os.path.dirname(__file__), "config.json") -@unittest.skipIf(not os.path.exists(CONFIG), "Optional %s not found" % CONFIG) -class FileBasedTestCase(BaseMixin, UsernamePasswordMixin, unittest.TestCase): - def setUp(self): - with open(CONFIG) as f: - self.config = json.load(f) + cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) From 1ef5a7e903a0b32b83a562ae6dd569d2ebdbe29a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 26 Aug 2019 17:47:57 -0700 Subject: [PATCH 112/440] Combine multiple sub-classes back to one test class --- tests/test_e2e.py | 149 +++++++++++++++++++++------------------------- 1 file changed, 67 insertions(+), 82 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4544f452..1ea044da 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -13,12 +13,6 @@ class E2eTestCase(unittest.TestCase): - config = {} - - def skipIfNotConfigured(self, fields): - for field in fields: - if not self.config.get(field): - self.skipTest('"%s" not found in configuration' % field) def assertLoosely(self, response, assertion=None, skippable_errors=("invalid_grant", "interaction_required")): @@ -36,39 +30,36 @@ def assertLoosely(self, response, assertion=None, error_description=response.get("error_description"))) assertion() - def assertCacheWorks(self, result_from_wire): + def assertCacheWorks(self, result_from_wire, username, scope): result = result_from_wire # You can filter by predefined username, or let end user to choose one - accounts = self.app.get_accounts(username=self.config.get("username")) + accounts = self.app.get_accounts(username=username) self.assertNotEqual(0, len(accounts)) account = accounts[0] # Going to test acquire_token_silent(...) to locate an AT from cache - result_from_cache = self.app.acquire_token_silent( - self.config["scope"], account=account) + result_from_cache = self.app.acquire_token_silent(scope, account=account) self.assertIsNotNone(result_from_cache) self.assertEqual(result['access_token'], result_from_cache['access_token'], "We should get a cached AT") # 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( - self.config["scope"], account=account) + result_from_cache = self.app.acquire_token_silent(scope, account=account) self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") self.assertNotEqual(result['access_token'], result_from_cache['access_token'], "We should get a fresh AT (via RT)") - def test_username_password(self): - self.skipIfNotConfigured([ - "authority", "client_id", "username", "password", "scope"]) - self.app = msal.PublicClientApplication( - self.config["client_id"], authority=self.config["authority"]) + def _test_username_password(self, + authority=None, client_id=None, username=None, password=None, scope=None, + **ignored): + assert authority and client_id and username and password and scope + self.app = msal.PublicClientApplication(client_id, authority=authority) result = self.app.acquire_token_by_username_password( - self.config["username"], self.config["password"], - scopes=self.config.get("scope")) + username, password, scopes=scope) self.assertLoosely(result) # self.assertEqual(None, result.get("error"), str(result)) - self.assertCacheWorks(result) + self.assertCacheWorks(result, username, scope) CONFIG = os.path.join(os.path.dirname(__file__), "config.json") @@ -78,10 +69,14 @@ def setUp(self): with open(CONFIG) as f: self.config = json.load(f) + def test_username_password(self): + self._test_username_password(**self.config) -def get_lab_user(query): # This API requires no authorization +def get_lab_user(mam=False, mfa=False, isFederated=False, federationProvider=None): # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx - user = requests.get("https://api.msidlab.com/api/user", params=query).json() + user = requests.get("https://api.msidlab.com/api/user", params=dict( # Publicly available + mam=mam, mfa=mfa, isFederated=isFederated, federationProvider=federationProvider, + )).json() return { # Mapping lab API response to our simplified configuration format "authority": user["Authority"][0] + user["Users"]["tenantId"], "client_id": user["AppID"], @@ -112,15 +107,15 @@ def get_lab_app( 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 NotImplementedError("MSI-based mechanism has not been implemented yet") + raise unittest.SkipTest("MSI-based mechanism has not been implemented yet") return msal.ConfidentialClientApplication(client_id, client_secret, authority="https://login.microsoftonline.com/" "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID ) -def get_session(lab_app): # BTW, this infrastructure tests the confidential client flow +def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow logger.info("Creating session") - lab_token = lab_app.acquire_token_for_client("https://request.msidlab.com/.default") + lab_token = lab_app.acquire_token_for_client(scopes) session = requests.Session() session.headers.update({"Authorization": "Bearer %s" % lab_token["access_token"]}) session.hooks["response"].append(lambda r, *args, **kwargs: r.raise_for_status()) @@ -128,9 +123,19 @@ def get_session(lab_app): # BTW, this infrastructure tests the confidential cli class LabBasedTestCase(E2eTestCase): - session = get_session(get_lab_app()) # It will run even all test cases are skipped _secrets = {} + @classmethod + def setUpClass(cls): + cls.session = get_session(get_lab_app(), [ + "https://request.msidlab.com/.default", # Existing user & password API + # "https://user.msidlab.com/.default", # New user API + ]) + + @classmethod + def tearDownClass(cls): + cls.session.close() + @classmethod def get_lab_user_secret(cls, lab_name="msidlab4"): lab_name = lab_name.lower() @@ -146,7 +151,7 @@ def get_lab_user_secret(cls, lab_name="msidlab4"): return cls._secrets[lab_name] @classmethod - def get_lab_user(cls, query): # The query format is in lab team's Aug 9 email + def get_lab_user(cls, query): # Experimental: The query format is in lab team's Aug 9 email resp = cls.session.get("https://user.msidlab.com/api/user", params=query) result = resp.json()[0] return { # Mapping lab API response to our simplified configuration format @@ -157,59 +162,39 @@ def get_lab_user(cls, query): # The query format is in lab team's Aug 9 email "scope": ["https://graph.microsoft.com/.default"], } -DEFAULT_QUERY = {"mam": False, "mfa": False} - -class AadManagedUserTestCase(LabBasedTestCase): - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict(DEFAULT_QUERY, - isFederated=False, # Supposed to find a pure managed user, - # but lab still gives us a idlab@msidlab4.onmicrosoft.com - )) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -class Adfs4FedUserTestCase(LabBasedTestCase): - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv4")) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -class Adfs4ManagedUserTestCase(LabBasedTestCase): # a.k.a. the hybrid - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv4")) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -class Adfs3FedUserTestCase(LabBasedTestCase): - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv3")) - #cls.config = cls.get_lab_user({ - # "MFA": "none", "UserType": "federated", "FederationProvider": "adfsv3"}) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -class Adfs3ManagedUserTestCase(LabBasedTestCase): # a.k.a. the hybrid - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=False, federationProvider="ADFSv3")) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -class Adfs2FedUserTestCase(LabBasedTestCase): - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2")) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) - -@unittest.skip("Lab API returns nothing. We might need to switch to beta api") -class Adfs2019FedUserTestCase(LabBasedTestCase): - @classmethod - def setUpClass(cls): - cls.config = get_lab_user(dict( - DEFAULT_QUERY, isFederated=True, federationProvider="ADFSv2019")) - cls.config["password"] = cls.get_lab_user_secret(cls.config["lab"]["labname"]) + def test_aad_managed_user(self): # Pure cloud or hybrid + config = get_lab_user(isFederated=False) + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + def test_adfs4_fed_user(self): + config = get_lab_user(isFederated=True, federationProvider="ADFSv4") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + def test_adfs4_managed_user(self): # Conceptually the hybrid + config = get_lab_user(isFederated=False, federationProvider="ADFSv4") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + def test_adfs3_fed_user(self): + config = get_lab_user(isFederated=True, federationProvider="ADFSv3") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + def test_adfs3_managed_user(self): + config = get_lab_user(isFederated=False, federationProvider="ADFSv3") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + def test_adfs2_fed_user(self): + config = get_lab_user(isFederated=True, federationProvider="ADFSv2") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + + @unittest.skip("Old Lab API returns nothing. We will switch to new api later") + def test_adfs2019_fed_user(self): + config = get_lab_user(isFederated=True, federationProvider="ADFSv2019") + self._test_username_password( + password=self.get_lab_user_secret(config["lab"]["labname"]), **config) From a81f5ae990be728f4059ec6636cc52496bdf6660 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 26 Aug 2019 18:36:20 -0700 Subject: [PATCH 113/440] Skip end-to-end tests when cutting release --- tests/test_e2e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1ea044da..acc234d9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -12,6 +12,7 @@ logging.basicConfig(level=logging.INFO) +@unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip e2e tests during tagged release") class E2eTestCase(unittest.TestCase): def assertLoosely(self, response, assertion=None, From 2d2827d427b384b8c4fd732798975c8a98ec3fae Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 4 Sep 2019 10:14:42 -0700 Subject: [PATCH 114/440] Move some tests from test_application to test_e2e --- tests/test_application.py | 165 +------------------------------------- tests/test_client.py | 1 + tests/test_e2e.py | 150 +++++++++++++++++++++++++++++++--- 3 files changed, 144 insertions(+), 172 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 5e4c3b3a..cc072838 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,3 +1,5 @@ +# Note: Since Aug 2019 we move all e2e tests into test_e2e.py, +# so this test_application file contains only unit tests without dependency. import os import json import logging @@ -13,91 +15,11 @@ from tests.test_token_cache import TokenCacheTestCase -THIS_FOLDER = os.path.dirname(__file__) -CONFIG_FILE = os.path.join(THIS_FOLDER, 'config.json') -CONFIG = {} -if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE) as conf: - CONFIG = json.load(conf) - logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) -class Oauth2TestCase(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 assertCacheWorks(self, result_from_wire): - result = result_from_wire - # You can filter by predefined username, or let end user to choose one - accounts = self.app.get_accounts(username=CONFIG.get("username")) - self.assertNotEqual(0, len(accounts)) - account = accounts[0] - # Going to test acquire_token_silent(...) to locate an AT from cache - result_from_cache = self.app.acquire_token_silent( - CONFIG["scope"], account=account) - self.assertIsNotNone(result_from_cache) - self.assertEqual(result['access_token'], result_from_cache['access_token'], - "We should get a cached AT") - - # 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( - CONFIG["scope"], account=account) - self.assertIsNotNone(result_from_cache, - "We should get a result from acquire_token_silent(...) call") - self.assertNotEqual(result['access_token'], result_from_cache['access_token'], - "We should get a fresh AT (via RT)") - - -@unittest.skipUnless("client_id" in CONFIG, "client_id missing") -class TestConfidentialClientApplication(unittest.TestCase): - - def assertCacheWorks(self, result_from_wire, result_from_cache): - self.assertIsNotNone(result_from_cache) - self.assertEqual( - result_from_wire['access_token'], result_from_cache['access_token']) - - @unittest.skipUnless("client_secret" in CONFIG, "Missing client secret") - def test_client_secret(self): - app = ConfidentialClientApplication( - CONFIG["client_id"], client_credential=CONFIG.get("client_secret"), - authority=CONFIG.get("authority")) - scope = CONFIG.get("scope", []) - result = app.acquire_token_for_client(scope) - self.assertIn('access_token', result) - self.assertCacheWorks(result, app.acquire_token_silent(scope, account=None)) - - @unittest.skipUnless("client_certificate" in CONFIG, "Missing client cert") - def test_client_certificate(self): - client_certificate = CONFIG["client_certificate"] - assert ("private_key_path" in client_certificate - and "thumbprint" in client_certificate) - key_path = os.path.join(THIS_FOLDER, client_certificate['private_key_path']) - with open(key_path) as f: - pem = f.read() - app = ConfidentialClientApplication( - CONFIG['client_id'], - {"private_key": pem, "thumbprint": client_certificate["thumbprint"]}) - scope = CONFIG.get("scope", []) - result = app.acquire_token_for_client(scope) - self.assertIn('access_token', result) - self.assertCacheWorks(result, app.acquire_token_silent(scope, account=None)) +class TestHelperExtractCerts(unittest.TestCase): # It is used by SNI scenario def test_extract_a_tag_less_public_cert(self): pem = "my_cert" @@ -116,92 +38,13 @@ def test_extract_multiple_tag_enclosed_certs(self): -----BEGIN CERTIFICATE----- my_cert1 -----END CERTIFICATE----- - + -----BEGIN CERTIFICATE----- my_cert2 -----END CERTIFICATE----- """ self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem)) - @unittest.skipUnless("public_certificate" in CONFIG, "Missing Public cert") - def test_subject_name_issuer_authentication(self): - assert ("private_key_file" in CONFIG - and "thumbprint" in CONFIG and "public_certificate" in CONFIG) - with open(os.path.join(THIS_FOLDER, CONFIG['private_key_file'])) as f: - pem = f.read() - with open(os.path.join(THIS_FOLDER, CONFIG['public_certificate'])) as f: - public_certificate = f.read() - app = ConfidentialClientApplication( - CONFIG['client_id'], authority=CONFIG["authority"], - client_credential={"private_key": pem, "thumbprint": CONFIG["thumbprint"], - "public_certificate": public_certificate}) - scope = CONFIG.get("scope", []) - result = app.acquire_token_for_client(scope) - self.assertIn('access_token', result) - self.assertCacheWorks(result, app.acquire_token_silent(scope, account=None)) - -@unittest.skipUnless("client_id" in CONFIG, "client_id missing") -class TestPublicClientApplication(Oauth2TestCase): - - @unittest.skipUnless("username" in CONFIG and "password" in CONFIG, "Missing U/P") - def test_username_password(self): - self.app = PublicClientApplication( - CONFIG["client_id"], authority=CONFIG["authority"]) - result = self.app.acquire_token_by_username_password( - CONFIG["username"], CONFIG["password"], scopes=CONFIG.get("scope")) - self.assertLoosely(result) - self.assertCacheWorks(result) - - def test_device_flow(self): - self.app = PublicClientApplication( - CONFIG["client_id"], authority=CONFIG["authority"]) - flow = self.app.initiate_device_flow(scopes=CONFIG.get("scope")) - assert "user_code" in flow, str(flow) # Provision or policy might block DF - logging.warning(flow["message"]) - - duration = 30 - logging.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.app.acquire_token_by_device_flow(flow) - self.assertLoosely( - result, - assertion=lambda: self.assertIn('access_token', result), - skippable_errors=self.app.client.DEVICE_FLOW_RETRIABLE_ERRORS) - - if "access_token" in result: - self.assertCacheWorks(result) - - -@unittest.skipUnless("client_id" in CONFIG, "client_id missing") -class TestClientApplication(Oauth2TestCase): - - @classmethod - def setUpClass(cls): - cls.app = ClientApplication( - CONFIG["client_id"], client_credential=CONFIG.get("client_secret"), - authority=CONFIG.get("authority")) - - @unittest.skipUnless("scope" in CONFIG, "Missing scope") - def test_auth_code(self): - from msal.oauth2cli.authcode import obtain_auth_code - port = CONFIG.get("listen_port", 44331) - redirect_uri = "http://localhost:%s" % port - auth_request_uri = self.app.get_authorization_request_url( - CONFIG["scope"], redirect_uri=redirect_uri) - ac = obtain_auth_code(port, auth_uri=auth_request_uri) - self.assertNotEqual(ac, None) - - result = self.app.acquire_token_by_authorization_code( - ac, CONFIG["scope"], redirect_uri=redirect_uri) - logging.debug("cache = %s", 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.assertCacheWorks(result) - class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase): diff --git a/tests/test_client.py b/tests/test_client.py index 8d4166e1..87d2ecf6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -78,6 +78,7 @@ def load_conf(filename): # 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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index acc234d9..98118362 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,6 +1,7 @@ import logging import os import json +import time import requests @@ -31,8 +32,7 @@ def assertLoosely(self, response, assertion=None, error_description=response.get("error_description"))) assertion() - def assertCacheWorks(self, result_from_wire, username, scope): - result = result_from_wire + def assertCacheWorksForUser(self, result_from_wire, scope, username=None): # 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)) @@ -40,16 +40,26 @@ def assertCacheWorks(self, result_from_wire, username, scope): # Going to test acquire_token_silent(...) to locate an AT from cache result_from_cache = self.app.acquire_token_silent(scope, account=account) self.assertIsNotNone(result_from_cache) - self.assertEqual(result['access_token'], result_from_cache['access_token'], - "We should get a cached AT") + self.assertEqual( + result_from_wire['access_token'], result_from_cache['access_token'], + "We should get a cached AT") # 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) self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") - self.assertNotEqual(result['access_token'], result_from_cache['access_token'], - "We should get a fresh AT (via RT)") + self.assertNotEqual( + result_from_wire['access_token'], result_from_cache['access_token'], + "We should get a fresh AT (via RT)") + + def assertCacheWorksForApp(self, result_from_wire, scope): + # 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, @@ -60,19 +70,137 @@ def _test_username_password(self, username, password, scopes=scope) self.assertLoosely(result) # self.assertEqual(None, result.get("error"), str(result)) - self.assertCacheWorks(result, username, scope) + self.assertCacheWorksForUser(result, scope, username=username) -CONFIG = os.path.join(os.path.dirname(__file__), "config.json") -@unittest.skipIf(not os.path.exists(CONFIG), "Optional %s not found" % CONFIG) +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): - def setUp(self): + # 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: - self.config = json.load(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 test_auth_code(self): + self.skipUnlessWithConfig(["client_id", "scope"]) + from msal.oauth2cli.authcode import obtain_auth_code + self.app = msal.ClientApplication( + self.config["client_id"], + client_credential=self.config.get("client_secret"), + authority=self.config.get("authority")) + port = self.config.get("listen_port", 44331) + redirect_uri = "http://localhost:%s" % port + auth_request_uri = self.app.get_authorization_request_url( + self.config["scope"], redirect_uri=redirect_uri) + ac = obtain_auth_code(port, auth_uri=auth_request_uri) + self.assertNotEqual(ac, None) + + result = self.app.acquire_token_by_authorization_code( + ac, self.config["scope"], redirect_uri=redirect_uri) + 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_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")) + 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"]}) + 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, + }) + 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): + scopes = self.config["scope"] + self.app = msal.PublicClientApplication( + self.config['client_id'], authority=self.config["authority"]) + flow = self.app.initiate_device_flow(scopes=scopes) + 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.skip("End user did not complete Device Flow in time") + self.assertCacheWorksForUser(result, scopes, username=None) + result["access_token"] = result["refresh_token"] = "************" + logger.info( + "%s obtained tokens: %s", self.id(), json.dumps(result, indent=4)) + + def get_lab_user(mam=False, mfa=False, isFederated=False, federationProvider=None): # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx user = requests.get("https://api.msidlab.com/api/user", params=dict( # Publicly available From 8ba9cf3f05d94a8259423fd3592be2ee2e195f18 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Oct 2016 11:18:25 -0700 Subject: [PATCH 115/440] OnBehalfOf implementation --- msal/application.py | 15 +++++++++++++-- tests/test_application.py | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index f7f92d2a..5cc2a00b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -639,6 +639,17 @@ def acquire_token_for_client(self, scopes, **kwargs): scope=scopes, # This grant flow requires no scope decoration **kwargs) - def acquire_token_on_behalf_of(self, user_assertion, scopes, authority=None): - raise NotImplementedError() + def acquire_token_on_behalf_of( + self, user_assertion, scope, authority=None, policy=''): + the_authority = Authority(authority) if authority else self.authority + return oauth2.Client( + self.client_id, token_endpoint=the_authority.token_endpoint, + default_body=self._build_auth_parameters( + self.client_credential, the_authority.token_endpoint, + self.client_id) + )._get_token( # TODO: Avoid using internal methods + "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion=user_assertion, requested_token_use='on_behalf_of', + scope=scope, # This grant flow requires no scope decoration??? + query={'p': policy} if policy else None) diff --git a/tests/test_application.py b/tests/test_application.py index cc072838..2504ae02 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -194,3 +194,12 @@ def test_acquire_token_silent(self): self.assertNotEqual(None, at) self.assertEqual(self.access_token, at.get('access_token')) + def test_acquire_token_obo(self): + token = self.app.acquire_token_on_behalf_of( + self.token['access_token'], self.scope2) + error_description = token.get('error_description', "") + if 'grant is not supported by this API version' in error_description: + raise unittest.SkipTest( + "OBO is not yet supported by service: %s" % error_description) + self.assertEqual(error_description, "") + From 2fa303189ae98f76fa6897ea9c051fc4e9bea0a8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 5 Sep 2019 16:10:48 -0700 Subject: [PATCH 116/440] Rework the previous work stopped at 3-years ago --- msal/application.py | 37 +++++++++++++++++++++++-------------- tests/test_application.py | 9 --------- tests/test_e2e.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/msal/application.py b/msal/application.py index 5cc2a00b..44bcff50 100644 --- a/msal/application.py +++ b/msal/application.py @@ -624,7 +624,7 @@ def _acquire_token_by_username_password_federated( class ConfidentialClientApplication(ClientApplication): # server-side web app def acquire_token_for_client(self, scopes, **kwargs): - """Acquires token from the service for the confidential client. + """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). @@ -639,17 +639,26 @@ def acquire_token_for_client(self, scopes, **kwargs): scope=scopes, # This grant flow requires no scope decoration **kwargs) - def acquire_token_on_behalf_of( - self, user_assertion, scope, authority=None, policy=''): - the_authority = Authority(authority) if authority else self.authority - return oauth2.Client( - self.client_id, token_endpoint=the_authority.token_endpoint, - default_body=self._build_auth_parameters( - self.client_credential, the_authority.token_endpoint, - self.client_id) - )._get_token( # TODO: Avoid using internal methods - "urn:ietf:params:oauth:grant-type:jwt-bearer", - assertion=user_assertion, requested_token_use='on_behalf_of', - scope=scope, # This grant flow requires no scope decoration??? - query={'p': policy} if policy else None) + def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): + """Acquires token using on-behalf-of (OBO) flow. + + The current app is a middle-tier service which already receives 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 service, on behalf of that user. + + 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 + """ + # The implementation is NOT based on Token Exchange + # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 + return 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=scopes, # Without decorate_scope(...), it still gets an AT. + # As of 2019, AAD would even issue RT, and ClientInfo i.e. account. + # No IDT will be issued. OBO app probably does not need one anyway. + data=dict(kwargs.pop("data", {}), requested_token_use="on_behalf_of"), + **kwargs) diff --git a/tests/test_application.py b/tests/test_application.py index 2504ae02..cc072838 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -194,12 +194,3 @@ def test_acquire_token_silent(self): self.assertNotEqual(None, at) self.assertEqual(self.access_token, at.get('access_token')) - def test_acquire_token_obo(self): - token = self.app.acquire_token_on_behalf_of( - self.token['access_token'], self.scope2) - error_description = token.get('error_description', "") - if 'grant is not supported by this API version' in error_description: - raise unittest.SkipTest( - "OBO is not yet supported by service: %s" % error_description) - self.assertEqual(error_description, "") - diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 98118362..58c6f1f9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -327,3 +327,33 @@ def test_adfs2019_fed_user(self): self._test_username_password( password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + @unittest.skipUnless( + os.getenv("OBO_CLIENT_SECRET"), + "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") + def test_acquire_token_obo(self): # It hardcodes many pre-defined resources + obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c" + obo_scopes = ["https://graph.microsoft.com/User.Read"] + config = get_lab_user(isFederated=False) + pca = msal.PublicClientApplication( + "be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority")) + pca_result = pca.acquire_token_by_username_password( + config["username"], + self.get_lab_user_secret(config["lab"]["labname"]), + scopes=["%s/access_as_user" % obo_client_id], # Need setup beforehand + ) + self.assertNotEqual(None, pca_result.get("access_token"), "PCA should work") + + cca = msal.ConfidentialClientApplication( + obo_client_id, + client_credential=os.getenv("OBO_CLIENT_SECRET"), + authority=config.get("authority")) + cca_result = cca.acquire_token_on_behalf_of( + pca_result['access_token'], obo_scopes) + self.assertNotEqual(None, cca_result.get("access_token"), str(cca_result)) + + # Cache would also work, with the one-cache-per-user caveat. + if len(cca.get_accounts()) == 1: + account = cca.get_accounts()[0] # This test involves only 1 account + result = cca.acquire_token_silent(obo_scopes, account) + self.assertEqual(cca_result["access_token"], result["access_token"]) + From 83bf599e17e8f68cdeb782bb57c6b600ba89ab8c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 12 Sep 2019 13:54:16 -0700 Subject: [PATCH 117/440] Refactor to provide IDT, and demo how to do cache --- msal/application.py | 9 ++++++--- tests/test_e2e.py | 36 ++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/msal/application.py b/msal/application.py index 44bcff50..76cd9e32 100644 --- a/msal/application.py +++ b/msal/application.py @@ -656,9 +656,12 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): return 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=scopes, # Without decorate_scope(...), it still gets an AT. - # As of 2019, AAD would even issue RT, and ClientInfo i.e. account. - # No IDT will be issued. OBO app probably does not need one anyway. + scope=decorate_scope(scopes, self.client_id), # 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"), **kwargs) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 58c6f1f9..c8be77f9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -330,30 +330,46 @@ def test_adfs2019_fed_user(self): @unittest.skipUnless( os.getenv("OBO_CLIENT_SECRET"), "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") - def test_acquire_token_obo(self): # It hardcodes many pre-defined resources + def test_acquire_token_obo(self): + # Some hardcoded, pre-defined settings obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c" - obo_scopes = ["https://graph.microsoft.com/User.Read"] + downstream_scopes = ["https://graph.microsoft.com/User.Read"] config = get_lab_user(isFederated=False) + + # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( "be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority")) pca_result = pca.acquire_token_by_username_password( config["username"], self.get_lab_user_secret(config["lab"]["labname"]), - scopes=["%s/access_as_user" % obo_client_id], # Need setup beforehand + scopes=[ # The OBO app's scope. Yours might be different. + "%s/access_as_user" % obo_client_id], ) - self.assertNotEqual(None, pca_result.get("access_token"), "PCA should work") + self.assertIsNotNone(pca_result.get("access_token"), "PCA should work") + # 2. Our mid-tier service uses OBO to obtain a token for downstream service cca = msal.ConfidentialClientApplication( obo_client_id, client_credential=os.getenv("OBO_CLIENT_SECRET"), - authority=config.get("authority")) + authority=config.get("authority"), + # 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'], obo_scopes) + pca_result['access_token'], downstream_scopes) self.assertNotEqual(None, cca_result.get("access_token"), str(cca_result)) - # Cache would also work, with the one-cache-per-user caveat. - if len(cca.get_accounts()) == 1: - account = cca.get_accounts()[0] # This test involves only 1 account - result = cca.acquire_token_silent(obo_scopes, account) + # 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") + self.assertEqual(config["username"], username) + if username: # A precaution so that we won't use other user's token + account = cca.get_accounts(username=username)[0] + result = cca.acquire_token_silent(downstream_scopes, account) self.assertEqual(cca_result["access_token"], result["access_token"]) From 73962d25bc8d5487517dcc97025b04642c333a4f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 13 Sep 2019 10:55:43 -0700 Subject: [PATCH 118/440] Fine tune API docs --- msal/application.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/msal/application.py b/msal/application.py index 76cd9e32..f936bfe4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -646,10 +646,19 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): representing an end user. The current app can use such token (a.k.a. a user assertion) to request another token to access downstream service, 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). + + :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". """ # The implementation is NOT based on Token Exchange # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 From 8ee300faf7fcc197b3f7335f8a4a185dca2854d5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sun, 22 Sep 2019 23:22:37 -0700 Subject: [PATCH 119/440] Wire up kwargs for auth code flow and device flow --- msal/application.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/msal/application.py b/msal/application.py index f7f92d2a..7bc83732 100644 --- a/msal/application.py +++ b/msal/application.py @@ -238,7 +238,7 @@ def acquire_token_by_authorization_code( # 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. - ): + **kwargs): """The second half of the Authorization Code Grant. :param code: The authorization code returned from Authorization Server. @@ -270,9 +270,11 @@ def acquire_token_by_authorization_code( # really empty. assert isinstance(scopes, list), "Invalid parameter type" return self.client.obtain_token_by_authorization_code( - code, redirect_uri=redirect_uri, - data={"scope": decorate_scope(scopes, self.client_id)}, - ) + code, redirect_uri=redirect_uri, + data=dict( + kwargs.pop("data", {}), + scope=decorate_scope(scopes, self.client_id)), + **kwargs) def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. @@ -551,7 +553,8 @@ def acquire_token_by_device_flow(self, flow, **kwargs): """ return self.client.obtain_token_by_device_flow( flow, - data={"code": flow["device_code"]}, # 2018-10-4 Hack: + 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. **kwargs) From 2f329dc1933e742766e710fda92e82299f793bd9 Mon Sep 17 00:00:00 2001 From: Bogdan Gavril Date: Tue, 24 Sep 2019 08:33:59 +0100 Subject: [PATCH 120/440] [SSH] Serialize AT token_type and expose in result (#95) --- msal/application.py | 2 +- msal/token_cache.py | 1 + tests/test_token_cache.py | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 7bc83732..4502a454 100644 --- a/msal/application.py +++ b/msal/application.py @@ -441,7 +441,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( logger.debug("Cache hit an AT") return { # Mimic a real response "access_token": entry["secret"], - "token_type": "Bearer", + "token_type": entry.get("token_type", "Bearer"), "expires_in": int(expires_in), # OAuth2 specs defines it as int } return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( diff --git a/msal/token_cache.py b/msal/token_cache.py index f3f647fe..9ca30df7 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -160,6 +160,7 @@ def __add(self, event, now=None): "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 diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 85f44576..c1f33ff9 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -83,6 +83,7 @@ def testAddByAad(self): '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') @@ -155,6 +156,7 @@ def testAddByAdfs(self): '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') From 1e6a4aa7fc0bfeea12edebf1d24ef1a3d40cc82b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 24 Sep 2019 12:10:15 -0700 Subject: [PATCH 121/440] Update username_password_sample.py --- sample/username_password_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index 055e24d0..6a032f7f 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -61,4 +61,4 @@ print(result.get("correlation_id")) # You may need this when reporting a bug if 65001 in result.get("error_codes", []): # Not mean to be coded programatically, but... # AAD requires user consent for U/P flow - print("Visit this to consent:", app.get_authorization_request_url(scope)) + print("Visit this to consent:", app.get_authorization_request_url(config["scope"])) From 8c5fd1840b8e1b68d2c48d5f6cfde89a4074234e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 25 Sep 2019 11:54:26 -0700 Subject: [PATCH 122/440] Addressing PR comments --- msal/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index f936bfe4..6601f79e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -642,10 +642,10 @@ def acquire_token_for_client(self, scopes, **kwargs): def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): """Acquires token using on-behalf-of (OBO) flow. - The current app is a middle-tier service which already receives a token + 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 service, on behalf of that user. + 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. From 5f00c8d7bfbfc92505dcbd4cd05e438f44fbbb9c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 25 Sep 2019 14:15:35 -0700 Subject: [PATCH 123/440] Retire the old auth code sample, refer to the new one --- .../authorization_code_flow_sample.py | 87 +------------------ .../templates/display.html | 19 ---- 2 files changed, 3 insertions(+), 103 deletions(-) delete mode 100644 sample/authorization-code-flow-sample/templates/display.html diff --git a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py index eea11dff..fb1503d0 100644 --- a/sample/authorization-code-flow-sample/authorization_code_flow_sample.py +++ b/sample/authorization-code-flow-sample/authorization_code_flow_sample.py @@ -1,84 +1,3 @@ -""" -The configuration file would look like this: - -{ - "authority": "https://login.microsoftonline.com/organizations", - "client_id": "your_client_id", - "scope": ["https://graph.microsoft.com/.default"], - "redirect_uri": "http://localhost:5000/getAToken", - // Configure this redirect uri for this sample - // redirect_uri should match what you've configured in here - // https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#add-redirect-uris-to-your-application - "client_secret": "yoursecret" -} - -You can then run this sample with a JSON configuration file: - - python sample.py parameters.json your_flask_session_secret_here - -And the on the browser open http://localhost:5000/ - -""" - -import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] -import json -import logging -import uuid -import os - -import flask - -import msal - -app = flask.Flask(__name__) -app.debug = True -app.secret_key = os.environ.get("FLASK_SECRET") -assert app.secret_key, "This sample requires a FLASK_SECRET env var to enable session" - - -# Optional logging -# logging.basicConfig(level=logging.DEBUG) - -config = json.load(open(sys.argv[1])) - -application = msal.ConfidentialClientApplication( - config["client_id"], authority=config["authority"], - client_credential=config["client_secret"], - # token_cache=... # Default cache is in memory only. - # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache - ) - - -@app.route("/") -def main(): - resp = flask.Response(status=307) - resp.headers['location'] = '/login' - return resp - - -@app.route("/login") -def login(): - auth_state = str(uuid.uuid4()) - flask.session['state'] = auth_state - authorization_url = application.get_authorization_request_url(config['scope'], state=auth_state, - redirect_uri=config['redirect_uri']) - resp = flask.Response(status=307) - resp.headers['location'] = authorization_url - return resp - - -@app.route("/getAToken") -def main_logic(): - code = flask.request.args['code'] - state = flask.request.args['state'] - if state != flask.session['state']: - raise ValueError("State does not match") - - result = application.acquire_token_by_authorization_code(code, scopes=config["scope"], - redirect_uri=config['redirect_uri']) - return flask.render_template('display.html', auth_result=result) - - -if __name__ == "__main__": - app.run() +# We have moved! +# +# Please visit https://github.com/Azure-Samples/ms-identity-python-webapp diff --git a/sample/authorization-code-flow-sample/templates/display.html b/sample/authorization-code-flow-sample/templates/display.html deleted file mode 100644 index 78522b70..00000000 --- a/sample/authorization-code-flow-sample/templates/display.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Acquire Token Result - - -Acquire Token Result - -{% for key, value in auth_result.items() %} - - - - -{% endfor %} -
{{ key }} {{ value }}
- - - \ No newline at end of file From 3b4ea1acdc920867ea85d71dae15db2e76b4818f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 25 Sep 2019 13:47:03 -0700 Subject: [PATCH 124/440] MSAL Python 0.7.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index f59c093e..26f12860 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.6.1" +__version__ = "0.7.0" logger = logging.getLogger(__name__) From 95db2747e866b69ce24250f6ec8786e5aabf55ea Mon Sep 17 00:00:00 2001 From: Bogdan Gavril Date: Sat, 28 Sep 2019 00:36:43 +0100 Subject: [PATCH 125/440] [SSH] SSH support including ATs bound to keys (#102) * [SSH] SSH support including ATs bound to keys * Fix test * Do not use casefold() * Move _validate_ssh_cert_input_data() to outer layer This at least avoids the performance penalty in those implicit loops inside acquire_token_silent(). * Code style "Don't use spaces around the = sign when used to indicate a keyword argument" - quoted from https://www.python.org/dev/peps/pep-0008/#other-recommendations * General tidy up * Move AT key_id test into its own unit test * Remove an extra space * Fix typo introduced in online editing via browser :-( --- msal/application.py | 30 ++++++++++++++++++---- msal/token_cache.py | 3 +++ tests/test_e2e.py | 52 ++++++++++++++++++++++++++++++++++++--- tests/test_token_cache.py | 17 +++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/msal/application.py b/msal/application.py index 26f12860..5cb20146 100644 --- a/msal/application.py +++ b/msal/application.py @@ -269,6 +269,7 @@ def acquire_token_by_authorization_code( # 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", {})) return self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, data=dict( @@ -396,6 +397,7 @@ def acquire_token_silent( - None when cache lookup does not yield anything. """ assert isinstance(scopes, list), "Invalid parameter type" + self._validate_ssh_cert_input_data(kwargs.get("data", {})) if authority: warnings.warn("We haven't decided how/if this method will accept authority parameter") # the_authority = Authority( @@ -424,15 +426,19 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( force_refresh=False, # type: Optional[boolean] **kwargs): if not force_refresh: - matches = self.token_cache.find( - self.token_cache.CredentialType.ACCESS_TOKEN, - target=scopes, - query={ + 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() for entry in matches: expires_in = int(entry["expires_on"]) - now @@ -513,6 +519,20 @@ def _acquire_token_silent_by_finding_specific_refresh_token( if break_condition(response): break + 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.") + class PublicClientApplication(ClientApplication): # browser app or mobile app diff --git a/msal/token_cache.py b/msal/token_cache.py index 9ca30df7..edad7236 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -127,6 +127,7 @@ def __add(self, event, now=None): if "token_endpoint" in event: _, environment, realm = canonicalize(event["token_endpoint"]) 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") @@ -165,6 +166,8 @@ def __add(self, event, now=None): "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") self.modify(self.CredentialType.ACCESS_TOKEN, at, at) if client_info: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c8be77f9..2bf80508 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -94,19 +94,23 @@ def test_username_password(self): self.skipUnlessWithConfig(["client_id", "username", "password", "scope"]) self._test_username_password(**self.config) - def test_auth_code(self): - self.skipUnlessWithConfig(["client_id", "scope"]) + def _get_app_and_auth_code(self): from msal.oauth2cli.authcode import obtain_auth_code - self.app = msal.ClientApplication( + app = msal.ClientApplication( self.config["client_id"], client_credential=self.config.get("client_secret"), authority=self.config.get("authority")) port = self.config.get("listen_port", 44331) redirect_uri = "http://localhost:%s" % port - auth_request_uri = self.app.get_authorization_request_url( + auth_request_uri = app.get_authorization_request_url( self.config["scope"], redirect_uri=redirect_uri) ac = obtain_auth_code(port, auth_uri=auth_request_uri) self.assertNotEqual(ac, None) + return (app, ac, redirect_uri) + + def test_auth_code(self): + self.skipUnlessWithConfig(["client_id", "scope"]) + (self.app, ac, redirect_uri) = self._get_app_and_auth_code() result = self.app.acquire_token_by_authorization_code( ac, self.config["scope"], redirect_uri=redirect_uri) @@ -120,6 +124,46 @@ def test_auth_code(self): error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, self.config["scope"], username=None) + + def test_ssh_cert(self): + self.skipUnlessWithConfig(["client_id", "scope"]) + + 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} + ssh_test_slice = { + "dc": "prod-wst-test1", + "slice": "test", + "sshcrt": "true", + } + + (self.app, ac, redirect_uri) = self._get_app_and_auth_code() + + result = self.app.acquire_token_by_authorization_code( + ac, self.config["scope"], redirect_uri=redirect_uri, data=data1, + params=ssh_test_slice) + self.assertEqual("ssh-cert", result["token_type"]) + logger.debug("%s.cache = %s", + self.id(), json.dumps(self.app.token_cache._cache, indent=4)) + + # acquire_token_silent() needs to be passed the same key to work + account = self.app.get_accounts()[0] + result_from_cache = self.app.acquire_token_silent( + self.config["scope"], account=account, data=data1) + self.assertIsNotNone(result_from_cache) + self.assertEqual( + result['access_token'], result_from_cache['access_token'], + "We should get the cached SSH-cert") + + # refresh_token grant can fetch an ssh-cert bound to a different key + refreshed_ssh_cert = self.app.acquire_token_silent( + self.config["scope"], account=account, params=ssh_test_slice, + data={"token_type": "ssh-cert", "key_id": "key2", "req_cnf": JWK2}) + self.assertIsNotNone(refreshed_ssh_cert) + self.assertEqual(refreshed_ssh_cert["token_type"], "ssh-cert") + self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) + + def test_client_secret(self): self.skipUnlessWithConfig(["client_id", "client_secret"]) self.app = msal.ConfidentialClientApplication( diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index c1f33ff9..1666bba2 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -205,6 +205,23 @@ def testAddByAdfs(self): "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": self.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") + class SerializableTokenCacheTestCase(TokenCacheTestCase): # Run all inherited test methods, and have extra check in tearDown() From 589847ba5a1cc5f42260daf7e8474c861c236f44 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 4 Oct 2019 13:43:35 -0700 Subject: [PATCH 126/440] Refactor: instance_discovery() emits actionable msg It will guide app developer to use validate_authority=False when needed. --- msal/authority.py | 22 +++++++++++++--------- tests/test_authority.py | 23 ++++------------------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 2e3a8185..69ccb762 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -46,9 +46,18 @@ def __init__(self, authority_url, validate_authority=True, )) if (tenant != "adfs" and validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): - tenant_discovery_endpoint = instance_discovery( + payload = instance_discovery( canonicalized + "/oauth2/v2.0/authorize", verify=verify, proxies=proxies, timeout=timeout) + 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'] openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) @@ -80,20 +89,15 @@ def canonicalize(url): "https://login.microsoftonline.com/" % url) return match_object.group(0), match_object.group(1), match_object.group(2) -def instance_discovery(url, response=None, **kwargs): - # Returns tenant discovery endpoint - resp = requests.get( # Note: This URL seemingly returns V1 endpoint only +def instance_discovery(url, **kwargs): + return requests.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) - payload = response or resp.json() - if 'tenant_discovery_endpoint' not in payload: - raise MsalServiceError(status_code=resp.status_code, **payload) - return payload['tenant_discovery_endpoint'] + **kwargs).json() def tenant_discovery(tenant_discovery_endpoint, **kwargs): # Returns Openid Configuration diff --git a/tests/test_authority.py b/tests/test_authority.py index d7fc5cac..ca1ca9b3 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -1,8 +1,11 @@ +import os + from msal.authority import * from msal.exceptions import MsalServiceError from tests import unittest +@unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release") class TestAuthority(unittest.TestCase): def test_wellknown_host_and_tenant(self): @@ -26,7 +29,7 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): self.assertNotIn('v2.0', a.token_endpoint) def test_unknown_host_wont_pass_instance_discovery(self): - with self.assertRaisesRegexp(MsalServiceError, "invalid_instance"): + with self.assertRaisesRegexp(ValueError, "invalid_instance"): Authority('https://unknown.host/tenant_doesnt_matter_in_this_case') def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(self): @@ -63,21 +66,3 @@ def test_canonicalize_rejects_tenantless_host_with_trailing_slash(self): with self.assertRaises(ValueError): canonicalize("https://no.tenant.example.com/") - -class TestAuthorityInternalHelperInstanceDiscovery(unittest.TestCase): - - def test_instance_discovery_happy_case(self): - self.assertEqual( - instance_discovery("https://login.windows.net/tenant"), - "https://login.windows.net/tenant/.well-known/openid-configuration") - - def test_instance_discovery_with_unknown_instance(self): - with self.assertRaisesRegexp(MsalServiceError, "invalid_instance"): - instance_discovery('https://unknown.host/tenant_doesnt_matter_here') - - def test_instance_discovery_with_mocked_response(self): - mock_response = {'tenant_discovery_endpoint': 'http://a.com/t/openid'} - endpoint = instance_discovery( - "https://login.microsoftonline.in/tenant.com", response=mock_response) - self.assertEqual(endpoint, mock_response['tenant_discovery_endpoint']) - From ef72d21eb2e5ae8ae831e9040c807bbfad6bb84a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 4 Oct 2019 14:15:02 -0700 Subject: [PATCH 127/440] Refactor: user_realm_discovery() memorizes domains not supporting URD This would become handy when we meet B2C authority with customized domain. --- msal/authority.py | 24 +++++++++++++++--------- tests/test_authority.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 69ccb762..8db2da6c 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -23,6 +23,8 @@ class Authority(object): 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([]) + def __init__(self, authority_url, validate_authority=True, verify=True, proxies=None, timeout=None, ): @@ -67,17 +69,21 @@ def __init__(self, authority_url, validate_authority=True, _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID self.is_adfs = self.tenant.lower() == 'adfs' - def user_realm_discovery(self, username): - resp = requests.get( - "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( - netloc=self.instance, username=username), - headers={'Accept':'application/json'}, - verify=self.verify, proxies=self.proxies, timeout=self.timeout) - resp.raise_for_status() - return resp.json() - # It will typically contain "ver", "account_type", + def user_realm_discovery(self, username, 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 requests.get( + "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( + netloc=self.instance, username=username), + headers={'Accept':'application/json'}, + verify=self.verify, proxies=self.proxies, timeout=self.timeout) + if resp.status_code != 404: + resp.raise_for_status() + return resp.json() + self.__class__._domains_without_user_realm_discovery.add(self.instance) + return {} # This can guide the caller to fall back normal ROPC flow def canonicalize(url): # Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors. diff --git a/tests/test_authority.py b/tests/test_authority.py index ca1ca9b3..45381a33 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -66,3 +66,23 @@ 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, validate_authority=False) + + # 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") + From 2a21d4b3673726a6c5662a83757e72c6912e799e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 4 Oct 2019 15:29:23 -0700 Subject: [PATCH 128/440] B2C implementation --- msal/authority.py | 41 ++++++++++------- tests/test_authority.py | 18 ++++---- tests/test_e2e.py | 98 ++++++++++++++++++++++++++++++++++------- 3 files changed, 115 insertions(+), 42 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 8db2da6c..7ade2788 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -1,4 +1,7 @@ -import re +try: + from urllib.parse import urlparse +except ImportError: # Fall back to Python 2 + from urlparse import urlparse import logging import requests @@ -39,17 +42,13 @@ def __init__(self, authority_url, validate_authority=True, self.verify = verify self.proxies = proxies self.timeout = timeout - canonicalized, self.instance, tenant = canonicalize(authority_url) - tenant_discovery_endpoint = ( - 'https://{}/{}{}/.well-known/openid-configuration'.format( - self.instance, - tenant, - "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint - )) - if (tenant != "adfs" and validate_authority + authority, self.instance, tenant = canonicalize(authority_url) + is_b2c = self.instance.endswith(".b2clogin.com") + if (tenant != "adfs" and (not is_b2c) and validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): payload = instance_discovery( - canonicalized + "/oauth2/v2.0/authorize", + "https://{}{}/oauth2/v2.0/authorize".format( + self.instance, authority.path), verify=verify, proxies=proxies, timeout=timeout) if payload.get("error") == "invalid_instance": raise ValueError( @@ -60,6 +59,13 @@ def __init__(self, authority_url, validate_authority=True, "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 + )) openid_config = tenant_discovery( tenant_discovery_endpoint, verify=verify, proxies=proxies, timeout=timeout) @@ -85,15 +91,18 @@ def user_realm_discovery(self, username, response=None): self.__class__._domains_without_user_realm_discovery.add(self.instance) return {} # This can guide the caller to fall back normal ROPC flow -def canonicalize(url): - # Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors. - match_object = re.match(r'https://([^/]+)/([^/?#]+)', url.lower()) - if not match_object: + +def canonicalize(authority_url): + 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/" % url) - return match_object.group(0), match_object.group(1), match_object.group(2) + "https://login.microsoftonline.com/ " + "or https://.b2clogin.com/.onmicrosoft.com/policy" + % authority_url) + return authority, authority.netloc, parts[1] def instance_discovery(url, **kwargs): return requests.get( # Note: This URL seemingly returns V1 endpoint only diff --git a/tests/test_authority.py b/tests/test_authority.py index 45381a33..340b4936 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -40,19 +40,19 @@ def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(s class TestAuthorityInternalHelperCanonicalize(unittest.TestCase): def test_canonicalize_tenant_followed_by_extra_paths(self): - self.assertEqual( - canonicalize("https://example.com/tenant/subpath?foo=bar#fragment"), - ("https://example.com/tenant", "example.com", "tenant")) + _, 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): - self.assertEqual( - canonicalize("https://example.com/tenant?foo=bar#fragment"), - ("https://example.com/tenant", "example.com", "tenant")) + _, 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): - self.assertEqual( - canonicalize("https://example.com/tenant#fragment"), - ("https://example.com/tenant", "example.com", "tenant")) + _, 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): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 2bf80508..baea3f58 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -13,6 +13,21 @@ logging.basicConfig(level=logging.INFO) +def _get_app_and_auth_code( + client_id, + client_secret=None, + authority="https://login.microsoftonline.com/common", + port=44331, + scopes=["https://graph.windows.net/.default"], + ): + from msal.oauth2cli.authcode import obtain_auth_code + app = msal.ClientApplication(client_id, client_secret, authority=authority) + redirect_uri = "http://localhost:%d" % port + ac = obtain_auth_code(port, auth_uri=app.get_authorization_request_url( + scopes, redirect_uri=redirect_uri)) + 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): @@ -49,9 +64,15 @@ def assertCacheWorksForUser(self, result_from_wire, scope, username=None): result_from_cache = self.app.acquire_token_silent(scope, account=account) self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") - self.assertNotEqual( - result_from_wire['access_token'], result_from_cache['access_token'], - "We should get a fresh AT (via RT)") + 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): # Going to test acquire_token_silent(...) to locate an AT from cache @@ -70,7 +91,10 @@ def _test_username_password(self, username, password, scopes=scope) self.assertLoosely(result) # self.assertEqual(None, result.get("error"), str(result)) - self.assertCacheWorksForUser(result, scope, username=username) + self.assertCacheWorksForUser( + result, scope, + username=username if ".b2clogin.com" not in authority else None, + ) THIS_FOLDER = os.path.dirname(__file__) @@ -95,23 +119,17 @@ def test_username_password(self): self._test_username_password(**self.config) def _get_app_and_auth_code(self): - from msal.oauth2cli.authcode import obtain_auth_code - app = msal.ClientApplication( + return _get_app_and_auth_code( self.config["client_id"], - client_credential=self.config.get("client_secret"), - authority=self.config.get("authority")) - port = self.config.get("listen_port", 44331) - redirect_uri = "http://localhost:%s" % port - auth_request_uri = app.get_authorization_request_url( - self.config["scope"], redirect_uri=redirect_uri) - ac = obtain_auth_code(port, auth_uri=auth_request_uri) - self.assertNotEqual(ac, None) - return (app, ac, redirect_uri) + client_secret=self.config.get("client_secret"), + authority=self.config.get("authority"), + port=self.config.get("listen_port", 44331), + scopes=self.config["scope"], + ) def test_auth_code(self): self.skipUnlessWithConfig(["client_id", "scope"]) (self.app, ac, redirect_uri) = self._get_app_and_auth_code() - result = self.app.acquire_token_by_authorization_code( ac, self.config["scope"], redirect_uri=redirect_uri) logger.debug("%s.cache = %s", @@ -314,7 +332,7 @@ 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) - # Note: Short link won't work "https://aka.ms/GetLabUserSecret?Secret=%s" + # Short link only works in browser "https://aka.ms/GetLabUserSecret?Secret=%s" # So we use the official link written in here # https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API%27s.aspx url = ("https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&Secret=%s" @@ -417,3 +435,49 @@ def test_acquire_token_obo(self): result = cca.acquire_token_silent(downstream_scopes, account) self.assertEqual(cca_result["access_token"], result["access_token"]) + 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 + """ + scopes = ["https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"] + (self.app, ac, redirect_uri) = _get_app_and_auth_code( + "b876a048-55a5-4fc5-9403-f5d90cb1c852", + client_secret=self.get_lab_user_secret("MSIDLABB2C-MSAapp-AppSecret"), + authority=self._build_b2c_authority("B2C_1_SignInPolicy"), + port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] + scopes=scopes, + ) + result = self.app.acquire_token_by_authorization_code( + ac, scopes, 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, scopes, username=None) + + def test_b2c_acquire_token_by_ropc(self): + self._test_username_password( + authority=self._build_b2c_authority("B2C_1_ROPC_Auth"), + client_id="e3b9ad76-9763-4827-b088-80c7a7888f79", + username="b2clocal@msidlabb2c.onmicrosoft.com", + password=self.get_lab_user_secret("msidlabb2c"), + scope=["https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"], + ) + From 3165e18793889ee1621d571b1a7a43e64280a3f0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 10 Oct 2019 10:51:34 -0700 Subject: [PATCH 129/440] Wire up acquire_token_silent(..., force_refresh=...) Interestingly, during last 10 months since this library was first released, there has been basically no customer found and asked for this feature (except only once, but that one was a red herring so he did not really want it https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/56#issue-453538977 ). --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 5cb20146..23293d07 100644 --- a/msal/application.py +++ b/msal/application.py @@ -414,7 +414,7 @@ def acquire_token_silent( validate_authority=False, verify=self.verify, proxies=self.proxies, timeout=self.timeout) result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( - scopes, account, the_authority, **kwargs) + scopes, account, the_authority, force_refresh=force_refresh, **kwargs) if result: return result From ab419ea49372de235ce3d04a689b0bf61ea28110 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 10 Oct 2019 14:58:37 -0700 Subject: [PATCH 130/440] get_authorization_request_url(...) API refactoring --- msal/application.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 23293d07..bd53653d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -194,8 +194,6 @@ def get_authorization_request_url( login_hint=None, # type: Optional[str] state=None, # Recommended by OAuth2 for CSRF protection redirect_uri=None, - authority=None, # By default, it will use self.authority; - # Multi-tenant app can use new authority on demand response_type="code", # Can be "token" if you use Implicit Grant **kwargs): """Constructs a URL for you to start a Authorization Code Grant. @@ -217,10 +215,17 @@ def get_authorization_request_url( (Under the hood, we simply merge scope and additional_scope before sending them on the wire.) """ + 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, verify=self.verify, proxies=self.proxies, timeout=self.timeout, ) if authority else self.authority + client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id) From 93196bf8e0126d0769c865db35e9bbd89aba1a48 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 30 Sep 2019 11:56:13 -0700 Subject: [PATCH 131/440] Customizable response_type, useful in B2C --- msal/application.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index bd53653d..d2621551 100644 --- a/msal/application.py +++ b/msal/application.py @@ -205,6 +205,9 @@ def get_authorization_request_url( 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 can use other content such as "id_token". :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() @@ -230,7 +233,7 @@ def get_authorization_request_url( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id) return client.build_auth_request_uri( - response_type="code", # Using Authorization Code grant + response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, scope=decorate_scope(scopes, self.client_id), ) From 77ce16deeb4bec5086f373801528b436da28c196 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 14 Oct 2019 16:48:36 -0700 Subject: [PATCH 132/440] Use Microsoft Graph rather than AAD Graph as default scope --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index baea3f58..770e4625 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -18,7 +18,7 @@ def _get_app_and_auth_code( client_secret=None, authority="https://login.microsoftonline.com/common", port=44331, - scopes=["https://graph.windows.net/.default"], + scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph ): from msal.oauth2cli.authcode import obtain_auth_code app = msal.ClientApplication(client_id, client_secret, authority=authority) From 502a1d145ff57c7674fb7624189a0a695cc786bf Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 14 Oct 2019 17:13:36 -0700 Subject: [PATCH 133/440] Better support for B2C sovereign clouds Based on the way we implemented it, the previous implementation would still work, in a sense that the app dev would be guided to bypass the Instance Discovery. This commit merely adds a shortcut so that app dev would not have to explicitly toggle validate_authority=False. --- msal/authority.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 7ade2788..56b3d4c6 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -18,7 +18,12 @@ 'login.microsoftonline.us', 'login.microsoftonline.de', ]) - +WELL_KNOWN_B2C_HOSTS = [ + "b2clogin.com", + "b2clogin.cn", + "b2clogin.us", + "b2clogin.de", + ] class Authority(object): """This class represents an (already-validated) authority. @@ -43,7 +48,7 @@ def __init__(self, authority_url, validate_authority=True, self.proxies = proxies self.timeout = timeout authority, self.instance, tenant = canonicalize(authority_url) - is_b2c = self.instance.endswith(".b2clogin.com") + is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) if (tenant != "adfs" and (not is_b2c) and validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): payload = instance_discovery( From 4a2edfb5a37c00712bb6e27bb54f553e44fde5bf Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 15 Oct 2019 12:10:57 -0700 Subject: [PATCH 134/440] Enable Python 3.8 in test automation --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index c5dc9268..85917242 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,9 @@ matrix: - python: 3.7 dist: xenial sudo: true + - python: 3.8 + dist: xenial + sudo: true install: - pip install -r requirements.txt From caa3a3a978f9a1e4160d956e5ce30bac4e44b359 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 17 Oct 2019 17:21:25 -0700 Subject: [PATCH 135/440] MSAL 0.8.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index d2621551..ba6bd844 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.7.0" +__version__ = "0.8.0" logger = logging.getLogger(__name__) From 2ee0fcaf677109c838040674c964ecce5b5205df Mon Sep 17 00:00:00 2001 From: Arthur Valadares Date: Tue, 22 Oct 2019 14:44:05 -0700 Subject: [PATCH 136/440] force_refresh not being passed to _acquire_token_silent_from_cache .... (#113) * force_refresh not being passed to _acquire_token_silent_from_cache_and_possibly_refresh_it acquire_token_silent is receiving force_refresh but it's not passing to the next function: _acquire_token_silent_from_cache_and_possibly_refresh_it. This update just adds that argument when calling the latter. * force_refresh is specified on acquire_token_silent As requested, the call to self._acquire_token_silent_from_cache_and_possibly_refresh_it now specifies force_refresh=force_refresh, similar to line 425. --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index ba6bd844..9879350e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -413,7 +413,7 @@ def acquire_token_silent( # verify=self.verify, proxies=self.proxies, timeout=self.timeout, # ) if authority else self.authority result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( - scopes, account, self.authority, **kwargs) + scopes, account, self.authority, force_refresh=force_refresh, **kwargs) if result: return result for alias in self._get_authority_aliases(self.authority.instance): From 027d86a861040774095da8e289727d76b760c1a6 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Fri, 25 Oct 2019 11:13:51 -0700 Subject: [PATCH 137/440] Changing B2C implementation to verify policy format (#114) --- msal/authority.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index 56b3d4c6..667f5ebd 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -48,7 +48,9 @@ def __init__(self, authority_url, validate_authority=True, self.proxies = proxies self.timeout = timeout authority, self.instance, tenant = canonicalize(authority_url) - is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) + 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( From 13d1b8f1ec8ce2c0a5428815ea68283c79f31762 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Fri, 25 Oct 2019 14:41:22 -0700 Subject: [PATCH 138/440] Updating samples to call MS graph (#112) --- .../confidential_client_certificate_sample.py | 27 ++++++++++++----- sample/confidential_client_secret_sample.py | 29 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py index 4c21ee32..52a20a63 100644 --- a/sample/confidential_client_certificate_sample.py +++ b/sample/confidential_client_certificate_sample.py @@ -2,16 +2,25 @@ The configuration file would look like this (sans those // comments): { - "authority": "https://login.microsoftonline.com/organizations", + "authority": "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here", "client_id": "your_client_id", "scope": ["https://graph.microsoft.com/.default"], - // For more information about scopes for an app, refer: - // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate" + // Specific to Client Credentials Grant i.e. acquire_token_for_client(), + // you don't specify, in the code, the individual scopes you want to access. + // Instead, you statically declared them when registering your application. + // Therefore the only possible scope is "resource/.default" + // (here "https://graph.microsoft.com/.default") + // which means "the static permissions defined in the application". "thumbprint": "790E... The thumbprint generated by AAD when you upload your public cert", - "private_key_file": "filename.pem" + "private_key_file": "filename.pem", // For information about generating thumbprint and private key file, refer: // https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate + + "endpoint": "https://graph.microsoft.com/v1.0/users" + // For this resource to work, you need to visit Application Permissions + // page in portal, declare scope User.Read.All, which needs admin consent + // https://github.com/Azure-Samples/ms-identity-python-daemon/blob/master/2-Call-MsGraph-WithCertificate/README.md } You can then run this sample with a JSON configuration file: @@ -23,6 +32,7 @@ import json import logging +import requests import msal @@ -53,10 +63,11 @@ result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: - print(result["access_token"]) - print(result["token_type"]) - print(result["expires_in"]) # You don't normally need to care about this. - # It will be good for at least 5 minutes. + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: " + str(graph_data)) else: print(result.get("error")) print(result.get("error_description")) diff --git a/sample/confidential_client_secret_sample.py b/sample/confidential_client_secret_sample.py index 3897c54c..82833a89 100644 --- a/sample/confidential_client_secret_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -2,16 +2,24 @@ The configuration file would look like this (sans those // comments): { - "authority": "https://login.microsoftonline.com/organizations", + "authority": "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here", "client_id": "your_client_id", "scope": ["https://graph.microsoft.com/.default"], - // For more information about scopes for an app, refer: - // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate" - - "secret": "The secret generated by AAD during your confidential app registration" + // Specific to Client Credentials Grant i.e. acquire_token_for_client(), + // you don't specify, in the code, the individual scopes you want to access. + // Instead, you statically declared them when registering your application. + // Therefore the only possible scope is "resource/.default" + // (here "https://graph.microsoft.com/.default") + // which means "the static permissions defined in the application". + + "secret": "The secret generated by AAD during your confidential app registration", // For information about generating client secret, refer: // https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#registering-client-secrets-using-the-application-registration-portal + "endpoint": "https://graph.microsoft.com/v1.0/users" + // For this resource to work, you need to visit Application Permissions + // page in portal, declare scope User.Read.All, which needs admin consent + // https://github.com/Azure-Samples/ms-identity-python-daemon/blob/master/1-Call-MsGraph-WithSecret/README.md } You can then run this sample with a JSON configuration file: @@ -23,6 +31,7 @@ import json import logging +import requests import msal @@ -53,10 +62,12 @@ result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: - print(result["access_token"]) - print(result["token_type"]) - print(result["expires_in"]) # You don't normally need to care about this. - # It will be good for at least 5 minutes. + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: " + str(graph_data)) + else: print(result.get("error")) print(result.get("error_description")) From 3f075db21a3a223defc9096ea390ae885ae11ca7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 10 Oct 2019 15:22:48 -0700 Subject: [PATCH 139/440] Support prompt parameter --- msal/application.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/msal/application.py b/msal/application.py index 9879350e..6be41a40 100644 --- a/msal/application.py +++ b/msal/application.py @@ -195,6 +195,7 @@ def get_authorization_request_url( state=None, # Recommended by OAuth2 for CSRF protection redirect_uri=None, response_type="code", # Can be "token" if you use Implicit Grant + prompt=None, **kwargs): """Constructs a URL for you to start a Authorization Code Grant. @@ -208,6 +209,11 @@ def get_authorization_request_url( :param str response_type: Default value is "code" for an OAuth2 Authorization Code grant. You can use other content such as "id_token". + :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 :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() @@ -235,6 +241,7 @@ def get_authorization_request_url( return client.build_auth_request_uri( response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, + prompt=prompt, scope=decorate_scope(scopes, self.client_id), ) From 5ae7c0a1511d3dd10ca649146ed4c34d753e08cd Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 28 Oct 2019 10:12:10 -0700 Subject: [PATCH 140/440] Backport stand-alone sample PR 5 https://github.com/Azure-Samples/ms-identity-python-daemon/pull/5/files --- sample/confidential_client_secret_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/confidential_client_secret_sample.py b/sample/confidential_client_secret_sample.py index 82833a89..569e66c4 100644 --- a/sample/confidential_client_secret_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -66,7 +66,7 @@ graph_data = requests.get( # Use token to call downstream service config["endpoint"], headers={'Authorization': 'Bearer ' + result['access_token']},).json() - print("Graph API call result: " + str(graph_data)) + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) else: print(result.get("error")) From 715545402a9e36b920322c26a0f56f1fdc28aa9f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 28 Oct 2019 10:14:05 -0700 Subject: [PATCH 141/440] Backport stand-alone sample PR 5 https://github.com/Azure-Samples/ms-identity-python-daemon/pull/5/files --- sample/confidential_client_certificate_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py index 52a20a63..b38ec0fd 100644 --- a/sample/confidential_client_certificate_sample.py +++ b/sample/confidential_client_certificate_sample.py @@ -67,7 +67,7 @@ graph_data = requests.get( # Use token to call downstream service config["endpoint"], headers={'Authorization': 'Bearer ' + result['access_token']},).json() - print("Graph API call result: " + str(graph_data)) + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) else: print(result.get("error")) print(result.get("error_description")) From 13d2a1328ea60b9f570a0ee553c88ea49a670d78 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 29 Oct 2019 10:59:08 -0700 Subject: [PATCH 142/440] Updating device code and username password samples to call MS Graph (#117) * Updating device flow sample * Updating username password flow sample --- sample/device_flow_sample.py | 18 ++++++++++++------ sample/username_password_sample.py | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index c2a3e572..ee6ff603 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -4,7 +4,12 @@ { "authority": "https://login.microsoftonline.com/common", "client_id": "your_client_id", - "scope": ["User.Read"] + "scope": ["User.ReadBasic.All"], + // You can find the other permission names from this document + // https://docs.microsoft.com/en-us/graph/permissions-reference + "endpoint": "https://graph.microsoft.com/v1.0/users" + // You can find more Microsoft Graph API endpoints from Graph Explorer + // https://developer.microsoft.com/en-us/graph/graph-explorer } You can then run this sample with a JSON configuration file: @@ -16,6 +21,7 @@ import json import logging +import requests import msal @@ -70,12 +76,12 @@ # and then keep calling acquire_token_by_device_flow(flow) in your own customized loop. if "access_token" in result: - print(result["access_token"]) - print(result["token_type"]) - print(result["expires_in"]) # You don't normally need to care about this. - # It will be good for at least 5 minutes. + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) else: print(result.get("error")) print(result.get("error_description")) print(result.get("correlation_id")) # You may need this when reporting a bug - diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index 6a032f7f..6fd51877 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -5,8 +5,13 @@ "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", "username": "your_username@your_tenant.com", - "scope": ["User.Read"], - "password": "This is a sample only. You better NOT persist your password." + "password": "This is a sample only. You better NOT persist your password.", + "scope": ["User.ReadBasic.All"], + // You can find the other permission names from this document + // https://docs.microsoft.com/en-us/graph/permissions-reference + "endpoint": "https://graph.microsoft.com/v1.0/users" + // You can find more Microsoft Graph API endpoints from Graph Explorer + // https://developer.microsoft.com/en-us/graph/graph-explorer } You can then run this sample with a JSON configuration file: @@ -18,6 +23,7 @@ import json import logging +import requests import msal @@ -51,10 +57,11 @@ config["username"], config["password"], scopes=config["scope"]) if "access_token" in result: - print(result["access_token"]) - print(result["token_type"]) - print(result["expires_in"]) # You don't normally need to care about this. - # It will be good for at least 5 minutes. + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) else: print(result.get("error")) print(result.get("error_description")) From 27a53b5aab442d0d87b89f6733ac0d9e92812ce3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 29 Oct 2019 11:04:17 -0700 Subject: [PATCH 143/440] Further adjust token_cache logs --- msal/token_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index edad7236..6884075d 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -108,7 +108,7 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info if sensitive in dictionary: dictionary[sensitive] = "********" wipe(event.get("data", {}), - ("password", "client_secret", "refresh_token", "assertion")) + ("password", "client_secret", "refresh_token", "assertion", "username")) try: return self.__add(event, now=now) finally: From 8f7bf678f36327c467d8e57cf9d71eb722a1cdfb Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 29 Oct 2019 11:15:22 -0700 Subject: [PATCH 144/440] Demo how to disable MSAL log but keep other logs --- sample/confidential_client_certificate_sample.py | 3 ++- sample/confidential_client_secret_sample.py | 3 ++- sample/device_flow_sample.py | 3 ++- sample/username_password_sample.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py index b38ec0fd..e3b1bf86 100644 --- a/sample/confidential_client_certificate_sample.py +++ b/sample/confidential_client_certificate_sample.py @@ -37,7 +37,8 @@ # Optional logging -# logging.basicConfig(level=logging.DEBUG) +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs config = json.load(open(sys.argv[1])) diff --git a/sample/confidential_client_secret_sample.py b/sample/confidential_client_secret_sample.py index 569e66c4..c7bc7374 100644 --- a/sample/confidential_client_secret_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -36,7 +36,8 @@ # Optional logging -# logging.basicConfig(level=logging.DEBUG) +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs config = json.load(open(sys.argv[1])) diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index ee6ff603..51667ce7 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -26,7 +26,8 @@ # Optional logging -# logging.basicConfig(level=logging.DEBUG) +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs config = json.load(open(sys.argv[1])) diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index 6fd51877..9c9b3c06 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -28,7 +28,8 @@ # Optional logging -# logging.basicConfig(level=logging.DEBUG) +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs config = json.load(open(sys.argv[1])) From 0dfbc5384b96268d5906e602db451c85f67c426b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 29 Oct 2019 18:33:33 -0700 Subject: [PATCH 145/440] Add setup.cfg to build universal wheel by default --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..11d9c44f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[bdist_wheel] +universal=1 + From 72895e6844ac646891a98ba643b92e06201e8f17 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 31 Oct 2019 16:05:41 +0000 Subject: [PATCH 146/440] Bumping version number to MSAL Python 0.9.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 6be41a40..05290842 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.8.0" +__version__ = "0.9.0" logger = logging.getLogger(__name__) From d5d2dd4d173086d9058700c7a96c7929500a9383 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 1 Nov 2019 14:14:51 -0700 Subject: [PATCH 147/440] MSAL Python 1.0.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 05290842..19c49eb1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -18,7 +18,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "0.9.0" +__version__ = "1.0.0" logger = logging.getLogger(__name__) From 5608a7cf90051cafa64fd794c4945fa5cb819177 Mon Sep 17 00:00:00 2001 From: Navya Canumalla Date: Tue, 12 Nov 2019 16:40:34 -0800 Subject: [PATCH 148/440] Update Readme preview note and links --- README.md | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6b0bf225..3de13d22 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,40 @@ # Microsoft Authentication Library (MSAL) for Python Preview -The MSAL library for Python enables your app to access the -[Microsoft Cloud](https://cloud.microsoft.com) -by supporting authentication of users with -[Microsoft Azure Active Directory accounts](https://azure.microsoft.com/en-us/services/active-directory/) -and [Microsoft Accounts](https://account.microsoft.com) using industry standard OAuth2 and OpenID Connect. -Soon MSAL Python will also support [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/). - -Not sure whether this is the SDK you are looking for? There are other Microsoft Identity SDKs + +| `dev` branch | Reference Docs +|---------------|--------------- + [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) + +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). -## Important Note about the MSAL Preview +Quick links: -This library is suitable for use in a production environment. -We provide the same production level support for this library as we do our current production libraries. -During the preview we may make changes to the API, internal cache format, and other mechanisms of this library, -which you will be required to take along with bug fixes or feature improvements. -This may impact your application. -For instance, a change to the cache format may impact your users, such as requiring them to sign in again. -An API change may require you to update your code. -When we provide the General Availability release -we will require you to update to the General Availability version within six months, -as applications written using a preview version of library may no longer work. +| [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) +| --- | --- | --- | --- | ## 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`. -## Usage and Samples +## 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 AAD 2.0 endpoint](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-register-an-app). +[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 need to follow this 3-step pattern. +Acquiring tokens with MSAL Python follows this 3-step pattern. 1. MSAL proposes a clean separation between [public client applications, and confidential client applications](https://tools.ietf.org/html/rfc6749#section-2.1). @@ -89,28 +89,28 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. That is the high level pattern. There will be some variations for different flows. They are demonstrated in [samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). +Refer the [Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) pages for more details on the MSAL Python functionality and usage. -## Documentation +## Migrating from ADAL -The generic documents on -[Auth Scenarios](https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-scenarios) -and -[Auth protocols](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) -are recommended reading. +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. -There is the [API reference of MSAL Python](https://msal-python.rtfd.io) which documents every parameter of each public method. +See the [ADAL to MSAL migration](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Migrate-to-MSAL-Python) guide. -More and more detail about MSAL Python functionality and usage will be documented in the -[Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki). +## 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 -## Versions - -This library follows [Semantic Versioning](http://semver.org/). +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. -You can find the changes for each version under -[Releases](https://github.com/AzureAD/microsoft-authentication-library-for-python/releases). +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 @@ -124,7 +124,7 @@ Here is the latest Q&A on Stack Overflow for MSAL: ## 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/en-us/security/dd252948) and subscribing to Security Advisory Alerts. +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 From cec6d1c1c7f862aed5aa6f45db2d99e6d74cda19 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 22 Nov 2019 15:26:08 -0800 Subject: [PATCH 149/440] Should have normalized authority host to lowercase --- msal/authority.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index 667f5ebd..dae97aab 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -100,6 +100,7 @@ def user_realm_discovery(self, username, response=None): 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]: @@ -109,7 +110,7 @@ def canonicalize(authority_url): "https://login.microsoftonline.com/ " "or https://.b2clogin.com/.onmicrosoft.com/policy" % authority_url) - return authority, authority.netloc, parts[1] + return authority, authority.hostname, parts[1] def instance_discovery(url, **kwargs): return requests.get( # Note: This URL seemingly returns V1 endpoint only From ec87071c404af3099e43820ebe561734d539628d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 26 Nov 2019 13:37:48 -0800 Subject: [PATCH 150/440] Disable OBO tests due to a temporary test environment issue --- tests/test_e2e.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 770e4625..7f4c32bb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -390,7 +390,7 @@ def test_adfs2019_fed_user(self): password=self.get_lab_user_secret(config["lab"]["labname"]), **config) @unittest.skipUnless( - os.getenv("OBO_CLIENT_SECRET"), + os.getenv("OBO_CLIENT_SECRET") and False, # Temporarily disable this case "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") def test_acquire_token_obo(self): # Some hardcoded, pre-defined settings @@ -407,7 +407,9 @@ def test_acquire_token_obo(self): scopes=[ # The OBO app's scope. Yours might be different. "%s/access_as_user" % obo_client_id], ) - self.assertIsNotNone(pca_result.get("access_token"), "PCA should work") + 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( From 2b889f5018fc0a48d236a36685b0453b74ef9dab Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 18 Nov 2019 13:28:22 -0800 Subject: [PATCH 151/440] Change to new lab api --- tests/test_e2e.py | 82 ++++++++++++++++------------------------------- 1 file changed, 28 insertions(+), 54 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 7f4c32bb..670d36d6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -2,11 +2,11 @@ import os import json import time +import unittest import requests import msal -from tests import unittest logger = logging.getLogger(__name__) @@ -263,19 +263,6 @@ def test_device_flow(self): "%s obtained tokens: %s", self.id(), json.dumps(result, indent=4)) -def get_lab_user(mam=False, mfa=False, isFederated=False, federationProvider=None): - # Based on https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/LAB.aspx - user = requests.get("https://api.msidlab.com/api/user", params=dict( # Publicly available - mam=mam, mfa=mfa, isFederated=isFederated, federationProvider=federationProvider, - )).json() - return { # Mapping lab API response to our simplified configuration format - "authority": user["Authority"][0] + user["Users"]["tenantId"], - "client_id": user["AppID"], - "username": user["Users"]["upn"], - "lab": {"labname": user["Users"]["upn"].split('@')[1].split('.')[0]}, # :( - "scope": ["https://graph.microsoft.com/.default"], - } - def get_lab_app( env_client_id="LAB_APP_CLIENT_ID", env_client_secret="LAB_APP_CLIENT_SECRET", @@ -318,10 +305,8 @@ class LabBasedTestCase(E2eTestCase): @classmethod def setUpClass(cls): - cls.session = get_session(get_lab_app(), [ - "https://request.msidlab.com/.default", # Existing user & password API - # "https://user.msidlab.com/.default", # New user API - ]) + # https://docs.msidlab.com/accounts/apiaccess.html#code-snippet + cls.session = get_session(get_lab_app(), ["https://user.msidlab.com/.default"]) @classmethod def tearDownClass(cls): @@ -332,62 +317,48 @@ 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) - # Short link only works in browser "https://aka.ms/GetLabUserSecret?Secret=%s" - # So we use the official link written in here - # https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API%27s.aspx - url = ("https://request.msidlab.com/api/GetLabUserSecret?code=KpY5uCcoKo0aW8VOL/CUO3wnu9UF2XbSnLFGk56BDnmQiwD80MQ7HA==&Secret=%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"] + cls._secrets[lab_name] = resp.json()["value"] return cls._secrets[lab_name] @classmethod - def get_lab_user(cls, query): # Experimental: The query format is in lab team's Aug 9 email - resp = cls.session.get("https://user.msidlab.com/api/user", params=query) + 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] return { # Mapping lab API response to our simplified configuration format - "authority": result["lab"]["authority"] + result["lab"]["tenantid"], - "client_id": result["app"]["objectid"], - "username": result["user"]["upn"], - "lab": result["lab"], + "authority": "https://login.microsoftonline.com/{}.onmicrosoft.com".format( + result["labName"]), + "client_id": result["appId"], + "username": result["upn"], + "lab_name": result["labName"], "scope": ["https://graph.microsoft.com/.default"], } - def test_aad_managed_user(self): # Pure cloud or hybrid - config = get_lab_user(isFederated=False) + def test_aad_managed_user(self): # Pure cloud + config = self.get_lab_user(usertype="cloud") self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + password=self.get_lab_user_secret(config["lab_name"]), **config) def test_adfs4_fed_user(self): - config = get_lab_user(isFederated=True, federationProvider="ADFSv4") - self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) - - def test_adfs4_managed_user(self): # Conceptually the hybrid - config = get_lab_user(isFederated=False, federationProvider="ADFSv4") + config = self.get_lab_user(usertype="federated", federationProvider="ADFSv4") self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + password=self.get_lab_user_secret(config["lab_name"]), **config) def test_adfs3_fed_user(self): - config = get_lab_user(isFederated=True, federationProvider="ADFSv3") + config = self.get_lab_user(usertype="federated", federationProvider="ADFSv3") self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) - - def test_adfs3_managed_user(self): - config = get_lab_user(isFederated=False, federationProvider="ADFSv3") - self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + password=self.get_lab_user_secret(config["lab_name"]), **config) def test_adfs2_fed_user(self): - config = get_lab_user(isFederated=True, federationProvider="ADFSv2") + config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2") self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + password=self.get_lab_user_secret(config["lab_name"]), **config) - @unittest.skip("Old Lab API returns nothing. We will switch to new api later") def test_adfs2019_fed_user(self): - config = get_lab_user(isFederated=True, federationProvider="ADFSv2019") + config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2019") self._test_username_password( - password=self.get_lab_user_secret(config["lab"]["labname"]), **config) + password=self.get_lab_user_secret(config["lab_name"]), **config) @unittest.skipUnless( os.getenv("OBO_CLIENT_SECRET") and False, # Temporarily disable this case @@ -396,14 +367,14 @@ def test_acquire_token_obo(self): # Some hardcoded, pre-defined settings obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c" downstream_scopes = ["https://graph.microsoft.com/User.Read"] - config = get_lab_user(isFederated=False) + config = self.get_lab_user(usertype="cloud") # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( "be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority")) pca_result = pca.acquire_token_by_username_password( config["username"], - self.get_lab_user_secret(config["lab"]["labname"]), + self.get_lab_user_secret(config["lab_name"]), scopes=[ # The OBO app's scope. Yours might be different. "%s/access_as_user" % obo_client_id], ) @@ -483,3 +454,6 @@ def test_b2c_acquire_token_by_ropc(self): scope=["https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"], ) +if __name__ == "__main__": + unittest.main() + From ae52f471fa538a47cf6c5495a8000d82f9a08da4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 3 Dec 2019 09:54:18 -0800 Subject: [PATCH 152/440] Re-enable OBO tests The new test credential has been setup in Travis-CI.org --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 670d36d6..727f3dba 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -361,7 +361,7 @@ def test_adfs2019_fed_user(self): password=self.get_lab_user_secret(config["lab_name"]), **config) @unittest.skipUnless( - os.getenv("OBO_CLIENT_SECRET") and False, # Temporarily disable this case + os.getenv("OBO_CLIENT_SECRET"), "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") def test_acquire_token_obo(self): # Some hardcoded, pre-defined settings From d21c3be02d083b472ba3db5b33beceb795b6d691 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 3 Dec 2019 15:59:52 -0800 Subject: [PATCH 153/440] Update test_e2e.py --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 727f3dba..37f47d77 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -306,7 +306,7 @@ class LabBasedTestCase(E2eTestCase): @classmethod def setUpClass(cls): # https://docs.msidlab.com/accounts/apiaccess.html#code-snippet - cls.session = get_session(get_lab_app(), ["https://user.msidlab.com/.default"]) + cls.session = get_session(get_lab_app(), ["https://msidlab.com/.default"]) @classmethod def tearDownClass(cls): From f43b8b2ae2e0aae29d64892a0bc98b6f15a3ccd1 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 10 Dec 2019 17:39:02 -0800 Subject: [PATCH 154/440] Adding app_name and app_version headers (#136) --- msal/application.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index 19c49eb1..17fc8dc9 100644 --- a/msal/application.py +++ b/msal/application.py @@ -73,7 +73,7 @@ def __init__( client_credential=None, authority=None, validate_authority=True, token_cache=None, verify=True, proxies=None, timeout=None, - client_claims=None): + client_claims=None, app_name=None, app_version=None): """Create an instance of application. :param client_id: Your app has a client_id after you register it on AAD. @@ -131,6 +131,12 @@ def __init__( It will be passed to the `timeout parameter in the underlying requests library `_ + :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. """ self.client_id = client_id self.client_credential = client_credential @@ -138,6 +144,8 @@ def __init__( self.verify = verify self.proxies = proxies self.timeout = timeout + self.app_name = app_name + self.app_version = app_version self.authority = Authority( authority or "https://login.microsoftonline.com/common/", validate_authority, verify=verify, proxies=proxies, timeout=timeout) @@ -149,6 +157,15 @@ def __init__( def _build_client(self, client_credential, authority): 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", + } + 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 @@ -174,11 +191,7 @@ def _build_client(self, client_credential, authority): return Client( server_configuration, self.client_id, - 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", - }, + default_headers=default_headers, default_body=default_body, client_assertion=client_assertion, client_assertion_type=client_assertion_type, From 3d1c482fb88d1f2e1791599eb3f26eb38773ced0 Mon Sep 17 00:00:00 2001 From: Navya Canumalla Date: Mon, 16 Dec 2019 11:17:46 -0800 Subject: [PATCH 155/440] Update Readme to remove Preview --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3de13d22..6a87fc1d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Microsoft Authentication Library (MSAL) for Python Preview +# Microsoft Authentication Library (MSAL) for Python | `dev` branch | Reference Docs From 94542429be7a26c682254b7b2bf517a24900d3a7 Mon Sep 17 00:00:00 2001 From: Santiago Gonzalez <35743865+sangonzal@users.noreply.github.com> Date: Tue, 17 Dec 2019 08:54:44 -0800 Subject: [PATCH 156/440] Add correlation id, public api id, and current request header (#103) * Add correlation id, public api id, and current request header * Make get_new_correlation_id private * Update calls to _get_new_correlation_id() * PR feedback. * Fix authority test * PR feedback * More PR feedback * Remove indent * Refactor and fix a bug in the initiate_device_flow() * Refactor acquire_token_silent() to reuse same correlation_id * fixup! Refactor and fix a bug in the initiate_device_flow() * Define consts for all telemetry headers * Replace one last magic string with const --- msal/application.py | 102 +++++++++++++++++++++++++++++++++++++------- msal/authority.py | 5 ++- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/msal/application.py b/msal/application.py index 17fc8dc9..20a77525 100644 --- a/msal/application.py +++ b/msal/application.py @@ -6,6 +6,7 @@ import logging import sys import warnings +import uuid import requests @@ -49,6 +50,16 @@ def decorate_scope( decorated = scope_set | reserved_scope return list(decorated) +CLIENT_REQUEST_ID = 'client-request-id' +CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry' + +def _get_new_correlation_id(): + return str(uuid.uuid4()) + + +def _build_current_telemetry_request_header(public_api_id, force_refresh=False): + return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0") + def extract_certs(public_cert_content): # Parses raw public certificate file contents and returns a list of strings @@ -68,6 +79,15 @@ def extract_certs(public_cert_content): class ClientApplication(object): + ACQUIRE_TOKEN_SILENT_ID = "84" + 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" + GET_ACCOUNTS_ID = "902" + REMOVE_ACCOUNT_ID = "903" + def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, @@ -303,6 +323,11 @@ def acquire_token_by_authorization_code( data=dict( kwargs.pop("data", {}), scope=decorate_scope(scopes, self.client_id)), + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), + }, **kwargs) def get_accounts(self, username=None): @@ -426,6 +451,7 @@ def acquire_token_silent( """ assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) + correlation_id = _get_new_correlation_id() if authority: warnings.warn("We haven't decided how/if this method will accept authority parameter") # the_authority = Authority( @@ -433,7 +459,9 @@ def acquire_token_silent( # verify=self.verify, proxies=self.proxies, timeout=self.timeout, # ) if authority else self.authority result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( - scopes, account, self.authority, force_refresh=force_refresh, **kwargs) + scopes, account, self.authority, force_refresh=force_refresh, + correlation_id=correlation_id, + **kwargs) if result: return result for alias in self._get_authority_aliases(self.authority.instance): @@ -442,7 +470,9 @@ def acquire_token_silent( validate_authority=False, verify=self.verify, proxies=self.proxies, timeout=self.timeout) result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( - scopes, account, the_authority, force_refresh=force_refresh, **kwargs) + scopes, account, the_authority, force_refresh=force_refresh, + correlation_id=correlation_id, + **kwargs) if result: return result @@ -480,7 +510,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( } return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, decorate_scope(scopes, self.client_id), account, - **kwargs) + force_refresh=force_refresh, **kwargs) def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): @@ -526,7 +556,8 @@ def _get_app_metadata(self, environment): def _acquire_token_silent_by_finding_specific_refresh_token( self, authority, scopes, query, - rt_remover=None, break_condition=lambda response: False, **kwargs): + rt_remover=None, break_condition=lambda response: False, + force_refresh=False, correlation_id=None, **kwargs): matches = self.token_cache.find( self.token_cache.CredentialType.REFRESH_TOKEN, # target=scopes, # AAD RTs are scope-independent @@ -539,6 +570,11 @@ def _acquire_token_silent_by_finding_specific_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], on_removing_rt=rt_remover or self.token_cache.remove_rt, scope=scopes, + headers={ + CLIENT_REQUEST_ID: correlation_id or _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_SILENT_ID, force_refresh=force_refresh), + }, **kwargs) if "error" not in response: return response @@ -564,6 +600,8 @@ def _validate_ssh_cert_input_data(self, data): 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") @@ -581,9 +619,16 @@ def initiate_device_flow(self, scopes=None, **kwargs): - A successful response would contain "user_code" key, among others - an error response would contain some other readable key/value pairs. """ - return self.client.initiate_device_flow( + correlation_id = _get_new_correlation_id() + flow = self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), + headers={ + CLIENT_REQUEST_ID: correlation_id, + # CLIENT_CURRENT_TELEMETRY is not currently required + }, **kwargs) + flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id + return flow def acquire_token_by_device_flow(self, flow, **kwargs): """Obtain token by a device flow object, with customizable polling effect. @@ -600,12 +645,18 @@ def acquire_token_by_device_flow(self, flow, **kwargs): - an error response would contain "error" and usually "error_description". """ return 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. - **kwargs) + 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. + headers={ + CLIENT_REQUEST_ID: + flow.get(self.DEVICE_FLOW_CORRELATION_ID) or _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID), + }, + **kwargs) def acquire_token_by_username_password( self, username, password, scopes, **kwargs): @@ -625,13 +676,22 @@ def acquire_token_by_username_password( - an error response would contain "error" and usually "error_description". """ scopes = decorate_scope(scopes, self.client_id) + headers = { + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID), + } if not self.authority.is_adfs: - user_realm_result = self.authority.user_realm_discovery(username) + user_realm_result = self.authority.user_realm_discovery( + username, correlation_id=headers[CLIENT_REQUEST_ID]) if user_realm_result.get("account_type") == "Federated": return self._acquire_token_by_username_password_federated( - user_realm_result, username, password, scopes=scopes, **kwargs) + user_realm_result, username, password, scopes=scopes, + headers=headers, **kwargs) return self.client.obtain_token_by_username_password( - username, password, scope=scopes, **kwargs) + username, password, scope=scopes, + headers=headers, + **kwargs) def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): @@ -687,8 +747,13 @@ def acquire_token_for_client(self, scopes, **kwargs): """ # TBD: force_refresh behavior return self.client.obtain_token_for_client( - scope=scopes, # This grant flow requires no scope decoration - **kwargs) + scope=scopes, # This grant flow requires no scope decoration + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_FOR_CLIENT_ID), + }, + **kwargs) def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): """Acquires token using on-behalf-of (OBO) flow. @@ -723,5 +788,10 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): # 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"), + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), + }, **kwargs) diff --git a/msal/authority.py b/msal/authority.py index dae97aab..d8221eca 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -82,7 +82,7 @@ def __init__(self, authority_url, validate_authority=True, _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID self.is_adfs = self.tenant.lower() == 'adfs' - def user_realm_discovery(self, username, response=None): + 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. @@ -90,7 +90,8 @@ def user_realm_discovery(self, username, response=None): resp = response or requests.get( "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( netloc=self.instance, username=username), - headers={'Accept':'application/json'}, + headers={'Accept':'application/json', + 'client-request-id': correlation_id}, verify=self.verify, proxies=self.proxies, timeout=self.timeout) if resp.status_code != 404: resp.raise_for_status() From 79c303d58500a5f82aa11a3224ce23ec11a8c97c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 18 Dec 2019 11:14:57 -0800 Subject: [PATCH 157/440] Marking this library as Production/Stable for PyPI FYI: https://pypi.org/classifiers/ --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0c26e5da..960d4bca 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ author_email='nugetaad@microsoft.com', url='https://github.com/AzureAD/microsoft-authentication-library-for-python', classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', From 052e2f55903ad483819bd62d806a613eac6b0960 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 20 Dec 2019 12:12:43 -0800 Subject: [PATCH 158/440] We should not assume error_description always exist, because it won't. --- msal/application.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 20a77525..ce7b340a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -578,8 +578,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token( **kwargs) if "error" not in response: return response - logger.debug( - "Refresh failed. {error}: {error_description}".format(**response)) + logger.debug("Refresh failed. {error}: {error_description}".format( + error=response.get("error"), + error_description=response.get("error_description"), + )) if break_condition(response): break From 8eaf2710b43e457ca954b8ede4c3f87cb168cdb7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 20 Dec 2019 14:36:04 -0800 Subject: [PATCH 159/440] Error test case should better use HTTP 400 status code --- tests/test_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_application.py b/tests/test_application.py index cc072838..814f7c71 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -74,7 +74,7 @@ def test_unknown_orphan_app_will_attempt_frt_and_not_remove_it(self): logger.debug("%s.cache = %s", self.id(), self.cache.serialize()) def tester(url, data=None, **kwargs): self.assertEqual(self.frt, data.get("refresh_token"), "Should attempt the FRT") - return Mock(status_code=200, json=Mock(return_value={ + return Mock(status_code=400, json=Mock(return_value={ "error": "invalid_grant", "error_description": "Was issued to another client"})) app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( From 55b0ea4365ae36fb2b78c46de5707cfaf94d7266 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 14 Jan 2020 13:10:29 -0800 Subject: [PATCH 160/440] Minor refactor based on underlying changes --- msal/application.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 20a77525..d053877c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -320,9 +320,7 @@ def acquire_token_by_authorization_code( self._validate_ssh_cert_input_data(kwargs.get("data", {})) return self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, - data=dict( - kwargs.pop("data", {}), - scope=decorate_scope(scopes, self.client_id)), + scope=decorate_scope(scopes, self.client_id), headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( From 27eef91363d13e3e3de441aa61c4879691d0223d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 25 Nov 2019 15:56:48 -0800 Subject: [PATCH 161/440] Tolerate Authorization Server not granting all scopes --- tests/test_e2e.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 37f47d77..3b50ac51 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -52,12 +52,18 @@ def assertCacheWorksForUser(self, result_from_wire, scope, username=None): accounts = self.app.get_accounts(username=username) self.assertNotEqual(0, len(accounts)) account = accounts[0] - # Going to test acquire_token_silent(...) to locate an AT from cache - result_from_cache = self.app.acquire_token_silent(scope, account=account) - self.assertIsNotNone(result_from_cache) - self.assertEqual( - result_from_wire['access_token'], result_from_cache['access_token'], - "We should get a cached AT") + 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) + 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") # 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 From e4f1d2953d4cc9b4138107a4d73fbd114e03c960 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 18 Nov 2019 13:41:03 -0800 Subject: [PATCH 162/440] ADFS 2019 on-prem test cases --- tests/test_e2e.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3b50ac51..b4d34e5d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -308,6 +308,10 @@ def get_session(lab_app, scopes): # BTW, this infrastructure tests the confiden 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): @@ -366,6 +370,47 @@ def test_adfs2019_fed_user(self): self._test_username_password( password=self.get_lab_user_secret(config["lab_name"]), **config) + def test_ropc_adfs2019_onprem(self): + config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") + config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] + config["client_id"] = "PublicClientId" + config["scope"] = self.adfs2019_scopes + self._test_username_password( + password=self.get_lab_user_secret(config["lab_name"]), **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 + """ + scopes = self.adfs2019_scopes + config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") + (self.app, ac, redirect_uri) = _get_app_and_auth_code( + # 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 + "PublicClientId", + authority="https://fs.%s.com/adfs" % config["lab_name"], + port=8080, + scopes=scopes, + ) + result = self.app.acquire_token_by_authorization_code( + ac, scopes, 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, scopes, username=None) + @unittest.skipUnless( os.getenv("OBO_CLIENT_SECRET"), "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") From 33420352c525320a8b651a666f793d753bf1e131 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 15 Jan 2020 10:58:49 -0800 Subject: [PATCH 163/440] Add a new acquire_token_silent_with_error() --- msal/application.py | 52 ++++++++++++++++++++++++++++++++++----- tests/test_application.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index 593d957d..a25858f8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -431,10 +431,40 @@ def acquire_token_silent( **kwargs): """Acquire an access token for given account, without user interaction. + It behaves same as :func:`~acquire_token_silent_with_error`, + except that this method will combine the cache empty and refresh error + into one return value, `None`. + If your app does not need to care the exact token refresh error during + token cache look-up, then this method is easier to use. + + Internally, this method calls :func:`~acquire_token_silent_with_error`. + + :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, force_refresh, **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] + **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. + Unlike :func:`~acquire_token_silent`, + error happened during token refresh would also be returned. + :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param account: @@ -444,8 +474,11 @@ def acquire_token_silent( If True, it will skip Access Token look-up, and try to find a Refresh Token to obtain a new Access Token. :return: - - A dict containing "access_token" key, when cache lookup succeeds. - - None when cache lookup does not yield anything. + - 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", {})) @@ -460,8 +493,9 @@ def acquire_token_silent( scopes, account, self.authority, force_refresh=force_refresh, correlation_id=correlation_id, **kwargs) - if result: + if result and "error" not in result: return result + final_result = result for alias in self._get_authority_aliases(self.authority.instance): the_authority = Authority( "https://" + alias + "/" + self.authority.tenant, @@ -472,7 +506,10 @@ def acquire_token_silent( correlation_id=correlation_id, **kwargs) if result: - return result + if "error" not in result: + return result + final_result = result + return final_result def _acquire_token_silent_from_cache_and_possibly_refresh_it( self, @@ -533,13 +570,13 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( # https://msazure.visualstudio.com/One/_git/ESTS-Docs/pullrequest/1138595 "client_mismatch" in response.get("error_additional_info", []), **kwargs) - if at: + if at and "error" not in at: return at if app_metadata.get("family_id"): # Meaning this app belongs to this family at = self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, dict(query, family_id=app_metadata["family_id"]), **kwargs) - if at: + 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. @@ -562,6 +599,8 @@ def _acquire_token_silent_by_finding_specific_refresh_token( query=query) logger.debug("Found %d RTs matching %s", len(matches), query) client = self._build_client(self.client_credential, authority) + + response = None # A distinguishable value to mean cache is empty for entry in matches: logger.debug("Cache attempts an RT") response = client.obtain_token_by_refresh_token( @@ -582,6 +621,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token( )) 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": diff --git a/tests/test_application.py b/tests/test_application.py index 814f7c71..1973df51 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -46,6 +46,49 @@ def test_extract_multiple_tag_enclosed_certs(self): self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem)) +class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase): + + def setUp(self): + self.authority_url = "https://login.microsoftonline.com/common" + self.authority = msal.authority.Authority(self.authority_url) + 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": TokenCacheTestCase.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.assertEqual( + None, self.app.acquire_token_silent(['cache_miss'], self.account)) + self.assertEqual( + None, self.app.acquire_token_silent_with_error(['cache_miss'], self.account)) + + def test_acquire_token_silent_with_error_will_return_error(self): + error_response = {"error": "invalid_grant", "error_description": "xyz"} + def tester(url, **kwargs): + return Mock(status_code=400, json=Mock(return_value=error_response)) + self.assertEqual(error_response, self.app.acquire_token_silent_with_error( + self.scopes, self.account, post=tester)) + + def test_acquire_token_silent_will_suppress_error(self): + error_response = {"error": "invalid_grant", "error_description": "xyz"} + def tester(url, **kwargs): + return Mock(status_code=400, json=Mock(return_value=error_response)) + self.assertEqual(None, self.app.acquire_token_silent( + self.scopes, self.account, post=tester)) + class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase): def setUp(self): From 79c51fdf89df1d47b7c8575661aae4f288a686de Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 15 Jan 2020 10:59:14 -0800 Subject: [PATCH 164/440] Map suberror into classification --- msal/application.py | 8 ++++++++ tests/test_application.py | 24 ++++++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/msal/application.py b/msal/application.py index a25858f8..9b72affb 100644 --- a/msal/application.py +++ b/msal/application.py @@ -509,6 +509,14 @@ def acquire_token_silent_with_error( 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( diff --git a/tests/test_application.py b/tests/test_application.py index 1973df51..4d7c2881 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -75,6 +75,13 @@ def test_cache_empty_will_be_returned_as_None(self): 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 Mock(status_code=400, json=Mock(return_value=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): @@ -82,12 +89,21 @@ def tester(url, **kwargs): self.assertEqual(error_response, self.app.acquire_token_silent_with_error( self.scopes, self.account, post=tester)) - def test_acquire_token_silent_will_suppress_error(self): - error_response = {"error": "invalid_grant", "error_description": "xyz"} + 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 Mock(status_code=400, json=Mock(return_value=error_response)) - self.assertEqual(None, self.app.acquire_token_silent( - self.scopes, self.account, post=tester)) + 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 Mock(status_code=400, json=Mock(return_value=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): From 7a057fdb588fc2e597e15f8d5df251099af0cade Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 17 Jan 2020 14:09:24 -0800 Subject: [PATCH 165/440] Adjust API docs based on off-line feedback --- msal/application.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index 9b72affb..e5b6bbfb 100644 --- a/msal/application.py +++ b/msal/application.py @@ -431,11 +431,14 @@ def acquire_token_silent( **kwargs): """Acquire an access token for given account, without user interaction. - It behaves same as :func:`~acquire_token_silent_with_error`, - except that this method will combine the cache empty and refresh error + 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 need to care the exact token refresh error during - token cache look-up, then this method is easier to use. + 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`. @@ -462,8 +465,10 @@ def acquire_token_silent_with_error( or by finding a valid refresh token from cache and then automatically use it to redeem a new access token. - Unlike :func:`~acquire_token_silent`, - error happened during token refresh would also be returned. + 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). From 08288f01550cf03a034e468f97f216f67afe8817 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 23 Jan 2020 10:05:05 -0800 Subject: [PATCH 166/440] Switch from unknown.host to example.com https://unknown.host happened to be available but not any more. It would cause our test cases to hang indefinitely. This change switches it to a well-known existing example.com Eventually we might mock it out. --- tests/test_authority.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_authority.py b/tests/test_authority.py index 340b4936..d1e75ef7 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -29,12 +29,18 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): self.assertNotIn('v2.0', a.token_endpoint) def test_unknown_host_wont_pass_instance_discovery(self): - with self.assertRaisesRegexp(ValueError, "invalid_instance"): - Authority('https://unknown.host/tenant_doesnt_matter_in_this_case') - - def test_invalid_host_skipping_validation_meets_connection_error_down_the_road(self): - with self.assertRaises(requests.exceptions.RequestException): - Authority('https://unknown.host/invalid', validate_authority=False) + _assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp) # Hack + with _assert(ValueError, "invalid_instance"): + Authority('https://example.com/tenant_doesnt_matter_in_this_case') + + def test_invalid_host_skipping_validation_can_be_turned_off(self): + try: + Authority('https://example.com/invalid', 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): From 641bf8178940f58a7de509f76de1c2910f0208fd Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 23 Jan 2020 10:37:03 -0800 Subject: [PATCH 167/440] MSAL Python 1.1.0 Bump version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index e5b6bbfb..01f25461 100644 --- a/msal/application.py +++ b/msal/application.py @@ -19,7 +19,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.0.0" +__version__ = "1.1.0" logger = logging.getLogger(__name__) From 16d9682599fab7fa90369dcc0451fcf4fc80f21c Mon Sep 17 00:00:00 2001 From: Luca Boccassi Date: Tue, 11 Feb 2020 13:34:22 +0000 Subject: [PATCH 168/440] Add source root dir to python path in docs/conf.py conf.py imports the version from the local module, so it needs to have the directory in the python path, otherwise it fails with: $ sphinx-apidoc -f -e -o docs/api msal Creating file docs/api/msal.application.rst. Creating file docs/api/msal.authority.rst. Creating file docs/api/msal.exceptions.rst. Creating file docs/api/msal.mex.rst. Creating file docs/api/msal.token_cache.rst. Creating file docs/api/msal.wstrust_request.rst. Creating file docs/api/msal.wstrust_response.rst. Creating file docs/api/msal.rst. Creating file docs/api/msal.oauth2cli.assertion.rst. Creating file docs/api/msal.oauth2cli.authcode.rst. Creating file docs/api/msal.oauth2cli.oauth2.rst. Creating file docs/api/msal.oauth2cli.oidc.rst. Creating file docs/api/msal.oauth2cli.rst. Creating file docs/api/modules.rst. $ make -C docs man make: Entering directory '/home/bluca/git/microsoft-authentication-library-for-python/docs' Running Sphinx v1.8.4 Configuration error: There is a programmable error in your configuration file: Traceback (most recent call last): File "/usr/lib/python3/dist-packages/sphinx/config.py", line 368, in eval_config_file execfile_(filename, namespace) File "/usr/lib/python3/dist-packages/sphinx/util/pycompat.py", line 150, in execfile_ exec_(code, _globals) File "/home/bluca/git/microsoft-authentication-library-for-python/docs/conf.py", line 27, in from msal import __version__ as version ModuleNotFoundError: No module named 'msal' make: *** [Makefile:19: man] Error 2 --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d0a02003..251cf948 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,9 +12,9 @@ # 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. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- From 36730499758758b18a11eecbfd3845a380906170 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 27 Feb 2020 13:00:33 -0800 Subject: [PATCH 169/440] Switching to Lab App for OBO testing (#163) --- tests/test_e2e.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b4d34e5d..887e1609 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -412,22 +412,22 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): self.assertCacheWorksForUser(result, scopes, username=None) @unittest.skipUnless( - os.getenv("OBO_CLIENT_SECRET"), - "Need OBO_CLIENT_SECRET from https://buildautomation.vault.azure.net/secrets/IdentityDivisionDotNetOBOServiceSecret") + os.getenv("LAB_OBO_CLIENT_SECRET"), + "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") def test_acquire_token_obo(self): # Some hardcoded, pre-defined settings - obo_client_id = "23c64cd8-21e4-41dd-9756-ab9e2c23f58c" - downstream_scopes = ["https://graph.microsoft.com/User.Read"] + obo_client_id = "f4aa5217-e87c-42b2-82af-5624dd14ee72" + downstream_scopes = ["https://graph.microsoft.com/.default"] config = self.get_lab_user(usertype="cloud") # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( - "be9b0186-7dfd-448a-a944-f771029105bf", authority=config.get("authority")) + "c0485386-1e9a-4663-bc96-7ab30656de7f", authority=config.get("authority")) pca_result = pca.acquire_token_by_username_password( config["username"], self.get_lab_user_secret(config["lab_name"]), scopes=[ # The OBO app's scope. Yours might be different. - "%s/access_as_user" % obo_client_id], + "api://%s/read" % obo_client_id], ) self.assertIsNotNone( pca_result.get("access_token"), @@ -436,7 +436,7 @@ def test_acquire_token_obo(self): # 2. Our mid-tier service uses OBO to obtain a token for downstream service cca = msal.ConfidentialClientApplication( obo_client_id, - client_credential=os.getenv("OBO_CLIENT_SECRET"), + client_credential=os.getenv("LAB_OBO_CLIENT_SECRET"), authority=config.get("authority"), # token_cache= ..., # Default token cache is all-tokens-store-in-memory. # That's fine if OBO app uses short-lived msal instance per session. From c752caca7b216c99c7dce05217a16ea972c5cab5 Mon Sep 17 00:00:00 2001 From: Abhidnya Patil Date: Mon, 2 Mar 2020 16:26:07 -0800 Subject: [PATCH 170/440] Changes --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 01f25461..760daa0e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -713,7 +713,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs): def acquire_token_by_username_password( self, username, password, scopes, **kwargs): - """Gets a token for a given resource via user credentails. + """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 From b01d5b6f42a080af26d9f93a91b150354738dd3f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 16 Mar 2020 13:08:10 -0700 Subject: [PATCH 171/440] Fix a merging error introduced in last commit --- {oauth2cli => msal/oauth2cli}/http.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {oauth2cli => msal/oauth2cli}/http.py (100%) diff --git a/oauth2cli/http.py b/msal/oauth2cli/http.py similarity index 100% rename from oauth2cli/http.py rename to msal/oauth2cli/http.py From 53cd04f4bd8098e98d2c3916bb24371a82d26bea Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 17 Mar 2020 16:04:56 -0700 Subject: [PATCH 172/440] Now msal auth code flow explicitly accepts and validates nonce --- msal/application.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/msal/application.py b/msal/application.py index 760daa0e..58984283 100644 --- a/msal/application.py +++ b/msal/application.py @@ -229,6 +229,7 @@ def get_authorization_request_url( redirect_uri=None, response_type="code", # Can be "token" if you use Implicit Grant prompt=None, + nonce=None, **kwargs): """Constructs a URL for you to start a Authorization Code Grant. @@ -247,6 +248,9 @@ def get_authorization_request_url( 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 `_. :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() @@ -276,6 +280,7 @@ def get_authorization_request_url( redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, scope=decorate_scope(scopes, self.client_id), + nonce=nonce, ) def acquire_token_by_authorization_code( @@ -286,6 +291,7 @@ def acquire_token_by_authorization_code( # 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, **kwargs): """The second half of the Authorization Code Grant. @@ -306,6 +312,11 @@ def acquire_token_by_authorization_code( 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. + :return: A dict representing the json response from AAD: - A successful response would contain "access_token" key, @@ -326,6 +337,7 @@ def acquire_token_by_authorization_code( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), }, + nonce=nonce, **kwargs) def get_accounts(self, username=None): From 02350d22cf0e506fd34becb183c9a96400bad4cc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 18 Mar 2020 13:48:41 -0700 Subject: [PATCH 173/440] Unit test case for nonce validation --- tests/test_e2e.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 887e1609..a4afd124 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -19,12 +19,12 @@ def _get_app_and_auth_code( 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 app = msal.ClientApplication(client_id, client_secret, authority=authority) redirect_uri = "http://localhost:%d" % port ac = obtain_auth_code(port, auth_uri=app.get_authorization_request_url( - scopes, redirect_uri=redirect_uri)) + scopes, redirect_uri=redirect_uri, **kwargs)) assert ac is not None return (app, ac, redirect_uri) @@ -124,20 +124,21 @@ 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): + def _get_app_and_auth_code(self, **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=self.config["scope"], - ) + **kwargs) def test_auth_code(self): self.skipUnlessWithConfig(["client_id", "scope"]) - (self.app, ac, redirect_uri) = self._get_app_and_auth_code() + nonce = "foo" + (self.app, ac, redirect_uri) = self._get_app_and_auth_code(nonce=nonce) result = self.app.acquire_token_by_authorization_code( - ac, self.config["scope"], redirect_uri=redirect_uri) + ac, self.config["scope"], redirect_uri=redirect_uri, nonce=nonce) logger.debug("%s.cache = %s", self.id(), json.dumps(self.app.token_cache._cache, indent=4)) self.assertIn( From 4b8189ddce9f0db72f683f94898c5cede7c2fa5b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 23 Mar 2020 14:42:52 -0700 Subject: [PATCH 174/440] Retain no-nonce test case, and add mismatching nonce test case --- tests/test_e2e.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a4afd124..eda78de2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -133,12 +133,11 @@ def _get_app_and_auth_code(self, **kwargs): scopes=self.config["scope"], **kwargs) - def test_auth_code(self): + def _test_auth_code(self, auth_kwargs, token_kwargs): self.skipUnlessWithConfig(["client_id", "scope"]) - nonce = "foo" - (self.app, ac, redirect_uri) = self._get_app_and_auth_code(nonce=nonce) + (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, nonce=nonce) + 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( @@ -149,6 +148,18 @@ def test_auth_code(self): 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_ssh_cert(self): self.skipUnlessWithConfig(["client_id", "scope"]) From 9a115608153e941c82fa9420784311092e00f2c9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 30 Mar 2020 17:27:33 -0700 Subject: [PATCH 175/440] MSAL Python 1.2.0 Bump version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 58984283..a036b80d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -19,7 +19,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.1.0" +__version__ = "1.2.0" logger = logging.getLogger(__name__) From 9e99c314622f5317400519acb6db3bdb6e845f9f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 31 Mar 2020 19:03:41 -0700 Subject: [PATCH 176/440] Add a download badge into README because, why not? --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6a87fc1d..4fb665d0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Microsoft Authentication Library (MSAL) for Python -| `dev` branch | Reference Docs -|---------------|--------------- - [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) +| `dev` branch | Reference Docs | # of Downloads +|---------------|---------------|----------------| + [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) | [![Download monthly](https://pepy.tech/badge/msal/month)](https://pypistats.org/packages/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 From 8cbc088fbfd3b1a3fff2ebcdbb939e68c2e8ffd3 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 7 Apr 2020 12:19:21 -0700 Subject: [PATCH 177/440] Arlington automation (#165) --- tests/test_e2e.py | 295 ++++++++++++++++++++++++++++------------------ 1 file changed, 178 insertions(+), 117 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index eda78de2..0d74eb1d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -102,6 +102,32 @@ def _test_username_password(self, username=username if ".b2clogin.com" not in authority else None, ) + 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) + 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.skip("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)) + THIS_FOLDER = os.path.dirname(__file__) CONFIG = os.path.join(THIS_FOLDER, "config.json") @@ -256,29 +282,7 @@ def setUpClass(cls): cls.config = json.load(f) def test_device_flow(self): - scopes = self.config["scope"] - self.app = msal.PublicClientApplication( - self.config['client_id'], authority=self.config["authority"]) - flow = self.app.initiate_device_flow(scopes=scopes) - 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.skip("End user did not complete Device Flow in time") - self.assertCacheWorksForUser(result, scopes, username=None) - result["access_token"] = result["refresh_token"] = "************" - logger.info( - "%s obtained tokens: %s", self.id(), json.dumps(result, indent=4)) + self._test_device_flow(**self.config) def get_lab_app( @@ -334,6 +338,12 @@ def setUpClass(cls): 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) + return resp.json()[0] + @classmethod def get_lab_user_secret(cls, lab_name="msidlab4"): lab_name = lab_name.lower() @@ -348,67 +358,29 @@ def get_lab_user_secret(cls, lab_name="msidlab4"): 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": "https://login.microsoftonline.com/{}.onmicrosoft.com".format( - result["labName"]), + "authority": authority_base + result["tenantID"], "client_id": result["appId"], "username": result["upn"], "lab_name": result["labName"], - "scope": ["https://graph.microsoft.com/.default"], + "scope": scope, } - def test_aad_managed_user(self): # Pure cloud - config = self.get_lab_user(usertype="cloud") - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **config) - - def test_adfs4_fed_user(self): - config = self.get_lab_user(usertype="federated", federationProvider="ADFSv4") - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **config) - - def test_adfs3_fed_user(self): - config = self.get_lab_user(usertype="federated", federationProvider="ADFSv3") - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **config) - - def test_adfs2_fed_user(self): - config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2") - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **config) - - def test_adfs2019_fed_user(self): - config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2019") - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **config) - - def test_ropc_adfs2019_onprem(self): - config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") - config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] - config["client_id"] = "PublicClientId" - config["scope"] = self.adfs2019_scopes - self._test_username_password( - password=self.get_lab_user_secret(config["lab_name"]), **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 - """ - scopes = self.adfs2019_scopes - config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") + 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( - # 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 - "PublicClientId", - authority="https://fs.%s.com/adfs" % config["lab_name"], - port=8080, - scopes=scopes, - ) + client_id, authority=authority, port=port, scopes=scope) result = self.app.acquire_token_by_authorization_code( - ac, scopes, redirect_uri=redirect_uri) + ac, scope, redirect_uri=redirect_uri) logger.debug( "%s: cache = %s, id_token_claims = %s", self.id(), @@ -421,25 +393,16 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) - self.assertCacheWorksForUser(result, scopes, username=None) - - @unittest.skipUnless( - os.getenv("LAB_OBO_CLIENT_SECRET"), - "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") - def test_acquire_token_obo(self): - # Some hardcoded, pre-defined settings - obo_client_id = "f4aa5217-e87c-42b2-82af-5624dd14ee72" - downstream_scopes = ["https://graph.microsoft.com/.default"] - config = self.get_lab_user(usertype="cloud") + self.assertCacheWorksForUser(result, scope, username=None) + def _test_acquire_token_obo(self, config_pca, config_cca): # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( - "c0485386-1e9a-4663-bc96-7ab30656de7f", authority=config.get("authority")) + config_pca["client_id"], authority=config_pca["authority"]) pca_result = pca.acquire_token_by_username_password( - config["username"], - self.get_lab_user_secret(config["lab_name"]), - scopes=[ # The OBO app's scope. Yours might be different. - "api://%s/read" % obo_client_id], + config_pca["username"], + config_pca["password"], + scopes=config_pca["scope"], ) self.assertIsNotNone( pca_result.get("access_token"), @@ -447,15 +410,15 @@ def test_acquire_token_obo(self): # 2. Our mid-tier service uses OBO to obtain a token for downstream service cca = msal.ConfidentialClientApplication( - obo_client_id, - client_credential=os.getenv("LAB_OBO_CLIENT_SECRET"), - authority=config.get("authority"), + config_cca["client_id"], + client_credential=config_cca["client_secret"], + authority=config_cca["authority"], # 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'], downstream_scopes) + 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. @@ -465,12 +428,93 @@ def test_acquire_token_obo(self): # 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") - self.assertEqual(config["username"], username) + self.assertEqual(config_cca["username"], username) if username: # A precaution so that we won't use other user's token account = cca.get_accounts(username=username)[0] - result = cca.acquire_token_silent(downstream_scopes, account) + 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) + 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): + config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2019") + config["password"] = self.get_lab_user_secret(config["lab_name"]) + self._test_username_password(**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["client_id"] = "PublicClientId" + 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["client_id"] = "PublicClientId" + config["scope"] = self.adfs2019_scopes + config["port"] = 8080 + self._test_acquire_token_by_auth_code(**config) + + @unittest.skipUnless( + os.getenv("LAB_OBO_CLIENT_SECRET"), + "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") + def test_acquire_token_obo(self): + config = self.get_lab_user(usertype="cloud") + + config_cca = {} + config_cca.update(config) + config_cca["client_id"] = "f4aa5217-e87c-42b2-82af-5624dd14ee72" + 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"] = "c0485386-1e9a-4663-bc96-7ab30656de7f" + 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 _build_b2c_authority(self, policy): base = "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com" return base + "/" + policy # We do not support base + "?p=" + policy @@ -484,29 +528,12 @@ def test_b2c_acquire_token_by_auth_code(self): # This won't work https://msidlab.com/api/user?usertype=b2c password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabb2c """ - scopes = ["https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"] - (self.app, ac, redirect_uri) = _get_app_and_auth_code( - "b876a048-55a5-4fc5-9403-f5d90cb1c852", - client_secret=self.get_lab_user_secret("MSIDLABB2C-MSAapp-AppSecret"), + self._test_acquire_token_by_auth_code( authority=self._build_b2c_authority("B2C_1_SignInPolicy"), + client_id="b876a048-55a5-4fc5-9403-f5d90cb1c852", port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] - scopes=scopes, + scope=["https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"] ) - result = self.app.acquire_token_by_authorization_code( - ac, scopes, 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, scopes, username=None) def test_b2c_acquire_token_by_ropc(self): self._test_username_password( @@ -517,6 +544,40 @@ def test_b2c_acquire_token_by_ropc(self): scope=["https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"], ) + +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) + + if __name__ == "__main__": unittest.main() From 987c196a970c31e4e5b010a8db5d29773b28bc93 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 14 Apr 2020 14:35:11 -0700 Subject: [PATCH 178/440] Allow domain hint (#181) --- msal/application.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/msal/application.py b/msal/application.py index a036b80d..57e1c4a9 100644 --- a/msal/application.py +++ b/msal/application.py @@ -230,6 +230,7 @@ def get_authorization_request_url( response_type="code", # Can be "token" if you use Implicit Grant prompt=None, nonce=None, + domain_hint=None, # type: Optional[str] **kwargs): """Constructs a URL for you to start a Authorization Code Grant. @@ -251,6 +252,12 @@ def get_authorization_request_url( :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. + https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8 :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() @@ -281,6 +288,7 @@ def get_authorization_request_url( prompt=prompt, scope=decorate_scope(scopes, self.client_id), nonce=nonce, + domain_hint=domain_hint, ) def acquire_token_by_authorization_code( From 099d576fc0c9ed9b1d5955be008cd0cb77d2fa61 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 17 Apr 2020 09:37:17 -0700 Subject: [PATCH 179/440] Improve SNI docs (#185) * Improve SNI docs based on https://github.com/AzureAD/azure-activedirectory-library-for-python/issues/225 * Added type hints --- msal/application.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 57e1c4a9..87d83478 100644 --- a/msal/application.py +++ b/msal/application.py @@ -96,8 +96,9 @@ def __init__( client_claims=None, app_name=None, app_version=None): """Create an instance of application. - :param client_id: Your app has a client_id after you register it on AAD. - :param client_credential: + :param str client_id: Your app has a client_id after you register it on AAD. + + :param str client_credential: For :class:`PublicClientApplication`, you simply use `None` here. For :class:`ConfidentialClientApplication`, it can be a string containing client secret, @@ -114,6 +115,17 @@ def __init__( 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. + :param dict client_claims: *Added in version 0.5.0*: It is a dictionary of extra claims that would be signed by From f2340a40ba8f1cb908e3661f40495f6bb2effdda Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 20 Apr 2020 17:24:03 -0700 Subject: [PATCH 180/440] Allowing transport layer to be customized (#169) --- msal/application.py | 56 +++++++++++++++++----------- msal/authority.py | 38 +++++++++---------- msal/mex.py | 7 ++-- msal/oauth2cli/oauth2.py | 77 +++++++++++++++++++++++++++++---------- msal/wstrust_request.py | 10 ++--- requirements.txt | 1 - tests/http_client.py | 30 +++++++++++++++ tests/test_application.py | 46 ++++++++++------------- tests/test_authority.py | 18 +++++---- tests/test_client.py | 4 ++ tests/test_e2e.py | 29 +++++++++------ 11 files changed, 200 insertions(+), 116 deletions(-) create mode 100644 tests/http_client.py diff --git a/msal/application.py b/msal/application.py index 87d83478..a17d3594 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,3 +1,5 @@ +import functools +import json import time try: # Python 2 from urlparse import urljoin @@ -54,11 +56,11 @@ def decorate_scope( CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry' def _get_new_correlation_id(): - return str(uuid.uuid4()) + return str(uuid.uuid4()) def _build_current_telemetry_request_header(public_api_id, force_refresh=False): - return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0") + return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0") def extract_certs(public_cert_content): @@ -92,6 +94,7 @@ 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): """Create an instance of application. @@ -151,18 +154,24 @@ def __init__( :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 :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. @@ -173,14 +182,21 @@ def __init__( self.client_id = client_id self.client_credential = client_credential self.client_claims = client_claims - self.verify = verify - self.proxies = proxies - self.timeout = timeout + if http_client: + self.http_client = http_client + else: + 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) self.app_name = app_name self.app_version = app_version self.authority = Authority( authority or "https://login.microsoftonline.com/common/", - validate_authority, verify=verify, proxies=proxies, timeout=timeout) + self.http_client, validate_authority=validate_authority) # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() self.client = self._build_client(client_credential, self.authority) @@ -223,14 +239,14 @@ def _build_client(self, client_credential, authority): return Client( server_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=self.token_cache.add, on_removing_rt=self.token_cache.remove_rt, - on_updating_rt=self.token_cache.update_rt, - verify=self.verify, proxies=self.proxies, timeout=self.timeout) + on_updating_rt=self.token_cache.update_rt) def get_authorization_request_url( self, @@ -288,12 +304,13 @@ def get_authorization_request_url( # Multi-tenant app can use new authority on demand the_authority = Authority( authority, - verify=self.verify, proxies=self.proxies, timeout=self.timeout, + self.http_client ) if authority else self.authority client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, - self.client_id) + self.client_id, + http_client=self.http_client) return client.build_auth_request_uri( response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, @@ -399,13 +416,12 @@ def _find_msal_accounts(self, environment): def _get_authority_aliases(self, instance): if not self.authority_groups: - resp = requests.get( + 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'}, - verify=self.verify, proxies=self.proxies, timeout=self.timeout) + headers={'Accept': 'application/json'}) resp.raise_for_status() self.authority_groups = [ - set(group['aliases']) for group in resp.json()['metadata']] + 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] @@ -524,7 +540,7 @@ def acquire_token_silent_with_error( warnings.warn("We haven't decided how/if this method will accept authority parameter") # the_authority = Authority( # authority, - # verify=self.verify, proxies=self.proxies, timeout=self.timeout, + # 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, @@ -536,8 +552,8 @@ def acquire_token_silent_with_error( for alias in self._get_authority_aliases(self.authority.instance): the_authority = Authority( "https://" + alias + "/" + self.authority.tenant, - validate_authority=False, - verify=self.verify, proxies=self.proxies, timeout=self.timeout) + 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, correlation_id=correlation_id, @@ -780,13 +796,11 @@ def acquire_token_by_username_password( def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): - verify = kwargs.pop("verify", self.verify) - proxies = kwargs.pop("proxies", self.proxies) wstrust_endpoint = {} if user_realm_result.get("federation_metadata_url"): wstrust_endpoint = mex_send_request( user_realm_result["federation_metadata_url"], - verify=verify, proxies=proxies) + self.http_client) if wstrust_endpoint is None: raise ValueError("Unable to find wstrust endpoint from MEX. " "This typically happens when attempting MSA accounts. " @@ -798,7 +812,7 @@ def _acquire_token_by_username_password_federated( wstrust_endpoint.get("address", # Fallback to an AAD supplied endpoint user_realm_result.get("federation_active_auth_url")), - wstrust_endpoint.get("action"), verify=verify, proxies=proxies) + 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' diff --git a/msal/authority.py b/msal/authority.py index d8221eca..94caaab4 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -1,11 +1,10 @@ +import json try: from urllib.parse import urlparse except ImportError: # Fall back to Python 2 from urlparse import urlparse import logging -import requests - from .exceptions import MsalServiceError @@ -25,6 +24,7 @@ "b2clogin.de", ] + class Authority(object): """This class represents an (already-validated) authority. @@ -33,9 +33,7 @@ class Authority(object): """ _domains_without_user_realm_discovery = set([]) - def __init__(self, authority_url, validate_authority=True, - verify=True, proxies=None, timeout=None, - ): + def __init__(self, authority_url, http_client, validate_authority=True): """Creates an authority instance, and also validates it. :param validate_authority: @@ -44,9 +42,7 @@ def __init__(self, authority_url, validate_authority=True, This parameter only controls whether an instance discovery will be performed. """ - self.verify = verify - self.proxies = proxies - self.timeout = timeout + self.http_client = http_client 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 ( @@ -56,7 +52,7 @@ def __init__(self, authority_url, validate_authority=True, payload = instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( self.instance, authority.path), - verify=verify, proxies=proxies, timeout=timeout) + self.http_client) if payload.get("error") == "invalid_instance": raise ValueError( "invalid_instance: " @@ -75,7 +71,7 @@ def __init__(self, authority_url, validate_authority=True, )) openid_config = tenant_discovery( tenant_discovery_endpoint, - verify=verify, proxies=proxies, timeout=timeout) + self.http_client) logger.debug("openid_config = %s", openid_config) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] @@ -87,15 +83,14 @@ def user_realm_discovery(self, username, correlation_id=None, response=None): # "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 requests.get( + 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}, - verify=self.verify, proxies=self.proxies, timeout=self.timeout) + headers={'Accept': 'application/json', + 'client-request-id': correlation_id},) if resp.status_code != 404: resp.raise_for_status() - return resp.json() + 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 @@ -113,20 +108,21 @@ def canonicalize(authority_url): % authority_url) return authority, authority.hostname, parts[1] -def instance_discovery(url, **kwargs): - return requests.get( # Note: This URL seemingly returns V1 endpoint only +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).json() + **kwargs) + return json.loads(resp.text) -def tenant_discovery(tenant_discovery_endpoint, **kwargs): +def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs): # Returns Openid Configuration - resp = requests.get(tenant_discovery_endpoint, **kwargs) - payload = resp.json() + resp = http_client.get(tenant_discovery_endpoint, **kwargs) + payload = json.loads(resp.text) if 'authorization_endpoint' in payload and 'token_endpoint' in payload: return payload raise MsalServiceError(status_code=resp.status_code, **payload) diff --git a/msal/mex.py b/msal/mex.py index caf5e3ed..684d50ed 100644 --- a/msal/mex.py +++ b/msal/mex.py @@ -34,15 +34,14 @@ except ImportError: from xml.etree import ElementTree as ET -import requests - 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, **kwargs): - mex_document = requests.get( + +def send_request(mex_endpoint, http_client, **kwargs): + mex_document = http_client.get( mex_endpoint, headers={'Content-Type': 'application/soap+xml'}, **kwargs).text return Mex(mex_document).get_wstrust_username_password_endpoint() diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 9a947390..fac35f1b 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -1,6 +1,7 @@ """This OAuth2 client implementation aims to be spec-compliant, and generic.""" # OAuth2 spec https://tools.ietf.org/html/rfc6749 +import json try: from urllib.parse import urlencode, parse_qs except ImportError: @@ -11,6 +12,7 @@ import time import base64 import sys +import functools import requests @@ -35,6 +37,7 @@ 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] @@ -57,6 +60,9 @@ def __init__( 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. client_secret (str): Triggers HTTP AUTH for Confidential Client client_assertion (bytes, callable): The client assertion to authenticate this client, per RFC 7521. @@ -76,20 +82,51 @@ def __init__( 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 + `_ + 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. + + There is no session-wide `timeout` parameter defined here. + The timeout behavior is determined by the actual http client you use. + If you happen to use Requests, it chose to not support session-wide timeout + (https://github.com/psf/requests/issues/3341), but you can patch that by: + + s = requests.Session() + s.request = functools.partial(s.request, timeout=3) + + and then feed that patched session instance to this class. """ 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__) - self.session = s = requests.Session() - s.headers.update(default_headers or {}) - s.verify = verify - s.proxies = proxies or {} - self.timeout = timeout + if http_client: + self.http_client = http_client + else: + self.http_client = requests.Session() + self.http_client.verify = 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 @@ -110,7 +147,6 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 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 - timeout=None, post=None, # A callable to replace requests.post(), for testing. # Such as: lambda url, **kwargs: # Mock(status_code=200, json=Mock(return_value={})) @@ -128,11 +164,15 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 _data.update(self.default_body) # It may contain authen parameters _data.update(data or {}) # So the content in data param prevails - # We don't have to clean up None values here, because requests lib will. + _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. @@ -140,18 +180,16 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 # the authorization server MAY support including the # client credentials in the request-body using the following # parameters: client_id, client_secret. - auth = None if self.client_secret and self.client_id: - auth = (self.client_id, self.client_secret) # for HTTP Basic Auth + _headers["Authorization"] = "Basic " + base64.b64encode( + "{}:{}".format(self.client_id, self.client_secret) + .encode("ascii")).decode("ascii") if "token_endpoint" not in self.configuration: raise ValueError("token_endpoint not found in configuration") - _headers = {'Accept': 'application/json'} - _headers.update(headers or {}) - resp = (post or self.session.post)( + resp = (post or self.http_client.post)( self.configuration["token_endpoint"], - headers=_headers, params=params, data=_data, auth=auth, - timeout=timeout or self.timeout, + headers=_headers, params=params, data=_data, **kwargs) if resp.status_code >= 500: resp.raise_for_status() # TODO: Will probably retry here @@ -159,7 +197,7 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 # 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 resp.json() + return json.loads(resp.text) except ValueError: self.logger.exception( "Token response is not in json format: %s", resp.text) @@ -200,7 +238,7 @@ class Client(BaseClient): # We choose to implement all 4 grants in 1 class grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion} - def initiate_device_flow(self, scope=None, timeout=None, **kwargs): + 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 @@ -218,10 +256,11 @@ def initiate_device_flow(self, scope=None, timeout=None, **kwargs): DAE = "device_authorization_endpoint" if not self.configuration.get(DAE): raise ValueError("You need to provide device authorization endpoint") - flow = self.session.post(self.configuration[DAE], + resp = self.http_client.post(self.configuration[DAE], data={"client_id": self.client_id, "scope": self._stringify(scope or [])}, - timeout=timeout or self.timeout, - **kwargs).json() + 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 diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 84c03848..b2898f76 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -29,16 +29,13 @@ from datetime import datetime, timedelta import logging -import requests - 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, + 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") @@ -51,7 +48,7 @@ def send_request( "Unsupported soap action: %s" % soap_action) data = _build_rst( username, password, cloud_audience_urn, endpoint_address, soap_action) - resp = requests.post(endpoint_address, data=data, headers={ + resp = http_client.post(endpoint_address, data=data, headers={ 'Content-type':'application/soap+xml; charset=utf-8', 'SOAPAction': soap_action, }, **kwargs) @@ -61,11 +58,13 @@ def send_request( # 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 @@ -74,6 +73,7 @@ def wsu_time_format(datetime_obj): # 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 """ diff --git a/requirements.txt b/requirements.txt index 61a6510d..9c558e35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ . -mock; python_version < '3.3' diff --git a/tests/http_client.py b/tests/http_client.py new file mode 100644 index 00000000..4bff9b45 --- /dev/null +++ b/tests/http_client.py @@ -0,0 +1,30 @@ +import 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): + 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): + return MinimalResponse(requests_resp=self.session.get( + url, params=params, headers=headers, timeout=self.timeout)) + + +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: + self._raw_resp.raise_for_status() diff --git a/tests/test_application.py b/tests/test_application.py index 4d7c2881..39becd5a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,18 +1,10 @@ # Note: Since Aug 2019 we move all e2e tests into test_e2e.py, # so this test_application file contains only unit tests without dependency. -import os -import json -import logging - -try: - from unittest.mock import * # Python 3 -except: - from mock import * # Need an external mock package - from msal.application import * import msal from tests import unittest from tests.test_token_cache import TokenCacheTestCase +from tests.http_client import MinimalHttpClient, MinimalResponse logger = logging.getLogger(__name__) @@ -50,7 +42,8 @@ class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase): def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" - self.authority = msal.authority.Authority(self.authority_url) + self.authority = msal.authority.Authority( + self.authority_url, MinimalHttpClient()) self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" @@ -76,31 +69,31 @@ def test_cache_empty_will_be_returned_as_None(self): 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"} + error_response = '{"error": "invalid_grant", "suberror": "xyz"}' def tester(url, **kwargs): - return Mock(status_code=400, json=Mock(return_value=error_response)) + 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"} + error_response = '{"error": "invalid_grant", "error_description": "xyz"}' def tester(url, **kwargs): - return Mock(status_code=400, json=Mock(return_value=error_response)) - self.assertEqual(error_response, self.app.acquire_token_silent_with_error( + 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"} + error_response = '{"error": "invalid_grant", "suberror": "basic_action"}' def tester(url, **kwargs): - return Mock(status_code=400, json=Mock(return_value=error_response)) + 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"} + error_response = '{"error": "invalid_grant", "suberror": "client_mismatch"}' def tester(url, **kwargs): - return Mock(status_code=400, json=Mock(return_value=error_response)) + 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")) @@ -109,7 +102,8 @@ class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase): def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" - self.authority = msal.authority.Authority(self.authority_url) + self.authority = msal.authority.Authority( + self.authority_url, MinimalHttpClient()) self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" @@ -131,11 +125,10 @@ 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 Mock(status_code=400, json=Mock(return_value={ - "error": "invalid_grant", - "error_description": "Was issued to another client"})) + 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( @@ -156,7 +149,7 @@ def test_known_orphan_app_will_skip_frt_and_only_use_its_own_rt(self): 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 Mock(status_code=200, json=Mock(return_value={})) + 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) @@ -164,9 +157,8 @@ 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 Mock( - status_code=200, - json=Mock(return_value=TokenCacheTestCase.build_response( + return MinimalResponse( + status_code=200, text=json.dumps(TokenCacheTestCase.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) diff --git a/tests/test_authority.py b/tests/test_authority.py index d1e75ef7..15a0eb52 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -1,8 +1,8 @@ import os from msal.authority import * -from msal.exceptions import MsalServiceError from tests import unittest +from tests.http_client import MinimalHttpClient @unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip network io during tagged release") @@ -11,7 +11,8 @@ class TestAuthority(unittest.TestCase): 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: - a = Authority('https://{}/common'.format(host)) + a = Authority( + 'https://{}/common'.format(host), MinimalHttpClient()) self.assertEqual( a.authorization_endpoint, 'https://%s/common/oauth2/v2.0/authorize' % host) @@ -24,18 +25,22 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): # 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)) + 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') + 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', validate_authority=False) + 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") @@ -79,7 +84,7 @@ 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, validate_authority=False) + a = Authority(authority, MinimalHttpClient(), validate_authority=False) # We now pretend this authority supports no User Realm Discovery class MockResponse(object): @@ -91,4 +96,3 @@ class MockResponse(object): "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") - diff --git a/tests/test_client.py b/tests/test_client.py index d1de2b6f..75cdfc9c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,6 +12,7 @@ from msal.oauth2cli import Client, JwtSigner from msal.oauth2cli.authcode import obtain_auth_code from tests import unittest, Oauth2TestCase +from tests.http_client import MinimalHttpClient logging.basicConfig(level=logging.DEBUG) @@ -83,6 +84,7 @@ class TestClient(Oauth2TestCase): @classmethod def setUpClass(cls): + http_client = MinimalHttpClient() if "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: @@ -90,6 +92,7 @@ def setUpClass(cls): cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], + http_client=http_client, client_assertion=JwtSigner( private_key, algorithm="RS256", @@ -103,6 +106,7 @@ def setUpClass(cls): else: cls.client = Client( CONFIG["openid_configuration"], CONFIG['client_id'], + http_client=http_client, client_secret=CONFIG.get('client_secret')) @unittest.skipIf( diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 0d74eb1d..28383cd6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7,7 +7,7 @@ import requests import msal - +from tests.http_client import MinimalHttpClient logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -21,7 +21,8 @@ def _get_app_and_auth_code( scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph **kwargs): from msal.oauth2cli.authcode import obtain_auth_code - app = msal.ClientApplication(client_id, client_secret, authority=authority) + app = msal.ClientApplication( + client_id, client_secret, 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)) @@ -92,7 +93,8 @@ def _test_username_password(self, authority=None, client_id=None, username=None, password=None, scope=None, **ignored): assert authority and client_id and username and password and scope - self.app = msal.PublicClientApplication(client_id, authority=authority) + self.app = msal.PublicClientApplication( + client_id, authority=authority, http_client=MinimalHttpClient()) result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) @@ -106,7 +108,7 @@ 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) + 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)) @@ -225,13 +227,13 @@ def test_ssh_cert(self): self.assertEqual(refreshed_ssh_cert["token_type"], "ssh-cert") self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) - 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")) + 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) @@ -245,7 +247,8 @@ def test_client_certificate(self): 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"]}) + {"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) @@ -267,7 +270,8 @@ def test_subject_name_issuer_authentication(self): "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) @@ -311,7 +315,7 @@ def get_lab_app( return msal.ConfidentialClientApplication(client_id, client_secret, authority="https://login.microsoftonline.com/" "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID - ) + http_client=MinimalHttpClient()) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow logger.info("Creating session") @@ -398,7 +402,8 @@ def _test_acquire_token_by_auth_code( def _test_acquire_token_obo(self, config_pca, config_cca): # 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"]) + config_pca["client_id"], authority=config_pca["authority"], + http_client=MinimalHttpClient()) pca_result = pca.acquire_token_by_username_password( config_pca["username"], config_pca["password"], @@ -413,6 +418,7 @@ def _test_acquire_token_obo(self, config_pca, config_cca): config_cca["client_id"], client_credential=config_cca["client_secret"], authority=config_cca["authority"], + http_client=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. @@ -439,7 +445,8 @@ def _test_acquire_token_by_client_secret( **ignored): assert client_id and client_secret and authority and scope app = msal.ConfidentialClientApplication( - client_id, client_credential=client_secret, authority=authority) + 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) From a7c5ea659df3d49fcacb35f235fb58157c10ddd6 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 23 Apr 2020 16:34:19 -0700 Subject: [PATCH 181/440] Returning refresh token errors which were discarded (#186) --- msal/application.py | 6 ++++-- tests/test_application.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index a17d3594..66d9b430 100644 --- a/msal/application.py +++ b/msal/application.py @@ -633,8 +633,9 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( **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 - at = self._acquire_token_silent_by_finding_specific_refresh_token( + 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: @@ -642,7 +643,8 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( # 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) + 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(...) diff --git a/tests/test_application.py b/tests/test_application.py index 39becd5a..65b36b34 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -176,6 +176,19 @@ def tester(url, data=None, **kwargs): # 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( From 226eb71eaf101c87f2dfc91bfb1fd206c0cc8339 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 30 Apr 2020 17:22:31 -0700 Subject: [PATCH 182/440] Patch authority for backward compatibility --- msal/authority.py | 12 +++++++++++- tests/test_authority_patch.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/test_authority_patch.py diff --git a/msal/authority.py b/msal/authority.py index 94caaab4..61495c4f 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -5,6 +5,11 @@ from urlparse import urlparse import logging +# Historically some customers patched this module-wide requests instance. +# We keep it here for now. They will be removed in next major release. +import requests +import requests as _requests + from .exceptions import MsalServiceError @@ -33,6 +38,11 @@ class Authority(object): """ _domains_without_user_realm_discovery = set([]) + @property + def http_client(self): # Obsolete. We will remove this in next major release. + # A workaround: if module-wide requests is patched, we honor it. + return self._http_client if requests is _requests else requests + def __init__(self, authority_url, http_client, validate_authority=True): """Creates an authority instance, and also validates it. @@ -42,7 +52,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ - self.http_client = http_client + self._http_client = http_client 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 ( diff --git a/tests/test_authority_patch.py b/tests/test_authority_patch.py new file mode 100644 index 00000000..1feca62d --- /dev/null +++ b/tests/test_authority_patch.py @@ -0,0 +1,32 @@ +import unittest + +import msal +from tests.http_client import MinimalHttpClient + + +class DummyHttpClient(object): + def get(self, url, **kwargs): + raise RuntimeError("just for testing purpose") + + +class TestAuthorityHonorsPatchedRequests(unittest.TestCase): + """This is only a workaround for an undocumented behavior.""" + def test_authority_honors_a_patched_requests(self): + # First, we test that the original, unmodified authority is working + a = msal.authority.Authority( + "https://login.microsoftonline.com/common", MinimalHttpClient()) + self.assertEqual( + a.authorization_endpoint, + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize') + + original = msal.authority.requests + try: + # Now we mimic a (discouraged) practice of patching authority.requests + msal.authority.requests = DummyHttpClient() + # msal.authority is expected to honor that patch. + with self.assertRaises(RuntimeError): + a = msal.authority.Authority( + "https://login.microsoftonline.com/common", MinimalHttpClient()) + finally: # Tricky: + # Unpatch is necessary otherwise other test cases would be affected + msal.authority.requests = original From 25ee6d8688f81299d99d42b0d6e2725054e096f3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 30 Apr 2020 12:21:38 -0700 Subject: [PATCH 183/440] Improve error message on incorrect authority uri --- README.md | 14 +++++++++----- msal/authority.py | 13 ++++++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4fb665d0..78d56abd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ 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). @@ -43,7 +48,9 @@ Acquiring tokens with MSAL Python follows this 3-step pattern. ```python from msal import PublicClientApplication - app = PublicClientApplication("your_client_id", authority="...") + 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: @@ -67,7 +74,7 @@ Acquiring tokens with MSAL Python follows this 3-step pattern. # 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(config["scope"], account=chosen) + 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, @@ -86,9 +93,6 @@ Acquiring tokens with MSAL Python follows this 3-step pattern. print(result.get("correlation_id")) # You may need this when reporting a bug ``` -That is the high level pattern. There will be some variations for different flows. They are demonstrated in -[samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). - 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 diff --git a/msal/authority.py b/msal/authority.py index 61495c4f..d738b966 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -79,9 +79,16 @@ def __init__(self, authority_url, http_client, validate_authority=True): authority.path, # In B2C scenario, it is "/tenant/policy" "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint )) - openid_config = tenant_discovery( - tenant_discovery_endpoint, - self.http_client) + try: + openid_config = tenant_discovery( + tenant_discovery_endpoint, + self.http_client) + except json.decoder.JSONDecodeError: + raise ValueError( + "Unable to get authority configuration for {}. " + "Authority would typically be in a format of " + "https://login.microsoftonline.com/your_tenant_name".format( + authority_url)) logger.debug("openid_config = %s", openid_config) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] From 56e806ea0042b07f057ca51e57fadbef4bd77ae5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 5 May 2020 17:29:20 -0700 Subject: [PATCH 184/440] json.decoder.JSONDecodeError is not in Python 2 --- msal/authority.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index d738b966..e200299d 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -83,7 +83,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): openid_config = tenant_discovery( tenant_discovery_endpoint, self.http_client) - except json.decoder.JSONDecodeError: + except ValueError: # json.decoder.JSONDecodeError in Py3 subclasses this raise ValueError( "Unable to get authority configuration for {}. " "Authority would typically be in a format of " From 453dc39ae9aab75553b994d07d7299a0f3e69e28 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 4 May 2020 17:56:27 -0700 Subject: [PATCH 185/440] A built-in API for RT migration --- msal/application.py | 22 +++++++++++++ sample/migrate_rt.py | 75 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 sample/migrate_rt.py diff --git a/msal/application.py b/msal/application.py index 66d9b430..35e78412 100644 --- a/msal/application.py +++ b/msal/application.py @@ -700,6 +700,28 @@ def _validate_ssh_cert_input_data(self, data): "you must include a string parameter named 'key_id' " "which identifies the key in the 'req_cnf' argument.") + def import_refresh_token(self, refresh_token, scopes): + """Import an RT from elsewhere into MSAL's token cache. + + :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. + https://docs.microsoft.com/en-us/azure/active-directory/develop/migrate-python-adal-msal#scopes-not-resources + + :return: + * A dict contains "error" and some other keys, when error happened. + * A dict contains no "error" key. + """ + result = self.client.obtain_token_by_refresh_token( + refresh_token, + decorate_scope(scopes, self.client_id), + rt_getter=lambda rt: rt, + on_updating_rt=False, + ) + return {} if "error" not in result else result # Returns NO token + class PublicClientApplication(ClientApplication): # browser app or mobile app diff --git a/sample/migrate_rt.py b/sample/migrate_rt.py new file mode 100644 index 00000000..bd3c3feb --- /dev/null +++ b/sample/migrate_rt.py @@ -0,0 +1,75 @@ +""" +The configuration file would look like this: + +{ + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "your_client_id", + "scope": ["User.ReadBasic.All"], + // You can find the other permission names from this document + // https://docs.microsoft.com/en-us/graph/permissions-reference +} + +You can then run this sample with a JSON configuration file: + + python sample.py parameters.json +""" + +import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] +import json +import logging + +import msal + + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs + +config = json.load(open(sys.argv[1])) + +def get_rt_via_old_app(): + # Let's pretend this is an old app powered by ADAL + app = msal.PublicClientApplication( + config["client_id"], authority=config["authority"]) + flow = app.initiate_device_flow(scopes=config["scope"]) + if "user_code" not in flow: + raise ValueError( + "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) + print(flow["message"]) + sys.stdout.flush() # Some terminal needs this to ensure the message is shown + + # Ideally you should wait here, in order to save some unnecessary polling + # input("Press Enter after signing in from another device to proceed, CTRL+C to abort.") + + result = app.acquire_token_by_device_flow(flow) # By default it will block + assert "refresh_token" in result, "We should have a successful result" + return result["refresh_token"] + +try: # For easier testing, we try to reload a RT from previous run + old_rt = json.load(open("rt.json"))[0] +except: # If that is not possible, we acquire a RT + old_rt = get_rt_via_old_app() + json.dump([old_rt], open("rt.json", "w")) + +# Now we will try to migrate this old_rt into a new app powered by MSAL + +token_cache = msal.SerializableTokenCache() +assert token_cache.serialize() == '{}', "Token cache is initially empty" +app = msal.PublicClientApplication( + config["client_id"], authority=config["authority"], token_cache=token_cache) +result = app.import_refresh_token(old_rt, config["scope"]) +if "error" in result: + print("Migration unsuccessful. Error: ", json.dumps(result, indent=2)) +else: + print("Migration is successful") + logging.debug("Token cache contains: %s", token_cache.serialize()) + +# From now on, the RT is saved inside MSAL's cache, +# and becomes available in normal MSAL coding pattern. For example: +accounts = app.get_accounts() +if accounts: + account = accounts[0] # Assuming end user pick this account + result = app.acquire_token_silent(config["scope"], account) + if "access_token" in result: + print("RT is available in MSAL's cache, and can be used to acquire new AT") + From 73a9b8bce608d889d4779d4fa1209979edf15d6a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 13 May 2020 17:43:42 -0700 Subject: [PATCH 186/440] Switch import_refresh_token() to acquire_token_by_refresh_token() Remove distracting test setup from sample Demonstrate the migration-in-batch pattern --- msal/application.py | 20 +++++++---- sample/migrate_rt.py | 84 ++++++++++++++++++++------------------------ 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/msal/application.py b/msal/application.py index 35e78412..540ce4f1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -700,27 +700,35 @@ def _validate_ssh_cert_input_data(self, data): "you must include a string parameter named 'key_id' " "which identifies the key in the 'req_cnf' argument.") - def import_refresh_token(self, refresh_token, scopes): - """Import an RT from elsewhere into MSAL's token cache. + def acquire_token_by_refresh_token(self, refresh_token, scopes): + """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. - https://docs.microsoft.com/en-us/azure/active-directory/develop/migrate-python-adal-msal#scopes-not-resources + See `Scopes not resources `_. :return: * A dict contains "error" and some other keys, when error happened. - * A dict contains no "error" key. + * A dict contains no "error" key means migration was successful. """ - result = self.client.obtain_token_by_refresh_token( + return self.client.obtain_token_by_refresh_token( refresh_token, decorate_scope(scopes, self.client_id), rt_getter=lambda rt: rt, on_updating_rt=False, ) - return {} if "error" not in result else result # Returns NO token class PublicClientApplication(ClientApplication): # browser app or mobile app diff --git a/sample/migrate_rt.py b/sample/migrate_rt.py index bd3c3feb..eb623733 100644 --- a/sample/migrate_rt.py +++ b/sample/migrate_rt.py @@ -25,51 +25,43 @@ # logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script # logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs +def get_preexisting_rt_and_their_scopes_from_elsewhere(): + # Maybe you have an ADAL-powered app like this + # https://github.com/AzureAD/azure-activedirectory-library-for-python/blob/1.2.3/sample/device_code_sample.py#L72 + # which uses a resource rather than a scope, + # you need to convert your v1 resource into v2 scopes + # See https://docs.microsoft.com/azure/active-directory/develop/azure-ad-endpoint-comparison#scopes-not-resources + # You may be able to append "/.default" to your v1 resource to form a scope + # See https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#the-default-scope + + # Or maybe you have an app already talking to Microsoft identity platform v2, + # powered by some 3rd-party auth library, and persist its tokens somehow. + + # Either way, you need to extract RTs from there, and return them like this. + return [ + ("old_rt_1", ["scope1", "scope2"]), + ("old_rt_2", ["scope3", "scope4"]), + ] + + +# We will migrate all the old RTs into a new app powered by MSAL config = json.load(open(sys.argv[1])) - -def get_rt_via_old_app(): - # Let's pretend this is an old app powered by ADAL - app = msal.PublicClientApplication( - config["client_id"], authority=config["authority"]) - flow = app.initiate_device_flow(scopes=config["scope"]) - if "user_code" not in flow: - raise ValueError( - "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) - print(flow["message"]) - sys.stdout.flush() # Some terminal needs this to ensure the message is shown - - # Ideally you should wait here, in order to save some unnecessary polling - # input("Press Enter after signing in from another device to proceed, CTRL+C to abort.") - - result = app.acquire_token_by_device_flow(flow) # By default it will block - assert "refresh_token" in result, "We should have a successful result" - return result["refresh_token"] - -try: # For easier testing, we try to reload a RT from previous run - old_rt = json.load(open("rt.json"))[0] -except: # If that is not possible, we acquire a RT - old_rt = get_rt_via_old_app() - json.dump([old_rt], open("rt.json", "w")) - -# Now we will try to migrate this old_rt into a new app powered by MSAL - -token_cache = msal.SerializableTokenCache() -assert token_cache.serialize() == '{}', "Token cache is initially empty" app = msal.PublicClientApplication( - config["client_id"], authority=config["authority"], token_cache=token_cache) -result = app.import_refresh_token(old_rt, config["scope"]) -if "error" in result: - print("Migration unsuccessful. Error: ", json.dumps(result, indent=2)) -else: - print("Migration is successful") - logging.debug("Token cache contains: %s", token_cache.serialize()) - -# From now on, the RT is saved inside MSAL's cache, -# and becomes available in normal MSAL coding pattern. For example: -accounts = app.get_accounts() -if accounts: - account = accounts[0] # Assuming end user pick this account - result = app.acquire_token_silent(config["scope"], account) - if "access_token" in result: - print("RT is available in MSAL's cache, and can be used to acquire new AT") - + config["client_id"], authority=config["authority"], + # token_cache=... # Default cache is in memory only. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + ) + +# We choose a migration strategy of migrating all RTs in one loop +for old_rt, scopes in get_preexisting_rt_and_their_scopes_from_elsewhere(): + result = app.acquire_token_by_refresh_token(old_rt, scopes) + if "error" in result: + print("Discarding unsuccessful RT. Error: ", json.dumps(result, indent=2)) + +print("Migration completed") + +# From now on, those successfully-migrated RTs are saved inside MSAL's cache, +# and becomes available in normal MSAL coding pattern, which is NOT part of migration. +# You can refer to: +# https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/1.2.0/sample/device_flow_sample.py#L42-L60 From 5d97ff0407c8be9e809df12fbb38e08bbbad2e01 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 14 May 2020 17:00:06 -0700 Subject: [PATCH 187/440] MSAL Python 1.3.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 540ce4f1..c4f42c1e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.2.0" +__version__ = "1.3.0" logger = logging.getLogger(__name__) From ec0bb7402fe16884c077cc31bf6614650c101354 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 18 May 2020 17:19:14 -0700 Subject: [PATCH 188/440] Changing format of links in docstring --- msal/application.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index c4f42c1e..096ee24e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -284,8 +284,9 @@ def get_authorization_request_url( 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. - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code - https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8 + More information on possible values + `here `_ and + `here `_. :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() From f4249ab23c8e21489cdad40e6631928d7ebce2cd Mon Sep 17 00:00:00 2001 From: Neil Katin Date: Fri, 22 May 2020 19:15:27 -0700 Subject: [PATCH 189/440] One of the examples in the README was not legal python syntax. I updated the example to match what the source seems to want. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78d56abd..7702b367 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ There will be some variations for different flows. They are demonstrated in from msal import PublicClientApplication app = PublicClientApplication( "your_client_id", - "authority": "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here") + authority="https://login.microsoftonline.com/Enter_the_Tenant_Name_Here") ``` Later, each time you would want an access token, you start by: From 8d1833aff72efeee9e99ff6ffa4bebcb71167ca7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Jun 2020 16:06:37 -0700 Subject: [PATCH 190/440] Use release history as changelog for PyPI --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index 11d9c44f..7e543541 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,6 @@ [bdist_wheel] universal=1 +[metadata] +project_urls = + Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases From ff38c786d1642b212ff8d412950cfb01903e273e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 19 Jun 2020 15:12:44 -0700 Subject: [PATCH 191/440] Migration should fail gracefully even on wrong RT --- msal/application.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 096ee24e..e3bbc794 100644 --- a/msal/application.py +++ b/msal/application.py @@ -284,7 +284,7 @@ def get_authorization_request_url( 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 + More information on possible values `here `_ and `here `_. :return: The authorization url as a string. @@ -726,9 +726,10 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): """ return self.client.obtain_token_by_refresh_token( refresh_token, - decorate_scope(scopes, self.client_id), + scope=decorate_scope(scopes, self.client_id), rt_getter=lambda rt: rt, on_updating_rt=False, + on_removing_rt=lambda rt_item: None, # No OP ) From 492a76e9a0bcc5e10c5c6e045b63c50762261d59 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 22 Jun 2020 14:58:33 -0700 Subject: [PATCH 192/440] Fix typos (#210) --- msal/oauth2cli/oauth2.py | 6 +++--- msal/oauth2cli/oidc.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 3bf9339e..55fa0547 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -233,7 +233,7 @@ def obtain_token_by_refresh_token(self, refresh_token, scope=None, **kwargs): :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 ownser, + granted by the resource owner, according to https://tools.ietf.org/html/rfc6749#section-6 """ assert isinstance(refresh_token, string_types) @@ -397,7 +397,7 @@ def parse_auth_response(params, state=None): def obtain_token_by_authorization_code( self, code, redirect_uri=None, scope=None, **kwargs): - """Get a token via auhtorization code. a.k.a. Authorization Code Grant. + """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). @@ -503,7 +503,7 @@ def obtain_token_by_refresh_token(self, token_item, scope=None, 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 ownser, + 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 diff --git a/msal/oauth2cli/oidc.py b/msal/oauth2cli/oidc.py index 33bbdb2d..45861303 100644 --- a/msal/oauth2cli/oidc.py +++ b/msal/oauth2cli/oidc.py @@ -99,7 +99,7 @@ def build_auth_request_uri(self, response_type, nonce=None, **kwargs): response_type, nonce=nonce, **kwargs) def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs): - """Get a token via auhtorization code. a.k.a. Authorization Code Grant. + """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`, From d32436773f31b4ad125c54e0750c0badc57147a5 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 22 Jun 2020 15:41:31 -0700 Subject: [PATCH 193/440] Application initializer does not make tenant discovery calls (#205) --- msal/application.py | 40 +++++++++++++++++++++++------------ msal/authority.py | 11 ++++++++++ tests/test_application.py | 1 + tests/test_authority.py | 5 +++-- tests/test_authority_patch.py | 2 ++ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/msal/application.py b/msal/application.py index 096ee24e..85cd6096 100644 --- a/msal/application.py +++ b/msal/application.py @@ -198,8 +198,9 @@ def __init__( authority or "https://login.microsoftonline.com/common/", self.http_client, validate_authority=validate_authority) # Here the self.authority is not the same type as authority in input + self.client = None self.token_cache = token_cache or TokenCache() - self.client = self._build_client(client_credential, self.authority) + self._client_credential = client_credential self.authority_groups = None def _build_client(self, client_credential, authority): @@ -248,6 +249,12 @@ def _build_client(self, client_credential, authority): on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) + def _get_client(self): + if not self.client: + self.authority.initialize() + self.client = self._build_client(self._client_credential, self.authority) + return self.client + def get_authorization_request_url( self, scopes, # type: list[str] @@ -307,6 +314,7 @@ def get_authorization_request_url( authority, self.http_client ) if authority else self.authority + the_authority.initialize() client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, @@ -367,7 +375,7 @@ def acquire_token_by_authorization_code( # really empty. assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self.client.obtain_token_by_authorization_code( + return self._get_client().obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=decorate_scope(scopes, self.client_id), headers={ @@ -391,6 +399,7 @@ def get_accounts(self, username=None): Your app can choose to display those information to end user, and allow user to choose one of his/her accounts to proceed. """ + self.authority.initialize() 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): @@ -543,6 +552,7 @@ def acquire_token_silent_with_error( # authority, # self.http_client, # ) if authority else self.authority + self.authority.initialize() result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, self.authority, force_refresh=force_refresh, correlation_id=correlation_id, @@ -555,6 +565,7 @@ def acquire_token_silent_with_error( "https://" + alias + "/" + self.authority.tenant, self.http_client, validate_authority=False) + the_authority.initialize() result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, force_refresh=force_refresh, correlation_id=correlation_id, @@ -724,7 +735,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): * A dict contains "error" and some other keys, when error happened. * A dict contains no "error" key means migration was successful. """ - return self.client.obtain_token_by_refresh_token( + return self._get_client().obtain_token_by_refresh_token( refresh_token, decorate_scope(scopes, self.client_id), rt_getter=lambda rt: rt, @@ -754,7 +765,7 @@ def initiate_device_flow(self, scopes=None, **kwargs): - an error response would contain some other readable key/value pairs. """ correlation_id = _get_new_correlation_id() - flow = self.client.initiate_device_flow( + flow = self._get_client().initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), headers={ CLIENT_REQUEST_ID: correlation_id, @@ -778,7 +789,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs): - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - return self.client.obtain_token_by_device_flow( + return self._get_client().obtain_token_by_device_flow( flow, data=dict(kwargs.pop("data", {}), code=flow["device_code"]), # 2018-10-4 Hack: @@ -815,6 +826,7 @@ def acquire_token_by_username_password( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID), } + self.authority.initialize() if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[CLIENT_REQUEST_ID]) @@ -822,7 +834,7 @@ def acquire_token_by_username_password( return self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, headers=headers, **kwargs) - return self.client.obtain_token_by_username_password( + return self._get_client().obtain_token_by_username_password( username, password, scope=scopes, headers=headers, **kwargs) @@ -851,16 +863,16 @@ def _acquire_token_by_username_password_federated( 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, + SAML_TOKEN_TYPE_V2: Client.GRANT_TYPE_SAML2, WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1, - WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2 + WSS_SAML_TOKEN_PROFILE_V2: 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( + Client.grant_assertion_encoders.setdefault( # Register a non-standard type + grant_type, Client.encode_saml_assertion) + return self._get_client().obtain_token_by_assertion( wstrust_result["token"], grant_type, scope=scopes, **kwargs) @@ -878,7 +890,7 @@ def acquire_token_for_client(self, scopes, **kwargs): - an error response would contain "error" and usually "error_description". """ # TBD: force_refresh behavior - return self.client.obtain_token_for_client( + return self._get_client().obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), @@ -910,9 +922,9 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): """ # The implementation is NOT based on Token Exchange # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 - return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 + return self._get_client().obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, - self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs + Client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs scope=decorate_scope(scopes, self.client_id), # Decoration is used for: # 1. Explicitly requesting an RT, without relying on AAD default # behavior, even though it currently still issues an RT. diff --git a/msal/authority.py b/msal/authority.py index e200299d..edafbd3d 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -52,6 +52,17 @@ def __init__(self, authority_url, http_client, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ + self._http_client = http_client + self._authority_url = authority_url + self._validate_authority = validate_authority + self._is_initialized = False + + def initialize(self): + if not self._is_initialized: + self.__initialize(self._authority_url, self._http_client, self._validate_authority) + self._is_initialized = True + + def __initialize(self, authority_url, http_client, validate_authority): self._http_client = http_client authority, self.instance, tenant = canonicalize(authority_url) parts = authority.path.split('/') diff --git a/tests/test_application.py b/tests/test_application.py index 65b36b34..57095bbb 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -104,6 +104,7 @@ def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" self.authority = msal.authority.Authority( self.authority_url, MinimalHttpClient()) + self.authority.initialize() self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" diff --git a/tests/test_authority.py b/tests/test_authority.py index 15a0eb52..eae2c57a 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -13,6 +13,7 @@ def test_wellknown_host_and_tenant(self): for host in WELL_KNOWN_AUTHORITY_HOSTS: a = Authority( 'https://{}/common'.format(host), MinimalHttpClient()) + a.initialize() self.assertEqual( a.authorization_endpoint, 'https://%s/common/oauth2/v2.0/authorize' % host) @@ -34,7 +35,7 @@ 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()) + MinimalHttpClient()).initialize() def test_invalid_host_skipping_validation_can_be_turned_off(self): try: @@ -85,7 +86,7 @@ def test_memorize(self): authority = "https://login.microsoftonline.com/common" self.assertNotIn(authority, Authority._domains_without_user_realm_discovery) a = Authority(authority, MinimalHttpClient(), validate_authority=False) - + a.initialize() # We now pretend this authority supports no User Realm Discovery class MockResponse(object): status_code = 404 diff --git a/tests/test_authority_patch.py b/tests/test_authority_patch.py index 1feca62d..0a211648 100644 --- a/tests/test_authority_patch.py +++ b/tests/test_authority_patch.py @@ -15,6 +15,7 @@ def test_authority_honors_a_patched_requests(self): # First, we test that the original, unmodified authority is working a = msal.authority.Authority( "https://login.microsoftonline.com/common", MinimalHttpClient()) + a.initialize() self.assertEqual( a.authorization_endpoint, 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize') @@ -27,6 +28,7 @@ def test_authority_honors_a_patched_requests(self): with self.assertRaises(RuntimeError): a = msal.authority.Authority( "https://login.microsoftonline.com/common", MinimalHttpClient()) + a.initialize() finally: # Tricky: # Unpatch is necessary otherwise other test cases would be affected msal.authority.requests = original From 956eb471a16bed03e5dfe6eb00f8914560b8a826 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 25 Jun 2020 12:56:01 -0700 Subject: [PATCH 194/440] MSAL Python 1.4.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index aecfc5a8..24d45898 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.3.0" +__version__ = "1.4.0" logger = logging.getLogger(__name__) From 956a48f6641d359663f0b6569a3e716a19bdcd81 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 26 Jun 2020 12:40:16 -0700 Subject: [PATCH 195/440] Revert "Application initializer does not make tenant discovery calls (#205)" This reverts commit d32436773f31b4ad125c54e0750c0badc57147a5. --- msal/application.py | 40 ++++++++++++----------------------- msal/authority.py | 11 ---------- tests/test_application.py | 1 - tests/test_authority.py | 5 ++--- tests/test_authority_patch.py | 2 -- 5 files changed, 16 insertions(+), 43 deletions(-) diff --git a/msal/application.py b/msal/application.py index 24d45898..cab823b5 100644 --- a/msal/application.py +++ b/msal/application.py @@ -198,9 +198,8 @@ def __init__( authority or "https://login.microsoftonline.com/common/", self.http_client, validate_authority=validate_authority) # Here the self.authority is not the same type as authority in input - self.client = None self.token_cache = token_cache or TokenCache() - self._client_credential = client_credential + self.client = self._build_client(client_credential, self.authority) self.authority_groups = None def _build_client(self, client_credential, authority): @@ -249,12 +248,6 @@ def _build_client(self, client_credential, authority): on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) - def _get_client(self): - if not self.client: - self.authority.initialize() - self.client = self._build_client(self._client_credential, self.authority) - return self.client - def get_authorization_request_url( self, scopes, # type: list[str] @@ -314,7 +307,6 @@ def get_authorization_request_url( authority, self.http_client ) if authority else self.authority - the_authority.initialize() client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, @@ -375,7 +367,7 @@ def acquire_token_by_authorization_code( # really empty. assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self._get_client().obtain_token_by_authorization_code( + return self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=decorate_scope(scopes, self.client_id), headers={ @@ -399,7 +391,6 @@ def get_accounts(self, username=None): Your app can choose to display those information to end user, and allow user to choose one of his/her accounts to proceed. """ - self.authority.initialize() 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): @@ -552,7 +543,6 @@ def acquire_token_silent_with_error( # authority, # self.http_client, # ) if authority else self.authority - self.authority.initialize() result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, self.authority, force_refresh=force_refresh, correlation_id=correlation_id, @@ -565,7 +555,6 @@ def acquire_token_silent_with_error( "https://" + alias + "/" + self.authority.tenant, self.http_client, validate_authority=False) - the_authority.initialize() result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, force_refresh=force_refresh, correlation_id=correlation_id, @@ -735,7 +724,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): * A dict contains "error" and some other keys, when error happened. * A dict contains no "error" key means migration was successful. """ - return self._get_client().obtain_token_by_refresh_token( + return self.client.obtain_token_by_refresh_token( refresh_token, scope=decorate_scope(scopes, self.client_id), rt_getter=lambda rt: rt, @@ -766,7 +755,7 @@ def initiate_device_flow(self, scopes=None, **kwargs): - an error response would contain some other readable key/value pairs. """ correlation_id = _get_new_correlation_id() - flow = self._get_client().initiate_device_flow( + flow = self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), headers={ CLIENT_REQUEST_ID: correlation_id, @@ -790,7 +779,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs): - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - return self._get_client().obtain_token_by_device_flow( + return self.client.obtain_token_by_device_flow( flow, data=dict(kwargs.pop("data", {}), code=flow["device_code"]), # 2018-10-4 Hack: @@ -827,7 +816,6 @@ def acquire_token_by_username_password( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID), } - self.authority.initialize() if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[CLIENT_REQUEST_ID]) @@ -835,7 +823,7 @@ def acquire_token_by_username_password( return self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, headers=headers, **kwargs) - return self._get_client().obtain_token_by_username_password( + return self.client.obtain_token_by_username_password( username, password, scope=scopes, headers=headers, **kwargs) @@ -864,16 +852,16 @@ def _acquire_token_by_username_password_federated( 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: Client.GRANT_TYPE_SAML2, + SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2, WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1, - WSS_SAML_TOKEN_PROFILE_V2: Client.GRANT_TYPE_SAML2 + 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")) - Client.grant_assertion_encoders.setdefault( # Register a non-standard type - grant_type, Client.encode_saml_assertion) - return self._get_client().obtain_token_by_assertion( + 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, **kwargs) @@ -891,7 +879,7 @@ def acquire_token_for_client(self, scopes, **kwargs): - an error response would contain "error" and usually "error_description". """ # TBD: force_refresh behavior - return self._get_client().obtain_token_for_client( + return self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), @@ -923,9 +911,9 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): """ # The implementation is NOT based on Token Exchange # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 - return self._get_client().obtain_token_by_assertion( # bases on assertion RFC 7521 + return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, - Client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs + self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs scope=decorate_scope(scopes, self.client_id), # Decoration is used for: # 1. Explicitly requesting an RT, without relying on AAD default # behavior, even though it currently still issues an RT. diff --git a/msal/authority.py b/msal/authority.py index edafbd3d..e200299d 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -52,17 +52,6 @@ def __init__(self, authority_url, http_client, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ - self._http_client = http_client - self._authority_url = authority_url - self._validate_authority = validate_authority - self._is_initialized = False - - def initialize(self): - if not self._is_initialized: - self.__initialize(self._authority_url, self._http_client, self._validate_authority) - self._is_initialized = True - - def __initialize(self, authority_url, http_client, validate_authority): self._http_client = http_client authority, self.instance, tenant = canonicalize(authority_url) parts = authority.path.split('/') diff --git a/tests/test_application.py b/tests/test_application.py index 57095bbb..65b36b34 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -104,7 +104,6 @@ def setUp(self): self.authority_url = "https://login.microsoftonline.com/common" self.authority = msal.authority.Authority( self.authority_url, MinimalHttpClient()) - self.authority.initialize() self.scopes = ["s1", "s2"] self.uid = "my_uid" self.utid = "my_utid" diff --git a/tests/test_authority.py b/tests/test_authority.py index eae2c57a..15a0eb52 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -13,7 +13,6 @@ def test_wellknown_host_and_tenant(self): for host in WELL_KNOWN_AUTHORITY_HOSTS: a = Authority( 'https://{}/common'.format(host), MinimalHttpClient()) - a.initialize() self.assertEqual( a.authorization_endpoint, 'https://%s/common/oauth2/v2.0/authorize' % host) @@ -35,7 +34,7 @@ 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()).initialize() + MinimalHttpClient()) def test_invalid_host_skipping_validation_can_be_turned_off(self): try: @@ -86,7 +85,7 @@ def test_memorize(self): authority = "https://login.microsoftonline.com/common" self.assertNotIn(authority, Authority._domains_without_user_realm_discovery) a = Authority(authority, MinimalHttpClient(), validate_authority=False) - a.initialize() + # We now pretend this authority supports no User Realm Discovery class MockResponse(object): status_code = 404 diff --git a/tests/test_authority_patch.py b/tests/test_authority_patch.py index 0a211648..1feca62d 100644 --- a/tests/test_authority_patch.py +++ b/tests/test_authority_patch.py @@ -15,7 +15,6 @@ def test_authority_honors_a_patched_requests(self): # First, we test that the original, unmodified authority is working a = msal.authority.Authority( "https://login.microsoftonline.com/common", MinimalHttpClient()) - a.initialize() self.assertEqual( a.authorization_endpoint, 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize') @@ -28,7 +27,6 @@ def test_authority_honors_a_patched_requests(self): with self.assertRaises(RuntimeError): a = msal.authority.Authority( "https://login.microsoftonline.com/common", MinimalHttpClient()) - a.initialize() finally: # Tricky: # Unpatch is necessary otherwise other test cases would be affected msal.authority.requests = original From a68543cd28acab57d2787f400243e42c447d1849 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Fri, 26 Jun 2020 13:47:15 -0700 Subject: [PATCH 196/440] MSAL Python 1.4.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index cab823b5..cbeedef7 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.4.0" +__version__ = "1.4.1" logger = logging.getLogger(__name__) From 12202f5891d078dabb6ce5429c5638924f8b726b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 29 Jun 2020 23:33:15 -0700 Subject: [PATCH 197/440] Update issue templates Good for bug reports --- .github/ISSUE_TEMPLATE/bug_report.md | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..58bfecda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +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. From 51d4843be80e19f4e8462de72f016d10b80e1f4d Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 6 Jul 2020 15:10:55 -0700 Subject: [PATCH 198/440] Removing hardcoded client_ids from test environment (#220) --- tests/test_e2e.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 28383cd6..8826dddb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -482,7 +482,6 @@ 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["client_id"] = "PublicClientId" config["scope"] = self.adfs2019_scopes config["password"] = self.get_lab_user_secret(config["lab_name"]) self._test_username_password(**config) @@ -497,7 +496,6 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): """ config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019") config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"] - config["client_id"] = "PublicClientId" config["scope"] = self.adfs2019_scopes config["port"] = 8080 self._test_acquire_token_by_auth_code(**config) @@ -505,18 +503,24 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") + @unittest.skipUnless( + os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), + "Confidential client id can be found here https://docs.msidlab.com/flows/onbehalfofflow.html") + @unittest.skipUnless( + os.getenv("LAB_OBO_PUBLIC_CLIENT_ID"), + "Public client id can be found here 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"] = "f4aa5217-e87c-42b2-82af-5624dd14ee72" + 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"] = "c0485386-1e9a-4663-bc96-7ab30656de7f" + 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"]] @@ -535,20 +539,22 @@ def test_b2c_acquire_token_by_auth_code(self): # 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="b876a048-55a5-4fc5-9403-f5d90cb1c852", + client_id=config["appId"], port=3843, # Lab defines 4 of them: [3843, 4584, 4843, 60000] - scope=["https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"] + scope=config["defaultScopes"].split(','), ) 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="e3b9ad76-9763-4827-b088-80c7a7888f79", + client_id=config["appId"], username="b2clocal@msidlabb2c.onmicrosoft.com", password=self.get_lab_user_secret("msidlabb2c"), - scope=["https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"], + scope=config["defaultScopes"].split(','), ) From d4b10a2d5ffb7b8ece429b9a1cf5b289e7b1f31c Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 6 Jul 2020 18:01:52 -0700 Subject: [PATCH 199/440] Adding header --- msal/application.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/msal/application.py b/msal/application.py index cbeedef7..0be68494 100644 --- a/msal/application.py +++ b/msal/application.py @@ -82,6 +82,7 @@ def extract_certs(public_cert_content): 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" @@ -727,6 +728,11 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): return self.client.obtain_token_by_refresh_token( refresh_token, scope=decorate_scope(scopes, self.client_id), + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_REFRESH_TOKEN), + }, rt_getter=lambda rt: rt, on_updating_rt=False, on_removing_rt=lambda rt_item: None, # No OP From 6ffcb893e638c4b58080f7e57615f1eb4104e92d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 10 Jul 2020 14:44:03 -0700 Subject: [PATCH 200/440] Update to the yet another latest lab api page --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 8826dddb..b62e47f1 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -302,7 +302,7 @@ def get_lab_app( # 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://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Rese.aspx#programmatic-access-info-for-lab-request-api + # https://docs.msidlab.com/accounts/confidentialclient.html logger.info("Using lab app defined by ENV variables %s and %s", env_client_id, env_client_secret) client_id = os.getenv(env_client_id) From 7003b3067d46ed1646dd09f07608099596adbd19 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 13 Jul 2020 14:37:50 -0700 Subject: [PATCH 201/440] change --- msal/wstrust_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index b2898f76..bdfb57ef 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -79,7 +79,7 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac return """ {soap_action} - urn:uuid:{message_id} + urn:uuid:{message_id} http://www.w3.org/2005/08/addressing/anonymous From 1811a453e6342f1e7110c9d8e894d899fa536d5e Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 16 Jul 2020 09:46:05 -0700 Subject: [PATCH 202/440] Removing content type header for GET requests to Mex endpoint (#227) --- msal/mex.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/msal/mex.py b/msal/mex.py index 684d50ed..a84f320b 100644 --- a/msal/mex.py +++ b/msal/mex.py @@ -41,9 +41,7 @@ def _xpath_of_root(route_to_leaf): def send_request(mex_endpoint, http_client, **kwargs): - mex_document = http_client.get( - mex_endpoint, headers={'Content-Type': 'application/soap+xml'}, - **kwargs).text + mex_document = http_client.get(mex_endpoint, **kwargs).text return Mex(mex_document).get_wstrust_username_password_endpoint() From 599b0fec68931cc87c1d759dd0c0b11da5e75331 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 7 Jul 2020 12:27:34 -0700 Subject: [PATCH 203/440] Adding assert statement for verifying --- tests/test_e2e.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b62e47f1..957d01a4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -590,6 +590,17 @@ def test_acquire_token_device_flow(self): 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() From b12c9ad90d80025f3ae57cc15704c5c9d8514876 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 10 Jul 2020 14:37:13 -0700 Subject: [PATCH 204/440] An optional tuning happens to also bypass a malfunctioning alias --- msal/application.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/msal/application.py b/msal/application.py index 0be68494..3bd924b3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -552,6 +552,12 @@ def acquire_token_silent_with_error( 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, + 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, From b0619a90395af0c31bffe45b076c81d0f2018877 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 23 Jul 2020 14:11:11 -0700 Subject: [PATCH 205/440] MSAL Python 1.4.2 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 3bd924b3..0d38a1ae 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.4.1" +__version__ = "1.4.2" logger = logging.getLogger(__name__) From f1634c564e99dc367ee6a422b6b4385ed9585916 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 24 Jul 2020 15:45:35 -0700 Subject: [PATCH 206/440] Now we can find scope-less RT from cache --- msal/application.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 3bd924b3..f5c8d017 100644 --- a/msal/application.py +++ b/msal/application.py @@ -554,7 +554,9 @@ def acquire_token_silent_with_error( for alias in self._get_authority_aliases(self.authority.instance): if not self.token_cache.find( self.token_cache.CredentialType.REFRESH_TOKEN, - target=scopes, + # 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 From 7237f4c0db41d3e1d79d1da4af6fee58bf6829ff Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 24 Jul 2020 17:01:38 -0700 Subject: [PATCH 207/440] Unit test to catch a potential future regression --- tests/test_application.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 65b36b34..17164702 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -240,21 +240,30 @@ def setUp(self): 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 - - def test_get_accounts(self): - app = ClientApplication( + self.app = ClientApplication( self.client_id, authority=self.authority_url_in_app, token_cache=self.cache) - accounts = app.get_accounts() + + 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(self): - app = ClientApplication( - self.client_id, - authority=self.authority_url_in_app, token_cache=self.cache) - at = app.acquire_token_silent(self.scopes, self.account) - self.assertNotEqual(None, at) - self.assertEqual(self.access_token, at.get('access_token')) + 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) From 36c60cada96b7b2df5dbabb1fcec956fdd2a70d6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 24 Jul 2020 17:52:52 -0700 Subject: [PATCH 208/440] Update application.py --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index e8bf71bd..ec0648c8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.4.2" +__version__ = "1.4.3" logger = logging.getLogger(__name__) From 7a539ea10d78fd7a31692464fb6a2f6a83028808 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 27 Jul 2020 18:42:08 -0700 Subject: [PATCH 209/440] Ensure no side effect leaks outside --- tests/test_authority.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/test_authority.py b/tests/test_authority.py index 15a0eb52..cd6db785 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -86,13 +86,17 @@ def test_memorize(self): self.assertNotIn(authority, Authority._domains_without_user_realm_discovery) a = Authority(authority, MinimalHttpClient(), validate_authority=False) - # 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") + 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([]) + From 8c471448758b3924d90b2f4e228023e29c0a06c3 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 25 Aug 2020 14:57:42 -0700 Subject: [PATCH 210/440] Fetching device code endpoint from discovery (#245) * Extracting device_endpt from discovery --- msal/application.py | 1 + msal/authority.py | 1 + 2 files changed, 2 insertions(+) diff --git a/msal/application.py b/msal/application.py index ec0648c8..085914c4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -235,6 +235,7 @@ def _build_client(self, client_credential, authority): "authorization_endpoint": authority.authorization_endpoint, "token_endpoint": authority.token_endpoint, "device_authorization_endpoint": + authority.device_authorization_endpoint or urljoin(authority.token_endpoint, "devicecode"), } return Client( diff --git a/msal/authority.py b/msal/authority.py index e200299d..88753b23 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -92,6 +92,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): 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' From 035a37bf204b45b85d70607b601cfa90b2d52f0e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 27 Aug 2020 19:11:19 -0700 Subject: [PATCH 211/440] Cache tokens by specified environment, not by OIDC Discovery --- msal/application.py | 3 ++- msal/token_cache.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 085914c4..dd65ac6c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -246,7 +246,8 @@ def _build_client(self, client_credential, authority): default_body=default_body, client_assertion=client_assertion, client_assertion_type=client_assertion_type, - on_obtaining_tokens=self.token_cache.add, + 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) diff --git a/msal/token_cache.py b/msal/token_cache.py index 6884075d..83fc1891 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -126,6 +126,8 @@ def __add(self, event, now=None): 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") From e9a8c565ba5540890cfafb240bef806867561fbb Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Mon, 31 Aug 2020 16:10:09 -0700 Subject: [PATCH 212/440] Client capabilities (#240) --- msal/application.py | 125 +++++++++++++++++++++++++++++++++----- tests/test_application.py | 43 +++++++++++++ 2 files changed, 154 insertions(+), 14 deletions(-) diff --git a/msal/application.py b/msal/application.py index 085914c4..4a1aa7b1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -79,6 +79,17 @@ def extract_certs(public_cert_content): 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) + + class ClientApplication(object): ACQUIRE_TOKEN_SILENT_ID = "84" @@ -97,7 +108,8 @@ def __init__( token_cache=None, http_client=None, verify=True, proxies=None, timeout=None, - client_claims=None, app_name=None, app_version=None): + client_claims=None, app_name=None, app_version=None, + client_capabilities=None): """Create an instance of application. :param str client_id: Your app has a client_id after you register it on AAD. @@ -179,10 +191,16 @@ def __init__( :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"]. + MSAL will combine them into + `claims parameter `_ 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. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() @@ -321,6 +346,8 @@ def get_authorization_request_url( scope=decorate_scope(scopes, self.client_id), nonce=nonce, domain_hint=domain_hint, + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge), ) def acquire_token_by_authorization_code( @@ -332,6 +359,7 @@ def acquire_token_by_authorization_code( # 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. @@ -357,6 +385,12 @@ def acquire_token_by_authorization_code( 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, @@ -377,6 +411,10 @@ def acquire_token_by_authorization_code( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), }, + data=dict( + kwargs.pop("data", {}), + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge)), nonce=nonce, **kwargs) @@ -479,6 +517,7 @@ def acquire_token_silent( 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. @@ -493,6 +532,12 @@ def acquire_token_silent( 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, @@ -500,7 +545,8 @@ def acquire_token_silent( - None when cache lookup does not yield a token. """ result = self.acquire_token_silent_with_error( - scopes, account, authority, force_refresh, **kwargs) + scopes, account, authority, force_refresh, + claims_challenge=claims_challenge, **kwargs) return result if result and "error" not in result else None def acquire_token_silent_with_error( @@ -509,6 +555,7 @@ def acquire_token_silent_with_error( 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. @@ -529,6 +576,11 @@ def acquire_token_silent_with_error( :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, @@ -547,6 +599,7 @@ def acquire_token_silent_with_error( # ) 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: @@ -567,6 +620,7 @@ def acquire_token_silent_with_error( 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: @@ -589,8 +643,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( account, # type: Optional[Account] authority, # This can be different than self.authority force_refresh=False, # type: Optional[boolean] + claims_challenge=None, **kwargs): - if not force_refresh: + if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims query={ "client_id": self.client_id, "environment": authority.instance, @@ -617,7 +672,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( } return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, decorate_scope(scopes, self.client_id), account, - force_refresh=force_refresh, **kwargs) + force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs) def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): @@ -666,7 +721,7 @@ def _get_app_metadata(self, environment): def _acquire_token_silent_by_finding_specific_refresh_token( self, authority, scopes, query, rt_remover=None, break_condition=lambda response: False, - force_refresh=False, correlation_id=None, **kwargs): + force_refresh=False, 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 @@ -686,6 +741,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_SILENT_ID, force_refresh=force_refresh), }, + data=dict( + kwargs.pop("data", {}), + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge)), **kwargs) if "error" not in response: return response @@ -780,7 +839,7 @@ def initiate_device_flow(self, scopes=None, **kwargs): flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id return flow - def acquire_token_by_device_flow(self, flow, **kwargs): + 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: @@ -788,6 +847,11 @@ def acquire_token_by_device_flow(self, flow, **kwargs): 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: @@ -796,10 +860,14 @@ def acquire_token_by_device_flow(self, flow, **kwargs): """ return 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. + 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={ CLIENT_REQUEST_ID: flow.get(self.DEVICE_FLOW_CORRELATION_ID) or _get_new_correlation_id(), @@ -809,7 +877,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs): **kwargs) def acquire_token_by_username_password( - self, username, password, scopes, **kwargs): + 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. @@ -819,6 +887,11 @@ def acquire_token_by_username_password( :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: @@ -831,16 +904,22 @@ def acquire_token_by_username_password( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID), } + 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[CLIENT_REQUEST_ID]) if user_realm_result.get("account_type") == "Federated": return self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, + data=data, headers=headers, **kwargs) return self.client.obtain_token_by_username_password( username, password, scope=scopes, headers=headers, + data=data, **kwargs) def _acquire_token_by_username_password_federated( @@ -882,11 +961,16 @@ def _acquire_token_by_username_password_federated( class ConfidentialClientApplication(ClientApplication): # server-side web app - def acquire_token_for_client(self, scopes, **kwargs): + 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: @@ -901,9 +985,13 @@ def acquire_token_for_client(self, scopes, **kwargs): CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_FOR_CLIENT_ID), }, + data=dict( + kwargs.pop("data", {}), + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge)), **kwargs) - def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): + 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 @@ -918,6 +1006,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): :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: @@ -935,7 +1028,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs): # 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"), + data=dict( + kwargs.pop("data", {}), + requested_token_use="on_behalf_of", + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge)), headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( diff --git a/tests/test_application.py b/tests/test_application.py index 17164702..337816a3 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2,6 +2,7 @@ # so this test_application file contains only unit tests without dependency. from msal.application import * import msal +from msal.application import _merge_claims_challenge_and_capabilities from tests import unittest from tests.test_token_cache import TokenCacheTestCase from tests.http_client import MinimalHttpClient, MinimalResponse @@ -267,3 +268,45 @@ def helper(scopes, account, authority, *args, **kwargs): 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 = ["llt", "ssm"] + claims_challenge = '''{"id_token": {"auth_time": {"essential": true}}}''' + merged_claims = '''{"id_token": {"auth_time": {"essential": true}}, + "access_token": {"xms_cc": {"values": ["llt", "ssm"]}}}''' + # 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 = ["llt", "ssm"] + 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": ["llt", "ssm"]}}}''' + # 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 = ["llt", "ssm"] + merged_claims = '''{"access_token": {"xms_cc": {"values": ["llt", "ssm"]}}}''' + 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) From a6048dc8c9f6b20276798e5d2fb301ad45d89824 Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Tue, 1 Sep 2020 16:15:07 -0700 Subject: [PATCH 213/440] Changes as per AzCLI feedback --- msal/application.py | 2 +- tests/test_application.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msal/application.py b/msal/application.py index 4a1aa7b1..865dc75b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1010,7 +1010,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No 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.. + 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: diff --git a/tests/test_application.py b/tests/test_application.py index 337816a3..3281dc04 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -272,10 +272,10 @@ def helper(scopes, account, authority, *args, **kwargs): class TestApplicationForClientCapabilities(unittest.TestCase): def test_capabilities_and_id_token_claims_merge(self): - client_capabilities = ["llt", "ssm"] + 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": ["llt", "ssm"]}}}''' + "access_token": {"xms_cc": {"values": ["foo", "bar"]}}}''' # Comparing dictionaries as JSON object order differs based on python version self.assertEqual( json.loads(merged_claims), @@ -283,12 +283,12 @@ def test_capabilities_and_id_token_claims_merge(self): client_capabilities, claims_challenge))) def test_capabilities_and_id_token_claims_and_access_token_claims_merge(self): - client_capabilities = ["llt", "ssm"] + 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": ["llt", "ssm"]}}}''' + "xms_cc": {"values": ["foo", "bar"]}}}''' # Comparing dictionaries as JSON object order differs based on python version self.assertEqual( json.loads(merged_claims), @@ -302,8 +302,8 @@ def test_no_capabilities_only_claims_merge(self): json.loads(_merge_claims_challenge_and_capabilities(None, claims_challenge))) def test_only_client_capabilities_no_claims_merge(self): - client_capabilities = ["llt", "ssm"] - merged_claims = '''{"access_token": {"xms_cc": {"values": ["llt", "ssm"]}}}''' + 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))) From acb0d799a0920bc4500d3c432f55da5f58760a7e Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 3 Sep 2020 14:19:33 -0700 Subject: [PATCH 214/440] MSAL Python 1.5.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 865dc75b..98050535 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.4.3" +__version__ = "1.5.0" logger = logging.getLogger(__name__) From 141fd742a912dd229c1e839386656d721b383cec Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 3 Sep 2020 17:39:34 -0700 Subject: [PATCH 215/440] Document some offline info into this code base --- msal/application.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/msal/application.py b/msal/application.py index 98050535..187a95e7 100644 --- a/msal/application.py +++ b/msal/application.py @@ -193,6 +193,16 @@ def __init__( 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. + + Client capability is implemented using ‘claims’ parameter, for now. MSAL will combine them into `claims parameter Date: Fri, 4 Sep 2020 11:49:57 -0700 Subject: [PATCH 216/440] Document why recommend against using Implicit Grant, to avoid future https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/249 --- msal/application.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 187a95e7..35820c02 100644 --- a/msal/application.py +++ b/msal/application.py @@ -285,7 +285,7 @@ def get_authorization_request_url( login_hint=None, # type: Optional[str] state=None, # Recommended by OAuth2 for CSRF protection redirect_uri=None, - response_type="code", # Can be "token" if you use Implicit Grant + response_type="code", # Could be "token" if you use Implicit Grant prompt=None, nonce=None, domain_hint=None, # type: Optional[str] @@ -302,7 +302,11 @@ def get_authorization_request_url( 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 can use other content such as "id_token". + + 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. From 0013ea3dae0b49e9ede2742db0ae55c917d46382 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 17 Sep 2020 12:24:47 -0700 Subject: [PATCH 217/440] Remove non-ascii character in prevoius commit --- msal/application.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 187a95e7..b8318593 100644 --- a/msal/application.py +++ b/msal/application.py @@ -202,7 +202,9 @@ def __init__( knowing when the resource emits *claims challenge* the client will be capable to handle. - Client capability is implemented using ‘claims’ parameter, for now. + Implementation details: + Client capability is implemented using "claims" parameter on the wire, + for now. MSAL will combine them into `claims parameter Date: Mon, 21 Sep 2020 16:49:32 -0700 Subject: [PATCH 218/440] Backporting change from ADAL PR 240 --- msal/application.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 2c2959bb..9d22eace 100644 --- a/msal/application.py +++ b/msal/application.py @@ -953,7 +953,8 @@ def _acquire_token_by_username_password_federated( "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"), + 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")), From 28f7931c86961d7cf3618beb7e9141d6e8c8a85f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sat, 3 Oct 2020 15:58:25 -0700 Subject: [PATCH 219/440] Experiment 1 --- msal/application.py | 5 +++++ msal/oauth2cli/oauth2.py | 4 +++- msal/token_cache.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 9d22eace..2b3c947a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -752,6 +752,11 @@ def _acquire_token_silent_by_finding_specific_refresh_token( response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], on_removing_rt=rt_remover or self.token_cache.remove_rt, + on_obtaining_tokens=lambda event: self.token_cache.add(dict( + event, + environment=authority.instance, + add_account=False, # To honor a concurrent remove_account() + )), scope=scopes, headers={ CLIENT_REQUEST_ID: correlation_id or _get_new_correlation_id(), diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 1d9c21d5..90c1d31b 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -462,6 +462,7 @@ def __init__(self, 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( @@ -481,7 +482,7 @@ def _obtain_token( # but our obtain_token_by_authorization_code(...) encourages # app developer to still explicitly provide a scope here. scope = _data.get("scope") - self.on_obtaining_tokens({ + (on_obtaining_tokens or self.on_obtaining_tokens)({ "client_id": self.client_id, "scope": scope, "token_endpoint": self.configuration["token_endpoint"], @@ -495,6 +496,7 @@ 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, + on_obtaining_tokens=None, **kwargs): # type: (Union[str, dict], Union[str, list, set, tuple], Callable) -> dict """This is an overload which will trigger token storage callbacks. diff --git a/msal/token_cache.py b/msal/token_cache.py index 83fc1891..e1d8705b 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -172,7 +172,7 @@ def __add(self, event, now=None): at["key_id"] = data.get("key_id") self.modify(self.CredentialType.ACCESS_TOKEN, at, at) - if client_info: + if client_info and event.get("add_account") is not False: account = { "home_account_id": home_account_id, "environment": environment, From 7f205c341e1fae8e73a217d5e9af4706b6fc6824 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 14 Oct 2020 17:32:00 -0700 Subject: [PATCH 220/440] Skip account creation during acquire_token_silent() --- msal/application.py | 2 +- msal/token_cache.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 2b3c947a..410086d1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -755,7 +755,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token( on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=authority.instance, - add_account=False, # To honor a concurrent remove_account() + skip_account_creation=True, # To honor a concurrent remove_account() )), scope=scopes, headers={ diff --git a/msal/token_cache.py b/msal/token_cache.py index e1d8705b..b7ebbb99 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -172,7 +172,7 @@ def __add(self, event, now=None): at["key_id"] = data.get("key_id") self.modify(self.CredentialType.ACCESS_TOKEN, at, at) - if client_info and event.get("add_account") is not False: + if client_info and not event.get("skip_account_creation"): account = { "home_account_id": home_account_id, "environment": environment, From ec312cf0fc20558d7d4b818b6d428b0da2a50fb3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 20 Oct 2020 16:54:06 -0700 Subject: [PATCH 221/440] MSAL Python 1.5.1 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 410086d1..1769352b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.5.0" +__version__ = "1.5.1" logger = logging.getLogger(__name__) From 2b3c6111cfbf956a9c4b125b5647aa32bb0474f0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 22 Oct 2020 14:40:09 -0700 Subject: [PATCH 222/440] Bubble http exceptions so apps could catch them --- msal/authority.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 88753b23..0656011f 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -83,7 +83,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): openid_config = tenant_discovery( tenant_discovery_endpoint, self.http_client) - except ValueError: # json.decoder.JSONDecodeError in Py3 subclasses this + except ValueError: raise ValueError( "Unable to get authority configuration for {}. " "Authority would typically be in a format of " @@ -140,8 +140,17 @@ def instance_discovery(url, http_client, **kwargs): def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs): # Returns Openid Configuration resp = http_client.get(tenant_discovery_endpoint, **kwargs) - payload = json.loads(resp.text) - if 'authorization_endpoint' in payload and 'token_endpoint' in payload: - return payload - raise MsalServiceError(status_code=resp.status_code, **payload) + 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") + # 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)) From 5a82af1c07bb3b9b9919a47da0389c3a696d250d Mon Sep 17 00:00:00 2001 From: Abhidnya Date: Thu, 22 Oct 2020 09:02:16 -0700 Subject: [PATCH 223/440] Initial commit Code refactoring Changing reference doc string Adding tests PR review 1 Adding dependencies and polishing code Python 2 compat --- msal/application.py | 21 ++++++++++++++++++++- setup.py | 2 ++ tests/test_application.py | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 1769352b..15ef548d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,6 +1,11 @@ import functools import json import time + +import six +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + try: # Python 2 from urlparse import urljoin except: # Python 3 @@ -124,6 +129,7 @@ def __init__( "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 0.5.0*: @@ -252,8 +258,21 @@ def _build_client(self, client_credential, authority): 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: + if isinstance(client_credential['private_key'], six.text_type): + private_key = client_credential['private_key'].encode(encoding="utf-8") + else: + private_key = client_credential['private_key'] + if isinstance(client_credential['passphrase'], six.text_type): + password = client_credential['passphrase'].encode(encoding="utf-8") + else: + password = client_credential['passphrase'] + unencrypted_private_key = serialization.load_pem_private_key( + private_key, password=password, backend=default_backend()) assertion = JwtAssertionCreator( - client_credential["private_key"], algorithm="RS256", + 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, diff --git a/setup.py b/setup.py index 960d4bca..4ca79d33 100644 --- a/setup.py +++ b/setup.py @@ -74,6 +74,8 @@ install_requires=[ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<2', + 'six>=1.6', + 'cryptography>=2.1.4' ] ) diff --git a/tests/test_application.py b/tests/test_application.py index 3281dc04..5751114a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -39,6 +39,33 @@ def test_extract_multiple_tag_enclosed_certs(self): self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem)) +class TestEncryptedKeyAsClientCredential(unittest.TestCase): + # Internally, we use serialization.load_pem_private_key() to load an encrypted private key with a passphrase + # This function takes in encrypted key in bytes and passphrase in bytes too + # Our code handles such a conversion, adding test cases to verify such a conversion is needed + + def test_encyrpted_key_in_bytes_and_string_password_should_error(self): + private_key = b""" + -----BEGIN ENCRYPTED PRIVATE KEY----- + test_private_key + -----END ENCRYPTED PRIVATE KEY----- + """ + with self.assertRaises(TypeError): + # Using a unicode string for Python 2 to identify it as a string and not default to bytes + serialization.load_pem_private_key( + private_key, password=u"string_password", backend=default_backend()) + + def test_encyrpted_key_is_string_and_bytes_password_should_error(self): + private_key = u""" + -----BEGIN ENCRYPTED PRIVATE KEY----- + test_private_key + -----END ENCRYPTED PRIVATE KEY----- + """ + with self.assertRaises(TypeError): + serialization.load_pem_private_key( + private_key, password=b"byte_password", backend=default_backend()) + + class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase): def setUp(self): From de618ba31197526ce3d8321382de6d136ee0f4dc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 29 Oct 2020 00:18:09 -0700 Subject: [PATCH 224/440] Removing dependency of six Adding missing arguments to api call Use cryptography lower bound as low as 0.6 Add test cases for _str2bytes() Choose cryptography upper bound as <4 --- msal/application.py | 28 ++++++++++++++-------------- setup.py | 12 ++++++++++-- tests/test_application.py | 30 ++++++------------------------ 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/msal/application.py b/msal/application.py index 15ef548d..cae9013d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,11 +1,6 @@ import functools import json import time - -import six -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization - try: # Python 2 from urlparse import urljoin except: # Python 3 @@ -95,6 +90,14 @@ def _merge_claims_challenge_and_capabilities(capabilities, claims_challenge): 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 + + class ClientApplication(object): ACQUIRE_TOKEN_SILENT_ID = "84" @@ -261,16 +264,13 @@ def _build_client(self, client_credential, authority): if not client_credential.get("passphrase"): unencrypted_private_key = client_credential['private_key'] else: - if isinstance(client_credential['private_key'], six.text_type): - private_key = client_credential['private_key'].encode(encoding="utf-8") - else: - private_key = client_credential['private_key'] - if isinstance(client_credential['passphrase'], six.text_type): - password = client_credential['passphrase'].encode(encoding="utf-8") - else: - password = client_credential['passphrase'] + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend unencrypted_private_key = serialization.load_pem_private_key( - private_key, password=password, backend=default_backend()) + _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) diff --git a/setup.py b/setup.py index 4ca79d33..51c988dd 100644 --- a/setup.py +++ b/setup.py @@ -74,8 +74,16 @@ install_requires=[ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<2', - 'six>=1.6', - 'cryptography>=2.1.4' + + 'cryptography>=0.6,<4', + # load_pem_private_key() is available since 0.6 + # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 + # + # Not sure what should be used as an upper bound here + # https://github.com/pyca/cryptography/issues/5532 + # We will go with "<4" for now, which is also what our another dependency, + # pyjwt, currently use. + ] ) diff --git a/tests/test_application.py b/tests/test_application.py index 5751114a..8d48a0ac 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,6 +1,7 @@ # 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 @@ -39,31 +40,12 @@ def test_extract_multiple_tag_enclosed_certs(self): self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem)) -class TestEncryptedKeyAsClientCredential(unittest.TestCase): - # Internally, we use serialization.load_pem_private_key() to load an encrypted private key with a passphrase - # This function takes in encrypted key in bytes and passphrase in bytes too - # Our code handles such a conversion, adding test cases to verify such a conversion is needed +class TestBytesConversion(unittest.TestCase): + def test_string_to_bytes(self): + self.assertEqual(type(_str2bytes("some string")), type(b"bytes")) - def test_encyrpted_key_in_bytes_and_string_password_should_error(self): - private_key = b""" - -----BEGIN ENCRYPTED PRIVATE KEY----- - test_private_key - -----END ENCRYPTED PRIVATE KEY----- - """ - with self.assertRaises(TypeError): - # Using a unicode string for Python 2 to identify it as a string and not default to bytes - serialization.load_pem_private_key( - private_key, password=u"string_password", backend=default_backend()) - - def test_encyrpted_key_is_string_and_bytes_password_should_error(self): - private_key = u""" - -----BEGIN ENCRYPTED PRIVATE KEY----- - test_private_key - -----END ENCRYPTED PRIVATE KEY----- - """ - with self.assertRaises(TypeError): - serialization.load_pem_private_key( - private_key, password=b"byte_password", backend=default_backend()) + def test_bytes_to_bytes(self): + self.assertEqual(type(_str2bytes(b"some bytes")), type(b"bytes")) class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase): From ac40a14bac64b55cac6e349067ea5053ae3e537f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sun, 1 Nov 2020 21:16:12 -0800 Subject: [PATCH 225/440] MSAL Python 1.6.0 Bump version number, and document the version of new API --- msal/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index cae9013d..610b41f8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.5.1" +__version__ = "1.6.0" logger = logging.getLogger(__name__) @@ -131,8 +131,8 @@ def __init__( { "private_key": "...-----BEGIN PRIVATE KEY-----...", "thumbprint": "A1B2C3D4E5F6...", - "public_certificate": "...-----BEGIN CERTIFICATE-----..." (Optional. See below.) - "passphrase": "Passphrase if the private_key is encrypted (Optional)" + "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*: From e75c441c8a957ea2a9ed601e2fb8ef17ea68b031 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 11 Nov 2020 18:17:49 -0800 Subject: [PATCH 226/440] Implement acquire_token_by_auth_code_flow --- msal/application.py | 140 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_e2e.py | 67 ++++++++++++++++++++- 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 610b41f8..e0a86005 100644 --- a/msal/application.py +++ b/msal/application.py @@ -300,6 +300,79 @@ def _build_client(self, client_credential, authority): on_removing_rt=self.token_cache.remove_rt, on_updating_rt=self.token_cache.update_rt) + 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, + ): + """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 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 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 `_. + + :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 = Client( + {"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=decorate_scope(scopes, self.client_id), + domain_hint=domain_hint, + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge), + ) + flow["claims_challenge"] = claims_challenge + return flow + def get_authorization_request_url( self, scopes, # type: list[str] @@ -386,6 +459,73 @@ def get_authorization_request_url( 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", {})) + return self.client.obtain_token_by_auth_code_flow( + auth_code_flow, + auth_response, + scope=decorate_scope(scopes, self.client_id) if scopes else None, + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), + }, + data=dict( + kwargs.pop("data", {}), + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, + auth_code_flow.pop("claims_challenge", None))), + **kwargs) + def acquire_token_by_authorization_code( self, code, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 957d01a4..29a2ccaa 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -8,6 +8,7 @@ import msal from tests.http_client import MinimalHttpClient +from msal.oauth2cli import AuthCodeReceiver logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -297,14 +298,16 @@ def get_lab_app( 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 - logger.info("Using lab app defined by ENV variables %s and %s", - env_client_id, env_client_secret) client_id = os.getenv(env_client_id) client_secret = os.getenv(env_client_secret) else: @@ -399,6 +402,45 @@ def _test_acquire_token_by_auth_code( 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( + redirect_uri="http://localhost:%d" % receiver.get_port(), + scopes=scope, + ) + 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. +
  3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ + (replace the lab name with the labName from the link above).
  4. +
  5. Sign In or Abort
  6. +
""".format(id=self.id(), username_uri=username_uri), + ) + 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): # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( @@ -500,6 +542,16 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): 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.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") @@ -547,6 +599,17 @@ def test_b2c_acquire_token_by_auth_code(self): scope=config["defaultScopes"].split(','), ) + @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["defaultScopes"].split(','), + 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( From 26dcd316f1166b8ef638d28ce228b9c9e4c791c5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 24 Nov 2020 22:27:55 -0800 Subject: [PATCH 227/440] Reuse old rt data even if its key is different --- msal/token_cache.py | 5 +++-- tests/test_token_cache.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index b7ebbb99..34eff37c 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -234,8 +234,9 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): with self._lock: if new_key_value_pairs: # Update with them entries = self._cache.setdefault(credential_type, {}) - entry = entries.setdefault(key, {}) # Create it if not yet exist - entry.update(new_key_value_pairs) + 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) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 1666bba2..c846883d 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -222,6 +222,24 @@ def test_key_id_is_also_recorded(self): {}).get("key_id") self.assertEqual(my_key_id, cached_key_id, "AT should be bound to the key") + 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() From 9d9b72253da32a19eebf38e4ace4ce1864e3481d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 1 Dec 2020 16:25:24 -0800 Subject: [PATCH 228/440] MEX endpoint in our test environment tends to fail recently So, we ignore that error when it is running on Travis CI --- msal/mex.py | 13 +++++++++++-- tests/http_client.py | 3 ++- tests/test_e2e.py | 11 ++++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/msal/mex.py b/msal/mex.py index a84f320b..edecba37 100644 --- a/msal/mex.py +++ b/msal/mex.py @@ -33,16 +33,25 @@ 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_document = http_client.get(mex_endpoint, **kwargs).text - return Mex(mex_document).get_wstrust_username_password_endpoint() + 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): diff --git a/tests/http_client.py b/tests/http_client.py index 4bff9b45..c02fd29a 100644 --- a/tests/http_client.py +++ b/tests/http_client.py @@ -26,5 +26,6 @@ def __init__(self, requests_resp=None, status_code=None, text=None): self._raw_resp = requests_resp def raise_for_status(self): - if self._raw_resp: + 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() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 29a2ccaa..c706ec0f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -516,9 +516,14 @@ def test_adfs2_fed_user(self): self._test_username_password(**config) def test_adfs2019_fed_user(self): - config = self.get_lab_user(usertype="federated", federationProvider="ADFSv2019") - config["password"] = self.get_lab_user_secret(config["lab_name"]) - self._test_username_password(**config) + 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 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 From 7b61d72854c40aef2e9d31b10b2184a66ea5ed19 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 23 Nov 2020 22:36:26 -0800 Subject: [PATCH 229/440] New acquire_token_interactive() With Telemetry docs also being updated at https://microsoft-my.sharepoint-df.com/:x:/p/sagonzal/EXSrr4vM1utAqQQfD6bMln4BYKwjrqh3cagiNJWPVNjLzw?e=G5FybL --- msal/application.py | 74 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_e2e.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index e0a86005..07447c05 100644 --- a/msal/application.py +++ b/msal/application.py @@ -107,6 +107,7 @@ class ClientApplication(object): 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" @@ -318,7 +319,6 @@ def initiate_auth_code_flow( :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: @@ -998,6 +998,78 @@ def __init__(self, client_id, client_credential=None, **kwargs): 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, + **kwargs): + """Acquire token interactively i.e. via a local browser. + + :param list scope: + 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``.) + + :return: + - A dict containing no "error" key, + and typically contains an "access_token" key, + if cache lookup succeeded. + - 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) + return self.client.obtain_token_by_browser( + scope=decorate_scope(scopes, self.client_id) if scopes else None, + 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, + domain_hint=domain_hint, + timeout=timeout, + auth_params={"claims": claims}, + data=dict(kwargs.pop("data", {}), claims=claims), + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_INTERACTIVE), + }, + **kwargs) + def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, which will be used in :func:`~acquire_token_by_device_flow`. diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c706ec0f..adfe5d42 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -441,6 +441,39 @@ def _test_acquire_token_by_auth_code_flow( error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) + def _test_acquire_token_interactive( + self, client_id=None, authority=None, scope=None, port=None, + username_uri="", # But you would want to provide one + **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=60, + port=port, + welcome_template= # This is an undocumented feature for testing + """

{id}

    +
  1. Get a username from the upn shown at here
  2. +
  3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ + (replace the lab name with the labName from the link above).
  4. +
  5. Sign In or Abort
  6. +
""".format(id=self.id(), username_uri=username_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_obo(self, config_pca, config_cca): # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( @@ -525,6 +558,13 @@ def test_adfs2019_fed_user(self): 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") @@ -557,6 +597,16 @@ def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self): 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://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") From f6669113c4ea64f0d98cb6be0f8071c658b02655 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 3 Dec 2020 20:16:49 -0800 Subject: [PATCH 230/440] Improve test infrastructure to catch a malfunction --- msal/application.py | 6 ++++-- tests/http_client.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 07447c05..141ede59 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1059,9 +1059,11 @@ def acquire_token_interactive( port=port or 0), prompt=prompt, login_hint=login_hint, - domain_hint=domain_hint, timeout=timeout, - auth_params={"claims": claims}, + auth_params={ + "claims": claims, + "domain_hint": domain_hint, + }, data=dict(kwargs.pop("data", {}), claims=claims), headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), diff --git a/tests/http_client.py b/tests/http_client.py index c02fd29a..a5587b70 100644 --- a/tests/http_client.py +++ b/tests/http_client.py @@ -10,11 +10,13 @@ def __init__(self, verify=True, proxies=None, timeout=None): 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)) From 1f7d503d1efa05b449e87ae07ea70d1394a75357 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 3 Dec 2020 23:31:35 -0800 Subject: [PATCH 231/440] A sample for the new acquire_token_interactive() --- sample/interactive_sample.py | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 sample/interactive_sample.py diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py new file mode 100644 index 00000000..38593315 --- /dev/null +++ b/sample/interactive_sample.py @@ -0,0 +1,75 @@ +""" +The configuration file would look like this: + +{ + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "your_client_id", + "scope": ["User.ReadBasic.All"], + // You can find the other permission names from this document + // https://docs.microsoft.com/en-us/graph/permissions-reference + "username": "your_username@your_tenant.com", // This is optional + "endpoint": "https://graph.microsoft.com/v1.0/users" + // You can find more Microsoft Graph API endpoints from Graph Explorer + // https://developer.microsoft.com/en-us/graph/graph-explorer +} + +You can then run this sample with a JSON configuration file: + + python sample.py parameters.json +""" + +import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1] +import json, logging, msal, requests + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs + +config = json.load(open(sys.argv[1])) + +# Create a preferably long-lived app instance which maintains a token cache. +app = msal.PublicClientApplication( + config["client_id"], authority=config["authority"], + # token_cache=... # Default cache is in memory only. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + ) + +# The pattern to acquire a token looks like this. +result = None + +# Firstly, check the cache to see if this end user has signed in before +accounts = app.get_accounts(username=config.get("username")) +if accounts: + logging.info("Account(s) exists in cache, probably with token too. Let's try.") + print("Account(s) already signed in:") + for a in accounts: + print(a["username"]) + chosen = accounts[0] # Assuming the end user chose this one to proceed + print("Proceed with account: %s" % chosen["username"]) + # Now let's try to find a token in cache for this account + result = app.acquire_token_silent(config["scope"], account=chosen) + +if not result: + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + print("A local browser window will be open for you to sign in. CTRL+C to cancel.") + result = app.acquire_token_interactive( + config["scope"], + login_hint=config.get("username"), # You can use this parameter to pre-fill + # the username (or email address) field of the sign-in page for the user, + # if you know the username ahead of time. + # Often, apps use this parameter during reauthentication, + # after already extracting the username from an earlier sign-in + # by using the preferred_username claim from returned id_token_claims. + ) + +if "access_token" in result: + # Calling graph using the access token + graph_response = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},) + print("Graph API call result: %s ..." % graph_response.text[:100]) +else: + print(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug From 0091a9fe491b6b61374e821c25c98234add1b077 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 4 Dec 2020 20:41:43 -0800 Subject: [PATCH 232/440] MSAL Python 1.7.0 Bump version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 141ede59..6d4eeb58 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.6.0" +__version__ = "1.7.0" logger = logging.getLogger(__name__) From 41e27d9422a5d516c46cb6fa32d5ba14374f3915 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 8 Dec 2020 12:14:38 -0800 Subject: [PATCH 233/440] Enables extra_scopes_to_consent in acquire_token_silent() --- msal/application.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/msal/application.py b/msal/application.py index 6d4eeb58..eb507656 100644 --- a/msal/application.py +++ b/msal/application.py @@ -376,7 +376,6 @@ def initiate_auth_code_flow( def get_authorization_request_url( self, scopes, # type: list[str] - # additional_scope=None, # type: Optional[list] login_hint=None, # type: Optional[str] state=None, # Recommended by OAuth2 for CSRF protection redirect_uri=None, @@ -425,14 +424,6 @@ def get_authorization_request_url( :return: The authorization url as a string. """ - """ # TBD: this would only be meaningful in a new acquire_token_interactive() - :param additional_scope: Additional scope is a concept only 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. - (Under the hood, we simply merge scope and additional_scope before - sending them on the wire.) - """ authority = kwargs.pop("authority", None) # Historically we support this if authority: warnings.warn( @@ -1007,6 +998,7 @@ def acquire_token_interactive( claims_challenge=None, timeout=None, port=None, + extra_scopes_to_consent=None, **kwargs): """Acquire token interactively i.e. via a local browser. @@ -1043,6 +1035,12 @@ def acquire_token_interactive( 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. + :return: - A dict containing no "error" key, and typically contains an "access_token" key, @@ -1054,6 +1052,7 @@ def acquire_token_interactive( self._client_capabilities, claims_challenge) return self.client.obtain_token_by_browser( scope=decorate_scope(scopes, self.client_id) 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), From 9ca339afbcbfa5cbb912bc432b624487b67de6bc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 15 Dec 2020 12:06:27 -0800 Subject: [PATCH 234/440] MSAL 1.8.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index eb507656..fcd0d072 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.7.0" +__version__ = "1.8.0" logger = logging.getLogger(__name__) From 0c9303d9d8ee78b29a9d12ed87756877f1dd1224 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 19 Jan 2021 23:54:52 -0800 Subject: [PATCH 235/440] The ssh-cert scope needs to be updated Switch to the new SSH cert scope --- tests/test_e2e.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index adfe5d42..ab93eff2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -153,13 +153,13 @@ 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, **kwargs): + 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=self.config["scope"], + scopes=scopes or self.config["scope"], **kwargs) def _test_auth_code(self, auth_kwargs, token_kwargs): @@ -202,11 +202,15 @@ def test_ssh_cert(self): "sshcrt": "true", } - (self.app, ac, redirect_uri) = self._get_app_and_auth_code() + scopes = [ # Only this scope would result in an SSH-Cert + "https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] + (self.app, ac, redirect_uri) = self._get_app_and_auth_code(scopes=scopes) result = self.app.acquire_token_by_authorization_code( - ac, self.config["scope"], redirect_uri=redirect_uri, data=data1, + ac, scopes, redirect_uri=redirect_uri, data=data1, params=ssh_test_slice) + 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)) @@ -214,7 +218,7 @@ def test_ssh_cert(self): # acquire_token_silent() needs to be passed the same key to work account = self.app.get_accounts()[0] result_from_cache = self.app.acquire_token_silent( - self.config["scope"], account=account, data=data1) + scopes, account=account, data=data1) self.assertIsNotNone(result_from_cache) self.assertEqual( result['access_token'], result_from_cache['access_token'], @@ -222,7 +226,7 @@ def test_ssh_cert(self): # refresh_token grant can fetch an ssh-cert bound to a different key refreshed_ssh_cert = self.app.acquire_token_silent( - self.config["scope"], account=account, params=ssh_test_slice, + scopes, account=account, params=ssh_test_slice, data={"token_type": "ssh-cert", "key_id": "key2", "req_cnf": JWK2}) self.assertIsNotNone(refreshed_ssh_cert) self.assertEqual(refreshed_ssh_cert["token_type"], "ssh-cert") From de51404393d0a8039a32bff251bdbcb5c3301019 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 25 Jan 2021 12:08:40 -0800 Subject: [PATCH 236/440] Show correlation_id when unittesting with -v param --- msal/application.py | 4 +++- tests/test_e2e.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index fcd0d072..01584ac3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -56,7 +56,9 @@ def decorate_scope( CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry' def _get_new_correlation_id(): - return str(uuid.uuid4()) + correlation_id = str(uuid.uuid4()) + logger.debug("Generates correlation_id: %s", correlation_id) + return correlation_id def _build_current_telemetry_request_header(public_api_id, force_refresh=False): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index ab93eff2..3e6dc038 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3,6 +3,7 @@ import json import time import unittest +import sys import requests @@ -11,7 +12,7 @@ from msal.oauth2cli import AuthCodeReceiver logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG if "-v" in sys.argv else logging.INFO) def _get_app_and_auth_code( From 56a6c27972c081556164aa66cca354842eb283cd Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 27 Jan 2021 20:02:34 -0800 Subject: [PATCH 237/440] Refactor SSH Cert test cases to represent test requirements, and officially support SSH Cert for SP --- msal/application.py | 1 + tests/test_e2e.py | 162 +++++++++++++++++++++++--------------------- 2 files changed, 85 insertions(+), 78 deletions(-) diff --git a/msal/application.py b/msal/application.py index 01584ac3..6ee6908c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1235,6 +1235,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): - an error response would contain "error" and usually "error_description". """ # TBD: force_refresh behavior + self._validate_ssh_cert_input_data(kwargs.get("data", {})) return self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers={ diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3e6dc038..20624e63 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -50,7 +50,8 @@ def assertLoosely(self, response, assertion=None, error_description=response.get("error_description"))) assertion() - def assertCacheWorksForUser(self, result_from_wire, scope, username=None): + def assertCacheWorksForUser( + self, result_from_wire, scope, username=None, data=None): # 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)) @@ -60,7 +61,8 @@ def assertCacheWorksForUser(self, result_from_wire, scope, username=None): 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) + 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") @@ -70,7 +72,8 @@ def assertCacheWorksForUser(self, result_from_wire, scope, username=None): # 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) + result_from_cache = self.app.acquire_token_silent( + scope, account=account, data=data or {}) self.assertIsNotNone(result_from_cache, "We should get a result from acquire_token_silent(...) call") self.assertIsNotNone( @@ -132,6 +135,84 @@ def _test_device_flow( 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. +
  3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ + (replace the lab name with the labName from the link above).
  4. +
  5. Sign In or Abort
  6. +
""".format(id=self.id(), username_uri=username_uri), + data=data or {}, + ) + 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, 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") @@ -191,48 +272,6 @@ def test_auth_code_with_mismatching_nonce(self): self.app.acquire_token_by_authorization_code( ac, self.config["scope"], redirect_uri=redirect_uri, nonce="bar") - def test_ssh_cert(self): - self.skipUnlessWithConfig(["client_id", "scope"]) - - 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} - ssh_test_slice = { - "dc": "prod-wst-test1", - "slice": "test", - "sshcrt": "true", - } - - scopes = [ # Only this scope would result in an SSH-Cert - "https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] - (self.app, ac, redirect_uri) = self._get_app_and_auth_code(scopes=scopes) - - result = self.app.acquire_token_by_authorization_code( - ac, scopes, redirect_uri=redirect_uri, data=data1, - params=ssh_test_slice) - 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)) - - # acquire_token_silent() needs to be passed the same key to work - account = self.app.get_accounts()[0] - result_from_cache = self.app.acquire_token_silent( - scopes, account=account, data=data1) - self.assertIsNotNone(result_from_cache) - self.assertEqual( - result['access_token'], result_from_cache['access_token'], - "We should get the cached SSH-cert") - - # refresh_token grant can fetch an ssh-cert bound to a different key - refreshed_ssh_cert = self.app.acquire_token_silent( - scopes, account=account, params=ssh_test_slice, - data={"token_type": "ssh-cert", "key_id": "key2", "req_cnf": JWK2}) - self.assertIsNotNone(refreshed_ssh_cert) - self.assertEqual(refreshed_ssh_cert["token_type"], "ssh-cert") - self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) - def test_client_secret(self): self.skipUnlessWithConfig(["client_id", "client_secret"]) self.app = msal.ConfidentialClientApplication( @@ -446,39 +485,6 @@ def _test_acquire_token_by_auth_code_flow( error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) - def _test_acquire_token_interactive( - self, client_id=None, authority=None, scope=None, port=None, - username_uri="", # But you would want to provide one - **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=60, - port=port, - welcome_template= # This is an undocumented feature for testing - """

{id}

    -
  1. Get a username from the upn shown at here
  2. -
  3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ - (replace the lab name with the labName from the link above).
  4. -
  5. Sign In or Abort
  6. -
""".format(id=self.id(), username_uri=username_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_obo(self, config_pca, config_cca): # 1. An app obtains a token representing a user, for our mid-tier service pca = msal.PublicClientApplication( From 36c46bd1236a61296a3af502a62360e8a40b1fbe Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 29 Jan 2021 16:22:40 -0800 Subject: [PATCH 238/440] Precise DeprecationWarning for auth code API --- msal/application.py | 56 ++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/msal/application.py b/msal/application.py index 6ee6908c..1a5f1fec 100644 --- a/msal/application.py +++ b/msal/application.py @@ -441,16 +441,20 @@ def get_authorization_request_url( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id, http_client=self.http_client) - return client.build_auth_request_uri( - response_type=response_type, - redirect_uri=redirect_uri, state=state, login_hint=login_hint, - prompt=prompt, - scope=decorate_scope(scopes, self.client_id), - nonce=nonce, - domain_hint=domain_hint, - claims=_merge_claims_challenge_and_capabilities( - self._client_capabilities, claims_challenge), - ) + 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=decorate_scope(scopes, self.client_id), + 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): @@ -572,20 +576,24 @@ def acquire_token_by_authorization_code( # really empty. assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self.client.obtain_token_by_authorization_code( - code, redirect_uri=redirect_uri, - scope=decorate_scope(scopes, self.client_id), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), - }, - data=dict( - kwargs.pop("data", {}), - claims=_merge_claims_challenge_and_capabilities( - self._client_capabilities, claims_challenge)), - nonce=nonce, - **kwargs) + warnings.warn( + "Change your acquire_token_by_authorization_code() " + "to acquire_token_by_auth_code_flow()", DeprecationWarning) + with warnings.catch_warnings(record=True): + return self.client.obtain_token_by_authorization_code( + code, redirect_uri=redirect_uri, + scope=decorate_scope(scopes, self.client_id), + headers={ + CLIENT_REQUEST_ID: _get_new_correlation_id(), + CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( + self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), + }, + data=dict( + kwargs.pop("data", {}), + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge)), + nonce=nonce, + **kwargs) def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. From 615f632f859932db73d5724882534b553780c969 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 29 Jan 2021 18:16:20 -0800 Subject: [PATCH 239/440] Trying github actions Enables Python 2.7 and 3.7 only, for now --- .github/workflows/python-package.yml | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/python-package.yml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 00000000..8e7d43e1 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,38 @@ +# 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: Python package + +on: + push: + pull_request: + branches: [ dev ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [2.7, 3.7] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - 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 From 7663cf0a94e659fcf6ee4cc6da94e9fe70206a8d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 29 Jan 2021 18:25:30 -0800 Subject: [PATCH 240/440] Disable flake8 for now --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8e7d43e1..423b519f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,9 +30,9 @@ jobs: - 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 + #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 + #flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest From edd6138c0c3658d00678b3ecada61d25366de9df Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 29 Jan 2021 19:01:47 -0800 Subject: [PATCH 241/440] Fake a TRAVIS env --- .github/workflows/python-package.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 423b519f..2a27f1b3 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -10,6 +10,9 @@ on: jobs: build: + env: + # Fake a TRAVIS env so that the pre-existing test cases would behave like before + TRAVIS: true runs-on: ubuntu-latest strategy: From 274cc00d9eff46e3386b39f568d11e6b0d002ad5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Feb 2021 14:40:04 -0800 Subject: [PATCH 242/440] Use env vars to enable e2e tests --- .github/workflows/python-package.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2a27f1b3..5015740c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,6 +13,11 @@ jobs: 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 }} runs-on: ubuntu-latest strategy: From d8923848e1812ca5d676f0b2d41188f2a07b018c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Feb 2021 16:45:44 -0800 Subject: [PATCH 243/440] Cache dependencies, although the gain is insignificant for this repo --- .github/workflows/python-package.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5015740c..c841b10d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,6 +30,20 @@ jobs: 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 From 61b4a28b5c3e303224de8e49e527e107ccc66e08 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Feb 2021 17:00:05 -0800 Subject: [PATCH 244/440] Enable tests on all python versions we supported --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c841b10d..bc983d46 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.7] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 From bdda6cba525828f729ab819eee0ea19ac2558f4e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Feb 2021 20:40:36 -0800 Subject: [PATCH 245/440] Refine OBO test case's guidance message --- tests/test_e2e.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 20624e63..94e8e17b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -620,13 +620,13 @@ def test_adfs2019_onprem_acquire_token_interactive(self): @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), - "Need LAB_OBO_CLIENT SECRET from https://msidlabs.vault.azure.net/secrets/TodoListServiceV2-OBO/c58ba97c34ca4464886943a847d1db56") + "Need LAB_OBO_CLIENT_SECRET from https://aka.ms/GetLabSecret?Secret=TodoListServiceV2-OBO") @unittest.skipUnless( os.getenv("LAB_OBO_CONFIDENTIAL_CLIENT_ID"), - "Confidential client id can be found here https://docs.msidlab.com/flows/onbehalfofflow.html") + "Need LAB_OBO_CONFIDENTIAL_CLIENT_ID from https://docs.msidlab.com/flows/onbehalfofflow.html") @unittest.skipUnless( os.getenv("LAB_OBO_PUBLIC_CLIENT_ID"), - "Public client id can be found here https://docs.msidlab.com/flows/onbehalfofflow.html") + "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") From 3b10466ddc73dd897f8628b2767e13737663cac1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Feb 2021 21:05:58 -0800 Subject: [PATCH 246/440] Add prompt parameter into interactive sample --- sample/interactive_sample.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 38593315..2e5b1cf6 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -55,12 +55,14 @@ print("A local browser window will be open for you to sign in. CTRL+C to cancel.") result = app.acquire_token_interactive( config["scope"], - login_hint=config.get("username"), # You can use this parameter to pre-fill + login_hint=config.get("username"), # Optional. + # If you know the username ahead of time, this parameter can pre-fill # the username (or email address) field of the sign-in page for the user, - # if you know the username ahead of time. # Often, apps use this parameter during reauthentication, # after already extracting the username from an earlier sign-in # by using the preferred_username claim from returned id_token_claims. + + #prompt="select_account", # Optional. It forces to show account selector page ) if "access_token" in result: From e56cdd3101fd13c80e19534c25b2e68c15dbab4e Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Wed, 3 Feb 2021 13:19:58 +0800 Subject: [PATCH 247/440] Pass kwargs to acquire_token_by_refresh_token (#298) Pass kwargs to acquire_token_by_refresh_token Co-authored-by: Ray Luo --- msal/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 1a5f1fec..5d12e42f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -952,7 +952,7 @@ def _validate_ssh_cert_input_data(self, data): "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): + 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, @@ -975,6 +975,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): * 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", {})) return self.client.obtain_token_by_refresh_token( refresh_token, scope=decorate_scope(scopes, self.client_id), @@ -986,7 +987,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes): rt_getter=lambda rt: rt, on_updating_rt=False, on_removing_rt=lambda rt_item: None, # No OP - ) + **kwargs) class PublicClientApplication(ClientApplication): # browser app or mobile app @@ -1305,4 +1306,3 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), }, **kwargs) - From 146e7b58c9e3307837c8b31a8e6c1e32cea2bde9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Feb 2021 23:07:01 -0800 Subject: [PATCH 248/440] MSAL Python 1.9.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 5d12e42f..a1f50038 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.8.0" +__version__ = "1.9.0" logger = logging.getLogger(__name__) From 3815326ee53e2d842a2b08cb43a6cb2908a91eca Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Feb 2021 23:40:56 -0800 Subject: [PATCH 249/440] We will use github actions for release --- .github/workflows/python-package.yml | 33 ++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bc983d46..c6b90bfd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,7 +1,7 @@ # 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: Python package +name: CI/CD on: push: @@ -9,7 +9,7 @@ on: branches: [ dev ] jobs: - build: + ci: env: # Fake a TRAVIS env so that the pre-existing test cases would behave like before TRAVIS: true @@ -19,6 +19,7 @@ jobs: 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: @@ -58,3 +59,31 @@ jobs: - 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 }} From 9591106e2fd477587b27029d3cbffc56c712f785 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sun, 14 Feb 2021 15:50:44 -0800 Subject: [PATCH 250/440] Let e2e test use PCA or CCA rather than base class --- tests/test_e2e.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 94e8e17b..f57a3a48 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -23,8 +23,14 @@ def _get_app_and_auth_code( scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph **kwargs): from msal.oauth2cli.authcode import obtain_auth_code - app = msal.ClientApplication( - client_id, client_secret, authority=authority, http_client=MinimalHttpClient()) + 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)) From 7eb55a5c1b28e0eecea5b980919a4853333b2916 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 11 Feb 2021 20:19:37 -0800 Subject: [PATCH 251/440] Implement refresh_in behavior, and some test cases --- msal/application.py | 17 +++++++-- msal/token_cache.py | 3 ++ tests/test_application.py | 80 +++++++++++++++++++++++++++++++++++++++ tests/test_token_cache.py | 30 +++++++++------ 4 files changed, 115 insertions(+), 15 deletions(-) diff --git a/msal/application.py b/msal/application.py index a1f50038..72bbecf3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -822,6 +822,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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, @@ -839,17 +840,27 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( now = time.time() for entry in matches: expires_in = int(entry["expires_on"]) - now - if expires_in < 5*60: + if expires_in < 5*60: # Then consider it expired continue # Removal is not necessary, it will be overwritten logger.debug("Cache hit an AT") - return { # Mimic a real response + 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 } - return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( + if "refresh_on" in entry and int(entry["refresh_on"]) < now: # aging + break # With a fallback in hand, we break here to go refresh + return access_token_from_cache # It is still good as new + try: + result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, decorate_scope(scopes, self.client_id), account, force_refresh=force_refresh, 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 + logger.exception("Refresh token failed") # Potential AAD outage? + return access_token_from_cache + def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): diff --git a/msal/token_cache.py b/msal/token_cache.py index 34eff37c..028635b5 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -170,6 +170,9 @@ def __add(self, event, now=None): } 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"): diff --git a/tests/test_application.py b/tests/test_application.py index 8d48a0ac..3c3b4644 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -319,3 +319,83 @@ def test_only_client_capabilities_no_claims_merge(self): 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 + """ + 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.app = ClientApplication( + self.client_id, authority=self.authority_url, token_cache=self.cache) + + 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": TokenCacheTestCase.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) + self.assertEqual( + access_token, + self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + + 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" + self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( + lambda *args, **kwargs: {"access_token": new_access_token}) + self.assertEqual( + new_access_token, + self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + + 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) + self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( + lambda *args, **kwargs: {"error": "sth went wrong"}) + self.assertEqual( + old_at, + self.app.acquire_token_silent(['s1'], self.account).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" + self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( + lambda *args, **kwargs: {"error": error}) + self.assertEqual( + error, + self.app.acquire_token_silent_with_error( # This variant preserves error + ['s1'], self.account).get("error")) + + 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" + self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( + lambda *args, **kwargs: {"access_token": new_access_token}) + self.assertEqual( + new_access_token, + self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index c846883d..92ab7c33 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -29,30 +29,20 @@ def build_id_token( 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", - refresh_token=None, - foci=None, - id_token=None, # or something generated by build_id_token() - error=None, + **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 error: - response["error"] = error if access_token: response.update({ "access_token": access_token, "expires_in": expires_in, "token_type": token_type, }) - if refresh_token: - response["refresh_token"] = refresh_token - if id_token: - response["id_token"] = id_token - if foci: - response["foci"] = foci + response.update(kwargs) # Pass-through key-value pairs as top-level fields return response def setUp(self): @@ -222,6 +212,21 @@ def test_key_id_is_also_recorded(self): {}).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": self.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', @@ -241,6 +246,7 @@ def test_old_rt_data_with_wrong_key_should_still_be_salvaged_into_new_rt(self): '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() From c184dcebf87ba48cfaccb4b55df07083a75daef4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 19 Feb 2021 17:08:39 -0800 Subject: [PATCH 252/440] Documents Redirect URI requirement for interactive flow --- msal/application.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/application.py b/msal/application.py index a1f50038..711adf88 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1013,6 +1013,9 @@ def acquire_token_interactive( **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 scope: It is a list of case-sensitive strings. :param str prompt: From 38f512ab244c3d869de6201aae74388c5cd67659 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Mar 2021 16:28:06 -0800 Subject: [PATCH 253/440] More suitable for work :-) --- sample/confidential_client_certificate_sample.py | 2 +- sample/confidential_client_secret_sample.py | 2 +- sample/device_flow_sample.py | 2 +- sample/interactive_sample.py | 2 +- sample/migrate_rt.py | 2 +- sample/username_password_sample.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py index e3b1bf86..7e5d8069 100644 --- a/sample/confidential_client_certificate_sample.py +++ b/sample/confidential_client_certificate_sample.py @@ -48,7 +48,7 @@ client_credential={"thumbprint": config["thumbprint"], "private_key": open(config['private_key_file']).read()}, # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. diff --git a/sample/confidential_client_secret_sample.py b/sample/confidential_client_secret_sample.py index c7bc7374..d4c06e20 100644 --- a/sample/confidential_client_secret_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -47,7 +47,7 @@ client_credential=config["secret"], # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 51667ce7..48f8e7f4 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -36,7 +36,7 @@ config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 2e5b1cf6..6aafd160 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -32,7 +32,7 @@ config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. diff --git a/sample/migrate_rt.py b/sample/migrate_rt.py index eb623733..ed0011ed 100644 --- a/sample/migrate_rt.py +++ b/sample/migrate_rt.py @@ -50,7 +50,7 @@ def get_preexisting_rt_and_their_scopes_from_elsewhere(): config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # We choose a migration strategy of migrating all RTs in one loop diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index 9c9b3c06..bcc8b7d5 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -38,7 +38,7 @@ config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from - # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache + # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. From 184de89687b0602d5c30a388efa9ca29e8541e94 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 1 Mar 2021 23:50:22 -0800 Subject: [PATCH 254/440] Keep unfit RTs b/c they might be useful for others Mark last_modification_time for RT --- msal/application.py | 13 +++++++++++-- msal/token_cache.py | 9 ++++++--- tests/test_application.py | 3 +-- tests/test_token_cache.py | 2 ++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/msal/application.py b/msal/application.py index 9fcad59a..21f35132 100644 --- a/msal/application.py +++ b/msal/application.py @@ -918,11 +918,20 @@ def _acquire_token_silent_by_finding_specific_refresh_token( client = self._build_client(self.client_credential, authority) response = None # A distinguishable value to mean cache is empty - for entry in matches: + 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") response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], - on_removing_rt=rt_remover or self.token_cache.remove_rt, + on_removing_rt=(rt_remover or self.token_cache.remove_rt) + if # we can remove a RT when a single scope is an exact match + len(scopes) == 1 + and set(entry.get("target", "").split()) <= set(scopes) + else # otherwise keep the RT as it might work for a subset of scopes + lambda rt_item: None, # No OP on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, environment=authority.instance, diff --git a/msal/token_cache.py b/msal/token_cache.py index 028635b5..edc7dcb6 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -148,9 +148,9 @@ def __add(self, event, now=None): target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: + now = int(time.time() if now is None else now) if access_token: - now = int(time.time() if now is None else now) expires_in = int( # AADv1-like endpoint returns a string response.get("expires_in", 3599)) ext_expires_in = int( # AADv1-like endpoint returns a string @@ -212,6 +212,7 @@ def __add(self, event, now=None): "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"] @@ -249,8 +250,10 @@ def remove_rt(self, 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}) + 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 diff --git a/tests/test_application.py b/tests/test_application.py index 3c3b4644..28e598b2 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -73,8 +73,7 @@ def setUp(self): self.client_id, authority=self.authority_url, token_cache=self.cache) def test_cache_empty_will_be_returned_as_None(self): - self.assertEqual( - None, self.app.acquire_token_silent(['cache_miss'], self.account)) + self.app.token_cache = msal.SerializableTokenCache() # Reset it to empty self.assertEqual( None, self.app.acquire_token_silent_with_error(['cache_miss'], self.account)) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 92ab7c33..3cce0c82 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -84,6 +84,7 @@ def testAddByAad(self): 'credential_type': 'RefreshToken', 'environment': 'login.example.com', 'home_account_id': "uid.utid", + 'last_modification_time': '1000', 'secret': 'a refresh token', 'target': 's2 s1 s3', }, @@ -157,6 +158,7 @@ def testAddByAdfs(self): 'credential_type': 'RefreshToken', 'environment': 'fs.msidlab8.com', 'home_account_id': "subject", + 'last_modification_time': "1000", 'secret': 'a refresh token', 'target': 's2 s1 s3', }, From 8e0639c5f2a2ba498a774096a4702368334ef099 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Mar 2021 01:25:06 -0800 Subject: [PATCH 255/440] Decide to completely disable RT removal --- msal/application.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/msal/application.py b/msal/application.py index 21f35132..8a3fcae4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -926,12 +926,9 @@ def _acquire_token_silent_by_finding_specific_refresh_token( logger.debug("Cache attempts an RT") response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], - on_removing_rt=(rt_remover or self.token_cache.remove_rt) - if # we can remove a RT when a single scope is an exact match - len(scopes) == 1 - and set(entry.get("target", "").split()) <= set(scopes) - else # otherwise keep the RT as it might work for a subset of scopes - lambda rt_item: None, # No OP + 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, From 0c98babb8c726552e675cf635c35175ef32cfca1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 3 Mar 2021 11:35:02 -0800 Subject: [PATCH 256/440] Only trigger CI/CD when a PR is labelled --- .github/workflows/python-package.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c6b90bfd..09840ba6 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -8,6 +8,9 @@ on: pull_request: branches: [ dev ] + # This guards against unknown PR until a community member vet it and label it. + types: [ labeled ] + jobs: ci: env: From 88479e80abf27e6d0d9944665a14aee1f3d97d96 Mon Sep 17 00:00:00 2001 From: Anthony Shaw Date: Thu, 4 Mar 2021 17:28:05 +1100 Subject: [PATCH 257/440] Refactor docs into a different theme, improve nav (#319) * Refactor docs into a different theme, improve nav * Amendments to PR --- .gitignore | 3 ++- docs/conf.py | 14 ++++++++---- docs/index.rst | 50 ++++++++++++------------------------------- docs/requirements.txt | 2 ++ 4 files changed, 28 insertions(+), 41 deletions(-) create mode 100644 docs/requirements.txt diff --git a/.gitignore b/.gitignore index ff05e560..e776c10e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,8 @@ src/build # Virtual Environments /env* - +.venv/ +docs/_build/ # Visual Studio Files /.vs/* /tests/.vs/* diff --git a/docs/conf.py b/docs/conf.py index 251cf948..810dfc02 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,7 @@ # 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('..')) @@ -20,7 +21,7 @@ # -- Project information ----------------------------------------------------- project = u'MSAL Python' -copyright = u'2018, Microsoft' +copyright = u'{0}, Microsoft'.format(date.today().year) author = u'Microsoft' # The short X.Y version @@ -77,13 +78,18 @@ # a list of builtin themes. # # html_theme = 'alabaster' -html_theme = 'sphinx_rtd_theme' +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 = {} +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, @@ -176,4 +182,4 @@ epub_exclude_files = ['search.html'] -# -- Extension configuration ------------------------------------------------- +# -- Extension configuration ------------------------------------------------- \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index baad12fd..439ca0ee 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,13 @@ -.. MSAL Python documentation master file, created by - sphinx-quickstart on Tue Dec 18 10:53:22 2018. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -.. This file is also inspired by - https://pythonhosted.org/an_example_pypi_project/sphinx.html#full-code-example - -Welcome to MSAL Python's documentation! -======================================= +MSAL Python documentation +========================= .. toctree:: :maxdepth: 2 :caption: Contents: + :hidden: + + MSAL Documentation + GitHub Repository You can find high level conceptual documentations in the project `README `_ @@ -22,9 +18,8 @@ and The documentation hosted here is for API Reference. - -PublicClientApplication and ConfidentialClientApplication -========================================================= +API +=== MSAL proposes a clean separation between `public client applications and confidential client applications @@ -35,31 +30,22 @@ with different methods for different authentication scenarios. PublicClientApplication ----------------------- + .. autoclass:: msal.PublicClientApplication :members: + :inherited-members: ConfidentialClientApplication ----------------------------- -.. autoclass:: msal.ConfidentialClientApplication - :members: - -Shared Methods --------------- -Both PublicClientApplication and ConfidentialClientApplication -have following methods inherited from their base class. -You typically do not need to initiate this base class, though. - -.. autoclass:: msal.ClientApplication +.. autoclass:: msal.ConfidentialClientApplication :members: - - .. automethod:: __init__ - + :inherited-members: TokenCache -========== +---------- -One of the parameter accepted by +One of the parameters accepted by both `PublicClientApplication` and `ConfidentialClientApplication` is the `TokenCache`. @@ -71,11 +57,3 @@ See `SerializableTokenCache` for example. .. autoclass:: msal.SerializableTokenCache :members: - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`search` - diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..d5de57fe --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +furo +-r ../requirements.txt \ No newline at end of file From d45ccd6709e0b7f33d1890a743af588be279bcd2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 3 Mar 2021 16:08:51 -0800 Subject: [PATCH 258/440] Filter out refresh_in from auth responses --- msal/application.py | 44 ++++++++++++++++++++++----------------- tests/test_application.py | 34 ++++++++++++++++++------------ 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/msal/application.py b/msal/application.py index 72bbecf3..4e1fba84 100644 --- a/msal/application.py +++ b/msal/application.py @@ -100,6 +100,12 @@ def _str2bytes(raw): return raw +def _clean_up(result): + if isinstance(result, dict): + result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not + return result + + class ClientApplication(object): ACQUIRE_TOKEN_SILENT_ID = "84" @@ -507,7 +513,7 @@ def authorize(): # A controller in a web app return redirect(url_for("index")) """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self.client.obtain_token_by_auth_code_flow( + return _clean_up(self.client.obtain_token_by_auth_code_flow( auth_code_flow, auth_response, scope=decorate_scope(scopes, self.client_id) if scopes else None, @@ -521,7 +527,7 @@ def authorize(): # A controller in a web app claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, auth_code_flow.pop("claims_challenge", None))), - **kwargs) + **kwargs)) def acquire_token_by_authorization_code( self, @@ -580,7 +586,7 @@ def acquire_token_by_authorization_code( "Change your acquire_token_by_authorization_code() " "to acquire_token_by_auth_code_flow()", DeprecationWarning) with warnings.catch_warnings(record=True): - return self.client.obtain_token_by_authorization_code( + return _clean_up(self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=decorate_scope(scopes, self.client_id), headers={ @@ -593,7 +599,7 @@ def acquire_token_by_authorization_code( claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), nonce=nonce, - **kwargs) + **kwargs)) def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. @@ -855,13 +861,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, decorate_scope(scopes, self.client_id), account, force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs) + result = _clean_up(result) if (result and "error" not in result) or (not access_token_from_cache): return result except: # The exact HTTP exception is transportation-layer dependent logger.exception("Refresh token failed") # Potential AAD outage? return access_token_from_cache - def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): query = { @@ -987,7 +993,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): * A dict contains no "error" key means migration was successful. """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self.client.obtain_token_by_refresh_token( + return _clean_up(self.client.obtain_token_by_refresh_token( refresh_token, scope=decorate_scope(scopes, self.client_id), headers={ @@ -998,7 +1004,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): rt_getter=lambda rt: rt, on_updating_rt=False, on_removing_rt=lambda rt_item: None, # No OP - **kwargs) + **kwargs)) class PublicClientApplication(ClientApplication): # browser app or mobile app @@ -1072,7 +1078,7 @@ def acquire_token_interactive( self._validate_ssh_cert_input_data(kwargs.get("data", {})) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) - return self.client.obtain_token_by_browser( + return _clean_up(self.client.obtain_token_by_browser( scope=decorate_scope(scopes, self.client_id) if scopes else None, extra_scope_to_consent=extra_scopes_to_consent, redirect_uri="http://localhost:{port}".format( @@ -1091,7 +1097,7 @@ def acquire_token_interactive( CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_INTERACTIVE), }, - **kwargs) + **kwargs)) def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, @@ -1134,7 +1140,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - return self.client.obtain_token_by_device_flow( + return _clean_up(self.client.obtain_token_by_device_flow( flow, data=dict( kwargs.pop("data", {}), @@ -1150,7 +1156,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID), }, - **kwargs) + **kwargs)) def acquire_token_by_username_password( self, username, password, scopes, claims_challenge=None, **kwargs): @@ -1188,15 +1194,15 @@ def acquire_token_by_username_password( user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[CLIENT_REQUEST_ID]) if user_realm_result.get("account_type") == "Federated": - return self._acquire_token_by_username_password_federated( + return _clean_up(self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, data=data, - headers=headers, **kwargs) - return self.client.obtain_token_by_username_password( + headers=headers, **kwargs)) + return _clean_up(self.client.obtain_token_by_username_password( username, password, scope=scopes, headers=headers, data=data, - **kwargs) + **kwargs)) def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): @@ -1256,7 +1262,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): """ # TBD: force_refresh behavior self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return self.client.obtain_token_for_client( + return _clean_up(self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers={ CLIENT_REQUEST_ID: _get_new_correlation_id(), @@ -1267,7 +1273,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), - **kwargs) + **kwargs)) def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=None, **kwargs): """Acquires token using on-behalf-of (OBO) flow. @@ -1297,7 +1303,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No """ # The implementation is NOT based on Token Exchange # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16 - return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 + return _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=decorate_scope(scopes, self.client_id), # Decoration is used for: @@ -1316,4 +1322,4 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), }, - **kwargs) + **kwargs)) diff --git a/tests/test_application.py b/tests/test_application.py index 3c3b4644..2ba66a8b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -354,19 +354,23 @@ 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) - self.assertEqual( - access_token, - self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + result = self.app.acquire_token_silent(['s1'], self.account) + 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" - self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( - lambda *args, **kwargs: {"access_token": new_access_token}) - self.assertEqual( - new_access_token, - self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + def mock_post(*args, **kwargs): + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": new_access_token, + "refresh_in": 123, + })) + self.app.http_client.post = mock_post + result = self.app.acquire_token_silent(['s1'], self.account) + 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 @@ -393,9 +397,13 @@ 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" - self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( - lambda *args, **kwargs: {"access_token": new_access_token}) - self.assertEqual( - new_access_token, - self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + def mock_post(*args, **kwargs): + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": new_access_token, + "refresh_in": 123, + })) + self.app.http_client.post = mock_post + result = self.app.acquire_token_silent(['s1'], self.account) + self.assertEqual(new_access_token, result.get("access_token")) + self.assertNotIn("refresh_in", result, "Customers need not know refresh_in") From ce63016cd2b01f26d31d7cfcf0dd22cb4f7d705d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 8 Mar 2021 10:01:44 -0800 Subject: [PATCH 259/440] Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 027bc5b9..c4a46b1f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,7 +21,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.9.0" +__version__ = "1.10.0" logger = logging.getLogger(__name__) From 679d7068263fdbea8711e61fbc7e6a0d00f53aa3 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Tue, 9 Mar 2021 15:54:14 +0800 Subject: [PATCH 260/440] Fix typo in docstring --- msal/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index c4a46b1f..4ddb78da 100644 --- a/msal/application.py +++ b/msal/application.py @@ -325,7 +325,7 @@ def initiate_auth_code_flow( you can use :func:`~acquire_token_by_auth_code_flow()` to complete the authentication/authorization. - :param list scope: + :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. @@ -1039,7 +1039,7 @@ def acquire_token_interactive( Prerequisite: In Azure Portal, configure the Redirect URI of your "Mobile and Desktop application" as ``http://localhost``. - :param list scope: + :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". From f7514f3bd3c585549af885ee2b457b753a930cfa Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 17 Mar 2021 08:30:29 -0700 Subject: [PATCH 261/440] Remove inaccurate doc due to copy-and-paste error --- msal/application.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 4ddb78da..6c7b90d4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1080,8 +1080,7 @@ def acquire_token_interactive( :return: - A dict containing no "error" key, - and typically contains an "access_token" key, - if cache lookup succeeded. + 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", {})) From 61e0deb1ca9b968b17d3dd33169f804528d8e843 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 16 Mar 2021 08:37:29 -0700 Subject: [PATCH 262/440] Enable retry on connection error --- msal/application.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/msal/application.py b/msal/application.py index 6c7b90d4..9ff16514 100644 --- a/msal/application.py +++ b/msal/application.py @@ -241,6 +241,13 @@ def __init__( # 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.app_name = app_name self.app_version = app_version self.authority = Authority( From 2e68856261dea5d181352a2705351ba5b7373853 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 23 Feb 2021 18:11:06 -0800 Subject: [PATCH 263/440] Implementing Telemetry V4 Implement Telemetry's app-wide state Test cases for telemetry id on most public methods Test telemetry buffer for offline states --- msal/application.py | 166 +++++++++++++++-------------- msal/telemetry.py | 78 ++++++++++++++ msal/token_cache.py | 2 +- tests/test_application.py | 213 ++++++++++++++++++++++++++++++++------ 4 files changed, 346 insertions(+), 113 deletions(-) create mode 100644 msal/telemetry.py diff --git a/msal/application.py b/msal/application.py index 9ff16514..cf4a1a3a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -8,7 +8,7 @@ import logging import sys import warnings -import uuid +from threading import Lock import requests @@ -18,6 +18,7 @@ from .wstrust_request import send_request as wst_send_request from .wstrust_response import * from .token_cache import TokenCache +import msal.telemetry # The __init__.py will import this. Not the other way around. @@ -52,18 +53,6 @@ def decorate_scope( decorated = scope_set | reserved_scope return list(decorated) -CLIENT_REQUEST_ID = 'client-request-id' -CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry' - -def _get_new_correlation_id(): - correlation_id = str(uuid.uuid4()) - logger.debug("Generates correlation_id: %s", correlation_id) - return correlation_id - - -def _build_current_telemetry_request_header(public_api_id, force_refresh=False): - return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0") - def extract_certs(public_cert_content): # Parses raw public certificate file contents and returns a list of strings @@ -257,6 +246,14 @@ def __init__( self.token_cache = token_cache or TokenCache() self.client = self._build_client(client_credential, self.authority) self.authority_groups = None + self._telemetry_buffer = {} + self._telemetry_lock = Lock() + + 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 _build_client(self, client_credential, authority): client_assertion = None @@ -520,21 +517,21 @@ def authorize(): # A controller in a web app return redirect(url_for("index")) """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return _clean_up(self.client.obtain_token_by_auth_code_flow( + 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=decorate_scope(scopes, self.client_id) if scopes else None, - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), - }, + 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, @@ -593,20 +590,20 @@ def acquire_token_by_authorization_code( "Change your acquire_token_by_authorization_code() " "to acquire_token_by_auth_code_flow()", DeprecationWarning) with warnings.catch_warnings(record=True): - return _clean_up(self.client.obtain_token_by_authorization_code( + 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=decorate_scope(scopes, self.client_id), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID), - }, + 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(resposne) + return response def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. @@ -735,7 +732,7 @@ def acquire_token_silent( - None when cache lookup does not yield a token. """ result = self.acquire_token_silent_with_error( - scopes, account, authority, force_refresh, + scopes, account, authority=authority, force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs) return result if result and "error" not in result else None @@ -780,7 +777,7 @@ def acquire_token_silent_with_error( """ assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) - correlation_id = _get_new_correlation_id() + 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( @@ -851,9 +848,11 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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 @@ -862,13 +861,18 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( "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 = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( + result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, decorate_scope(scopes, self.client_id), account, - force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs) - result = _clean_up(result) + 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 @@ -922,7 +926,8 @@ def _get_app_metadata(self, environment): def _acquire_token_silent_by_finding_specific_refresh_token( self, authority, scopes, query, rt_remover=None, break_condition=lambda response: False, - force_refresh=False, correlation_id=None, claims_challenge=None, **kwargs): + 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 @@ -931,6 +936,9 @@ def _acquire_token_silent_by_finding_specific_refresh_token( client = self._build_client(self.client_credential, authority) response = None # A distinguishable value to mean cache is empty + 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, @@ -948,16 +956,13 @@ def _acquire_token_silent_by_finding_specific_refresh_token( skip_account_creation=True, # To honor a concurrent remove_account() )), scope=scopes, - headers={ - CLIENT_REQUEST_ID: correlation_id or _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_SILENT_ID, force_refresh=force_refresh), - }, + 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) if "error" not in response: return response logger.debug("Refresh failed. {error}: {error_description}".format( @@ -1006,18 +1011,19 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): * A dict contains no "error" key means migration was successful. """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return _clean_up(self.client.obtain_token_by_refresh_token( + 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=decorate_scope(scopes, self.client_id), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_REFRESH_TOKEN), - }, + 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 class PublicClientApplication(ClientApplication): # browser app or mobile app @@ -1093,7 +1099,9 @@ def acquire_token_interactive( self._validate_ssh_cert_input_data(kwargs.get("data", {})) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) - return _clean_up(self.client.obtain_token_by_browser( + telemetry_context = self._build_telemetry_context( + self.ACQUIRE_TOKEN_INTERACTIVE) + response = _clean_up(self.client.obtain_token_by_browser( scope=decorate_scope(scopes, self.client_id) if scopes else None, extra_scope_to_consent=extra_scopes_to_consent, redirect_uri="http://localhost:{port}".format( @@ -1107,12 +1115,10 @@ def acquire_token_interactive( "domain_hint": domain_hint, }, data=dict(kwargs.pop("data", {}), claims=claims), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_INTERACTIVE), - }, + headers=telemetry_context.generate_headers(), **kwargs)) + telemetry_context.update_telemetry(response) + return response def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, @@ -1125,13 +1131,10 @@ def initiate_device_flow(self, scopes=None, **kwargs): - A successful response would contain "user_code" key, among others - an error response would contain some other readable key/value pairs. """ - correlation_id = _get_new_correlation_id() + correlation_id = msal.telemetry._get_new_correlation_id() flow = self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), - headers={ - CLIENT_REQUEST_ID: correlation_id, - # CLIENT_CURRENT_TELEMETRY is not currently required - }, + headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id}, **kwargs) flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id return flow @@ -1155,7 +1158,10 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - return _clean_up(self.client.obtain_token_by_device_flow( + 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", {}), @@ -1165,13 +1171,10 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), ), - headers={ - CLIENT_REQUEST_ID: - flow.get(self.DEVICE_FLOW_CORRELATION_ID) or _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID), - }, + headers=telemetry_context.generate_headers(), **kwargs)) + telemetry_context.update_telemetry(response) + return response def acquire_token_by_username_password( self, username, password, scopes, claims_challenge=None, **kwargs): @@ -1196,28 +1199,30 @@ def acquire_token_by_username_password( - an error response would contain "error" and usually "error_description". """ scopes = decorate_scope(scopes, self.client_id) - headers = { - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID), - } + 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[CLIENT_REQUEST_ID]) + username, correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]) if user_realm_result.get("account_type") == "Federated": - return _clean_up(self._acquire_token_by_username_password_federated( + response = _clean_up(self._acquire_token_by_username_password_federated( user_realm_result, username, password, scopes=scopes, data=data, headers=headers, **kwargs)) - return _clean_up(self.client.obtain_token_by_username_password( + 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): @@ -1277,18 +1282,18 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): """ # TBD: force_refresh behavior self._validate_ssh_cert_input_data(kwargs.get("data", {})) - return _clean_up(self.client.obtain_token_for_client( + telemetry_context = self._build_telemetry_context( + self.ACQUIRE_TOKEN_FOR_CLIENT_ID) + response = _clean_up(self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_FOR_CLIENT_ID), - }, + 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. @@ -1316,9 +1321,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No - 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 - return _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 + 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=decorate_scope(scopes, self.client_id), # Decoration is used for: @@ -1332,9 +1339,8 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No requested_token_use="on_behalf_of", claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge)), - headers={ - CLIENT_REQUEST_ID: _get_new_correlation_id(), - CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header( - self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID), - }, + headers=telemetry_context.generate_headers(), **kwargs)) + telemetry_context.update_telemetry(response) + return response + diff --git a/msal/telemetry.py b/msal/telemetry.py new file mode 100644 index 00000000..b07ab3ed --- /dev/null +++ b/msal/telemetry.py @@ -0,0 +1,78 @@ +import 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}) + diff --git a/msal/token_cache.py b/msal/token_cache.py index edc7dcb6..b0731278 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -145,7 +145,7 @@ def __add(self, event, now=None): client_info["uid"] = id_token_claims.get("sub") home_account_id = id_token_claims.get("sub") - target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it + 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) diff --git a/tests/test_application.py b/tests/test_application.py index 93b3d002..f4787e2c 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -7,6 +7,7 @@ from tests import unittest from tests.test_token_cache import TokenCacheTestCase from tests.http_client import MinimalHttpClient, MinimalResponse +from msal.telemetry import CLIENT_CURRENT_TELEMETRY, CLIENT_LAST_TELEMETRY logger = logging.getLogger(__name__) @@ -282,7 +283,7 @@ 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}}, + 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( @@ -292,7 +293,7 @@ def test_capabilities_and_id_token_claims_merge(self): 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}}, + 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"}, @@ -324,19 +325,17 @@ 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" + app = ClientApplication(client_id, authority=authority_url) + 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.app = ClientApplication( - self.client_id, authority=self.authority_url, token_cache=self.cache) + self.app.token_cache = self.cache = msal.SerializableTokenCache() def populate_cache(self, access_token="at", expires_in=86400, refresh_in=43200): self.cache.add({ @@ -353,7 +352,11 @@ 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) + 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") @@ -361,13 +364,13 @@ 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(*args, **kwargs): + 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, })) - self.app.http_client.post = mock_post - result = self.app.acquire_token_silent(['s1'], self.account) + 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") @@ -375,34 +378,180 @@ 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) - self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( - lambda *args, **kwargs: {"error": "sth went wrong"}) - self.assertEqual( - old_at, - self.app.acquire_token_silent(['s1'], self.account).get("access_token")) + 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" - self.app._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family = ( - lambda *args, **kwargs: {"error": error}) - self.assertEqual( - error, - self.app.acquire_token_silent_with_error( # This variant preserves error - ['s1'], self.account).get("error")) + 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(*args, **kwargs): + 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, })) - self.app.http_client.post = mock_post - result = self.app.acquire_token_silent(['s1'], self.account) + 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": TokenCacheTestCase.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): + 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): + 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): + 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")) + From 4047c1b4bc789b7c353f8a1b799eae6c6cfa24b6 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Tue, 30 Mar 2021 23:55:51 +0800 Subject: [PATCH 264/440] Support launching browser in WSL Ubuntu 18.04 (#333) * Support launching browser in WSL Ubuntu 18.04 * rename * Add test * Add -NoProfile * sort import * Apply suggestions from code review Co-authored-by: Ray Luo * Remove test_authcode.py Co-authored-by: Ray Luo --- msal/oauth2cli/authcode.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/msal/oauth2cli/authcode.py b/msal/oauth2cli/authcode.py index 71e3f07c..25c337c4 100644 --- a/msal/oauth2cli/authcode.py +++ b/msal/oauth2cli/authcode.py @@ -33,9 +33,34 @@ def obtain_auth_code(listen_port, auth_uri=None): # Historically only used in t ).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): # throws ImportError, possibly webbrowser.Error in future import webbrowser # Lazy import. Some distro may not have this. - return webbrowser.open(auth_uri) # Use default browser. Customizable by $BROWSER + browser_opened = webbrowser.open(auth_uri) # Use default browser. Customizable by $BROWSER + + # 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): @@ -245,4 +270,3 @@ def __exit__(self, exc_type, exc_val, exc_tb): timeout=60, state=flow["state"], # Optional ), indent=4)) - From 4c0e58200acd64de031cb332e67c2008921f2650 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 25 Mar 2021 11:37:45 -0700 Subject: [PATCH 265/440] Saml token should use utf-8 encoding --- msal/wstrust_response.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/wstrust_response.py b/msal/wstrust_response.py index 61458e04..9c58af23 100644 --- a/msal/wstrust_response.py +++ b/msal/wstrust_response.py @@ -88,5 +88,7 @@ def parse_token_by_re(raw_response): # Returns the saml:assertion token_types = findall_content(rstr, "TokenType") tokens = findall_content(rstr, "RequestedSecurityToken") if token_types and tokens: - return {"token": tokens[0].encode('us-ascii'), "type": token_types[0]} + # Historically, we use "us-ascii" encoding, but it should be "utf-8" + # https://stackoverflow.com/questions/36658000/what-is-encoding-used-for-saml-conversations + return {"token": tokens[0].encode('utf-8'), "type": token_types[0]} From 5a60811a5eee4b862546e3683fb5120b42d2e4d6 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Thu, 1 Apr 2021 16:54:32 +0800 Subject: [PATCH 266/440] Fix type hint for `client_credential` --- msal/application.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index cf4a1a3a..dbe11e9d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -120,7 +120,7 @@ def __init__( :param str client_id: Your app has a client_id after you register it on AAD. - :param str client_credential: + :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, @@ -1343,4 +1343,3 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No **kwargs)) telemetry_context.update_telemetry(response) return response - From 060c5f576e94a7dda9b102e3f5c5389c712e01b5 Mon Sep 17 00:00:00 2001 From: ShannonCanTech Date: Tue, 6 Apr 2021 16:24:00 -0700 Subject: [PATCH 267/440] survey added to README.md Loyalty developer survey added to README.md Link placed in tab and new feedback section. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7702b367..f570672c 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Not sure whether this is the SDK you are looking for your app? There are other M 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) -| --- | --- | --- | --- | +| [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) | +| --- | --- | --- | --- | --- | ## Installation @@ -126,6 +126,9 @@ 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. From 2832281e59b5588c88d5702a35c46b6b4d001f61 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 12 Mar 2021 16:29:19 -0800 Subject: [PATCH 268/440] Move ROPC from PCA to base --- msal/application.py | 170 ++++++++++++++--------------- sample/username_password_sample.py | 3 +- tests/test_e2e.py | 34 +++++- 3 files changed, 119 insertions(+), 88 deletions(-) diff --git a/msal/application.py b/msal/application.py index dbe11e9d..0aeb3268 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1025,6 +1025,91 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **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 = decorate_scope(scopes, self.client_id) + 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, **kwargs) + class PublicClientApplication(ClientApplication): # browser app or mobile app @@ -1176,91 +1261,6 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **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 = decorate_scope(scopes, self.client_id) - 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, **kwargs) - class ConfidentialClientApplication(ClientApplication): # server-side web app diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index bcc8b7d5..c5b98632 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -34,8 +34,9 @@ config = json.load(open(sys.argv[1])) # Create a preferably long-lived app instance which maintains a token cache. -app = msal.PublicClientApplication( +app = msal.ClientApplication( config["client_id"], authority=config["authority"], + client_credential=config.get("client_secret"), # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f57a3a48..b9886257 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -102,10 +102,12 @@ def assertCacheWorksForApp(self, result_from_wire, scope): 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 **ignored): assert authority and client_id and username and password and scope - self.app = msal.PublicClientApplication( - client_id, authority=authority, http_client=MinimalHttpClient()) + self.app = msal.ClientApplication( + client_id, authority=authority, http_client=MinimalHttpClient(), + client_credential=client_secret) result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) @@ -650,6 +652,34 @@ def test_acquire_token_obo(self): self._test_acquire_token_obo(config_pca, config_cca) + def test_acquire_token_by_client_secret(self): + # This is copied from ArlingtonCloudTestCase's same test case + try: + config = self.get_lab_user(usertype="cloud", publicClient="no") + except requests.exceptions.HTTPError: + self.skipTest("The lab does not provide confidential app for testing") + else: + config["client_secret"] = self.get_lab_user_secret("TBD") # TODO + self._test_acquire_token_by_client_secret(**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") + 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 From 45e77c164a7fbd966cd6ce046050922f18f24f89 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Apr 2021 10:40:30 -0700 Subject: [PATCH 269/440] Fix typo --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index cf4a1a3a..9b551496 100644 --- a/msal/application.py +++ b/msal/application.py @@ -602,7 +602,7 @@ def acquire_token_by_authorization_code( self._client_capabilities, claims_challenge)), nonce=nonce, **kwargs)) - telemetry_context.update_telemetry(resposne) + telemetry_context.update_telemetry(response) return response def get_accounts(self, username=None): From c40256f9f4023bad5e33798931c4750919f08648 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Apr 2021 10:51:49 -0700 Subject: [PATCH 270/440] Bumps version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index ff158e0a..ea5bf48b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -22,7 +22,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.10.0" +__version__ = "1.11.0" logger = logging.getLogger(__name__) From 0004be6a5fcbbae304a14fd89d7e3e213bf80ace Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Apr 2021 11:07:29 -0700 Subject: [PATCH 271/440] Doc for the new retry-on-connection-error behavior --- msal/application.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 9ff16514..4dbc66ee 100644 --- a/msal/application.py +++ b/msal/application.py @@ -187,7 +187,12 @@ def __init__( 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 + 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 From 59ce0c49992ff6097e011d3ffd2e99ac00f73bbc Mon Sep 17 00:00:00 2001 From: Laurent Mazuel Date: Tue, 13 Apr 2021 13:42:42 -0700 Subject: [PATCH 272/440] Clarify supported Python versions --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0555c667..22c3bf80 100644 --- a/setup.py +++ b/setup.py @@ -58,11 +58,11 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], From fbbc814595c440a2f3a0fdf7ef9b9dc2b7ed142c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 4 Jun 2020 11:23:34 -0700 Subject: [PATCH 273/440] Group accounts by home_account_id, and keep same return format Maintain backward compatibility --- msal/application.py | 21 ++++++++++++++++++--- tests/test_application.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 6ab23dfe..2bce2f8e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -642,10 +642,25 @@ def get_accounts(self, username=None): return accounts def _find_msal_accounts(self, environment): - return [a for a in self.token_cache.find( - TokenCache.CredentialType.ACCOUNT, query={"environment": 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)] + TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS) + } + return list(grouped_accounts.values()) def _get_authority_aliases(self, instance): if not self.authority_groups: diff --git a/tests/test_application.py b/tests/test_application.py index f4787e2c..ea98b16f 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -555,3 +555,42 @@ def mock_post(url, headers=None, *args, **kwargs): 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": TokenCacheTestCase.build_response( + uid=uid, utid=utid, access_token="at", refresh_token="rt", + id_token=TokenCacheTestCase.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") + From 0b3b8a84cf690d4fe8374966245eeaf725349669 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 19 Apr 2021 16:51:54 -0700 Subject: [PATCH 274/440] Adjust log to facilitate testing --- tests/test_e2e.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b9886257..4a7f0c9e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -58,6 +58,12 @@ def assertLoosely(self, response, assertion=None, 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)) @@ -164,12 +170,6 @@ def _test_acquire_token_interactive( """.format(id=self.id(), username_uri=username_uri), data=data or {}, ) - 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( From c4165426af3adb44832b7b864f44b7af6dfc568a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 19 Apr 2021 17:14:49 -0700 Subject: [PATCH 275/440] Fix imprecise test --- tests/test_e2e.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4a7f0c9e..268e765c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -401,7 +401,10 @@ def tearDownClass(cls): 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) - return resp.json()[0] + 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"): @@ -698,7 +701,7 @@ def test_b2c_acquire_token_by_auth_code(self): 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["defaultScopes"].split(','), + scope=config["scopes"], ) @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") @@ -708,7 +711,7 @@ def test_b2c_acquire_token_by_auth_code_flow(self): 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["defaultScopes"].split(','), + scope=config["scopes"], username_uri="https://msidlab.com/api/user?usertype=b2c&b2cprovider=local", ) @@ -719,7 +722,7 @@ def test_b2c_acquire_token_by_ropc(self): client_id=config["appId"], username="b2clocal@msidlabb2c.onmicrosoft.com", password=self.get_lab_user_secret("msidlabb2c"), - scope=config["defaultScopes"].split(','), + scope=config["scopes"], ) From a0c412012b81823c6fe3eee293f867ed4127357c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 23 Apr 2021 20:18:49 -0700 Subject: [PATCH 276/440] Bring back docs for __init__() --- docs/index.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 439ca0ee..c2ce0c4b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,8 @@ PublicClientApplication :members: :inherited-members: + .. automethod:: __init__ + ConfidentialClientApplication ----------------------------- @@ -42,6 +44,8 @@ ConfidentialClientApplication :members: :inherited-members: + .. automethod:: __init__ + TokenCache ---------- From a82e8236a670e430ab846627be7914b0d038ddec Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 23 Apr 2021 16:07:30 -0700 Subject: [PATCH 277/440] Use image map to guide customer to proper sample --- docs/index.rst | 59 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index c2ce0c4b..fe147c2a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,17 +10,64 @@ MSAL Python documentation GitHub Repository You can find high level conceptual documentations in the project -`README `_ -and -`workable samples inside the project code base -`_ -. +`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 + `_. -The documentation hosted here is for API Reference. 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 `_. From 94f173ffeac4c8188f4ac9d0e916689a107463d2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 23 Apr 2021 15:51:42 -0700 Subject: [PATCH 278/440] acquire_token_for_client() can use regional endpoint --- msal/application.py | 89 ++++++++++++++++++++++++++++++++++++---- tests/test_e2e.py | 99 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/msal/application.py b/msal/application.py index 2bce2f8e..4648d0e5 100644 --- a/msal/application.py +++ b/msal/application.py @@ -9,6 +9,7 @@ import sys import warnings from threading import Lock +import os import requests @@ -108,6 +109,8 @@ class ClientApplication(object): GET_ACCOUNTS_ID = "902" REMOVE_ACCOUNT_ID = "903" + ATTEMPT_REGION_DISCOVERY = "TryAutoDetect" + def __init__( self, client_id, client_credential=None, authority=None, validate_authority=True, @@ -115,7 +118,12 @@ def __init__( http_client=None, verify=True, proxies=None, timeout=None, client_claims=None, app_name=None, app_version=None, - client_capabilities=None): + client_capabilities=None, + 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. + ): """Create an instance of application. :param str client_id: Your app has a client_id after you register it on AAD. @@ -220,6 +228,25 @@ def __init__( MSAL will combine them into `claims parameter Date: Fri, 30 Apr 2021 18:14:39 -0700 Subject: [PATCH 279/440] Expensive IMDS call --- msal/application.py | 52 ++++++++++++++++++++++----------------------- msal/region.py | 26 +++++++++++++++++++++++ tests/test_e2e.py | 23 +++++++++++--------- 3 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 msal/region.py diff --git a/msal/application.py b/msal/application.py index 4648d0e5..4f62a804 100644 --- a/msal/application.py +++ b/msal/application.py @@ -9,7 +9,6 @@ import sys import warnings from threading import Lock -import os import requests @@ -20,6 +19,7 @@ from .wstrust_response import * from .token_cache import TokenCache import msal.telemetry +from .region import _detect_region # The __init__.py will import this. Not the other way around. @@ -261,7 +261,7 @@ def __init__( # 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) + self.http_client.request, timeout=timeout or 2) # Enable a minimal retry. Better than nothing. # https://github.com/psf/requests/blob/v2.25.1/requests/adapters.py#L94-L108 @@ -290,11 +290,8 @@ def _build_telemetry_context( self._telemetry_buffer, self._telemetry_lock, api_id, correlation_id=correlation_id, refresh_reason=refresh_reason) - def _detect_region(self): - return os.environ.get("REGION_NAME") # TODO: or Call IMDS - def _get_regional_authority(self, central_authority): - self._region_detected = self._region_detected or self._detect_region() + self._region_detected = self._region_detected or _detect_region(self.http_client) if self._region_configured and self._region_detected != self._region_configured: logger.warning('Region configured ({}) != region detected ({})'.format( repr(self._region_configured), repr(self._region_detected))) @@ -369,27 +366,28 @@ def _build_client(self, client_credential, authority): on_updating_rt=self.token_cache.update_rt) regional_client = None - 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 = Client( - 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) + if client_credential: # Currently regional endpoint only serves some CCA flows + 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 = Client( + 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( diff --git a/msal/region.py b/msal/region.py new file mode 100644 index 00000000..b6b33e1e --- /dev/null +++ b/msal/region.py @@ -0,0 +1,26 @@ +import os +import json +import logging + +logger = logging.getLogger(__name__) + + +def _detect_region(http_client): + return _detect_region_of_azure_function() or _detect_region_of_azure_vm(http_client) + + +def _detect_region_of_azure_function(): + return os.environ.get("REGION_NAME") + + +def _detect_region_of_azure_vm(http_client): + url = "http://169.254.169.254/metadata/instance?api-version=2021-01-01" + 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 json.loads(resp.text)["compute"]["location"] + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c4805dda..5143d05e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -27,7 +27,7 @@ def _get_app_and_auth_code( app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, - authority=authority, http_client=MinimalHttpClient()) + authority=authority, http_client=MinimalHttpClient(timeout=2)) else: app = msal.PublicClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) @@ -292,7 +292,7 @@ def test_client_secret(self): self.config["client_id"], client_credential=self.config.get("client_secret"), authority=self.config.get("authority"), - http_client=MinimalHttpClient()) + http_client=MinimalHttpClient(timeout=2)) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -307,7 +307,7 @@ def test_client_certificate(self): self.app = msal.ConfidentialClientApplication( self.config['client_id'], {"private_key": private_key, "thumbprint": client_cert["thumbprint"]}, - http_client=MinimalHttpClient()) + http_client=MinimalHttpClient(timeout=2)) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -330,7 +330,7 @@ def test_subject_name_issuer_authentication(self): "thumbprint": self.config["thumbprint"], "public_certificate": public_certificate, }, - http_client=MinimalHttpClient()) + http_client=MinimalHttpClient(timeout=2)) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -379,14 +379,17 @@ def get_lab_app( client_id, client_credential=client_secret, authority=authority, - http_client=MinimalHttpClient(), + http_client=MinimalHttpClient(timeout=2), **kwargs) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow logger.info("Creating session") - lab_token = lab_app.acquire_token_for_client(scopes) + 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" % lab_token["access_token"]}) + session.headers.update({"Authorization": "Bearer %s" % result["access_token"]}) session.hooks["response"].append(lambda r, *args, **kwargs: r.raise_for_status()) return session @@ -525,7 +528,7 @@ def _test_acquire_token_obo(self, config_pca, config_cca): config_cca["client_id"], client_credential=config_cca["client_secret"], authority=config_cca["authority"], - http_client=MinimalHttpClient(), + http_client=MinimalHttpClient(timeout=2), # 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. @@ -553,7 +556,7 @@ def _test_acquire_token_by_client_secret( assert client_id and client_secret and authority and scope app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, - http_client=MinimalHttpClient()) + http_client=MinimalHttpClient(timeout=2)) result = app.acquire_token_for_client(scope) self.assertIsNotNone(result.get("access_token"), "Got %s instead" % result) @@ -852,7 +855,7 @@ def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): usertype="cloud", azureenvironment=self.environment, publicClient="no") app = msal.ConfidentialClientApplication( config['client_id'], authority=config['authority'], - http_client=MinimalHttpClient()) + http_client=MinimalHttpClient(timeout=2)) 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. From e9d27f26b5bc8da30a82e151df406f468d5af4c8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 5 May 2021 21:46:23 -0700 Subject: [PATCH 280/440] Only call IMDS when input region != None --- msal/application.py | 31 +++++++++++++++++++++++-------- msal/region.py | 13 ++++++++++--- tests/test_e2e.py | 18 +++++++++--------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/msal/application.py b/msal/application.py index 4f62a804..ff15d1d3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -232,11 +232,22 @@ def __init__( :param str region: Added since MSAL Python 1.12.0. - If enabled, MSAL token requests would remain inside that region. - Currently, regional endpoint only supports using - ``acquire_token_for_client()`` for some scopes. + As of 2021 May, regional service is only available for + ``acquire_token_for_client()`` sent by any of the following scenarios:: - The default value is None, which means region support remains turned off. + 1. An app powered by a capable MSAL + (MSAL Python 1.12+ will be provisioned) + + 2. An app with managed identity, which is formerly known as MSI. + (However MSAL Python does not support managed identity, + so this one does not apply.) + + 3. An app authenticated by Subject Name/Issuer (SNI). + + 4. An app which already onboard to the region's allow-list. + + MSAL's default value is None, which means region behavior remains off. + If enabled, some of the MSAL traffic would remain inside that region. App developer can opt in to regional endpoint, by provide a region name, such as "westus", "eastus2". @@ -261,7 +272,7 @@ def __init__( # 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 or 2) + 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 @@ -291,11 +302,15 @@ def _build_telemetry_context( 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 and self._region_detected != self._region_configured: + is_region_specified = bool(self._region_configured + and self._region_configured != self.ATTEMPT_REGION_DISCOVERY) + self._region_detected = self._region_detected or _detect_region( + self.http_client if self._region_configured is not None else None) + if (is_region_specified 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_configured or self._region_detected + region_to_use = ( + self._region_configured if is_region_specified else self._region_detected) if region_to_use: logger.info('Region to be used: {}'.format(repr(region_to_use))) regional_host = ("{}.login.microsoft.com".format(region_to_use) diff --git a/msal/region.py b/msal/region.py index b6b33e1e..67033c58 100644 --- a/msal/region.py +++ b/msal/region.py @@ -5,8 +5,11 @@ logger = logging.getLogger(__name__) -def _detect_region(http_client): - return _detect_region_of_azure_function() or _detect_region_of_azure_vm(http_client) +def _detect_region(http_client=None): + region = _detect_region_of_azure_function() # It is cheap, so we do it always + if http_client and not region: + return _detect_region_of_azure_vm(http_client) # It could hang for minutes + return region def _detect_region_of_azure_function(): @@ -15,11 +18,15 @@ def _detect_region_of_azure_function(): def _detect_region_of_azure_vm(http_client): url = "http://169.254.169.254/metadata/instance?api-version=2021-01-01" + logger.info( + "Connecting to IMDS {}. " + "You may want to use a shorter timeout on your http_client".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)) + logger.info( + "IMDS {} unavailable. Perhaps not running in Azure VM?".format(url)) return None else: return json.loads(resp.text)["compute"]["location"] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5143d05e..d68bfe2f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -27,7 +27,7 @@ def _get_app_and_auth_code( app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, - authority=authority, http_client=MinimalHttpClient(timeout=2)) + authority=authority, http_client=MinimalHttpClient()) else: app = msal.PublicClientApplication( client_id, authority=authority, http_client=MinimalHttpClient()) @@ -292,7 +292,7 @@ def test_client_secret(self): self.config["client_id"], client_credential=self.config.get("client_secret"), authority=self.config.get("authority"), - http_client=MinimalHttpClient(timeout=2)) + http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -307,7 +307,7 @@ def test_client_certificate(self): self.app = msal.ConfidentialClientApplication( self.config['client_id'], {"private_key": private_key, "thumbprint": client_cert["thumbprint"]}, - http_client=MinimalHttpClient(timeout=2)) + http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -330,7 +330,7 @@ def test_subject_name_issuer_authentication(self): "thumbprint": self.config["thumbprint"], "public_certificate": public_certificate, }, - http_client=MinimalHttpClient(timeout=2)) + http_client=MinimalHttpClient()) scope = self.config.get("scope", []) result = self.app.acquire_token_for_client(scope) self.assertIn('access_token', result) @@ -379,7 +379,7 @@ def get_lab_app( client_id, client_credential=client_secret, authority=authority, - http_client=MinimalHttpClient(timeout=2), + http_client=MinimalHttpClient(), **kwargs) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow @@ -528,7 +528,7 @@ def _test_acquire_token_obo(self, config_pca, config_cca): config_cca["client_id"], client_credential=config_cca["client_secret"], authority=config_cca["authority"], - http_client=MinimalHttpClient(timeout=2), + http_client=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. @@ -556,7 +556,7 @@ def _test_acquire_token_by_client_secret( assert client_id and client_secret and authority and scope app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, - http_client=MinimalHttpClient(timeout=2)) + http_client=MinimalHttpClient()) result = app.acquire_token_for_client(scope) self.assertIsNotNone(result.get("access_token"), "Got %s instead" % result) @@ -758,7 +758,7 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): scopes = ["https://graph.microsoft.com/.default"] result = self.app.acquire_token_for_client( scopes, - params={"AllowEstsRNonMsi": "true"}, # For testing regional endpoint + 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) @@ -855,7 +855,7 @@ def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): usertype="cloud", azureenvironment=self.environment, publicClient="no") app = msal.ConfidentialClientApplication( config['client_id'], authority=config['authority'], - http_client=MinimalHttpClient(timeout=2)) + 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. From 1129d1927339a03fda8086ff32ba08d6442d77b5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 May 2021 16:02:00 -0700 Subject: [PATCH 281/440] Host diagrams locally --- docs/daemon-app.svg | 1071 +++++++++++++ docs/index.rst | 13 +- docs/scenarios-with-users.svg | 2782 +++++++++++++++++++++++++++++++++ 3 files changed, 3861 insertions(+), 5 deletions(-) create mode 100644 docs/daemon-app.svg create mode 100644 docs/scenarios-with-users.svg diff --git a/docs/daemon-app.svg b/docs/daemon-app.svg new file mode 100644 index 00000000..4b37db82 --- /dev/null +++ b/docs/daemon-app.svg @@ -0,0 +1,1071 @@ + + + +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 + + + \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index fe147c2a..95b89b98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,8 +24,10 @@ MSAL Python supports some of them. .. raw:: html - - + + + Web app @@ -42,9 +44,10 @@ MSAL Python supports some of them. .. raw:: html - - - + + + Daemon App acquires token for themselves diff --git a/docs/scenarios-with-users.svg b/docs/scenarios-with-users.svg new file mode 100644 index 00000000..4526e479 --- /dev/null +++ b/docs/scenarios-with-users.svg @@ -0,0 +1,2782 @@ + + + +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 + + + + + + \ No newline at end of file From 2c993dde36da04a3e0608334a5f3e0ad2e474e20 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 May 2021 16:12:49 -0700 Subject: [PATCH 282/440] Grey out those scenarios MSAL does not yet support --- docs/daemon-app.svg | 3 +++ docs/scenarios-with-users.svg | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/docs/daemon-app.svg b/docs/daemon-app.svg index 4b37db82..8f1af659 100644 --- a/docs/daemon-app.svg +++ b/docs/daemon-app.svg @@ -57,6 +57,9 @@ type="text/css" id="style996"> Date: Fri, 7 May 2021 11:34:18 -0700 Subject: [PATCH 283/440] Add a thumbnail to real map --- README.md | 6 ++++++ docs/thumbnail.png | Bin 0 -> 35733 bytes 2 files changed, 6 insertions(+) create mode 100644 docs/thumbnail.png diff --git a/README.md b/README.md index f570672c..eb134674 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ 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/). diff --git a/docs/thumbnail.png b/docs/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..e1606e9152b19339693ea56925587a2f067adb97 GIT binary patch literal 35733 zcmbTdbvT}H{6Bo@j$yinVP?9!yJm*zW|;2IX=Y5vFw6`y-Oc1JhMCS=cjxci=lgt* z=lSRN&vP9I+i~NHGv4o4T~Qip@;I2}m=FZvC@RQkLJ)!~_&P&J1fSet3CDnckX)q{ zwb0SgXP4ENAcz)Hl#$f(%G#axHX*dU7C3|t`}3)e7j5}3hNn6 zEGz5w=?u=oV(dBX{^@ar>=c&Fy5@)r33IqhO>&OJ!E>Is89W!f2L}lq8n_+fQsb8a z_e$0aRyqA^{lX_BH?5%@y%P9g>gHZTPzbGq&__7(@{K|cVc5Zrbk2e9$?}5Bt@I^}N z{q)#g>B#9^tdqp6m-WRe?vt8*N&+EM_Z~;?YlG8 zfs^uqyQV;T*p3q4op#Q|k>AaIQ$HaiM}_uMXkBVTHrL9)`tmrs(Y1g?%$@Z&=)WI z9>DAZW*kU=zV>$UUMrn7ofs6)`KepeI4yql?YQbnSXg#sUQy<0R9IqBC)ax3q-qI?Shktg7^RONF zmR+po*AoKLyZ03aTqlK0#Pvi2)CIRm?DyyJ{x0sKp|@qsN@*tx<|T$L9`JN|COC_k z>wcr_WieA&?$B}W!^Qwd;b(cFjhHr7-?s}`3VAj<9f7Sz#NDWnZp#{mXVxlGMy^q{ zR=c^=_y%i>KA-h{gd&+D6V`cKT_6nY!ss?~17-JZzkl1h_{CNyp|xmhwfkOaI9ygc z`}`AL+b^wS$Yd}2@xS4l`_yojjbbaQTdZ``%k+Fxx^}Ms<*6i|_@nvi&Cp@YM~?;9 zVX&uDs<&q+2DMg`)E7@~+8!2&*-@c)llw7EhkkQuzW461*k*cJdE$UdK6UTqSlii3*Utlo6CUo@_loC_p!U`pn(24;qN@tCw==6T)B(L@_~Yf3{RqhtS-{9>p`qWy@>e&3Qq8XuSy1Vl(J?G02ea*lA$uf!CJm_gwS|XRnJtZ;( z&9(m)yil>-9wiC93Ej?3xLBCFJX*a#WweTLzhYW{SPi{eYm;#1xxpFjUOW zbYAScctOS%KNzq+S={Ppj@x(l>HTQocDcFv0Rpb*ALrSCHawCm=R^$)&uojo-P*dQ7AjRW=>Q+ zysbw)A#>8sb0akB>h@S2aiA3;C|B&}aG{QWNNuCl<>FnRaD;YkmvBYfM*_N~hilG@ zctR8NUiyas(~rla9Rl(fc}r=)PV?|8|i? z+u>&a?9D!mqt#WvWcNJo!K>P1Pk1npMGeuQb@|{$ZB*`Xzo8#&CH%Q3{6-%>cOQ1u zLbea9s$XzkTX{k_O3`RR5_`afG)~-lye&Bvf+RnCvLE|-uzPLss^j^DxITxazVot9 z%Zj%yG8rL_uerxu^IK1wG%RazA6V31T-C|CCRiGR1gWr@tz2g^3f|b6_kr{)M!#p= zmcOu0iY5+>eZMpKtr4;>rox;$mp?%!58K6mANFR3gqy@394E0+S6A4w>~0qmwqN+R znN}Y(+x6F-|9Q1)WN_cfupY9~0-4n5Baoi@o1=!33vIOO?8p0?U1yVf$vXEi&1rc{ zeGo7`I`KRX`g_+VTjIJHxHpoPu(8L76r>so|7t$EOk6B)Q69>wtbeuaXPB?JDPUf z-^L8QO}}~;tbz&QLqR*^6c=12M363PO484*-|&Su^(GzpmPdl=r0wdgs1R}J@)w`G zP~`)Y%O}uVHy_&{Z^t?>jw_M*3r?FYZa5`#z#z-QNzNT^lcIvl1ghq0X{Aq)6?*m{P=Y_AmRMpBU-2 z$}X18p3RFBv+8Q}MW>pR_(%E%zTV~dW$+X>F)?9hYpbNJOyjeeH#mJn*mp%!hLuS= zga|40Q@Hx|p~lsoq>8>SI8HY9JDLcK*kI2PT{|p6B_16A`z=pgqa}|?K%{!rt)rhf zW-1nWbkd zxW>foU6Xpn+@guq6$%Db<^|>W*{KP!@^lH;tO4{PH@9vlRaH;*Bxkj^nW62uXRQZp z`&Id+tYd4xtq^%iYM{kU<3{f{@2XtPA&O#b1W-Ew34}*nGuKnu1_g|BT zDb8*GKF6&?EfPrZuPJ`F(AiV>%$H@74T9`iP)^iJ<85^b?Ky8V)jJoD7idfWe3`CNFO&Xf;Cy7Vh2o4m~S!CAHUvR|iH z+I6!D1wrzcM%{szYQhE!$zCcox%0tm3Y`GP;q0r8O!ZtgAyf#uckbM1fonAd>Mi=u zH2qk8Z~b1h`Yw33_bTmXk)QTocM;0?CVV@N=!tlWohiy&x>THN z5H&&5=KuSL({w_nvX$5kmwP$S)%i?H^j&l0u12`a(CN&RlyUc2rG1}!#~D+zuzKnD z476eO$A@7W&miP5`K6>==OCh?| z@9Qe{2;#Wq_UTCvSPYR-o+%1-S7-BiLhp#Zy4#wM%1|>|4C-CB;PQR~^HV)C{6tE+ zY)FsD?nS!KX)=i7@jr(3 z$|D~_5IqqgO+(-W`y0F#5i!B-ajy>|BG0r2B+(1i{b@hn9p;e3J4texp9^zyJ?CUK z{hAe%uNe`Y4ngCWkHFeY7Ga1}D~XJ3eu2L#;)F->GNbZ+8zP;VWU&bh;(7{l?qC=Xme2PQX zi2wz~4po7@<8RITF7}(%-Z?QI12r0d*@_CK ze8k5zcBFhf_l#fGRD%`TbSi$jjZ~KyJHQBhgmBrbR%3M9#2nU6-PHW{b6w@-eTA|* zBGv>YFU7`b;%WV8`dvc~{dF1w_urlRw9MEOqazbUbx%~{cYj`M@m?4ocRAf0YitxM zom65st<k0c{~fsj*KZRQWGZ;mRkE|Q zXKUXf1C-20HLEJJC*2lYZ&*`Q7=2fwm`Jh5lNIo%o$0-e3c?#^51k*!fBXz%%6s+_iFi1Q zkjZ0zj*N`#YAFq%4r-^tRR-)?MJ6^x~2X3oyfmyOvp zHZ`?@x_5r=K^((t+KC)2@x%Ydqv>u%A+NfcCpv#5M^uW>)A;-MuauP3noENM!}aNe`W6;uqQdE67akf4N?~zv;KSWT zsZK@f!~G3suT|%w)-0MK3oW#mprcM zA(Ih((Su&6)>u{cEkYzBD&BXR8iuK~JF+2!U#`&M$;r<=F# z9*`-8J+J|gB92*aG4x08R6c#7II^`AYA&FH9YG^i^Y_=rL=O`zHH+J6AzcjOX2V2A zEYmL4Vv(gw9P=`@LM+kx+Z3x=V!=U5Y96LxXqY`?zu_csx5y)TSu3{IFoIezZSn~N zsXeuLxrbFFx^rdtY&K)Y+Th6@4dHh!761&=RIFrVWZd0rG)s23wq|PVQZ<-k6WTaF zi!(EC&RFN}?e4}#Mlv%q+c-E-^V({))v4f%mE*zp9t3=z{I^Vu=VBgQAajV%quIj* z%9vYSS#c9G^!@K}ayrbtKP|pGfj=jT=CzypZrpa?M-#~U>=^>oV33oQ#Y%)JpFsVN zgOq@Pp!{Y8`3P+~;`$c;+`&XJ7zG!LP}efMz7kE=(DJ@UZ0Y3c?c2A$=euJ&XNro7 z5G@fVjrUq&5|g^aV(a|6%jEChKXku;{mNXTWm`3aiG{Vgy4p|67gApihGG)sC4t-4 z&ktwc)C?u0h@{+wHm!4z+Pk9&Q`wD2R*$?ys9TVqB!hjXtvxk0g$muA?{Q}iL+z_a zl(>D|Fp-HxRf2Aspy@dClJDQ=ewa!d87+KKOq#5DTrP9<&XSUn=ug7%A%J1;Z?BNhaDPuuf+E7r z%R4!dNYK5s6z7c#>6LlFaztxtYFru&@-z;;L^6k`W@bvpY|PBfi&fI;lKRUZ*pMZm zpy>@k)#8b4VNbO(?FmuqkdP2FGc$$vgfhn7?m2|WoV33p)j$nofMIlAcyqxF*+^tx zlE=xFX&X08+4I#^R~uZhv!%uu*x}3MdfER-)hiS8*iFmLHTLll>P^dMj^zyP_zaKe zB+VS&I6pr>JakGOlx0T?I@_6!c>g{_rKmrNsVQ}0Jza&dH!WbfB%2QXV69SQ+O`Hf zXV-fiJ9~RK0ZLH69zA-58v2Bd4b%)*92^|;zIc?NjEszt5jBXE+X@>(M-G{rn|q;O z>*nDB=HTz%d3U!=<8AfZiGhKEdIJYJxn9eHIrJ605p(!nR8$mVu*pP`>ZzhY>frFe zz)!t0R^q{BCk95w>zf-S39zPR=(hIvJ%q9$6TVDkB_$VE*Fwz_Etc1%ll#YphK8)f z%FIN3{QSPQ#ps>F-rvOzHaF36aUJaKcSTE)A%SeQsu|Wq1p_t`=$FOM&CN~l=ssc? zc^U#PYYGrED=Q_Ry)M{vP0ccNl{5DJ{rzj(*|cn3emiA#B0o)uV_q8?Qqj;H92~IY zqs!1GYOz>yknZj6H8eD^e2P|^4+{(1-nNa-Uvm+JIMP)bb7R4v+u7OK*x1O+qZx|v z@cdqPsj04J{-ng!k1ct+;eKZU?isY5wzfd~-GhvRqKb8l6}(?N$DorSz?TEKQVp|= zi)w49Y-drUz8~qoboten(t9JTRj#U%nh39tIuc#S0ur zh4B>_MSuU6VXK&!7`wM`x3;&H6cm_Wyr`M6S7Dr3ciH<}69fCV_v7Pnqh!Vx7R1bZ zu^WwXB=MJKuA9GoW8veQT6dY*^T3TrN>5i5S9(E5N5{@y(b~FTs97);OD!rSARr(j zV!%wa?Isj1m6)AfHfj5s$jJ@Zbigvb`+)}ABbM}{Qs3}4H#fJoW{T)4 zDJcPyqFwr_b%8Hkg^G$Q_3UkxE~{-#V{@~JkPtb<(R;@3+S{)WPdGU_8Iwv%O9#y=v8jZ9+t%cW2j&c0;gNBLwd#+oyMXJJ ze*3l=QOK39(qO=rsNg1)t-yeZgJW5#`{Kn5&=Vyibuu-fQ%LOUR!A;HCMJuP!}nh>(SII2#X75NdZZdZ3#V~aAPWYUH|Ndu_F0wz1(S(+pQBP>i@N{`5^+;QzGl-)2 zhMEs4j^8~NX$Z~0k3Z)zM?>)=jy?^we;S59_A}}7P}(NVti2YD%eLJ1NnP~vL;R@g zk7eVL{JjgheJJiM;EtA>I8v(!D{y?+(H>bi<3||M1ydeV#Ao~U@EThzR+etx1A6#A zvNM%`tB$2d=TAZsCcU}okh*E(0xz0sQWIs-9C7SVrIq~%YM2YpJMbI}zq3Vtq9u`Q z=wMd%uDspxwh6>4pT--S!sv`R6NELfnz)QUL=hTTyMBEY5uchIAcx_Pn&6|c_^MnHig}nu{5KO_{KGf2>m3=}STi@jH7+d>5reG9 zpU8ye%mw3BhlGZNzhQok&h>cHfFD5L`gn>h^YeY?Zc{qq`XPnOfN+b7XGOF9TY=10 zXvkQ3Qb;TlJ-=}t8L~QAvo`aS`x^%9=q&s4n?f4sdBcAmUI0Y6qn{)E?CWG_n8Kwg zYDO9*hukRsuS$7tVfntu8yd_A3}PmQhITUpd%aadpBRGyBp;)54|+bKpiX>-&4(0> z&c3td=4?dWdOUXao&WNrd^*y=GmS(WsRz&8AaW5P@iAmNXP6G##mshWy9&X{TP8To|{lB5%%;7U4 zIx~mPg#t{%Csm4fEp6`ANWJ$8H85+HH!&2aKWv3qbh;LCm!BXU9wD!Np`D^h?lf9| z&2f&lo!Ug2RB+v38FJ|CFW~ra`}=7dIu)azHd@d#T+6F(1QT(Gcbp=m`M0AamKu}c zeX{Mz%wA8?F!yCWFy#W&@qCre=4OLO70C#N zTq2PpgPJBIo+zmG4~)3y=o{w52hsY4z)$>MKZZa7(XxRcu=?w56*<$gbE2YY{i5J- z$Hm2v17(@a9gbf7t%!J!B@RxZ@({FF;8z9Fa7l(#QIJP}BZ*J7cPso5khBZ;pcmmJ zn#x6pBLk&Pg-9O1t>@(ID$U95D;3nfB6Xv0D#LtTO8`?0VISatELcAGK-Nuw&X7_o zhCgKYuG@ZalI)G}A3i*JbL%E_TbEVMNYL$g?3be+ACeHiYKoc65MH6SI%hIEyJSMK z7C{H0KMvJImqUXjoGFNSf<{mw{C2cwc-S5vPJ;UnL*{AG**C6`F$jW&Q3&Zw^{^p{ zFjB72B^CZ3WV9At4{FlksN+ZokQU>R1U>^Yr2860Jhz9CKF>L{s>*wz+563#H%#g|)w6pd)ancbC}?Qb78Y{> zH+w?a`?HR1d)wQ`ZaQZ~JJrc`Rf0&t=;CA2s{qyI~q9VqE zv0o+}mwsXZ&7NGHYz&)M&E&*q&^Bj#u3s&8Mc`2h5fKm^|3{ju_4WX1Rx{4$P9Fkb zt-ikg^JiMN)SI(uWBA(I8Wt2uWAps@z$3{iHefLgPO^rGYG-dgD zg&fE7(1==1RN)__hk9&2z6|6aA~M}QJ#TJq^Z`NbS#jX~H8O&Wjg1V6fpMQfCx2g;?bi(8~+hk z%&uF*KPG69ljh;Rsh>6Lz)M^e9>idY;t4QZ7X+n~+X2I7l`AVNKsiD9TwU!3)DIO# z!Y6L0n=c&)U5h75CT(9eWCj0k7NAD&MQ8`e2Smoj4G#^eC@27yn1dU4Nx$uy6QvW%|~2vw(Yj%*|E*=D9*_-g-8r?|+TPha|>^ zl8LpZo4NhnhfB%5QPIEr4X=ZZmbf%$=J5LZIv|K(-RqU{F*3gIx5yE6l>;jn71b;j z$UPFY3W|zRadA}q4s#{1if!%eBCyE_C@JS)s*553I_7D7`SK-}MqG@I&0)IS5CsLr zfQ_GrC%NM=UrwX54$R!epJfeU=C5i<$95gTOj(r#%Cn;0FYmB>U@*sr`@6BRF(CG6 zmbigU1VAwW!1eXIK)%w}-afmIj6@g9Wl!oCpp4gL74tdW{9W=2P5BH^4*59p$E2i} zV69J2PlLVoK{%wlYz!_kJ!>k1?xH#R#kHq#d(ePW4YTMMOkE*i*P5A?xU{&q_~%c} zGHn2l8Tj~8x|YGs|5`r=`wC3$`}e4fNs69nz>8|~*wbYB1lZvY+{2NxP*c#XJF>I0 zGe1B7`*&8h)SazJ^u_7Ad#BQGOY`&s2H47S`FX(_fkhh{s(ry~W5sxd3U^_53B49v z{^v8+Y^iqLk&n!KW1ZQJTYTIB?TY9EvkbuhKl&=QsUCPeK}$7kRyV6tZZI=9cU#wX z7soU5h+m&(`-F>k?hdPvOR|MpTW9%|t-$QtsFlsyI;XvlzY_D1oLu9&mcbJMvXZVs zOhvWV8WI;3l{mP5b#+C^q^6>--bY$jTRW1*Nl!4G= zxvvy$OXsk&b{E3gn>|bcl=3bMXl}l-Ve*yt-0ZaPCXJlkL_k0QdlkrRV4)C1g~8z5 zq}Xh!6huVbt4Bbt^Vs|QT3_GyX0Hy6tOZmH7evo9?Rx)izVUU%ba7FUj}T>ND!^Hy zhlEPz4Hi+@J4RT49zt1e8H7*%7|3~y28B<~&$oCUEC8`HQGo~_AIK*-sG-`WyrkI9 z&d#g7aY=*gNVS{^nx&KMZMVn52hH4GFje$}=?IZr9j8wp5t8KwKfDUc87BR)z~vw+ zDhdh~d0ek;&9RpV7#V76F$MNt6{g@slarG%BFJOD)i+lg&g1T=H_NlrWdGgFw7tzR)E^qW45)s zt5u? ztlTQ#qXWw!(N|Vix1Q{TD(mX%zIl^g zTtMrW)VF<-mPT5r+29ISyGFnUd;96r@SM&W6Ap%_PsivrcXoDw;M;GJUtzfC;VUgI z9T3neL2FVq1IFIasC1Gp?2mn|UfKC?wX+Ca)~TJdocNGKRq9gM?BBlvl(-RH4ZFR7 zu2zBKlR3QX1O-Xup<`ldG83Jyrd;|3$jPBXlmbq)&z_b1Bi&*Q4Ba+7a`N(c_`&{ukt;Q{ zX$E~rOdOk-NXpLMV`C?Xl9HC5wB?QHIs$izmJ8$09RAH#OC3RRvVk z!S(H}EoY%@P$`T6LU;xVc?!Z7Jft;J`y}$@S*dt5-@& zNXte)%9U;rBE zvJ(+8@i=$yTb_*1pFbBB6}_D(N=&Cf?;K9**LeN9P>W^Hqp8Pi*`)y>iBq24Cr_RL z#T}?E`yYj}*|@o1z6MK;^N{lJ;$?| zPb0-CffS;zpEsNFjPc-s`#l#f4qfz1(=waSXr^n!I*)gNV7yPp0Y%{6x=LOq5S-sC1~+6 z!@=~6P*;?cJY!*DdGaK^2z*mhE&s)NBuF_g(s0j&O*Y!ce0|$bHf&w~pD)xvL z9kCgHFPJ^<)DxP59QAz$k(n<6B{T1&Y!yp8FC}L|%1KrH&cmen>OJSIp zp3_c$tW+C3nWuvf0PvDB$D)s>C5NugyPjW z5izEUVi}Y;tjD90A(#HZ^f4q&N$s4CIP%A6x5~G{Xy!x|Ajx>7(L#OiN z>*Nqieq*dOI*7nBeV4ijb-HoY;&1I$R04#S)2{;1S*Hcpy#}#(A4iGVx-l{WhDGz z3RwYm(vrYz>|Qm=%ty?zxHaZaD2S~%H;N=|+rw+(L^nT4`8nazy%v%*n8lgu)ZYnG zbAg;*9tJ*5o|#$H7}+xMB+SVTRx7O>L-4>PrfJ8zc&Qd!6^b7brMdQjwN3ZF!u#tN zq-nP^&nD#M6NevSj=V-*PkMVvbwaUc9_nW9e|5WwS8l$Ser6wx{49e22Ys}q&4d`K zcFLR@t0fJ^aihS!=5Qe3d+ld9cW`IBrBpqsOXygUP!=l_FSx5goQMXEw~~l#mLDcXT?B!$|5oL}<#xB9g|o$%i1N9t}1T%rfk! z9=zvFM5Tm?C>nVPaqDK|O7$dIu?a!^Cu6UKnIUE!=~-@tk%(Tl8#Ed}33Z|!QG6*- zk_XqXudZa}q51*))(=k#Kd!;Wia26=FSRNvgXG z@FPyk#%F-2vEF2pXwK*QD*BVMHak&|S$1SwtvN{s(ZKtea+`al4MjronL|5Fc*tw? z-dS3%gPa;DTR=LmcV1;6h$=3izjwOMg2#z+_=}crcSdnlGtkU)KW%pMJmZwcN@9wR z!Q4@2#?aK&$5MKt5*zlyeW_29kA$dZ`R3yXO{ct}HD=wcL4m^F0%`D&KYB5f=-EeJ zz_W|ga~&#m$tfseqN5jl4_Y_@8YAbm)et{UBy|H9gdKFeHY9~?Yc7Ld2&hGKH92c= z`1T2k6d@E{6v(x`Kr|8#f@QueMIb_~Hf0nleBVaZnH10&NE7PC8hIS58|dXp1pGvS zA}RBL&-aeMX*UV`;3rA{9Y6=3sxWTzz1Wwh_6`g9AS^kD=&$_IgV5ft{!7n}0squr zeCWrszJZB5TWn>#Fk~`4Bg`5-5l#ZR_nlQ_KTeE@enq5HlthWYTn*@kyO}s6LK>u) z`n2PC>%Q)W0S!R=myID-1uo~+@j7sZPHH!#J9W;=#KiLu*$9`$kMdZEC}s>RHr6wQ zwvGz;_vc@3CcYy=k_QW}_OOaA4{1xH^e5Er$i-Xk)~1bx7P4xP9}(!xm5RdD~zVh^e<9VKqY^ha{0 zIjTj3hl^g!xelhhc*RTvYJCoF#G$1Tzme)taE)iaC*G>~UUxrN+9RwPNoED~cXD4x zmtMGQOro5OFHJ5hh+JA{DyXWq03w|FLjU~!=D-&qz*KL;93=mIG0b7b5E|R12=dz_ zMSFLI1PlbfG@@{^#=R8{Vj2$9&M@f++{Z*jYa1Ir;IFK`&2uI+k(RPw9*!`7sgPHu> zeqqV&z?R5358pjS4XQLXag~ZAhJyOSnh3#A`IS>h_2BQ|g}-pBr`oV?SFZ`ZM>b|A z@|k7!>9qC|gE7egVcV;o00$$?k;TafUBGbEHYh3)9r_}I|Dz)u0m3BzT!f{93qhO| zn-TozGoy<}XA6?F_y98g2Y+GkhFCOAgjkG-{+IZ4Atp~^u|o@3OlUPQO;vf_GM>D~ zg@O=v_DJ5>e-}lPedpzeTuruoAaCOG`!mdqIzfKhb%pWh_Dm4+^Atves0mxsVxgt< zQ!GVcy0wpH>c+b9Q@`7P+eD_&Lceh2D&X_uHjV^<9KJ6h;E&Q-^EMtjDVnX^=MN(Gc)s((o=7@tW@Bs+_DkLx_fvu8kBEt+FVGR$kIK^g*n>U5uzg_ zAc*nU<-(r8_qxw!>}x@iK`fVsoZM$7DJT7(dyXviwWK5}w(Cgmi`WIRRNkm?g*&Ld z>gwtM)MO49rP{FmIOZuIZU3UcBmC%+^~2ygUE^C(vjBs7&Ysm9(+`L&y!^s{inR?RtXz^j;Sk#4h+58H)fZvETy8$Qx#aFEKLP zf|fZfhwcpJK?@wmLpLH4pxVjE$r;UjSoqp@7=l(yWbIzGbNVf$fC|ojF9HZ}cz8H44i|rF zMD5Ud@5_!)Ou(`PSGSnimYmA(4N7k{mN*cg_d)}{qyg;k_EhUT+)q>IY^zQJVV$Mo zkqL+BAK^v}^z;Sw^?xQ_8hqAZF86u#XbVlfn4S^o&(f|5a+{Cl%R!YNO>F5;R&TFP z0M4#s(OkK1uB0jlB4eYDr#+uABjd(Yx9i~A`nv!9)j!S$Puj~RHj zu?l^C{dw-MoTRbgSR~OhbTAoj*GQTC)6-KN3clMbSm3!ED+FPq7I@wS*5T}5ksa7| zD7sX83HbPaJMLbtDj`;Fno1TuU0FK;jKVG9gb!TBqlxMX52$0$OWiy>Pp)n|JEd~7 zc#rZTsINheLk9xmv`e!->0qjrlR-``VF%6_I-9Pba3MOB3 zu*HF{eM2av=|ApI2YdfXh$WGOHx zsj59XZC>ev8Fz)$F}kClqD;TtUO;$p(gY=w42kYl(R0Mf3*-|8(-hnOBS zoD>mp&wT?)$&Le7N z-m9H;*qN8U|8{<`XsoO2C5_-S6idc^dv{l1xRL4D+Q8cZK={U0hxX-{)6Np*P-^ii ztO07-q2O{$S-LTR^T57*!>SD!D!_jWExusa&6cD&9NDu=O54ZJ5vH}A& zMyQxO9yWHfgW@M8S-rn46X3j@pKcxu?{z(m;sz;3KPU*s2!5DU)6zybJP=&>*;S#XWrmIScJm4hC3IzCEHo2$-&XsP?1Wy zCfJa=x@l=?x;i>c_~=Dy**<`!)7;=1W@?uB?bS?!U^5U!n*j6!0{!jnZF{>!VBo{z zc8J)L=BrnMFhL@zO^`S;X!3ZcrZ%!WQw4g3Z$%;VH*o(z@NlSm{%N4QyIa!D)vYg{ z3Xt_2PIEaB?XhgB+V$`Sc_TZ!=Rjb_z))0EJMYX5r5HNd;>2f?g*R5lj#ID1&XvI_ zt$WF9({aH#`?3zZAUfpnj!0HS?@l<|Y>#~*vQYis$U^MTM1Pa~d_n(7G?y{OkQjRg&X zqZgc@q2cOqxeFAIEr03Y{I4J}<3+ca$!M=xNTFlTnRaqmf)G>;6`vr-b1_Cxuy2^%{ zODt#a9}^Ca^dU*DCs5F&?H|R{updG@4&P5u zhIT9k%uh~Q@)=WRj#zNPhtj*?CqTy1E`7nm@)v~6Waz+n7GY@+0j+R7TeZyRE_}J( zs;Z&1wDiJ8BueJ^k9k5=k6D<8ynG*+UOxLjhuwtgUXx+oz(a^3Twv}L&NR{E@e@Sh zG~K+<)=$vwG`}T#v-NiR;h_}-P^kFq)0!}*9qKO1+rf-i{rrjQ5o0uB!;_MnY`@r= z-ERRRi=5ot+(61=W{&JRP6@y-)h@eUPc;S_9eAN<0hkG%KBLUhe{0`mj@ba2w{10E z7#Knz-1YI}$4z5q;+V6|f&?Gt!=1~6z=zUFTj1~TILrx@Xn`eKvS8;fWGE?#AY}te z^93M8(T|_wQj2(jjHfTl-{)snH%DU_mrDYuK@+xsYQ4%QH4{yJYEourvS?=}hi8*? z2%H}<8emcGxHJHX1{8zEKY!2zgKpqD`OMo(9ijTQ_CUD;MPvX5J9r3hA2zAfy$7*G zTnfJL7o907#6H!XdD0PjdV0`jpv=(Hnp_?(Q}Ed{KYtETjZwgfh@hZ1u--Jco?3qg z!rQ6HImr0{(Gwb7Sh$~ag*|0r($Ufa$u-2F8T(d;#bEu zS-TemcG7pl~f<~jn3zDm{Ty$LqyF60lD3m%_W7ZR0WesZuC2`-h0lW&czwbHc+y^4LAQ=f1*Ibx5NRWZU%q$?!=8%JG zYisA5Jh8$%Ulz!D3w>4gQWoB`EEg4Nk3cIt*ETR{1~x!X49Sh$$5%u(4)a1y^CQbn zz|>?Q=m*we^SALPPVzXbv+c=!w2|4~%<%8%o(RU~;LD__qyW=JN?2be@FbDj1o4_AGZ21DK z6Evba9Q)l;P~1TvyD@=3shV_rX zzS#mN&Kj130)bPm9{G4qeW~EzdX|7oDKI)Q@yftpZgTRqre^+_O~8ZJDAGvGadTs1 zBOp)#{+pwD3(^9h<8NgkK;-1)Al^Oq)L-4&x)^V$6&S&9LEL$AQr1%+_#G`x@c@+r zp}tbr;LRHh2-pX}H~@k;T_Q6J%Ya>ONi3(s+qa4R7QDQ?5U@)&pApQVjARLdM)XJu z#4r0TfXjguE)~;5mpb@LQ&Wp&(y{GgcwN@Jd}52``SWMdTrb`1Q1n?0ESOYh5za!zm3KW6`j?R+YP3OO+rlzQ&9ikuT1qIVvl?ez5 zrPW!V5n)nLPyiGI>XHE){K$*cyy9IbQIrhe6e~ww;C$x0IW#OxOcDDr*_oNjG{T_S z-CjCFzhE#=0ZOp5Sc!dqoaB10|I0(ae3=MLXV_Fce!jS5V};$pR@@^g+z4}yco2Sw zt*krC*nLhMBMYy+K^dNB291@Eg9C8mf1BoV17s^Ed011OX;KGqKL}f0;JKNYn5e1Y zw#+R%Sy)N!sh0z zev9+dQwwYB)Z}CkNgo~6ph?ROqIDo+FN3`x#!Bi3&>PGTSgl~q0vo1G`{MKzn?@|_ z_X+YClaXF7SY5!qypA#euLp8%32*O{AWPl`fUpgS!hop;ua1I}0676DoI3azAOG{D zEr_k~xNT~vqbcBrMx|~v_zrb}NKOKka7uc5zXg{en~tBKpNB`SwK;vHWS{a$b6r*z z@s=?sRU31aPMTq{^x)3zUBHMq|D$mHNf_YrD^?e`dtq~IQKyi z+Kniz;)(aZp&r<*>FU6%1tJC;lUmlMs5P!6Hu~|WV>ZwQKq~m;-)|l@25{kGj=U|u zPt($iq*3lX8O|*RhI-R{PGaIR7+PacrOUYlbSNk8v>9E~nKcJ;lJ=KpY8^oI2yoeN zH~UQ>6P%;6A&bpgF$h8=8c#i!r$$Q4{VijMHcp2rK^Xgs@cGpA^gm^B+RHF!!i|^& zx)tFcYFQKSnfKjM@t{#X2A=J^x%wl31M-#F5Wn|2Uh4;u z?wt7W!#7}xjB`gX?z!p1JawW;q0XoFfi$-E6<@zz0x=duN4T6y3BW0mAc=xpC|Q$ zS6G0wBP{H@`=?T=@S~dVvs4(is zomlK2vv?KDGf1RZ7T)--xSh=)#O!1sBC81I_Dd|~J5?u_)t{Q>&_)i50S2S#c~3|HqiQ-Ro&)LMC`*!xsmp$U%@k&L$NT zi{j4?y&)J&dGpJJTCdtsCsFjtYm0%Q`ltkHC9IYG0IrbjN%&a-U72|9k?vFvH7_gb zFO)A-nY3)vaFK^1L?gCtqW4H;0^&rXAAU^;3nJLxsD0XeHbiLjfD=#wc7?Tj!~Y@d zEra4}qIT`UCAbE+Ac5czY|!BDF2O>81b0u+KyY^m!QI^<1a}DT?(XpQ^PY2leCN-h zD5{2{y6H_*VZ;SfNy({HtlxLG7SOtKd3gu>qNiarLl z(FUh~uMcH6C{%qNs(a?>S)I^+acx4g(`s#prbW`?3 z7MpNJ5A|T!qxfJT23~aRUNbY%S*&>N-0VFKOm|*AhgkhocT6-K7H;P7YeJgGdvQ$2 z&oFFQzv&-|hkLMopah2KX8PFSJU|s9?ANx{*onA7&2oZC)1l+CNKA&`OCCbUM*fQ1 zMW9SK77je=?)qGNU&XMzC+ne^SSYyE<%c(P5b4(ie&5B1Mvqa63tVYHh1tt+r`ljM zfmaj_g1K`Sd!S)^JmY2ft9jpT(mc54GHkG^*{HCz@9w;=ScyyqD+JCH zr-!tgNOUSnUavB^sGNo)8qc9z^{|=81z&#ixo*$+;$`39_$jwi{3`)N2GM%UyWB?4 z!k?%c!bp72(bx)Z(-Bmz^(5;V?Cdm3bbNDEcxpB0z^0<) z%u%ntFM>QK7s7O4Yr``tTh*v8$caI)u#sAIqE6vOG~)|v#;bVBEiED^Ti!p)NEb~i6vUiB_`T0@V-H$KE`!ZbTLo%vLy={iQwL*EW5TI2i)Vz}AnMn{Qy zNhoV%{qwvLa4(HnSe+)v#8ZTR6^ZEtAEjdXxULvUX>rCQgKp(-SE7CLhDS(XijT_Z zMMi9?hpl8Bpz79#uS|GE25jj*~Xg9BG`MulTyzc!&obgZA#UGdSf( z`_9vhJ%kc=&?YY`p%zIQkz0J-mtOBiiSl+#-Iwtz&+wBYde2)1Ev>9fXe!ys!nWKK zC&$KCcX!>c?p|-FR6^%{8M_#!S&KMF2$ zkp0+lpLGBVwSWJ#BTN?p_oTo0?(2fg1`zd3%&D*mq7PoMykTYz-?=L&DERNISZZgu z;%gQ=Zw< z!py;6D;s8Is(Iah*acxRQrbEWJ5KB19{_P5(51fY7nfHNln;fEp(|pm4YJ?U^Iuyu zap$|Ej#4d#2Q*_BhHw!Z*EqGaSnCy+lsq0*kCJT3G|{0O znR5OJO|<`=L{Mni7<4>fJz}9GaOe{Pe&vxHE5q7QLc_xT)5KN++a?)*O|_}9N6Gt) zdOdh(oqyS29s!J@%Vsl*nXFwOqD$u`auqF!h4re(^BM+h!EXrVF=9TAFFg?HKAf~u z0Gl7kmJ0|F%ASU__IKgAf15a#8_(c}~O zdtv`u%6KR!FJEbLwl*Xd4xE9OsI zfjj~SNQZH9ZaLh2z!bzIBV)#Kj(i}Kcb)z7^u>hozA?6ju?UA)N!CO&fif0zhKJ5c zU7McCZijK-IM}}PGg}8^mMl^=ez+uL?Dby$K?E5-hU?V24QZ=kxn?msB3z%ni;D{g z(?ABy2xQac>6-6@iSqnwXh)f02b{Gz<6ANya>>>dM~%3MnIdfy%X6(cT}6{QHPP>1 z|1IwRQ7(vQA}tXWiuHnCIs?;@lk<3(g6RC;5u&Bv#LI$Yt86YvoW#oJrjubiGBq7T zXOm7ZZBmp&qubm}8SJAakCz&mRf?~iW3D43b_*&b5luGW(qc(Jc(KYu^#eNF%+{pO z&2dVx@8pS(Cso2slQNE)E*{f!oO&%$vqZuL14C}8h486IL|gc!xN0*FCPklUPT^xc z4Y;;GqYxZtXDZNn3$B_f9qUWv2ja&6HYMm|69iliRJGL8tNvH4aK-rbT=PsYLd^Q?ZZOfP?;`S@QTQ- zEhW9Z%QVFWM;@iw5}RHAW(GfU=M$6s%yv76Pzde{V2EoSO%N3%y992$^KFkDpCE!lf8Q6x@l zgVW(%aHdFB-+X8#$KXg~`0!gmoK$s?{V!pyM*+H>+AsAF=mZW0Sgrc2mcbtaL!5&9 zLmjVPV~-sPfP-U<_q!_>OMS(4Rx>#>l>NpU(}DdMC(Bt}K}%9Rv6t}WF;236n@c$H z-Z!|Q7FDRo#qm7e8#wI53k|?RSvGx>)X13>gplN1f;G({UqHX>4x=P7b{dBi=Z$&!TMUT`tby8Q1qL3wnQ$3*0~3TCJ}uq&4f zW<;~VM>UXyPNkDkbUWc1@Q-p9kDCQ$3NqN`A-1Q98)3qJ+|YTG_$yM+qOXr_Nj$Ml zh+^}VD{XYxNBY1>RQqLQ=Rdz%L!A2Td}j(dyY-8Y?O)Ph@pawiU_=@bGZ^)=1>Oc3 zv*dVgg0=T(Jhyxld^xQDy~K{N2~zyOR^!=}o$pO%ADHIMr==n@TC$^SHIq|yl`We7 zBp;woI5MT$e1?8C5lo4C^SFc-l-elEDyQeM?Ib0P_?d}QSg>-!u2x53`iEsBS@Y#! zb{}r+i^~W75%ix&w(E`OEaRk+QOOMl;x>0%vfQ*Ma|s$nm90%PQ2GGa0^YjoK?Qn< zvTD84-t6q{+_jwH%iQ&3_$=?m-qA{_Qdv{>SN85P5mkrhzcq^N2#@2Vm za|JwlgSoW?>@kq4J!+;(3yYIn(V>vd#q;^kgE9Z$*ELY-Xvex859h=M_*Qn z=5JOaF@avJ+%5W_^=S#=F_9G${nyEtwz*_hT&H@$$^xakOu2fE%Ls})>6Y8BzCLjX z5n4LDl?&f_jC7v&O}#Q3S~g@v&o-Df z@&_eUD4sm;M^9Sqo4#=(pS#<>lYM-=bBs`_IWOtA9f)b%cqUfrobM%(6NIX$ji`PzQ%%=5b|%%~_wHX@BhpB8)%0Sn zmgf-?3L$4B>7kBakB@{_+32Dzx)mZ~bp7YiHKm8c zilWZxES8=}pFw!1-gPFEU83O?pZ=ob8m%7t-Cbd8o7>vi*@zC96i} z)8?RUt5r*)qP@?{qlcTvYlt?NM0xA;UG+(u_vs*y-uuoB%*?NIdfQ6&PlNk7?_W;R zTM=}_GDZw;KXqTfd?~$jVJD0aqZUdL&gXljU~crdlIm_TqTsciF3=r7EBCCVWHykc z686HKb;V*oPDg?SK{-g+ic8GsFaL>ZhY;0p9HtR0=N9We-%h|)W~8Z`H&Qx<;5iQi zc?ts{gNh0^3W`i%GcY{Ye9k->eljD;jhk3!w|7k_N>wb=eq5|aIe~Indb^aiRqke3 zM(!13_J;eYE}7{!e!kxEd=;*66{(b}VrIia#cJ7C-d_5&^<C9O;#yYO_=7-j8(x z8Mq9&aaM|DZ5zp>=#t$$tcz!zd5@u-!9I6aH}Na&oR4k2_thkHLeCWGoeeh^JIf1E z)gQM{(`oO&E1W2$w##E(-pDr7Zv5$$V0pQhVL)iDzdfu<1WHs`F>G$Sjj~CL0-9H3{e5E2uh^ciLT@*$lG%%+nt9>+dnyC{h4=W^3{1*C?fh0L^oT{ zC`@m9{?&2+>Q9OAa--?8Qhoxfe#0YP?qzs0ect|XHU09{jptr7ro50UeT^Ze^K>(v zP{Zlc4*61&u1Ejzl!xux+k^cg0iT7Rge2&H9*-!74VzpR=p6wJ_B-kyBszuiuNmD? z0T#MKRW;x3QG3i6`n6_4qr2yV@hr~`3N@UfP~8(8LffkU&YF^oPNC(7xibMG);sm% zM#DP%%3?+#U>>pm`-?4`o~#QJ&(O_-tH=}w^}t%lnA>K2t6|KBC(B1-gI zH@UC&$#NnU^3)ip=d4VuFlOX!$Kq!6_#>H0PqgGOBEDj83?syH5oKA82vK&nw{-M0 z9xh{UiZCCa=ht012FmTSt#|f{aR!g@=u9tUsVFG}^xfliyXi)oId>N#m+!rXkBipB z+QLc$h(_Bt)GMs}yLyqTi)H1CKEMj%k5Ju=lG^vvDK~lW%eV!mcbQF{ygVJK5zA<2 zA{S2kU8D|a>dGYZz3VV6y0ojDnG5&cY(w72mos{?luOzARc^nRcX@SzjlF(!q-&su z>B(vouf4x!TJ+T!#=KA!7teX{eCE8VQ^!&^2DiZCA^Yvu_mV?i~@b&eo)v=G2 z;sm<~+J?EdWR3^rmZ^>VJ=@b#1F&K=Ee5>)Z5nqlVzE<8Ds(9O_+uV~w%$ZuTdjC? zZo1{ch^vd)N2yBFQh{!URRXokdXLXdP5ZK|*ab1Nk01Q4Wd~q@^}}C!v*oUWV{OF6r!sG)+_`Q{vEx46|I$S2`zSqy`HV+*TR>}1SCm$~PtwailQYsXR@qJ^&AlRQfj%AU zP{}U*+#WL7ND}q7mX!-fOcxOqVY#q}fXK;GT0g*6#$G2@5eYIzA)s;9(=tm=X59WP zV*k7PJym%g<3Y$Ou4Oa7Jq9|9fcq~^gVeY*7)qvMv)kQo2}Lo8t(|RJW(B@IEePep z9M`}SF*IGC7v56s4w;WeSG-br}Ul$KSnBuYe zgW+~FsS;G^INO4-mDhCAcHu!hh;%Vi)t<$8&6A0x9wlY#(^hW;Y3Y=fMj^V#H^bLU zh#{!;O42M=1-v@H(5lk-t%N^rAIKRp|En*H-j7im$;dpIP)JTr075WQg~2`i_3-<& z--ksL8&de4#)JfKuPrVlzV_?`Xx#b7SOL*<3s zw2KS*d~`{OKF;!Q&lpX<&s|^Cxe4hMW%*rGqiB8IqU?iOKC!u}IZeTDrw)m`cA2h? zConLVG+YW?FKbBrz7t+soAOulp!3x!1v|j&(J^~nkZAnda-j!K`h#in)J)+l#CQlwmUbPo6#mn)`(f0TVCA5?< zIvaM0)`zquw)C^=RBgt{a3p^Lr86MKDbLT}p|#biaVBoG{lco$p{hE;`{f@btgKCC zo73XDC&#yJInilAz{9Q~@z?JN5^_QUJY2nsoyhdy7!8=G`>S(0055eOd%W)xTA@$Rt*2Wp?F`@-q!T)%PCuKc6P?i&!a6J6;)zkcbC_1_3!!!{73)hpOBBMtL>9> zPw7#`B}`$Ek>AX^Et+jP_i!GKMeGV>k`fj^ns&2m>Dp&0U$0MA9}zohmE6&^e)l~* z4r>U`ln2Fo94-%gg3KhAz=#U|C5wucu$%_wv;q_hOB@sO7 zY_u!QeO3Q{y-_6R<`-8TKR1Xo!GKr*+0A-=T(vXikhj_4KWvNZl4Zwtz5|C3k`ZL% zR=5p`b@OJNe8RA$!(IXE*cE3VL+Zx%+O=PF-} zs=Y=lFR?i(Nbnd9FSkc0^CCKp-sdhZZJAYyCOXO=aC;orG@u{U-3p}E(`gD`JjRZs z&Fo^WGtGKPhNn)fJkR1=huw9|xh@~eInEwIg+87eOk4I>s+|liSC6yT2!}XqA9_wF z>%BjM;<)$4*5BNGCUYz)A3NuUTRMU?z{BUB+#abDHCoFqo{>&ER-2>@wwrpDWIE5; z>K&1Z=N}kkxH)-TXE^sc9C!xa{XrvJRyP!~la{~3zft`sCr@&1CVaIn9FcB>na$#r zEXn_L>{&9`9Qe06h<~=`3#_Ao``|LiC;69G#e$YvB?CKT|IXG7zD2o^Rvyjw_M7m4k;AcQ3!Rk z|CW6%^j9;NHay(v*?B8v2(|YWD|B<{Kn@+_!vi8${;^T{XmauVWoNe$S&`5SqwheC z1wUs>YG1|IgN(tpY?)`v>xK4AU-JyXGnOawo#wkLpY-=9`6C#=E-IhR=EbzMj1!vq z2HYWjxxZ^>X8UqtP50HO#0uWDV^j_)fL0bBIgLU`~4p+04>)>DF=!26C9O$1iKL zNC_ji%VLUzt>&8xM8Ssb>VOF@^LC_VFShGgo)t#+3mlD}^}tAFGG3+bJDm3!AVBe-HdbEw*YqvlTUBeDT~YAuG!pqkye{sm zy9vBlmS5U=B4xB%uXZO0jnddyxH=bEcdAbYssFCq{}rm+yIRyRr^{p&_R+zB+-~;v z-Tu98s?LW>3g1nA6{29)(KW}{`<)R+(7MTh=x4O-1-&&|L3b^6-!D{pxmjCe3T%3u z?&GpKrm+~JAX~FDt%L1e)SmT#CL*B`W8qxh`q#td`8`i2wB6{g+qTCa8f6Tid+Z$5 zch?B;q^WBWnoG(1Y~~hvU+?G`8CDdytu)R5+K^&WfAM;b_m$tX{kz--ojpr;U2g2o zzkE1qgv7N5$B`}mhI#+k<~(-l=PD%7u$!K13njmJwy@^Fw0Vyd?s-=`D=ERXQjU5P zO$t?UKm8n0c>i-mKPuNn+0wdb+NE1EXys`N7sGFg$!jh3gm>%fsF1IYih&c|mo8y~ z*6WTx8mXu^ETifez7I#aPo*+olmC@-N(0ZD8S<{T7yl1au&zPUxa)Si)a56c5htx# zp*CVF_y8I|F3NWzd0&(})OFOOydQ775M=H>#u?I&%a8s@7{~4`={y`s*>eB&-wBT; zupra9)Q#6!_&2D84rw`lZ^tB;5JtfAZk^TxGQEy`fYu zirEu@1pAuHrYFVu0WUn}!9QS0;KsctlIkBOgp`UDX7yx6jwQZ%?!v>-u_uoA&STI$ z?0d=(er}+U=mss0%;%`tgbCuTt0NoPw`N)x8pDl2LHrjb12KDbWu`l{Q|jey1?7Gn zyU#wx_OA$7(zs3U$9#53`s=KCmfdQD=3Ey!_hx%9S|k@~<8LLT5TK7;kA&pDt7RxO zqi@KLy!%XT-`#3;-E-IRnYT(#)Yu{D%=)|79yD zrtjKbr`l@iN417MP0$@*lCNGH2Rp6neYeteU9B6vgSU?4-=sOX`@0P3V$aR&vz9tA zV{^1b_9cWr{MzW^3(jg2Rdn8>g|Y8W?}aF+5^Ne{!aa(S`~}KWUY2=sCy{nfB47-}MIf zSt{WAq=`%3y0~PaO*(w;*F5MI880iP`5PvA*l1 z?nYOQM8q36Ww?+5`H-B8%eA>2Wq=#-2=VjR(bRovM1NiLUGY_~D=v!CN1W45#U@UQ zNl#-n@&V^5>J9@@vGgpwwKU>BiZ>Ax*Q^P~9uajEqv(j;3Cwy`)z#*{qR7A0mob|s^`B;I!)JMC%*K49UP+BzRQ@bwAV0l5}6n&V_m zYnvbs4|=S1 z41;;dYBkD1l$XEv*P|gWE)Ll5fxzzezaO`SkYXV6KYDxdnUCx+Tm&hE4_YmCJIdbq zF+1{xcF=}CJL1+7CwcDGu4x z?FP)qgh9hJ0sj^j0Bz^yXhADiwU+)((4NbD{0_mO#D^DbF(?!Y>T|$B53>qHplxk! zKyh@W6A%zA5C6Qb_@+VA|M>@R0Dq_ER;Ko+S8$4`mVhyFKmrp7WJD8!fE%P+yZ?he zBLrZ7l+@8CTWg-$QUK@^$dC_AKb0G{qN1V#_XIFRLcq8JL3`hyKm2@rwR~;9$G>2E zU;e}{3r{$r4`=&u1bI?jcR}M7k}5N1h{hI(TGQT{wR}Z%WaI`17_A#e#02tO3kn)z z;W0W+1FnDtEpeXo?(S%9XuvM>X?1Xb{|s6zzASs2QeO*PSN5F#Vy*G^aUrLHOo{`uxWZ)A2VWu_3%@ zSDoopo824g7GfF1GF!ZQN}=4mszI)i;|TuJkMWq>5M(N#B?RaaI7GD1jvvt`!<{z0 zxuby>sSqfOjcsjD4iBN9hF${_LxlASwIKx>#KnYRM2i@QvI`+n0vAH#Ez`isVZH0R zcNq^S7RN*$-sX{PV`CE@9)5)O)3_-t!Z#w9wsIkJ?L8yPbCP7*)@G!BoF%LLixV1n z2p6ESUK~O1h-6Ffs3JCZ7Jl3k6UZT38dP_%3F_Ur88Q#x>wSu0BnvA0WlNqdHK;NN zj~c@FCUIj<&}qnaY8E5aqDpFvXq#i+?d4IM&*@W$`_I$8e{N+AT?h*&biSLb4{Ddb zmT@##7i>CI+gMwn6!bANqES`vkI)?XzV=^CF}~3l^Yo0( zKmjmAa;AoOrB0oL*ogkmDPm+lTs*e=o}#~Y-N5(6I108sdEC&KkOqJhaltm zEav~}vbvlZB!0_^$|P%yQWRQ?B#KbH;okVq5D_Xwsns&SShQ#sw>K%$Zg%jwx=AIz z&5$5tes#TbXa@{EW5qpM&}|gNbBIFv8o%>%U^@K8dcUnjHg}FToD%KGRHsU4Fm?a4 z$zO?8Kbdc4M%dz7dS6V4K9%GbSDhMjFq_;o66JX}ef9;Vx|e^FT8dO#{4F(+&t3w2 zx`<+IJtQU=^o-f>O|l3>iB<9fM}B_hF`K6D!Co8rtCdrL?Q^XxW0&=s92M*P8sRJ< zIS|XTv)cnoRQl!9>+QV#{hQI&xPqz9&k~|E6Gh-;Rf{X?LxOnMkHq(jyB_+v{*a9x zWk;9Yo}gG&tml}`_u*5}s<8)a=uAmOWO5e#;0`smF*Xn!4cC-*CuIrL==dzWS#0US zU255NhI(5V(h3iAja(C)NCtZZ1H=RDm^^&HskvUSQGcKyY+%gdbiL*=P8yk%=riEg z3m%|5d@CkPn=YILhF*e72F;xn6h|VI%P#&y7Q;lASa3_ww(nrgD(_$Me@191NJuD* zPT>Cy*zV{d)>^@F;Hh#RKX433vBG^r3aX{QvqW++OfV#wT8_p(2I%;;!fS?lz>(&D zW_$l~Kpm_h5{rO}{Ts@d3X0ACFpMbbF|W3&4;;4>CL|(v5`WXeK;sDw>&Sydjghh% zn|T-7S4UsIZJ*)emy8QIS|9As?x1zo7$pu~P^0HPiaPE`YXr~YGWbaZ@69#6;%u=G zpwn>DbwisZb8ftF@x>P-qrzRYd;4XN>{uR_w4xjtl^xMNJCUZ!N@Xb^x@~ypT9Qfk zxQy^UbvX>=QQb~!^)sKki5>hGQjKlRnXQN&$RC8Lr9ie<$PVH*aN_WCxP|#R`BqWN zs1T1=u!mX@h`k;C|n4X zlaV*z{yxY!`5{dd*)p! zj%iu*V^V6)V0t}VMPim@q^O}?{6?)mvU69-asZ8T=vrY%8};T?j(XUSZ^KJ+{L-w8 z2Kgdw!~&=vD>_!lbvf&{HbhXKv|j3NB>LHw477$EA??ee`dg5SBO5V4K5u1f2vWn} zGj-uETy6LU#uN+k_3)2K)Z2D?5Gs)hn)^o60-iM#(%g_6@fHh5OEgF8{a^0OF$p#f zkX7jm3J4T0ab)rS*h3fJ>yUCIJ0?s1l@jef`tJ1g*~iy;^t<5ApPm-SM_cdkmKHE< z(M|IY&?adQ)?NqgY5(6fVw-VjqVyYru-xh#@TGs}PuCmKARz%Bc90z1dma0N8I$bO zSVu#S7-j49HCow{dJOxf2o=hRX}yDC^&#V=YH61(qjB|GN@d~$xA>T8g1G%ap`up^ z=GK7@G-O*2$(YBbl_>~TtaP*ZY>rF~V9WvNRN^Kd!gl6+@wp5C^t;hfqVyg!wLl`j zu+5X>p`+r;FQqMZh7~3M^sRsSr;BSyHr3ysj1V67{?os6<$M&Fgoq4?(l#+bd=cua z{$0-AMNa92r35f|`h0zRKpoaN#q0sC-)f{pX7+FTWCj0L%F7JCqi@^+`&)>JMeq8% z4JEiuJ;osq7+Mcw8^6tQ^(QVRdk6omScbbZ#OyB00LDf(0gR3m)yafTS=ga-f>Z`@ zvZn#+M1fL|v@%zksEd2FnyG=q_yk%gFjbh1fkry+dddAVFB_EgtGHd&Yhy1J~Z~rvj5Y;%Uy@4r|ynQeDcH&;t%_E#0L>5dMEiU!T}$qLfI?L z8dV)D9RG>*_QnxyYD$*rQJG(hX3V_4W0^W>+sS3BCrcpoHIdzIbzu zmL_gJ6}CD*jw@-;G90UDGtct}wT4%jVo2RiP~&Oc8>El96=YuSuzbOtieAD<$6;;f z=DnX-iFV#mS<5OI5hlG5F(j|Dz*gi=lvH=gNovL?Nj04+;&8C4JJgJIT5*NQ71ng)G#BEezDH}Tnu!qjEPYO70s&@{> zH>K0flP;L=Q%6=4h8-aQ!VTp0pWTVRj%pAg_(NcB*=C*kC%+!6PON-uU(j2W>xWT3 z74Pa4D-7{d-P}#_3@1OKQ?#jo{ikawV~Yo2aHrl{rJ`*;{0r|o{+Tek7{2zCuJtd- zVs2V~9GU};;4VE(*5RSlxDLl5zl(Sx3=9_M2WtsK8V&QwxNYt<-a>kL}NaYyxFdB7@;m z3Ip-GmT`jRpyf*GM}(rEmo^mk4y_s%xw=ev&_b?pJN!g#i!Bto#xOi6j9rZ(n~L^Z zM)}M4JnS%vT-y2WGA(93QX?{a0gO5sD^33`tvuf-kpU+3zlIxK-Q7U)7t~nU znZGn=;`nsrj_62CWWL)e>1Jt+=NYfox9=xeUo!cftz=~S^MS{BJ&aX0ccR!gi<1El zHKTn19~i1Qh{Jj4$@4rdwK6iH?i|d_!@a#*RVR$`0{{k8lkWwQLcO9OFZwFjtMZ4b z2foA9K7zUoZhw?FA{E#k0Q=^7M$fkOc0J7C;@$=1>YtyVeT7E>LJ=1S$3_troD6U|A2MEHQ>$nR3z+iDE!_MBk>S}!9}*H$ zJr{PpZYeO9g9s%`HupOvK!*UHy)`f>9L>FaPw%b2(X7kBS$~Ip_P4BUsG792k>OT^ z{%mD*GDZ7c2Df!WV&W{%iWe>^>3^nCqm>b&?p~A1il65En$6er(b7{fn-Jha9tS;2 ze*XOV{PX~#BjDO4B1#5-2?=MnCGe(_eWlxWJFC96Rc+ATkK!J{rI+2R2mSes&!C!t z-cn|6Ps(F8RRF%ta4JtbfRO_0Usz-$CI&{^{%RWQc_>f1Nk8i0H@{SpXb#+g@l;6J^T(O4+x%5q_-vrKCI%H0G|<<_Lk&DK`7OAzTLiq#S~@y+NA)XZdi6E{@mlIjJN)TlAfAbv7@aC$ zgBOHspsU0Ks6UOlC6Q;*T%=+BQfCAh)-SBUaH!5$T3&9eluQ)=XEZ`TuGm{J7a~p? zhXYHQGvQPs&AKfUt236ei{;yx7@GJ4*$?Pti;9Zgv+}dCRRF^1@86rC!y;)PKt}@T zY`m=PC6#*dG;CJf%+wUPyE)F>`QQcuw=#fBNxuPJzy)CDEz&3^_qjF3#Ec{%#+h8I z6&xiaB?X9qi}Q2cJTNqXyFD~CG$n;NVuOn~JUBQwGE&KNy&84OEIU!Yag%mb^zGVj zYU(YS9MMz$7wx??KF4{lLj@%z04%MQenTwa@&&*IK^j4vdW|;%%OQuPA2bZH2WDGx za&n-hA`$ec8UMUkX(~0?oRN_Mk`}-)NKI`5ERFwZnj^>r$U%d*|8s7@TMCfEdP+bV z3+!;9oeelaEO^j6X7*sg(cXtS4rhdsvV3N0LBRyO;Frb?>sNtOO%07F&_xG~5YA6S zfTtVZZ^qMj)hw96)Cpx9(&ODgttowU;rjPX{$8PD80dA!ozo|lIR z>9c2HVW|r$1JH%ar|svf#6;tWAaM?HJ$yG z$kNa))|XYE*!~fb@hCW6big>N+1&QA6Be|%i1=B%giHn0K{oDto0~PZD?9%jJAZZ0sbsoL@2PzbrLz3k&-KBEh@<BU$`i%n?5l_ss_t+$hf$BE24vcNnFVA92jMm~)P^o?NB5X@nW z?YFYBy7YJg?!pJq0cOsg-N1n7??sH{pfBcZJlA`pth{&<9&<~WfJPZf1g1ZMbqAqw zs`p?eS_H!s=)cLZ4$vjv{?}9s7(}P7!ol44O%>vHgcW|%6`5Z!A0UCkwT!!^>$b3#8e7vYz(wa3CBGqfmT77i)nV|h z!P+KguV@p=P42;Ht(})5`L{Q-GGK}dLZP~|pE%_srxG@d{b61r=yAwdPjYme-{g~f zz8W9=@mc;eSEo#>zTK@M;R9J~*3{O<-Jy`@S6wF}j-74Dl<;UBm(8jZfEn+ze z%>Eujp3OQtP}{CB?zR!{KT_A${x>#887~WfNdVp}AV8Y8U@e!SwOjEv^4gJ}5e4>$ zof%J+T#{*VGvumc8=9uJamNowrE~gy-!B4pFiHbUH^P zRCEv)tBafp+NrB2{(Y>tc8P`O`yn@#P~+g=p7T%P?YxU7*u2Dqq2!L~?H(D+wk97B zTgUw9Z@}s;s*%KuF92l4;_}u~!W4qIiH*L<(PYkibn8_~0GgYdo6C^`%@5^@fjvAl zbpJ3U>9~~HoqQw0P!YZ(K!kehOclzmV^8ueuCL`=k6AA~Y(}@OD^4;pyqh;N`>WT4 z5QtnbE>o*)O#L56WG9@=V$SJ|oLA{4@yvSJ> z^-+qg^Q+4H=x?DsE?UCELwt=rrw?z@N24ztOdFavZ`8tHUayWdZ2~tbXznt%uuyM| zzW5nBdI4o62m^L+tx{cl92^tt$z#xf3Yc>Mxm-$0O0T*RWINqGJapd?B+LJ~0Crpe zb^rBsY6tjh09^`zw-Vyxm+bq<(x1|kMy3)D9AO!Hl|!kvU_`{TunaEi1CW%mVz?sZ z8Md>qM7v=8;)2)tef2_#hlDVG;alz(VlzTCLmi#-$mZtRw0_LVl9{(;`u|nzl4y|A zk8hcIvpJpoL@J)mvOdHZt1kW}AL6W#cpV1Z^K(~9KlmbY;y_cVa7h|uF|YvzPGQhH zhXF4+IT=uS!Ak)5EiW&pfV$a%^}*CMN4NBNGCxu>Cp&I_Vc`aViWuXoo~dl@4KAPE zf6S|9SAG%7VYaCl3Q=!lbVE_P-rRGrlFzjotU-=f7yn6s`rV}VPQJFVpwlaFKF+{& zq=;jg!t_N>*3>e`T(ic?G(nPX{T=LZQJa$#IBvT=^EJOLAW;BYxxIZQz%CUR9{?_u z24lQoIp7!72!>l67!O>t=Bfkw_V6bNAj#3i&XND#;yosA_Opj}U3MUp#8Ce|vxT%fr~g6MUPOVc~bz zX>`a1hLwY|X8w%_0|~I%2H*o=-v*rTj~}yoO=Ogmv>5O}Pb+^!bbzM-jl;ka4Yt-m z0t$!}0MM&dZU`1YpdA6tos^5GfpHitCm^6h#>5=$??1b_v9*wb$k)$ETs+d<9ygzO zd3)QoKV1Xgm9b5OUVU#b^8;Mp;&*=GQRh$h9VzFd5mUa2i>K4$K}^FgRg1TedD|n) zC3r~Z<<3dJgMC?r4Lub6v0S)8j=ipL5VR-*lQy`EpfEFS#r*Ca)cyST5&t6S6jj&M zbU#4LUf(n_>X@4ZFAzo__;%-yfabxwhf!|si@e@U0<7$IFs;GE~)3*REPmRoaH5c?hMBwZWMX zQ0Y~9LM;g76`72rHtcb zD#cu@>T%hn_(!k|{$|@+PoFN6H8jQR(x=tY$oqg`U=QE>=g%L&We5#LT}XOQ+6Mqd zdc%!J#~y=epQ?3oRl&fE?N`^*st5QN(3S3Zz7|YedjO54ASbub>gg)rxn$nd%o4W) zsj`+gwuw0;R&)4~K@%0$Xc5d?E@QjsC28t;qg`o8MWu?3?I_Mj4K}!jWiiV(F+XK< z0_ee>i4Y;59kuk;Vp8r^72#Kb;@HzSV=^T%4zLKhtH@^56N4IQdXxCgN<;s{hgL1D zsi+uxzXd8r{OLA;fSEXyoSJG~tE<5{4W1q_e1UHYL{qZ4Rnz~#90l4$r}I1S=lR0r zz1n+x*zw>TF6BNFo^@*J`B*i9(i*3mlf|P}ERw#pm{2|cyj=Bkr(j_%kBZ{zD;!M! zQgHjIh?%Z)E2Q&u3JJz9w1PzT@WV>8u!9oFS>l51a_sEpuho;I;&WF#)@6;SakHDB zvKuK*3L#GVee`&bxo_B&OA{{U(3@87=_6%}Bz zygZm4{m7;)>&%~CTHXM5c%_=w78Wl&HU?3+ac?opK(!hiGkN10Xjs`PN2V8oO zW@u$LYDCn^$xXtKhR$b>+<=EKMMz4N_|d#ZM3BIwH+Z`n-sYS+hg&-fm6)%hz%N|L zMSN-M#$*=Zy@zamz{2oPt7CqaE`#$$@p`%WR#yZ7IID9hhmewzf);i91_pr81zh+L z(9aJ97kWsk0IO7IV+e3H!NF2s%Ft&S9U8j4x~g@KR0GIw&+hPo3KO0O!0U((K)RZxO3V=Y0L$dT^V3X8yt4m(n1oPdgzvJ78xNC35kYx#K6|e}InVD$~(#q~tnGXVHkCEG2Ah5)OiKUL-7(nwt ze&&D`O&7GONlZ!EKRg_|faXq3PF7b`*nyt6WLTe=DBf)NTGogm39x?(-iZI!qvTwg zIXp6A*yaT``vw&sVGd}b8=Y$oJ|1jsIrrm5Y=ABl=AhjRfFORl0P)3w+Ov7%Teb

cPo>TpPyhIAcw=KqI-TURI zW82VJNL`+Q!4dy=W1H_0i+j1Mdtd992yk&Xj*l@w=b@?h1=4+t%BEeqx>Lx$o{#L| zUVIUYZHoWmr1E$om0^*jEGRNDjNWxSnUeY1RnzkMMJSy2a6oHGATa}$Hi8IIdXX=3 zUFZEhqM@#l6J!YDrcDxk3C2n7vx15yC;JK!@iaL>umw{FNoCyMAUNdl3d1K6*EBFC z<4oQ9C7wUa>6B~4lhq>@W>KtY4txH&@Z{ssxHO(F!5^;`Y!ls<@p4diJLT1Fx|fn? z=1(Fl&__7TR&inxvFVB>f|jrQF}$wElWgr3s{BLjb2J>}0(0Umm3D^AK{$wM z(n-<$5sVeJy@Qs?(K1#y%=l?vzwjZtB0L@~ctTfHM2rO9EY?f7S+=hhig$T0euX(= zj579UsUt-yzI3Y-R>9~Ad)VF6Rs9#D(73xHmNLYqOSQ(vj!gP3k}T~vvV%xGIf*0o ziOewU8gkE(wc1N&^yXFPZ!sh(BK@sHt2E=XGG=$fr2ywSu0P=n;>a#NLu^>zC2{q) zUhn=8c2ph<@$|@14nOn=;G-IWqXxlC^qE~8Ao#fzQEYw{oZLns{cRUjcea7%_{z%E zyIBVUiSVPxf)v~Bt&o13L&Zjl5Xq{T*d{8$gZ+9!m2RALFd=wc%#Jom&hyZ5?OhROA0q4+o&Tqens?yzLFcyUBYkHlPFg`gGfwh7`4bt zEBOP70q4y+zgGw;@V_-DsL}1?yPqH73b{R|cI~t9V*Nv!VHeKNX4t?T(1o*)q3TKw z;9VYa2Gh4}z5zpD15Im+mBN_b>4Hv*MsQ?AcOS+z&STOyh$ksS9$f3G4^Ar$d}bbH zB^rJ4ZjZ>&f)bM3YO|AvE$_4EdY43BfJjwwl$CsgUkW^XYPivQ32hs}8a;3UPgP<%XwxXN$tx?q?a>}IzkORYk_8f(rX zy$Y`mB)^@-`W$4|LTkq3eMwRU+&Rca$gD+HEwY?(rVbf3$X8>*K=NHWHT-`&cZ||d z5CmZOpV_w(2?~lJghruUwWX zwi$M2c8lDOWwvJi!EDWZ!{U=+`=89kF_Uvfr@U;q9*}h(@cE73>-w~Pvik7KSx@dJ zT%E}MqhI!A`PIzr0Z%L5zquJ7_;^D27l*F5*M@^Y5>#-g4FQ2ln|57RiepX^c1EQX z<0w%acPho5(yr&k-=s`&k|0o9lj`>Y4F`v;&Hmy5m0(V7BxPr0MFn7M2+{uw9T9!C g5V|iCRtVkq1IySZ;^K6Q@Bjb+07*qoM6N<$f@J#M@c;k- literal 0 HcmV?d00001 From 627daa9835143598356c6ba7da034adae1c17129 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 14:36:05 -0700 Subject: [PATCH 284/440] An explicit short timeout is needed because the test case is run outside of Azure VM --- tests/test_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d68bfe2f..7649503b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -353,6 +353,7 @@ def get_lab_app( 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. @@ -379,7 +380,7 @@ def get_lab_app( client_id, client_credential=client_secret, authority=authority, - http_client=MinimalHttpClient(), + http_client=MinimalHttpClient(timeout=timeout), **kwargs) def get_session(lab_app, scopes): # BTW, this infrastructure tests the confidential client flow @@ -754,6 +755,7 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", region=self.region, # Explicitly use this region, regardless of detection + timeout=2, # Short timeout makes this test case responsive on non-VM ) scopes = ["https://graph.microsoft.com/.default"] result = self.app.acquire_token_for_client( From 4a143b60ec70932029e89bfc32b256a6c7ff2ed1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 14:42:01 -0700 Subject: [PATCH 285/440] Assert regional request reaches regional endpoint --- setup.py | 1 + tests/test_e2e.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 22c3bf80..79bdda3e 100644 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ # We will go with "<4" for now, which is also what our another dependency, # pyjwt, currently use. + "mock;python_version<'3.3'", ] ) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 7649503b..8556b313 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -4,11 +4,15 @@ 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 +from tests.http_client import MinimalHttpClient, MinimalResponse from msal.oauth2cli import AuthCodeReceiver logger = logging.getLogger(__name__) @@ -758,13 +762,21 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): 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) + mocked_method.assert_called_with( + 'https://westus.login.microsoft.com/{}/oauth2/v2.0/token'.format( + 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) - # TODO: Test the request hit the regional endpoint self.region? class RegionalEndpointViaEnvVarTestCase(WorldWideRegionalEndpointTestCase): From 3ed3ce0c5bdbc8d20efc5ca2a4aad4407932f0a1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 14:48:15 -0700 Subject: [PATCH 286/440] Tolerate authority validation error if opting in a region --- msal/application.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index ff15d1d3..d64736f1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -282,10 +282,24 @@ def __init__( self.app_name = app_name self.app_version = app_version - self.authority = Authority( + + # 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) - # Here the self.authority is not the same type as authority in input + except ValueError: # Those are explicit authority validation errors + raise + except Exception: # The rest are typically connection errors + if validate_authority and 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 = region self._region_detected = None From dcdeb5ab8722e9701d7bb3f4af01ba25e4b466fc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 15:34:45 -0700 Subject: [PATCH 287/440] Addresses https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629667644 --- msal/region.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/msal/region.py b/msal/region.py index 67033c58..83a16d5d 100644 --- a/msal/region.py +++ b/msal/region.py @@ -1,5 +1,4 @@ import os -import json import logging logger = logging.getLogger(__name__) @@ -17,7 +16,17 @@ def _detect_region_of_azure_function(): def _detect_region_of_azure_vm(http_client): - url = "http://169.254.169.254/metadata/instance?api-version=2021-01-01" + 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 {}. " "You may want to use a shorter timeout on your http_client".format(url)) @@ -29,5 +38,5 @@ def _detect_region_of_azure_vm(http_client): "IMDS {} unavailable. Perhaps not running in Azure VM?".format(url)) return None else: - return json.loads(resp.text)["compute"]["location"] + return resp.text.strip() From f4292d8436a143fb232270c4648185a36eb984d4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 16:06:14 -0700 Subject: [PATCH 288/440] Addresses https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328 --- msal/application.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index d64736f1..bf26ef81 100644 --- a/msal/application.py +++ b/msal/application.py @@ -328,7 +328,12 @@ def _get_regional_authority(self, central_authority): if region_to_use: logger.info('Region to be used: {}'.format(repr(region_to_use))) regional_host = ("{}.login.microsoft.com".format(region_to_use) - if central_authority.instance == "login.microsoftonline.com" + 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), From 474cae17477d2c70c515e0f77d2eb37e34a5e4a2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 16:32:28 -0700 Subject: [PATCH 289/440] Rename region to azure_region based on https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629383289 --- msal/application.py | 6 +++--- tests/test_e2e.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/msal/application.py b/msal/application.py index bf26ef81..f9cfa85d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -119,7 +119,7 @@ def __init__( verify=True, proxies=None, timeout=None, client_claims=None, app_name=None, app_version=None, client_capabilities=None, - region=None, # Note: We choose to add this param in this base class, + 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. @@ -229,7 +229,7 @@ def __init__( `claims parameter Date: Mon, 10 May 2021 17:01:57 -0700 Subject: [PATCH 290/440] Addresses https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629384542 --- msal/application.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index f9cfa85d..ae4ee13d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -249,8 +249,11 @@ def __init__( MSAL's default value is None, which means region behavior remains off. If enabled, some of the MSAL traffic would remain inside that region. - App developer can opt in to regional endpoint, - by provide a region name, such as "westus", "eastus2". + 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 VM can use a special keyword ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. From b4fce337f714aa130e391aeefb117eb56cf4bf52 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 17:20:49 -0700 Subject: [PATCH 291/440] Addresses https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629565528 --- msal/application.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index ae4ee13d..29435d46 100644 --- a/msal/application.py +++ b/msal/application.py @@ -247,7 +247,8 @@ def __init__( 4. An app which already onboard to the region's allow-list. MSAL's default value is None, which means region behavior remains off. - If enabled, some of the MSAL traffic would remain inside that region. + If enabled, the `acquire_token_for_client()`-relevant traffic + would remain inside that region. App developer can opt in to a regional endpoint, by provide its region name, such as "westus", "eastus2". From 7d16f5b5b5009628ac4fa4680901579e806998aa Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 17:32:08 -0700 Subject: [PATCH 292/440] Addresses https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r628486479 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 29435d46..43239fe4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -256,7 +256,7 @@ def __init__( ``az account list-locations -o table``, or referencing to `this doc `_. - An app running inside Azure VM can use a special keyword + An app running inside Azure Functions and Azure VM can use a special keyword ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. (Attempting this on a non-VM could hang indefinitely. Make sure you configure a short timeout, From b57cd7225bd08a1074943da0076f6e08b93983f7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 May 2021 15:24:45 -0700 Subject: [PATCH 293/440] A demo on how to use prompt=create --- sample/interactive_sample.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 6aafd160..260ff347 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -63,6 +63,8 @@ # by using the preferred_username claim from returned id_token_claims. #prompt="select_account", # Optional. It forces to show account selector page + #prompt="create", # Optional. It brings user to a self-service sign-up flow. + # Prerequisite: https://docs.microsoft.com/en-us/azure/active-directory/external-identities/self-service-sign-up-user-flow ) if "access_token" in result: From e3cb43e0fc1be7173db0bbd21d0c8b60ddc0aa47 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 13 May 2021 18:10:15 -0700 Subject: [PATCH 294/440] Use the newly introduced prompt constants --- msal/__init__.py | 1 + sample/interactive_sample.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/msal/__init__.py b/msal/__init__.py index 824363fb..4e2faaed 100644 --- a/msal/__init__.py +++ b/msal/__init__.py @@ -31,5 +31,6 @@ ConfidentialClientApplication, PublicClientApplication, ) +from .oauth2cli.oidc import Prompt from .token_cache import TokenCache, SerializableTokenCache diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 260ff347..b5f3950f 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -62,8 +62,8 @@ # after already extracting the username from an earlier sign-in # by using the preferred_username claim from returned id_token_claims. - #prompt="select_account", # Optional. It forces to show account selector page - #prompt="create", # Optional. It brings user to a self-service sign-up flow. + #prompt=msal.Prompt.SELECT_ACCOUNT, # Or simply "select_account". Optional. It forces to show account selector page + #prompt=msal.Prompt.CREATE, # Or simply "create". Optional. It brings user to a self-service sign-up flow. # Prerequisite: https://docs.microsoft.com/en-us/azure/active-directory/external-identities/self-service-sign-up-user-flow ) From 87a345ff99f77005d7be3585d626948cc67cde5d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 10 May 2021 18:14:49 -0700 Subject: [PATCH 295/440] Change ATTEMPT_REGION_DISCOVERY to boolean, to facilitate the runtime opt-in/opt-out --- msal/application.py | 25 +++++++++++++++++++------ msal/region.py | 7 ++++++- tests/test_e2e.py | 6 ++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/msal/application.py b/msal/application.py index 43239fe4..a8457e46 100644 --- a/msal/application.py +++ b/msal/application.py @@ -109,7 +109,7 @@ class ClientApplication(object): GET_ACCOUNTS_ID = "902" REMOVE_ACCOUNT_ID = "903" - ATTEMPT_REGION_DISCOVERY = "TryAutoDetect" + ATTEMPT_REGION_DISCOVERY = True # "TryAutoDetect" def __init__( self, client_id, @@ -242,7 +242,8 @@ def __init__( (However MSAL Python does not support managed identity, so this one does not apply.) - 3. An app authenticated by Subject Name/Issuer (SNI). + 3. An app authenticated by + `Subject Name/Issuer (SNI) `_. 4. An app which already onboard to the region's allow-list. @@ -258,10 +259,22 @@ def __init__( An app running inside Azure Functions and Azure VM can use a special keyword ``ClientApplication.ATTEMPT_REGION_DISCOVERY`` to auto-detect region. - (Attempting this on a non-VM could hang indefinitely. - Make sure you configure a short timeout, - or provide a custom http_client which has a short timeout. - That way, the latency would be under your control.) + + .. 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. """ self.client_id = client_id self.client_credential = client_credential diff --git a/msal/region.py b/msal/region.py index 83a16d5d..6ad84c45 100644 --- a/msal/region.py +++ b/msal/region.py @@ -29,7 +29,12 @@ def _detect_region_of_azure_vm(http_client): ) logger.info( "Connecting to IMDS {}. " - "You may want to use a shorter timeout on your http_client".format(url)) + "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"}) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index e64d7d30..93d321e4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -750,15 +750,13 @@ class WorldWideRegionalEndpointTestCase(LabBasedTestCase): def test_acquire_token_for_client_should_hit_regional_endpoint(self): """This is the only grant supported by regional endpoint, for now""" self.app = get_lab_app( # Regional endpoint only supports confidential client - ## Would fail the OIDC Discovery - #authority="https://westus2.login.microsoftonline.com/" - # "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID + ## 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=self.region, # Explicitly use this region, regardless of detection + timeout=2, # Short timeout makes this test case responsive on non-VM ) scopes = ["https://graph.microsoft.com/.default"] From 2e23186d7e8aebca4bc9aff3acb6782faa9881ac Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 16 Jun 2020 22:19:02 -0700 Subject: [PATCH 296/440] Proof-of-Concept: Removing hardcoded offline_access --- tests/test_e2e.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 93d321e4..627f70d6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -86,10 +86,15 @@ def assertCacheWorksForUser( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") - # 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 + 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( From f73e616b5ed8e1d77d2c148b912f6149ecec7bd9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 24 Jun 2020 10:54:30 -0700 Subject: [PATCH 297/440] Remove hardcoded profile scope --- msal/application.py | 15 ++++++++++++++- msal/token_cache.py | 8 ++++++-- tests/test_e2e.py | 15 ++++++++------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/msal/application.py b/msal/application.py index a8457e46..ee12271a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -757,6 +757,13 @@ def get_accounts(self, username=None): lowercase_username = username.lower() accounts = [a for a in accounts if a["username"].lower() == lowercase_username] + if not accounts: + logger.warning(( + "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, @@ -1251,7 +1258,13 @@ def _acquire_token_by_username_password_federated( 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, **kwargs) + 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 diff --git a/msal/token_cache.py b/msal/token_cache.py index b0731278..d11d5c91 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -108,11 +108,13 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info if sensitive in dictionary: dictionary[sensitive] = "********" wipe(event.get("data", {}), - ("password", "client_secret", "refresh_token", "assertion", "username")) + ("password", "client_secret", "refresh_token", "assertion")) try: return self.__add(event, now=now) finally: - wipe(event.get("response", {}), ("access_token", "refresh_token")) + wipe(event.get("response", {}), ( # These claims were useful during __add() + "access_token", "refresh_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. @@ -184,6 +186,8 @@ def __add(self, event, now=None): "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" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 627f70d6..b7280e8f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -132,10 +132,9 @@ def _test_username_password(self, result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) - # self.assertEqual(None, result.get("error"), str(result)) self.assertCacheWorksForUser( result, scope, - username=username if ".b2clogin.com" not in authority else None, + username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C ) def _test_device_flow( @@ -554,11 +553,13 @@ def _test_acquire_token_obo(self, config_pca, config_cca): # 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") - self.assertEqual(config_cca["username"], username) - if username: # A precaution so that we won't use other user's token - account = cca.get_accounts(username=username)[0] - result = cca.acquire_token_silent(config_cca["scope"], account) - self.assertEqual(cca_result["access_token"], result["access_token"]) + 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, From f59ed821cca05a2dbebf8c2e2b43dfc2075855d8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 12 May 2021 22:55:30 -0700 Subject: [PATCH 298/440] Add exclude_scopes:Optional[list] --- msal/application.py | 93 +++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/msal/application.py b/msal/application.py index ee12271a..7d7af50f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -27,33 +27,6 @@ logger = logging.getLogger(__name__) -def decorate_scope( - scopes, client_id, - 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 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 - return list(decorated) - def extract_certs(public_cert_content): # Parses raw public certificate file contents and returns a list of strings @@ -123,6 +96,7 @@ def __init__( # 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, ): """Create an instance of application. @@ -275,11 +249,28 @@ def __init__( 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. + :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"]``. """ 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: @@ -326,6 +317,34 @@ def __init__( 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( @@ -505,7 +524,7 @@ def initiate_auth_code_flow( flow = client.initiate_auth_code_flow( redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), @@ -587,7 +606,7 @@ def get_authorization_request_url( response_type=response_type, redirect_uri=redirect_uri, state=state, login_hint=login_hint, prompt=prompt, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), nonce=nonce, domain_hint=domain_hint, claims=_merge_claims_challenge_and_capabilities( @@ -650,7 +669,7 @@ def authorize(): # A controller in a web app response =_clean_up(self.client.obtain_token_by_auth_code_flow( auth_code_flow, auth_response, - scope=decorate_scope(scopes, self.client_id) if scopes else None, + scope=self._decorate_scope(scopes) if scopes else None, headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), @@ -722,7 +741,7 @@ def acquire_token_by_authorization_code( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) response = _clean_up(self.client.obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), data=dict( kwargs.pop("data", {}), @@ -1020,7 +1039,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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, decorate_scope(scopes, self.client_id), account, + 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): @@ -1166,7 +1185,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): refresh_reason=msal.telemetry.FORCE_REFRESH) response = _clean_up(self.client.obtain_token_by_refresh_token( refresh_token, - scope=decorate_scope(scopes, self.client_id), + scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), rt_getter=lambda rt: rt, on_updating_rt=False, @@ -1197,7 +1216,7 @@ def acquire_token_by_username_password( - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - scopes = decorate_scope(scopes, self.client_id) + scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID) headers = telemetry_context.generate_headers() @@ -1343,7 +1362,7 @@ def acquire_token_interactive( telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) response = _clean_up(self.client.obtain_token_by_browser( - scope=decorate_scope(scopes, self.client_id) if scopes else None, + 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 @@ -1374,7 +1393,7 @@ def initiate_device_flow(self, scopes=None, **kwargs): """ correlation_id = msal.telemetry._get_new_correlation_id() flow = self.client.initiate_device_flow( - scope=decorate_scope(scopes or [], self.client_id), + scope=self._decorate_scope(scopes or []), headers={msal.telemetry.CLIENT_REQUEST_ID: correlation_id}, **kwargs) flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id @@ -1485,7 +1504,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No 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=decorate_scope(scopes, self.client_id), # Decoration is used for: + 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) From de2284de83daff06ea32511c5220078b45503357 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 19 May 2021 09:47:57 -0700 Subject: [PATCH 299/440] Fix NameError in ClientApplication exception handler --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 7d7af50f..c4cbbcd2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -299,7 +299,7 @@ def __init__( except ValueError: # Those are explicit authority validation errors raise except Exception: # The rest are typically connection errors - if validate_authority and region: + 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( From 414e0ce7af73c5b201e3ddf3112a89fbd653b903 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 19 May 2021 10:00:56 -0700 Subject: [PATCH 300/440] Also redact id token from now on --- msal/token_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index d11d5c91..5b31b299 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -113,7 +113,7 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info return self.__add(event, now=now) finally: wipe(event.get("response", {}), ( # These claims were useful during __add() - "access_token", "refresh_token", "username")) + "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, From e9168d42b05b8598b711952f81e2690e1f3b3bad Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 19 May 2021 10:27:24 -0700 Subject: [PATCH 301/440] MSAL Python 1.12.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index c4cbbcd2..cb2ee87a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -23,7 +23,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.11.0" +__version__ = "1.12.0" logger = logging.getLogger(__name__) From 81381b9397e69986c50519c56004108f8634c06f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 25 May 2021 18:33:51 -0700 Subject: [PATCH 302/440] Should have not overreacted to account-not-found --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index ee12271a..057f6eb3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -758,7 +758,7 @@ def get_accounts(self, username=None): accounts = [a for a in accounts if a["username"].lower() == lowercase_username] if not accounts: - logger.warning(( + 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. " From a433b71bf0d33982495a6df423722ce7bfc70878 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 3 Jun 2021 17:23:07 -0700 Subject: [PATCH 303/440] Add download stats for recent MSAL versions --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eb134674..eb9e6248 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Microsoft Authentication Library (MSAL) for Python -| `dev` branch | Reference Docs | # of Downloads -|---------------|---------------|----------------| - [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) | [![Download monthly](https://pepy.tech/badge/msal/month)](https://pypistats.org/packages/msal) +| `dev` branch | Reference Docs | # of Downloads per different platforms | # of Downloads per recent MSAL versions | +|---------------|---------------|----------------------------------------|-----------------------------------------| + [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![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 From 9082dc194f5bfb38c2f395376cc048aa85370a0b Mon Sep 17 00:00:00 2001 From: David Freedman Date: Mon, 7 Jun 2021 19:47:17 +0100 Subject: [PATCH 304/440] Add support for acquiring a token with a pre-signed JWT (#271) * Add support for acquiring a token with a client provided, pre-signed JWT. Useful for where the signing takes place externally for example using Azure Key Vault (AKV). AKV sample included. * Changes to parameter name for #271 * Address comment in #271 "No need to repeat this statement twice in both if and else" * merge rayluo / microsoft-authentication-library-for-python:patch1 * Update msal/application.py Co-authored-by: Ray Luo * Update tests/test_e2e.py Co-authored-by: Ray Luo * Resolve merge conflict Co-authored-by: David Freedman Co-authored-by: Ray Luo --- msal/application.py | 54 +++++++++------ sample/vault_jwt_sample.py | 134 +++++++++++++++++++++++++++++++++++++ tests/test_client.py | 10 ++- tests/test_e2e.py | 11 ++- 4 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 sample/vault_jwt_sample.py diff --git a/msal/application.py b/msal/application.py index 097c0b00..036b63dc 100644 --- a/msal/application.py +++ b/msal/application.py @@ -131,6 +131,14 @@ def __init__( "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 completly 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 @@ -391,28 +399,32 @@ def _build_client(self, client_credential, authority): 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) - 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 {}) + 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 = { diff --git a/sample/vault_jwt_sample.py b/sample/vault_jwt_sample.py new file mode 100644 index 00000000..131732e1 --- /dev/null +++ b/sample/vault_jwt_sample.py @@ -0,0 +1,134 @@ +""" +The configuration file would look like this (sans those // comments): +{ + "tenant": "your_tenant_name", + // Your target tenant, DNS name + "client_id": "your_client_id", + // Target app ID in Azure AD + "scope": ["https://graph.microsoft.com/.default"], + // Specific to Client Credentials Grant i.e. acquire_token_for_client(), + // you don't specify, in the code, the individual scopes you want to access. + // Instead, you statically declared them when registering your application. + // Therefore the only possible scope is "resource/.default" + // (here "https://graph.microsoft.com/.default") + // which means "the static permissions defined in the application". + "vault_tenant": "your_vault_tenant_name", + // Your Vault tenant may be different to your target tenant + // If that's not the case, you can set this to the same + // as "tenant" + "vault_clientid": "your_vault_client_id", + // Client ID of your vault app in your vault tenant + "vault_clientsecret": "your_vault_client_secret", + // Secret for your vault app + "vault_url": "your_vault_url", + // URL of your vault app + "cert": "your_cert_name", + // Name of your certificate in your vault + "cert_thumb": "your_cert_thumbprint", + // Thumbprint of your certificate + "endpoint": "https://graph.microsoft.com/v1.0/users" + // For this resource to work, you need to visit Application Permissions + // page in portal, declare scope User.Read.All, which needs admin consent + // https://github.com/Azure-Samples/ms-identity-python-daemon/blob/master/2-Call-MsGraph-WithCertificate/README.md +} +You can then run this sample with a JSON configuration file: + python sample.py parameters.json +""" + +import base64 +import json +import logging +import requests +import sys +import time +import uuid +import msal + +# Optional logging +# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script +# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs + +from azure.keyvault import KeyVaultClient, KeyVaultAuthentication +from azure.common.credentials import ServicePrincipalCredentials +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes + +config = json.load(open(sys.argv[1])) + +def auth_vault_callback(server, resource, scope): + credentials = ServicePrincipalCredentials( + client_id=config['vault_clientid'], + secret=config['vault_clientsecret'], + tenant=config['vault_tenant'], + resource='https://vault.azure.net' + ) + token = credentials.token + return token['token_type'], token['access_token'] + + +def make_vault_jwt(): + + header = { + 'alg': 'RS256', + 'typ': 'JWT', + 'x5t': base64.b64encode( + config['cert_thumb'].decode('hex')) + } + header_b64 = base64.b64encode(json.dumps(header).encode('utf-8')) + + body = { + 'aud': "https://login.microsoftonline.com/%s/oauth2/token" % + config['tenant'], + 'exp': (int(time.time()) + 600), + 'iss': config['client_id'], + 'jti': str(uuid.uuid4()), + 'nbf': int(time.time()), + 'sub': config['client_id'] + } + body_b64 = base64.b64encode(json.dumps(body).encode('utf-8')) + + full_b64 = b'.'.join([header_b64, body_b64]) + + client = KeyVaultClient(KeyVaultAuthentication(auth_vault_callback)) + chosen_hash = hashes.SHA256() + hasher = hashes.Hash(chosen_hash, default_backend()) + hasher.update(full_b64) + digest = hasher.finalize() + signed_digest = client.sign(config['vault_url'], + config['cert'], '', 'RS256', + digest).result + + full_token = b'.'.join([full_b64, base64.b64encode(signed_digest)]) + + return full_token + + +authority = "https://login.microsoftonline.com/%s" % config['tenant'] + +app = msal.ConfidentialClientApplication( + config['client_id'], authority=authority, client_credential={"client_assertion": make_vault_jwt()} + ) + +# The pattern to acquire a token looks like this. +result = None + +# Firstly, looks up a token from cache +# Since we are looking for token for the current app, NOT for an end user, +# notice we give account parameter as None. +result = app.acquire_token_silent(config["scope"], account=None) + +if not result: + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") + result = app.acquire_token_for_client(scopes=config["scope"]) + +if "access_token" in result: + # Calling graph using the access token + graph_data = requests.get( # Use token to call downstream service + config["endpoint"], + headers={'Authorization': 'Bearer ' + result['access_token']},).json() + print("Graph API call result: %s" % json.dumps(graph_data, indent=2)) +else: + print(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug + diff --git a/tests/test_client.py b/tests/test_client.py index 39fc9145..b180c6b8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -85,7 +85,15 @@ class TestClient(Oauth2TestCase): @classmethod def setUpClass(cls): http_client = MinimalHttpClient() - if "client_certificate" in CONFIG: + 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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b7280e8f..e5c0c129 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -344,6 +344,16 @@ def test_subject_name_issuer_authentication(self): 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 @@ -882,4 +892,3 @@ def test_acquire_token_silent_with_an_empty_cache_should_return_none(self): if __name__ == "__main__": unittest.main() - From 448623c7d34bcbaa2d12d28aede3103acb0ac324 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 21 Jun 2021 16:53:17 -0700 Subject: [PATCH 305/440] Skip unnecessary and repetitive region detection --- msal/application.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index a8457e46..bf1d150a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -358,7 +358,7 @@ def _get_regional_authority(self, central_authority): validate_authority=False) # The central_authority has already been validated return None - def _build_client(self, client_credential, authority): + def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None default_headers = { @@ -417,7 +417,8 @@ def _build_client(self, client_credential, authority): on_updating_rt=self.token_cache.update_rt) regional_client = None - if client_credential: # Currently regional endpoint only serves some CCA flows + 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 = { @@ -1076,9 +1077,13 @@ def _acquire_token_silent_by_finding_specific_refresh_token( # target=scopes, # AAD RTs are scope-independent query=query) logger.debug("Found %d RTs matching %s", len(matches), query) - client, _ = self._build_client(self.client_credential, authority) 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) From 90633599f644a3a7b2ca92cce2ad7eb719dd4be3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 20 Jul 2021 14:31:54 -0700 Subject: [PATCH 306/440] MSAL Python 1.13.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 7e592d04..35bd91a1 100644 --- a/msal/application.py +++ b/msal/application.py @@ -23,7 +23,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.12.0" +__version__ = "1.13.0" logger = logging.getLogger(__name__) From 3b371e10498b93d4338b5c115b14915f4dc888b2 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 15 Jul 2021 15:31:37 -0700 Subject: [PATCH 307/440] Use dot-env for convenient local testing --- .gitignore | 2 ++ requirements.txt | 1 + tests/test_e2e.py | 13 +++++++++++++ 3 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index e776c10e..18dae08c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ docs/_build/ # The test configuration file(s) could potentially contain credentials tests/config.json + +.env \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9c558e35..d078afb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ . +python-dotenv diff --git a/tests/test_e2e.py b/tests/test_e2e.py index e5c0c129..869dc0c1 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,3 +1,16 @@ +"""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 From dad2d5c88491a27e3d5fe75d0687a50c2ad051ea Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 29 Jul 2021 18:22:55 -0700 Subject: [PATCH 308/440] It was skipped and recently broken. Now it works. --- tests/test_e2e.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 869dc0c1..67832252 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -703,14 +703,17 @@ def test_acquire_token_obo(self): self._test_acquire_token_obo(config_pca, config_cca) def test_acquire_token_by_client_secret(self): - # This is copied from ArlingtonCloudTestCase's same test case - try: - config = self.get_lab_user(usertype="cloud", publicClient="no") - except requests.exceptions.HTTPError: - self.skipTest("The lab does not provide confidential app for testing") - else: - config["client_secret"] = self.get_lab_user_secret("TBD") # TODO - self._test_acquire_token_by_client_secret(**config) + # 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"), From 26252ce1d210a8e1663add346d3cb97cd32682ee Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Jul 2021 01:11:02 -0700 Subject: [PATCH 309/440] Switch to github action badge --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index eb9e6248..e193f257 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # Microsoft Authentication Library (MSAL) for Python - | `dev` branch | Reference Docs | # of Downloads per different platforms | # of Downloads per recent MSAL versions | |---------------|---------------|----------------------------------------|-----------------------------------------| - [![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![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) + [![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 From 6047d7d41ce83a986d03f3fcb8ef9dc7ce5e5dc7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 3 Aug 2021 23:02:20 -0700 Subject: [PATCH 310/440] Survive issue 387 --- msal/token_cache.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index 5b31b299..2ed819d7 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -122,6 +122,19 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info 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 @@ -138,14 +151,7 @@ def __add(self, event, now=None): id_token_claims = ( decode_id_token(id_token, client_id=event["client_id"]) if id_token else {}) - client_info = {} - home_account_id = None # It would remain None in client_credentials flow - if "client_info" in response: # We asked for it, and AAD will provide it - client_info = json.loads(decode_part(response["client_info"])) - home_account_id = "{uid}.{utid}".format(**client_info) - elif id_token_claims: # This would be an end user on ADFS-direct scenario - client_info["uid"] = id_token_claims.get("sub") - home_account_id = id_token_claims.get("sub") + 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 From 1457de471f6553295a95ac2aad55d8ac2e663b57 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 9 Aug 2021 17:47:52 -0700 Subject: [PATCH 311/440] Change regional endpoint doname name --- msal/application.py | 2 +- tests/test_e2e.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 35bd91a1..fa76f941 100644 --- a/msal/application.py +++ b/msal/application.py @@ -371,7 +371,7 @@ def _get_regional_authority(self, central_authority): self._region_configured if is_region_specified else self._region_detected) if region_to_use: logger.info('Region to be used: {}'.format(repr(region_to_use))) - regional_host = ("{}.login.microsoft.com".format(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", diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 67832252..0d07cafa 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -798,7 +798,7 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): status_code=400, text='{"error": "mock"}')) as mocked_method: self.app.acquire_token_for_client(scopes) mocked_method.assert_called_with( - 'https://westus.login.microsoft.com/{}/oauth2/v2.0/token'.format( + 'https://westus.r.login.microsoftonline.com/{}/oauth2/v2.0/token'.format( self.app.authority.tenant), params=ANY, data=ANY, headers=ANY) result = self.app.acquire_token_for_client( From e4adfefe2393d76c7953fff6cb6f1e471d87d2c1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 9 Aug 2021 18:34:21 -0700 Subject: [PATCH 312/440] Regional endpoint test cases do not rely on env var REGION_NAME --- tests/test_e2e.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 0d07cafa..20afaa0a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -137,10 +137,15 @@ def assertCacheWorksForApp(self, result_from_wire, scope): 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=MinimalHttpClient(), + 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) @@ -541,11 +546,16 @@ def _test_acquire_token_by_auth_code_flow( error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) - def _test_acquire_token_obo(self, config_pca, config_cca): + 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"], - http_client=MinimalHttpClient()) + azure_region=azure_region, + http_client=http_client or MinimalHttpClient()) pca_result = pca.acquire_token_by_username_password( config_pca["username"], config_pca["password"], @@ -560,7 +570,8 @@ def _test_acquire_token_obo(self, config_pca, config_cca): config_cca["client_id"], client_credential=config_cca["client_secret"], authority=config_cca["authority"], - http_client=MinimalHttpClient(), + 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. @@ -778,6 +789,7 @@ def test_b2c_acquire_token_by_ropc(self): class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" + timeout = 2 # Short timeout makes this test case responsive on non-VM def test_acquire_token_for_client_should_hit_regional_endpoint(self): """This is the only grant supported by regional endpoint, for now""" @@ -808,15 +820,6 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scopes) - -class RegionalEndpointViaEnvVarTestCase(WorldWideRegionalEndpointTestCase): - - def setUp(self): - os.environ["REGION_NAME"] = "eastus" - - def tearDown(self): - 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") @@ -842,7 +845,11 @@ def test_cca_obo_should_bypass_regional_endpoint_therefore_still_work(self): 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) + 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"), @@ -859,7 +866,10 @@ def test_cca_ropc_should_bypass_regional_endpoint_therefore_still_work(self): 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) + self._test_username_password( + azure_region=self.region, + http_client=MinimalHttpClient(timeout=self.timeout), + **config) class ArlingtonCloudTestCase(LabBasedTestCase): From 6d81ff14e8bc478d292b7f36a44dca8f4806db50 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 9 Aug 2021 18:36:06 -0700 Subject: [PATCH 313/440] REGION_NAME has no unified format across services --- msal/region.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/msal/region.py b/msal/region.py index 6ad84c45..dacd49d7 100644 --- a/msal/region.py +++ b/msal/region.py @@ -5,14 +5,9 @@ def _detect_region(http_client=None): - region = _detect_region_of_azure_function() # It is cheap, so we do it always - if http_client and not region: + if http_client: return _detect_region_of_azure_vm(http_client) # It could hang for minutes - return region - - -def _detect_region_of_azure_function(): - return os.environ.get("REGION_NAME") + return None def _detect_region_of_azure_vm(http_client): From 711f3cd07180ec91b1534197327b3e12f2b3b9c5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 30 Jul 2021 16:21:17 -0700 Subject: [PATCH 314/440] Prefer Edge when running on Linux First attempt was by using BROWSER env var Switch to less intrusive register(browser_name...) Only perform webbrowse.register() when necessary Explain design decisions based on PR review Q&A --- msal/application.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/msal/application.py b/msal/application.py index fa76f941..4f68fc20 100644 --- a/msal/application.py +++ b/msal/application.py @@ -9,6 +9,7 @@ import sys import warnings from threading import Lock +import os import requests @@ -69,6 +70,46 @@ def _clean_up(result): 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 + 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 ClientApplication(object): ACQUIRE_TOKEN_SILENT_ID = "84" @@ -1393,6 +1434,7 @@ def acquire_token_interactive( }, data=dict(kwargs.pop("data", {}), claims=claims), headers=telemetry_context.generate_headers(), + browser_name=_preferred_browser(), **kwargs)) telemetry_context.update_telemetry(response) return response From 1b386cb3d512ada554d40131493e1e1acd2af17d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 6 May 2020 18:39:13 -0700 Subject: [PATCH 315/440] An individual cache, after 3+ prototypes WIP: Not runnable scratch Doodle Gear towards a different direction WIP Rename to ControllableCache Use new ControllableCache Proof-of-Concept Support runtime storage Rename to IndividualCache Documentation draft Enable http_decorate Precise KeyError message --- msal/individual_cache.py | 286 +++++++++++++++++++++++++++++++++ tests/test_individual_cache.py | 93 +++++++++++ 2 files changed, 379 insertions(+) create mode 100644 msal/individual_cache.py create mode 100644 tests/test_individual_cache.py diff --git a/msal/individual_cache.py b/msal/individual_cache.py new file mode 100644 index 00000000..4c6fa00e --- /dev/null +++ b/msal/individual_cache.py @@ -0,0 +1,286 @@ +from 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 + diff --git a/tests/test_individual_cache.py b/tests/test_individual_cache.py new file mode 100644 index 00000000..38bd572d --- /dev/null +++ b/tests/test_individual_cache.py @@ -0,0 +1,93 @@ +from 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)) + From b3098e8ab6562ee47203a56c444ced7b578243f4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 7 Jul 2021 23:09:35 -0700 Subject: [PATCH 316/440] ThrottledHttpClient Decorate the http_client for http_cache behavior Wrap http_client instead of decorate it Rename to throttled_http_client.py Refactor and change default retry-after delay to 60 seconds ThrottledHttpClient test case contains params --- msal/throttled_http_client.py | 134 ++++++++++++++++++++++ tests/test_throttled_http_client.py | 165 ++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 msal/throttled_http_client.py create mode 100644 tests/test_throttled_http_client.py diff --git a/msal/throttled_http_client.py b/msal/throttled_http_client.py new file mode 100644 index 00000000..24bf5137 --- /dev/null +++ b/msal/throttled_http_client.py @@ -0,0 +1,134 @@ +from 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, data=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 cause its retry is expected and regulated + isinstance(data, dict) and 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) + + # The following 2 methods have been defined dynamically by __init__() + #def post(self, *args, **kwargs): pass + #def get(self, *args, **kwargs): pass + diff --git a/tests/test_throttled_http_client.py b/tests/test_throttled_http_client.py new file mode 100644 index 00000000..9a65efc1 --- /dev/null +++ b/tests/test_throttled_http_client.py @@ -0,0 +1,165 @@ +# 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() + + +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.get( + "https://example.com", data={"grant_type": DEVICE_AUTH_GRANT}) + resp2 = http_client.get( + "https://example.com", data={"grant_type": DEVICE_AUTH_GRANT}) + logger.debug(http_cache) + self.assertNotEqual(resp1.text, resp2.text, "Should return a new response") + From e538b028ecdd3130236f219d85b012b8b43438d1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 7 Jul 2021 23:42:41 -0700 Subject: [PATCH 317/440] Use throttled_http_client --- msal/application.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/msal/application.py b/msal/application.py index 4f68fc20..d6fb131a 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,6 +21,7 @@ from .token_cache import TokenCache import msal.telemetry from .region import _detect_region +from .throttled_http_client import ThrottledHttpClient # The __init__.py will import this. Not the other way around. @@ -336,6 +337,10 @@ def __init__( 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, + {} # Hard code an in-memory cache, for now + ) self.app_name = app_name self.app_version = app_version @@ -433,6 +438,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False "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 From c8f37589e874faf18409b362bdde1a37b33e6106 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 17 Aug 2021 18:35:48 -0700 Subject: [PATCH 318/440] Convert staticmethod to module-wide public method --- tests/test_application.py | 23 ++++++------ tests/test_token_cache.py | 78 ++++++++++++++++++++------------------- 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index ea98b16f..5a92c8d4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -5,7 +5,7 @@ import msal from msal.application import _merge_claims_challenge_and_capabilities from tests import unittest -from tests.test_token_cache import TokenCacheTestCase +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 @@ -66,7 +66,7 @@ def setUp(self): "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), - "response": TokenCacheTestCase.build_response( + "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 @@ -125,9 +125,9 @@ def setUp(self): "client_id": self.preexisting_family_app_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), - "response": TokenCacheTestCase.build_response( + "response": build_response( access_token="Siblings won't share AT. test_remove_account() will.", - id_token=TokenCacheTestCase.build_id_token(aud=self.preexisting_family_app_id), + 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 @@ -153,8 +153,7 @@ def test_known_orphan_app_will_skip_frt_and_only_use_its_own_rt(self): "client_id": app.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), - "response": TokenCacheTestCase.build_response( - uid=self.uid, utid=self.utid, refresh_token=rt), + "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): @@ -168,7 +167,7 @@ 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(TokenCacheTestCase.build_response( + 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) @@ -246,7 +245,7 @@ def setUp(self): "scope": self.scopes, "token_endpoint": "https://{}/common/oauth2/v2.0/token".format( self.environment_in_cache), - "response": TokenCacheTestCase.build_response( + "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 @@ -342,7 +341,7 @@ def populate_cache(self, access_token="at", expires_in=86400, refresh_in=43200): "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), - "response": TokenCacheTestCase.build_response( + "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), @@ -424,7 +423,7 @@ def populate_cache(self, cache, access_token="at"): "client_id": self.client_id, "scope": self.scopes, "token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url), - "response": TokenCacheTestCase.build_response( + "response": build_response( access_token=access_token, uid=self.uid, utid=self.utid, refresh_token=self.rt), }) @@ -571,9 +570,9 @@ def test_get_accounts(self): "scope": scopes, "token_endpoint": "https://{}/{}/oauth2/v2.0/token".format(environment, tenant), - "response": TokenCacheTestCase.build_response( + "response": build_response( uid=uid, utid=utid, access_token="at", refresh_token="rt", - id_token=TokenCacheTestCase.build_id_token( + id_token=build_id_token( aud=client_id, sub="oid_in_" + tenant, preferred_username=username, diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 3cce0c82..2fe486c2 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -11,52 +11,56 @@ logging.basicConfig(level=logging.DEBUG) -class TokenCacheTestCase(unittest.TestCase): +# 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') + - @staticmethod - 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 - @staticmethod - 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 = self.build_id_token( + 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": self.build_response( + "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"), @@ -125,12 +129,12 @@ def testAddByAad(self): def testAddByAdfs(self): client_id = "my_client_id" - id_token = self.build_id_token(aud=client_id, upn="JaneDoe@example.com") + 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": self.build_response( + "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"), @@ -204,7 +208,7 @@ def test_key_id_is_also_recorded(self): "client_id": "my_client_id", "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", - "response": self.build_response( + "response": build_response( uid="uid", utid="utid", # client_info expires_in=3600, access_token="an access token", refresh_token="a refresh token"), @@ -219,7 +223,7 @@ def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep "client_id": "my_client_id", "scope": ["s2", "s1", "s3"], # Not in particular order "token_endpoint": "https://login.example.com/contoso/v2/token", - "response": self.build_response( + "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"), From 7054e4e6ef38369ffb875980dd211e8240cf0229 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 25 Aug 2021 17:59:18 -0700 Subject: [PATCH 319/440] Enable ThrottledHttpClient.close() --- msal/throttled_http_client.py | 6 ++++++ tests/test_throttled_http_client.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/msal/throttled_http_client.py b/msal/throttled_http_client.py index 24bf5137..d30eda5e 100644 --- a/msal/throttled_http_client.py +++ b/msal/throttled_http_client.py @@ -128,7 +128,13 @@ def __init__(self, http_client, http_cache): 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() + diff --git a/tests/test_throttled_http_client.py b/tests/test_throttled_http_client.py index 9a65efc1..75408330 100644 --- a/tests/test_throttled_http_client.py +++ b/tests/test_throttled_http_client.py @@ -35,6 +35,13 @@ def post(self, url, params=None, data=None, headers=None, **kwargs): 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): @@ -163,3 +170,10 @@ def test_device_flow_retry_should_not_be_cached(self): 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() + From 0dd87e4a79cead1664e46f838bc5403b278c9022 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 25 Aug 2021 15:25:34 -0700 Subject: [PATCH 320/440] MSAL Python 1.14 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index d6fb131a..c7a3471f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.13.0" +__version__ = "1.14.0" logger = logging.getLogger(__name__) From 2483463c9e2af2a3b6702b751daae916ea475628 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 4 Aug 2021 11:00:16 -0700 Subject: [PATCH 321/440] Implementing CCS Routing info X-AnchorMailbox's value is case-insensitive Both auth code flow and interactive flow switch to client_info Add upn:username for ROPC per recent discussion --- msal/application.py | 43 ++++++++++++++++++++++---- tests/test_ccs.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_e2e.py | 2 +- 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 tests/test_ccs.py diff --git a/msal/application.py b/msal/application.py index c7a3471f..d7c4c147 100644 --- a/msal/application.py +++ b/msal/application.py @@ -14,6 +14,7 @@ import requests from .oauth2cli import Client, JwtAssertionCreator +from .oauth2cli.oidc import decode_part from .authority import Authority from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request @@ -111,6 +112,34 @@ def _preferred_browser(): return None +class _ClientWithCcsRoutingInfo(Client): + + def initiate_auth_code_flow(self, **kwargs): + 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" @@ -481,7 +510,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False authority.device_authorization_endpoint or urljoin(authority.token_endpoint, "devicecode"), } - central_client = Client( + central_client = _ClientWithCcsRoutingInfo( central_configuration, self.client_id, http_client=self.http_client, @@ -506,7 +535,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False regional_authority.device_authorization_endpoint or urljoin(regional_authority.token_endpoint, "devicecode"), } - regional_client = Client( + regional_client = _ClientWithCcsRoutingInfo( regional_configuration, self.client_id, http_client=self.http_client, @@ -577,7 +606,7 @@ def initiate_auth_code_flow( 3. and then relay this dict and subsequent auth response to :func:`~acquire_token_by_auth_code_flow()`. """ - client = Client( + client = _ClientWithCcsRoutingInfo( {"authorization_endpoint": self.authority.authorization_endpoint}, self.client_id, http_client=self.http_client) @@ -654,7 +683,7 @@ def get_authorization_request_url( self.http_client ) if authority else self.authority - client = Client( + client = _ClientWithCcsRoutingInfo( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id, http_client=self.http_client) @@ -1178,6 +1207,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token( 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, @@ -1189,7 +1222,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token( skip_account_creation=True, # To honor a concurrent remove_account() )), scope=scopes, - headers=telemetry_context.generate_headers(), + headers=headers, data=dict( kwargs.pop("data", {}), claims=_merge_claims_challenge_and_capabilities( diff --git a/tests/test_ccs.py b/tests/test_ccs.py new file mode 100644 index 00000000..8b801773 --- /dev/null +++ b/tests/test_ccs.py @@ -0,0 +1,73 @@ +import 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") + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 20afaa0a..2defecd6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -516,8 +516,8 @@ def _test_acquire_token_by_auth_code_flow( 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(), - scopes=scope, ) auth_response = receiver.get_auth_response( auth_uri=flow["auth_uri"], state=flow["state"], timeout=60, From 0c4019591f0fe3805035f2b53ae1b579fd6ae504 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 1 Sep 2021 14:16:48 +0800 Subject: [PATCH 322/440] Fix typos --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index d7c4c147..2d06e1d5 100644 --- a/msal/application.py +++ b/msal/application.py @@ -203,7 +203,7 @@ def __init__( you may try use only the leaf cert (in PEM/str format) instead. *Added in version 1.13.0*: - It can also be a completly pre-signed assertion that you've assembled yourself. + 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:: { From 1c05a4de29e072e29f4d938e3c7a5e070d77024d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 2 Aug 2021 17:30:48 -0700 Subject: [PATCH 323/440] Add max_age support https://stackoverflow.microsoft.com/questions/267209#comment248855_267214 WIP: Relax exp check with reasonable skew. And add auth_time check. Previous commit incorrectly always enables max_age --- msal/application.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/msal/application.py b/msal/application.py index 2d06e1d5..d2b39af4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -558,6 +558,7 @@ def initiate_auth_code_flow( login_hint=None, # type: Optional[str] domain_hint=None, # type: Optional[str] claims_challenge=None, + max_age=None, ): """Initiate an auth code flow. @@ -588,6 +589,17 @@ def initiate_auth_code_flow( `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:: @@ -617,6 +629,7 @@ def initiate_auth_code_flow( 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 @@ -1403,6 +1416,7 @@ def acquire_token_interactive( timeout=None, port=None, extra_scopes_to_consent=None, + max_age=None, **kwargs): """Acquire token interactively i.e. via a local browser. @@ -1448,6 +1462,17 @@ def acquire_token_interactive( 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. @@ -1466,6 +1491,7 @@ def acquire_token_interactive( port=port or 0), prompt=prompt, login_hint=login_hint, + max_age=max_age, timeout=timeout, auth_params={ "claims": claims, From a015d55ce5dbd39d703909cc1d04a4713e14861c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 28 Sep 2021 20:24:41 -0700 Subject: [PATCH 324/440] Map login_hint into CCS routing info, for now --- msal/application.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/application.py b/msal/application.py index d7c4c147..b1035707 100644 --- a/msal/application.py +++ b/msal/application.py @@ -115,6 +115,8 @@ def _preferred_browser(): 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) @@ -1614,6 +1616,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No 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 From 250c6d798a19d5921fd36dbe979d0f6056155129 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 29 Sep 2021 17:07:00 -0700 Subject: [PATCH 325/440] Bypass device authorization flow, for real --- msal/throttled_http_client.py | 7 ++++--- tests/test_throttled_http_client.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/msal/throttled_http_client.py b/msal/throttled_http_client.py index d30eda5e..378cd3df 100644 --- a/msal/throttled_http_client.py +++ b/msal/throttled_http_client.py @@ -100,7 +100,7 @@ def __init__(self, http_client, http_cache): # acquire_token_silent(..., force_refresh=True) pattern. str(kwargs.get("params")) + str(kwargs.get("data"))), ), - expires_in=lambda result=None, data=None, **ignored: + 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) @@ -108,8 +108,9 @@ def __init__(self, http_client, http_cache): # (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 cause its retry is expected and regulated - isinstance(data, dict) and data.get("grant_type") == DEVICE_AUTH_GRANT + 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()) diff --git a/tests/test_throttled_http_client.py b/tests/test_throttled_http_client.py index 75408330..93820505 100644 --- a/tests/test_throttled_http_client.py +++ b/tests/test_throttled_http_client.py @@ -163,9 +163,9 @@ def test_device_flow_retry_should_not_be_cached(self): http_cache = {} http_client = DummyHttpClient(status_code=400) http_client = ThrottledHttpClient(http_client, http_cache) - resp1 = http_client.get( + resp1 = http_client.post( "https://example.com", data={"grant_type": DEVICE_AUTH_GRANT}) - resp2 = http_client.get( + 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") From 0d1417a8e92ac6fb0cedd2675c39ba8ff4e33cb3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 30 Sep 2021 08:43:52 -0700 Subject: [PATCH 326/440] MSAL Python 1.15.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index a32e5f5b..686cc95d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -26,7 +26,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.14.0" +__version__ = "1.15.0" logger = logging.getLogger(__name__) From 9658d19956a24d4a4882fb4605c452aa5f4baac8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 30 Sep 2021 11:56:52 -0700 Subject: [PATCH 327/440] Bumping cryptography upper bound to X+3 --- setup.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 79bdda3e..c5d89186 100644 --- a/setup.py +++ b/setup.py @@ -75,14 +75,13 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', - 'cryptography>=0.6,<4', + 'cryptography>=0.6,<38', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # - # Not sure what should be used as an upper bound here - # https://github.com/pyca/cryptography/issues/5532 - # We will go with "<4" for now, which is also what our another dependency, - # pyjwt, currently use. + # 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'", ] From 544e3e5b3e9bb004ee73cac460317aadd60f8359 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 7 Jul 2021 23:21:25 -0700 Subject: [PATCH 328/440] Expose http_cache parameter, with its docs and recipe. --- msal/application.py | 57 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 686cc95d..3651d216 100644 --- a/msal/application.py +++ b/msal/application.py @@ -170,6 +170,7 @@ def __init__( # 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. @@ -336,6 +337,60 @@ def __init__( 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 ( + IOError, # A non-exist http cache file + pickle.UnpicklingError, # A corrupted http cache file + EOFError, # An empty http cache file + AttributeError, ImportError, IndexError, # Other corruption + ): + 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 @@ -370,7 +425,7 @@ def __init__( self.http_client.mount("https://", a) self.http_client = ThrottledHttpClient( self.http_client, - {} # Hard code an in-memory cache, for now + {} if http_cache is None else http_cache, # Default to an in-memory dict ) self.app_name = app_name From 2f1b48b0679707c72c30fe614e225d5d098d6ddc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 27 Oct 2021 00:50:40 -0700 Subject: [PATCH 329/440] Adjusts the path --- tests/test_authcode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authcode.py b/tests/test_authcode.py index 385100fd..c7e7565f 100644 --- a/tests/test_authcode.py +++ b/tests/test_authcode.py @@ -2,7 +2,7 @@ import socket import sys -from oauth2cli.authcode import AuthCodeReceiver +from msal.oauth2cli.authcode import AuthCodeReceiver class TestAuthCodeReceiver(unittest.TestCase): From 2cbc14e4dcb81600c1ca61247c81aca0d187cb34 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 27 Oct 2021 10:13:30 -0700 Subject: [PATCH 330/440] tests/authcode.py has long been obsolete --- tests/authcode.py | 77 ----------------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 tests/authcode.py diff --git a/tests/authcode.py b/tests/authcode.py deleted file mode 100644 index 4973d4c2..00000000 --- a/tests/authcode.py +++ /dev/null @@ -1,77 +0,0 @@ -import argparse -import webbrowser -import logging - -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 - - -def build_auth_url(authority, client_id): - # Lucky that redirect_uri can be omitted, so it works for any app - return "{a}/oauth2/authorize?response_type=code&client_id={c}".format( - a=authority, c=client_id) - -class AuthCodeReceiver(BaseHTTPRequestHandler): - """A one-stop solution to acquire an authorization code. - - This helper starts a web server as redirect_uri, waiting for auth code. - It also opens a browser window to guide a human tester to manually login. - After obtaining an auth code, the web server will be shut down. - """ # Note: This docstring is also used by this script's command line help. - @classmethod - def acquire(cls, auth_endpoint, redirect_port): - """Usage: ac = AuthCodeReceiver.acquire('http://.../authorize', 8088)""" - webbrowser.open( - "http://localhost:{p}?{q}".format(p=redirect_port, q=urlencode({ - "text": """Open this link to acquire auth code. - If you prefer, you may want to use incognito window.""", - "link": auth_endpoint,}))) - logging.warn( - """Listening on http://localhost:{}, and a browser window is opened - for you on THIS machine, and waiting for human interaction. - This function call will hang until an auth code is received. - """.format(redirect_port)) - server = HTTPServer(("", int(redirect_port)), cls) - server.authcode = None - while not server.authcode: # https://docs.python.org/2/library/basehttpserver.html#more-examples - server.handle_request() - return server.authcode - - 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'): # Then store it into the server instance - ac = self.server.authcode = qs['code'][0] - self.send_full_response('Authcode:\n{}'.format(ac)) - # NOTE: Don't do self.server.shutdown() here. It'll halt the server. - elif qs.get('text') and qs.get('link'): # Then display a landing page - self.send_full_response('{text}'.format( - link=qs['link'][0], text=qs['text'][0])) - else: - self.send_full_response("This web service serves your redirect_uri") - - 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) - -if __name__ == '__main__': - p = parser = argparse.ArgumentParser( - description=AuthCodeReceiver.__doc__ - + "The auth code received will be dumped into stdout.") - p.add_argument('client_id', help="The client_id of your web service app") - p.add_argument('redirect_port', type=int, help="The port in redirect_uri") - p.add_argument( - "--authority", default="https://login.microsoftonline.com/common") - args = parser.parse_args() - print(AuthCodeReceiver.acquire( - build_auth_url(args.authority, args.client_id), args.redirect_port)) - From 7d1b03fc5be0f67d5ffaf839be3599edee2e3279 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 6 Oct 2021 11:20:41 -0700 Subject: [PATCH 331/440] Re-enable REGION env var detection --- msal/region.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/msal/region.py b/msal/region.py index dacd49d7..c540dc71 100644 --- a/msal/region.py +++ b/msal/region.py @@ -5,6 +5,9 @@ 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 From f80b5c15e3d84632af947a0a6d919e02b5efbeea Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 21 Oct 2021 19:35:45 -0700 Subject: [PATCH 332/440] Change Regional Endpoint to require opt-in --- msal/application.py | 21 ++++++++++++--------- tests/test_e2e.py | 34 +++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/msal/application.py b/msal/application.py index 3651d216..c10935dd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -286,7 +286,8 @@ def __init__( which you will later provide via one of the acquire-token request. :param str azure_region: - Added since MSAL Python 1.12.0. + AAD provides regional endpoints for apps to opt in + to keep their traffic remain inside that region. As of 2021 May, regional service is only available for ``acquire_token_for_client()`` sent by any of the following scenarios:: @@ -303,9 +304,7 @@ def __init__( 4. An app which already onboard to the region's allow-list. - MSAL's default value is None, which means region behavior remains off. - If enabled, the `acquire_token_for_client()`-relevant traffic - would remain inside that region. + 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". @@ -331,6 +330,9 @@ def __init__( 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. @@ -492,17 +494,18 @@ def _build_telemetry_context( correlation_id=correlation_id, refresh_reason=refresh_reason) def _get_regional_authority(self, central_authority): - is_region_specified = bool(self._region_configured - and self._region_configured != self.ATTEMPT_REGION_DISCOVERY) self._region_detected = self._region_detected or _detect_region( self.http_client if self._region_configured is not None else None) - if (is_region_specified and self._region_configured != self._region_detected): + 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_configured if is_region_specified else self._region_detected) + 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: - logger.info('Region to be used: {}'.format(repr(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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 2defecd6..a23806ed 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -791,7 +791,7 @@ class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" timeout = 2 # Short timeout makes this test case responsive on non-VM - def test_acquire_token_for_client_should_hit_regional_endpoint(self): + 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 @@ -799,8 +799,7 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): #authority="https://westus.login.microsoft.com/microsoft.onmicrosoft.com", #validate_authority=False, authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", - azure_region=self.region, # Explicitly use this region, regardless of detection - + azure_region=configured_region, timeout=2, # Short timeout makes this test case responsive on non-VM ) scopes = ["https://graph.microsoft.com/.default"] @@ -809,9 +808,11 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): 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://westus.r.login.microsoftonline.com/{}/oauth2/v2.0/token'.format( - self.app.authority.tenant), + '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, @@ -820,6 +821,29 @@ def test_acquire_token_for_client_should_hit_regional_endpoint(self): 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") From 091a07d32b5d4df71e429ed645eb7b9a929c81bc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 29 Oct 2021 12:58:50 -0700 Subject: [PATCH 333/440] MSAL Python 1.16.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index c10935dd..13015d09 100644 --- a/msal/application.py +++ b/msal/application.py @@ -26,7 +26,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.15.0" +__version__ = "1.16.0" logger = logging.getLogger(__name__) From a81c3559b6cc74c86a3a7a36e6e755c2aa40d2f6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 2 Nov 2021 13:12:51 -0700 Subject: [PATCH 334/440] Bubble up refresh exception when we cannot recover --- msal/application.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 13015d09..05b77fc3 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1207,7 +1207,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( if (result and "error" not in result) or (not access_token_from_cache): return result except: # The exact HTTP exception is transportation-layer dependent - logger.exception("Refresh token failed") # Potential AAD outage? + # 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( From 5271e1fb81b193f989217e32a1a9f4fe59bd8a95 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 4 Nov 2021 23:56:11 -0700 Subject: [PATCH 335/440] Lazy initialization makes partial test faster --- tests/test_application.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 5a92c8d4..518042a8 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -331,7 +331,10 @@ class TestApplicationForRefreshInBehaviors(unittest.TestCase): account = {"home_account_id": "{}.{}".format(uid, utid)} rt = "this is a rt" client_id = "my_app" - app = ClientApplication(client_id, authority=authority_url) + + @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() @@ -485,8 +488,10 @@ def mock_post(url, headers=None, *args, **kwargs): class TestTelemetryOnClientApplication(unittest.TestCase): - app = ClientApplication( - "client_id", authority="https://login.microsoftonline.com/common") + @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" @@ -509,8 +514,10 @@ def mock_post(url, headers=None, *args, **kwargs): class TestTelemetryOnPublicClientApplication(unittest.TestCase): - app = PublicClientApplication( - "client_id", authority="https://login.microsoftonline.com/common") + @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. @@ -534,9 +541,11 @@ def mock_post(url, headers=None, *args, **kwargs): class TestTelemetryOnConfidentialClientApplication(unittest.TestCase): - app = ConfidentialClientApplication( - "client_id", client_credential="secret", - authority="https://login.microsoftonline.com/common") + @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" From 7e2172a12d1a68600f1b4efb99cb63981919f9e0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 1 Nov 2021 21:10:08 -0700 Subject: [PATCH 336/440] Add cloud instances string constants Reduce duplicated magic strings Add test cases Writing docs --- msal/application.py | 19 +++++++++++++++++-- msal/authority.py | 26 ++++++++++++++++++++++++-- tests/http_client.py | 3 +++ tests/test_authority.py | 35 ++++++++++++++++++++++++++++------- 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/msal/application.py b/msal/application.py index 05b77fc3..04ad5fd4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -231,8 +231,23 @@ def __init__( :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 + ``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: diff --git a/msal/authority.py b/msal/authority.py index 0656011f..14a6ad1a 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -14,12 +14,19 @@ 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, - 'login.chinacloudapi.cn', + AZURE_CHINA, 'login-us.microsoftonline.com', - 'login.microsoftonline.us', + AZURE_US_GOVERNMENT, 'login.microsoftonline.de', ]) WELL_KNOWN_B2C_HOSTS = [ @@ -30,6 +37,19 @@ ] +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. @@ -53,6 +73,8 @@ def __init__(self, authority_url, http_client, validate_authority=True): 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 ( diff --git a/tests/http_client.py b/tests/http_client.py index a5587b70..5adbbded 100644 --- a/tests/http_client.py +++ b/tests/http_client.py @@ -20,6 +20,9 @@ def get(self, url, params=None, headers=None, **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): diff --git a/tests/test_authority.py b/tests/test_authority.py index cd6db785..9fdc83c5 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -8,16 +8,37 @@ @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: - a = Authority( - 'https://{}/common'.format(host), MinimalHttpClient()) - self.assertEqual( - a.authorization_endpoint, - 'https://%s/common/oauth2/v2.0/authorize' % host) - self.assertEqual( - a.token_endpoint, 'https://%s/common/oauth2/v2.0/token' % host) + 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): From 8659b272cef6d7861890c1dc4c0f0cc857f8957d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 9 Nov 2021 18:51:17 -0800 Subject: [PATCH 337/440] Fine tune http_cache usage pattern --- msal/application.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 04ad5fd4..5d1406af 100644 --- a/msal/application.py +++ b/msal/application.py @@ -377,10 +377,8 @@ def __init__( with open(http_cache_filename, "rb") as f: persisted_http_cache = pickle.load(f) # Take a snapshot except ( - IOError, # A non-exist http cache file + FileNotFoundError, # Or IOError in Python 2 pickle.UnpicklingError, # A corrupted http cache file - EOFError, # An empty http cache file - AttributeError, ImportError, IndexError, # Other corruption ): persisted_http_cache = {} # Recover by starting afresh atexit.register(lambda: pickle.dump( From aee20e3e522fd4418fbceb0fdc2b4f91e7e7b120 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 25 Nov 2021 11:40:19 -0800 Subject: [PATCH 338/440] Descriptive error messages for troubleshooting --- msal/authority.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 14a6ad1a..145ce3d9 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -109,7 +109,8 @@ def __init__(self, authority_url, http_client, validate_authority=True): raise ValueError( "Unable to get authority configuration for {}. " "Authority would typically be in a format of " - "https://login.microsoftonline.com/your_tenant_name".format( + "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'] @@ -170,7 +171,10 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs): 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") + 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 From 88575d2bf095bb1a8e38d940427531359e0ec48a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 10 Dec 2021 21:07:07 -0800 Subject: [PATCH 339/440] Document redirect_uri requirement inside sample --- sample/interactive_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index b5f3950f..530892e5 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -53,7 +53,7 @@ if not result: logging.info("No suitable token exists in cache. Let's get a new one from AAD.") print("A local browser window will be open for you to sign in. CTRL+C to cancel.") - result = app.acquire_token_interactive( + result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost config["scope"], login_hint=config.get("username"), # Optional. # If you know the username ahead of time, this parameter can pre-fill From 149360be9a60b9f3a03febe1997b4013caf1fd29 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 18 Jan 2022 21:27:42 -0800 Subject: [PATCH 340/440] Lazy load dependencies --- msal/application.py | 4 ++-- msal/authority.py | 18 +++++++----------- msal/oauth2cli/assertion.py | 3 +-- msal/oauth2cli/oauth2.py | 4 ++-- tests/test_authority_patch.py | 32 -------------------------------- 5 files changed, 12 insertions(+), 49 deletions(-) delete mode 100644 tests/test_authority_patch.py diff --git a/msal/application.py b/msal/application.py index 5d1406af..a06df303 100644 --- a/msal/application.py +++ b/msal/application.py @@ -11,8 +11,6 @@ from threading import Lock import os -import requests - from .oauth2cli import Client, JwtAssertionCreator from .oauth2cli.oidc import decode_part from .authority import Authority @@ -425,6 +423,8 @@ def __init__( 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 diff --git a/msal/authority.py b/msal/authority.py index 145ce3d9..ecf6b777 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -5,11 +5,6 @@ from urlparse import urlparse import logging -# Historically some customers patched this module-wide requests instance. -# We keep it here for now. They will be removed in next major release. -import requests -import requests as _requests - from .exceptions import MsalServiceError @@ -59,9 +54,10 @@ class Authority(object): _domains_without_user_realm_discovery = set([]) @property - def http_client(self): # Obsolete. We will remove this in next major release. - # A workaround: if module-wide requests is patched, we honor it. - return self._http_client if requests is _requests else requests + 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. @@ -84,7 +80,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): payload = instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( self.instance, authority.path), - self.http_client) + self._http_client) if payload.get("error") == "invalid_instance": raise ValueError( "invalid_instance: " @@ -104,7 +100,7 @@ def __init__(self, authority_url, http_client, validate_authority=True): try: openid_config = tenant_discovery( tenant_discovery_endpoint, - self.http_client) + self._http_client) except ValueError: raise ValueError( "Unable to get authority configuration for {}. " @@ -124,7 +120,7 @@ def user_realm_discovery(self, username, correlation_id=None, response=None): # "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( + 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', diff --git a/msal/oauth2cli/assertion.py b/msal/oauth2cli/assertion.py index 0cf58799..855bd16b 100644 --- a/msal/oauth2cli/assertion.py +++ b/msal/oauth2cli/assertion.py @@ -4,8 +4,6 @@ import uuid import logging -import jwt - logger = logging.getLogger(__name__) @@ -99,6 +97,7 @@ def create_normal_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, diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index e092b3dd..54708004 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -17,8 +17,6 @@ import string import hashlib -import requests - from .authcode import AuthCodeReceiver as _AuthCodeReceiver try: @@ -159,6 +157,8 @@ def __init__( "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 diff --git a/tests/test_authority_patch.py b/tests/test_authority_patch.py deleted file mode 100644 index 1feca62d..00000000 --- a/tests/test_authority_patch.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest - -import msal -from tests.http_client import MinimalHttpClient - - -class DummyHttpClient(object): - def get(self, url, **kwargs): - raise RuntimeError("just for testing purpose") - - -class TestAuthorityHonorsPatchedRequests(unittest.TestCase): - """This is only a workaround for an undocumented behavior.""" - def test_authority_honors_a_patched_requests(self): - # First, we test that the original, unmodified authority is working - a = msal.authority.Authority( - "https://login.microsoftonline.com/common", MinimalHttpClient()) - self.assertEqual( - a.authorization_endpoint, - 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize') - - original = msal.authority.requests - try: - # Now we mimic a (discouraged) practice of patching authority.requests - msal.authority.requests = DummyHttpClient() - # msal.authority is expected to honor that patch. - with self.assertRaises(RuntimeError): - a = msal.authority.Authority( - "https://login.microsoftonline.com/common", MinimalHttpClient()) - finally: # Tricky: - # Unpatch is necessary otherwise other test cases would be affected - msal.authority.requests = original From 6264fd8833380239c0228e1150e9b5199be771e3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 25 Jan 2022 13:20:12 -0800 Subject: [PATCH 341/440] Document new info on how to detect edge on Linux --- msal/application.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/msal/application.py b/msal/application.py index a06df303..c8d84649 100644 --- a/msal/application.py +++ b/msal/application.py @@ -78,6 +78,10 @@ def _preferred_browser(): 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". From eb7c58bd931c618e72659a40811474f465ea62d3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 2 Feb 2022 10:42:31 -0800 Subject: [PATCH 342/440] Remove decommissioned domain to get tests working --- msal/authority.py | 1 - 1 file changed, 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index ecf6b777..4fb6e829 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -22,7 +22,6 @@ AZURE_CHINA, 'login-us.microsoftonline.com', AZURE_US_GOVERNMENT, - 'login.microsoftonline.de', ]) WELL_KNOWN_B2C_HOSTS = [ "b2clogin.com", From 31c7498c2e427a68a43fae8f388099560d017b30 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 2 Feb 2022 22:45:34 -0800 Subject: [PATCH 343/440] Change skip() to skipTest() --- tests/test_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a23806ed..65691689 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -175,7 +175,7 @@ def _test_device_flow( assertion=lambda: self.assertIn('access_token', result), skippable_errors=self.app.client.DEVICE_FLOW_RETRIABLE_ERRORS) if "access_token" not in result: - self.skip("End user did not complete Device Flow in time") + 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( @@ -528,6 +528,8 @@ def _test_acquire_token_by_auth_code_flow(

  • 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)) From 034b9ae2f40dbf7872d97dc2eecac7eeda0181c0 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 8 Feb 2022 03:04:14 -0800 Subject: [PATCH 344/440] Actionable exception from ADFS ROPC --- msal/application.py | 18 ++++++++++++------ msal/wstrust_request.py | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/msal/application.py b/msal/application.py index c8d84649..60e5e2e5 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1417,12 +1417,18 @@ def acquire_token_by_username_password( 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 + try: + response = _clean_up(self._acquire_token_by_username_password_federated( + user_realm_result, username, password, scopes=scopes, + data=data, + headers=headers, **kwargs)) + except (ValueError, RuntimeError): + raise RuntimeError( + "ADFS is not configured properly. " + "Consider use acquire_token_interactive() instead.") + else: + telemetry_context.update_telemetry(response) + return response response = _clean_up(self.client.obtain_token_by_username_password( username, password, scope=scopes, headers=headers, diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index bdfb57ef..570bfc0e 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -44,8 +44,8 @@ def send_request( soap_action = Mex.ACTION_2005 elif '/trust/13/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_13 - assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005), ( # A loose check here - "Unsupported soap action: %s" % soap_action) + if soap_action not in (Mex.ACTION_13, Mex.ACTION_2005): + raise ValueError("Unsupported soap action: %s" % soap_action) data = _build_rst( username, password, cloud_audience_urn, endpoint_address, soap_action) resp = http_client.post(endpoint_address, data=data, headers={ From e0b8a8863e40b26fa8e0532dc83e7cd834fd347e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 10 Feb 2022 01:11:56 -0800 Subject: [PATCH 345/440] Removes the middle-layer exception --- msal/application.py | 18 ++++++------------ msal/wstrust_request.py | 3 ++- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/msal/application.py b/msal/application.py index 60e5e2e5..c8d84649 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1417,18 +1417,12 @@ def acquire_token_by_username_password( 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": - try: - response = _clean_up(self._acquire_token_by_username_password_federated( - user_realm_result, username, password, scopes=scopes, - data=data, - headers=headers, **kwargs)) - except (ValueError, RuntimeError): - raise RuntimeError( - "ADFS is not configured properly. " - "Consider use acquire_token_interactive() instead.") - else: - telemetry_context.update_telemetry(response) - return response + 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, diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 570bfc0e..43a2804f 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -45,7 +45,8 @@ def send_request( 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" % soap_action) + 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={ From 844c0edfdb324578adb18e592a2de792ca8bc5b3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 8 Feb 2022 10:43:30 -0800 Subject: [PATCH 346/440] MSAL Python 1.17.0 --- msal/application.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index c8d84649..e2f20446 100644 --- a/msal/application.py +++ b/msal/application.py @@ -24,7 +24,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.16.0" +__version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index c5d89186..bcec8fe7 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', - 'cryptography>=0.6,<38', + '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 # From 997517a0db53e5fc55ef6ada5529a772d4d9c4c6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 11 Feb 2022 14:52:34 -0800 Subject: [PATCH 347/440] Use absolute link for thumbnail in README This way, it will probably show up properly in PyPI, too. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e193f257..9088b60a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Quick links: 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/) +[![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](https://raw.githubusercontent.com/AzureAD/microsoft-authentication-library-for-python/dev/docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/) ## Installation From 1e51ee38c29931078512a44847e7001552e22848 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 9 Feb 2022 01:22:43 -0800 Subject: [PATCH 348/440] Test matrix covers Python 3.10 --- .github/workflows/python-package.yml | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 09840ba6..1df9c915 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10"] steps: - uses: actions/checkout@v2 diff --git a/setup.py b/setup.py index bcec8fe7..8523c2e3 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,7 @@ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], From 42672d515215a10aaa2b42fc1f22356ec50fa603 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 9 Feb 2022 01:55:28 -0800 Subject: [PATCH 349/440] Test matrix covers Python 3.11 --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1df9c915..10afc207 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10"] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha.5"] steps: - uses: actions/checkout@v2 From cec910ae0e3e98d1a0ca4009f2fc4619467c05db Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 14 Feb 2022 12:40:44 -0800 Subject: [PATCH 350/440] Fine tune some inline comments --- tests/test_e2e.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 65691689..f74c0767 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -86,7 +86,7 @@ def assertCacheWorksForUser( 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 + or # Authority server could return different set of scopes set(scope) <= set(result_from_wire["scope"].split(" ")) ): # Going to test acquire_token_silent(...) to locate an AT from cache @@ -115,7 +115,7 @@ def assertCacheWorksForUser( # 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, + # was just an empirical observation but never a commitment 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") From 599142f035d954b794c4cbb80a315747aac81396 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 18 Apr 2022 23:37:04 -0700 Subject: [PATCH 351/440] Add an interactive console test script --- tests/msaltest.py | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/msaltest.py diff --git a/tests/msaltest.py b/tests/msaltest.py new file mode 100644 index 00000000..9f0f8525 --- /dev/null +++ b/tests/msaltest.py @@ -0,0 +1,158 @@ +import getpass, logging, pprint, sys, msal + + +def _input_boolean(message): + return input( + "{} (N/n/F/f or empty means False, otherwise it is True): ".format(message) + ) not in ('N', 'n', 'F', 'f', '') + +def _input(message, default=None): + return input(message.format(default=default)).strip() or default + +def _select_options( + options, header="Your options:", footer=" Your choice? ", option_renderer=str, + accept_nonempty_string=False, + ): + assert options, "options must not be empty" + if header: + print(header) + for i, o in enumerate(options, start=1): + print(" {}: {}".format(i, option_renderer(o))) + if accept_nonempty_string: + print(" Or you can just type in your input.") + while True: + raw_data = input(footer) + try: + choice = int(raw_data) + if 1 <= choice <= len(options): + return options[choice - 1] + except ValueError: + if raw_data and accept_nonempty_string: + return raw_data + +def _input_scopes(): + return _select_options([ + "https://graph.microsoft.com/.default", + "https://management.azure.com/.default", + "User.Read", + "User.ReadBasic.All", + ], + header="Select a scope (multiple scopes can only be input by manually typing them):", + accept_nonempty_string=True, + ).split() + +def _select_account(app): + accounts = app.get_accounts() + if accounts: + return _select_options( + accounts, + option_renderer=lambda a: a["username"], + header="Account(s) already signed in inside MSAL Python:", + ) + else: + print("No account available inside MSAL Python. Use other methods to acquire token first.") + +def acquire_token_silent(app): + """acquire_token_silent() - with an account already signed into MSAL Python.""" + account = _select_account(app) + if account: + pprint.pprint(app.acquire_token_silent( + _input_scopes(), + account=account, + force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), + )) + +def acquire_token_interactive(app): + """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" + pprint.pprint(app.acquire_token_interactive( + _input_scopes(), + prompt="select_account" if _input_boolean("Select Account?") else None, + login_hint=_input("login_hint: ") or None, + )) + +def acquire_token_by_username_password(app): + """acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc""" + pprint.pprint(app.acquire_token_by_username_password( + _input("username: "), getpass.getpass("password: "), scopes=_input_scopes())) + +_JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}""" +SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1} +SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"] + +def acquire_ssh_cert_silently(app): + """Acquire an SSH Cert silently- This typically only works with Azure CLI""" + account = _select_account(app) + if account: + result = app.acquire_token_silent( + SSH_CERT_SCOPE, + account, + data=SSH_CERT_DATA, + force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), + ) + pprint.pprint(result) + if result and result.get("token_type") != "ssh-cert": + logging.error("Unable to acquire an ssh-cert.") + +def acquire_ssh_cert_interactive(app): + """Acquire an SSH Cert interactively - This typically only works with Azure CLI""" + result = app.acquire_token_interactive( + SSH_CERT_SCOPE, + prompt="select_account" if _input_boolean("Select Account?") else None, + login_hint=_input("login_hint: ") or None, + data=SSH_CERT_DATA, + ) + pprint.pprint(result) + if result.get("token_type") != "ssh-cert": + logging.error("Unable to acquire an ssh-cert") + +def remove_account(app): + """remove_account() - Invalidate account and/or token(s) from cache, so that acquire_token_silent() would be reset""" + account = _select_account(app) + if account: + app.remove_account(account) + print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"])) + +def exit(_): + """Exit""" + print("Bye") + sys.exit() + +def main(): + print("Welcome to the Msal Python Console Test App") + chosen_app = _select_options([ + {"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI"}, + {"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"}, + ], + option_renderer=lambda a: a["name"], + header="Impersonate this app (or you can type in the client_id of your own app)", + accept_nonempty_string=True) + app = msal.PublicClientApplication( + chosen_app["client_id"] if isinstance(chosen_app, dict) else chosen_app, + authority=_select_options([ + "https://login.microsoftonline.com/common", + "https://login.microsoftonline.com/organizations", + "https://login.microsoftonline.com/microsoft.onmicrosoft.com", + "https://login.microsoftonline.com/msidlab4.onmicrosoft.com", + "https://login.microsoftonline.com/consumers", + ], header="Input authority", accept_nonempty_string=True), + ) + if _input_boolean("Enable MSAL Python's DEBUG log?"): + logging.basicConfig(level=logging.DEBUG) + while True: + func = _select_options([ + acquire_token_silent, + acquire_token_interactive, + acquire_token_by_username_password, + acquire_ssh_cert_silently, + acquire_ssh_cert_interactive, + remove_account, + exit, + ], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:") + try: + func(app) + except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow + print("Aborted") + +if __name__ == "__main__": + main() + From 2ae2d2cb63744d29b19304b4e02f48f8041a5e03 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 3 May 2022 06:51:11 +0000 Subject: [PATCH 352/440] Fine tune msaltest.py with prompt options --- tests/msaltest.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/tests/msaltest.py b/tests/msaltest.py index 9f0f8525..b8f28e77 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -37,7 +37,7 @@ def _input_scopes(): "User.Read", "User.ReadBasic.All", ], - header="Select a scope (multiple scopes can only be input by manually typing them):", + header="Select a scope (multiple scopes can only be input by manually typing them, delimited by space):", accept_nonempty_string=True, ).split() @@ -62,13 +62,23 @@ def acquire_token_silent(app): force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), )) +def _acquire_token_interactive(app, scopes, data=None): + return app.acquire_token_interactive( + scopes, + prompt=_select_options([ + {"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."}, + {"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."}, + {"value": "select_account", "description": "select_account. Prompt with an account picker."}, + ], + option_renderer=lambda o: o["description"], + header="Prompt behavior?")["value"], + login_hint=_input("login_hint (typically an email address, or leave it blank if you don't need one): ") or None, + data=data or {}, + ) + def acquire_token_interactive(app): """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" - pprint.pprint(app.acquire_token_interactive( - _input_scopes(), - prompt="select_account" if _input_boolean("Select Account?") else None, - login_hint=_input("login_hint: ") or None, - )) + pprint.pprint(_acquire_token_interactive(app, _input_scopes())) def acquire_token_by_username_password(app): """acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc""" @@ -95,12 +105,7 @@ def acquire_ssh_cert_silently(app): def acquire_ssh_cert_interactive(app): """Acquire an SSH Cert interactively - This typically only works with Azure CLI""" - result = app.acquire_token_interactive( - SSH_CERT_SCOPE, - prompt="select_account" if _input_boolean("Select Account?") else None, - login_hint=_input("login_hint: ") or None, - data=SSH_CERT_DATA, - ) + result = _acquire_token_interactive(app, SSH_CERT_SCOPE, data=SSH_CERT_DATA) pprint.pprint(result) if result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert") @@ -118,9 +123,9 @@ def exit(_): sys.exit() def main(): - print("Welcome to the Msal Python Console Test App") + print("Welcome to the Msal Python Console Test App, committed at 2022-5-2\n") chosen_app = _select_options([ - {"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI"}, + {"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI (Correctly configured for MSA-PT)"}, {"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"}, ], option_renderer=lambda a: a["name"], @@ -134,7 +139,10 @@ def main(): "https://login.microsoftonline.com/microsoft.onmicrosoft.com", "https://login.microsoftonline.com/msidlab4.onmicrosoft.com", "https://login.microsoftonline.com/consumers", - ], header="Input authority", accept_nonempty_string=True), + ], + header="Input authority (Note that MSA-PT apps would NOT use the /common authority)", + accept_nonempty_string=True, + ), ) if _input_boolean("Enable MSAL Python's DEBUG log?"): logging.basicConfig(level=logging.DEBUG) From 7d7a492d6f5101cfc452a5a4066b5784720e1782 Mon Sep 17 00:00:00 2001 From: Emmanuel Oche Date: Thu, 12 May 2022 16:53:43 -0400 Subject: [PATCH 353/440] implement response_mode (#469) * implement response_mode oidc supports passing the response_mode to allow redirects to send callback parameters as POST for increased security. * Fix error check logic and modify test_ccs to include response_mode * Add more comments * Apply suggestions from code review Co-authored-by: Ray Luo * PR review comments addressed * remove extraneous line Co-authored-by: Emmanuel Oche Co-authored-by: Ray Luo --- msal/application.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/msal/application.py b/msal/application.py index e2f20446..125b675b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -636,6 +636,7 @@ def initiate_auth_code_flow( domain_hint=None, # type: Optional[str] claims_challenge=None, max_age=None, + response_mode=None, # type: Optional[str] ): """Initiate an auth code flow. @@ -677,6 +678,20 @@ def initiate_auth_code_flow( New in version 1.15. + :param str response_mode: + OPTIONAL. Specifies the method with which response parameters should be returned. + The default value is equivalent to ``query``, which is still secure enough in MSAL Python + (because MSAL Python does not transfer tokens via query parameter in the first place). + For even better security, we recommend using the value ``form_post``. + In "form_post" mode, response parameters + will be encoded as HTML form values that are transmitted via the HTTP POST method and + encoded in the body using the application/x-www-form-urlencoded format. + Valid values can be either "form_post" for HTTP POST to callback URI or + "query" (the default) for HTTP GET with parameters encoded in query string. + More information on possible values + `here ` + and `here ` + :return: The auth code flow. It is a dict in this form:: @@ -707,6 +722,7 @@ def initiate_auth_code_flow( claims=_merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge), max_age=max_age, + response_mode=response_mode, ) flow["claims_challenge"] = claims_challenge return flow From 0ef4a48d7b9e531ef6b1bc12097d8a8963fb65e4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 13 May 2022 17:55:56 -0700 Subject: [PATCH 354/440] Automatically populate login_hint for easier tests --- tests/msaltest.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/msaltest.py b/tests/msaltest.py index b8f28e77..c1ef1e7c 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -63,18 +63,28 @@ def acquire_token_silent(app): )) def _acquire_token_interactive(app, scopes, data=None): - return app.acquire_token_interactive( - scopes, - prompt=_select_options([ - {"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."}, - {"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."}, - {"value": "select_account", "description": "select_account. Prompt with an account picker."}, - ], - option_renderer=lambda o: o["description"], - header="Prompt behavior?")["value"], - login_hint=_input("login_hint (typically an email address, or leave it blank if you don't need one): ") or None, - data=data or {}, + prompt = _select_options([ + {"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."}, + {"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."}, + {"value": "select_account", "description": "select_account. Prompt with an account picker."}, + ], + option_renderer=lambda o: o["description"], + header="Prompt behavior?")["value"] + raw_login_hint = _select_options( + # login_hint is unnecessary when prompt=select_account, + # but we still let tester input login_hint, just for testing purpose. + [None] + [a["username"] for a in app.get_accounts()], + header="login_hint? (If you have multiple signed-in sessions in browser, and you specify a login_hint to match one of them, you will bypass the account picker.)", + accept_nonempty_string=True, ) + login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint + result = app.acquire_token_interactive( + scopes, prompt=prompt, login_hint=login_hint, data=data or {}) + if login_hint and "id_token_claims" in result: + signed_in_user = result.get("id_token_claims", {}).get("preferred_username") + if signed_in_user != login_hint: + logging.warning('Signed-in user "%s" does not match login_hint', signed_in_user) + return result def acquire_token_interactive(app): """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" @@ -119,7 +129,8 @@ def remove_account(app): def exit(_): """Exit""" - print("Bye") + bug_link = "https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose" + print("Bye. If you found a bug, please report it here: {}".format(bug_link)) sys.exit() def main(): @@ -127,6 +138,7 @@ def main(): chosen_app = _select_options([ {"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI (Correctly configured for MSA-PT)"}, {"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"}, + {"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"}, ], option_renderer=lambda a: a["name"], header="Impersonate this app (or you can type in the client_id of your own app)", From 56d80c4de54acbcca809c04fd6a4bd87fbbe8ec6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 5 Nov 2021 00:11:55 -0700 Subject: [PATCH 355/440] Emit warning when common or organizations is used in acquire_token_for_client() --- msal/application.py | 5 +++++ tests/test_application.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/msal/application.py b/msal/application.py index 125b675b..7ca62d7c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1675,6 +1675,11 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): - an error response would contain "error" and usually "error_description". """ # TBD: force_refresh behavior + if self.authority.tenant.lower() in ["common", "organizations"]: + warnings.warn( + "Using /common or /organizations authority " + "in acquire_token_for_client() is unreliable. " + "Please use a specific tenant instead.", DeprecationWarning) self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_FOR_CLIENT_ID) diff --git a/tests/test_application.py b/tests/test_application.py index 518042a8..804ccb82 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,5 +1,6 @@ # Note: Since Aug 2019 we move all e2e tests into test_e2e.py, # so this test_application file contains only unit tests without dependency. +import sys from msal.application import * from msal.application import _str2bytes import msal @@ -602,3 +603,25 @@ def test_get_accounts(self): self.assertIn("local_account_id", account, "Backward compatibility") self.assertIn("realm", account, "Backward compatibility") + +@unittest.skipUnless( + sys.version_info[0] >= 3 and sys.version_info[1] >= 2, + "assertWarns() is only available in Python 3.2+") +class TestClientCredentialGrant(unittest.TestCase): + def _test_certain_authority_should_emit_warnning(self, authority): + app = ConfidentialClientApplication( + "client_id", client_credential="secret", authority=authority) + def mock_post(url, headers=None, *args, **kwargs): + return MinimalResponse( + status_code=200, text=json.dumps({"access_token": "an AT"})) + with self.assertWarns(DeprecationWarning): + app.acquire_token_for_client(["scope"], post=mock_post) + + def test_common_authority_should_emit_warnning(self): + self._test_certain_authority_should_emit_warnning( + authority="https://login.microsoftonline.com/common") + + def test_organizations_authority_should_emit_warnning(self): + self._test_certain_authority_should_emit_warnning( + authority="https://login.microsoftonline.com/organizations") + From 669ac8581de46c60fb65cd58576febcef6cbc69c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 18 Oct 2021 16:59:40 +0000 Subject: [PATCH 356/440] Acquire SSH cert from Cloud Shell IMDS Cloud Shell Detection PoC: Silent flow utilizes Cloud Shell IMDS Introduce get_accounts(username=msal.CURRENT_USER) A reasonable-effort to convert scope to resource Replace get_accounts(username=msal.CURRENT_USER) by acquire_token_interactive(..., prompt="none") Detect unsupported Portal so that AzCLI could fallback --- msal/application.py | 33 ++++++++++-- msal/cloudshell.py | 122 ++++++++++++++++++++++++++++++++++++++++++++ msal/token_cache.py | 9 ++-- tests/test_e2e.py | 17 ++++++ 4 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 msal/cloudshell.py diff --git a/msal/application.py b/msal/application.py index 7ca62d7c..812abbfb 100644 --- a/msal/application.py +++ b/msal/application.py @@ -21,13 +21,14 @@ import msal.telemetry from .region import _detect_region from .throttled_http_client import ThrottledHttpClient +from .cloudshell import _is_running_in_cloud_shell # The __init__.py will import this. Not the other way around. __version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) - +_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" def extract_certs(public_cert_content): # Parses raw public certificate file contents and returns a list of strings @@ -986,6 +987,10 @@ def get_accounts(self, username=None): return accounts def _find_msal_accounts(self, environment): + interested_authority_types = [ + TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS] + if _is_running_in_cloud_shell(): + interested_authority_types.append(_AUTHORITY_TYPE_CLOUDSHELL) grouped_accounts = { a.get("home_account_id"): # Grouped by home tenant's id { # These are minimal amount of non-tenant-specific account info @@ -1001,8 +1006,7 @@ def _find_msal_accounts(self, environment): for a in self.token_cache.find( TokenCache.CredentialType.ACCOUNT, query={"environment": environment}) - if a["authority_type"] in ( - TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS) + if a["authority_type"] in interested_authority_types } return list(grouped_accounts.values()) @@ -1062,6 +1066,21 @@ def _forget_me(self, home_account): TokenCache.CredentialType.ACCOUNT, query=owned_by_home_account): self.token_cache.remove_account(a) + def _acquire_token_by_cloud_shell(self, scopes, data=None): + from .cloudshell import _obtain_token + response = _obtain_token( + self.http_client, scopes, client_id=self.client_id, data=data) + if "error" not in response: + self.token_cache.add(dict( + client_id=self.client_id, + scope=response["scope"].split() if "scope" in response else scopes, + token_endpoint=self.authority.token_endpoint, + response=response.copy(), + data=data or {}, + authority_type=_AUTHORITY_TYPE_CLOUDSHELL, + )) + return response + def acquire_token_silent( self, scopes, # type: List[str] @@ -1195,6 +1214,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( authority, # This can be different than self.authority force_refresh=False, # type: Optional[boolean] claims_challenge=None, + correlation_id=None, **kwargs): access_token_from_cache = None if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims @@ -1233,9 +1253,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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: + if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: + return self._acquire_token_by_cloud_shell( + scopes, data=kwargs.get("data")) 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, + correlation_id=correlation_id, **kwargs)) if (result and "error" not in result) or (not access_token_from_cache): return result @@ -1574,6 +1598,9 @@ def acquire_token_interactive( - A dict containing an "error" key, when token refresh failed. """ self._validate_ssh_cert_input_data(kwargs.get("data", {})) + if _is_running_in_cloud_shell() and prompt == "none": + return self._acquire_token_by_cloud_shell( + scopes, data=kwargs.pop("data", {})) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) telemetry_context = self._build_telemetry_context( diff --git a/msal/cloudshell.py b/msal/cloudshell.py new file mode 100644 index 00000000..f4feaf44 --- /dev/null +++ b/msal/cloudshell.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. + +"""This module wraps Cloud Shell's IMDS-like interface inside an OAuth2-like helper""" +import base64 +import json +import logging +import os +import time +try: # Python 2 + from urlparse import urlparse +except: # Python 3 + from urllib.parse import urlparse +from .oauth2cli.oidc import decode_part + + +logger = logging.getLogger(__name__) + + +def _is_running_in_cloud_shell(): + return os.environ.get("AZUREPS_HOST_ENVIRONMENT", "").startswith("cloud-shell") + + +def _scope_to_resource(scope): # This is an experimental reasonable-effort approach + cloud_shell_supported_audiences = [ + "https://analysis.windows.net/powerbi/api", # Came from https://msazure.visualstudio.com/One/_git/compute-CloudShell?path=/src/images/agent/env/envconfig.PROD.json + "https://pas.windows.net/CheckMyAccess/Linux/.default", # Cloud Shell accepts it as-is + ] + for a in cloud_shell_supported_audiences: + if scope.startswith(a): + return a + u = urlparse(scope) + if u.scheme: + return "{}://{}".format(u.scheme, u.netloc) + return scope # There is no much else we can do here + + +def _obtain_token(http_client, scopes, client_id=None, data=None): + resp = http_client.post( + "http://localhost:50342/oauth2/token", + data=dict( + data or {}, + resource=" ".join(map(_scope_to_resource, scopes))), + headers={"Metadata": "true"}, + ) + if resp.status_code >= 300: + logger.debug("Cloud Shell IMDS error: %s", resp.text) + cs_error = json.loads(resp.text).get("error", {}) + return {k: v for k, v in { + "error": cs_error.get("code"), + "error_description": cs_error.get("message"), + }.items() if v} + imds_payload = json.loads(resp.text) + BEARER = "Bearer" + oauth2_response = { + "access_token": imds_payload["access_token"], + "expires_in": int(imds_payload["expires_in"]), + "token_type": imds_payload.get("token_type", BEARER), + } + expected_token_type = (data or {}).get("token_type", BEARER) + if oauth2_response["token_type"] != expected_token_type: + return { # Generate a normal error (rather than an intrusive exception) + "error": "broker_error", + "error_description": "token_type {} is not supported by this version of Azure Portal".format( + expected_token_type), + } + parts = imds_payload["access_token"].split(".") + + # The following default values are useful in SSH Cert scenario + client_info = { # Default value, in case the real value will be unavailable + "uid": "user", + "utid": "cloudshell", + } + now = time.time() + preferred_username = "currentuser@cloudshell" + oauth2_response["id_token_claims"] = { # First 5 claims are required per OIDC + "iss": "cloudshell", + "sub": "user", + "aud": client_id, + "exp": now + 3600, + "iat": now, + "preferred_username": preferred_username, # Useful as MSAL account's username + } + + if len(parts) == 3: # Probably a JWT. Use it to derive client_info and id token. + try: + # Data defined in https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims + jwt_payload = json.loads(decode_part(parts[1])) + client_info = { + # Mimic a real home_account_id, + # so that this pseudo account and a real account would interop. + "uid": jwt_payload.get("oid", "user"), + "utid": jwt_payload.get("tid", "cloudshell"), + } + oauth2_response["id_token_claims"] = { + "iss": jwt_payload["iss"], + "sub": jwt_payload["sub"], # Could use oid instead + "aud": client_id, + "exp": jwt_payload["exp"], + "iat": jwt_payload["iat"], + "preferred_username": jwt_payload.get("preferred_username") # V2 + or jwt_payload.get("unique_name") # V1 + or preferred_username, + } + except ValueError: + logger.debug("Unable to decode jwt payload: %s", parts[1]) + oauth2_response["client_info"] = base64.b64encode( + # Mimic a client_info, so that MSAL would create an account + json.dumps(client_info).encode("utf-8")).decode("utf-8") + oauth2_response["id_token_claims"]["tid"] = client_info["utid"] # TBD + + ## Note: Decided to not surface resource back as scope, + ## because they would cause the downstream OAuth2 code path to + ## cache the token with a different scope and won't hit them later. + #if imds_payload.get("resource"): + # oauth2_response["scope"] = imds_payload["resource"] + if imds_payload.get("refresh_token"): + oauth2_response["refresh_token"] = imds_payload["refresh_token"] + return oauth2_response + diff --git a/msal/token_cache.py b/msal/token_cache.py index 2ed819d7..f7d9f955 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -113,6 +113,7 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info return self.__add(event, now=now) finally: wipe(event.get("response", {}), ( # These claims were useful during __add() + "id_token_claims", # Provided by broker "access_token", "refresh_token", "id_token", "username")) wipe(event, ["username"]) # Needed for federated ROPC logger.debug("event=%s", json.dumps( @@ -150,7 +151,8 @@ def __add(self, event, now=None): id_token = response.get("id_token") id_token_claims = ( decode_id_token(id_token, client_id=event["client_id"]) - if id_token else {}) + if id_token + else response.get("id_token_claims", {})) # Broker would provide id_token_claims 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 @@ -195,9 +197,10 @@ def __add(self, event, now=None): 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": + "authority_type": event.get( + "authority_type", # Honor caller's choice of authority_type self.AuthorityType.ADFS if realm == "adfs" - else self.AuthorityType.MSSTS, + else self.AuthorityType.MSSTS), # "client_info": response.get("client_info"), # Optional } self.modify(self.CredentialType.ACCOUNT, account, account) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f74c0767..9a971f46 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -185,12 +185,14 @@ 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 + prompt=None, **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, + prompt=prompt, timeout=120, port=port, welcome_template= # This is an undocumented feature for testing @@ -237,6 +239,7 @@ def test_ssh_cert_for_user(self): scope=self.SCOPE, data=self.DATA1, username_uri="https://msidlab.com/api/user?usertype=cloud", + prompt="none" if msal.application._is_running_in_cloud_shell() else None, ) # 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( @@ -254,6 +257,20 @@ def test_ssh_cert_for_user(self): self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) +@unittest.skipUnless( + msal.application._is_running_in_cloud_shell(), + "Manually run this test case from inside Cloud Shell") +class CloudShellTestCase(E2eTestCase): + app = msal.PublicClientApplication("client_id") + scope_that_requires_no_managed_device = "https://management.core.windows.net/" # Scopes came from https://msazure.visualstudio.com/One/_git/compute-CloudShell?path=/src/images/agent/env/envconfig.PROD.json&version=GBmaster&_a=contents + def test_access_token_should_be_obtained_for_a_supported_scope(self): + result = self.app.acquire_token_interactive( + [self.scope_that_requires_no_managed_device], prompt="none") + self.assertEqual( + "Bearer", result.get("token_type"), "Unexpected result: %s" % result) + self.assertIsNotNone(result.get("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) From c644e572d297de1e5b3d6e890e22155228b7f504 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 18 May 2022 22:57:28 -0700 Subject: [PATCH 357/440] MSAL Python 1.18.0b1 Bump cryptography --- msal/application.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 812abbfb..1f6d50f2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.18.0b1" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" diff --git a/setup.py b/setup.py index 8523c2e3..f8bdd7d7 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', - 'cryptography>=0.6,<39', + 'cryptography>=0.6,<40', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # From 366e758f84e01b72053fe18f5a964301d9f5489f Mon Sep 17 00:00:00 2001 From: Alexander Overvoorde <60606100+OvervCW@users.noreply.github.com> Date: Mon, 30 May 2022 11:17:59 +0200 Subject: [PATCH 358/440] Fix typo in code I stumbled upon this typo while investigating a different issue in this file. --- msal/oauth2cli/assertion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/oauth2cli/assertion.py b/msal/oauth2cli/assertion.py index 855bd16b..419bb14e 100644 --- a/msal/oauth2cli/assertion.py +++ b/msal/oauth2cli/assertion.py @@ -115,7 +115,7 @@ def create_normal_assertion( 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"): + if self.algorithm.startswith("RS") or self.algorithm.startswith("ES"): logger.exception( 'Some algorithms requires "pip install cryptography". ' 'See https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional') From 8cb7f8be55a752eb1e8d6f2da2db27b8b7e815dc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 31 May 2022 12:51:18 -0700 Subject: [PATCH 359/440] MSAL Python 1.18.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 1f6d50f2..9ac8a3bd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.18.0b1" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.18.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From a92a15b19757233751ed1449b6158102cb24ab82 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 31 May 2022 14:26:16 -0700 Subject: [PATCH 360/440] Document our findings on addressing CVE-2022-29217 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f8bdd7d7..814627f9 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ # See https://stackoverflow.com/a/14211600/728675 for more detail install_requires=[ 'requests>=2.0.0,<3', - 'PyJWT[crypto]>=1.0.0,<3', + 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ 'cryptography>=0.6,<40', # load_pem_private_key() is available since 0.6 From bbd75fd69d0ef84ee759aaf5b8711bdf574f526f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 31 May 2022 14:56:21 -0700 Subject: [PATCH 361/440] Disable test for China cloud --- tests/test_authority.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_authority.py b/tests/test_authority.py index 9fdc83c5..ee81c15e 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -37,8 +37,9 @@ def test_wellknown_host_and_tenant(self): 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") + ## AZURE_CHINA is prone to some ConnectionError. We skip it to speed up our tests. + # self._test_authority_builder(AZURE_CHINA, "organizations") @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): From 85f4f9edf6eb37946f405b676c50d1e0ff981e9e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 8 Jun 2022 11:35:22 -0700 Subject: [PATCH 362/440] Tolerate home_account_id to be None --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 9ac8a3bd..829c35b0 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1340,7 +1340,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token( 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 + if query.get("home_account_id"): # 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( From 6b709b037cfb07884c6eda170f4373ecc6143046 Mon Sep 17 00:00:00 2001 From: sarathys <2991011+sarathys@users.noreply.github.com> Date: Tue, 28 Jun 2022 18:01:07 -0700 Subject: [PATCH 363/440] Use provided authority port when building the tenant discovery endpoint (#484) * Use provided authority port when building the tenant discovery endpoint * address PR comment * Polish the implementation Co-authored-by: Ray Luo --- msal/authority.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msal/authority.py b/msal/authority.py index 4fb6e829..81788200 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -91,8 +91,9 @@ def __init__(self, authority_url, http_client, validate_authority=True): tenant_discovery_endpoint = payload['tenant_discovery_endpoint'] else: tenant_discovery_endpoint = ( - 'https://{}{}{}/.well-known/openid-configuration'.format( + 'https://{}:{}{}{}/.well-known/openid-configuration'.format( self.instance, + 443 if authority.port is None else authority.port, authority.path, # In B2C scenario, it is "/tenant/policy" "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint )) From 0c885c480c144bc496deb043fee916e99f3ca4a6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 17 Jun 2022 12:37:22 -0700 Subject: [PATCH 364/440] Right regional endpoint for login.microsoft.com We got it right in PR 358 based on the specs at that time, but we were using a fragile approach, which caused the "login.microsoft.com" to be left out in subsequent PR 394. Lesson learned. Explicit is better than implicit. https://peps.python.org/pep-0020/ --- msal/application.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 829c35b0..838a28d8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -526,8 +526,10 @@ def _get_regional_authority(self, central_authority): 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 + # The list came from point 3 of the algorithm section in this internal doc + # https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PinAuthToRegion/AAD%20SDK%20Proposal%20to%20Pin%20Auth%20to%20region.md&anchor=algorithm&_a=preview "login.microsoftonline.com", + "login.microsoft.com", "login.windows.net", "sts.windows.net", ) From b553a51f37520f5fae74a7888ddebca6c20d0d5c Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 29 Jun 2022 12:46:03 -0700 Subject: [PATCH 365/440] Disable more tests for China cloud --- tests/test_authority.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_authority.py b/tests/test_authority.py index ee81c15e..fc6e12fc 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -33,7 +33,8 @@ def _test_authority_builder(self, host, tenant): 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") + if host != AZURE_CHINA: # It is prone to ConnectionError + 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") From ebfa0d59ec290e2d7b47a2c6da76210f4629c56e Mon Sep 17 00:00:00 2001 From: Anthony Shaw Date: Mon, 25 Jul 2022 12:14:57 +1000 Subject: [PATCH 366/440] Test latest 3.11 beta --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 10afc207..8950c15a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha.5"] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11-dev"] steps: - uses: actions/checkout@v2 From cf6944ce9af903e0f2cd41e0fbb5e6cbaad66494 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 25 Aug 2022 16:50:14 -0700 Subject: [PATCH 367/440] Enable sphinx-paramlinks Troubleshooting --- docs/conf.py | 5 +++-- docs/requirements.txt | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 810dfc02..024451d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,8 +40,9 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', + 'sphinx.ext.autodoc', # This seems need to be the first extension to load 'sphinx.ext.githubpages', + 'sphinx_paramlinks', ] # Add any paths that contain templates here, relative to this directory. @@ -182,4 +183,4 @@ epub_exclude_files = ['search.html'] -# -- Extension configuration ------------------------------------------------- \ No newline at end of file +# -- Extension configuration ------------------------------------------------- diff --git a/docs/requirements.txt b/docs/requirements.txt index d5de57fe..0fd0c33a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ furo --r ../requirements.txt \ No newline at end of file +sphinx-paramlinks +-r ../requirements.txt From d88592740c5dfdd63b02e106754d4e92613291a1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 25 Aug 2022 18:16:10 -0700 Subject: [PATCH 368/440] Fix doc typo --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 838a28d8..62fc6cb8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -300,7 +300,7 @@ def __init__( Client capability is implemented using "claims" parameter on the wire, for now. MSAL will combine them into - `claims parameter `_ which you will later provide via one of the acquire-token request. :param str azure_region: From 95e523d7d556fd80b66f0cd182e452812e573e24 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sun, 28 Aug 2022 23:56:51 -0700 Subject: [PATCH 369/440] Broker (WAM) integration Disabled SSH Cert when using broker --- msal/application.py | 283 +++++++++++++++++++++++++++++++++-- msal/authority.py | 5 +- msal/broker.py | 239 +++++++++++++++++++++++++++++ msal/token_cache.py | 20 ++- sample/interactive_sample.py | 6 + setup.py | 10 +- tests/msaltest.py | 15 +- tests/test_broker.py | 63 ++++++++ tests/test_e2e.py | 36 ++++- 9 files changed, 642 insertions(+), 35 deletions(-) create mode 100644 msal/broker.py create mode 100644 tests/test_broker.py diff --git a/msal/application.py b/msal/application.py index 62fc6cb8..a16e1844 100644 --- a/msal/application.py +++ b/msal/application.py @@ -17,7 +17,7 @@ from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request from .wstrust_response import * -from .token_cache import TokenCache +from .token_cache import TokenCache, _get_username import msal.telemetry from .region import _detect_region from .throttled_http_client import ThrottledHttpClient @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.18.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.20.0b1" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" @@ -67,8 +67,12 @@ def _str2bytes(raw): def _clean_up(result): if isinstance(result, dict): - result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not - return result + return { + k: result[k] for k in result + if k != "refresh_in" # MSAL handled refresh_in, customers need not + and not k.startswith('_') # Skim internal properties + } + return result # It could be None def _preferred_browser(): @@ -174,6 +178,7 @@ def __init__( # when we would eventually want to add this feature to PCA in future. exclude_scopes=None, http_cache=None, + allow_broker=None, ): """Create an instance of application. @@ -409,6 +414,34 @@ def __init__( Personally Identifiable Information (PII). Encryption is unnecessary. New in version 1.16.0. + + :param boolean allow_broker: + Brokers provide Single-Sign-On, device identification, + and application identification verification. + If this parameter is set to True, + MSAL will use the broker and return either a token or an error, + when your scenario is supported by a broker, + otherwise it will automatically fall back to non-broker behavior. + This also means you could set this flag as True universally, + as long as your app meets the following prerequisite: + + * Installed optional dependency, e.g. ``pip install msal[broker]>=1.20,<2``. + (Note that broker is currently only available on Windows 10+) + + * Register a new redirect_uri for your desktop app as: + ``ms-appx-web://Microsoft.AAD.BrokerPlugin/your_client_id`` + + * Tested your app in following scenarios: + + * Windows 10+ + + * PublicClientApplication's following methods:: + acquire_token_interactive(), acquire_token_by_username_password(), + acquire_token_silent() (or acquire_token_silent_with_error()). + + * AAD and MSA accounts (i.e. Non-ADFS, non-B2C) + + New in version 1.20.0. """ self.client_id = client_id self.client_credential = client_credential @@ -467,6 +500,15 @@ def __init__( self.http_client, validate_authority=False) else: raise + is_confidential_app = bool( + isinstance(self, ConfidentialClientApplication) or self.client_credential) + if is_confidential_app and allow_broker: + raise ValueError("allow_broker=True is only supported in PublicClientApplication") + self._enable_broker = bool( + allow_broker and not is_confidential_app + and sys.platform == "win32" + and not self.authority.is_adfs and not self.authority._is_b2c) + logger.debug("Broker enabled? %s", self._enable_broker) self.token_cache = token_cache or TokenCache() self._region_configured = azure_region @@ -1028,6 +1070,15 @@ def _get_authority_aliases(self, instance): def remove_account(self, account): """Sign me out and forget me from token cache""" self._forget_me(account) + if self._enable_broker: + try: + from .broker import _signout_silently + except RuntimeError: # TODO: TBD + logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") + else: + error = _signout_silently(self.client_id, account["local_account_id"]) + if error: + logger.debug("_signout_silently() returns error: %s", error) def _sign_out(self, home_account): # Remove all relevant RTs and ATs from token cache @@ -1255,9 +1306,28 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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: + data = kwargs.get("data", {}) if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: - return self._acquire_token_by_cloud_shell( - scopes, data=kwargs.get("data")) + return self._acquire_token_by_cloud_shell(scopes, data=data) + + if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert": + try: + from .broker import _acquire_token_silently + except RuntimeError: # TODO: TBD + logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") + else: + response = _acquire_token_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + account["local_account_id"], + scopes, + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge), + correlation_id=correlation_id, + **data) + if response: # The broker provided a decisive outcome, so we use it + return self._process_broker_response(response, scopes, data) + 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, @@ -1271,6 +1341,18 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( raise # We choose to bubble up the exception return access_token_from_cache + def _process_broker_response(self, response, scopes, data): + if "error" not in response: + self.token_cache.add(dict( + client_id=self.client_id, + scope=response["scope"].split() if "scope" in response else scopes, + token_endpoint=self.authority.token_endpoint, + response=response.copy(), + data=data, + _account_id=response["_account_id"], + )) + return _clean_up(response) + def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): query = { @@ -1447,14 +1529,33 @@ def acquire_token_by_username_password( - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ + claims = _merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge) + if self._enable_broker: + try: + from .broker import _signin_silently + except RuntimeError: # TODO: TBD + logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") + else: + response = _signin_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + scopes, # Decorated scopes won't work due to offline_access + MSALRuntime_Username=username, + MSALRuntime_Password=password, + validateAuthority="no" + if self.authority._validate_authority is False + or self.authority.is_adfs or self.authority._is_b2c + else None, + claims=claims, + ) + return self._process_broker_response(response, scopes, kwargs.get("data", {})) + 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)) + data = dict(kwargs.pop("data", {}), claims=claims) if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery( username, correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]) @@ -1520,6 +1621,7 @@ def _acquire_token_by_username_password_federated( class PublicClientApplication(ClientApplication): # browser app or mobile app DEVICE_FLOW_CORRELATION_ID = "_correlation_id" + CONSOLE_WINDOW_HANDLE = object() def __init__(self, client_id, client_credential=None, **kwargs): if client_credential is not None: @@ -1538,11 +1640,16 @@ def acquire_token_interactive( port=None, extra_scopes_to_consent=None, max_age=None, + parent_window_handle=None, + on_before_launching_ui=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``. + If you opts in to use broker during ``PublicClientApplication`` creation, + your app also need this Redirect URI: + ``ms-appx-web://Microsoft.AAD.BrokerPlugin/YOUR_CLIENT_ID`` :param list scopes: It is a list of case-sensitive strings. @@ -1594,17 +1701,79 @@ def acquire_token_interactive( New in version 1.15. + :param int parent_window_handle: + OPTIONAL. If your app is a GUI app running on modern Windows system, + and your app opts in to use broker, + you are recommended to also provide its window handle, + so that the sign in UI window will properly pop up on top of your window. + + New in version 1.20.0. + + :param function on_before_launching_ui: + A callback with the form of + ``lambda ui="xyz", **kwargs: print("A {} will be launched".format(ui))``, + where ``ui`` will be either "browser" or "broker". + You can use it to inform your end user to expect a pop-up window. + + New in version 1.20.0. + :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", {})) + data = kwargs.pop("data", {}) + self._validate_ssh_cert_input_data(data) + if not on_before_launching_ui: + on_before_launching_ui = lambda **kwargs: None if _is_running_in_cloud_shell() and prompt == "none": - return self._acquire_token_by_cloud_shell( - scopes, data=kwargs.pop("data", {})) + # Note: _acquire_token_by_cloud_shell() is always silent, + # so we would not fire on_before_launching_ui() + return self._acquire_token_by_cloud_shell(scopes, data=data) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) + if self._enable_broker and data.get("token_type") != "ssh-cert": + if parent_window_handle is None: + raise ValueError( + "parent_window_handle is required when you opted into using broker. " + "You need to provide the window handle of your GUI application, " + "or use msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE " + "when and only when your application is a console app.") + if extra_scopes_to_consent: + logger.warning( + "Ignoring parameter extra_scopes_to_consent, " + "which is not supported by broker") + enable_msa_passthrough = kwargs.pop( + "enable_msa_passthrough", # Keep it as a hidden param, for now. + # OPTIONAL. MSA-Passthrough is a legacy configuration, + # needed by a small amount of Microsoft first-party apps, + # which would login MSA accounts via ".../organizations" authority. + # If you app belongs to this category, AND you are enabling broker, + # you would want to enable this flag. Default value is equivalent to False. + self.client_id in [ + # Experimental: Automatically enable MSA-PT mode for known MSA-PT apps + # More background of MSA-PT is available from this internal docs: + # https://microsoft.sharepoint.com/:w:/t/Identity-DevEx/EatIUauX3c9Ctw1l7AQ6iM8B5CeBZxc58eoQCE0IuZ0VFw?e=tgc3jP&CID=39c853be-76ea-79d7-ee73-f1b2706ede05 + "04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI + "04f0c124-f2bc-4f59-8241-bf6df9866bbd", # Visual Studio + ] and data.get("token_type") != "ssh-cert" # Work around a known issue as of PyMsalRuntime 0.8 + ) + try: + return self._acquire_token_interactive_via_broker( + scopes, + parent_window_handle, + enable_msa_passthrough, + claims, + data, + on_before_launching_ui, + prompt=prompt, + login_hint=login_hint, + max_age=max_age, + ) + except RuntimeError: # TODO: TBD + logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") + + on_before_launching_ui(ui="browser") telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_INTERACTIVE) response = _clean_up(self.client.obtain_token_by_browser( @@ -1621,13 +1790,99 @@ def acquire_token_interactive( "claims": claims, "domain_hint": domain_hint, }, - data=dict(kwargs.pop("data", {}), claims=claims), + data=dict(data, claims=claims), headers=telemetry_context.generate_headers(), browser_name=_preferred_browser(), **kwargs)) telemetry_context.update_telemetry(response) return response + def _acquire_token_interactive_via_broker( + self, + scopes, # type: list[str] + parent_window_handle, # type: int + enable_msa_passthrough, # type: boolean + claims, # type: str + data, # type: dict + on_before_launching_ui, # type: callable + prompt=None, + login_hint=None, # type: Optional[str] + max_age=None, + **kwargs): + from .broker import _signin_interactively, _signin_silently, _acquire_token_silently + if "welcome_template" in kwargs: + logger.debug(kwargs["welcome_template"]) # Experimental + authority = "https://{}/{}".format( + self.authority.instance, self.authority.tenant) + validate_authority = ( + "no" if self.authority._validate_authority is False + or self.authority.is_adfs or self.authority._is_b2c + else None) + # Calls different broker methods to mimic the OIDC behaviors + if login_hint and prompt != "select_account": # OIDC prompts when the user did not sign in + accounts = self.get_accounts(username=login_hint) + if len(accounts) == 1: # Unambiguously proceed with this account + response = _acquire_token_silently( # When it works, it bypasses prompt + authority, + self.client_id, + accounts[0]["local_account_id"], + scopes, + claims=claims, + **data) + if response and "error" not in response: + return self._process_broker_response(response, scopes, data) + # login_hint undecisive or not exists + if prompt == "none" or not prompt: # Must/Can attempt _signin_silently() + response = _signin_silently( # Unlike OIDC, it doesn't honor login_hint + authority, self.client_id, scopes, + validateAuthority=validate_authority, + claims=claims, + max_age=max_age, + enable_msa_pt=enable_msa_passthrough, + **data) + is_wrong_account = bool( + # _signin_silently() only gets tokens for default account, + # but this seems to have been fixed in PyMsalRuntime 0.11.2 + "access_token" in response and login_hint + and response.get("id_token_claims", {}) != login_hint) + wrong_account_error_message = ( + 'prompt="none" will not work for login_hint="non-default-user"') + if is_wrong_account: + logger.debug(wrong_account_error_message) + if prompt == "none": + return self._process_broker_response( # It is either token or error + response, scopes, data + ) if not is_wrong_account else { + "error": "broker_error", + "error_description": wrong_account_error_message, + } + else: + assert bool(prompt) is False + from pymsalruntime import Response_Status + recoverable_errors = frozenset([ + Response_Status.Status_AccountUnusable, + Response_Status.Status_InteractionRequired, + ]) + if is_wrong_account or "error" in response and response.get( + "_broker_status") in recoverable_errors: + pass # It will fall back to the _signin_interactively() + else: + return self._process_broker_response(response, scopes, data) + # Falls back to _signin_interactively() + on_before_launching_ui(ui="broker") + response = _signin_interactively( + authority, self.client_id, scopes, + None if parent_window_handle is self.CONSOLE_WINDOW_HANDLE + else parent_window_handle, + validateAuthority=validate_authority, + login_hint=login_hint, + prompt=prompt, + claims=claims, + max_age=max_age, + enable_msa_pt=enable_msa_passthrough, + **data) + return self._process_broker_response(response, scopes, data) + def initiate_device_flow(self, scopes=None, **kwargs): """Initiate a Device Flow instance, which will be used in :func:`~acquire_token_by_device_flow`. diff --git a/msal/authority.py b/msal/authority.py index 81788200..93aafeea 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -72,9 +72,10 @@ def __init__(self, authority_url, http_client, validate_authority=True): 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 ( + self._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 + self._validate_authority = True if validate_authority is None else bool(validate_authority) + if (tenant != "adfs" and (not self._is_b2c) and self._validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS): payload = instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( diff --git a/msal/broker.py b/msal/broker.py new file mode 100644 index 00000000..e9bbe28c --- /dev/null +++ b/msal/broker.py @@ -0,0 +1,239 @@ +"""This module is an adaptor to the underlying broker. +It relies on PyMsalRuntime which is the package providing broker's functionality. +""" +from threading import Event +import json +import logging +import time +import uuid + + +logger = logging.getLogger(__name__) +try: + import pymsalruntime # ImportError would be raised on unsupported platforms such as Windows 8 + # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi + pymsalruntime.register_logging_callback(lambda message, level: { # New in pymsalruntime 0.7 + pymsalruntime.LogLevel.TRACE: logger.debug, # Python has no TRACE level + pymsalruntime.LogLevel.DEBUG: logger.debug, + # Let broker's excess info, warning and error logs map into default DEBUG, for now + #pymsalruntime.LogLevel.INFO: logger.info, + #pymsalruntime.LogLevel.WARNING: logger.warning, + #pymsalruntime.LogLevel.ERROR: logger.error, + pymsalruntime.LogLevel.FATAL: logger.critical, + }.get(level, logger.debug)(message)) +except (ImportError, AttributeError): # AttributeError happens when a prior pymsalruntime uninstallation somehow leaved an empty folder behind + # PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link + # https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files + raise ImportError( # TODO: Remove or adjust this line right before merging this PR + 'You need to install dependency by: pip install "msal[broker]>=1.20.0b1,<2"') +# Other exceptions (possibly RuntimeError) would be raised if its initialization fails + + +class RedirectUriError(ValueError): + pass + + +class TokenTypeError(ValueError): + pass + + +class _CallbackData: + def __init__(self): + self.signal = Event() + self.result = None + + def complete(self, result): + self.signal.set() + self.result = result + + +def _convert_error(error, client_id): + context = error.get_context() # Available since pymsalruntime 0.0.4 + if ( + "AADSTS50011" in context # In WAM, this could happen on both interactive and silent flows + or "AADSTS7000218" in context # This "request body must contain ... client_secret" is just a symptom of current app has no WAM redirect_uri + ): + raise RedirectUriError( # This would be seen by either the app developer or end user + "MsalRuntime won't work unless this one more redirect_uri is registered to current app: " + "ms-appx-web://Microsoft.AAD.BrokerPlugin/{}".format(client_id)) + # OTOH, AAD would emit other errors when other error handling branch was hit first, + # so, the AADSTS50011/RedirectUriError is not guaranteed to happen. + return { + "error": "broker_error", # Note: Broker implies your device needs to be compliant. + # You may use "dsregcmd /status" to check your device state + # https://docs.microsoft.com/en-us/azure/active-directory/devices/troubleshoot-device-dsregcmd + "error_description": "{}. Status: {}, Error code: {}, Tag: {}".format( + context, + error.get_status(), error.get_error_code(), error.get_tag()), + "_broker_status": error.get_status(), + "_broker_error_code": error.get_error_code(), + "_broker_tag": error.get_tag(), + } + + +def _read_account_by_id(account_id, correlation_id): + """Return an instance of MSALRuntimeError or MSALRuntimeAccount, or None""" + callback_data = _CallbackData() + pymsalruntime.read_account_by_id( + account_id, + correlation_id, + lambda result, callback_data=callback_data: callback_data.complete(result) + ) + callback_data.signal.wait() + return (callback_data.result.get_error() or callback_data.result.get_account() + or None) # None happens when the account was not created by broker + + +def _convert_result(result, client_id, expected_token_type=None): # Mimic an on-the-wire response from AAD + error = result.get_error() + if error: + return _convert_error(error, client_id) + id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {} + account = result.get_account() + assert account, "Account is expected to be always available" + ## Note: As of pymsalruntime 0.1.0, only wam_account_ids property is available + #account.get_account_property("wam_account_ids") + return_value = {k: v for k, v in { + "access_token": result.get_access_token(), + "expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down + "id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1 + "id_token_claims": id_token_claims, + "client_info": account.get_client_info(), + "_account_id": account.get_account_id(), + "token_type": expected_token_type or "Bearer", # Workaround its absence from broker + }.items() if v} + likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation + if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert: + raise TokenTypeError("Broker could not get an SSH Cert: {}...".format( + return_value["access_token"][:8])) + granted_scopes = result.get_granted_scopes() # New in pymsalruntime 0.3.x + if granted_scopes: + return_value["scope"] = " ".join(granted_scopes) # Mimic the on-the-wire data format + return return_value + + +def _get_new_correlation_id(): + return str(uuid.uuid4()) + + +def _enable_msa_pt(params): + params.set_additional_parameter("msal_request_type", "consumer_passthrough") # PyMsalRuntime 0.8+ + + +def _signin_silently( + authority, client_id, scopes, correlation_id=None, claims=None, + enable_msa_pt=False, + **kwargs): + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + if claims: + params.set_decoded_claims(claims) + callback_data = _CallbackData() + for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. + if v is not None: + params.set_additional_parameter(k, str(v)) + if enable_msa_pt: + _enable_msa_pt(params) + pymsalruntime.signin_silently( + params, + correlation_id or _get_new_correlation_id(), + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _signin_interactively( + authority, client_id, scopes, + parent_window_handle, # None means auto-detect for console apps + prompt=None, + login_hint=None, + claims=None, + correlation_id=None, + enable_msa_pt=False, + **kwargs): + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + params.set_redirect_uri("placeholder") # pymsalruntime 0.1 requires non-empty str, + # the actual redirect_uri will be overridden by a value hardcoded by the broker + if prompt: + if prompt == "select_account": + if login_hint: + # FWIW, AAD's browser interactive flow would honor select_account + # and ignore login_hint in such a case. + # But pymsalruntime 0.3.x would pop up a meaningless account picker + # and then force the account_hint user to re-input password. Not what we want. + # https://identitydivision.visualstudio.com/Engineering/_workitems/edit/1744492 + login_hint = None # Mimicing the AAD behavior + logger.warning("Using both select_account and login_hint is ambiguous. Ignoring login_hint.") + else: + logger.warning("prompt=%s is not supported by this module", prompt) + if parent_window_handle is None: + # This fixes account picker hanging in IDE debug mode on some machines + params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1 + if enable_msa_pt: + _enable_msa_pt(params) + for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. + if v is not None: + params.set_additional_parameter(k, str(v)) + if claims: + params.set_decoded_claims(claims) + callback_data = _CallbackData() + pymsalruntime.signin_interactively( + parent_window_handle or pymsalruntime.get_console_window() or pymsalruntime.get_desktop_window(), # Since pymsalruntime 0.2+ + params, + correlation_id or _get_new_correlation_id(), + login_hint, # None value will be accepted since pymsalruntime 0.3+ + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _acquire_token_silently( + authority, client_id, account_id, scopes, claims=None, correlation_id=None, + **kwargs): + # For MSA PT scenario where you use the /organizations, yes, + # acquireTokenSilently is expected to fail. - Sam Wilson + correlation_id = correlation_id or _get_new_correlation_id() + account = _read_account_by_id(account_id, correlation_id) + if isinstance(account, pymsalruntime.MSALRuntimeError): + return _convert_error(account, client_id) + if account is None: + return + params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority) + params.set_requested_scopes(scopes) + if claims: + params.set_decoded_claims(claims) + for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc. + if v is not None: + params.set_additional_parameter(k, str(v)) + callback_data = _CallbackData() + pymsalruntime.acquire_token_silently( + params, + correlation_id, + account, + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + return _convert_result( + callback_data.result, client_id, expected_token_type=kwargs.get("token_type")) + + +def _signout_silently(client_id, account_id, correlation_id=None): + correlation_id = correlation_id or _get_new_correlation_id() + account = _read_account_by_id(account_id, correlation_id) + if isinstance(account, pymsalruntime.MSALRuntimeError): + return _convert_error(account, client_id) + if account is None: + return + callback_data = _CallbackData() + pymsalruntime.signout_silently( # New in PyMsalRuntime 0.7 + client_id, + correlation_id, + account, + lambda result, callback_data=callback_data: callback_data.complete(result)) + callback_data.signal.wait() + error = callback_data.result.get_error() + if error: + return _convert_error(error, client_id) + diff --git a/msal/token_cache.py b/msal/token_cache.py index f7d9f955..dc26e843 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -12,6 +12,10 @@ def is_subdict_of(small, big): return dict(big, **small) == big +def _get_username(id_token_claims): + return id_token_claims.get( + "preferred_username", # AAD + id_token_claims.get("upn")) # ADFS 2019 class TokenCache(object): """This is considered as a base class containing minimal cache behavior. @@ -149,10 +153,9 @@ def __add(self, event, now=None): 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 response.get("id_token_claims", {})) # Broker would provide id_token_claims + id_token_claims = response.get("id_token_claims") or ( # Prefer the claims from broker + # Only use decode_id_token() when necessary, it contains time-sensitive validation + 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 @@ -190,10 +193,11 @@ def __add(self, event, now=None): "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 + "local_account_id": event.get( + "_account_id", # Came from mid-tier code path. + # Emperically, it is the oid in AAD or cid in MSA. + id_token_claims.get("oid", id_token_claims.get("sub"))), + "username": _get_username(id_token_claims) 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 diff --git a/sample/interactive_sample.py b/sample/interactive_sample.py index 530892e5..98acd29f 100644 --- a/sample/interactive_sample.py +++ b/sample/interactive_sample.py @@ -1,4 +1,7 @@ """ +Prerequisite is documented here: +https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.acquire_token_interactive + The configuration file would look like this: { @@ -30,6 +33,8 @@ # Create a preferably long-lived app instance which maintains a token cache. app = msal.PublicClientApplication( config["client_id"], authority=config["authority"], + #allow_broker=True, # If opted in, you will be guided to meet the prerequisites, when applicable + # See also: https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam#wam-value-proposition # token_cache=... # Default cache is in memory only. # You can learn how to use SerializableTokenCache from # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache @@ -55,6 +60,7 @@ print("A local browser window will be open for you to sign in. CTRL+C to cancel.") result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost config["scope"], + #parent_window_handle=..., # If broker is enabled, you will be guided to provide a window handle login_hint=config.get("username"), # Optional. # If you know the username ahead of time, this parameter can pre-fill # the username (or email address) field of the sign-in page for the user, diff --git a/setup.py b/setup.py index 814627f9..1384634d 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,14 @@ # https://cryptography.io/en/latest/api-stability/#deprecation "mock;python_version<'3.3'", - ] + ], + extras_require={ # It does not seem to work if being defined inside setup.cfg + "broker": [ + # The broker is defined as optional dependency, + # so that downstream apps can opt in. The opt-in is needed, partially because + # most existing MSAL Python apps do not have the redirect_uri needed by broker. + "pymsalruntime>=0.11.2,<0.12;python_version>='3.6' and platform_system=='Windows'", + ], + }, ) diff --git a/tests/msaltest.py b/tests/msaltest.py index c1ef1e7c..5d33f2c2 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -74,12 +74,14 @@ def _acquire_token_interactive(app, scopes, data=None): # login_hint is unnecessary when prompt=select_account, # but we still let tester input login_hint, just for testing purpose. [None] + [a["username"] for a in app.get_accounts()], - header="login_hint? (If you have multiple signed-in sessions in browser, and you specify a login_hint to match one of them, you will bypass the account picker.)", + header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)", accept_nonempty_string=True, ) login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint result = app.acquire_token_interactive( - scopes, prompt=prompt, login_hint=login_hint, data=data or {}) + scopes, + parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app + prompt=prompt, login_hint=login_hint, data=data or {}) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") if signed_in_user != login_hint: @@ -127,9 +129,13 @@ def remove_account(app): app.remove_account(account) print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"])) -def exit(_): +def exit(app): """Exit""" - bug_link = "https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose" + bug_link = ( + "https://identitydivision.visualstudio.com/Engineering/_queries/query/79b3a352-a775-406f-87cd-a487c382a8ed/" + if app._enable_broker else + "https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose" + ) print("Bye. If you found a bug, please report it here: {}".format(bug_link)) sys.exit() @@ -155,6 +161,7 @@ def main(): header="Input authority (Note that MSA-PT apps would NOT use the /common authority)", accept_nonempty_string=True, ), + allow_broker=_input_boolean("Allow broker? (Azure CLI currently only supports @microsoft.com accounts when enabling broker)"), ) if _input_boolean("Enable MSAL Python's DEBUG log?"): logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 00000000..bb7d928e --- /dev/null +++ b/tests/test_broker.py @@ -0,0 +1,63 @@ +from tests import unittest +import logging +import sys + +if not sys.platform.startswith("win"): + raise unittest.SkipTest("Currently, our broker supports Windows") +from msal.broker import ( # Import them after the platform check + _signin_silently, _signin_interactively, _acquire_token_silently, RedirectUriError, + _signout_silently, _read_account_by_id, + ) + + +logging.basicConfig(level=logging.DEBUG) + +class BrokerTestCase(unittest.TestCase): + """These are the unit tests for the thin broker.py layer. + + It currently hardcodes some test apps which might be changed/gone in the future. + The existing test_e2e.py is sophisticated to pull test configuration securely from lab. + """ + _client_id = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" # Visual Studio + _authority = "https://login.microsoftonline.com/common" + _scopes = ["https://graph.microsoft.com/.default"] + + def test_signin_interactive_then_acquire_token_silent_then_signout(self): + result = _signin_interactively(self._authority, self._client_id, self._scopes, None) + self.assertIsNotNone(result.get("access_token"), result) + + account_id = result["_account_id"] + self.assertIsNotNone(_read_account_by_id(account_id, "correlation_id")) + result = _acquire_token_silently( + self._authority, self._client_id, account_id, self._scopes) + self.assertIsNotNone(result.get("access_token"), result) + + signout_error = _signout_silently(self._client_id, account_id) + self.assertIsNone(signout_error, msg=signout_error) + account = _read_account_by_id(account_id, "correlation_id") + self.assertIsNotNone(account, msg="pymsalruntime still has this account") + result = _acquire_token_silently( + self._authority, self._client_id, account_id, self._scopes) + self.assertIn("Status_AccountUnusable", result.get("error_description", "")) + + def test_unconfigured_app_should_raise_exception(self): + app_without_needed_redirect_uri = "289a413d-284b-4303-9c79-94380abe5d22" + with self.assertRaises(RedirectUriError): + _signin_interactively( + self._authority, app_without_needed_redirect_uri, self._scopes, None) + # Note: _acquire_token_silently() would raise same exception, + # we skip its test here due to the lack of a valid account_id + + def test_signin_interactively_and_select_account(self): + print("An account picker UI will pop up. See whether the auth result matches your account") + result = _signin_interactively( + self._authority, self._client_id, self._scopes, None, prompt="select_account") + self.assertIsNotNone(result.get("access_token"), result) + if "access_token" in result: + result["access_token"] = "********" + import pprint; pprint.pprint(result) + + def test_signin_silently(self): + result = _signin_silently(self._authority, self._client_id, self._scopes) + self.assertIsNotNone(result.get("access_token"), result) + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9a971f46..57ef8208 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -134,6 +134,31 @@ def assertCacheWorksForApp(self, result_from_wire, scope): result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") + @classmethod + def _build_app(cls, + client_id, + client_credential=None, + authority="https://login.microsoftonline.com/common", + scopes=["https://graph.microsoft.com/.default"], # Microsoft Graph + http_client=None, + azure_region=None, + **kwargs): + try: + import pymsalruntime + broker_available = True + except ImportError: + broker_available = False + return (msal.ConfidentialClientApplication + if client_credential else msal.PublicClientApplication)( + client_id, + client_credential=client_credential, + authority=authority, + azure_region=azure_region, + http_client=http_client or MinimalHttpClient(), + allow_broker=broker_available # This way, we reuse same test cases, by run them with and without broker + and not client_credential, + ) + 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 @@ -141,9 +166,9 @@ def _test_username_password(self, http_client=None, **ignored): assert authority and client_id and username and password and scope - self.app = msal.ClientApplication( + self.app = self._build_app( client_id, authority=authority, - http_client=http_client or MinimalHttpClient(), + http_client=http_client, 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) @@ -158,8 +183,7 @@ def _test_username_password(self, 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()) + self.app = self._build_app(client_id, authority=authority) 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)) @@ -188,13 +212,13 @@ def _test_acquire_token_interactive( prompt=None, **ignored): assert client_id and authority and scope - self.app = msal.PublicClientApplication( - client_id, authority=authority, http_client=MinimalHttpClient()) + self.app = self._build_app(client_id, authority=authority) result = self.app.acquire_token_interactive( scope, prompt=prompt, timeout=120, port=port, + parent_window_handle=self.app.CONSOLE_WINDOW_HANDLE, # This test app is a console app welcome_template= # This is an undocumented feature for testing """

    {id}

    1. Get a username from the upn shown at here
    2. From bcefb10b3dc7ede1116c9f0f77bf50c5c4e0553a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 29 Aug 2022 12:31:06 -0700 Subject: [PATCH 370/440] Refine document --- msal/application.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/msal/application.py b/msal/application.py index a16e1844..ef2cfd90 100644 --- a/msal/application.py +++ b/msal/application.py @@ -418,11 +418,12 @@ def __init__( :param boolean allow_broker: Brokers provide Single-Sign-On, device identification, and application identification verification. + This flag defaults to None, which means MSAL will not utilize broker. If this parameter is set to True, - MSAL will use the broker and return either a token or an error, - when your scenario is supported by a broker, - otherwise it will automatically fall back to non-broker behavior. - This also means you could set this flag as True universally, + MSAL will use the broker whenever possible, + and automatically fall back to non-broker behavior. + That also means your app does not need to enable broker conditionally, + you can always set allow_broker to True, as long as your app meets the following prerequisite: * Installed optional dependency, e.g. ``pip install msal[broker]>=1.20,<2``. From 3773e00d5d43cac033b1bcda7186295a1e2b514e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 12 Sep 2022 14:36:57 -0700 Subject: [PATCH 371/440] Gracefully handle RuntimeError upfront --- msal/application.py | 112 ++++++++++++++++++++------------------------ msal/broker.py | 5 +- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/msal/application.py b/msal/application.py index ef2cfd90..66370f57 100644 --- a/msal/application.py +++ b/msal/application.py @@ -505,10 +505,17 @@ def __init__( isinstance(self, ConfidentialClientApplication) or self.client_credential) if is_confidential_app and allow_broker: raise ValueError("allow_broker=True is only supported in PublicClientApplication") - self._enable_broker = bool( - allow_broker and not is_confidential_app - and sys.platform == "win32" - and not self.authority.is_adfs and not self.authority._is_b2c) + self._enable_broker = False + if (allow_broker and not is_confidential_app + and sys.platform == "win32" + and not self.authority.is_adfs and not self.authority._is_b2c): + try: + from . import broker # Trigger Broker's initialization + self._enable_broker = True + except RuntimeError: + logger.exception( + "Broker is unavailable on this platform. " + "We will fallback to non-broker.") logger.debug("Broker enabled? %s", self._enable_broker) self.token_cache = token_cache or TokenCache() @@ -1072,14 +1079,10 @@ def remove_account(self, account): """Sign me out and forget me from token cache""" self._forget_me(account) if self._enable_broker: - try: - from .broker import _signout_silently - except RuntimeError: # TODO: TBD - logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") - else: - error = _signout_silently(self.client_id, account["local_account_id"]) - if error: - logger.debug("_signout_silently() returns error: %s", error) + from .broker import _signout_silently + error = _signout_silently(self.client_id, account["local_account_id"]) + if error: + logger.debug("_signout_silently() returns error: %s", error) def _sign_out(self, home_account): # Remove all relevant RTs and ATs from token cache @@ -1312,22 +1315,18 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( return self._acquire_token_by_cloud_shell(scopes, data=data) if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert": - try: - from .broker import _acquire_token_silently - except RuntimeError: # TODO: TBD - logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") - else: - response = _acquire_token_silently( - "https://{}/{}".format(self.authority.instance, self.authority.tenant), - self.client_id, - account["local_account_id"], - scopes, - claims=_merge_claims_challenge_and_capabilities( - self._client_capabilities, claims_challenge), - correlation_id=correlation_id, - **data) - if response: # The broker provided a decisive outcome, so we use it - return self._process_broker_response(response, scopes, data) + from .broker import _acquire_token_silently + response = _acquire_token_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + account["local_account_id"], + scopes, + claims=_merge_claims_challenge_and_capabilities( + self._client_capabilities, claims_challenge), + correlation_id=correlation_id, + **data) + if response: # The broker provided a decisive outcome, so we use it + return self._process_broker_response(response, scopes, data) result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( authority, self._decorate_scope(scopes), account, @@ -1533,24 +1532,20 @@ def acquire_token_by_username_password( claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) if self._enable_broker: - try: - from .broker import _signin_silently - except RuntimeError: # TODO: TBD - logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") - else: - response = _signin_silently( - "https://{}/{}".format(self.authority.instance, self.authority.tenant), - self.client_id, - scopes, # Decorated scopes won't work due to offline_access - MSALRuntime_Username=username, - MSALRuntime_Password=password, - validateAuthority="no" - if self.authority._validate_authority is False - or self.authority.is_adfs or self.authority._is_b2c - else None, - claims=claims, - ) - return self._process_broker_response(response, scopes, kwargs.get("data", {})) + from .broker import _signin_silently + response = _signin_silently( + "https://{}/{}".format(self.authority.instance, self.authority.tenant), + self.client_id, + scopes, # Decorated scopes won't work due to offline_access + MSALRuntime_Username=username, + MSALRuntime_Password=password, + validateAuthority="no" + if self.authority._validate_authority is False + or self.authority.is_adfs or self.authority._is_b2c + else None, + claims=claims, + ) + return self._process_broker_response(response, scopes, kwargs.get("data", {})) scopes = self._decorate_scope(scopes) telemetry_context = self._build_telemetry_context( @@ -1759,20 +1754,17 @@ def acquire_token_interactive( "04f0c124-f2bc-4f59-8241-bf6df9866bbd", # Visual Studio ] and data.get("token_type") != "ssh-cert" # Work around a known issue as of PyMsalRuntime 0.8 ) - try: - return self._acquire_token_interactive_via_broker( - scopes, - parent_window_handle, - enable_msa_passthrough, - claims, - data, - on_before_launching_ui, - prompt=prompt, - login_hint=login_hint, - max_age=max_age, - ) - except RuntimeError: # TODO: TBD - logger.debug("Broker is unavailable on this platform. Fallback to non-broker.") + return self._acquire_token_interactive_via_broker( + scopes, + parent_window_handle, + enable_msa_passthrough, + claims, + data, + on_before_launching_ui, + prompt=prompt, + login_hint=login_hint, + max_age=max_age, + ) on_before_launching_ui(ui="browser") telemetry_context = self._build_telemetry_context( diff --git a/msal/broker.py b/msal/broker.py index e9bbe28c..1443505b 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -10,8 +10,7 @@ logger = logging.getLogger(__name__) try: - import pymsalruntime # ImportError would be raised on unsupported platforms such as Windows 8 - # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi + import pymsalruntime # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi pymsalruntime.register_logging_callback(lambda message, level: { # New in pymsalruntime 0.7 pymsalruntime.LogLevel.TRACE: logger.debug, # Python has no TRACE level pymsalruntime.LogLevel.DEBUG: logger.debug, @@ -26,7 +25,7 @@ # https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files raise ImportError( # TODO: Remove or adjust this line right before merging this PR 'You need to install dependency by: pip install "msal[broker]>=1.20.0b1,<2"') -# Other exceptions (possibly RuntimeError) would be raised if its initialization fails +# It could throw RuntimeError when running on ancient versions of Windows class RedirectUriError(ValueError): From 5ca97eea08f2cecfe1e9d726d1d1a7a182967a6f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 14 Sep 2022 16:13:59 -0700 Subject: [PATCH 372/440] Implement instance_discovery only --- msal/application.py | 78 +++++++++++++++++++++++++++++++++-------- msal/authority.py | 44 +++++++++++++++-------- tests/test_authority.py | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 30 deletions(-) diff --git a/msal/application.py b/msal/application.py index 62fc6cb8..61e61580 100644 --- a/msal/application.py +++ b/msal/application.py @@ -13,7 +13,7 @@ from .oauth2cli import Client, JwtAssertionCreator from .oauth2cli.oidc import decode_part -from .authority import Authority +from .authority import Authority, WORLD_WIDE from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request from .wstrust_response import * @@ -146,7 +146,6 @@ def obtain_token_by_username_password(self, username, password, **kwargs): class ClientApplication(object): - ACQUIRE_TOKEN_SILENT_ID = "84" ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85" ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301" @@ -174,6 +173,7 @@ def __init__( # when we would eventually want to add this feature to PCA in future. exclude_scopes=None, http_cache=None, + instance_discovery=None, ): """Create an instance of application. @@ -409,11 +409,40 @@ def __init__( Personally Identifiable Information (PII). Encryption is unnecessary. New in version 1.16.0. + + :param boolean instance_discovery: + Historically, MSAL would connect to a central endpoint located at + ``https://login.microsoftonline.com`` to acquire some metadata, + especially when using an unfamiliar authority. + This behavior is known as Instance Discovery. + + This parameter defaults to None, which enables the Instance Discovery. + + If you know some authorities which you allow MSAL to operate with as-is, + without involving any Instance Discovery, the recommended pattern is:: + + known_authorities = frozenset([ # Treat your known authorities as const + "https://contoso.com/adfs", "https://login.azs/foo"]) + ... + authority = "https://contoso.com/adfs" # Assuming your app will use this + app1 = PublicClientApplication( + "client_id", + authority=authority, + # Conditionally disable Instance Discovery for known authorities + instance_discovery=authority not in known_authorities, + ) + + If you do not know some authorities beforehand, + yet still want MSAL to accept any authority that you will provide, + you can use a ``False`` to unconditionally disable Instance Discovery. + + New in version 1.19.0. """ self.client_id = client_id self.client_credential = client_credential self.client_claims = client_claims self._client_capabilities = client_capabilities + self._instance_discovery = instance_discovery if exclude_scopes and not isinstance(exclude_scopes, list): raise ValueError( @@ -453,9 +482,13 @@ def __init__( # Here the self.authority will not be the same type as authority in input try: + authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE) self.authority = Authority( - authority or "https://login.microsoftonline.com/common/", - self.http_client, validate_authority=validate_authority) + authority_to_use, + self.http_client, + validate_authority=validate_authority, + instance_discovery=self._instance_discovery, + ) except ValueError: # Those are explicit authority validation errors raise except Exception: # The rest are typically connection errors @@ -463,8 +496,10 @@ def __init__( # 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) + authority_to_use, + self.http_client, + instance_discovery=False, + ) else: raise @@ -534,10 +569,11 @@ def _get_regional_authority(self, central_authority): "sts.windows.net", ) else "{}.{}".format(region_to_use, central_authority.instance)) - return Authority( + return Authority( # The central_authority has already been validated "https://{}/{}".format(regional_host, central_authority.tenant), self.http_client, - validate_authority=False) # The central_authority has already been validated + instance_discovery=False, + ) return None def _build_client(self, client_credential, authority, skip_regional_client=False): @@ -789,7 +825,8 @@ def get_authorization_request_url( # Multi-tenant app can use new authority on demand the_authority = Authority( authority, - self.http_client + self.http_client, + instance_discovery=self._instance_discovery, ) if authority else self.authority client = _ClientWithCcsRoutingInfo( @@ -1012,14 +1049,23 @@ def _find_msal_accounts(self, environment): } return list(grouped_accounts.values()) + def _get_instance_metadata(self): # This exists so it can be mocked in unit test + 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", # TBD: We may extend this to use self._instance_discovery endpoint + headers={'Accept': 'application/json'}) + resp.raise_for_status() + return json.loads(resp.text)['metadata'] + def _get_authority_aliases(self, instance): + if self._instance_discovery is False: + return [] + if self.authority._is_known_to_developer: + # Then it is an ADFS/B2C/known_authority_hosts situation + # which may not reach the central endpoint, so we skip it. + return [] 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']] + set(group['aliases']) for group in self._get_instance_metadata()] for group in self.authority_groups: if instance in group: return [alias for alias in group if alias != instance] @@ -1168,6 +1214,7 @@ def acquire_token_silent_with_error( # the_authority = Authority( # authority, # self.http_client, + # instance_discovery=self._instance_discovery, # ) if authority else self.authority result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, self.authority, force_refresh=force_refresh, @@ -1189,7 +1236,8 @@ def acquire_token_silent_with_error( the_authority = Authority( "https://" + alias + "/" + self.authority.tenant, self.http_client, - validate_authority=False) + instance_discovery=False, + ) result = self._acquire_token_silent_from_cache_and_possibly_refresh_it( scopes, account, the_authority, force_refresh=force_refresh, claims_challenge=claims_challenge, diff --git a/msal/authority.py b/msal/authority.py index 81788200..216fed5d 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -58,7 +58,11 @@ def http_client(self): # Obsolete. We will remove this eventually "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): + def __init__( + self, authority_url, http_client, + validate_authority=True, + instance_discovery=None, + ): """Creates an authority instance, and also validates it. :param validate_authority: @@ -67,19 +71,34 @@ def __init__(self, authority_url, http_client, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ + # :param instance_discovery: + # By default, the known-to-Microsoft validation will use an + # instance discovery endpoint located at ``login.microsoftonline.com``. + # You can customize the endpoint by providing a url as a string. + # Or you can turn this behavior off by passing in a False here. self._http_client = http_client if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) + self.is_adfs = tenant.lower() == 'adfs' 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( + is_b2c = any( + self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS + ) or (len(parts) == 3 and parts[2].lower().startswith("b2c_")) + self._is_known_to_developer = self.is_adfs or is_b2c or not validate_authority + is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS + instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only + 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 + ) if instance_discovery in (None, True) else instance_discovery + if instance_discovery_endpoint and not ( + is_known_to_microsoft or self._is_known_to_developer): + payload = _instance_discovery( "https://{}{}/oauth2/v2.0/authorize".format( self.instance, authority.path), - self._http_client) + self._http_client, + instance_discovery_endpoint) if payload.get("error") == "invalid_instance": raise ValueError( "invalid_instance: " @@ -113,7 +132,6 @@ def __init__(self, authority_url, http_client, validate_authority=True): 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", @@ -145,13 +163,9 @@ def canonicalize(authority_url): % 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 - ), +def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs): + resp = http_client.get( + instance_discovery_endpoint, params={'authorization_endpoint': url, 'api-version': '1.0'}, **kwargs) return json.loads(resp.text) diff --git a/tests/test_authority.py b/tests/test_authority.py index fc6e12fc..dd91afbb 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -1,5 +1,10 @@ import os +try: + from unittest.mock import patch +except: + from mock import patch +import msal from msal.authority import * from tests import unittest from tests.http_client import MinimalHttpClient @@ -123,3 +128,64 @@ class MockResponse(object): finally: # MUST NOT let the previous test changes affect other test cases Authority._domains_without_user_realm_discovery = set([]) + +@patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": "https://contoso.com/placeholder", + "token_endpoint": "https://contoso.com/placeholder", + }) +@patch("msal.authority._instance_discovery") +@patch.object(msal.ClientApplication, "_get_instance_metadata", return_value=[]) +class TestMsalBehaviorsWithoutAndWithInstanceDiscoveryBoolean(unittest.TestCase): + """Test cases use ClientApplication, which is a base class of both PCA and CCA""" + + def test_by_default_a_known_to_microsoft_authority_should_skip_validation_but_still_use_instance_metadata( + self, instance_metadata, known_to_microsoft_validation, _): + app = msal.ClientApplication("id", authority="https://login.microsoftonline.com/common") + known_to_microsoft_validation.assert_not_called() + app.get_accounts() # This could make an instance metadata call for authority aliases + instance_metadata.assert_called_once_with() + + def test_validate_authority_boolean_should_skip_validation_and_instance_metadata( + self, instance_metadata, known_to_microsoft_validation, _): + """Pending deprecation, but kept for backward compatibility, for now""" + app = msal.ClientApplication( + "id", authority="https://contoso.com/common", validate_authority=False) + known_to_microsoft_validation.assert_not_called() + app.get_accounts() # This could make an instance metadata call for authority aliases + instance_metadata.assert_not_called() + + def test_by_default_adfs_should_skip_validation_and_instance_metadata( + self, instance_metadata, known_to_microsoft_validation, _): + """Not strictly required, but when/if we already supported it, we better keep it""" + app = msal.ClientApplication("id", authority="https://contoso.com/adfs") + known_to_microsoft_validation.assert_not_called() + app.get_accounts() # This could make an instance metadata call for authority aliases + instance_metadata.assert_not_called() + + def test_by_default_b2c_should_skip_validation_and_instance_metadata( + self, instance_metadata, known_to_microsoft_validation, _): + """Not strictly required, but when/if we already supported it, we better keep it""" + app = msal.ClientApplication( + "id", authority="https://login.b2clogin.com/contoso/b2c_policy") + known_to_microsoft_validation.assert_not_called() + app.get_accounts() # This could make an instance metadata call for authority aliases + instance_metadata.assert_not_called() + + def test_turning_off_instance_discovery_should_work_for_all_kinds_of_clouds( + self, instance_metadata, known_to_microsoft_validation, _): + for authority in [ + "https://login.microsoftonline.com/common", # Known to Microsoft + "https://contoso.com/adfs", # ADFS + "https://login.b2clogin.com/contoso/b2c_policy", # B2C + "https://private.cloud/foo", # Private Cloud + ]: + self._test_turning_off_instance_discovery_should_skip_authority_validation_and_instance_metadata( + authority, instance_metadata, known_to_microsoft_validation) + + def _test_turning_off_instance_discovery_should_skip_authority_validation_and_instance_metadata( + self, authority, instance_metadata, known_to_microsoft_validation): + app = msal.ClientApplication("id", authority=authority, instance_discovery=False) + known_to_microsoft_validation.assert_not_called() + app.get_accounts() # This could make an instance metadata call for authority aliases + instance_metadata.assert_not_called() + From a0a128609b2e2b866469de9825612c1022e16607 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 19 Sep 2022 20:39:29 -0700 Subject: [PATCH 373/440] MSAL Python 1.19.0 Bump version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 61e61580..caa4b830 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.18.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.19.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From 81b48ce2dd62ffaa336062215d15ce227efc8037 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 19 Sep 2022 20:48:53 -0700 Subject: [PATCH 374/440] Bump up cryptography upperbound We have reviewed https://cryptography.io/en/latest/changelog/ --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 814627f9..64fd07df 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - 'cryptography>=0.6,<40', + 'cryptography>=0.6,<41', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # From 639f1b25e111295f85d4d4e982c761864c5139a1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 20 Sep 2022 22:34:00 -0700 Subject: [PATCH 375/440] Test acquire_token_silent() for confidential client --- tests/test_e2e.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9a971f46..51da742b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -133,6 +133,11 @@ def assertCacheWorksForApp(self, result_from_wire, scope): self.assertEqual( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") + self.app.acquire_token_silent( + # Result will typically be None, because client credential grant returns no RT. + # But we care more on this call should succeed without exception. + scope, account=None, + force_refresh=True) # Mimic the AT already expires def _test_username_password(self, authority=None, client_id=None, username=None, password=None, scope=None, @@ -618,11 +623,12 @@ 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( + self.app = msal.ConfidentialClientApplication( client_id, client_credential=client_secret, authority=authority, http_client=MinimalHttpClient()) - result = app.acquire_token_for_client(scope) + result = self.app.acquire_token_for_client(scope) self.assertIsNotNone(result.get("access_token"), "Got %s instead" % result) + self.assertCacheWorksForApp(result, scope) class WorldWideTestCase(LabBasedTestCase): From 11675e0e24a49f348e950a76348dad14431fb01b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 27 Sep 2022 22:25:15 -0700 Subject: [PATCH 376/440] Adopt pymsalruntime 0.13 --- msal/broker.py | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/msal/broker.py b/msal/broker.py index 1443505b..c9d0e38e 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -90,8 +90,7 @@ def _convert_result(result, client_id, expected_token_type=None): # Mimic an on id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {} account = result.get_account() assert account, "Account is expected to be always available" - ## Note: As of pymsalruntime 0.1.0, only wam_account_ids property is available - #account.get_account_property("wam_account_ids") + # Note: There are more account attribute getters available in pymsalruntime 0.13+ return_value = {k: v for k, v in { "access_token": result.get_access_token(), "expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down diff --git a/setup.py b/setup.py index 31f585b3..effc825c 100644 --- a/setup.py +++ b/setup.py @@ -91,7 +91,7 @@ # The broker is defined as optional dependency, # so that downstream apps can opt in. The opt-in is needed, partially because # most existing MSAL Python apps do not have the redirect_uri needed by broker. - "pymsalruntime>=0.11.2,<0.12;python_version>='3.6' and platform_system=='Windows'", + "pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'", ], }, ) From 962fb0e2af59b7136a07fc9011de8245302e32b5 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 27 Sep 2022 22:33:58 -0700 Subject: [PATCH 377/440] Remove automatic msa-pt for Azure CLI and Visual Studio --- msal/application.py | 26 +++++++++++--------------- tests/msaltest.py | 10 ++++++++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/msal/application.py b/msal/application.py index 5c8b7ba9..73992351 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1766,6 +1766,17 @@ def acquire_token_interactive( - A dict containing an "error" key, when token refresh failed. """ data = kwargs.pop("data", {}) + enable_msa_passthrough = kwargs.pop( # MUST remove it from kwargs + "enable_msa_passthrough", # Keep it as a hidden param, for now. + # OPTIONAL. MSA-Passthrough is a legacy configuration, + # needed by a small amount of Microsoft first-party apps, + # which would login MSA accounts via ".../organizations" authority. + # If you app belongs to this category, AND you are enabling broker, + # you would want to enable this flag. Default value is False. + # More background of MSA-PT is available from this internal docs: + # https://microsoft.sharepoint.com/:w:/t/Identity-DevEx/EatIUauX3c9Ctw1l7AQ6iM8B5CeBZxc58eoQCE0IuZ0VFw?e=tgc3jP&CID=39c853be-76ea-79d7-ee73-f1b2706ede05 + False + ) and data.get("token_type") != "ssh-cert" # Work around a known issue as of PyMsalRuntime 0.8 self._validate_ssh_cert_input_data(data) if not on_before_launching_ui: on_before_launching_ui = lambda **kwargs: None @@ -1786,21 +1797,6 @@ def acquire_token_interactive( logger.warning( "Ignoring parameter extra_scopes_to_consent, " "which is not supported by broker") - enable_msa_passthrough = kwargs.pop( - "enable_msa_passthrough", # Keep it as a hidden param, for now. - # OPTIONAL. MSA-Passthrough is a legacy configuration, - # needed by a small amount of Microsoft first-party apps, - # which would login MSA accounts via ".../organizations" authority. - # If you app belongs to this category, AND you are enabling broker, - # you would want to enable this flag. Default value is equivalent to False. - self.client_id in [ - # Experimental: Automatically enable MSA-PT mode for known MSA-PT apps - # More background of MSA-PT is available from this internal docs: - # https://microsoft.sharepoint.com/:w:/t/Identity-DevEx/EatIUauX3c9Ctw1l7AQ6iM8B5CeBZxc58eoQCE0IuZ0VFw?e=tgc3jP&CID=39c853be-76ea-79d7-ee73-f1b2706ede05 - "04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI - "04f0c124-f2bc-4f59-8241-bf6df9866bbd", # Visual Studio - ] and data.get("token_type") != "ssh-cert" # Work around a known issue as of PyMsalRuntime 0.8 - ) return self._acquire_token_interactive_via_broker( scopes, parent_window_handle, diff --git a/tests/msaltest.py b/tests/msaltest.py index 5d33f2c2..cc4e5606 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -1,6 +1,9 @@ import getpass, logging, pprint, sys, msal +AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" +VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" + def _input_boolean(message): return input( "{} (N/n/F/f or empty means False, otherwise it is True): ".format(message) @@ -81,6 +84,9 @@ def _acquire_token_interactive(app, scopes, data=None): result = app.acquire_token_interactive( scopes, parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app + enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right + AZURE_CLI, VISUAL_STUDIO, + ], # Here this test app mimics the setting for some known MSA-PT apps prompt=prompt, login_hint=login_hint, data=data or {}) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") @@ -142,8 +148,8 @@ def exit(app): def main(): print("Welcome to the Msal Python Console Test App, committed at 2022-5-2\n") chosen_app = _select_options([ - {"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI (Correctly configured for MSA-PT)"}, - {"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"}, + {"client_id": AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"}, + {"client_id": VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"}, {"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"}, ], option_renderer=lambda a: a["name"], From a08d6cd65574d502bfc8c338f791df569c26411e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 29 Sep 2022 19:11:05 -0700 Subject: [PATCH 378/440] Refactor test infrastructure to expose a known bug Apply the refactor to similar code path --- msal/application.py | 5 +- msal/broker.py | 2 +- tests/test_e2e.py | 113 +++++++++++++++++++++++++++++++------------- 3 files changed, 85 insertions(+), 35 deletions(-) diff --git a/msal/application.py b/msal/application.py index 73992351..b1324284 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1857,6 +1857,7 @@ def _acquire_token_interactive_via_broker( if login_hint and prompt != "select_account": # OIDC prompts when the user did not sign in accounts = self.get_accounts(username=login_hint) if len(accounts) == 1: # Unambiguously proceed with this account + logger.debug("Calling broker._acquire_token_silently()") response = _acquire_token_silently( # When it works, it bypasses prompt authority, self.client_id, @@ -1868,6 +1869,7 @@ def _acquire_token_interactive_via_broker( return self._process_broker_response(response, scopes, data) # login_hint undecisive or not exists if prompt == "none" or not prompt: # Must/Can attempt _signin_silently() + logger.debug("Calling broker._signin_silently()") response = _signin_silently( # Unlike OIDC, it doesn't honor login_hint authority, self.client_id, scopes, validateAuthority=validate_authority, @@ -1903,7 +1905,8 @@ def _acquire_token_interactive_via_broker( pass # It will fall back to the _signin_interactively() else: return self._process_broker_response(response, scopes, data) - # Falls back to _signin_interactively() + + logger.debug("Falls back to broker._signin_interactively()") on_before_launching_ui(ui="broker") response = _signin_interactively( authority, self.client_id, scopes, diff --git a/msal/broker.py b/msal/broker.py index c9d0e38e..f1de262c 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -144,7 +144,7 @@ def _signin_silently( def _signin_interactively( authority, client_id, scopes, parent_window_handle, # None means auto-detect for console apps - prompt=None, + prompt=None, # Note: This function does not really use this parameter login_hint=None, claims=None, correlation_id=None, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 607af6e4..cd3ee467 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -54,6 +54,25 @@ def _get_app_and_auth_code( assert ac is not None return (app, ac, redirect_uri) +def _render(url, description=None): + # Render a url in html if description is available, otherwise return url as-is + return "{description}".format( + url=url, description=description) if description else url + + +def _get_hint(html_mode=None, username=None, lab_name=None, username_uri=None): + return "Sign in with {user} whose password is available from {lab}".format( + user=("{}".format(username) if html_mode else username) + if username + else "the upn from {}".format(_render( + username_uri, description="here" if html_mode else None)), + lab=_render( + "https://aka.ms/GetLabUserSecret?Secret=" + (lab_name or "msidlabXYZ"), + description="this password api" if html_mode else None, + ), + ) + + @unittest.skipIf(os.getenv("TRAVIS_TAG"), "Skip e2e tests during tagged release") class E2eTestCase(unittest.TestCase): @@ -212,25 +231,31 @@ def _test_device_flow( def _test_acquire_token_interactive( self, client_id=None, authority=None, scope=None, port=None, - username_uri="", # But you would want to provide one + username=None, lab_name=None, + username_uri="", # Unnecessary if you provided username and lab_name data=None, # Needed by ssh-cert feature prompt=None, + enable_msa_passthrough=None, **ignored): assert client_id and authority and scope self.app = self._build_app(client_id, authority=authority) + logger.info(_get_hint( # Useful when testing broker which shows no welcome_template + username=username, lab_name=lab_name, username_uri=username_uri)) result = self.app.acquire_token_interactive( scope, + login_hint=username, prompt=prompt, timeout=120, port=port, parent_window_handle=self.app.CONSOLE_WINDOW_HANDLE, # This test app is a console app + enable_msa_passthrough=enable_msa_passthrough, # Needed when testing MSA-PT app welcome_template= # This is an undocumented feature for testing """

      {id}

        -
      1. Get a username from the upn shown at here
      2. -
      3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ - (replace the lab name with the labName from the link above).
      4. +
      5. {hint}
      6. Sign In or Abort
      7. -
      """.format(id=self.id(), username_uri=username_uri), +
    """.format(id=self.id(), hint=_get_hint( + html_mode=True, + username=username, lab_name=lab_name, username_uri=username_uri)), data=data or {}, ) self.assertIn( @@ -239,6 +264,11 @@ def _test_acquire_token_interactive( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) + if username and result.get("id_token_claims", {}).get("preferred_username"): + self.assertEqual( + username, result["id_token_claims"]["preferred_username"], + "You are expected to sign in as account {}, but tokens returned is for {}".format( + username, result["id_token_claims"]["preferred_username"])) self.assertCacheWorksForUser(result, scope, username=None, data=data or {}) return result # For further testing @@ -260,7 +290,7 @@ def test_ssh_cert_for_service_principal(self): 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): + def test_ssh_cert_for_user_should_work_with_any_account(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 @@ -555,7 +585,8 @@ def _test_acquire_token_by_auth_code( 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 + username=None, lab_name=None, + username_uri="", # Optional if you provided username and lab_name **ignored): assert client_id and authority and scope self.app = msal.ClientApplication( @@ -568,11 +599,11 @@ def _test_acquire_token_by_auth_code_flow( 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. -
    3. Get its password from https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ - (replace the lab name with the labName from the link above).
    4. +
    5. {hint}
    6. Sign In or Abort
    7. -
    """.format(id=self.id(), username_uri=username_uri), + """.format(id=self.id(), hint=_get_hint( + html_mode=True, + username=username, lab_name=lab_name, username_uri=username_uri)), ) if auth_response is None: self.skipTest("Timed out. Did not have test settings in hand? Prepare and retry.") @@ -592,6 +623,11 @@ def _test_acquire_token_by_auth_code_flow( # Note: No interpolation here, cause error won't always present error=result.get("error"), error_description=result.get("error_description"))) + if username and result.get("id_token_claims", {}).get("preferred_username"): + self.assertEqual( + username, result["id_token_claims"]["preferred_username"], + "You are expected to sign in as account {}, but tokens returned is for {}".format( + username, result["id_token_claims"]["preferred_username"])) self.assertCacheWorksForUser(result, scope, username=None) def _test_acquire_token_obo(self, config_pca, config_cca, @@ -689,10 +725,23 @@ def test_adfs2019_fed_user(self): @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) + self._test_acquire_token_interactive(**self.get_lab_user(usertype="cloud")) + + @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") + def test_msa_pt_app_signin_via_organizations_authority_without_login_hint(self): + """There is/was an upstream bug. See test case full docstring for the details. + + When a MSAL-PT flow that account control is launched, user has 2+ AAD accounts in WAM, + selects an AAD account that is NOT the default AAD account from the OS, + it will incorrectly get tokens for default AAD account. + """ + self._test_acquire_token_interactive(**dict( + self.get_lab_user(usertype="cloud"), # This is generally not the current laptop's default AAD account + authority="https://login.microsoftonline.com/organizations", + client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI is an MSA-PT app + enable_msa_passthrough=True, + prompt="select_account", # In MSAL Python, this resets login_hint + )) 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 @@ -719,22 +768,22 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): @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) + self._test_acquire_token_by_auth_code_flow(**dict( + config, + authority="https://fs.%s.com/adfs" % config["lab_name"], + scope=self.adfs2019_scopes, + port=8080, + )) @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) + self._test_acquire_token_interactive(**dict( + config, + authority="https://fs.%s.com/adfs" % config["lab_name"], + scope=self.adfs2019_scopes, + port=8080, + )) @unittest.skipUnless( os.getenv("LAB_OBO_CLIENT_SECRET"), @@ -816,14 +865,12 @@ def test_b2c_acquire_token_by_auth_code(self): @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( + self._test_acquire_token_by_auth_code_flow(**dict( + self.get_lab_user(usertype="b2c", b2cprovider="local"), 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", - ) + scope=self.get_lab_app_object(azureenvironment="azureb2ccloud")["scopes"], + )) def test_b2c_acquire_token_by_ropc(self): config = self.get_lab_app_object(azureenvironment="azureb2ccloud") From 3696acb6e19ad8f0ba968665964807fdf6e9e26a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 Oct 2022 21:20:32 -0700 Subject: [PATCH 379/440] Add more docs --- msal/application.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index b1324284..8c803f70 100644 --- a/msal/application.py +++ b/msal/application.py @@ -444,9 +444,22 @@ def __init__( New in version 1.19.0. :param boolean allow_broker: - Brokers provide Single-Sign-On, device identification, - and application identification verification. - This flag defaults to None, which means MSAL will not utilize broker. + A broker is a component installed on your device. + Broker implicitly gives your device an identity. By using a broker, + your device becomes a factor that can satisfy MFA (Multi-factor authentication). + This factor would become mandatory + if a tenant's admin enables a corresponding Conditional Access (CA) policy. + The broker's presence allows Microsoft identity platform + to have higher confidence that the tokens are being issued to your device, + and that is more secure. + + An additional benefit of broker is, + it runs as a long-lived process with your device's OS, + and maintains its own cache, + so that your broker-enabled apps (even a CLI) + could automatically SSO from a previously established signed-in session. + + This parameter defaults to None, which means MSAL will not utilize a broker. If this parameter is set to True, MSAL will use the broker whenever possible, and automatically fall back to non-broker behavior. From f9ff1a622a6e1245dcc6a2be5fe0959fdf7534fa Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 Oct 2022 21:32:36 -0700 Subject: [PATCH 380/440] MSAL Python 1.20.0 Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 8c803f70..916f7170 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.20.0b1" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.20.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From 0c737242e33cb4cc4b35a614a0e827e60c9041a8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 6 Oct 2022 21:39:47 -0700 Subject: [PATCH 381/440] Update installation instruction --- msal/broker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/broker.py b/msal/broker.py index f1de262c..8b997c61 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -24,7 +24,7 @@ # PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link # https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files raise ImportError( # TODO: Remove or adjust this line right before merging this PR - 'You need to install dependency by: pip install "msal[broker]>=1.20.0b1,<2"') + 'You need to install dependency by: pip install "msal[broker]>=1.20,<2"') # It could throw RuntimeError when running on ancient versions of Windows From 8026c0204c3b3027ede4704785b9f85f2fb65ead Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 2 Nov 2022 16:27:53 -0700 Subject: [PATCH 382/440] Test matrix covers Python 3.11 --- .github/workflows/python-package.yml | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8950c15a..9c11210f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11-dev"] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/setup.py b/setup.py index effc825c..dd86f2ee 100644 --- a/setup.py +++ b/setup.py @@ -64,6 +64,7 @@ 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], From c79d32e6b7b6d8bff2bfb9c1e2d236d8e99fbac1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 14 Nov 2022 14:51:50 -0800 Subject: [PATCH 383/440] Switch to new region endpoints --- msal/application.py | 2 +- tests/test_e2e.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 916f7170..b469c350 100644 --- a/msal/application.py +++ b/msal/application.py @@ -622,7 +622,7 @@ def _get_regional_authority(self, central_authority): 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) + regional_host = ("{}.login.microsoft.com".format(region_to_use) if central_authority.instance in ( # The list came from point 3 of the algorithm section in this internal doc # https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PinAuthToRegion/AAD%20SDK%20Proposal%20to%20Pin%20Auth%20to%20region.md&anchor=algorithm&_a=preview diff --git a/tests/test_e2e.py b/tests/test_e2e.py index cd3ee467..ae3683e5 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -904,7 +904,7 @@ def _test_acquire_token_for_client(self, configured_region, expected_region): 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_host = '{}.login.microsoft.com'.format( expected_region) if expected_region else 'login.microsoftonline.com' mocked_method.assert_called_with( 'https://{}/{}/oauth2/v2.0/token'.format( From 383fcfc4d61a15f7e023aa7f955b7dd0985f681a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Dec 2022 10:00:19 -0800 Subject: [PATCH 384/440] Test only Python versions available on github's ubuntu 22.04 --- .github/workflows/python-package.yml | 4 ++-- tests/test_authority.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9c11210f..cf56cb2a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -23,10 +23,10 @@ jobs: 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 + runs-on: ubuntu-latest # It switched to 22.04 shortly after 2022-Nov-8 strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: [2.7, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12-dev"] steps: - uses: actions/checkout@v2 diff --git a/tests/test_authority.py b/tests/test_authority.py index dd91afbb..ca0bc68f 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -59,7 +59,10 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self): self.assertNotIn('v2.0', a.token_endpoint) def test_unknown_host_wont_pass_instance_discovery(self): - _assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp) # Hack + _assert = ( + # Was Regexp, added alias Regex in Py 3.2, and Regexp will be gone in Py 3.12 + getattr(self, "assertRaisesRegex", None) or + getattr(self, "assertRaisesRegexp", None)) with _assert(ValueError, "invalid_instance"): Authority('https://example.com/tenant_doesnt_matter_in_this_case', MinimalHttpClient()) From d0e4bb9d3b359e1039cb87ef71913804da7ac250 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 9 Dec 2022 10:50:12 -0800 Subject: [PATCH 385/440] Cleaner skip declaration --- tests/test_e2e.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index ae3683e5..6cf9e629 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -196,6 +196,8 @@ def _test_username_password(self, 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) + self.assertEqual( + self.app.get_accounts(username=username), [], "Cache starts empty") result = self.app.acquire_token_by_username_password( username, password, scopes=scope) self.assertLoosely(result) @@ -204,6 +206,9 @@ def _test_username_password(self, username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C ) + @unittest.skipIf( + os.getenv("TRAVIS"), # It is set when running on TravisCI or Github Actions + "Although it is doable, we still choose to skip device flow to save time") def _test_device_flow( self, client_id=None, authority=None, scope=None, **ignored): assert client_id and authority and scope @@ -229,6 +234,7 @@ def _test_device_flow( logger.info( "%s obtained tokens: %s", self.id(), json.dumps(result, indent=4)) + @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def _test_acquire_token_interactive( self, client_id=None, authority=None, scope=None, port=None, username=None, lab_name=None, @@ -289,7 +295,6 @@ def test_ssh_cert_for_service_principal(self): 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_should_work_with_any_account(self): result = self._test_acquire_token_interactive( client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI is one @@ -524,8 +529,8 @@ 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" + def get_lab_app_object(cls, client_id=None, **query): # https://msidlab.com/swagger/index.html + url = "https://msidlab.com/api/app/{}".format(client_id or "") resp = cls.session.get(url, params=query) result = resp.json()[0] result["scopes"] = [ # Raw data has extra space, such as "s1, s2" @@ -561,6 +566,7 @@ def get_lab_user(cls, **query): # https://docs.msidlab.com/labapi/userapi.html "scope": scope, } + @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def _test_acquire_token_by_auth_code( self, client_id=None, authority=None, port=None, scope=None, **ignored): @@ -583,6 +589,7 @@ def _test_acquire_token_by_auth_code( error_description=result.get("error_description"))) self.assertCacheWorksForUser(result, scope, username=None) + @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def _test_acquire_token_by_auth_code_flow( self, client_id=None, authority=None, port=None, scope=None, username=None, lab_name=None, @@ -723,11 +730,9 @@ def test_adfs2019_fed_user(self): 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): self._test_acquire_token_interactive(**self.get_lab_user(usertype="cloud")) - @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_msa_pt_app_signin_via_organizations_authority_without_login_hint(self): """There is/was an upstream bug. See test case full docstring for the details. @@ -751,7 +756,6 @@ def test_ropc_adfs2019_onprem(self): 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: @@ -765,7 +769,6 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self): 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") self._test_acquire_token_by_auth_code_flow(**dict( @@ -775,7 +778,6 @@ def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self): port=8080, )) - @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") self._test_acquire_token_interactive(**dict( @@ -846,7 +848,6 @@ 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: @@ -863,7 +864,6 @@ def test_b2c_acquire_token_by_auth_code(self): scope=config["scopes"], ) - @unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented") def test_b2c_acquire_token_by_auth_code_flow(self): self._test_acquire_token_by_auth_code_flow(**dict( self.get_lab_user(usertype="b2c", b2cprovider="local"), From 14a4b79260c8deb586867259e8be84fe9061f0ac Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Dec 2022 09:43:51 -0800 Subject: [PATCH 386/440] Fallback to expires_on when expires_in is absent --- msal/token_cache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index dc26e843..0259522f 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -164,8 +164,11 @@ def __add(self, event, now=None): now = int(time.time() if now is None else now) if access_token: + default_expires_in = ( # https://www.rfc-editor.org/rfc/rfc6749#section-5.1 + int(response.get("expires_on")) - now # Some Managed Identity emits this + ) if response.get("expires_on") else 600 expires_in = int( # AADv1-like endpoint returns a string - response.get("expires_in", 3599)) + response.get("expires_in", default_expires_in)) ext_expires_in = int( # AADv1-like endpoint returns a string response.get("ext_expires_in", expires_in)) at = { From 6dc0fdd157e66d3a68aa1e1a13fd17eae5fb8df9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 20 Jan 2023 18:35:26 -0800 Subject: [PATCH 387/440] Show lab api error, useful when trying api params --- tests/test_e2e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 6cf9e629..5c43f4f0 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -551,6 +551,8 @@ def get_lab_user_secret(cls, lab_name="msidlab4"): 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] + assert result.get("upn"), "Found no test user but {}".format( + json.dumps(result, indent=2)) _env = query.get("azureenvironment", "").lower() authority_base = { "azureusgovernment": "https://login.microsoftonline.us/" From 5dfe85eea2a35ecedeb50ef84342c02467cd167d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 21 Oct 2022 16:36:24 -0700 Subject: [PATCH 388/440] Clarify when (not) to read API section --- docs/index.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 95b89b98..b376f52d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,6 +62,16 @@ API === The following section is the API Reference of MSAL Python. +The API Reference is like a dictionary. You **read this API section when and only when**: + +* You already followed our sample(s) above and have your app up and running, + but want to know more on how you could tweak the authentication experience + by using other optional parameters (there are plenty of them!) +* You read the MSAL Python source code and found a helper function that is useful to you, + then you would want to double check whether that helper is documented below. + Only documented APIs are considered part of the MSAL Python public API, + which are guaranteed to be backward-compatible in MSAL Python 1.x series. + Undocumented internal helpers are subject to change anytime, without prior notice. .. note:: From 21bd4220a7b7bc86f7668c7bc6e26c501f9d91ea Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 10 Nov 2022 12:07:49 -0800 Subject: [PATCH 389/440] Use broker for SSH Cert feature It is OK to use PyMsalRuntime now, since ESTS has deployed the fix. https://identitydivision.visualstudio.com/Engineering/_workitems/edit/2060332 --- msal/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index b469c350..7e3ec16e 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1375,7 +1375,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: return self._acquire_token_by_cloud_shell(scopes, data=data) - if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert": + if self._enable_broker and account is not None: from .broker import _acquire_token_silently response = _acquire_token_silently( "https://{}/{}".format(self.authority.instance, self.authority.tenant), @@ -1799,7 +1799,7 @@ def acquire_token_interactive( return self._acquire_token_by_cloud_shell(scopes, data=data) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) - if self._enable_broker and data.get("token_type") != "ssh-cert": + if self._enable_broker: if parent_window_handle is None: raise ValueError( "parent_window_handle is required when you opted into using broker. " From e8b52b754b79ec8608d430a611e3f40e6f93bf21 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 27 Jan 2023 12:07:31 -0800 Subject: [PATCH 390/440] Allow using client_id as scope. Needed by B2C. --- msal/application.py | 15 +++------------ tests/test_application.py | 15 +++++++++++++++ tests/test_e2e.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/msal/application.py b/msal/application.py index 7e3ec16e..62ba4b5c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -588,18 +588,9 @@ def _decorate_scope( 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 + + # client_id can also be used as a scope in B2C + decorated = scope_set | reserved_scope decorated -= self._exclude_scopes return list(decorated) diff --git a/tests/test_application.py b/tests/test_application.py index 804ccb82..b62f41d5 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -625,3 +625,18 @@ def test_organizations_authority_should_emit_warnning(self): self._test_certain_authority_should_emit_warnning( authority="https://login.microsoftonline.com/organizations") + +class TestScopeDecoration(unittest.TestCase): + def _test_client_id_should_be_a_valid_scope(self, client_id, other_scopes): + # B2C needs this https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes + reserved_scope = ['openid', 'profile', 'offline_access'] + scopes_to_use = [client_id] + other_scopes + self.assertEqual( + set(ClientApplication(client_id)._decorate_scope(scopes_to_use)), + set(scopes_to_use + reserved_scope), + "Scope decoration should return input scopes plus reserved scopes") + + def test_client_id_should_be_a_valid_scope(self): + self._test_client_id_should_be_a_valid_scope("client_id", []) + self._test_client_id_should_be_a_valid_scope("client_id", ["foo"]) + diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5c43f4f0..48ffe47a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -884,6 +884,18 @@ def test_b2c_acquire_token_by_ropc(self): scope=config["scopes"], ) + def test_b2c_allows_using_client_id_as_scope(self): + # See also https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes + config = self.get_lab_app_object(azureenvironment="azureb2ccloud") + config["scopes"] = [config["appId"]] + 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" From 206cf3ec46136459d82e1cd57da1863a4604f28e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 30 Jan 2023 11:25:05 -0800 Subject: [PATCH 391/440] Bump PyMsalRuntime to 0.13.2+ --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index dd86f2ee..73be693f 100644 --- a/setup.py +++ b/setup.py @@ -92,7 +92,9 @@ # The broker is defined as optional dependency, # so that downstream apps can opt in. The opt-in is needed, partially because # most existing MSAL Python apps do not have the redirect_uri needed by broker. - "pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'", + # MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+, + # but we still bump the lower bound to 0.13.2+ for its important bugfix (https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/3244) + "pymsalruntime>=0.13.2,<0.14;python_version>='3.6' and platform_system=='Windows'", ], }, ) From 8216e2ca31ea8eb0b715081cdad33855a0074b84 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 31 Jan 2023 09:30:04 -0800 Subject: [PATCH 392/440] MSAL Python 1.21.0 --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 62ba4b5c..e024252c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.20.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.21.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From e61e8a03f314a647e44604e76076d9c3d1794c7e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sat, 28 Jan 2023 18:50:30 -0800 Subject: [PATCH 393/440] Remind user to use proper helper for ssh cert test --- tests/msaltest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/msaltest.py b/tests/msaltest.py index cc4e5606..b1556106 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -34,7 +34,7 @@ def _select_options( return raw_data def _input_scopes(): - return _select_options([ + scopes = _select_options([ "https://graph.microsoft.com/.default", "https://management.azure.com/.default", "User.Read", @@ -42,7 +42,10 @@ def _input_scopes(): ], header="Select a scope (multiple scopes can only be input by manually typing them, delimited by space):", accept_nonempty_string=True, - ).split() + ).split() # It also converts the input string(s) into a list + if "https://pas.windows.net/CheckMyAccess/Linux/.default" in scopes: + raise ValueError("SSH Cert scope shall be tested by its dedicated functions") + return scopes def _select_account(app): accounts = app.get_accounts() @@ -183,6 +186,8 @@ def main(): ], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:") try: func(app) + except ValueError as e: + logging.error("Invalid input: %s", e) except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow print("Aborted") From 7ecf6514a22c0a5e34fbc20d7a516b80cfbd18b1 Mon Sep 17 00:00:00 2001 From: Dickson Mwendia <64727760+Dickson-Mwendia@users.noreply.github.com> Date: Thu, 23 Feb 2023 09:04:42 +0300 Subject: [PATCH 394/440] point to correct quickstart --- README.md | 2 +- docs/index.rst | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9088b60a..3f1b01e8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Not sure whether this is the SDK you are looking for your app? There are other M 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) | +| [Getting Started](https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python| [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 diff --git a/docs/index.rst b/docs/index.rst index b376f52d..fecc8ee0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ You can find high level conceptual documentations in the project Scenarios ========= -There are many `different application 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.** @@ -24,15 +24,15 @@ MSAL Python supports some of them. .. raw:: html - + Web app + alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python> Web app + alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python> Desktop App From b9ac33f99c5ec3e66f6d2f23809948efed165506 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 2 Mar 2023 10:48:22 -0800 Subject: [PATCH 395/440] Fix Markdown link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f1b01e8..3fab9682 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Not sure whether this is the SDK you are looking for your app? There are other M Quick links: -| [Getting Started](https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python| [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) | +| [Getting Started](https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python)| [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 From 2fb3056bd757760fd5ee149525d1ecbca506a288 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 13 Feb 2023 13:34:04 -0800 Subject: [PATCH 396/440] Remove effectiveless in-place clean --- msal/application.py | 4 ++-- msal/token_cache.py | 45 +++++++++++++++++++++++---------------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/msal/application.py b/msal/application.py index e024252c..b6b584a7 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1182,7 +1182,7 @@ def _acquire_token_by_cloud_shell(self, scopes, data=None): client_id=self.client_id, scope=response["scope"].split() if "scope" in response else scopes, token_endpoint=self.authority.token_endpoint, - response=response.copy(), + response=response, data=data or {}, authority_type=_AUTHORITY_TYPE_CLOUDSHELL, )) @@ -1399,7 +1399,7 @@ def _process_broker_response(self, response, scopes, data): client_id=self.client_id, scope=response["scope"].split() if "scope" in response else scopes, token_endpoint=self.authority.token_endpoint, - response=response.copy(), + response=response, data=data, _account_id=response["_account_id"], )) diff --git a/msal/token_cache.py b/msal/token_cache.py index 0259522f..4f6d225c 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -103,29 +103,30 @@ def find(self, credential_type, target=None, query=None): 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() + """Handle a token obtaining event, and add tokens into cache.""" + def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info + return { + k: "********" if k in sensitive_fields else v + for k, v in dictionary.items() + } + clean_event = dict( + event, + data=make_clean_copy(event.get("data", {}), ( + "password", "client_secret", "refresh_token", "assertion", + )), + response=make_clean_copy(event.get("response", {}), ( "id_token_claims", # Provided by broker - "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 - )) + "access_token", "refresh_token", "id_token", "username", + )), + ) + 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. + clean_event, + indent=4, sort_keys=True, + default=str, # assertion is in bytes in Python 3 + )) + return self.__add(event, now=now) def __parse_account(self, response, id_token_claims): """Return client_info and home_account_id""" From 0781ba15f3b6eb04706b3f331f1925a361f6b406 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 16 Feb 2023 15:05:41 -0800 Subject: [PATCH 397/440] Simplify and easier debugging --- msal/authority.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 407ff7cc..7c82b161 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -174,16 +174,14 @@ 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") + return json.loads(resp.text) # It could raise ValueError 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 + raise ValueError("OIDC Discovery failed on {}. HTTP status: {}, Error: {}".format( + tenant_discovery_endpoint, + resp.status_code, + 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() From 8a429bd1c217b56641a943fbc719618e1516c988 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 5 Apr 2023 21:19:58 -0700 Subject: [PATCH 398/440] Update setup.cfg with documentation URL (#539) * Update setup.cfg * Update setup.cfg Co-authored-by: Ray Luo --------- Co-authored-by: Ray Luo --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index 7e543541..013719f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,6 @@ universal=1 [metadata] project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases + Documentation = https://msal-python.readthedocs.io/ + Questions = https://stackoverflow.com/questions/tagged/msal+python + Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues From ec4748d75a852ce3e09960aa54b524feec8e1fa9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 20 Feb 2023 16:45:50 -0800 Subject: [PATCH 399/440] Fix type introduced in #537 --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index fecc8ee0..8f24a58d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,9 +30,9 @@ MSAL Python supports some of them. usemap="#public-map"> Web app Web app Desktop App From 078a5739d4b65711483c000fbf7aff66c602c820 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 20 Feb 2023 08:14:26 -0800 Subject: [PATCH 400/440] Remove a deprecated attribute, scheduled for 1.21+ Actually, it was broken since MSAL 1.17 because we forgot to import warnings. So, that makes the removal today even easier. Nobody needs it anymore. --- msal/authority.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 7c82b161..13aafa7f 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -52,12 +52,6 @@ class Authority(object): """ _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, From 89ef0154196d9b82662878e72be66a85b3364379 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Dec 2022 09:05:12 -0800 Subject: [PATCH 401/440] CIAM end-to-end test cases based on new lab API --- tests/test_e2e.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 48ffe47a..44c1d5f2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -897,6 +897,57 @@ def test_b2c_allows_using_client_id_as_scope(self): ) +class CiamTestCase(LabBasedTestCase): + # Test cases below show you what scenarios need to be covered for CIAM. + # Detail test behaviors have already been implemented in preexisting helpers. + + @classmethod + def setUpClass(cls): + super(CiamTestCase, cls).setUpClass() + cls.user = cls.get_lab_user( + federationProvider="ciam", signinAudience="azureadmyorg", publicClient="No") + # FYI: Only single- or multi-tenant CIAM app can have other-than-OIDC + # delegated permissions on Microsoft Graph. + cls.app_config = cls.get_lab_app_object(cls.user["client_id"]) + + def test_ciam_acquire_token_interactive(self): + self._test_acquire_token_interactive( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + scope=self.app_config["scopes"], + username=self.user["username"], + lab_name=self.user["lab_name"], + ) + + def test_ciam_acquire_token_for_client(self): + self._test_acquire_token_by_client_secret( + client_id=self.app_config["appId"], + client_secret=self.get_lab_user_secret( + self.app_config["clientSecret"].split("=")[-1]), + authority=self.app_config["authority"], + scope=["{}/.default".format(self.app_config["appId"])], # App permission + ) + + def test_ciam_acquire_token_by_ropc(self): + # Somehow, this would only work after creating a secret for the test app + # and enabling "Allow public client flows". + # Otherwise it would hit AADSTS7000218. + self._test_username_password( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + username=self.user["username"], + password=self.get_lab_user_secret(self.user["lab_name"]), + scope=self.app_config["scopes"], + ) + + def test_ciam_device_flow(self): + self._test_device_flow( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + scope=self.app_config["scopes"], + ) + + class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" timeout = 2 # Short timeout makes this test case responsive on non-VM From 57c417bdad176c567a535d06800928b5ed35ae83 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 1 Mar 2023 10:20:19 -0800 Subject: [PATCH 402/440] Support https://contoso.ciamlogin.com as authority --- msal/authority.py | 53 +++++++++++++++++++++++++---------------- tests/test_authority.py | 20 ++++++++++++++++ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 13aafa7f..6eb294f1 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -5,8 +5,6 @@ from urlparse import urlparse import logging -from .exceptions import MsalServiceError - logger = logging.getLogger(__name__) @@ -28,7 +26,9 @@ "b2clogin.cn", "b2clogin.us", "b2clogin.de", + "ciamlogin.com", ] +_CIAM_DOMAIN_SUFFIX = ".ciamlogin.com" class AuthorityBuilder(object): @@ -74,7 +74,8 @@ def __init__( if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) - self.is_adfs = tenant.lower() == 'adfs' + is_ciam = self.instance.endswith(_CIAM_DOMAIN_SUFFIX) + self.is_adfs = tenant.lower() == 'adfs' and not is_ciam parts = authority.path.split('/') self._is_b2c = any( self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS @@ -103,13 +104,13 @@ def __init__( % authority_url) tenant_discovery_endpoint = payload['tenant_discovery_endpoint'] else: - tenant_discovery_endpoint = ( - 'https://{}:{}{}{}/.well-known/openid-configuration'.format( - self.instance, - 443 if authority.port is None else authority.port, - authority.path, # In B2C scenario, it is "/tenant/policy" - "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint - )) + tenant_discovery_endpoint = authority._replace( + path="{prefix}{version}/.well-known/openid-configuration".format( + prefix=tenant if is_ciam and len(authority.path) <= 1 # Path-less CIAM + else authority.path, # In B2C, it is "/tenant/policy" + version="" if self.is_adfs else "/v2.0", + ) + ).geturl() # Keeping original port and query. Query is useful for test. try: openid_config = tenant_discovery( tenant_discovery_endpoint, @@ -144,18 +145,28 @@ def user_realm_discovery(self, username, correlation_id=None, response=None): return {} # This can guide the caller to fall back normal ROPC flow -def canonicalize(authority_url): +def canonicalize(authority_or_auth_endpoint): # 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] + authority = urlparse(authority_or_auth_endpoint) + if authority.scheme == "https": + parts = authority.path.split("/") + first_part = parts[1] if len(parts) >= 2 and parts[1] else None + if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX): # CIAM + # Use path in CIAM authority. It will be validated by OIDC Discovery soon + tenant = first_part if first_part else "{}.onmicrosoft.com".format( + # Fallback to sub domain name. This variation may not be advertised + authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0]) + return authority, authority.hostname, tenant + # AAD + if len(parts) >= 2 and parts[1]: + return authority, authority.hostname, 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://.ciamlogin.com/ " + "or https://.b2clogin.com/.onmicrosoft.com/policy" + % authority_or_auth_endpoint) def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs): resp = http_client.get( diff --git a/tests/test_authority.py b/tests/test_authority.py index ca0bc68f..2ced23f8 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -79,6 +79,26 @@ def test_invalid_host_skipping_validation_can_be_turned_off(self): pass # Those are expected for this unittest case +@patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": "https://contoso.com/placeholder", + "token_endpoint": "https://contoso.com/placeholder", + }) +class TestCiamAuthority(unittest.TestCase): + http_client = MinimalHttpClient() + + def test_path_less_authority_should_work(self, oidc_discovery): + Authority('https://contoso.ciamlogin.com', self.http_client) + oidc_discovery.assert_called_once_with( + "https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration", + self.http_client) + + def test_authority_with_path_should_be_used_as_is(self, oidc_discovery): + Authority('https://contoso.ciamlogin.com/anything', self.http_client) + oidc_discovery.assert_called_once_with( + "https://contoso.ciamlogin.com/anything/v2.0/.well-known/openid-configuration", + self.http_client) + + class TestAuthorityInternalHelperCanonicalize(unittest.TestCase): def test_canonicalize_tenant_followed_by_extra_paths(self): From ae7f40f65d260bf38a27a679a5da8b85b5244b0b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 3 Apr 2023 23:16:44 -0700 Subject: [PATCH 403/440] Bumping version numbers --- msal/application.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index b6b584a7..b3e2c209 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.21.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.22.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" diff --git a/setup.py b/setup.py index 73be693f..721baa6d 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - 'cryptography>=0.6,<41', + 'cryptography>=0.6,<43', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # From a6023ea7934a457e8c5e5ed5efac60aed6b91e96 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 30 Mar 2023 10:12:13 -0700 Subject: [PATCH 404/440] Turns out they changed to a new tag for MSAL. Fix #539 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 013719f3..3ec1c6ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,5 +5,5 @@ universal=1 project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases Documentation = https://msal-python.readthedocs.io/ - Questions = https://stackoverflow.com/questions/tagged/msal+python + Questions = https://stackoverflow.com/questions/tagged/azure-ad-msal+python Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues From a48e22b685c18f56c5cba21f50e85d65ca7b8937 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 17 Mar 2023 21:02:33 -0700 Subject: [PATCH 405/440] Clarify that allow_broker is not applicable to ConfidentialClientApplication It is applicable to PublicClientApplication and base class ClientApplication --- msal/application.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/msal/application.py b/msal/application.py index b3e2c209..a3295cc2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -444,6 +444,8 @@ def __init__( New in version 1.19.0. :param boolean allow_broker: + This parameter is NOT applicable to :class:`ConfidentialClientApplication`. + A broker is a component installed on your device. Broker implicitly gives your device an identity. By using a broker, your device becomes a factor that can satisfy MFA (Multi-factor authentication). From 3517aed9316f9692c48bd5531ee4fcf2da05cb44 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 15 Mar 2023 17:31:16 -0700 Subject: [PATCH 406/440] No need for DummyHttpResponse --- tests/test_throttled_http_client.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_throttled_http_client.py b/tests/test_throttled_http_client.py index 93820505..aa20060d 100644 --- a/tests/test_throttled_http_client.py +++ b/tests/test_throttled_http_client.py @@ -11,19 +11,13 @@ 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( + return MinimalResponse( status_code=self._status_code, headers=self._response_headers, text=random(), # So that we'd know whether a new response is received From bc699f83f4a9693e1a110ce0a116540ae1164376 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 28 Apr 2023 23:56:28 -0700 Subject: [PATCH 407/440] Adjustment for new CIAM partition --- tests/test_e2e.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 44c1d5f2..657e777e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -925,10 +925,16 @@ def test_ciam_acquire_token_for_client(self): client_secret=self.get_lab_user_secret( self.app_config["clientSecret"].split("=")[-1]), authority=self.app_config["authority"], - scope=["{}/.default".format(self.app_config["appId"])], # App permission + #scope=["{}/.default".format(self.app_config["appId"])], # AADSTS500207: The account type can't be used for the resource you're trying to access. + #scope=["api://{}/.default".format(self.app_config["appId"])], # AADSTS500011: The resource principal named api://ced781e7-bdb0-4c99-855c-d3bacddea88a was not found in the tenant named MSIDLABCIAM2. This can happen if the application has not been installed by the administrator of the tenant or consented to by any user in the tenant. You might have sent your authentication request to the wrong tenant. + scope=self.app_config["scopes"], # It shall ends with "/.default" ) def test_ciam_acquire_token_by_ropc(self): + """CIAM does not officially support ROPC, especially not for external emails. + + We keep this test case for now, because the test data will use a local email. + """ # Somehow, this would only work after creating a secret for the test app # and enabling "Allow public client flows". # Otherwise it would hit AADSTS7000218. From d4ca347b479bfa57bdb8dfc8a5758be64aca7d05 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 26 May 2023 11:34:27 -0700 Subject: [PATCH 408/440] Improve logs --- msal/application.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index a3295cc2..659a7409 100644 --- a/msal/application.py +++ b/msal/application.py @@ -65,6 +65,12 @@ def _str2bytes(raw): return raw +def _pii_less_home_account_id(home_account_id): + parts = home_account_id.split(".") # It could contain one or two parts + parts[0] = "********" + return ".".join(parts) + + def _clean_up(result): if isinstance(result, dict): return { @@ -1460,7 +1466,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token( 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) + logger.debug("Found %d RTs matching %s", len(matches), { + k: _pii_less_home_account_id(v) if k == "home_account_id" and v else v + for k, v in query.items() + }) response = None # A distinguishable value to mean cache is empty if not matches: # Then exit early to avoid expensive operations From 634d328f65f422acdeeefa74df0201ddf38161f3 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 31 May 2023 13:58:32 -0700 Subject: [PATCH 409/440] Add more sections into TOC for the now long doc --- docs/index.rst | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 8f24a58d..5c49a7ba 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,4 @@ -MSAL Python documentation +MSAL Python Documentation ========================= .. toctree:: @@ -6,8 +6,11 @@ MSAL Python documentation :caption: Contents: :hidden: - MSAL Documentation - GitHub Repository + index + +.. + Comment: Perhaps because of the theme, only the first level sections will show in TOC, + regardless of maxdepth setting. You can find high level conceptual documentations in the project `README `_. @@ -58,8 +61,8 @@ MSAL Python supports some of them. `_. -API -=== +API Reference +============= The following section is the API Reference of MSAL Python. The API Reference is like a dictionary. You **read this API section when and only when**: @@ -88,8 +91,10 @@ MSAL proposes a clean separation between They are implemented as two separated classes, with different methods for different authentication scenarios. + + PublicClientApplication ------------------------ +======================= .. autoclass:: msal.PublicClientApplication :members: @@ -98,7 +103,7 @@ PublicClientApplication .. automethod:: __init__ ConfidentialClientApplication ------------------------------ +============================= .. autoclass:: msal.ConfidentialClientApplication :members: @@ -107,7 +112,7 @@ ConfidentialClientApplication .. automethod:: __init__ TokenCache ----------- +========== One of the parameters accepted by both `PublicClientApplication` and `ConfidentialClientApplication` From 6fe180f16a16c1ff550ef979bff3b618a2c54114 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 2 Jun 2023 14:43:40 -0700 Subject: [PATCH 410/440] Remove many Sphinx warnings --- docs/index.rst | 10 +++++++--- msal/application.py | 31 ++++++++++++++++++++----------- msal/token_cache.py | 1 - 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 5c49a7ba..e608fe6b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -91,14 +91,20 @@ MSAL proposes a clean separation between They are implemented as two separated classes, with different methods for different authentication scenarios. +ClientApplication +================= +.. autoclass:: msal.ClientApplication + :members: + :inherited-members: + + .. automethod:: __init__ PublicClientApplication ======================= .. autoclass:: msal.PublicClientApplication :members: - :inherited-members: .. automethod:: __init__ @@ -107,9 +113,7 @@ ConfidentialClientApplication .. autoclass:: msal.ConfidentialClientApplication :members: - :inherited-members: - .. automethod:: __init__ TokenCache ========== diff --git a/msal/application.py b/msal/application.py index 659a7409..48b6575b 100644 --- a/msal/application.py +++ b/msal/application.py @@ -156,6 +156,9 @@ def obtain_token_by_username_password(self, username, password, **kwargs): class ClientApplication(object): + """You do not usually directly use this class. Use its subclasses instead: + :class:`PublicClientApplication` and :class:`ConfidentialClientApplication`. + """ ACQUIRE_TOKEN_SILENT_ID = "84" ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85" ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301" @@ -319,7 +322,7 @@ def __init__( to keep their traffic remain inside that region. As of 2021 May, regional service is only available for - ``acquire_token_for_client()`` sent by any of the following scenarios:: + ``acquire_token_for_client()`` sent by any of the following scenarios: 1. An app powered by a capable MSAL (MSAL Python 1.12+ will be provisioned) @@ -764,9 +767,9 @@ def initiate_auth_code_flow( 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 `_. + More information on possible values available in + `Auth Code Flow doc `_ and + `domain_hint doc `_. :param int max_age: OPTIONAL. Maximum Authentication Age. @@ -804,7 +807,7 @@ def initiate_auth_code_flow( "...": "...", // Everything else are reserved and internal } - The caller is expected to:: + 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, @@ -868,9 +871,9 @@ def get_authorization_request_url( 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 `_. + More information on possible values available in + `Auth Code Flow doc `_ and + `domain_hint doc `_. :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 @@ -1682,6 +1685,9 @@ class PublicClientApplication(ClientApplication): # browser app or mobile app CONSOLE_WINDOW_HANDLE = object() def __init__(self, client_id, client_credential=None, **kwargs): + """Same as :func:`ClientApplication.__init__`, + except that ``client_credential`` parameter shall remain ``None``. + """ if client_credential is not None: raise ValueError("Public Client should not possess credentials") super(PublicClientApplication, self).__init__( @@ -1722,9 +1728,9 @@ def acquire_token_interactive( 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 `_. + More information on possible values available in + `Auth Code Flow doc `_ and + `domain_hint doc `_. :param claims_challenge: The claims_challenge parameter requests specific claims requested by the resource provider @@ -1994,6 +2000,9 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs): class ConfidentialClientApplication(ClientApplication): # server-side web app + """Same as :func:`ClientApplication.__init__`, + except that ``allow_broker`` parameter shall remain ``None``. + """ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): """Acquires token for the current confidential client, not for an end user. diff --git a/msal/token_cache.py b/msal/token_cache.py index 4f6d225c..49262069 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -102,7 +102,6 @@ def find(self, credential_type, target=None, query=None): ] def add(self, event, now=None): - # type: (dict) -> None """Handle a token obtaining event, and add tokens into cache.""" def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info return { From 97ec66525d8f7b70335e41d5f2bcca67bb4c13b4 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 7 Jun 2023 17:44:59 -0700 Subject: [PATCH 411/440] Github removes Python 2.7 support on 2023-6-19 See also https://github.com/actions/setup-python/issues/672 MSAL downloads from Python 2.7 is less than 0.2% according to https://pypistats.org/packages/msal --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index cf56cb2a..9d24904a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest # It switched to 22.04 shortly after 2022-Nov-8 strategy: matrix: - python-version: [2.7, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12-dev"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12-dev"] steps: - uses: actions/checkout@v2 From 2184e5019f5f474a1a0d2bed916bb70fb4dd22dc Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 28 Jun 2023 23:52:47 -0700 Subject: [PATCH 412/440] Remove acquire_token_silent(..., account=None) usage in a backward-compatible way Now acquire_token_for_client()'s cache behavior will have corresponding api id Continue to disallow acquire_token_for_client(..., force_refresh=True) --- msal/application.py | 90 +++++++++++++------ .../confidential_client_certificate_sample.py | 14 +-- sample/confidential_client_secret_sample.py | 14 +-- tests/test_application.py | 31 +++++-- tests/test_e2e.py | 12 ++- 5 files changed, 99 insertions(+), 62 deletions(-) diff --git a/msal/application.py b/msal/application.py index 48b6575b..29e3cb28 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1209,32 +1209,24 @@ def acquire_token_silent( **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. - + It has same parameters as the :func:`~acquire_token_silent_with_error`. + The difference is the behavior of the return value. 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( + if not account: + return None # A backward-compatible NO-OP to drop the account=None usage + result = _clean_up(self._acquire_token_silent_with_error( scopes, account, authority=authority, force_refresh=force_refresh, - claims_challenge=claims_challenge, **kwargs) + claims_challenge=claims_challenge, **kwargs)) return result if result and "error" not in result else None def acquire_token_silent_with_error( @@ -1258,9 +1250,10 @@ def acquire_token_silent_with_error( :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 account: (Required) + One of the account object returned by :func:`~get_accounts`. + Starting from MSAL Python 1.23, + a ``None`` input will become a NO-OP and always return ``None``. :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. @@ -1276,6 +1269,20 @@ def acquire_token_silent_with_error( - None when there is simply no token in the cache. - A dict containing an "error" key, when token refresh failed. """ + if not account: + return None # A backward-compatible NO-OP to drop the account=None usage + return _clean_up(self._acquire_token_silent_with_error( + scopes, account, authority=authority, force_refresh=force_refresh, + claims_challenge=claims_challenge, **kwargs)) + + 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): assert isinstance(scopes, list), "Invalid parameter type" self._validate_ssh_cert_input_data(kwargs.get("data", {})) correlation_id = msal.telemetry._get_new_correlation_id() @@ -1335,7 +1342,11 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( force_refresh=False, # type: Optional[boolean] claims_challenge=None, correlation_id=None, + http_exceptions=None, **kwargs): + # This internal method has two calling patterns: + # it accepts a non-empty account to find token for a user, + # and accepts account=None to find a token for the current app. access_token_from_cache = None if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims query={ @@ -1372,6 +1383,10 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( 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" + if not http_exceptions: # It can be a tuple of exceptions + # The exact HTTP exceptions are transportation-layer dependent + from requests.exceptions import RequestException # Lazy load + http_exceptions = (RequestException,) try: data = kwargs.get("data", {}) if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL: @@ -1391,14 +1406,19 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( if response: # The broker provided a decisive outcome, so we use it return self._process_broker_response(response, scopes, data) - 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, - correlation_id=correlation_id, - **kwargs)) + if account: + result = 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, + correlation_id=correlation_id, + **kwargs) + else: # The caller is acquire_token_for_client() + result = self._acquire_token_for_client( + scopes, 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 + except http_exceptions: # 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 @@ -2007,6 +2027,9 @@ 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. + Since MSAL Python 1.23, it will automatically look for token from cache, + and only send request to Identity Provider when cache misses. + :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param claims_challenge: @@ -2020,7 +2043,20 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ - # TBD: force_refresh behavior + if kwargs.get("force_refresh"): + raise ValueError( # We choose to disallow force_refresh + "Historically, this method does not support force_refresh behavior. " + ) + return _clean_up(self._acquire_token_silent_with_error( + scopes, None, claims_challenge=claims_challenge, **kwargs)) + + def _acquire_token_for_client( + self, + scopes, + refresh_reason, + claims_challenge=None, + **kwargs + ): if self.authority.tenant.lower() in ["common", "organizations"]: warnings.warn( "Using /common or /organizations authority " @@ -2028,16 +2064,16 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs): "Please use a specific tenant instead.", DeprecationWarning) self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( - self.ACQUIRE_TOKEN_FOR_CLIENT_ID) + self.ACQUIRE_TOKEN_FOR_CLIENT_ID, refresh_reason=refresh_reason) client = self._regional_client or self.client - response = _clean_up(client.obtain_token_for_client( + response = 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)) + **kwargs) telemetry_context.update_telemetry(response) return response diff --git a/sample/confidential_client_certificate_sample.py b/sample/confidential_client_certificate_sample.py index 7e5d8069..6cd22a86 100644 --- a/sample/confidential_client_certificate_sample.py +++ b/sample/confidential_client_certificate_sample.py @@ -51,17 +51,9 @@ # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) -# The pattern to acquire a token looks like this. -result = None - -# Firstly, looks up a token from cache -# Since we are looking for token for the current app, NOT for an end user, -# notice we give account parameter as None. -result = app.acquire_token_silent(config["scope"], account=None) - -if not result: - logging.info("No suitable token exists in cache. Let's get a new one from AAD.") - result = app.acquire_token_for_client(scopes=config["scope"]) +# Since MSAL 1.23, acquire_token_for_client(...) will automatically look up +# a token from cache, and fall back to acquire a fresh token when needed. +result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: # Calling graph using the access token diff --git a/sample/confidential_client_secret_sample.py b/sample/confidential_client_secret_sample.py index d4c06e20..61fd1db7 100644 --- a/sample/confidential_client_secret_sample.py +++ b/sample/confidential_client_secret_sample.py @@ -50,17 +50,9 @@ # https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache ) -# The pattern to acquire a token looks like this. -result = None - -# Firstly, looks up a token from cache -# Since we are looking for token for the current app, NOT for an end user, -# notice we give account parameter as None. -result = app.acquire_token_silent(config["scope"], account=None) - -if not result: - logging.info("No suitable token exists in cache. Let's get a new one from AAD.") - result = app.acquire_token_for_client(scopes=config["scope"]) +# Since MSAL 1.23, acquire_token_for_client(...) will automatically look up +# a token from cache, and fall back to acquire a fresh token when needed. +result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: # Calling graph using the access token diff --git a/tests/test_application.py b/tests/test_application.py index b62f41d5..0d93737e 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -382,8 +382,8 @@ def test_aging_token_and_unavailable_aad_should_return_old_token(self): 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})) + self.assertEqual("4|84,4|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) + return MinimalResponse(status_code=400, text=json.dumps({"error": "foo"})) result = self.app.acquire_token_silent(['s1'], self.account, post=mock_post) self.assertEqual(old_at, result.get("access_token")) @@ -549,12 +549,31 @@ def setUpClass(cls): # Initialization at runtime, not interpret-time 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})) + self.assertEqual("4|730,2|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": "AT 1", + "expires_in": 0, + })) result = self.app.acquire_token_for_client(["scope"], post=mock_post) - self.assertEqual(at, result.get("access_token")) + self.assertEqual("AT 1", result.get("access_token"), "Shall get a new token") + + def mock_post(url, headers=None, *args, **kwargs): + self.assertEqual("4|730,3|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": "AT 2", + "expires_in": 3600, + "refresh_in": -100, # A hack to make sure it will attempt refresh + })) + result = self.app.acquire_token_for_client(["scope"], post=mock_post) + self.assertEqual("AT 2", result.get("access_token"), "Shall get a new token") + + def mock_post(url, headers=None, *args, **kwargs): + # 1/0 # TODO: Make sure this was called + self.assertEqual("4|730,4|", (headers or {}).get(CLIENT_CURRENT_TELEMETRY)) + return MinimalResponse(status_code=400, text=json.dumps({"error": "foo"})) + result = self.app.acquire_token_for_client(["scope"], post=mock_post) + self.assertEqual("AT 2", result.get("access_token"), "Shall get aging token") def test_acquire_token_on_behalf_of(self): at = "this is an access token" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 657e777e..d1fc50dd 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -146,17 +146,15 @@ def assertCacheWorksForApp(self, result_from_wire, scope): 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.assertIsNone( + self.app.acquire_token_silent(scope, account=None), + "acquire_token_silent(..., account=None) shall always return None") + # Going to test acquire_token_for_client(...) to locate an AT from cache + result_from_cache = self.app.acquire_token_for_client(scope) self.assertIsNotNone(result_from_cache) self.assertEqual( result_from_wire['access_token'], result_from_cache['access_token'], "We should get a cached AT") - self.app.acquire_token_silent( - # Result will typically be None, because client credential grant returns no RT. - # But we care more on this call should succeed without exception. - scope, account=None, - force_refresh=True) # Mimic the AT already expires @classmethod def _build_app(cls, From 0e5c68578aa47c1a763d1357582fd32d8475c856 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 11 Jul 2023 17:58:19 -0700 Subject: [PATCH 413/440] Bumping up version numbers --- msal/application.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 29e3cb28..16fbac28 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.22.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.23.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" diff --git a/setup.py b/setup.py index 721baa6d..f7a2a4a1 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - 'cryptography>=0.6,<43', + 'cryptography>=0.6,<44', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # From 44df9c78780902aaf6f27bec2c553c11de60c7a1 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 14 Jun 2023 02:07:34 -0700 Subject: [PATCH 414/440] msaltest.py switches from confusing pprint to json --- tests/msaltest.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/msaltest.py b/tests/msaltest.py index b1556106..fec57419 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -1,9 +1,12 @@ -import getpass, logging, pprint, sys, msal +import getpass, json, logging, sys, msal AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" +def print_json(blob): + print(json.dumps(blob, indent=2)) + def _input_boolean(message): return input( "{} (N/n/F/f or empty means False, otherwise it is True): ".format(message) @@ -62,7 +65,7 @@ def acquire_token_silent(app): """acquire_token_silent() - with an account already signed into MSAL Python.""" account = _select_account(app) if account: - pprint.pprint(app.acquire_token_silent( + print_json(app.acquire_token_silent( _input_scopes(), account=account, force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), @@ -99,11 +102,11 @@ def _acquire_token_interactive(app, scopes, data=None): def acquire_token_interactive(app): """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" - pprint.pprint(_acquire_token_interactive(app, _input_scopes())) + print_json(_acquire_token_interactive(app, _input_scopes())) def acquire_token_by_username_password(app): """acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc""" - pprint.pprint(app.acquire_token_by_username_password( + print_json(app.acquire_token_by_username_password( _input("username: "), getpass.getpass("password: "), scopes=_input_scopes())) _JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}""" @@ -120,14 +123,14 @@ def acquire_ssh_cert_silently(app): data=SSH_CERT_DATA, force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), ) - pprint.pprint(result) + print_json(result) if result and result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert.") def acquire_ssh_cert_interactive(app): """Acquire an SSH Cert interactively - This typically only works with Azure CLI""" result = _acquire_token_interactive(app, SSH_CERT_SCOPE, data=SSH_CERT_DATA) - pprint.pprint(result) + print_json(result) if result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert") @@ -149,7 +152,7 @@ def exit(app): sys.exit() def main(): - print("Welcome to the Msal Python Console Test App, committed at 2022-5-2\n") + print("Welcome to the Msal Python {} Tester\n".format(msal.__version__)) chosen_app = _select_options([ {"client_id": AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"}, {"client_id": VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"}, From 76259f985ae8c8543f400c25e453d5fbbe6cad0e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 19 Jun 2023 21:59:01 -0700 Subject: [PATCH 415/440] Switch from setup.py to setup.cfg --- setup.cfg | 67 +++++++++++++++++++++++++++++++++++ setup.py | 102 ++---------------------------------------------------- 2 files changed, 69 insertions(+), 100 deletions(-) diff --git a/setup.cfg b/setup.cfg index 3ec1c6ab..a69dbdd7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,76 @@ +# Format https://setuptools.pypa.io/en/latest/userguide/declarative_config.html + [bdist_wheel] universal=1 [metadata] +name = msal +version = attr: msal.__version__ +description = + The Microsoft Authentication Library (MSAL) for Python library + enables your app to access the Microsoft Cloud + by supporting authentication of users with + Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) + using industry standard OAuth2 and OpenID Connect. +long_description = file: README.md +long_description_content_type = text/markdown +license = MIT +author = Microsoft Corporation +author_email = nugetaad@microsoft.com +url = https://github.com/AzureAD/microsoft-authentication-library-for-python +classifiers = + Development Status :: 5 - Production/Stable + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases Documentation = https://msal-python.readthedocs.io/ Questions = https://stackoverflow.com/questions/tagged/azure-ad-msal+python Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues + + +[options] +include_package_data = False # We used to ship LICENSE, but our __init__.py already mentions MIT +packages = find: +python_requires = >=2.7 +install_requires = + requests>=2.0.0,<3 + + # MSAL does not use jwt.decode(), + # therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ + PyJWT[crypto]>=1.0.0,<3 + + # 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 + cryptography>=0.6,<44 + + mock; python_version<'3.3' + +[options.extras_require] +broker = + # The broker is defined as optional dependency, + # so that downstream apps can opt in. The opt-in is needed, partially because + # most existing MSAL Python apps do not have the redirect_uri needed by broker. + # MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+, + # but we still bump the lower bound to 0.13.2+ for its important bugfix (https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/3244) + pymsalruntime>=0.13.2,<0.14; python_version>='3.6' and platform_system=='Windows' + +[options.packages.find] +exclude = + tests diff --git a/setup.py b/setup.py index f7a2a4a1..1f21e1d5 100644 --- a/setup.py +++ b/setup.py @@ -1,101 +1,3 @@ -#!/usr/bin/env python -#------------------------------------------------------------------------------ -# -# 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. -# -#------------------------------------------------------------------------------ - -from setuptools import setup, find_packages -import re, io - -# setup.py shall not import main package -__version__ = re.search( - r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', # It excludes inline comment too - io.open('msal/application.py', encoding='utf_8_sig').read() - ).group(1) - -long_description = open('README.md').read() - -setup( - name='msal', - version=__version__, - description=' '.join( - """The Microsoft Authentication Library (MSAL) for Python library - enables your app to access the Microsoft Cloud - by supporting authentication of users with - Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) - using industry standard OAuth2 and OpenID Connect.""".split()), - long_description=long_description, - long_description_content_type="text/markdown", - license='MIT', - author='Microsoft Corporation', - author_email='nugetaad@microsoft.com', - url='https://github.com/AzureAD/microsoft-authentication-library-for-python', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - ], - packages=find_packages(exclude=["tests"]), - package_data={'': ['LICENSE']}, # Do not use data_files=[...], - # which would cause the LICENSE being copied to /usr/local, - # and tend to fail because of insufficient permission. - # See https://stackoverflow.com/a/14211600/728675 for more detail - install_requires=[ - 'requests>=2.0.0,<3', - 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - - 'cryptography>=0.6,<44', - # 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'", - ], - extras_require={ # It does not seem to work if being defined inside setup.cfg - "broker": [ - # The broker is defined as optional dependency, - # so that downstream apps can opt in. The opt-in is needed, partially because - # most existing MSAL Python apps do not have the redirect_uri needed by broker. - # MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+, - # but we still bump the lower bound to 0.13.2+ for its important bugfix (https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/3244) - "pymsalruntime>=0.13.2,<0.14;python_version>='3.6' and platform_system=='Windows'", - ], - }, -) +from setuptools import setup +setup() From 8d6d6c5ef527b960996cfce47fdb8d8a23b70fc7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 20 Jun 2023 21:59:25 -0700 Subject: [PATCH 416/440] Ship release- branch of a non-draft PR to TestPyPI --- .github/workflows/python-package.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9d24904a..95f3d4cb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -65,7 +65,12 @@ jobs: cd: needs: ci - if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/main') + if: | + github.event_name == 'push' && + ( + startsWith(github.ref, 'refs/tags') || + startsWith(github.ref, 'refs/heads/release-') + ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -77,14 +82,16 @@ jobs: run: | python -m pip install build --user python -m build --sdist --wheel --outdir dist/ . - - name: Publish to TestPyPI + - name: | + Publish to TestPyPI when pushing to release-* branch. + You better test with a1, a2, b1, b2 releases first. uses: pypa/gh-action-pypi-publish@v1.4.2 - if: github.ref == 'refs/heads/main' + if: startsWith(github.ref, 'refs/heads/release-') with: user: __token__ password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - - name: Publish to PyPI + - name: Publish to PyPI when tagged if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@v1.4.2 with: From 54b65a2b73e543e85ca7fdb2ccf3ee2cca3175f7 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 23 Jun 2023 02:15:40 -0700 Subject: [PATCH 417/440] Surface msal telemetry as a long opaque string Remove wam_telemetry, for now --- msal/application.py | 7 ++++++- msal/broker.py | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/msal/application.py b/msal/application.py index 16fbac28..5a750598 100644 --- a/msal/application.py +++ b/msal/application.py @@ -73,6 +73,11 @@ def _pii_less_home_account_id(home_account_id): def _clean_up(result): if isinstance(result, dict): + if "_msalruntime_telemetry" in result or "_msal_python_telemetry" in result: + result["msal_telemetry"] = json.dumps({ # Telemetry as an opaque string + "msalruntime_telemetry": result.get("_msalruntime_telemetry"), + "msal_python_telemetry": result.get("_msal_python_telemetry"), + }, separators=(",", ":")) return { k: result[k] for k in result if k != "refresh_in" # MSAL handled refresh_in, customers need not @@ -966,7 +971,7 @@ def authorize(): # A controller in a web app 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( + 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, diff --git a/msal/broker.py b/msal/broker.py index 8b997c61..ce7a9bde 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -23,8 +23,7 @@ except (ImportError, AttributeError): # AttributeError happens when a prior pymsalruntime uninstallation somehow leaved an empty folder behind # PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link # https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files - raise ImportError( # TODO: Remove or adjust this line right before merging this PR - 'You need to install dependency by: pip install "msal[broker]>=1.20,<2"') + raise ImportError('You need to install dependency by: pip install "msal[broker]>=1.20,<2"') # It could throw RuntimeError when running on ancient versions of Windows @@ -84,9 +83,11 @@ def _read_account_by_id(account_id, correlation_id): def _convert_result(result, client_id, expected_token_type=None): # Mimic an on-the-wire response from AAD + telemetry = result.get_telemetry_data() + telemetry.pop("wam_telemetry", None) # In pymsalruntime 0.13, it contains PII "account_id" error = result.get_error() if error: - return _convert_error(error, client_id) + return dict(_convert_error(error, client_id), _msalruntime_telemetry=telemetry) id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {} account = result.get_account() assert account, "Account is expected to be always available" @@ -107,7 +108,7 @@ def _convert_result(result, client_id, expected_token_type=None): # Mimic an on granted_scopes = result.get_granted_scopes() # New in pymsalruntime 0.3.x if granted_scopes: return_value["scope"] = " ".join(granted_scopes) # Mimic the on-the-wire data format - return return_value + return dict(return_value, _msalruntime_telemetry=telemetry) def _get_new_correlation_id(): From 450ca649cc5b63ffd747e5138dce20c364b1dc78 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 24 Jul 2023 11:31:08 -0700 Subject: [PATCH 418/440] Use a neutral name to hopefully avoid false alarm --- tests/test_e2e.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d1fc50dd..f2c3d97a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -465,7 +465,7 @@ def test_device_flow(self): def get_lab_app( env_client_id="LAB_APP_CLIENT_ID", - env_client_secret="LAB_APP_CLIENT_SECRET", + env_name2="LAB_APP_CLIENT_SECRET", # A var name that hopefully avoids false alarm authority="https://login.microsoftonline.com/" "72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID timeout=None, @@ -477,18 +477,17 @@ def get_lab_app( 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): + env_client_id, env_name2) + if os.getenv(env_client_id) and os.getenv(env_name2): # 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) + client_secret = os.getenv(env_name2) else: - logger.info("ENV variables %s and/or %s are not defined. Fall back to MSI.", - env_client_id, env_client_secret) + logger.info("ENV variables are not defined. Fall back to MSI.") # 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( From c943dc8f797746fe87b228f1c7de750abce789c3 Mon Sep 17 00:00:00 2001 From: "Benjamin A. Beasley" Date: Wed, 9 Aug 2023 09:17:28 -0400 Subject: [PATCH 419/440] =?UTF-8?q?Fix=20typo=20in=20test=20names=20(warnn?= =?UTF-8?q?ing=20=E2=86=92=20warning)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_application.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_application.py b/tests/test_application.py index 0d93737e..df87a05b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -627,7 +627,7 @@ def test_get_accounts(self): sys.version_info[0] >= 3 and sys.version_info[1] >= 2, "assertWarns() is only available in Python 3.2+") class TestClientCredentialGrant(unittest.TestCase): - def _test_certain_authority_should_emit_warnning(self, authority): + def _test_certain_authority_should_emit_warning(self, authority): app = ConfidentialClientApplication( "client_id", client_credential="secret", authority=authority) def mock_post(url, headers=None, *args, **kwargs): @@ -636,12 +636,12 @@ def mock_post(url, headers=None, *args, **kwargs): with self.assertWarns(DeprecationWarning): app.acquire_token_for_client(["scope"], post=mock_post) - def test_common_authority_should_emit_warnning(self): - self._test_certain_authority_should_emit_warnning( + def test_common_authority_should_emit_warning(self): + self._test_certain_authority_should_emit_warning( authority="https://login.microsoftonline.com/common") - def test_organizations_authority_should_emit_warnning(self): - self._test_certain_authority_should_emit_warnning( + def test_organizations_authority_should_emit_warning(self): + self._test_certain_authority_should_emit_warning( authority="https://login.microsoftonline.com/organizations") From 23e3ce2108a530c3de04118810b398206d9d7d63 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 26 Jun 2023 19:49:34 -0700 Subject: [PATCH 420/440] Explicitly pip cache seems unnecessary --- .github/workflows/python-package.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 95f3d4cb..e7ae1643 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -32,22 +32,11 @@ jobs: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 + # It automatically takes care of pip cache, according to + # https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#about-caching-workflow-dependencies 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 From 546760d6ab27a25efadc478429c9bf55dee84577 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 11 Aug 2023 14:56:55 -0700 Subject: [PATCH 421/440] Add enable_pii_log and wire it up with MsalRuntime --- msal/application.py | 10 ++++++++++ msal/broker.py | 3 +++ setup.cfg | 2 +- tests/msaltest.py | 8 ++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index 5a750598..7803702f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -193,6 +193,7 @@ def __init__( http_cache=None, instance_discovery=None, allow_broker=None, + enable_pii_log=None, ): """Create an instance of application. @@ -500,6 +501,13 @@ def __init__( * AAD and MSA accounts (i.e. Non-ADFS, non-B2C) New in version 1.20.0. + + :param boolean enable_pii_log: + When enabled, logs may include PII (Personal Identifiable Information). + This can be useful in troubleshooting broker behaviors. + The default behavior is False. + + New in version 1.24.0. """ self.client_id = client_id self.client_credential = client_credential @@ -576,6 +584,8 @@ def __init__( try: from . import broker # Trigger Broker's initialization self._enable_broker = True + if enable_pii_log: + broker._enable_pii_log() except RuntimeError: logger.exception( "Broker is unavailable on this platform. " diff --git a/msal/broker.py b/msal/broker.py index ce7a9bde..81b14a2a 100644 --- a/msal/broker.py +++ b/msal/broker.py @@ -236,3 +236,6 @@ def _signout_silently(client_id, account_id, correlation_id=None): if error: return _convert_error(error, client_id) +def _enable_pii_log(): + pymsalruntime.set_is_pii_enabled(1) # New in PyMsalRuntime 0.13.0 + diff --git a/setup.cfg b/setup.cfg index a69dbdd7..39b8524e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -67,7 +67,7 @@ broker = # The broker is defined as optional dependency, # so that downstream apps can opt in. The opt-in is needed, partially because # most existing MSAL Python apps do not have the redirect_uri needed by broker. - # MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+, + # MSAL Python uses a subset of API from PyMsalRuntime 0.13.0+, # but we still bump the lower bound to 0.13.2+ for its important bugfix (https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/3244) pymsalruntime>=0.13.2,<0.14; python_version>='3.6' and platform_system=='Windows' diff --git a/tests/msaltest.py b/tests/msaltest.py index fec57419..21f78bd4 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -161,6 +161,9 @@ def main(): option_renderer=lambda a: a["name"], header="Impersonate this app (or you can type in the client_id of your own app)", accept_nonempty_string=True) + allow_broker = _input_boolean("Allow broker?") + enable_debug_log = _input_boolean("Enable MSAL Python's DEBUG log?") + enable_pii_log = _input_boolean("Enable PII in broker's log?") if allow_broker and enable_debug_log else False app = msal.PublicClientApplication( chosen_app["client_id"] if isinstance(chosen_app, dict) else chosen_app, authority=_select_options([ @@ -173,9 +176,10 @@ def main(): header="Input authority (Note that MSA-PT apps would NOT use the /common authority)", accept_nonempty_string=True, ), - allow_broker=_input_boolean("Allow broker? (Azure CLI currently only supports @microsoft.com accounts when enabling broker)"), + allow_broker=allow_broker, + enable_pii_log=enable_pii_log, ) - if _input_boolean("Enable MSAL Python's DEBUG log?"): + if enable_debug_log: logging.basicConfig(level=logging.DEBUG) while True: func = _select_options([ From 45d76827dced0e6839b633a1a7825c24230b63b6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 3 Jul 2023 16:07:41 -0700 Subject: [PATCH 422/440] Guarding against perf regression for acquire_token_for_client() Turns out we do not really need a full-blown Timeable class Refactor to use pytest and pytest-benchmark Calibrate ratios Adjust detection calculation Experimenting different reference workload Add more iterations to quick test cases Tune reference and each test case to be in tenth of second Go with fewer loop in hoping for more stable time Relax threshold to 20% One more run Use 40% threshold Use larger threshold 0.4 * 3 Refactor to potentially use PyPerf Automatically choose the right number and repeat Remove local regression detection, for now --- requirements.txt | 1 + tests/simulator.py | 69 +++++++++++++++++++++++++++++++++++++++++ tests/test_benchmark.py | 20 ++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 tests/simulator.py create mode 100644 tests/test_benchmark.py diff --git a/requirements.txt b/requirements.txt index d078afb9..d1b00aea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ . python-dotenv +pytest-benchmark diff --git a/tests/simulator.py b/tests/simulator.py new file mode 100644 index 00000000..ea82cf38 --- /dev/null +++ b/tests/simulator.py @@ -0,0 +1,69 @@ +"""Simulator(s) that can be used to create MSAL instance +whose token cache is in a certain state, and remains unchanged. +This generic simulator(s) becomes the test subject for different benchmark tools. + +For example, you can install pyperf and then run: + + pyperf timeit -s "from tests.simulator import ClientCredentialGrantSimulator as T; t=T(tokens_per_tenant=1, cache_hit=True)" "t.run()" +""" +import json +import logging +import random +from unittest.mock import patch + +import msal +from tests.http_client import MinimalResponse + + +logger = logging.getLogger(__name__) + + +def _count_access_tokens(app): + return len(app.token_cache._cache[app.token_cache.CredentialType.ACCESS_TOKEN]) + + +class ClientCredentialGrantSimulator(object): + + def __init__(self, number_of_tenants=1, tokens_per_tenant=1, cache_hit=False): + logger.info( + "number_of_tenants=%d, tokens_per_tenant=%d, cache_hit=%s", + number_of_tenants, tokens_per_tenant, cache_hit) + with patch.object(msal.authority, "tenant_discovery", return_value={ + "authorization_endpoint": "https://contoso.com/placeholder", + "token_endpoint": "https://contoso.com/placeholder", + }) as _: # Otherwise it would fail on OIDC discovery + self.apps = [ # In MSAL Python, each CCA binds to one tenant only + msal.ConfidentialClientApplication( + "client_id", client_credential="foo", + authority="https://login.microsoftonline.com/tenant_%s" % t, + ) for t in range(number_of_tenants) + ] + for app in self.apps: + for i in range(tokens_per_tenant): # Populate token cache + self.run(app=app, scope="scope_{}".format(i)) + assert tokens_per_tenant == _count_access_tokens(app), ( + "Token cache did not populate correctly: {}".format(json.dumps( + app.token_cache._cache, indent=4))) + + if cache_hit: + self.run(app=app)["access_token"] # Populate 1 token to be hit + expected_tokens = tokens_per_tenant + 1 + else: + expected_tokens = tokens_per_tenant + app.token_cache.modify = lambda *args, **kwargs: None # Cache becomes read-only + self.run(app=app)["access_token"] + assert expected_tokens == _count_access_tokens(app), "Cache shall not grow" + + def run(self, app=None, scope=None): + # This implementation shall be as concise as possible + app = app or random.choice(self.apps) + #return app.acquire_token_for_client([scope or "scope"], post=_fake) + return app.acquire_token_for_client( + [scope or "scope"], + post=lambda url, **kwargs: MinimalResponse( # Using an inline lambda is as fast as a standalone function + status_code=200, text='''{ + "access_token": "AT for %s", + "token_type": "bearer" + }''' % kwargs["data"]["scope"], + )) + diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 00000000..dbc54da9 --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,20 @@ +from tests.simulator import ClientCredentialGrantSimulator as CcaTester + +# Here come benchmark test cases, powered by pytest-benchmark +# Func names will become diag names. +def test_cca_1_tenant_with_10_tokens_per_tenant_and_cache_hit(benchmark): + tester = CcaTester(tokens_per_tenant=10, cache_hit=True) + benchmark(tester.run) + +def test_cca_many_tenants_with_10_tokens_per_tenant_and_cache_hit(benchmark): + tester = CcaTester(number_of_tenants=1000, tokens_per_tenant=10, cache_hit=True) + benchmark(tester.run) + +def test_cca_1_tenant_with_10_tokens_per_tenant_and_cache_miss(benchmark): + tester = CcaTester(tokens_per_tenant=10, cache_hit=False) + benchmark(tester.run) + +def test_cca_many_tenants_with_10_tokens_per_tenant_and_cache_miss(benchmark): + tester = CcaTester(number_of_tenants=1000, tokens_per_tenant=10, cache_hit=False) + benchmark(tester.run) + From a3e6017e45877bacab55e86b95112ca46a1a0f2e Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 4 Jul 2023 16:06:45 -0700 Subject: [PATCH 423/440] Add benchmark action and publish it to gh-pages Experimenting not using GPO Use vanilla git command to publish Do not run benchmark in matrix Skip chatty test case discovery during benchmark --- .github/workflows/python-package.yml | 29 ++++++++++++++++++++++++++-- README.md | 6 +++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e7ae1643..1bac8ebb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -42,15 +42,40 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test with pytest + run: pytest --benchmark-skip - 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 + + cb: + # Benchmark only after the correctness has been tested by CI, + # and then run benchmark only once (sampling with only one Python version). + needs: ci + 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: Install dependencies run: | - pytest + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Run benchmark + run: pytest --benchmark-only --benchmark-json benchmark.json --log-cli-level INFO tests/test_benchmark.py + - name: Render benchmark result + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: 'pytest' + output-file-path: benchmark.json + fail-on-alert: true + - name: Publish Gibhub Pages + run: git push origin gh-pages cd: needs: ci diff --git a/README.md b/README.md index 3fab9682..9d72fdfe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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) +| `dev` branch | Reference Docs | # of Downloads per different platforms | # of Downloads per recent MSAL versions | Benchmark Diagram | +|:------------:|:--------------:|:--------------------------------------:|:---------------------------------------:|:-----------------:| + [![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://static.pepy.tech/badge/msal)](https://pypistats.org/packages/msal) | [![Download monthly](https://static.pepy.tech/badge/msal/month)](https://pepy.tech/project/msal) | [📉](https://azuread.github.io/microsoft-authentication-library-for-python/dev/bench/) 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 From dd9e090168140c21aa6d0da8c0fc2b6b527c8e65 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 31 Jul 2023 19:03:23 -0700 Subject: [PATCH 424/440] Automatically check cryptography version --- tests/certificate-with-password.pem | 51 +++++++++++++++++++++++ tests/test_cryptography.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/certificate-with-password.pem create mode 100644 tests/test_cryptography.py diff --git a/tests/certificate-with-password.pem b/tests/certificate-with-password.pem new file mode 100644 index 00000000..bca1bb58 --- /dev/null +++ b/tests/certificate-with-password.pem @@ -0,0 +1,51 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIpDOLr9sNuTwCAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECIWEFMkTyS60BIIEyPvMPGyf1shr +ql0UnZzMWDz/9bqdRIZe5N7F1qYjgZNms/QXVzZOQ2J9YwaLHbwpEv2QigfHXq/3 +nLyTm4HFyg3qpCqWa33A0r/v5B6WtjgYbuPePqpM2UV34CMGylkmhMVUUbs1X6j7 +ezwmipq/paOslokC0RYl16cQD/uLTD0usTDtWoEs3S4gbcGUj/b9Ll2urG9zBYwI +faSWQcwcRgfYk/OZSbv6zNT74dYMAOW3mjsS/ackc/+h2XWFQSVjN7BBXamfCQ3C +qE27Py85PP8Qt7MbHuoj8rMuoaQ3NIi++1RkW6cyFo5n+HBi7YYCP74loG1OCTN8 +H5StIi2aLbls9ZbQJHLM2+J1tBwJqIR/UogITESV++17ZVHfDLk8uaad7i6Kj+u5 +6vbruehnFqo5P80lZRuqHfGf/5v8Hbsve/zL24wdQ+tFDHaC6v+kiz9unnO/+k86 +9gph/WTly4N4wJhdxhoYxJLMdPcWk6AxA7ZsJ/mI9+t8iHdSOZY91FyN3sDlDB4C +yLi8t1WP+VB9KfMjSN0AuULWrwwQ1YGRUsKaS9pxTy8MbXQ1OgXGGHzHKDm6vqyp +Jow9wD8Ql+V7zPsNgBpeRWXzA5VS6nEyIuOolkJnNoC5d69/LtDaBn58TZQ4z1Ja +wGXG6n9BeFrgwgH5X5kGslLDXZ71V/aT0HHoBbiAPWf90teccGJ5nVXv3kMaC/zc +klzNCrQ2koFphQgW1bU/FZ46yd2rvlFo8wbxAwRieldZpkwRcFVKz+cRh4/QsTpl +uPKtPpI0c2jgiSReNXi2kRlkOPg1UVHnapvv2yRUoq4NvzaOegSVJG1oe/XdLS02 +5JoDNajEcIvHQpLZL1ecQSwpge843mW4F5twm8/1MKY8G2CTXjKif77n3WVR2Tvp +RdOm86TbIB4FpbLAqiN11A/8cLNfVmioQkNdULLELfKiOeQZABSMPJMVGVxgODN0 +nP31BMibPayqApLLEFIQbSLhZvWIJ5ircZ5XKPHeuqpnxFoeIwrGQuqHo6gvSp5J +CPv4Pul43y0s3vxRpJAqmXO4aAzsPsrYGJiNckbD43OxRV9ZDDeD1Wrc4u82zQxo +frSy5XhVPsKH1lFZ7l6te4Tkro4vMxRVu+W2acToJI6QZct0xrlp+ScmD/9CEOGU +Wj5SN4Y8YW+UfeYhDAhntzruRx4HjdocbvwYsY9gAkim4P9AdiG6eeRUyU9GCUwd +MrjMt5HUzsTQiIyN9jnv9yWNdYmzgJ2V0ZOwVHaEZhZnkgYoK0O/NXSg0FsPo5LU +qdYncK+BMAGnEl/riaJRmnsIH29jKPjZDOvfo1+0UJM3+zPjYy4985+CH7xWGWnb +NQFBtwiPyWzlDyV3119T4rY+Ad6z90vG4hgvpvue1Qcuaure8NwkUEEh1/d8PN20 +kP4vWhpDeHbO5R5byXlJMNzmgVm3mc2t6mA/ouUcMmUOTvjdYALXqgw9RgOsqkob +DNjEK4VZUu3Vd6AsK+s796KTLgQvZhrcahoer/88j7Nu0PyQGrVN202IfmbjIwer +NNcieLmok6r2k8GvyUYP51hpdkXO5j3BsrtBeq4xn3qxzOtEUL8ITZ/BU20+xJq6 +GoGDjvCSBpzesnQFlvUtEw== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDbTCCAlWgAwIBAgIUHBH8mppwjLI2dFOQ7haLnd6iRjQwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMDAxMDMxODE2MzlaGA8yMTk5 +MDYwODE4MTYzOVowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBANMXamdgXR+0B2b6zt2nURcYcwC0YrqjvTH/ofF3 +MjUzZ1uKziPNxxAYrUY0O0zIcZWo9Aqfi10vS5oNya/aDrKoWxVRCsLltAV9dbLJ +65zF7wbVE7ZnZ7Nknop+ytd1t1VNTlpbxgWdT6z/WTn4ydqH7Hlh0Ucu2Q3QGQL3 +G9He0kOMog4Y0myxP2xNGjLoig2kh60KEwtxbudOxVN4rLpqhT/1n/L5s+7rznKc +cB4MRqPJMdycIYhTD2mfp/E9hDWRcVJY+9GlqzyxXFTsDsO1SzGgpMEjdO5mtc6N +A0dd8fZQLt1BHLFJlpsuk5Fk40y7HtT3kYKUcD55Xd0pd6ECAwEAAaNTMFEwHQYD +VR0OBBYEFKG65qd+cChhFLB8y4po+vL3HwxuMB8GA1UdIwQYMBaAFKG65qd+cChh +FLB8y4po+vL3HwxuMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +AAlEqWO8tGKprQrqfdZDsMyKyne+WYYA7RqRxYzKJ5j1zsPa4f7RKhLYzx6TqWK3 +keBc6aw9aeNYNqTBeDLGHRHRRwdHwwU5HdqowhE0sEQzOIJvs2JK5L+LcoSkTViy +PzidZ0qoAHRluuFw8Ag9sahcQfao6rqJOFY/16KEjDthATGo/4mHRsuAM+xza+2m +GbqJH/iO/q0lsPb3culm8aoJNxULTHrU5YWhuGvRypSYrfdL7RBkzW4VEt5LcRK6 +KcfmfHMrjPl/XxSSvrBmly7nYNH80DGSMRP/lnrQ8OS+hSiDy1KBaCcNhja5Dyzn +K0dXlMGmWrnDMs8m+4cUoIM= +-----END CERTIFICATE----- diff --git a/tests/test_cryptography.py b/tests/test_cryptography.py new file mode 100644 index 00000000..5af5722a --- /dev/null +++ b/tests/test_cryptography.py @@ -0,0 +1,63 @@ +import configparser +import os +import re +from unittest import TestCase +import warnings +import xml.etree.ElementTree as ET + +import requests + +from msal.application import _str2bytes + + +latest_cryptography_version = ET.fromstring( + requests.get("https://pypi.org/rss/project/cryptography/releases.xml").text + ).findall("./channel/item/title")[0].text + + +def get_current_ceiling(): + parser = configparser.ConfigParser() + parser.read("setup.cfg") + for line in parser["options"]["install_requires"].splitlines(): + if line.startswith("cryptography"): + match = re.search(r"<(\d+)", line) + if match: + return int(match.group(1)) + raise RuntimeError("Unable to find cryptography info from setup.cfg") + + +class CryptographyTestCase(TestCase): + + def test_should_be_run_with_latest_version_of_cryptography(self): + import cryptography + self.assertEqual( + cryptography.__version__, latest_cryptography_version, + "We are using cryptography {} but we should test with latest {} instead. " + "Run 'pip install -U cryptography'.".format( + cryptography.__version__, latest_cryptography_version)) + + def test_latest_cryptography_should_support_our_usage_without_warnings(self): + with open(os.path.join( + os.path.dirname(__file__), "certificate-with-password.pem")) as f: + cert = f.read() + with warnings.catch_warnings(record=True) as encountered_warnings: + # The usage was copied from application.py + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend + unencrypted_private_key = serialization.load_pem_private_key( + _str2bytes(cert), + _str2bytes("password"), + backend=default_backend(), # It was a required param until 2020 + ) + self.assertEqual(0, len(encountered_warnings), + "Did cryptography deprecate the functions that we used?") + + def test_ceiling_should_be_latest_cryptography_version_plus_three(self): + expected_ceiling = int(latest_cryptography_version.split(".")[0]) + 3 + self.assertEqual( + expected_ceiling, get_current_ceiling(), + "Test passed with latest cryptography, so we shall bump ceiling to N+3={}, " + "based on their latest deprecation policy " + "https://cryptography.io/en/latest/api-stability/#deprecation".format( + expected_ceiling)) + From 42516f5225b502a33f67c699a22cae9318de5f36 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 21 Aug 2023 21:15:53 -0700 Subject: [PATCH 425/440] Placeholders in some error will use curly brackets This way, it will remain visible even if it is rendered on web. The choice of curly brackets is influenced by URL Template RFC 6570. --- msal/authority.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msal/authority.py b/msal/authority.py index 6eb294f1..ae3ebf74 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -163,9 +163,9 @@ def canonicalize(authority_or_auth_endpoint): 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://.ciamlogin.com/ " - "or https://.b2clogin.com/.onmicrosoft.com/policy" + "https://login.microsoftonline.com/{tenant} " + "or https://{tenant_name}.ciamlogin.com/{tenant} " + "or https://{tenant_name}.b2clogin.com/{tenant_name}.onmicrosoft.com/policy" % authority_or_auth_endpoint) def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs): From 4850c8155f9c2f52dc4f10713802cf293fd0fa4a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 24 Aug 2023 09:36:32 -0700 Subject: [PATCH 426/440] Provide guidance on how to DIY the pkcs12-to-pem --- msal/application.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 7803702f..b3afbccf 100644 --- a/msal/application.py +++ b/msal/application.py @@ -206,12 +206,19 @@ def __init__( or an X509 certificate container in this form:: { - "private_key": "...-----BEGIN PRIVATE KEY-----...", + "private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format", "thumbprint": "A1B2C3D4E5F6...", "public_certificate": "...-----BEGIN CERTIFICATE-----... (Optional. See below.)", "passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)", } + MSAL Python requires a "private_key" in PEM format. + If your cert is in a PKCS12 (.pfx) format, you can also + `convert it to PEM and get the thumbprint `_. + + The thumbprint is available in your app's registration in Azure Portal. + Alternatively, you can `calculate the thumbprint `_. + *Added in version 0.5.0*: public_certificate (optional) is public key certificate which will be sent through 'x5c' JWT header only for From d788a880e9000427944e5485b11e62807a3550a6 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 16 Aug 2023 18:45:24 -0700 Subject: [PATCH 427/440] Experimental: More precise regression detection --- .github/workflows/python-package.yml | 6 ++++++ .gitignore | 3 ++- requirements.txt | 11 ++++++++--- tests/test_benchmark.py | 8 ++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1bac8ebb..87125759 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -66,6 +66,12 @@ jobs: run: | python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Setup an updatable cache for Performance Baselines + uses: actions/cache@v3 + with: + path: .perf.baseline + key: ${{ runner.os }}-performance-${{ hashFiles('tests/test_benchmark.py') }} + restore-keys: ${{ runner.os }}-performance- - name: Run benchmark run: pytest --benchmark-only --benchmark-json benchmark.json --log-cli-level INFO tests/test_benchmark.py - name: Render benchmark result diff --git a/.gitignore b/.gitignore index 18dae08c..36b43713 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ docs/_build/ tests/config.json -.env \ No newline at end of file +.env +.perf.baseline diff --git a/requirements.txt b/requirements.txt index d1b00aea..7252b96e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ -. -python-dotenv -pytest-benchmark +-e . + +# python-dotenv 1.0+ no longer supports Python 3.7 +python-dotenv>=0.21,<2 + +pytest-benchmark>=4,<5 +perf_baseline>=0.1,<0.2 + diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index dbc54da9..9aaeac05 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -1,20 +1,28 @@ from tests.simulator import ClientCredentialGrantSimulator as CcaTester +from perf_baseline import Baseline + + +baseline = Baseline(".perf.baseline", threshold=1.5) # Up to 1.5x slower than baseline # Here come benchmark test cases, powered by pytest-benchmark # Func names will become diag names. def test_cca_1_tenant_with_10_tokens_per_tenant_and_cache_hit(benchmark): tester = CcaTester(tokens_per_tenant=10, cache_hit=True) + baseline.set_or_compare(tester.run) benchmark(tester.run) def test_cca_many_tenants_with_10_tokens_per_tenant_and_cache_hit(benchmark): tester = CcaTester(number_of_tenants=1000, tokens_per_tenant=10, cache_hit=True) + baseline.set_or_compare(tester.run) benchmark(tester.run) def test_cca_1_tenant_with_10_tokens_per_tenant_and_cache_miss(benchmark): tester = CcaTester(tokens_per_tenant=10, cache_hit=False) + baseline.set_or_compare(tester.run) benchmark(tester.run) def test_cca_many_tenants_with_10_tokens_per_tenant_and_cache_miss(benchmark): tester = CcaTester(number_of_tenants=1000, tokens_per_tenant=10, cache_hit=False) + baseline.set_or_compare(tester.run) benchmark(tester.run) From 0dd52f042cf455db82b48c493bf56b36ce80271a Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 29 Aug 2023 13:54:15 -0700 Subject: [PATCH 428/440] Refactor SshCert e2e test to use lab user --- tests/test_e2e.py | 95 ++++++++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f2c3d97a..12231a2a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -277,48 +277,6 @@ def _test_acquire_token_interactive( 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"]) - - def test_ssh_cert_for_user_should_work_with_any_account(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", - prompt="none" if msal.application._is_running_in_cloud_shell() else None, - ) # 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']) - - @unittest.skipUnless( msal.application._is_running_in_cloud_shell(), "Manually run this test case from inside Cloud Shell") @@ -697,6 +655,59 @@ def _test_acquire_token_by_client_secret( self.assertCacheWorksForApp(result, scope) +class PopWithExternalKeyTestCase(LabBasedTestCase): + def _test_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(self.EXPECTED_TOKEN_TYPE, result["token_type"]) + + def _test_user_account(self): + lab_user = self.get_lab_user(usertype="cloud") + 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=lab_user["username"], + lab_name=lab_user["lab_name"], + prompt="none" if msal.application._is_running_in_cloud_shell() else None, + ) # 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(self.EXPECTED_TOKEN_TYPE, 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(self.EXPECTED_TOKEN_TYPE, refreshed_ssh_cert["token_type"]) + self.assertNotEqual(result["access_token"], refreshed_ssh_cert['access_token']) + + +class SshCertTestCase(PopWithExternalKeyTestCase): + EXPECTED_TOKEN_TYPE = "ssh-cert" + _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_service_principal(self): + self._test_service_principal() + + def test_user_account(self): + self._test_user_account() + + class WorldWideTestCase(LabBasedTestCase): def test_aad_managed_user(self): # Pure cloud From 97152d826bc1bdd7bc1770dc4ccb36b428c54c15 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 30 Aug 2023 13:55:02 -0700 Subject: [PATCH 429/440] E2E test for Azure CLI's connectedk8s AT POP --- tests/test_e2e.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 12231a2a..9deec8f7 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -10,7 +10,7 @@ load_dotenv() # take environment variables from .env. except: pass - +import base64 import logging import os import json @@ -708,6 +708,32 @@ def test_user_account(self): self._test_user_account() +def _data_for_pop(key): + raw_req_cnf = json.dumps({"kid": key, "xms_ksl": "sw"}) + return { # Sampled from Azure CLI's plugin connectedk8s + 'token_type': 'pop', + 'key_id': key, + "req_cnf": base64.urlsafe_b64encode(raw_req_cnf.encode('utf-8')).decode('utf-8').rstrip('='), + # Note: Sending raw_req_cnf without base64 encoding would result in an http 500 error + } # See also https://github.com/Azure/azure-cli-extensions/blob/main/src/connectedk8s/azext_connectedk8s/_clientproxyutils.py#L86-L92 + + +class AtPopWithExternalKeyTestCase(PopWithExternalKeyTestCase): + EXPECTED_TOKEN_TYPE = "pop" + DATA1 = _data_for_pop('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA') # Fake key with a certain format and length + DATA2 = _data_for_pop('BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB-BBBBBBBB') # Fake key with a certain format and length + SCOPE = [ + '6256c85f-0aad-4d50-b960-e6e9b21efe35/.default', # Azure CLI's connectedk8s plugin uses this + # https://github.com/Azure/azure-cli-extensions/pull/4468/files#diff-a47efa3186c7eb4f1176e07d0b858ead0bf4a58bfd51e448ee3607a5b4ef47f6R116 + ] + + def test_service_principal(self): + self._test_service_principal() + + def test_user_account(self): + self._test_user_account() + + class WorldWideTestCase(LabBasedTestCase): def test_aad_managed_user(self): # Pure cloud From 91f0b141f0e0cdb9911aacabc1b6b28d64d891b9 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 31 Aug 2023 13:55:20 -0700 Subject: [PATCH 430/440] Add POP test function --- tests/msaltest.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/msaltest.py b/tests/msaltest.py index 21f78bd4..0721e38f 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -1,11 +1,11 @@ -import getpass, json, logging, sys, msal +import base64, getpass, json, logging, sys, msal AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" def print_json(blob): - print(json.dumps(blob, indent=2)) + print(json.dumps(blob, indent=2, sort_keys=True)) def _input_boolean(message): return input( @@ -134,6 +134,24 @@ def acquire_ssh_cert_interactive(app): if result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert") +POP_KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA' # Fake key with a certain format and length +RAW_REQ_CNF = json.dumps({"kid": POP_KEY_ID, "xms_ksl": "sw"}) +POP_DATA = { # Sampled from Azure CLI's plugin connectedk8s + 'token_type': 'pop', + 'key_id': POP_KEY_ID, + "req_cnf": base64.urlsafe_b64encode(RAW_REQ_CNF.encode('utf-8')).decode('utf-8').rstrip('='), + # Note: Sending RAW_REQ_CNF without base64 encoding would result in an http 500 error +} # See also https://github.com/Azure/azure-cli-extensions/blob/main/src/connectedk8s/azext_connectedk8s/_clientproxyutils.py#L86-L92 + +def acquire_pop_token_interactive(app): + """Acquire a POP token interactively - This typically only works with Azure CLI""" + POP_SCOPE = ['6256c85f-0aad-4d50-b960-e6e9b21efe35/.default'] # KAP 1P Server App Scope, obtained from https://github.com/Azure/azure-cli-extensions/pull/4468/files#diff-a47efa3186c7eb4f1176e07d0b858ead0bf4a58bfd51e448ee3607a5b4ef47f6R116 + result = _acquire_token_interactive(app, POP_SCOPE, data=POP_DATA) + print_json(result) + if result.get("token_type") != "pop": + logging.error("Unable to acquire a pop token") + + def remove_account(app): """remove_account() - Invalidate account and/or token(s) from cache, so that acquire_token_silent() would be reset""" account = _select_account(app) @@ -188,6 +206,7 @@ def main(): acquire_token_by_username_password, acquire_ssh_cert_silently, acquire_ssh_cert_interactive, + acquire_pop_token_interactive, remove_account, exit, ], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:") From 4f146f77a6785e89450fa071a43bb29cbd18679f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 4 Sep 2023 08:15:16 -0700 Subject: [PATCH 431/440] Calls out that each commit triggers a TestPyPI release --- .github/workflows/python-package.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 87125759..ab544fb1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -85,6 +85,10 @@ jobs: cd: needs: ci + # Note: github.event.pull_request.draft == false WON'T WORK in "if" statement, + # because the triggered event is a push, not a pull_request. + # This means each commit will trigger a release on TestPyPI. + # Those releases will only succeed when each push has a new version number: a1, a2, a3, etc. if: | github.event_name == 'push' && ( From 62050de028381d0a06103ffe73cdee5c49651c6b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 6 Sep 2023 23:46:25 -0700 Subject: [PATCH 432/440] Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index b3afbccf..2c63d202 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.23.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.24.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From 841bb0b15cb2ea23c69cb81ca992a472eed5c6cb Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 8 Sep 2023 19:16:02 -0700 Subject: [PATCH 433/440] CLI tester will be shipped with msal library --- tests/msaltest.py => msal/__main__.py | 95 ++++++++++++++------------- 1 file changed, 51 insertions(+), 44 deletions(-) rename tests/msaltest.py => msal/__main__.py (78%) diff --git a/tests/msaltest.py b/msal/__main__.py similarity index 78% rename from tests/msaltest.py rename to msal/__main__.py index 0721e38f..cd40b8b4 100644 --- a/tests/msaltest.py +++ b/msal/__main__.py @@ -1,8 +1,19 @@ +# It is currently shipped inside msal library. +# Pros: It is always available wherever msal is installed. +# Cons: Its 3rd-party dependencies (if any) may become msal's dependency. +"""MSAL Python Tester + +Usage 1: Run it on the fly. + python -m msal + +Usage 2: Build an all-in-one executable file for bug bash. + shiv -e msal.__main__._main -o msaltest-on-os-name.pyz . + Note: We choose to not define a console script to avoid name conflict. +""" import base64, getpass, json, logging, sys, msal - -AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" -VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" +_AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" +_VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" def print_json(blob): print(json.dumps(blob, indent=2, sort_keys=True)) @@ -61,7 +72,7 @@ def _select_account(app): else: print("No account available inside MSAL Python. Use other methods to acquire token first.") -def acquire_token_silent(app): +def _acquire_token_silent(app): """acquire_token_silent() - with an account already signed into MSAL Python.""" account = _select_account(app) if account: @@ -71,7 +82,8 @@ def acquire_token_silent(app): force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), )) -def _acquire_token_interactive(app, scopes, data=None): +def _acquire_token_interactive(app, scopes=None, data=None): + """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" prompt = _select_options([ {"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."}, {"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."}, @@ -88,78 +100,73 @@ def _acquire_token_interactive(app, scopes, data=None): ) login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint result = app.acquire_token_interactive( - scopes, + scopes or _input_scopes(), parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right - AZURE_CLI, VISUAL_STUDIO, + _AZURE_CLI, _VISUAL_STUDIO, ], # Here this test app mimics the setting for some known MSA-PT apps prompt=prompt, login_hint=login_hint, data=data or {}) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") if signed_in_user != login_hint: logging.warning('Signed-in user "%s" does not match login_hint', signed_in_user) + print_json(result) return result -def acquire_token_interactive(app): - """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" - print_json(_acquire_token_interactive(app, _input_scopes())) - -def acquire_token_by_username_password(app): +def _acquire_token_by_username_password(app): """acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc""" print_json(app.acquire_token_by_username_password( _input("username: "), getpass.getpass("password: "), scopes=_input_scopes())) _JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}""" -SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1} -SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"] +_SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1} +_SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"] -def acquire_ssh_cert_silently(app): +def _acquire_ssh_cert_silently(app): """Acquire an SSH Cert silently- This typically only works with Azure CLI""" account = _select_account(app) if account: result = app.acquire_token_silent( - SSH_CERT_SCOPE, + _SSH_CERT_SCOPE, account, - data=SSH_CERT_DATA, + data=_SSH_CERT_DATA, force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), ) print_json(result) if result and result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert.") -def acquire_ssh_cert_interactive(app): +def _acquire_ssh_cert_interactive(app): """Acquire an SSH Cert interactively - This typically only works with Azure CLI""" - result = _acquire_token_interactive(app, SSH_CERT_SCOPE, data=SSH_CERT_DATA) - print_json(result) + result = _acquire_token_interactive(app, scopes=_SSH_CERT_SCOPE, data=_SSH_CERT_DATA) if result.get("token_type") != "ssh-cert": logging.error("Unable to acquire an ssh-cert") -POP_KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA' # Fake key with a certain format and length -RAW_REQ_CNF = json.dumps({"kid": POP_KEY_ID, "xms_ksl": "sw"}) -POP_DATA = { # Sampled from Azure CLI's plugin connectedk8s +_POP_KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA' # Fake key with a certain format and length +_RAW_REQ_CNF = json.dumps({"kid": _POP_KEY_ID, "xms_ksl": "sw"}) +_POP_DATA = { # Sampled from Azure CLI's plugin connectedk8s 'token_type': 'pop', - 'key_id': POP_KEY_ID, - "req_cnf": base64.urlsafe_b64encode(RAW_REQ_CNF.encode('utf-8')).decode('utf-8').rstrip('='), - # Note: Sending RAW_REQ_CNF without base64 encoding would result in an http 500 error + 'key_id': _POP_KEY_ID, + "req_cnf": base64.urlsafe_b64encode(_RAW_REQ_CNF.encode('utf-8')).decode('utf-8').rstrip('='), + # Note: Sending _RAW_REQ_CNF without base64 encoding would result in an http 500 error } # See also https://github.com/Azure/azure-cli-extensions/blob/main/src/connectedk8s/azext_connectedk8s/_clientproxyutils.py#L86-L92 -def acquire_pop_token_interactive(app): +def _acquire_pop_token_interactive(app): """Acquire a POP token interactively - This typically only works with Azure CLI""" POP_SCOPE = ['6256c85f-0aad-4d50-b960-e6e9b21efe35/.default'] # KAP 1P Server App Scope, obtained from https://github.com/Azure/azure-cli-extensions/pull/4468/files#diff-a47efa3186c7eb4f1176e07d0b858ead0bf4a58bfd51e448ee3607a5b4ef47f6R116 - result = _acquire_token_interactive(app, POP_SCOPE, data=POP_DATA) + result = _acquire_token_interactive(app, scopes=POP_SCOPE, data=_POP_DATA) print_json(result) if result.get("token_type") != "pop": logging.error("Unable to acquire a pop token") - -def remove_account(app): +def _remove_account(app): """remove_account() - Invalidate account and/or token(s) from cache, so that acquire_token_silent() would be reset""" account = _select_account(app) if account: app.remove_account(account) print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"])) -def exit(app): +def _exit(app): """Exit""" bug_link = ( "https://identitydivision.visualstudio.com/Engineering/_queries/query/79b3a352-a775-406f-87cd-a487c382a8ed/" @@ -169,11 +176,11 @@ def exit(app): print("Bye. If you found a bug, please report it here: {}".format(bug_link)) sys.exit() -def main(): - print("Welcome to the Msal Python {} Tester\n".format(msal.__version__)) +def _main(): + print("Welcome to the Msal Python {} Tester (Experimental)\n".format(msal.__version__)) chosen_app = _select_options([ - {"client_id": AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"}, - {"client_id": VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"}, + {"client_id": _AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"}, + {"client_id": _VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"}, {"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"}, ], option_renderer=lambda a: a["name"], @@ -201,14 +208,14 @@ def main(): logging.basicConfig(level=logging.DEBUG) while True: func = _select_options([ - acquire_token_silent, - acquire_token_interactive, - acquire_token_by_username_password, - acquire_ssh_cert_silently, - acquire_ssh_cert_interactive, - acquire_pop_token_interactive, - remove_account, - exit, + _acquire_token_silent, + _acquire_token_interactive, + _acquire_token_by_username_password, + _acquire_ssh_cert_silently, + _acquire_ssh_cert_interactive, + _acquire_pop_token_interactive, + _remove_account, + _exit, ], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:") try: func(app) @@ -218,5 +225,5 @@ def main(): print("Aborted") if __name__ == "__main__": - main() + _main() From a4b76ab436405272fb0a295fab30b590b8179a7b Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sun, 10 Sep 2023 09:55:47 -0700 Subject: [PATCH 434/440] Fix regression on input order for interactive test --- msal/__main__.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/msal/__main__.py b/msal/__main__.py index cd40b8b4..ca8b9a87 100644 --- a/msal/__main__.py +++ b/msal/__main__.py @@ -84,6 +84,7 @@ def _acquire_token_silent(app): def _acquire_token_interactive(app, scopes=None, data=None): """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" + scopes = scopes or _input_scopes() # Let user input scope param before less important prompt and login_hint prompt = _select_options([ {"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."}, {"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."}, @@ -91,21 +92,23 @@ def _acquire_token_interactive(app, scopes=None, data=None): ], option_renderer=lambda o: o["description"], header="Prompt behavior?")["value"] - raw_login_hint = _select_options( - # login_hint is unnecessary when prompt=select_account, - # but we still let tester input login_hint, just for testing purpose. - [None] + [a["username"] for a in app.get_accounts()], - header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)", - accept_nonempty_string=True, - ) - login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint + if prompt == "select_account": + login_hint = None # login_hint is unnecessary when prompt=select_account + else: + raw_login_hint = _select_options( + [None] + [a["username"] for a in app.get_accounts()], + header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)", + accept_nonempty_string=True, + ) + login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint result = app.acquire_token_interactive( - scopes or _input_scopes(), + scopes, parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right _AZURE_CLI, _VISUAL_STUDIO, ], # Here this test app mimics the setting for some known MSA-PT apps - prompt=prompt, login_hint=login_hint, data=data or {}) + prompt=prompt, login_hint=login_hint, data=data or {}, + ) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") if signed_in_user != login_hint: From 0d9e07f4d3caa82987fee412577cf560cb6bb409 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 25 Sep 2023 20:44:57 -0700 Subject: [PATCH 435/440] Bumping version number --- msal/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 2c63d202..73a69a35 100644 --- a/msal/application.py +++ b/msal/application.py @@ -25,7 +25,7 @@ # The __init__.py will import this. Not the other way around. -__version__ = "1.24.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.24.1" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" From 82319cfc2376c85e201b35a3b1d40c13e4e013ad Mon Sep 17 00:00:00 2001 From: Anthony Shaw Date: Fri, 6 Oct 2023 12:54:34 +1100 Subject: [PATCH 436/440] Mark package as supporting Python 3.12 --- .github/workflows/python-package.yml | 2 +- setup.cfg | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ab544fb1..198767d7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest # It switched to 22.04 shortly after 2022-Nov-8 strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12-dev"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 diff --git a/setup.cfg b/setup.cfg index 39b8524e..adf13aba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 License :: OSI Approved :: MIT License Operating System :: OS Independent From e65781acd7aa0cfa3bb136f39d15174719df2b63 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 5 Oct 2023 20:55:30 -0700 Subject: [PATCH 437/440] Remove x-client-cpu --- msal/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/msal/application.py b/msal/application.py index 73a69a35..71b0afcd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -669,7 +669,6 @@ def _build_client(self, client_credential, authority, skip_regional_client=False 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: From a19f3e4a0f5e339df5de7e769dd1b7768bd1eceb Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 14 Sep 2023 14:39:57 -0700 Subject: [PATCH 438/440] Resolve warnings node12 deprecation warnings https://github.blog/changelog/2023-06-13-github-actions-all-actions-will-run-on-node16-instead-of-node12-by-default/ --- .github/workflows/python-package.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 198767d7..461eb959 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,9 +29,9 @@ jobs: python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 # It automatically takes care of pip cache, according to # https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#about-caching-workflow-dependencies with: @@ -57,9 +57,9 @@ jobs: needs: ci runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies @@ -97,9 +97,9 @@ jobs: ) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: Build a package for release From 83e3b8d4bf52a1511a19e7709a2b1957ecc87134 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 19 Oct 2023 16:45:10 -0700 Subject: [PATCH 439/440] Support path in acquire_token_interactive --- msal/__main__.py | 33 ++++++++++++++++++++++++++++----- msal/application.py | 25 +++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/msal/__main__.py b/msal/__main__.py index ca8b9a87..51b2e740 100644 --- a/msal/__main__.py +++ b/msal/__main__.py @@ -12,8 +12,26 @@ """ import base64, getpass, json, logging, sys, msal +# This tester can test scenarios of these apps _AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" _VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd" +_WHITE_BOARD = "95de633a-083e-42f5-b444-a4295d8e9314" +_KNOWN_APPS = { + _AZURE_CLI: { + "client_id": _AZURE_CLI, + "name": "Azure CLI (Correctly configured for MSA-PT)", + "path_in_redirect_uri": None, + }, + _VISUAL_STUDIO: { + "client_id": _VISUAL_STUDIO, + "name": "Visual Studio (Correctly configured for MSA-PT)", + "path_in_redirect_uri": None, + }, + _WHITE_BOARD: { + "client_id": _WHITE_BOARD, + "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)", + }, +} def print_json(blob): print(json.dumps(blob, indent=2, sort_keys=True)) @@ -82,6 +100,13 @@ def _acquire_token_silent(app): force_refresh=_input_boolean("Bypass MSAL Python's token cache?"), )) +def _get_redirect_uri_path(app): + if app._enable_broker: + return None + if "path_in_redirect_uri" in _KNOWN_APPS.get(app.client_id, {}): + return _KNOWN_APPS[app.client_id]["path_in_redirect_uri"] + return input("What is the path in this app's redirect_uri?") + def _acquire_token_interactive(app, scopes=None, data=None): """acquire_token_interactive() - User will be prompted if app opts to do select_account.""" scopes = scopes or _input_scopes() # Let user input scope param before less important prompt and login_hint @@ -108,6 +133,7 @@ def _acquire_token_interactive(app, scopes=None, data=None): _AZURE_CLI, _VISUAL_STUDIO, ], # Here this test app mimics the setting for some known MSA-PT apps prompt=prompt, login_hint=login_hint, data=data or {}, + path=_get_redirect_uri_path(app), ) if login_hint and "id_token_claims" in result: signed_in_user = result.get("id_token_claims", {}).get("preferred_username") @@ -181,11 +207,8 @@ def _exit(app): def _main(): print("Welcome to the Msal Python {} Tester (Experimental)\n".format(msal.__version__)) - chosen_app = _select_options([ - {"client_id": _AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"}, - {"client_id": _VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"}, - {"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"}, - ], + chosen_app = _select_options( + list(_KNOWN_APPS.values()), option_renderer=lambda a: a["name"], header="Impersonate this app (or you can type in the client_id of your own app)", accept_nonempty_string=True) diff --git a/msal/application.py b/msal/application.py index 71b0afcd..9943f5cd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1747,6 +1747,7 @@ def acquire_token_interactive( max_age=None, parent_window_handle=None, on_before_launching_ui=None, + path=None, **kwargs): """Acquire token interactively i.e. via a local browser. @@ -1786,8 +1787,21 @@ def acquire_token_interactive( :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``.) + By default, a system-allocated port will be used. + The unspecified parts of the ``redirect_uri`` are hard coded as + ``http://localhost``. + Only useful when using system browser authentication + (i.e., not an authentication brokerlike WAM). + + :param str path: + The path to be used in the redirect URI. + By default, no path is used. + The unspecified parts of the ``redirect_uri`` are hard coded as + ``http://localhost``. + Only useful when using system browser authentication + (i.e., not an authentication brokerlike WAM). + + New in version 1.25.0. :param list extra_scopes_to_consent: "Extra scopes to consent" is a concept only available in AAD. @@ -1877,9 +1891,12 @@ def 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( + redirect_uri="http://localhost:{port}/{path}".format( # Hardcode the host, for now. AAD portal rejects 127.0.0.1 anyway - port=port or 0), + port=port or 0, + path=path or "", # There could be multiple localhost uri only differ by path + # https://learn.microsoft.com/en-us/azure/active-directory/develop/reply-url#localhost-exceptions + ), prompt=prompt, login_hint=login_hint, max_age=max_age, From 6cc1efa81e0f496351fc234e22fd6faeddb2e082 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 26 Oct 2023 09:59:55 -0700 Subject: [PATCH 440/440] Fix typo --- msal/application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msal/application.py b/msal/application.py index 9943f5cd..82372cc9 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1791,7 +1791,7 @@ def acquire_token_interactive( The unspecified parts of the ``redirect_uri`` are hard coded as ``http://localhost``. Only useful when using system browser authentication - (i.e., not an authentication brokerlike WAM). + (i.e., not an authentication broker like WAM). :param str path: The path to be used in the redirect URI. @@ -1799,7 +1799,7 @@ def acquire_token_interactive( The unspecified parts of the ``redirect_uri`` are hard coded as ``http://localhost``. Only useful when using system browser authentication - (i.e., not an authentication brokerlike WAM). + (i.e., not an authentication broker like WAM). New in version 1.25.0.