Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/identity/azure-identity/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
([#8945](https://github.com/Azure/azure-sdk-for-python/pull/8945))
- Async credentials are async context managers and have an async `close` method
([#9090](https://github.com/Azure/azure-sdk-for-python/pull/9090))
- `CertificateCredential` supports password-protected private keys
([#9434](https://github.com/Azure/azure-sdk-for-python/pull/9434))


## 1.1.0 (2019-11-27)
Expand Down
6 changes: 5 additions & 1 deletion sdk/identity/azure-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,14 @@ This example demonstrates authenticating the `SecretClient` from the
from azure.identity import CertificateCredential
from azure.keyvault.secrets import SecretClient

# requires a PEM-encoded certificate with private key, not protected with a password
# requires a PEM-encoded certificate with private key
cert_path = "/app/certs/certificate.pem"
credential = CertificateCredential(tenant_id, client_id, cert_path)

# if the private key is password protected, provide a 'password' keyword argument
credential = CertificateCredential(tenant_id, client_id, cert_path, password="cert-password")


client = SecretClient("https://my-vault.vault.azure.net", credential)
```

Expand Down
11 changes: 7 additions & 4 deletions sdk/identity/azure-identity/azure/identity/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

if TYPE_CHECKING:
# pylint:disable=unused-import
from typing import Any, Mapping
from typing import Any, Optional, Union


class ClientSecretCredentialBase(object):
Expand All @@ -50,16 +50,19 @@ def __init__(self, tenant_id, client_id, certificate_path, **kwargs): # pylint:
# type: (str, str, str, **Any) -> None
if not certificate_path:
raise ValueError(
"certificate_path must be the path to a PEM file containing an "
"x509 certificate and its private key, not protected with a password"
"'certificate_path' must be the path to a PEM file containing an x509 certificate and its private key"
)

super(CertificateCredentialBase, self).__init__()

password = kwargs.pop("password", None)
if isinstance(password, six.text_type):
password = password.encode(encoding="utf-8")

with open(certificate_path, "rb") as f:
pem_bytes = f.read()

private_key = serialization.load_pem_private_key(pem_bytes, password=None, backend=default_backend())
private_key = serialization.load_pem_private_key(pem_bytes, password=password, backend=default_backend())
cert = x509.load_pem_x509_certificate(pem_bytes, default_backend())
fingerprint = cert.fingerprint(hashes.SHA1())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ class CertificateCredential(CertificateCredentialBase):
:param str tenant_id: ID of the service principal's tenant. Also called its 'directory' ID.
:param str client_id: the service principal's client ID
:param str certificate_path: path to a PEM-encoded certificate file including the private key.
This file must not be password-protected.

:keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com',
the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities`
defines authorities for other clouds.
:keyword password: The certificate's password. If a unicode string, it will be encoded as UTF-8. If the certificate
requires a different encoding, pass appropriately encoded bytes instead.
:paramtype password: str or bytes
"""

def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ class CertificateCredential(CertificateCredentialBase, AsyncCredentialBase):
:param str tenant_id: ID of the service principal's tenant. Also called its 'directory' ID.
:param str client_id: the service principal's client ID
:param str certificate_path: path to a PEM-encoded certificate file including the private key
This file must not be password-protected.

:keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com',
the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities`
defines authorities for other clouds.
:keyword password: The certificate's password. If a unicode string, it will be encoded as UTF-8. If the certificate
requires a different encoding, pass appropriately encoded bytes instead.
:paramtype password: str or bytes
"""

async def __aenter__(self):
Expand Down
28 changes: 24 additions & 4 deletions sdk/identity/azure-identity/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,11 @@ def live_service_principal(): # pylint:disable=inconsistent-return-statements

@pytest.fixture()
def live_certificate(live_service_principal): # pylint:disable=inconsistent-return-statements,redefined-outer-name
"""Fixture for live tests needing a certificate.
Skips them when environment configuration is incomplete.
"""
"""Provides a path to a PEM-encoded certificate with no password"""

pem_content = os.environ.get("PEM_CONTENT")
if not pem_content:
pytest.skip("Environment has no value for 'PEM_CONTENT'")
pytest.skip("Expected PEM content in environment variable 'PEM_CONTENT'")
return

pem_path = os.path.join(os.path.dirname(__file__), "certificate.pem")
Expand All @@ -81,6 +79,28 @@ def live_certificate(live_service_principal): # pylint:disable=inconsistent-ret
pytest.skip("Failed to write file '{}': {}".format(pem_path, ex))


@pytest.fixture()
def live_certificate_with_password(live_service_principal):
"""Provides a path to a PEM-encoded, password-protected certificate, and its password"""

pem_content = os.environ.get("PEM_CONTENT_PASSWORD_PROTECTED")
password = os.environ.get("CERTIFICATE_PASSWORD")
if not (pem_content and password):
pytest.skip(
"Expected password-protected PEM content in environment variable 'PEM_CONTENT_PASSWORD_PROTECTED'"
+ " and the password in 'CERTIFICATE_PASSWORD'"
)
return

pem_path = os.path.join(os.path.dirname(__file__), "certificate-with-password.pem")
try:
with open(pem_path, "w") as pem_file:
pem_file.write(pem_content)
return dict(live_service_principal, cert_path=pem_path, password=password)
except IOError as ex:
pytest.skip("Failed to write file '{}': {}".format(pem_path, ex))


@pytest.fixture()
def live_user_details():
user_details = {
Expand Down
51 changes: 51 additions & 0 deletions sdk/identity/azure-identity/tests/certificate-with-password.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIpDOLr9sNuTwCAggA
MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECIWEFMkTyS60BIIEyPvMPGyf1shr
ql0UnZzMWDz/9bqdRIZe5N7F1qYjgZNms/QXVzZOQ2J9YwaLHbwpEv2QigfHXq/3
nLyTm4HFyg3qpCqWa33A0r/v5B6WtjgYbuPePqpM2UV34CMGylkmhMVUUbs1X6j7
ezwmipq/paOslokC0RYl16cQD/uLTD0usTDtWoEs3S4gbcGUj/b9Ll2urG9zBYwI
faSWQcwcRgfYk/OZSbv6zNT74dYMAOW3mjsS/ackc/+h2XWFQSVjN7BBXamfCQ3C
qE27Py85PP8Qt7MbHuoj8rMuoaQ3NIi++1RkW6cyFo5n+HBi7YYCP74loG1OCTN8
H5StIi2aLbls9ZbQJHLM2+J1tBwJqIR/UogITESV++17ZVHfDLk8uaad7i6Kj+u5
6vbruehnFqo5P80lZRuqHfGf/5v8Hbsve/zL24wdQ+tFDHaC6v+kiz9unnO/+k86
9gph/WTly4N4wJhdxhoYxJLMdPcWk6AxA7ZsJ/mI9+t8iHdSOZY91FyN3sDlDB4C
yLi8t1WP+VB9KfMjSN0AuULWrwwQ1YGRUsKaS9pxTy8MbXQ1OgXGGHzHKDm6vqyp
Jow9wD8Ql+V7zPsNgBpeRWXzA5VS6nEyIuOolkJnNoC5d69/LtDaBn58TZQ4z1Ja
wGXG6n9BeFrgwgH5X5kGslLDXZ71V/aT0HHoBbiAPWf90teccGJ5nVXv3kMaC/zc
klzNCrQ2koFphQgW1bU/FZ46yd2rvlFo8wbxAwRieldZpkwRcFVKz+cRh4/QsTpl
uPKtPpI0c2jgiSReNXi2kRlkOPg1UVHnapvv2yRUoq4NvzaOegSVJG1oe/XdLS02
5JoDNajEcIvHQpLZL1ecQSwpge843mW4F5twm8/1MKY8G2CTXjKif77n3WVR2Tvp
RdOm86TbIB4FpbLAqiN11A/8cLNfVmioQkNdULLELfKiOeQZABSMPJMVGVxgODN0
nP31BMibPayqApLLEFIQbSLhZvWIJ5ircZ5XKPHeuqpnxFoeIwrGQuqHo6gvSp5J
CPv4Pul43y0s3vxRpJAqmXO4aAzsPsrYGJiNckbD43OxRV9ZDDeD1Wrc4u82zQxo
frSy5XhVPsKH1lFZ7l6te4Tkro4vMxRVu+W2acToJI6QZct0xrlp+ScmD/9CEOGU
Wj5SN4Y8YW+UfeYhDAhntzruRx4HjdocbvwYsY9gAkim4P9AdiG6eeRUyU9GCUwd
MrjMt5HUzsTQiIyN9jnv9yWNdYmzgJ2V0ZOwVHaEZhZnkgYoK0O/NXSg0FsPo5LU
qdYncK+BMAGnEl/riaJRmnsIH29jKPjZDOvfo1+0UJM3+zPjYy4985+CH7xWGWnb
NQFBtwiPyWzlDyV3119T4rY+Ad6z90vG4hgvpvue1Qcuaure8NwkUEEh1/d8PN20
kP4vWhpDeHbO5R5byXlJMNzmgVm3mc2t6mA/ouUcMmUOTvjdYALXqgw9RgOsqkob
DNjEK4VZUu3Vd6AsK+s796KTLgQvZhrcahoer/88j7Nu0PyQGrVN202IfmbjIwer
NNcieLmok6r2k8GvyUYP51hpdkXO5j3BsrtBeq4xn3qxzOtEUL8ITZ/BU20+xJq6
GoGDjvCSBpzesnQFlvUtEw==
-----END ENCRYPTED PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDbTCCAlWgAwIBAgIUHBH8mppwjLI2dFOQ7haLnd6iRjQwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMDAxMDMxODE2MzlaGA8yMTk5
MDYwODE4MTYzOVowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBANMXamdgXR+0B2b6zt2nURcYcwC0YrqjvTH/ofF3
MjUzZ1uKziPNxxAYrUY0O0zIcZWo9Aqfi10vS5oNya/aDrKoWxVRCsLltAV9dbLJ
65zF7wbVE7ZnZ7Nknop+ytd1t1VNTlpbxgWdT6z/WTn4ydqH7Hlh0Ucu2Q3QGQL3
G9He0kOMog4Y0myxP2xNGjLoig2kh60KEwtxbudOxVN4rLpqhT/1n/L5s+7rznKc
cB4MRqPJMdycIYhTD2mfp/E9hDWRcVJY+9GlqzyxXFTsDsO1SzGgpMEjdO5mtc6N
A0dd8fZQLt1BHLFJlpsuk5Fk40y7HtT3kYKUcD55Xd0pd6ECAwEAAaNTMFEwHQYD
VR0OBBYEFKG65qd+cChhFLB8y4po+vL3HwxuMB8GA1UdIwQYMBaAFKG65qd+cChh
FLB8y4po+vL3HwxuMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB
AAlEqWO8tGKprQrqfdZDsMyKyne+WYYA7RqRxYzKJ5j1zsPa4f7RKhLYzx6TqWK3
keBc6aw9aeNYNqTBeDLGHRHRRwdHwwU5HdqowhE0sEQzOIJvs2JK5L+LcoSkTViy
PzidZ0qoAHRluuFw8Ag9sahcQfao6rqJOFY/16KEjDthATGo/4mHRsuAM+xza+2m
GbqJH/iO/q0lsPb3culm8aoJNxULTHrU5YWhuGvRypSYrfdL7RBkzW4VEt5LcRK6
KcfmfHMrjPl/XxSSvrBmly7nYNH80DGSMRP/lnrQ8OS+hSiDy1KBaCcNhja5Dyzn
K0dXlMGmWrnDMs8m+4cUoIM=
-----END CERTIFICATE-----
Comment thread
xiangyan99 marked this conversation as resolved.
25 changes: 17 additions & 8 deletions sdk/identity/azure-identity/tests/test_certificate_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
import pytest
from six.moves.urllib_parse import urlparse

from helpers import build_aad_response, urlsafeb64_decode, mock_response, Request, validating_transport

try:
from unittest.mock import Mock, patch
from unittest.mock import Mock
except ImportError: # python < 3.3
from mock import Mock, patch # type: ignore
from mock import Mock # type: ignore

CERT_PATH = os.path.join(os.path.dirname(__file__), "certificate.pem")
CERT_WITH_PASSWORD_PATH = os.path.join(os.path.dirname(__file__), "certificate-with-password.pem")
CERT_PASSWORD = "password"
BOTH_CERTS = ((CERT_PATH, None), (CERT_WITH_PASSWORD_PATH, CERT_PASSWORD))


def test_policies_configurable():
Expand Down Expand Up @@ -50,7 +54,8 @@ def test_user_agent():
credential.get_token("scope")


def test_request_url():
@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS)
def test_request_url(cert_path, cert_password):
authority = "authority.com"
tenant_id = "expected_tenant"
access_token = "***"
Expand All @@ -65,12 +70,15 @@ def mock_send(request, **kwargs):
validate_url(request.url)
return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token})

cred = CertificateCredential(tenant_id, "client_id", CERT_PATH, transport=Mock(send=mock_send), authority=authority)
cred = CertificateCredential(
tenant_id, "client-id", cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority
)
token = cred.get_token("scope")
assert token.token == access_token


def test_request_body():
@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS)
def test_request_body(cert_path, cert_password):
access_token = "***"
authority = "authority.com"
client_id = "client-id"
Expand All @@ -81,14 +89,15 @@ def mock_send(request, **kwargs):
assert request.body["grant_type"] == "client_credentials"
assert request.body["scope"] == expected_scope

with open(CERT_PATH, "rb") as cert_file:
with open(cert_path, "rb") as cert_file:
validate_jwt(request, client_id, cert_file.read())

return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token})

cred = CertificateCredential(tenant_id, client_id, CERT_PATH, transport=Mock(send=mock_send), authority=authority)
cred = CertificateCredential(
tenant_id, client_id, cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority
)
token = cred.get_token(expected_scope)

assert token.token == access_token


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------
import json
import os
from unittest.mock import Mock, patch
from unittest.mock import Mock
from urllib.parse import urlparse

from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy
Expand All @@ -15,10 +13,7 @@

from helpers import build_aad_response, urlsafeb64_decode, mock_response, Request
from helpers_async import async_validating_transport, AsyncMockTransport
from test_certificate_credential import validate_jwt


CERT_PATH = os.path.join(os.path.dirname(__file__), "certificate.pem")
from test_certificate_credential import BOTH_CERTS, CERT_PATH, validate_jwt


@pytest.mark.asyncio
Expand Down Expand Up @@ -72,28 +67,32 @@ async def test_user_agent():


@pytest.mark.asyncio
async def test_request_url():
@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS)
async def test_request_url(cert_path, cert_password):
authority = "authority.com"
tenant_id = "expected_tenant"
access_token = "***"

def validate_url(url):
scheme, netloc, path, _, _, _ = urlparse(url)
assert scheme == "https"
assert netloc == authority
assert path.startswith("/" + tenant_id)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == authority
assert parsed.path.startswith("/" + tenant_id)

async def mock_send(request, **kwargs):
validate_url(request.url)
return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token})

cred = CertificateCredential(tenant_id, "client_id", CERT_PATH, transport=Mock(send=mock_send), authority=authority)
cred = CertificateCredential(
tenant_id, "client-id", cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority
)
token = await cred.get_token("scope")
assert token.token == access_token


@pytest.mark.asyncio
async def test_request_body():
@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS)
async def test_request_body(cert_path, cert_password):
access_token = "***"
authority = "authority.com"
client_id = "client-id"
Expand All @@ -104,12 +103,14 @@ async def mock_send(request, **kwargs):
assert request.body["grant_type"] == "client_credentials"
assert request.body["scope"] == expected_scope

with open(CERT_PATH, "rb") as cert_file:
with open(cert_path, "rb") as cert_file:
validate_jwt(request, client_id, cert_file.read())

return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token})

cred = CertificateCredential(tenant_id, client_id, CERT_PATH, transport=Mock(send=mock_send), authority=authority)
cred = CertificateCredential(
tenant_id, client_id, cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority
)
token = await cred.get_token("scope")

assert token.token == access_token
10 changes: 10 additions & 0 deletions sdk/identity/azure-identity/tests/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ def test_certificate_credential(live_certificate):
get_token(credential)


def test_certificate_credential_with_password(live_certificate_with_password):
credential = CertificateCredential(
live_certificate_with_password["tenant_id"],
live_certificate_with_password["client_id"],
live_certificate_with_password["cert_path"],
password=live_certificate_with_password["password"],
)
get_token(credential)


def test_client_secret_credential(live_service_principal):
credential = ClientSecretCredential(
live_service_principal["tenant_id"],
Expand Down
11 changes: 11 additions & 0 deletions sdk/identity/azure-identity/tests/test_live_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ async def test_certificate_credential(live_certificate):
await get_token(credential)


@pytest.mark.asyncio
async def test_certificate_credential_with_password(live_certificate_with_password):
credential = CertificateCredential(
live_certificate_with_password["tenant_id"],
live_certificate_with_password["client_id"],
live_certificate_with_password["cert_path"],
password=live_certificate_with_password["password"],
)
await get_token(credential)


@pytest.mark.asyncio
async def test_client_secret_credential(live_service_principal):
credential = ClientSecretCredential(
Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ jobs:
AZURE_CLIENT_ID: $(python-identity-client-id)
AZURE_CLIENT_SECRET: $(python-identity-client-secret)
AZURE_TENANT_ID: $(aad-azure-sdk-test-tenant-id)
CERTIFICATE_PASSWORD: $(python-identity-certificate-password)
PEM_CONTENT: $(python-identity-certificate)
PEM_CONTENT_PASSWORD_PROTECTED: $(python-identity-certificate-with-password)