pax_global_header00006660000000000000000000000064142025625730014520gustar00rootroot0000000000000052 comment=a88fa673af3602fe7c8c922314599b0c245e7add microsoft-authentication-extensions-for-python-1.0.0/000077500000000000000000000000001420256257300230405ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/.github/000077500000000000000000000000001420256257300244005ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/.github/workflows/000077500000000000000000000000001420256257300264355ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/.github/workflows/codeql.yml000066400000000000000000000023741420256257300304350ustar00rootroot00000000000000name: "Code Scanning - Action" on: push: schedule: - cron: '0 0 * * 0' jobs: CodeQL-Build: strategy: fail-fast: false # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 # Override language selection by uncommenting this and choosing your languages # with: # languages: go, javascript, csharp, python, cpp, java # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below). - name: Autobuild uses: github/codeql-action/autobuild@v1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 microsoft-authentication-extensions-for-python-1.0.0/.github/workflows/python-package.yml000066400000000000000000000077521420256257300321050ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: CI on: push: pull_request: branches: [ dev ] # This guards against unknown PR until a community member vet it and label it. types: [ labeled ] jobs: ci: runs-on: ${{ matrix.os }} strategy: matrix: python-version: [3.7, 3.8, 3.9, 2.7] os: [ubuntu-latest, windows-latest, macos-latest] include: # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#using-environment-variables-in-a-matrix - python-version: 3.7 toxenv: "py37" - python-version: 3.8 toxenv: "py38" - python-version: 3.9 toxenv: "py39" - python-version: 2.7 toxenv: "py27" - python-version: 3.9 os: ubuntu-latest lint: "true" 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 Linux dependencies for Python 2 if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '2.7' }} run: | sudo apt update sudo apt install python-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 gnome-keyring - name: Install Linux dependencies for Python 3 if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version != '2.7' }} run: | sudo apt update sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 gnome-keyring - name: Install PyGObject on Linux if: ${{ matrix.os == 'ubuntu-latest' }} run: | python -m pip install --upgrade pip python -m pip install pygobject - name: Install Python dependencies run: | python -m pip install --upgrade pip python -m pip install pylint tox pytest pip install . - name: Lint if: ${{ matrix.lint == 'true' }} run: | pylint msal_extensions # 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 on Linux with encryption if: ${{ matrix.os == 'ubuntu-latest' }} run: | # Don't know why, but the pytest and "." have to be re-installed again for them to be used echo "echo secret_placeholder | gnome-keyring-daemon --unlock; pip install pytest .; pytest" > linux_test.sh chmod +x linux_test.sh sudo dbus-run-session -- ./linux_test.sh - name: Test on other platforms without encryption if: ${{ matrix.os != 'ubuntu-latest' }} env: TOXENV: ${{ matrix.toxenv }} run: | tox cd: needs: ci if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/master') 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/master' with: user: __token__ password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@v1.4.2 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} microsoft-authentication-extensions-for-python-1.0.0/.gitignore000066400000000000000000000130021420256257300250240ustar00rootroot00000000000000## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUNIT *.VisualState.xml TestResult.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ **/Properties/launchSettings.json # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_i.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # JustCode is a .NET coding add-in .JustCode # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # JetBrains Rider .idea/ *.sln.iml # CodeRush .cr/ # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Python Auxiliary Tools *.egg-info/ .tox/ # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ .eggs/ microsoft-authentication-extensions-for-python-1.0.0/.pylintrc000066400000000000000000000003711420256257300247060ustar00rootroot00000000000000[MESSAGES CONTROL] good-names= logger disable= consider-using-f-string, # For Python < 3.6 super-with-arguments, # For Python 2.x raise-missing-from, # For Python 2.x trailing-newlines, useless-object-inheritance microsoft-authentication-extensions-for-python-1.0.0/.travis.yml000066400000000000000000000105401420256257300251510ustar00rootroot00000000000000language: python matrix: fast_finish: true include: - python: "2.7" env: TOXENV=py27 PYPI=true os: linux before_install: - sudo apt update - sudo apt install python-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 - pip install --upgrade pip - python: "3.5" env: TOXENV=py35 os: linux before_install: - sudo apt update - sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 - pip install --upgrade pip ## Somehow cryptography is not able to be compiled and installed on Python 3.6 #- python: "3.6" # env: # - TOXENV=py36 # - CRYPTOGRAPHY_DONT_BUILD_RUST=1 # os: linux # before_install: # - sudo apt update # - sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 # - pip install --upgrade pip - python: "3.7" env: TOXENV=py37 os: linux dist: xenial before_install: - sudo apt update - sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 - pip install --upgrade pip - python: "3.8" env: TOXENV=py38 os: linux dist: xenial before_install: - sudo apt update - sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 - pip install --upgrade pip - name: "Python 3.7 on macOS" env: TOXENV=py37 os: osx osx_image: xcode10.2 language: shell - name: "Python 2.7 on Windows" env: TOXENV=py27 PATH=/c/Python27:/c/Python27/Scripts:$PATH os: windows before_install: - choco install python2 - pip install --upgrade --user pip language: shell - name: "Python 3.5 on Windows" env: TOXENV=py35 PATH=/c/Python35:/c/Python35/Scripts:$PATH os: windows before_install: - choco install python3 --version 3.5.4 - pip install --upgrade --user pip language: shell - name: "Python 3.7 on Windows" env: TOXENV=py37 PATH=/c/Python37:/c/Python37/Scripts:$PATH os: windows before_install: - choco install python3 --version 3.7.3 - pip install --upgrade --user pip language: shell install: - pip install tox pylint - pip install . script: - # Difficult to get .pylintrc working on both Python 2 & 3, and we don't have to - if [ "$TOXENV" = "py37"]; then pylint msal_extensions; fi - tox deploy: - # test pypi provider: pypi distributions: "sdist bdist_wheel" server: https://test.pypi.org/legacy/ user: "nugetaad" password: secure: dpNi6BsZyiAx/gkxJ5Mz6m2yDz2dRGWsSgS5pF+ywNzgHJ6+0e234GyLbSUY5bFeeA7WtOr4is3bxSLB/6tTWDVWdw3TL4FGlDM/54MSLWg8R5bR9PRwO+VU1kvQ03yz+B9mTpzuiwL2e+OSwcwo97jForADzmSRA5OpEq5Z7zAs7WR8J2tyhl+288NwLtKJMVy39UmPl9oifu6/5RfBn7EWLmC7MrMFhHTb2Gj7fJWw4u+5vx9bsQ7ubfiwPbRAtYXLz6wDMtwtFzwme4zZPg5HwWCn0WWlX4b6x7xXirZ7yKsy9iACLgTrLMeAkferrex7f03NFeIDobasML+fLbZufATaL3M97kNGZwulEYNp2+RWyLu/NW6FoZCbS+cSL8HuFnkIDHzEoO56ItMiD9EH47q/NeKgwrrzKjfY+KzaMQOYLlVgCa4WrIeFh5CkwJ4RHrfanMIV2vbEvMxsnHc/mZ+yvgBOFoBNXYN1HEDzEv1NxDPcyt7MBlPUVinEreQaHba7w6qH9Rf0eWgfW2ypBXe+nHaZxQgaGC6J+WGUkzalYQspmHVU4CcuwJa55kuchJs/gbyZKkyK6P8uD5IP6VZiavwZcjWcfvwbZaLeOqzSDVCDMg8M2zYZHoa+6ZR4EgDVW7RvaRvjvvhPTPj5twmLf3YYVJtHIyJSLug= on: branch: master tags: false condition: $PYPI = "true" - # production pypi provider: pypi distributions: "sdist bdist_wheel" user: "nugetaad" password: secure: dpNi6BsZyiAx/gkxJ5Mz6m2yDz2dRGWsSgS5pF+ywNzgHJ6+0e234GyLbSUY5bFeeA7WtOr4is3bxSLB/6tTWDVWdw3TL4FGlDM/54MSLWg8R5bR9PRwO+VU1kvQ03yz+B9mTpzuiwL2e+OSwcwo97jForADzmSRA5OpEq5Z7zAs7WR8J2tyhl+288NwLtKJMVy39UmPl9oifu6/5RfBn7EWLmC7MrMFhHTb2Gj7fJWw4u+5vx9bsQ7ubfiwPbRAtYXLz6wDMtwtFzwme4zZPg5HwWCn0WWlX4b6x7xXirZ7yKsy9iACLgTrLMeAkferrex7f03NFeIDobasML+fLbZufATaL3M97kNGZwulEYNp2+RWyLu/NW6FoZCbS+cSL8HuFnkIDHzEoO56ItMiD9EH47q/NeKgwrrzKjfY+KzaMQOYLlVgCa4WrIeFh5CkwJ4RHrfanMIV2vbEvMxsnHc/mZ+yvgBOFoBNXYN1HEDzEv1NxDPcyt7MBlPUVinEreQaHba7w6qH9Rf0eWgfW2ypBXe+nHaZxQgaGC6J+WGUkzalYQspmHVU4CcuwJa55kuchJs/gbyZKkyK6P8uD5IP6VZiavwZcjWcfvwbZaLeOqzSDVCDMg8M2zYZHoa+6ZR4EgDVW7RvaRvjvvhPTPj5twmLf3YYVJtHIyJSLug= on: branch: master tags: true condition: $PYPI = "true" microsoft-authentication-extensions-for-python-1.0.0/Dockerfile000066400000000000000000000023451420256257300250360ustar00rootroot00000000000000# TODO: Can this Dockerfile use multi-stage build? # Final size 690MB. (It would be 1.16 GB if started with python:3 as base) FROM python:3-slim # Install Generic PyGObject (sans GTK) #The following somehow won't work: #RUN apt-get update && apt-get install -y python3-gi python3-gi-cairo RUN apt-get update && apt-get install -y \ libcairo2-dev \ libgirepository1.0-dev \ python3-dev RUN pip install "pygobject>=3,<4" # Install MSAL Extensions dependencies # Don't know how to get container talk to dbus on host, # so we choose to create a self-contained image by installing gnome-keyring RUN apt-get install -y \ gir1.2-secret-1 \ gnome-keyring # Not strictly necessary, but we include a pytest (which is only 3MB) to facilitate testing. RUN pip install "pytest>=6,<7" # Install MSAL Extensions. Upgrade the pinned version number to trigger a new image build. RUN pip install "msal-extensions==0.3" # This setup is inspired from https://github.com/jaraco/keyring#using-keyring-on-headless-linux-systems-in-a-docker-container ENTRYPOINT ["dbus-run-session", "--"] # Note: gnome-keyring-daemon needs previleged mode, therefore can not be run by a RUN command. CMD ["sh", "-c", "echo default_secret | gnome-keyring-daemon --unlock; bash"] microsoft-authentication-extensions-for-python-1.0.0/LICENSE000066400000000000000000000022371420256257300240510ustar00rootroot00000000000000 MIT License Copyright (c) Microsoft Corporation. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE microsoft-authentication-extensions-for-python-1.0.0/README.md000066400000000000000000000143641420256257300243270ustar00rootroot00000000000000 # Microsoft Authentication Extensions for Python The Microsoft Authentication Extensions for Python offers secure mechanisms for client applications to perform cross-platform token cache serialization and persistence. It gives additional support to the [Microsoft Authentication Library for Python (MSAL)](https://github.com/AzureAD/microsoft-authentication-library-for-python). MSAL Python supports an in-memory cache by default and provides the [SerializableTokenCache](https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache) to perform cache serialization. You can read more about this in the MSAL Python [documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-python-token-cache-serialization). Developers are required to implement their own cache persistance across multiple platforms and Microsoft Authentication Extensions makes this simpler. The supported platforms are Windows, Mac and Linux. - Windows - [DPAPI](https://docs.microsoft.com/en-us/dotnet/standard/security/how-to-use-data-protection) is used for encryption. - MAC - The MAC KeyChain is used. - Linux - [LibSecret](https://wiki.gnome.org/Projects/Libsecret) is used for encryption. > Note: It is recommended to use this library for cache persistance support for Public client applications such as Desktop apps only. In web applications, this may lead to scale and performance issues. Web applications are recommended to persist the cache in session. Take a look at this [webapp sample](https://github.com/Azure-Samples/ms-identity-python-webapp). ## Installation You can find Microsoft Authentication Extensions for Python on [Pypi](https://pypi.org/project/msal-extensions/). 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. Run `pip install msal-extensions`. ## 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-extensions-for-python/releases). ## Usage ### Creating an encrypted token cache file to be used by MSAL The Microsoft Authentication Extensions library provides the `PersistedTokenCache` which accepts a platform-dependent persistence instance. This token cache can then be used to instantiate the `PublicClientApplication` in MSAL Python. The token cache includes a file lock, and auto-reload behavior under the hood. Here is an example of this pattern for multiple platforms (taken from the complete [sample here](https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py)): ```python def build_persistence(location, fallback_to_plaintext=False): """Build a suitable persistence instance based your current OS""" try: return build_encrypted_persistence(location) except: if not fallback_to_plaintext: raise logging.warning("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) persistence = build_persistence("token_cache.bin") print("Type of persistence: {}".format(persistence.__class__.__name__)) print("Is this persistence encrypted?", persistence.is_encrypted) cache = PersistedTokenCache(persistence) ``` Now you can use it in an MSAL application like this: ```python app = msal.PublicClientApplication("my_client_id", token_cache=cache) ``` ### Creating an encrypted persistence file to store your own data Here is an example of this pattern for multiple platforms (taken from the complete [sample here](https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/persistence_sample.py)): ```python def build_persistence(location, fallback_to_plaintext=False): """Build a suitable persistence instance based your current OS""" try: return build_encrypted_persistence(location) except: # pylint: disable=bare-except if not fallback_to_plaintext: raise logging.warning("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) persistence = build_persistence("storage.bin", fallback_to_plaintext=False) print("Type of persistence: {}".format(persistence.__class__.__name__)) print("Is this persistence encrypted?", persistence.is_encrypted) data = { # It can be anything, here we demonstrate an arbitrary json object "foo": "hello world", "bar": "", "service_principle_1": "blah blah...", } persistence.save(json.dumps(data)) assert json.loads(persistence.load()) == data ``` ## 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 browse 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) ## Contributing All code is licensed under the MIT license and we triage actively on GitHub. This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com. When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. ## We value and adhere to the Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. microsoft-authentication-extensions-for-python-1.0.0/azure-pipelines.yml000066400000000000000000000001371420256257300267000ustar00rootroot00000000000000resources: - repo: self trigger: batch: true branches: include: - '*' microsoft-authentication-extensions-for-python-1.0.0/docker_run.sh000077500000000000000000000006101420256257300255270ustar00rootroot00000000000000#!/usr/bin/bash IMAGE_NAME=msal-extensions:latest docker build -t $IMAGE_NAME - < Dockerfile echo "==== Integration Test for Persistence on Linux (libsecret) ====" echo "After seeing the bash prompt, run the following to test encryption on Linux:" echo " pip install -e ." echo " pytest" docker run --rm -it \ --privileged \ -w /home -v $PWD:/home \ $IMAGE_NAME \ $1 microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/000077500000000000000000000000001420256257300262535ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/__init__.py000066400000000000000000000005321420256257300303640ustar00rootroot00000000000000"""Provides auxiliary functionality to the `msal` package.""" __version__ = "1.0.0" from .persistence import ( FilePersistence, build_encrypted_persistence, FilePersistenceWithDataProtection, KeychainPersistence, LibsecretPersistence, ) from .cache_lock import CrossPlatLock from .token_cache import PersistedTokenCache microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/cache_lock.py000066400000000000000000000057241420256257300307100ustar00rootroot00000000000000"""Provides a mechanism for not competing with other processes interacting with an MSAL cache.""" import os import sys import errno import time import logging from distutils.version import LooseVersion import portalocker logger = logging.getLogger(__name__) class CrossPlatLock(object): """Offers a mechanism for waiting until another process is finished interacting with a shared resource. This is specifically written to interact with a class of the same name in the .NET extensions library. """ def __init__(self, lockfile_path): self._lockpath = lockfile_path # Support for passing through arguments to the open syscall was added in v1.4.0 open_kwargs = ({'buffering': 0} if LooseVersion(portalocker.__version__) >= LooseVersion("1.4.0") else {}) self._lock = portalocker.Lock( lockfile_path, mode='wb+', # In posix systems, we HAVE to use LOCK_EX(exclusive lock) bitwise ORed # with LOCK_NB(non-blocking) to avoid blocking on lock acquisition. # More information here: # https://docs.python.org/3/library/fcntl.html#fcntl.lockf flags=portalocker.LOCK_EX | portalocker.LOCK_NB, **open_kwargs) def _try_to_create_lock_file(self): timeout = 5 check_interval = 0.25 current_time = getattr(time, "monotonic", time.time) timeout_end = current_time() + timeout pid = os.getpid() while timeout_end > current_time(): try: with open(self._lockpath, 'x'): # pylint: disable=unspecified-encoding return True except ValueError: # This needs to be the first clause, for Python 2 to hit it logger.warning("Python 2 does not support atomic creation of file") return False except FileExistsError: # Only Python 3 will reach this clause logger.debug( "Process %d found existing lock file, will retry after %f second", pid, check_interval) time.sleep(check_interval) return False def __enter__(self): pid = os.getpid() if not self._try_to_create_lock_file(): logger.warning("Process %d failed to create lock file", pid) file_handle = self._lock.__enter__() file_handle.write('{} {}'.format(pid, sys.argv[0]).encode('utf-8')) # pylint: disable=consider-using-f-string return file_handle def __exit__(self, *args): self._lock.__exit__(*args) try: # Attempt to delete the lockfile. In either of the failure cases enumerated below, it is # likely that another process has raced this one and ended up clearing or locking the # file for itself. os.remove(self._lockpath) except OSError as ex: # pylint: disable=invalid-name if ex.errno not in (errno.ENOENT, errno.EACCES): raise microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/libsecret.py000066400000000000000000000141141420256257300306020ustar00rootroot00000000000000"""Implements a Linux specific TokenCache, and provides auxiliary helper types. This module depends on PyGObject. But `pip install pygobject` would typically fail, until you install its dependencies first. For example, on a Debian Linux, you need:: sudo apt install libgirepository1.0-dev libcairo2-dev python3-dev gir1.2-secret-1 pip install pygobject Alternatively, you could skip Cairo & PyCairo, but you still need to do all these (derived from https://gitlab.gnome.org/GNOME/pygobject/-/issues/395):: sudo apt install libgirepository1.0-dev python3-dev gir1.2-secret-1 pip install wheel PYGOBJECT_WITHOUT_PYCAIRO=1 pip install --no-build-isolation pygobject """ try: import gi # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/Encryption-on-Linux # pylint: disable=line-too-long except ImportError: raise ImportError("""Unable to import module 'gi' Runtime dependency of PyGObject is missing. Depends on your Linux distro, you could install it system-wide by something like: sudo apt install python3-gi python3-gi-cairo gir1.2-secret-1 If necessary, please refer to PyGObject's doc: https://pygobject.readthedocs.io/en/latest/getting_started.html """) # Message via exception rather than log try: # pylint: disable=no-name-in-module gi.require_version("Secret", "1") # Would require a package gir1.2-secret-1 # pylint: disable=wrong-import-position from gi.repository import Secret # Would require a package gir1.2-secret-1 except (ValueError, ImportError) as ex: raise type(ex)( """Require a package "gir1.2-secret-1" which could be installed by: sudo apt install gir1.2-secret-1 """) # Message via exception rather than log class LibSecretAgent(object): """A loader/saver built on top of low-level libsecret""" # Inspired by https://developer.gnome.org/libsecret/unstable/py-examples.html def __init__( # pylint: disable=too-many-arguments self, schema_name, attributes, # {"name": "value", ...} label="", # Helpful when visualizing secrets by other viewers attribute_types=None, # {name: SchemaAttributeType, ...} collection=None, # None means default collection ): """This agent is built on top of lower level libsecret API. Content stored via libsecret is associated with a bunch of attributes. :param string schema_name: Attributes would conceptually follow an existing schema. But this class will do it in the other way around, by automatically deriving a schema based on your attributes. However, you will still need to provide a schema_name. load() and save() will only operate on data with matching schema_name. :param dict attributes: Attributes are key-value pairs, represented as a Python dict here. They will be used to filter content during load() and save(). Their arbitrary keys are strings. Their arbitrary values can MEAN strings, integers and booleans, but are always represented as strings, according to upstream sample: https://developer.gnome.org/libsecret/0.18/py-store-example.html :param string label: It will not be used during data lookup and filtering. It is only helpful when/if you visualize secrets by other viewers. :param dict attribute_types: Each key is the name of your each attribute. The corresponding value will be one of the following three: * Secret.SchemaAttributeType.STRING * Secret.SchemaAttributeType.INTEGER * Secret.SchemaAttributeType.BOOLEAN But if all your attributes are Secret.SchemaAttributeType.STRING, you do not need to provide this types definition at all. :param collection: The default value `None` means default collection. """ self._collection = collection self._attributes = attributes or {} self._label = label self._schema = Secret.Schema.new(schema_name, Secret.SchemaFlags.NONE, { k: (attribute_types or {}).get(k, Secret.SchemaAttributeType.STRING) for k in self._attributes}) def save(self, data): """Store data. Returns a boolean of whether operation was successful.""" return Secret.password_store_sync( self._schema, self._attributes, self._collection, self._label, data, None) def load(self): """Load a password in the secret service, return None when found nothing""" return Secret.password_lookup_sync(self._schema, self._attributes, None) def clear(self): """Returns a boolean of whether any passwords were removed""" return Secret.password_clear_sync(self._schema, self._attributes, None) def trial_run(): """This trial run will raise an exception if libsecret is not functioning. Even after you installed all the dependencies so that your script can start, or even if your previous run was successful, your script could fail next time, for example when it will be running inside a headless SSH session. You do not have to do trial_run. The exception would also be raised by save(). """ try: agent = LibSecretAgent("Test Schema", {"attr1": "foo", "attr2": "bar"}) payload = "Test Data" agent.save(payload) # It would fail when running inside an SSH session assert agent.load() == payload # This line is probably not reachable agent.clear() except (gi.repository.GLib.Error, AssertionError): # pylint: disable=no-member # https://pygobject.readthedocs.io/en/latest/guide/api/error_handling.html#examples message = """libsecret did not perform properly. * If you encountered error "Remote error from secret service: org.freedesktop.DBus.Error.ServiceUnknown", you may need to install gnome-keyring package. * Headless mode (such as in an ssh session) is not supported. """ raise RuntimeError(message) # Message via exception rather than log microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/osx.py000066400000000000000000000216451420256257300274460ustar00rootroot00000000000000# pylint: disable=duplicate-code """Implements a macOS specific TokenCache, and provides auxiliary helper types.""" import os import ctypes as _ctypes OS_RESULT = _ctypes.c_int32 # pylint: disable=invalid-name class KeychainError(OSError): """The RuntimeError that will be run when a function interacting with Keychain fails.""" ACCESS_DENIED = -128 NO_SUCH_KEYCHAIN = -25294 NO_DEFAULT = -25307 ITEM_NOT_FOUND = -25300 def __init__(self, exit_status): super(KeychainError, self).__init__() self.exit_status = exit_status # TODO: pylint: disable=fixme # use SecCopyErrorMessageString to fetch the appropriate message here. self.message = ( '{} see https://opensource.apple.com/source/CarbonHeaders/CarbonHeaders-18.1/MacErrors.h' # pylint: disable=consider-using-f-string,line-too-long .format(self.exit_status)) def _get_native_location(name): # type: (str) -> str """ Fetches the location of a native MacOS library. :param name: The name of the library to be loaded. :return: The location of the library on a MacOS filesystem. """ return '/System/Library/Frameworks/{0}.framework/{0}'.format(name) # pylint: disable=consider-using-f-string # Load native MacOS libraries _SECURITY = _ctypes.CDLL(_get_native_location('Security')) _CORE = _ctypes.CDLL(_get_native_location('CoreFoundation')) # Bind CFRelease from native MacOS libraries. _CORE_RELEASE = _CORE.CFRelease _CORE_RELEASE.argtypes = ( _ctypes.c_void_p, ) # Bind SecCopyErrorMessageString from native MacOS libraries. # https://developer.apple.com/documentation/security/1394686-seccopyerrormessagestring?language=objc _SECURITY_COPY_ERROR_MESSAGE_STRING = _SECURITY.SecCopyErrorMessageString _SECURITY_COPY_ERROR_MESSAGE_STRING.argtypes = ( OS_RESULT, _ctypes.c_void_p ) _SECURITY_COPY_ERROR_MESSAGE_STRING.restype = _ctypes.c_char_p # Bind SecKeychainOpen from native MacOS libraries. # https://developer.apple.com/documentation/security/1396431-seckeychainopen _SECURITY_KEYCHAIN_OPEN = _SECURITY.SecKeychainOpen _SECURITY_KEYCHAIN_OPEN.argtypes = ( _ctypes.c_char_p, _ctypes.POINTER(_ctypes.c_void_p) ) _SECURITY_KEYCHAIN_OPEN.restype = OS_RESULT # Bind SecKeychainCopyDefault from native MacOS libraries. # https://developer.apple.com/documentation/security/1400743-seckeychaincopydefault?language=objc _SECURITY_KEYCHAIN_COPY_DEFAULT = _SECURITY.SecKeychainCopyDefault _SECURITY_KEYCHAIN_COPY_DEFAULT.argtypes = ( _ctypes.POINTER(_ctypes.c_void_p), ) _SECURITY_KEYCHAIN_COPY_DEFAULT.restype = OS_RESULT # Bind SecKeychainItemFreeContent from native MacOS libraries. _SECURITY_KEYCHAIN_ITEM_FREE_CONTENT = _SECURITY.SecKeychainItemFreeContent _SECURITY_KEYCHAIN_ITEM_FREE_CONTENT.argtypes = ( _ctypes.c_void_p, _ctypes.c_void_p, ) _SECURITY_KEYCHAIN_ITEM_FREE_CONTENT.restype = OS_RESULT # Bind SecKeychainItemModifyAttributesAndData from native MacOS libraries. _SECURITY_KEYCHAIN_ITEM_MODIFY_ATTRIBUTES_AND_DATA = \ _SECURITY.SecKeychainItemModifyAttributesAndData _SECURITY_KEYCHAIN_ITEM_MODIFY_ATTRIBUTES_AND_DATA.argtypes = ( _ctypes.c_void_p, _ctypes.c_void_p, _ctypes.c_uint32, _ctypes.c_void_p, ) _SECURITY_KEYCHAIN_ITEM_MODIFY_ATTRIBUTES_AND_DATA.restype = OS_RESULT # Bind SecKeychainFindGenericPassword from native MacOS libraries. # https://developer.apple.com/documentation/security/1397301-seckeychainfindgenericpassword?language=objc _SECURITY_KEYCHAIN_FIND_GENERIC_PASSWORD = _SECURITY.SecKeychainFindGenericPassword _SECURITY_KEYCHAIN_FIND_GENERIC_PASSWORD.argtypes = ( _ctypes.c_void_p, _ctypes.c_uint32, _ctypes.c_char_p, _ctypes.c_uint32, _ctypes.c_char_p, _ctypes.POINTER(_ctypes.c_uint32), _ctypes.POINTER(_ctypes.c_void_p), _ctypes.POINTER(_ctypes.c_void_p), ) _SECURITY_KEYCHAIN_FIND_GENERIC_PASSWORD.restype = OS_RESULT # Bind SecKeychainAddGenericPassword from native MacOS # https://developer.apple.com/documentation/security/1398366-seckeychainaddgenericpassword?language=objc _SECURITY_KEYCHAIN_ADD_GENERIC_PASSWORD = _SECURITY.SecKeychainAddGenericPassword _SECURITY_KEYCHAIN_ADD_GENERIC_PASSWORD.argtypes = ( _ctypes.c_void_p, _ctypes.c_uint32, _ctypes.c_char_p, _ctypes.c_uint32, _ctypes.c_char_p, _ctypes.c_uint32, _ctypes.c_char_p, _ctypes.POINTER(_ctypes.c_void_p), ) _SECURITY_KEYCHAIN_ADD_GENERIC_PASSWORD.restype = OS_RESULT class Keychain(object): """Encapsulates the interactions with a particular MacOS Keychain.""" def __init__(self, filename=None): # type: (str) -> None self._ref = _ctypes.c_void_p() if filename: filename = os.path.expanduser(filename) self._filename = filename.encode('utf-8') else: self._filename = None def __enter__(self): if self._filename: status = _SECURITY_KEYCHAIN_OPEN(self._filename, self._ref) else: status = _SECURITY_KEYCHAIN_COPY_DEFAULT(self._ref) if status: raise OSError(status) return self def __exit__(self, *args): if self._ref: _CORE_RELEASE(self._ref) def get_generic_password(self, service, account_name): # type: (str, str) -> str """Fetch the password associated with a particular service and account. :param service: The service that this password is associated with. :param account_name: The account that this password is associated with. :return: The value of the password associated with the specified service and account. """ service = service.encode('utf-8') account_name = account_name.encode('utf-8') length = _ctypes.c_uint32() contents = _ctypes.c_void_p() exit_status = _SECURITY_KEYCHAIN_FIND_GENERIC_PASSWORD( self._ref, len(service), service, len(account_name), account_name, length, contents, None, ) if exit_status: raise KeychainError(exit_status=exit_status) value = _ctypes.create_string_buffer(length.value) _ctypes.memmove(value, contents.value, length.value) _SECURITY_KEYCHAIN_ITEM_FREE_CONTENT(None, contents) return value.raw.decode('utf-8') def set_generic_password(self, service, account_name, value): # type: (str, str, str) -> None """Associate a password with a given service and account. :param service: The service to associate this password with. :param account_name: The account to associate this password with. :param value: The string that should be used as the password. """ service = service.encode('utf-8') account_name = account_name.encode('utf-8') value = value.encode('utf-8') entry = _ctypes.c_void_p() find_exit_status = _SECURITY_KEYCHAIN_FIND_GENERIC_PASSWORD( self._ref, len(service), service, len(account_name), account_name, None, None, entry, ) if not find_exit_status: modify_exit_status = _SECURITY_KEYCHAIN_ITEM_MODIFY_ATTRIBUTES_AND_DATA( entry, None, len(value), value, ) if modify_exit_status: raise KeychainError(exit_status=modify_exit_status) elif find_exit_status == KeychainError.ITEM_NOT_FOUND: add_exit_status = _SECURITY_KEYCHAIN_ADD_GENERIC_PASSWORD( self._ref, len(service), service, len(account_name), account_name, len(value), value, None ) if add_exit_status: raise KeychainError(exit_status=add_exit_status) else: raise KeychainError(exit_status=find_exit_status) def get_internet_password(self, service, username): # type: (str, str) -> str """ Fetches a password associated with a domain and username. NOTE: THIS IS NOT YET IMPLEMENTED :param service: The website/service that this password is associated with. :param username: The account that this password is associated with. :return: The password that was associated with the given service and username. """ raise NotImplementedError() def set_internet_password(self, service, username, value): # type: (str, str, str) -> None """Sets a password associated with a domain and a username. NOTE: THIS IS NOT YET IMPLEMENTED :param service: The website/service that this password is associated with. :param username: The account that this password is associated with. :param value: The password that should be associated with the given service and username. """ raise NotImplementedError() microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/persistence.py000066400000000000000000000333531420256257300311600ustar00rootroot00000000000000"""A generic persistence layer, optionally encrypted on Windows, OSX, and Linux. Should a certain encryption is unavailable, exception will be raised at run-time, rather than at import time. By successfully creating and using a certain persistence object, app developer would naturally know whether the data are protected by encryption. """ import abc import os import errno import hashlib import logging import sys try: from pathlib import Path # Built-in in Python 3 except ImportError: from pathlib2 import Path # An extra lib for Python 2 try: ABC = abc.ABC except AttributeError: # Python 2.7, abc exists, but not ABC ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore logger = logging.getLogger(__name__) def _mkdir_p(path): """Creates a directory, and any necessary parents. If the path provided is an existing file, this function raises an exception. :param path: The directory name that should be created. """ if not path: return # NO-OP if sys.version_info >= (3, 2): os.makedirs(path, exist_ok=True) return # This fallback implementation is based on a Stack Overflow question: # https://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python # Known issue: it won't work when the path is a root folder like "C:\\" try: os.makedirs(path) except OSError as exp: if exp.errno == errno.EEXIST and os.path.isdir(path): pass else: raise def _auto_hash(input_string): return hashlib.sha256(input_string.encode('utf-8')).hexdigest() # We do not aim to wrap every os-specific exception. # Here we standardize only the most common ones, # otherwise caller would need to catch os-specific underlying exceptions. class PersistenceError(IOError): # Use IOError rather than OSError as base, """The base exception for persistence.""" # because historically an IOError was bubbled up and expected. # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/0.2.2/msal_extensions/token_cache.py#L38 # Now we want to maintain backward compatibility even when using Python 2.x # It makes no difference in Python 3.3+ where IOError is an alias of OSError. def __init__(self, err_no=None, message=None, location=None): # pylint: disable=useless-super-delegation super(PersistenceError, self).__init__(err_no, message, location) class PersistenceNotFound(PersistenceError): """This happens when attempting BasePersistence.load() on a non-existent persistence instance""" def __init__(self, err_no=None, message=None, location=None): super(PersistenceNotFound, self).__init__( err_no=errno.ENOENT, message=message or "Persistence not found", location=location) class PersistenceEncryptionError(PersistenceError): """This could be raised by persistence.save()""" class PersistenceDecryptionError(PersistenceError): """This could be raised by persistence.load()""" def build_encrypted_persistence(location): """Build a suitable encrypted persistence instance based your current OS. If you do not need encryption, then simply use ``FilePersistence`` constructor. """ # Does not (yet?) support fallback_to_plaintext flag, # because the persistence on Windows and macOS do not support built-in trial_run(). if sys.platform.startswith('win'): return FilePersistenceWithDataProtection(location) if sys.platform.startswith('darwin'): return KeychainPersistence(location) if sys.platform.startswith('linux'): return LibsecretPersistence(location) raise RuntimeError("Unsupported platform: {}".format(sys.platform)) # pylint: disable=consider-using-f-string class BasePersistence(ABC): """An abstract persistence defining the common interface of this family""" is_encrypted = False # Default to False. To be overridden by sub-classes. @abc.abstractmethod def save(self, content): # type: (str) -> None """Save the content into this persistence""" raise NotImplementedError @abc.abstractmethod def load(self): # type: () -> str """Load content from this persistence. Could raise PersistenceNotFound if no save() was called before. """ raise NotImplementedError @abc.abstractmethod def time_last_modified(self): """Get the last time when this persistence has been modified. Could raise PersistenceNotFound if no save() was called before. """ raise NotImplementedError @abc.abstractmethod def get_location(self): """Return the file path which this persistence stores (meta)data into""" raise NotImplementedError def _open(location): return os.open(location, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600) # The 600 seems no-op on NTFS/Windows, and that is fine class FilePersistence(BasePersistence): """A generic persistence, storing data in a plain-text file""" def __init__(self, location): if not location: raise ValueError("Requires a file path") self._location = os.path.expanduser(location) _mkdir_p(os.path.dirname(self._location)) def save(self, content): # type: (str) -> None """Save the content into this persistence""" with os.fdopen(_open(self._location), 'w+') as handle: handle.write(content) def load(self): # type: () -> str """Load content from this persistence""" try: with open(self._location, 'r') as handle: # pylint: disable=unspecified-encoding return handle.read() except EnvironmentError as exp: # EnvironmentError in Py 2.7 works across platform if exp.errno == errno.ENOENT: raise PersistenceNotFound( message=( "Persistence not initialized. " "You can recover by calling a save() first."), location=self._location, ) raise def time_last_modified(self): try: return os.path.getmtime(self._location) except EnvironmentError as exp: # EnvironmentError in Py 2.7 works across platform if exp.errno == errno.ENOENT: raise PersistenceNotFound( message=( "Persistence not initialized. " "You can recover by calling a save() first."), location=self._location, ) raise def touch(self): """To touch this file-based persistence without writing content into it""" Path(self._location).touch() # For os.path.getmtime() to work def get_location(self): return self._location class FilePersistenceWithDataProtection(FilePersistence): """A generic persistence with data stored in a file, protected by Win32 encryption APIs on Windows""" is_encrypted = True def __init__(self, location, entropy=''): """Initialization could fail due to unsatisfied dependency""" # pylint: disable=import-outside-toplevel from .windows import WindowsDataProtectionAgent self._dp_agent = WindowsDataProtectionAgent(entropy=entropy) super(FilePersistenceWithDataProtection, self).__init__(location) def save(self, content): # type: (str) -> None try: data = self._dp_agent.protect(content) except OSError as exception: raise PersistenceEncryptionError( err_no=getattr(exception, "winerror", None), # Exists in Python 3 on Windows message="Encryption failed: {}. Consider disable encryption.".format(exception), ) with os.fdopen(_open(self._location), 'wb+') as handle: handle.write(data) def load(self): # type: () -> str try: with open(self._location, 'rb') as handle: data = handle.read() except EnvironmentError as exp: # EnvironmentError in Py 2.7 works across platform if exp.errno == errno.ENOENT: raise PersistenceNotFound( message=( "Persistence not initialized. " "You can recover by calling a save() first."), location=self._location, ) logger.exception( "DPAPI error likely caused by file content not previously encrypted. " "App developer should migrate by calling save(plaintext) first.") raise try: return self._dp_agent.unprotect(data) except OSError as exception: raise PersistenceDecryptionError( err_no=getattr(exception, "winerror", None), # Exists in Python 3 on Windows message="Decryption failed: {}. " "App developer may consider this guidance: " "https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/PersistenceDecryptionError" # pylint: disable=line-too-long .format(exception), location=self._location, ) class KeychainPersistence(BasePersistence): """A generic persistence with data stored in, and protected by native Keychain libraries on OSX""" is_encrypted = True def __init__(self, signal_location, service_name=None, account_name=None): """Initialization could fail due to unsatisfied dependency. :param signal_location: See :func:`persistence.LibsecretPersistence.__init__` """ from .osx import Keychain, KeychainError # pylint: disable=import-outside-toplevel self._file_persistence = FilePersistence(signal_location) # Favor composition self._Keychain = Keychain # pylint: disable=invalid-name self._KeychainError = KeychainError # pylint: disable=invalid-name default_service_name = "msal-extensions" # This is also our package name self._service_name = service_name or default_service_name self._account_name = account_name or _auto_hash(signal_location) def save(self, content): with self._Keychain() as locker: locker.set_generic_password( self._service_name, self._account_name, content) self._file_persistence.touch() # For time_last_modified() def load(self): with self._Keychain() as locker: try: return locker.get_generic_password( self._service_name, self._account_name) except self._KeychainError as ex: # pylint: disable=invalid-name if ex.exit_status == self._KeychainError.ITEM_NOT_FOUND: # This happens when a load() is called before a save(). # We map it into cross-platform error for unified catching. raise PersistenceNotFound( location="Service:{} Account:{}".format( # pylint: disable=consider-using-f-string self._service_name, self._account_name), message=( "Keychain persistence not initialized. " "You can recover by call a save() first."), ) raise # We do not intend to hide any other underlying exceptions def time_last_modified(self): return self._file_persistence.time_last_modified() def get_location(self): return self._file_persistence.get_location() class LibsecretPersistence(BasePersistence): """A generic persistence with data stored in, and protected by native libsecret libraries on Linux""" is_encrypted = True def __init__(self, signal_location, schema_name=None, attributes=None, **kwargs): """Initialization could fail due to unsatisfied dependency. :param string signal_location: Besides saving the real payload into encrypted storage, this class will also touch this signal file. Applications may listen a FileSystemWatcher.Changed event for reload. https://docs.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher.changed?view=netframework-4.8#remarks :param string schema_name: See :func:`libsecret.LibSecretAgent.__init__` :param dict attributes: See :func:`libsecret.LibSecretAgent.__init__` """ # pylint: disable=import-outside-toplevel from .libsecret import ( # This uncertain import is deferred till runtime LibSecretAgent, trial_run) trial_run() self._agent = LibSecretAgent( schema_name or _auto_hash(signal_location), attributes or {}, **kwargs) self._file_persistence = FilePersistence(signal_location) # Favor composition def save(self, content): if self._agent.save(content): self._file_persistence.touch() # For time_last_modified() def load(self): data = self._agent.load() if data is None: # Lower level libsecret would return None when found nothing. Here # in persistence layer, we convert it to a unified error for consistence. raise PersistenceNotFound(message=( "Keyring persistence not initialized. " "You can recover by call a save() first.")) return data def time_last_modified(self): return self._file_persistence.time_last_modified() def get_location(self): return self._file_persistence.get_location() # We could also have a KeyringPersistence() which can then be used together # with a FilePersistence to achieve # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/issues/12 # But this idea is not pursued at this time. microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/token_cache.py000066400000000000000000000075621420256257300311020ustar00rootroot00000000000000"""Generic functions and types for working with a TokenCache that is not platform specific.""" import os import time import logging import msal from .cache_lock import CrossPlatLock from .persistence import _mkdir_p, PersistenceNotFound logger = logging.getLogger(__name__) class PersistedTokenCache(msal.SerializableTokenCache): """A token cache backed by a persistence layer, coordinated by a file lock, to sustain a certain level of multi-process concurrency for a desktop app. The scenario is that multiple instances of same desktop app (or even multiple different apps) create their own ``PersistedTokenCache`` instances, which are all backed by the same token cache file on disk (known as a persistence). The goal is to have Single Sign On (SSO). Each instance of ``PersistedTokenCache`` holds a snapshot of the token cache in memory. Each :func:`~find` call will automatically reload token cache from the persistence when necessary, so that it will have fresh data. Each :func:`~modify` call will automatically reload token cache from the persistence when necessary, so that new writes will be appended on top of latest token cache data, and then the new data will be immediately flushed back to the persistence. Note: :func:`~deserialize` and :func:`~serialize` remain the same as their counterparts in the parent class ``msal.SerializableTokenCache``. In other words, they do not have the "reload from persistence if necessary" nor the "flush back to persistence" behavior. """ def __init__(self, persistence, lock_location=None): super(PersistedTokenCache, self).__init__() self._lock_location = ( os.path.expanduser(lock_location) if lock_location else persistence.get_location() + ".lockfile") _mkdir_p(os.path.dirname(self._lock_location)) self._persistence = persistence self._last_sync = 0 # _last_sync is a Unixtime self.is_encrypted = persistence.is_encrypted def _reload_if_necessary(self): # type: () -> None """Reload cache from persistence layer, if necessary""" try: if self._last_sync < self._persistence.time_last_modified(): self.deserialize(self._persistence.load()) self._last_sync = time.time() except PersistenceNotFound: # From cache's perspective, a nonexistent persistence is a NO-OP. pass # However, existing data unable to be decrypted will still be bubbled up. def modify(self, credential_type, old_entry, new_key_value_pairs=None): with CrossPlatLock(self._lock_location): self._reload_if_necessary() super(PersistedTokenCache, self).modify( credential_type, old_entry, new_key_value_pairs=new_key_value_pairs) self._persistence.save(self.serialize()) self._last_sync = time.time() def find(self, credential_type, **kwargs): # pylint: disable=arguments-differ # Use optimistic locking rather than CrossPlatLock(self._lock_location) retry = 3 for attempt in range(1, retry + 1): try: self._reload_if_necessary() except Exception: # pylint: disable=broad-except # Presumably other processes are writing the file, causing dirty read if attempt < retry: logger.debug("Unable to load token cache file in No. %d attempt", attempt) time.sleep(0.5) else: raise # End of retry. Re-raise the exception as-is. else: # If reload encountered no error, the data is considered intact return super(PersistedTokenCache, self).find(credential_type, **kwargs) return [] # Not really reachable here. Just to keep pylint happy. microsoft-authentication-extensions-for-python-1.0.0/msal_extensions/windows.py000066400000000000000000000123231420256257300303200ustar00rootroot00000000000000"""Implements a Windows Specific TokenCache, and provides auxiliary helper types.""" import ctypes from ctypes import wintypes _LOCAL_FREE = ctypes.windll.kernel32.LocalFree _GET_LAST_ERROR = ctypes.windll.kernel32.GetLastError _MEMCPY = ctypes.cdll.msvcrt.memcpy _MEMCPY.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] # Note: # Suggested by https://github.com/AzureAD/microsoft-authentication-extensions-for-python/issues/85 # pylint: disable=line-too-long # Matching https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/memcpy-wmemcpy?view=msvc-160 # pylint: disable=line-too-long _CRYPT_PROTECT_DATA = ctypes.windll.crypt32.CryptProtectData _CRYPT_UNPROTECT_DATA = ctypes.windll.crypt32.CryptUnprotectData _CRYPTPROTECT_UI_FORBIDDEN = 0x01 class DataBlob(ctypes.Structure): # pylint: disable=too-few-public-methods """A wrapper for interacting with the _CRYPTOAPI_BLOB type and its many aliases. This type is exposed from Wincrypt.h in XP and above. The memory associated with a DataBlob itself does not need to be freed, as the Python runtime will correctly clean it up. However, depending on the data it points at, it may still need to be freed. For instance, memory created by ctypes.create_string_buffer is already managed, and needs to not be freed. However, memory allocated by CryptProtectData and CryptUnprotectData must have LocalFree called on pbData. See documentation for this type at: https://msdn.microsoft.com/en-us/7a06eae5-96d8-4ece-98cb-cf0710d2ddbd """ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] def raw(self): # type: () -> bytes """Copies the message from the DataBlob in natively allocated memory into Python controlled memory. :return A byte array that matches what is stored in native-memory.""" cb_data = int(self.cbData) pb_data = self.pbData blob_buffer = ctypes.create_string_buffer(cb_data) _MEMCPY(blob_buffer, pb_data, cb_data) return blob_buffer.raw _err_description = { # Keys came from real world observation, values came from winerror.h (http://errors (Microsoft internal)) -2146893813: "Key not valid for use in specified state.", -2146892987: "The requested operation cannot be completed. " "The computer must be trusted for delegation and " "the current user account must be configured to allow delegation. " "See also https://docs.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/enable-computer-and-user-accounts-to-be-trusted-for-delegation", 13: "The data is invalid", } # This code is modeled from a StackOverflow question, which can be found here: # https://stackoverflow.com/questions/463832/using-dpapi-with-python class WindowsDataProtectionAgent(object): """A mechanism for interacting with the Windows DP API Native library, e.g. Crypt32.dll.""" def __init__(self, entropy=None): # type: (str) -> None self._entropy_blob = None if entropy: entropy_utf8 = entropy.encode('utf-8') blob_buffer = ctypes.create_string_buffer(entropy_utf8, len(entropy_utf8)) self._entropy_blob = DataBlob(len(entropy_utf8), blob_buffer) def protect(self, message): # type: (str) -> bytes """Encrypts a message. :return cipher text holding the original message.""" message = message.encode('utf-8') message_buffer = ctypes.create_string_buffer(message, len(message)) message_blob = DataBlob(len(message), message_buffer) result = DataBlob() if self._entropy_blob: entropy = ctypes.byref(self._entropy_blob) else: entropy = None if _CRYPT_PROTECT_DATA( ctypes.byref(message_blob), u"python_data", # pylint: disable=redundant-u-string-prefix entropy, None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(result)): try: return result.raw() finally: _LOCAL_FREE(result.pbData) err_code = _GET_LAST_ERROR() raise OSError(None, _err_description.get(err_code), None, err_code) def unprotect(self, cipher_text): # type: (bytes) -> str """Decrypts cipher text that is provided. :return The original message hidden in the cipher text.""" ct_buffer = ctypes.create_string_buffer(cipher_text, len(cipher_text)) ct_blob = DataBlob(len(cipher_text), ct_buffer) result = DataBlob() if self._entropy_blob: entropy = ctypes.byref(self._entropy_blob) else: entropy = None if _CRYPT_UNPROTECT_DATA( ctypes.byref(ct_blob), None, entropy, None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(result) ): try: return result.raw().decode('utf-8') finally: _LOCAL_FREE(result.pbData) err_code = _GET_LAST_ERROR() raise OSError(None, _err_description.get(err_code), None, err_code) microsoft-authentication-extensions-for-python-1.0.0/sample/000077500000000000000000000000001420256257300243215ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/sample/persistence_sample.py000066400000000000000000000026071420256257300305650ustar00rootroot00000000000000import logging import json from msal_extensions import build_encrypted_persistence, FilePersistence, CrossPlatLock def build_persistence(location, fallback_to_plaintext=False): """Build a suitable persistence instance based your current OS""" # Note: This sample stores both encrypted persistence and plaintext persistence # into same location, therefore their data would likely override with each other. try: return build_encrypted_persistence(location) except: # pylint: disable=bare-except # On Linux, encryption exception will be raised during initialization. # On Windows and macOS, they won't be detected here, # but will be raised during their load() or save(). if not fallback_to_plaintext: raise logging.warning("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) persistence = build_persistence("storage.bin", fallback_to_plaintext=False) print("Type of persistence: {}".format(persistence.__class__.__name__)) print("Is this persistence encrypted?", persistence.is_encrypted) data = { # It can be anything, here we demonstrate an arbitrary json object "foo": "hello world", "bar": "", "service_principle_1": "blah blah...", } with CrossPlatLock("my_another_lock.txt"): persistence.save(json.dumps(data)) assert json.loads(persistence.load()) == data microsoft-authentication-extensions-for-python-1.0.0/sample/token_cache_sample.py000066400000000000000000000023501420256257300304770ustar00rootroot00000000000000import sys import logging import json from msal_extensions import build_encrypted_persistence, FilePersistence def build_persistence(location, fallback_to_plaintext=False): """Build a suitable persistence instance based your current OS""" # Note: This sample stores both encrypted persistence and plaintext persistence # into same location, therefore their data would likely override with each other. try: return build_encrypted_persistence(location) except: # pylint: disable=bare-except # On Linux, encryption exception will be raised during initialization. # On Windows and macOS, they won't be detected here, # but will be raised during their load() or save(). if not fallback_to_plaintext: raise logging.warning("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) persistence = build_persistence("token_cache.bin") print("Type of persistence: {}".format(persistence.__class__.__name__)) print("Is this persistence encrypted?", persistence.is_encrypted) cache = PersistedTokenCache(persistence) # Now you can use it in an msal application like this: # app = msal.PublicClientApplication("my_client_id", token_cache=cache) microsoft-authentication-extensions-for-python-1.0.0/setup.cfg000066400000000000000000000011411420256257300246560ustar00rootroot00000000000000# https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files [bdist_wheel] universal=1 [metadata] license = MIT project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-extensions-for-python/releases classifiers = License :: OSI Approved :: MIT License Development Status :: 5 - Production/Stable description = Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism. microsoft-authentication-extensions-for-python-1.0.0/setup.py000066400000000000000000000027761420256257300245660ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_packages import re, io __version__ = re.search( r'__version__\s*=\s*[rRfFuU]{0,2}[\'"]([^\'"]*)[\'"]', io.open('msal_extensions/__init__.py', encoding='utf_8_sig').read() ).group(1) try: long_description = open('README.md').read() except OSError: long_description = "README.md is not accessible on TRAVIS CI's Python 3.5" setup( name='msal-extensions', version=__version__, packages=find_packages(), long_description=long_description, long_description_content_type="text/markdown", package_data={'': ['LICENSE']}, install_requires=[ 'msal>=0.4.1,<2.0.0', # In order to implement these requirements: # Lowerbound = (1.6 if playform_system == 'Windows' else 1.0) # Upperbound < (3 if python_version >= '3.5' else 2) # The following 4 lines use the `and` syntax defined here: # https://www.python.org/dev/peps/pep-0508/#grammar "portalocker<3,>=1.0;python_version>='3.5' and platform_system!='Windows'", "portalocker<2,>=1.0;python_version=='2.7' and platform_system!='Windows'", "portalocker<3,>=1.6;python_version>='3.5' and platform_system=='Windows'", "portalocker<2,>=1.6;python_version=='2.7' and platform_system=='Windows'", "pathlib2;python_version<'3.0'", ## We choose to NOT define a hard dependency on this. # "pygobject>=3,<4;platform_system=='Linux'", ], tests_require=['pytest'], ) microsoft-authentication-extensions-for-python-1.0.0/tests/000077500000000000000000000000001420256257300242025ustar00rootroot00000000000000microsoft-authentication-extensions-for-python-1.0.0/tests/cache_file_generator.py000066400000000000000000000025531420256257300306710ustar00rootroot00000000000000""" Usage: cache_file_generator.py cache_file_path sleep_interval This is a console application which is to be used for cross-platform lock performance testing. The app will acquire lock for the cache file, log the process id and then release the lock. It takes in two arguments - cache file path and the sleep interval. The cache file path is the path of cache file. The sleep interval is the time in seconds for which the lock is held by a process. """ import logging import os import sys import time from portalocker import exceptions from msal_extensions import FilePersistence, CrossPlatLock def _acquire_lock_and_write_to_cache(cache_location, sleep_interval): cache_accessor = FilePersistence(cache_location) lock_file_path = cache_accessor.get_location() + ".lockfile" try: with CrossPlatLock(lock_file_path): data = cache_accessor.load() if data is None: data = "" data += "< " + str(os.getpid()) + "\n" time.sleep(sleep_interval) data += "> " + str(os.getpid()) + "\n" cache_accessor.save(data) except exceptions.LockException as e: logging.warning("Unable to acquire lock %s", e) if __name__ == "__main__": if len(sys.argv) < 3: print(__doc__) sys.exit(0) _acquire_lock_and_write_to_cache(sys.argv[1], float(sys.argv[2])) microsoft-authentication-extensions-for-python-1.0.0/tests/lock_acquire.py000066400000000000000000000016031420256257300272150ustar00rootroot00000000000000import sys import os import time import datetime from msal_extensions import CrossPlatLock def main(hold_time): # type: (datetime.timedelta) -> None """ Grabs a lock from a well-known file in order to test the CrossPlatLock class across processes. :param hold_time: The approximate duration that this process should hold onto the lock. :return: None """ pid = os.getpid() print('{} starting'.format(pid)) with CrossPlatLock('./delete_me.lockfile'): print('{} has acquired the lock'.format(pid)) time.sleep(hold_time.total_seconds()) print('{} is releasing the lock'.format(pid)) print('{} done.'.format(pid)) if __name__ == '__main__': lock_hold_time = datetime.timedelta(seconds=5) if len(sys.argv) > 1: hold_time = datetime.timedelta(seconds=int(sys.argv[1])) main(lock_hold_time) microsoft-authentication-extensions-for-python-1.0.0/tests/test_agnostic_backend.py000066400000000000000000000034251420256257300310750ustar00rootroot00000000000000import os import shutil import tempfile import sys import msal import pytest from msal_extensions import * @pytest.fixture def temp_location(): test_folder = tempfile.mkdtemp(prefix="test_token_cache_roundtrip") yield os.path.join(test_folder, 'token_cache.bin') shutil.rmtree(test_folder, ignore_errors=True) def _test_token_cache_roundtrip(cache): client_id = os.getenv('AZURE_CLIENT_ID') client_secret = os.getenv('AZURE_CLIENT_SECRET') if not (client_id and client_secret): pytest.skip('no credentials present to test TokenCache round-trip with.') app = msal.ConfidentialClientApplication( client_id=client_id, client_credential=client_secret, token_cache=cache) desired_scopes = ['https://graph.microsoft.com/.default'] token1 = app.acquire_token_for_client(scopes=desired_scopes) os.utime( # Mock having another process update the cache cache._persistence.get_location(), None) token2 = app.acquire_token_silent(scopes=desired_scopes, account=None) assert token1['access_token'] == token2['access_token'] def test_file_token_cache_roundtrip(temp_location): _test_token_cache_roundtrip(PersistedTokenCache(FilePersistence(temp_location))) def test_current_platform_cache_roundtrip_with_persistence_builder(temp_location): _test_token_cache_roundtrip(PersistedTokenCache(build_encrypted_persistence(temp_location))) def test_persisted_token_cache(temp_location): _test_token_cache_roundtrip(PersistedTokenCache(FilePersistence(temp_location))) def test_file_not_found_error_is_not_raised(): persistence = FilePersistence('non_existing_file') cache = PersistedTokenCache(persistence) # An exception raised here will fail the test case as it is supposed to be a NO-OP cache.find('') microsoft-authentication-extensions-for-python-1.0.0/tests/test_cache_lock_file_perf.py000066400000000000000000000045271420256257300317110ustar00rootroot00000000000000import multiprocessing import os import shutil import tempfile import pytest from cache_file_generator import _acquire_lock_and_write_to_cache @pytest.fixture def temp_location(): test_folder = tempfile.mkdtemp(prefix="test_persistence_roundtrip") yield os.path.join(test_folder, 'persistence.bin') shutil.rmtree(test_folder, ignore_errors=True) def _validate_result_in_cache(cache_location): with open(cache_location) as handle: data = handle.read() prev_process_id = None count = 0 for line in data.split("\n"): if line: count += 1 tag, process_id = line.split(" ") if prev_process_id is not None: assert process_id == prev_process_id, "Process overlap found" assert tag == '>', "Process overlap_found" prev_process_id = None else: assert tag == '<', "Opening bracket not found" prev_process_id = process_id return count def _run_multiple_processes(no_of_processes, cache_location, sleep_interval): open(cache_location, "w+") processes = [] for i in range(no_of_processes): process = multiprocessing.Process( target=_acquire_lock_and_write_to_cache, args=(cache_location, sleep_interval)) processes.append(process) for process in processes: process.start() for process in processes: process.join() def test_lock_for_normal_workload(temp_location): num_of_processes = 4 sleep_interval = 0.1 _run_multiple_processes(num_of_processes, temp_location, sleep_interval) count = _validate_result_in_cache(temp_location) assert count == num_of_processes * 2, "Should not observe starvation" def test_lock_for_high_workload(temp_location): num_of_processes = 80 sleep_interval = 0 _run_multiple_processes(num_of_processes, temp_location, sleep_interval) count = _validate_result_in_cache(temp_location) assert count <= num_of_processes * 2, "Starvation or not, we should not observe garbled payload" def test_lock_for_timeout(temp_location): num_of_processes = 30 sleep_interval = 1 _run_multiple_processes(num_of_processes, temp_location, sleep_interval) count = _validate_result_in_cache(temp_location) assert count < num_of_processes * 2, "Should observe starvation" microsoft-authentication-extensions-for-python-1.0.0/tests/test_crossplatlock.py000066400000000000000000000005601420256257300304770ustar00rootroot00000000000000import pytest from msal_extensions.cache_lock import CrossPlatLock def test_ensure_file_deleted(): lockfile = './test_lock_1.txt' try: FileNotFoundError except NameError: FileNotFoundError = IOError with CrossPlatLock(lockfile): pass with pytest.raises(FileNotFoundError): with open(lockfile): pass microsoft-authentication-extensions-for-python-1.0.0/tests/test_macos_backend.py000066400000000000000000000033741420256257300303730ustar00rootroot00000000000000import sys import os import shutil import tempfile import pytest import uuid import msal if not sys.platform.startswith('darwin'): pytest.skip('skipping MacOS-only tests', allow_module_level=True) else: from msal_extensions.osx import Keychain from msal_extensions.token_cache import PersistedTokenCache from msal_extensions.persistence import KeychainPersistence def test_keychain_roundtrip(): with Keychain() as subject: location, account = "msal_extension_test1", "test_account1" want = uuid.uuid4().hex subject.set_generic_password(location, account, want) got = subject.get_generic_password(location, account) assert got == want def test_osx_token_cache_roundtrip(): client_id = os.getenv('AZURE_CLIENT_ID') client_secret = os.getenv('AZURE_CLIENT_SECRET') if not (client_id and client_secret): pytest.skip('no credentials present to test PersistedTokenCache round-trip with.') test_folder = tempfile.mkdtemp(prefix="msal_extension_test_osx_token_cache_roundtrip") cache_file = os.path.join(test_folder, 'msal.cache') try: subject = PersistedTokenCache(KeychainPersistence(cache_file)) app = msal.ConfidentialClientApplication( client_id=client_id, client_credential=client_secret, token_cache=subject) desired_scopes = ['https://graph.microsoft.com/.default'] token1 = app.acquire_token_for_client(scopes=desired_scopes) os.utime(cache_file, None) # Mock having another process update the cache. token2 = app.acquire_token_silent(scopes=desired_scopes, account=None) assert token1['access_token'] == token2['access_token'] finally: shutil.rmtree(test_folder, ignore_errors=True) microsoft-authentication-extensions-for-python-1.0.0/tests/test_persistence.py000066400000000000000000000072561420256257300301510ustar00rootroot00000000000000import os import sys import shutil import tempfile import logging import pytest from msal_extensions.persistence import * def _is_env_var_defined(env_var): return bool( # (WTF) What-The-Finding: # The bool(...) is necessary, otherwise skipif(...) would treat "true" as # string conditions and then raise an undefined "true" exception. # https://docs.pytest.org/en/latest/historical-notes.html#string-conditions os.getenv(env_var)) # Note: If you use tox, remember to pass them through via tox.ini # https://tox.wiki/en/latest/example/basic.html#passing-down-environment-variables is_running_on_travis_ci = _is_env_var_defined("TRAVIS") is_running_on_github_ci = _is_env_var_defined("GITHUB_ACTIONS") @pytest.fixture def temp_location(): test_folder = tempfile.mkdtemp(prefix="test_persistence_roundtrip") yield os.path.join(test_folder, 'persistence.bin') shutil.rmtree(test_folder, ignore_errors=True) def _test_persistence_roundtrip(persistence): payload = 'arbitrary content' persistence.save(payload) assert persistence.load() == payload def _test_nonexistent_persistence(persistence): with pytest.raises(PersistenceNotFound): persistence.load() with pytest.raises(PersistenceNotFound): persistence.time_last_modified() def test_file_persistence(temp_location): _test_persistence_roundtrip(FilePersistence(temp_location)) def test_nonexistent_file_persistence(temp_location): _test_nonexistent_persistence(FilePersistence(temp_location)) @pytest.mark.skipif( is_running_on_travis_ci or not sys.platform.startswith('win'), reason="Requires Windows Desktop") def test_file_persistence_with_data_protection(temp_location): try: _test_persistence_roundtrip(FilePersistenceWithDataProtection(temp_location)) except PersistenceDecryptionError: if is_running_on_github_ci or is_running_on_travis_ci: logging.warning("DPAPI tends to fail on Windows VM. Run this on your desktop to double check.") else: raise @pytest.mark.skipif( is_running_on_travis_ci or not sys.platform.startswith('win'), reason="Requires Windows Desktop") def test_nonexistent_file_persistence_with_data_protection(temp_location): _test_nonexistent_persistence(FilePersistenceWithDataProtection(temp_location)) @pytest.mark.skipif( not sys.platform.startswith('darwin'), reason="Requires OSX. Whether running on TRAVIS CI does not seem to matter.") def test_keychain_persistence(temp_location): _test_persistence_roundtrip(KeychainPersistence(temp_location)) @pytest.mark.skipif( not sys.platform.startswith('darwin'), reason="Requires OSX. Whether running on TRAVIS CI does not seem to matter.") def test_nonexistent_keychain_persistence(temp_location): random_service_name = random_account_name = str(id(temp_location)) _test_nonexistent_persistence( KeychainPersistence(temp_location, random_service_name, random_account_name)) @pytest.mark.skipif( is_running_on_travis_ci or not sys.platform.startswith('linux'), reason="Requires Linux Desktop. Headless or SSH session won't work.") def test_libsecret_persistence(temp_location): _test_persistence_roundtrip(LibsecretPersistence(temp_location)) @pytest.mark.skipif( is_running_on_travis_ci or not sys.platform.startswith('linux'), reason="Requires Linux Desktop. Headless or SSH session won't work.") def test_nonexistent_libsecret_persistence(temp_location): random_schema_name = random_value = str(id(temp_location)) _test_nonexistent_persistence(LibsecretPersistence( temp_location, random_schema_name, {"my_attr_1": random_value, "my_attr_2": random_value}, )) microsoft-authentication-extensions-for-python-1.0.0/tests/test_windows_backend.py000066400000000000000000000074711420256257300307650ustar00rootroot00000000000000import sys import os import errno import shutil import tempfile import pytest import uuid import msal if not sys.platform.startswith('win'): pytest.skip('skipping windows-only tests', allow_module_level=True) else: from msal_extensions.windows import WindowsDataProtectionAgent from msal_extensions.token_cache import PersistedTokenCache from msal_extensions.persistence import FilePersistenceWithDataProtection def test_dpapi_roundtrip_with_entropy(): subject_without_entropy = WindowsDataProtectionAgent() subject_with_entropy = WindowsDataProtectionAgent(entropy=uuid.uuid4().hex) test_cases = [ '', 'lorem ipsum', 'lorem-ipsum', '', uuid.uuid4().hex, ] try: for tc in test_cases: ciphered = subject_with_entropy.protect(tc) assert ciphered != tc got = subject_with_entropy.unprotect(ciphered) assert got == tc ciphered = subject_without_entropy.protect(tc) assert ciphered != tc got = subject_without_entropy.unprotect(ciphered) assert got == tc except OSError as exp: if exp.errno == errno.EIO and os.getenv('TRAVIS_REPO_SLUG'): pytest.skip('DPAPI tests are known to fail in TravisCI. This effort tracked by ' 'https://github.com/AzureAD/microsoft-authentication-extentions-for-python' '/issues/21') def test_read_msal_cache_direct(): """ This loads and unprotects an MSAL cache directly, only using the DataProtectionAgent. """ localappdata_location = os.getenv('LOCALAPPDATA', os.path.expanduser('~')) cache_locations = [ os.path.join(localappdata_location, '.IdentityService', 'msal.cache'), # this is where it's supposed to be os.path.join(localappdata_location, '.IdentityServices', 'msal.cache'), # There was a miscommunications about whether this was plural or not. os.path.join(localappdata_location, 'msal.cache'), # The earliest most naive builds used this locations. ] found = False for loc in cache_locations: try: with open(loc, mode='rb') as fh: contents = fh.read() found = True break except IOError as exp: if exp.errno != errno.ENOENT: raise exp if not found: pytest.skip('could not find the msal.cache file (try logging in using MSAL)') subject = WindowsDataProtectionAgent() raw = subject.unprotect(contents) assert raw != "" cache = msal.SerializableTokenCache() cache.deserialize(raw) access_tokens = cache.find(msal.TokenCache.CredentialType.ACCESS_TOKEN) assert len(access_tokens) > 0 def test_windows_token_cache_roundtrip(): client_id = os.getenv('AZURE_CLIENT_ID') client_secret = os.getenv('AZURE_CLIENT_SECRET') if not (client_id and client_secret): pytest.skip('no credentials present to test PersistedTokenCache round-trip with.') test_folder = tempfile.mkdtemp(prefix="msal_extension_test_windows_token_cache_roundtrip") cache_file = os.path.join(test_folder, 'msal.cache') try: subject = PersistedTokenCache(FilePersistenceWithDataProtection(cache_file)) app = msal.ConfidentialClientApplication( client_id=client_id, client_credential=client_secret, token_cache=subject) desired_scopes = ['https://graph.microsoft.com/.default'] token1 = app.acquire_token_for_client(scopes=desired_scopes) os.utime(cache_file, None) # Mock having another process update the cache. token2 = app.acquire_token_silent(scopes=desired_scopes, account=None) assert token1['access_token'] == token2['access_token'] finally: shutil.rmtree(test_folder, ignore_errors=True) microsoft-authentication-extensions-for-python-1.0.0/tox.ini000066400000000000000000000002141420256257300243500ustar00rootroot00000000000000[tox] envlist = py27,py35,py36,py37,py38 [testenv] deps = pytest passenv = TRAVIS GITHUB_ACTIONS commands = pytest