├── src ├── tests │ ├── __init__.py │ └── test_backend.py └── artifacts_keyring │ ├── support.py │ ├── __init__.py │ └── plugin.py ├── MANIFEST.in ├── .gitattributes ├── .gitignore ├── pipelines ├── pr.yml ├── ci.yml ├── validate-setup-bash.yml ├── validate-setup-ps.yml ├── validate-setup.yml └── build-wheels.yml ├── .github └── workflows │ └── stale.yml ├── pyproject.toml ├── LICENSE.txt ├── setup.cfg ├── SECURITY.md ├── README.md └── setup.py /src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | recursive-include src/artifacts_keyring/plugins * 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior (used when a rule below doesn't match) 2 | * text=auto 3 | 4 | *.sln -text 5 | *.ico -text 6 | *.bmp -text 7 | *.png -text 8 | *.snk -text 9 | *.mht -text 10 | *.pickle -text 11 | 12 | # Some Windows-specific files should always be CRLF 13 | *.bat eol=crlf 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | build/ 8 | dist/ 9 | downloads/ 10 | *.egg-info/ 11 | 12 | # Environments 13 | .env 14 | .venv 15 | env/ 16 | venv/ 17 | ENV/ 18 | 19 | # FCIB 20 | src/artifacts_keyring/plugins/ 21 | 22 | # Pytest 23 | .pytest_cache/ 24 | -------------------------------------------------------------------------------- /pipelines/pr.yml: -------------------------------------------------------------------------------- 1 | 2 | resources: 3 | - repo: self 4 | 5 | pool: 6 | vmImage: windows-latest 7 | 8 | trigger: 9 | - master 10 | 11 | stages: 12 | - stage: BuildWheels 13 | displayName: 'Build Platform-Specific Wheels' 14 | dependsOn: [] 15 | jobs: 16 | - template: build-wheels.yml 17 | parameters: 18 | publish: false # Enable this to publish wheels to the build artifacts for validation 19 | 20 | - stage: Validate 21 | displayName: 'Validate Setup Binary Selection' 22 | dependsOn: [] 23 | jobs: 24 | - template: validate-setup.yml 25 | -------------------------------------------------------------------------------- /pipelines/ci.yml: -------------------------------------------------------------------------------- 1 | schedules: 2 | - cron: "0 0 * * SAT" 3 | displayName: CodeQL Saturday midnight (UTC) scan 4 | always: true 5 | branches: 6 | include: 7 | - master 8 | 9 | resources: 10 | - repo: self 11 | 12 | stages: 13 | - stage: BuildWheels 14 | displayName: 'Build Platform-Specific Wheels' 15 | dependsOn: [] 16 | jobs: 17 | - template: build-wheels.yml 18 | parameters: 19 | publish: true 20 | 21 | - stage: Validate 22 | displayName: 'Validate Setup Binary Selection' 23 | dependsOn: [] 24 | jobs: 25 | - template: validate-setup.yml 26 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */12 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - uses: actions/stale@v3 14 | env: 15 | ACTIONS_STEP_DEBUG: true 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | stale-issue-message: "In order to consolidate to fewer feedback channels, we've moved suggestions and issue reporting to [Developer Community](https://developercommunity.visualstudio.com/spaces/21/index.html)." 19 | stale-issue-label: 'redirect-to-dev-community' 20 | days-before-stale: 1 21 | days-before-close: 1 22 | days-before-pr-close: -1 23 | operations-per-run: 30 24 | enable-statistics: true 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm>=6.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.cibuildwheel] 6 | # Build binaries for everything supported by cibuildwheel, Python >= 3.7 7 | # For local cibuildwheel debugging, you can build only a single version 8 | # build = ["cp312-*"] 9 | 10 | # For each platform below, don't build 32bit binaries, they aren't supported 11 | # by the .NET credential provider plugin 12 | # cibuildwheel 3 will disable building pypy automatically 13 | enable = "pypy" 14 | 15 | [tool.cibuildwheel.windows] 16 | archs = ["AMD64"] 17 | skip = ["*-win32"] 18 | 19 | [tool.cibuildwheel.macos] 20 | archs = ["x86_64", "arm64"] 21 | skip = ["*-macosx_i386", "*-macosx_x86"] 22 | 23 | [tool.cibuildwheel.linux] 24 | archs = ["x86_64", "aarch64"] 25 | # musllinux will also be skipped as most distros are manylinux compatible 26 | skip = ["*-manylinux_i686", "*-manylinux2014_i686", "*-musllinux*"] 27 | manylinux-x86_64-image = "manylinux_2_28" 28 | manylinux-aarch64-image = "manylinux_2_28" 29 | -------------------------------------------------------------------------------- /src/artifacts_keyring/support.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for license information. 4 | # -------------------------------------------------------------------------------------------- 5 | 6 | """Helper imports for the Azure DevOps Keyring module. 7 | """ 8 | 9 | # ********************************************************* 10 | # Import the correct urlsplit function 11 | 12 | try: 13 | from urllib.parse import urlsplit 14 | except ImportError: 15 | from urlparse import urlsplit 16 | 17 | 18 | # ********************************************************* 19 | # Import (and possibly update) subprocess.Popen 20 | 21 | from subprocess import Popen 22 | 23 | if not hasattr(Popen, "__enter__"): 24 | # Handle Python 2.x not making Popen a context manager 25 | class Popen(Popen): 26 | def __enter__(self): 27 | return self 28 | 29 | def __exit__(self, ex_type, ex_value, ex_tb): 30 | pass 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Microsoft Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | 4 | [metadata] 5 | name = artifacts-keyring 6 | version = 1.0.0 7 | author = Microsoft Corporation 8 | url = https://github.com/Microsoft/artifacts-keyring 9 | license = "MIT" 10 | license_file = LICENSE.txt 11 | description = "Automatically retrieve credentials for Azure Artifacts." 12 | long_description = file:README.md 13 | long_description_content_type = text/markdown 14 | classifiers = 15 | Development Status :: 3 - Alpha 16 | Intended Audience :: Developers 17 | Programming Language :: Python :: 3.9 18 | Programming Language :: Python :: 3.10 19 | Programming Language :: Python :: 3.11 20 | Programming Language :: Python :: 3.12 21 | Programming Language :: Python :: 3.13 22 | Operating System :: Microsoft :: Windows 23 | Operating System :: POSIX :: Linux 24 | Operating System :: MacOS :: MacOS X 25 | 26 | [options] 27 | package_dir= 28 | =src 29 | packages = find_namespace: 30 | include_package_data = True 31 | zip_safe = False 32 | python_requires = >=3.9 33 | install_requires = 34 | keyring >= 22.0 35 | requests >= 2.20.0 36 | 37 | [options.package_data] 38 | artifacts_keyring = 39 | plugins/**/* 40 | 41 | [options.packages.find] 42 | where=src 43 | 44 | [options.entry_points] 45 | keyring.backends = 46 | ArtifactsKeyringBackend = artifacts_keyring:ArtifactsKeyringBackend 47 | 48 | [tool:pytest] 49 | testpaths = src/tests 50 | python_functions = test_* 51 | python_files = test_*.py 52 | -------------------------------------------------------------------------------- /pipelines/validate-setup-bash.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: repo 3 | type: string 4 | - name: scriptEnvVariables 5 | type: string 6 | - name: expectedCredentialProviderVersion 7 | type: string 8 | - name: validateKeyringBinary 9 | type: boolean 10 | default: true 11 | 12 | steps: 13 | - checkout: ${{ parameters.repo }} 14 | - task: PipAuthenticate@1 15 | displayName: 'Pip authenticate' 16 | inputs: 17 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 18 | - script: pip install build 19 | displayName: 'Install dependencies' 20 | - bash: | 21 | ${{ parameters.scriptEnvVariables }} 22 | python -m build --outdir $(Build.ArtifactStagingDirectory)/Keyring >> ./output.log 23 | 24 | echo "Output log:" 25 | echo "------------------" 26 | cat ./output.log 27 | echo "------------------" 28 | 29 | if ! grep ${{ parameters.expectedCredentialProviderVersion }} ./output.log; then 30 | echo "Expected credential provider not found" 31 | exit 1 32 | fi 33 | 34 | echo "Checking for Credential Provider installation..." 35 | 36 | if [ ! -d "./src/artifacts_keyring/plugins/plugins/netcore/CredentialProvider.Microsoft" ]; then 37 | echo "Credential provider plugin directory not found" 38 | exit 1 39 | fi 40 | 41 | echo "Credential provider installed successfully" 42 | workingDirectory: $(Build.SourcesDirectory) 43 | displayName: Validate Install Script 44 | - script: | 45 | mkdir KeyringValidation 46 | whl_file=$(ls Keyring/*.whl | head -n 1) 47 | echo "Installing Keyring from $whl_file" 48 | pip install $whl_file --force-reinstall 49 | echo "Installed Keyring from $whl_file" 50 | workingDirectory: '$(Build.ArtifactStagingDirectory)' 51 | displayName: Validate Keyring WHL 52 | condition: and(succeeded(), eq('${{ parameters.validateKeyringBinary }}', true)) -------------------------------------------------------------------------------- /pipelines/validate-setup-ps.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: repo 3 | type: string 4 | - name: scriptEnvVariables 5 | type: string 6 | - name: expectedCredentialProviderVersion 7 | type: string 8 | - name: validateKeyringBinary 9 | type: boolean 10 | default: true 11 | 12 | steps: 13 | - checkout: ${{ parameters.repo }} 14 | - task: PipAuthenticate@1 15 | displayName: 'Pip authenticate' 16 | inputs: 17 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 18 | - script: pip install build 19 | displayName: 'Install dependencies' 20 | - task: PowerShell@2 21 | displayName: Set Environment Variables 22 | inputs: 23 | targetType: 'inline' 24 | script: | 25 | ${{ parameters.scriptEnvVariables }} 26 | workingDirectory: $(Build.SourcesDirectory) 27 | - task: PowerShell@2 28 | displayName: Validate Setup Script 29 | inputs: 30 | targetType: 'inline' 31 | script: | 32 | python -m build --outdir $(Build.ArtifactStagingDirectory)/Keyring >> ./output.log 33 | 34 | Write-Host "Output log:" 35 | Write-Host "------------------" 36 | cat ./output.log 37 | Write-Host "------------------" 38 | 39 | if( (Select-String -Path ./output.log -Pattern ${{ parameters.expectedCredentialProviderVersion }}) -eq $null) {echo "Expected credential provider file not found."; exit 1} 40 | workingDirectory: $(Build.SourcesDirectory) 41 | - task: PowerShell@2 42 | displayName: Validate Keyring WHL 43 | inputs: 44 | targetType: 'inline' 45 | script: | 46 | mkdir KeyringValidation 47 | Write-Host "##vso[task.setvariable variable=ARTIFACTS_KEYRING_NONINTERACTIVE_MODE;]true" 48 | $whlFile = Get-ChildItem -Path Keyring/*.whl | Select-Object -First 1 49 | Write-Host "Installing Keyring from $($whlFile.FullName)" 50 | pip install $($whlFile.FullName) --force-reinstall 51 | Write-Host "Installed Keyring from $($whlFile.FullName)" 52 | workingDirectory: $(Build.ArtifactStagingDirectory) 53 | condition: and(succeeded(), eq('${{ parameters.validateKeyringBinary }}', true)) -------------------------------------------------------------------------------- /src/artifacts_keyring/__init__.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for license information. 4 | # -------------------------------------------------------------------------------------------- 5 | 6 | from __future__ import absolute_import 7 | 8 | __author__ = "Microsoft Corporation " 9 | __version__ = "1.0.0" 10 | 11 | import warnings 12 | from .support import urlsplit 13 | from .plugin import CredentialProvider 14 | 15 | import keyring.backend 16 | import keyring.credentials 17 | 18 | 19 | class ArtifactsKeyringBackend(keyring.backend.KeyringBackend): 20 | SUPPORTED_NETLOC = ( 21 | "pkgs.dev.azure.com", 22 | "pkgs.visualstudio.com", 23 | "pkgs.codedev.ms", 24 | "pkgs.vsts.me" 25 | ) 26 | _PROVIDER = CredentialProvider 27 | 28 | priority = 9.9 29 | 30 | 31 | def __init__(self): 32 | # In-memory cache of user-pass combination, to allow 33 | # fast handling of applications that insist on querying 34 | # username and password separately. get_password will 35 | # pop from this cache to avoid keeping the value 36 | # around for longer than necessary. 37 | self._cache = {} 38 | 39 | 40 | def get_credential(self, service, username): 41 | try: 42 | parsed = urlsplit(service) 43 | except Exception as exc: 44 | warnings.warn(str(exc)) 45 | return None 46 | 47 | netloc = parsed.netloc.rpartition("@")[-1] 48 | 49 | if netloc is None or not netloc.endswith(self.SUPPORTED_NETLOC): 50 | return None 51 | 52 | provider = self._PROVIDER() 53 | 54 | username, password = provider.get_credentials(service) 55 | 56 | if username and password: 57 | self._cache[service, username] = password 58 | return keyring.credentials.SimpleCredential(username, password) 59 | 60 | 61 | def get_password(self, service, username): 62 | password = self._cache.get((service, username), None) 63 | if password is not None: 64 | return password 65 | 66 | creds = self.get_credential(service, None) 67 | if creds and username == creds.username: 68 | return creds.password 69 | 70 | return None 71 | 72 | 73 | def set_password(self, service, username, password): 74 | # Defer setting a password to the next backend 75 | raise NotImplementedError() 76 | 77 | 78 | def delete_password(self, service, username): 79 | # Defer deleting a password to the next backend 80 | raise NotImplementedError() 81 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /pipelines/validate-setup.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - job: WindowsInstallDefault 3 | pool: 4 | vmImage: windows-latest 5 | steps: 6 | - template: validate-setup-ps.yml@self 7 | parameters: 8 | repo: self 9 | scriptEnvVariables: '' 10 | expectedCredentialProviderVersion: 'Microsoft.Net8.win-x64.NuGet.CredentialProvider' 11 | - job: WindowsInstallNet8Fallback 12 | pool: 13 | vmImage: windows-latest 14 | steps: 15 | - template: validate-setup-ps.yml@self 16 | parameters: 17 | repo: self 18 | scriptEnvVariables: | 19 | Write-Host "##vso[task.setvariable variable=ARTIFACTS_CREDENTIAL_PROVIDER_NON_SC;]true" 20 | expectedCredentialProviderVersion: 'Microsoft.Net8.NuGet.CredentialProvider' 21 | - job: WindowsInstallLinux64 22 | pool: 23 | vmImage: windows-latest 24 | steps: 25 | - template: validate-setup-ps.yml@self 26 | parameters: 27 | repo: self 28 | scriptEnvVariables: | 29 | Write-Host "##vso[task.setvariable variable=ARTIFACTS_CREDENTIAL_PROVIDER_RID;]linux-x64" 30 | expectedCredentialProviderVersion: 'Microsoft.Net8.linux-x64.NuGet.CredentialProvider' 31 | validateKeyringBinary: false 32 | - job: LinuxInstalllNet8Fallback 33 | pool: 34 | vmImage: ubuntu-latest 35 | steps: 36 | - template: validate-setup-bash.yml@self 37 | parameters: 38 | repo: self 39 | scriptEnvVariables: | 40 | export ARTIFACTS_CREDENTIAL_PROVIDER_NON_SC=true 41 | expectedCredentialProviderVersion: 'Microsoft.Net8.NuGet.CredentialProvider' 42 | - job: LinuxInstallNet8 43 | pool: 44 | vmImage: ubuntu-latest 45 | steps: 46 | - template: validate-setup-bash.yml@self 47 | parameters: 48 | repo: self 49 | scriptEnvVariables: '' 50 | expectedCredentialProviderVersion: 'Microsoft.Net8.linux-x64.NuGet.CredentialProvider' 51 | - job: LinuxInstallNet8linuxx64 52 | pool: 53 | vmImage: ubuntu-latest 54 | steps: 55 | - template: validate-setup-bash.yml@self 56 | parameters: 57 | repo: self 58 | scriptEnvVariables: | 59 | export ARTIFACTS_CREDENTIAL_PROVIDER_RID=linux-x64 60 | expectedCredentialProviderVersion: 'Microsoft.Net8.linux-x64.NuGet.CredentialProvider' 61 | - job: LinuxInstallNet8linuxarm64 62 | pool: 63 | vmImage: ubuntu-latest 64 | steps: 65 | - template: validate-setup-bash.yml@self 66 | parameters: 67 | repo: self 68 | scriptEnvVariables: | 69 | export ARTIFACTS_CREDENTIAL_PROVIDER_RID=linux-arm64 70 | expectedCredentialProviderVersion: 'Microsoft.Net8.linux-arm64.NuGet.CredentialProvider' 71 | validateKeyringBinary: false 72 | - job: LinuxInstallNet8osxarm64 73 | pool: 74 | vmImage: ubuntu-latest 75 | steps: 76 | - template: validate-setup-bash.yml@self 77 | parameters: 78 | repo: self 79 | scriptEnvVariables: | 80 | export ARTIFACTS_CREDENTIAL_PROVIDER_RID=osx-arm64 81 | expectedCredentialProviderVersion: 'Microsoft.Net8.osx-arm64.NuGet.CredentialProvider' 82 | validateKeyringBinary: false 83 | - job: LinuxInstallNet8osxx64 84 | pool: 85 | vmImage: ubuntu-latest 86 | steps: 87 | - template: validate-setup-bash.yml@self 88 | parameters: 89 | repo: self 90 | scriptEnvVariables: | 91 | export ARTIFACTS_CREDENTIAL_PROVIDER_RID=osx-x64 92 | expectedCredentialProviderVersion: 'Microsoft.Net8.osx-x64.NuGet.CredentialProvider' 93 | validateKeyringBinary: false 94 | - job: MacOSNet8Install 95 | pool: 96 | vmImage: macos-latest 97 | steps: 98 | - template: validate-setup-ps.yml@self 99 | parameters: 100 | repo: self 101 | scriptEnvVariables: '' 102 | expectedCredentialProviderVersion: 'Microsoft.Net8.osx-x64.NuGet.CredentialProvider' 103 | - job: MacOSArm64Net8Install 104 | pool: 105 | vmImage: macos-latest 106 | steps: 107 | - template: validate-setup-ps.yml@self 108 | parameters: 109 | repo: self 110 | scriptEnvVariables: | 111 | Write-Host "##vso[task.setvariable variable=ARTIFACTS_CREDENTIAL_PROVIDER_RID;]osx-arm64" 112 | expectedCredentialProviderVersion: 'Microsoft.Net8.osx-arm64.NuGet.CredentialProvider' 113 | validateKeyringBinary: false 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## NOTE 2 | 'artifacts-keyring' is a relatively thin wrapper around [artifacts-credprovider](https://github.com/microsoft/artifacts-credprovider). Make sure to also look at that repository for more information about different scenarios. For example: 3 | 4 | * [Environment variable to explicitly override tokens](https://github.com/microsoft/artifacts-credprovider) 5 | * [Safely using credentials in docker](https://github.com/dotnet/dotnet-docker/blob/master/documentation/scenarios/nuget-credentials.md#using-the-azure-artifact-credential-provider) 6 | 7 | # artifacts-keyring 8 | 9 | The `artifacts-keyring` package provides authentication for publishing or consuming Python packages to or from Azure Artifacts feeds within [Azure DevOps](https://azure.com/devops). 10 | 11 | This package is an extension to [keyring](https://pypi.org/project/keyring), which will automatically find and use it once installed. 12 | 13 | Both [pip](https://pypi.org/project/pip) and [twine](https://pypi.org/project/twine) will use `keyring` to 14 | find credentials. 15 | 16 | ## Installation 17 | 18 | To install this package, run the following `pip` command: 19 | 20 | ``` 21 | pip install artifacts-keyring 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Requirements 27 | 28 | To use `artifacts-keyring` to set up authentication between `pip`/`twine` and Azure 29 | Artifacts, the following requirements must be met: 30 | 31 | * pip version **19.2** or higher 32 | * twine version **1.13.0** or higher 33 | * python version **3.9** or higher 34 | 35 | ``` 36 | If no matching platform specific .whl is found when running pip install and the sdist is 37 | fetched instead, the .NET runtime 8.0.X or later is required. Refer to [here](https:// 38 | learn.microsoft.com/dotnet/core/install/) for installation guideline. 39 | ``` 40 | 41 | ### Publishing packages to an Azure Artifacts feed 42 | Once `artifacts-keyring` is installed, to publish a package, use the following `twine` 43 | command, replacing **** and **** with your own: 44 | 45 | ``` 46 | twine upload --repository-url https://pkgs.dev.azure.com//_packaging//pypi/upload 47 | ``` 48 | 49 | ### Installing packages from an Azure Artifacts feed 50 | Once `artifacts-keyring` is installed, to consume a package, use the following `pip` command, replacing 51 | **** and **** with your own, and **** with the package you want to install: 52 | 53 | ``` 54 | pip install --index-url https://pkgs.dev.azure.com//_packaging//pypi/simple 55 | ``` 56 | 57 | ## Advanced configuration 58 | The `artifacts-keyring` package is layered on top of our [Azure Artifacts Credential Provider](https://github.com/microsoft/artifacts-credprovider). 59 | Check out that link to the GitHub repo for more information on configuration options. 60 | 61 | ### Environment variables 62 | 63 | - `ARTIFACTS_KEYRING_NONINTERACTIVE_MODE`: Controls whether the underlying credential provider can issue 64 | interactive prompts. 65 | 66 | ### Build Environment Variables 67 | 68 | - `ARTIFACTS_CREDENTIAL_PROVIDER_RID`: Controls whether or not to build with a specific runtime of the 69 | self-contained .NET version of the Azure Artifacts Credential Provider. 70 | - `ARTIFACTS_CREDENTIAL_PROVIDER_NON_SC`: Controls whether or not to build the non-self-contained 71 | .NET 8 version of keyring. 72 | 73 | ## Local development 74 | 75 | 1. Install build dependencies with `pip install .` 76 | 2. For local builds, build the project using `python -m build --outdir %DIRECTORY%` 77 | 3. You can also mimic the CI build using `cibuildwheel --platform auto --output-dir %DIRECTORY%` 78 | 4. Open a new terminal window in `%DIRECTORY%`, then run `pip install ***.whl --force-reinstall` 79 | 80 | ## Contributing 81 | 82 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 83 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 84 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 85 | 86 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 87 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 88 | provided by the bot. You will only need to do this once across all repos using our CLA. 89 | 90 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 91 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 92 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 93 | -------------------------------------------------------------------------------- /pipelines/build-wheels.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: publish 3 | type: boolean 4 | default: false 5 | 6 | jobs: 7 | - job: BuildWindows 8 | displayName: 'Build Windows Wheels' 9 | pool: 10 | vmImage: 'windows-latest' 11 | steps: 12 | - task: UsePythonVersion@0 13 | inputs: 14 | versionSpec: '3.x' 15 | addToPath: true 16 | 17 | - task: PipAuthenticate@1 18 | displayName: 'Pip Authenticate' 19 | inputs: 20 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 21 | 22 | - script: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools 25 | pip install wheel 26 | pip install cibuildwheel 27 | displayName: 'Install build dependencies' 28 | 29 | - script: | 30 | cibuildwheel --output-dir wheel-win-x64 31 | displayName: 'Build win-x64 wheels' 32 | env: 33 | CIBW_ARCHS_WINDOWS: "AMD64" 34 | CIBW_ENVIRONMENT: "ARTIFACTS_CREDENTIAL_PROVIDER_RID=win-x64" 35 | 36 | - ${{ if eq(parameters.publish, true) }}: 37 | - task: PublishBuildArtifacts@1 38 | displayName: 'Publish win-x64 wheels' 39 | inputs: 40 | pathToPublish: 'wheel-win-x64' 41 | artifactName: 'wheels-win-x64' 42 | 43 | - job: BuildOSX 44 | displayName: 'Build macOS Wheels' 45 | pool: 46 | vmImage: 'macOS-latest' 47 | steps: 48 | - task: UsePythonVersion@0 49 | inputs: 50 | versionSpec: '3.x' 51 | addToPath: true 52 | 53 | - task: PipAuthenticate@1 54 | displayName: 'Pip Authenticate' 55 | inputs: 56 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 57 | 58 | - script: | 59 | python -m pip install --upgrade pip 60 | pip install setuptools 61 | pip install wheel 62 | pip install cibuildwheel 63 | displayName: 'Install build dependencies' 64 | 65 | - script: | 66 | cibuildwheel --output-dir wheel-osx-x64 67 | displayName: 'Build osx-x64 wheels' 68 | env: 69 | CIBW_ARCHS_MACOS: "x86_64" 70 | CIBW_ENVIRONMENT: "ARTIFACTS_CREDENTIAL_PROVIDER_RID=osx-x64" 71 | MACOSX_DEPLOYMENT_TARGET: "10.15" 72 | 73 | - script: | 74 | cibuildwheel --output-dir wheel-osx-arm64 75 | displayName: 'Build osx-arm64 wheels' 76 | env: 77 | CIBW_ARCHS_MACOS: "arm64" 78 | CIBW_ENVIRONMENT: "ARTIFACTS_CREDENTIAL_PROVIDER_RID=osx-arm64" 79 | MACOSX_DEPLOYMENT_TARGET: "10.15" 80 | 81 | - ${{ if eq(parameters.publish, true) }}: 82 | - task: PublishBuildArtifacts@1 83 | displayName: 'Publish osx-x64 wheels' 84 | inputs: 85 | pathToPublish: 'wheel-osx-x64' 86 | artifactName: 'wheels-osx-x64' 87 | 88 | - task: PublishBuildArtifacts@1 89 | displayName: 'Publish osx-arm64 wheels' 90 | inputs: 91 | pathToPublish: 'wheel-osx-arm64' 92 | artifactName: 'wheels-osx-arm64' 93 | 94 | - job: BuildLinux64 95 | displayName: 'Build Linux x64 Wheels' 96 | pool: 97 | vmImage: 'ubuntu-latest' 98 | steps: 99 | - script: | 100 | sudo apt-get update -y 101 | sudo apt-get install -y python3-full 102 | displayName: 'Install build tools' 103 | 104 | - task: PipAuthenticate@1 105 | displayName: 'Pip Authenticate' 106 | inputs: 107 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 108 | 109 | - script: | 110 | python3 -m pip install --upgrade pip 111 | pip install setuptools 112 | pip install wheel 113 | pip install cibuildwheel 114 | displayName: 'Install build dependencies' 115 | 116 | - script: | 117 | cibuildwheel --output-dir wheel-linux-x64 118 | displayName: 'Build linux-x64 wheels' 119 | env: 120 | CIBW_ARCHS_LINUX: "x86_64" 121 | CIBW_ENVIRONMENT: "ARTIFACTS_CREDENTIAL_PROVIDER_RID=linux-x64" 122 | 123 | - ${{ if eq(parameters.publish, true) }}: 124 | - task: PublishBuildArtifacts@1 125 | displayName: 'Publish linux-x64 wheels' 126 | inputs: 127 | pathToPublish: 'wheel-linux-x64' 128 | artifactName: 'wheels-linux-x64' 129 | 130 | - job: BuildSDist 131 | displayName: 'Build Source Distribution' 132 | pool: 133 | vmImage: 'ubuntu-latest' 134 | steps: 135 | - task: UsePythonVersion@0 136 | inputs: 137 | versionSpec: '3.x' 138 | addToPath: true 139 | 140 | - task: PipAuthenticate@1 141 | displayName: 'Pip Authenticate' 142 | inputs: 143 | artifactFeeds: 'Azure Artifacts/Azure-Artifacts' 144 | 145 | - script: | 146 | python -m pip install --upgrade pip build 147 | pip install setuptools 148 | pip install wheel 149 | pip install cibuildwheel 150 | displayName: 'Install build tools' 151 | 152 | - script: | 153 | python -m build --sdist 154 | displayName: 'Build source distribution' 155 | env: 156 | ARTIFACTS_CREDENTIAL_PROVIDER_NON_SC: "true" 157 | 158 | - ${{ if eq(parameters.publish, true) }}: 159 | - task: PublishBuildArtifacts@1 160 | inputs: 161 | pathToPublish: 'dist' 162 | artifactName: 'sdist' -------------------------------------------------------------------------------- /src/tests/test_backend.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for license information. 4 | # -------------------------------------------------------------------------------------------- 5 | 6 | import keyring 7 | import keyring.backend 8 | import keyring.backends.chainer 9 | import keyring.errors 10 | import requests 11 | 12 | from artifacts_keyring import ArtifactsKeyringBackend, CredentialProvider 13 | 14 | import pytest 15 | 16 | # Shouldn't be accessed by tests, but needs to be able 17 | # to get past the quick check. 18 | SUPPORTED_HOST = "https://pkgs.dev.azure.com/" 19 | 20 | 21 | class FakeProvider(object): 22 | def get_credentials(self, service): 23 | return "user" + service[-4:], "pass" + service[-4:] 24 | 25 | 26 | class PasswordsBackend(keyring.backend.KeyringBackend): 27 | priority = 9.9 28 | 29 | def __init__(self): 30 | self.passwords = {} 31 | 32 | def get_password(self, system, username): 33 | return self.passwords.get((system, username)) 34 | 35 | def set_password(self, system, username, password): 36 | self.passwords[system, username] = password 37 | 38 | def delete_password(self, system, username): 39 | try: 40 | del self.passwords[system, username] 41 | except LookupError: 42 | raise keyring.errors.PasswordDeleteError(username) 43 | 44 | 45 | class MockGetResponse: 46 | status_code = 200 47 | 48 | 49 | @pytest.fixture 50 | def only_backend(): 51 | previous = keyring.get_keyring() 52 | backend = ArtifactsKeyringBackend() 53 | keyring.set_keyring(backend) 54 | yield backend 55 | keyring.set_keyring(previous) 56 | 57 | 58 | @pytest.fixture 59 | def passwords(monkeypatch): 60 | passwords_backend = PasswordsBackend() 61 | 62 | def mock_get_all_keyring(): 63 | return [ArtifactsKeyringBackend(), passwords_backend] 64 | 65 | monkeypatch.setattr(keyring.backend, "get_all_keyring", mock_get_all_keyring) 66 | 67 | chainer_backend = keyring.backends.chainer.ChainerBackend() 68 | 69 | previous = keyring.get_keyring() 70 | keyring.set_keyring(chainer_backend) 71 | yield passwords_backend.passwords 72 | keyring.set_keyring(previous) 73 | 74 | 75 | @pytest.fixture 76 | def fake_provider(monkeypatch): 77 | monkeypatch.setattr(ArtifactsKeyringBackend, "_PROVIDER", FakeProvider) 78 | 79 | 80 | @pytest.fixture 81 | def validating_provider(monkeypatch): 82 | def mock_get_credentials(self, url, is_retry): 83 | return url, is_retry 84 | 85 | def mock_requests_get(url, auth): 86 | response = MockGetResponse() 87 | response.status_code = int(url[:3]) 88 | return response 89 | 90 | monkeypatch.setattr(CredentialProvider, "_get_credentials_from_credential_provider", mock_get_credentials) 91 | monkeypatch.setattr(requests, "get", mock_requests_get) 92 | 93 | yield CredentialProvider() 94 | 95 | 96 | def test_get_credential_unsupported_host(only_backend): 97 | assert keyring.get_credential("https://example.com", None) == None 98 | 99 | 100 | def test_get_credential(only_backend, fake_provider): 101 | creds = keyring.get_credential(SUPPORTED_HOST + "1234", None) 102 | assert creds.username == "user1234" 103 | assert creds.password == "pass1234" 104 | 105 | 106 | def test_set_password_raises(only_backend): 107 | with pytest.raises(NotImplementedError): 108 | keyring.set_password("SYSTEM", "USERNAME", "PASSWORD") 109 | 110 | 111 | def test_set_password_fallback(passwords, fake_provider): 112 | # Ensure we are getting good credentials 113 | assert keyring.get_credential(SUPPORTED_HOST + "1234", None).password == "pass1234" 114 | 115 | assert keyring.get_password("SYSTEM", "USERNAME") is None 116 | keyring.set_password("SYSTEM", "USERNAME", "PASSWORD") 117 | assert passwords["SYSTEM", "USERNAME"] == "PASSWORD" 118 | assert keyring.get_password("SYSTEM", "USERNAME") == "PASSWORD" 119 | assert keyring.get_credential("SYSTEM", "USERNAME").username == "USERNAME" 120 | assert keyring.get_credential("SYSTEM", "USERNAME").password == "PASSWORD" 121 | 122 | # Ensure we are getting good credentials 123 | assert keyring.get_credential(SUPPORTED_HOST + "1234", None).password == "pass1234" 124 | 125 | 126 | def test_delete_password_raises(only_backend): 127 | with pytest.raises(NotImplementedError): 128 | keyring.delete_password("SYSTEM", "USERNAME") 129 | 130 | 131 | def test_delete_password_fallback(passwords, fake_provider): 132 | # Ensure we are getting good credentials 133 | assert keyring.get_credential(SUPPORTED_HOST + "1234", None).password == "pass1234" 134 | 135 | passwords["SYSTEM", "USERNAME"] = "PASSWORD" 136 | keyring.delete_password("SYSTEM", "USERNAME") 137 | assert keyring.get_password("SYSTEM", "USERNAME") is None 138 | assert not passwords 139 | with pytest.raises(keyring.errors.PasswordDeleteError): 140 | keyring.delete_password("SYSTEM", "USERNAME") 141 | 142 | 143 | def test_cannot_delete_password(passwords, fake_provider): 144 | # Ensure we are getting good credentials 145 | creds = keyring.get_credential(SUPPORTED_HOST + "1234", None) 146 | assert creds.username == "user1234" 147 | assert creds.password == "pass1234" 148 | 149 | with pytest.raises(keyring.errors.PasswordDeleteError): 150 | keyring.delete_password(SUPPORTED_HOST + "1234", creds.username) 151 | 152 | 153 | def test_retry_on_invalid_credentials(validating_provider): 154 | # No credentials returned when it can already authenticate without them 155 | username, password = validating_provider.get_credentials("200" + SUPPORTED_HOST) 156 | assert username == None and password == None 157 | 158 | # Credentials returned from first call with IsRetry=false 159 | username, password = validating_provider.get_credentials("200" + SUPPORTED_HOST + "pypi/upload") 160 | assert password == False 161 | 162 | # Credentials returned from second call with IsRetry=true 163 | username, password = validating_provider.get_credentials("401" + SUPPORTED_HOST) 164 | assert password == True 165 | 166 | username, password = validating_provider.get_credentials("403" + SUPPORTED_HOST) 167 | assert password == True 168 | 169 | username, password = validating_provider.get_credentials("500" + SUPPORTED_HOST) 170 | assert password == True 171 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # -------------------------------------------------------------------------------------------- 4 | # Copyright (c) Microsoft Corporation. All rights reserved. 5 | # Licensed under the MIT License. See License.txt in the project root for license information. 6 | # -------------------------------------------------------------------------------------------- 7 | 8 | import io 9 | import os 10 | import platform 11 | import re 12 | import sys 13 | import zipfile 14 | import tarfile 15 | import urllib.request 16 | import shutil 17 | from setuptools import Distribution, setup 18 | from setuptools.command.build_py import build_py 19 | from setuptools.command.bdist_wheel import bdist_wheel 20 | 21 | CREDENTIAL_PROVIDER_BASE = "https://github.com/Microsoft/artifacts-credprovider/releases/download/v1.4.1/" 22 | CREDENTIAL_PROVIDER_NET8 = CREDENTIAL_PROVIDER_BASE + "Microsoft.Net8.NuGet.CredentialProvider.tar.gz" 23 | CREDENTIAL_PROVIDER_NET8_ZIP = CREDENTIAL_PROVIDER_BASE + "Microsoft.Net8.NuGet.CredentialProvider.zip" 24 | CREDENTIAL_PROVIDER_NON_SC_VAR_NAME = "ARTIFACTS_CREDENTIAL_PROVIDER_NON_SC" 25 | CREDENTIAL_PROVIDER_RID_VAR_NAME = "ARTIFACTS_CREDENTIAL_PROVIDER_RID" 26 | 27 | def get_version(root): 28 | src = os.path.join(root, "src", "artifacts_keyring", "__init__.py") 29 | 30 | with open(src, "r", encoding="utf-8", errors="strict") as f: 31 | txt = f.read() 32 | 33 | m = re.search(r"__version__\s*=\s*['\"](.+?)['\"]", txt) 34 | return m.group(1) if m else "0.1.0" 35 | 36 | def get_runtime_identifier(): 37 | os_system = platform.system().lower() 38 | os_arch = platform.machine().lower() 39 | 40 | if os_system == "linux": 41 | runtime_id = "linux" 42 | elif os_system == "darwin": 43 | runtime_id = "osx" 44 | elif os_system == "windows": 45 | runtime_id = "win" 46 | else: 47 | print(f"Warning: Unsupported OS: {os_system}. Please set the {CREDENTIAL_PROVIDER_RID_VAR_NAME} environment variable to specify a runtime identifier.") 48 | return "" 49 | 50 | if "aarch64" in os_arch or "arm64" in os_arch: 51 | if (os_system == "windows"): # windows on ARM runs x64 binaries 52 | runtime_id += "-x64" 53 | else: 54 | runtime_id += "-arm64" 55 | elif "x86_64" in os_arch or "amd64" in os_arch: 56 | runtime_id += "-x64" 57 | else: 58 | print(f"Warning: Unsupported architecture: {os_arch}. Please set the {CREDENTIAL_PROVIDER_RID_VAR_NAME} environment variable to specify a runtime identifier.") 59 | return "" 60 | 61 | return runtime_id 62 | 63 | def get_os_runtime_url(runtime_var): 64 | if runtime_var == "" and "osx" in runtime_var: 65 | return CREDENTIAL_PROVIDER_NET8_ZIP 66 | elif runtime_var == "": 67 | return CREDENTIAL_PROVIDER_NET8 68 | 69 | if "osx" in runtime_var: 70 | return CREDENTIAL_PROVIDER_NET8_ZIP.replace(".Net8", f".Net8.{runtime_var}") 71 | 72 | return CREDENTIAL_PROVIDER_NET8.replace(".Net8", f".Net8.{runtime_var}") 73 | 74 | def get_download_url(): 75 | # When building the platform wheels in CI, use the self-contained version of the credential provider. 76 | # In these cases, check ARTIFACTS_CREDENTIAL_PROVIDER_RID to determine the desired runtime identifier. 77 | if CREDENTIAL_PROVIDER_RID_VAR_NAME in os.environ and \ 78 | os.environ[CREDENTIAL_PROVIDER_RID_VAR_NAME]: 79 | runtime_var = str(os.environ[CREDENTIAL_PROVIDER_RID_VAR_NAME]).lower() 80 | return get_os_runtime_url(runtime_var) 81 | 82 | # Specify whether they want self-contained auto-detection or not. 83 | # Only applicable for non-CI environments. 84 | use_non_sc = CREDENTIAL_PROVIDER_NON_SC_VAR_NAME in os.environ and \ 85 | os.environ[CREDENTIAL_PROVIDER_NON_SC_VAR_NAME] and \ 86 | str(os.environ[CREDENTIAL_PROVIDER_NON_SC_VAR_NAME]).lower() == "true" 87 | 88 | if use_non_sc: 89 | return CREDENTIAL_PROVIDER_NET8 90 | else: 91 | runtime_id = str(get_runtime_identifier()) 92 | return get_os_runtime_url(runtime_id) 93 | 94 | 95 | def download_credential_provider(dest): 96 | if not os.path.isdir(dest): 97 | os.makedirs(dest) 98 | 99 | print("Downloading and extracting artifacts-credprovider to", dest) 100 | download_url = get_download_url() 101 | print("Downloading artifacts-credprovider from", download_url) 102 | 103 | with urllib.request.urlopen(download_url) as download_file: 104 | if download_url.endswith(".zip"): 105 | with zipfile.ZipFile(io.BytesIO(download_file.read())) as zip_file: 106 | zip_file.extractall(dest) 107 | else: 108 | tar = tarfile.open(mode="r|gz", fileobj=download_file) 109 | 110 | # Python 3.12 adds a safety filter for tar extraction 111 | # to prevent placement of files outside the target directory. 112 | # https://docs.python.org/3.12/library/tarfile.html#tarfile.tar_filter 113 | if sys.version_info >= (3, 12): 114 | tar.extractall(dest, filter="data") 115 | else: 116 | tar.extractall(dest) 117 | 118 | class BuildKeyring(build_py): 119 | def run(self): 120 | super().run() 121 | 122 | class BuildKeyringPlatformWheel(bdist_wheel): 123 | def finalize_options(self): 124 | super().finalize_options() 125 | self.root_is_pure = False 126 | 127 | class KeyringDistribution(Distribution): 128 | def has_ext_modules(self): 129 | return True 130 | 131 | if __name__ == "__main__": 132 | root = os.path.dirname(os.path.abspath(__file__)) 133 | dest = os.path.join(root, "src", "artifacts_keyring", "plugins") 134 | 135 | # Clean any previous build artifacts 136 | if os.path.exists(dest): 137 | print("Removing previous plugins artifacts in ", dest) 138 | shutil.rmtree(dest) 139 | 140 | download_credential_provider(dest) 141 | 142 | # Fix for liblttng-ust.so.0 not being found on Debian 12 and later. 143 | # See https://github.com/dotnet/runtime/issues/57784 for more info. 144 | clr_trace_path = os.path.join( 145 | dest, 146 | "plugins", 147 | "netcore", 148 | "CredentialProvider.Microsoft", 149 | "libcoreclrtraceptprovider.so", 150 | ) 151 | if os.path.exists(clr_trace_path): 152 | print("Removing libcoreclrtraceptprovider.so from plugins directory") 153 | os.remove(clr_trace_path) 154 | 155 | setup( 156 | version=get_version(root), 157 | cmdclass={ 158 | "build_py": BuildKeyring, 159 | "bdist_wheel": BuildKeyringPlatformWheel, 160 | }, 161 | distclass=KeyringDistribution, 162 | ) 163 | -------------------------------------------------------------------------------- /src/artifacts_keyring/plugin.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for license information. 4 | # -------------------------------------------------------------------------------------------- 5 | 6 | from __future__ import absolute_import 7 | 8 | import json 9 | import os 10 | import platform 11 | import requests 12 | import subprocess 13 | import sys 14 | 15 | from . import __version__ 16 | from .support import Popen 17 | 18 | 19 | class CredentialProvider(object): 20 | _NON_INTERACTIVE_VAR_NAME = "ARTIFACTS_KEYRING_NONINTERACTIVE_MODE" 21 | 22 | def __init__(self): 23 | if sys.platform.startswith("win"): 24 | tool_path = os.path.join( 25 | os.path.dirname(os.path.abspath(__file__)), 26 | "plugins", 27 | "plugins", 28 | "netcore", 29 | "CredentialProvider.Microsoft", 30 | "CredentialProvider.Microsoft.exe", 31 | ) 32 | 33 | self.exe = [tool_path] 34 | else: 35 | tool_path_root = os.path.join( 36 | os.path.dirname(os.path.abspath(__file__)), 37 | "plugins", 38 | "plugins", 39 | "netcore", 40 | "CredentialProvider.Microsoft" 41 | ) 42 | 43 | is_dotnet_runtime_required = False 44 | if os.path.exists(tool_path_root): 45 | # Ensure the plugins directory executable permissions are set so Python can execute 46 | # the Credential Provider plugin. 47 | try: 48 | tool_path = os.path.join(tool_path_root, 'CredentialProvider.Microsoft') 49 | if os.path.exists(tool_path): 50 | os.chmod(tool_path, 0o755) 51 | except Exception as e: 52 | raise RuntimeError( 53 | "Failed to set executable permissions for the Credential Provider plugins directory " 54 | + tool_path_root 55 | + ". Please ensure the directory has the correct access permissions (755). Error: " 56 | + str(e) 57 | ) 58 | 59 | # If tool_path_root contains the runtimes directory, it means that the 60 | # binary is not self-contained and requires a .NET install to run. 61 | tool_path_runtimes = os.path.join( 62 | tool_path_root, 63 | "runtimes" 64 | ) 65 | if os.path.exists(tool_path_runtimes): 66 | is_dotnet_runtime_required = True 67 | 68 | if is_dotnet_runtime_required: 69 | tool_path = os.path.join( 70 | tool_path_root, 71 | "CredentialProvider.Microsoft.dll" 72 | ) 73 | 74 | try: 75 | # check to see if any dotnet runtimes are installed. Not checking specific versions. 76 | output = subprocess.check_output(["dotnet", "--list-runtimes"]).decode().strip() 77 | if(len(output) == 0): 78 | raise Exception("No dotnet runtime found. Refer to https://learn.microsoft.com/dotnet/core/install/ for installation guidelines.") 79 | except Exception as e: 80 | message = ( 81 | "Unable to find dependency dotnet, please manually install" 82 | " the .NET runtime and ensure 'dotnet' is in your PATH. Error: " 83 | ) 84 | raise Exception(message + str(e)) 85 | 86 | self.exe = ["dotnet", "exec", tool_path] 87 | else: 88 | # for self-contained binaries, the executable is not the DLL 89 | if platform.system().lower() == "windows": 90 | tool_path = os.path.join( 91 | tool_path_root, 92 | "CredentialProvider.Microsoft.exe" 93 | ) 94 | # linux and macOS 95 | else: 96 | tool_path = os.path.join( 97 | tool_path_root, 98 | "CredentialProvider.Microsoft" 99 | ) 100 | 101 | self.exe = [tool_path] 102 | 103 | 104 | if not os.path.isfile(tool_path): 105 | raise RuntimeError("Unable to find credential provider in the expected path: " + tool_path) 106 | 107 | def get_credentials(self, url): 108 | # Public feed short circuit: return nothing if not getting credentials for the upload endpoint 109 | # (which always requires auth) and the endpoint is public (can authenticate without credentials). 110 | if not self._is_upload_endpoint(url) and self._can_authenticate(url, None): 111 | return None, None 112 | 113 | # Getting credentials with IsRetry=false; the credentials may come from the cache 114 | username, password = self._get_credentials_from_credential_provider(url, is_retry=False) 115 | 116 | # Do not attempt to validate if the credentials could not be obtained 117 | if username is None or password is None: 118 | return username, password 119 | 120 | # Make sure the credentials are still valid (i.e. not expired) 121 | if self._can_authenticate(url, (username, password)): 122 | return username, password 123 | 124 | # The cached credentials are expired; get fresh ones with IsRetry=true 125 | return self._get_credentials_from_credential_provider(url, is_retry=True) 126 | 127 | 128 | def _is_upload_endpoint(self, url): 129 | url = url[: -1] if url[-1] == "/" else url 130 | return url.endswith("pypi/upload") 131 | 132 | 133 | def _can_authenticate(self, url, auth): 134 | response = requests.get(url, auth=auth) 135 | 136 | return response.status_code < 500 and \ 137 | response.status_code != 401 and \ 138 | response.status_code != 403 139 | 140 | 141 | def _get_credentials_from_credential_provider(self, url, is_retry): 142 | non_interactive = self._NON_INTERACTIVE_VAR_NAME in os.environ and \ 143 | os.environ[self._NON_INTERACTIVE_VAR_NAME] and \ 144 | str(os.environ[self._NON_INTERACTIVE_VAR_NAME]).lower() == "true" 145 | 146 | proc = Popen( 147 | self.exe + [ 148 | "-Uri", url, 149 | "-IsRetry", str(is_retry), 150 | "-NonInteractive", str(non_interactive), 151 | "-CanShowDialog", "True", 152 | "-OutputFormat", "Json" 153 | ], 154 | stdin=subprocess.PIPE, 155 | stdout=subprocess.PIPE, 156 | stderr=subprocess.PIPE 157 | ) 158 | 159 | # Read all standard error first, which may either display 160 | # errors from the credential provider or instructions 161 | # from it for Device Flow authentication. 162 | for stderr_line in iter(proc.stderr.readline, b''): 163 | line = stderr_line.decode("utf-8", "ignore") 164 | sys.stderr.write(line) 165 | sys.stderr.flush() 166 | 167 | proc.wait() 168 | 169 | if proc.returncode != 0: 170 | stderr = proc.stderr.read().decode("utf-8", "ignore") 171 | 172 | error_msg = "Failed to get credentials: process with PID {pid} exited with code {code}".format( 173 | pid=proc.pid, code=proc.returncode 174 | ) 175 | if stderr.strip(): 176 | error_msg += "; additional error message: {error}".format(error=stderr) 177 | else: 178 | error_msg += "; no additional error message available, see Credential Provider logs above for details." 179 | raise RuntimeError(error_msg) 180 | 181 | try: 182 | # stdout is expected to be UTF-8 encoded JSON, so decoding errors are not ignored here. 183 | payload = proc.stdout.read().decode("utf-8") 184 | except ValueError: 185 | raise RuntimeError("Failed to get credentials: the Credential Provider's output could not be decoded using UTF-8.") 186 | 187 | try: 188 | parsed = json.loads(payload) 189 | return parsed["Username"], parsed["Password"] 190 | except ValueError: 191 | raise RuntimeError("Failed to get credentials: the Credential Provider's output could not be parsed as JSON.") 192 | --------------------------------------------------------------------------------