Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MSAL EX Python 1.0.0 #109

Merged
merged 11 commits into from
Feb 14, 2022
1 change: 1 addition & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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,
Expand Down
11 changes: 2 additions & 9 deletions msal_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
"""Provides auxiliary functionality to the `msal` package."""
__version__ = "0.3.1"

import sys
__version__ = "1.0.0"

from .persistence import (
FilePersistence,
build_encrypted_persistence,
FilePersistenceWithDataProtection,
KeychainPersistence,
LibsecretPersistence,
)
from .cache_lock import CrossPlatLock
from .token_cache import PersistedTokenCache

if sys.platform.startswith('win'):
from .token_cache import WindowsTokenCache as TokenCache
elif sys.platform.startswith('darwin'):
from .token_cache import OSXTokenCache as TokenCache
else:
from .token_cache import FileTokenCache as TokenCache
87 changes: 70 additions & 17 deletions msal_extensions/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import abc
import os
import errno
import hashlib
import logging
import sys
try:
Expand Down Expand Up @@ -50,21 +51,52 @@ def _mkdir_p(path):
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 define only the most common one,
# otherwise caller would need to catch os-specific persistence exceptions.
class PersistenceNotFound(IOError): # Use IOError rather than OSError as base,
# 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 or errno.ENOENT,
message or "Persistence not found",
location)
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):
Expand Down Expand Up @@ -101,6 +133,11 @@ def get_location(self):
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"""

Expand All @@ -113,7 +150,7 @@ def __init__(self, location):
def save(self, content):
# type: (str) -> None
"""Save the content into this persistence"""
with open(self._location, 'w+') as handle: # pylint: disable=unspecified-encoding
with os.fdopen(_open(self._location), 'w+') as handle:
handle.write(content)

def load(self):
Expand Down Expand Up @@ -168,16 +205,21 @@ def __init__(self, location, entropy=''):

def save(self, content):
# type: (str) -> None
data = self._dp_agent.protect(content)
with open(self._location, 'wb+') as handle:
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()
return self._dp_agent.unprotect(data)
except EnvironmentError as exp: # EnvironmentError in Py 2.7 works across platform
if exp.errno == errno.ENOENT:
raise PersistenceNotFound(
Expand All @@ -190,26 +232,36 @@ def load(self):
"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, account_name):
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__`
"""
if not (service_name and account_name): # It would hang on OSX
raise ValueError("service_name and account_name are required")
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
self._service_name = service_name
self._account_name = account_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:
Expand Down Expand Up @@ -247,7 +299,7 @@ class LibsecretPersistence(BasePersistence):
and protected by native libsecret libraries on Linux"""
is_encrypted = True

def __init__(self, signal_location, schema_name, attributes, **kwargs):
def __init__(self, signal_location, schema_name=None, attributes=None, **kwargs):
"""Initialization could fail due to unsatisfied dependency.

:param string signal_location:
Expand All @@ -262,7 +314,8 @@ def __init__(self, signal_location, schema_name, attributes, **kwargs):
from .libsecret import ( # This uncertain import is deferred till runtime
LibSecretAgent, trial_run)
trial_run()
self._agent = LibSecretAgent(schema_name, attributes, **kwargs)
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):
Expand Down
37 changes: 1 addition & 36 deletions msal_extensions/token_cache.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
"""Generic functions and types for working with a TokenCache that is not platform specific."""
import os
import warnings
import time
import logging

import msal

from .cache_lock import CrossPlatLock
from .persistence import (
_mkdir_p, PersistenceNotFound, FilePersistence,
FilePersistenceWithDataProtection, KeychainPersistence)
from .persistence import _mkdir_p, PersistenceNotFound


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -89,35 +86,3 @@ def find(self, credential_type, **kwargs): # pylint: disable=arguments-differ
return super(PersistedTokenCache, self).find(credential_type, **kwargs)
return [] # Not really reachable here. Just to keep pylint happy.


class FileTokenCache(PersistedTokenCache):
"""A token cache which uses plain text file to store your tokens."""
def __init__(self, cache_location, **ignored): # pylint: disable=unused-argument
warnings.warn("You are using an unprotected token cache", RuntimeWarning)
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
super(FileTokenCache, self).__init__(FilePersistence(cache_location))

UnencryptedTokenCache = FileTokenCache # For backward compatibility


class WindowsTokenCache(PersistedTokenCache):
"""A token cache which uses Windows DPAPI to encrypt your tokens."""
def __init__(
self, cache_location, entropy='',
**ignored): # pylint: disable=unused-argument
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
super(WindowsTokenCache, self).__init__(
FilePersistenceWithDataProtection(cache_location, entropy=entropy))


class OSXTokenCache(PersistedTokenCache):
"""A token cache which uses native Keychain libraries to encrypt your tokens."""
def __init__(self,
cache_location,
service_name='Microsoft.Developer.IdentityService',
account_name='MSALCache',
**ignored): # pylint: disable=unused-argument
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
super(OSXTokenCache, self).__init__(
KeychainPersistence(cache_location, service_name, account_name))

13 changes: 11 additions & 2 deletions msal_extensions/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ def raw(self):
_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
Expand Down Expand Up @@ -82,7 +91,7 @@ def protect(self, message):
_LOCAL_FREE(result.pbData)

err_code = _GET_LAST_ERROR()
raise OSError(256, '', '', err_code)
raise OSError(None, _err_description.get(err_code), None, err_code)

def unprotect(self, cipher_text):
# type: (bytes) -> str
Expand Down Expand Up @@ -111,4 +120,4 @@ def unprotect(self, cipher_text):
finally:
_LOCAL_FREE(result.pbData)
err_code = _GET_LAST_ERROR()
raise OSError(256, '', '', err_code)
raise OSError(None, _err_description.get(err_code), None, err_code)
36 changes: 12 additions & 24 deletions sample/persistence_sample.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import sys
import logging
import json

from msal_extensions import *
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"""
if sys.platform.startswith('win'):
return FilePersistenceWithDataProtection(location)
if sys.platform.startswith('darwin'):
return KeychainPersistence(location, "my_service_name", "my_account_name")
if sys.platform.startswith('linux'):
try:
return LibsecretPersistence(
# By using same location as the fall back option below,
# this would override the unencrypted data stored by the
# fall back option. It is probably OK, or even desirable
# (in order to aggressively wipe out plain-text persisted data),
# unless there would frequently be a desktop session and
# a remote ssh session being active simultaneously.
location,
schema_name="my_schema_name",
attributes={"my_attr1": "foo", "my_attr2": "bar"},
)
except: # pylint: disable=bare-except
if not fallback_to_plaintext:
raise
logging.warning("Encryption unavailable. Opting in to plain text.")
return FilePersistence(location)
# 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
# Known issue: Currently, only Linux
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
Expand Down
35 changes: 12 additions & 23 deletions sample/token_cache_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,24 @@
import logging
import json

from msal_extensions import *
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"""
if sys.platform.startswith('win'):
return FilePersistenceWithDataProtection(location)
if sys.platform.startswith('darwin'):
return KeychainPersistence(location, "my_service_name", "my_account_name")
if sys.platform.startswith('linux'):
try:
return LibsecretPersistence(
# By using same location as the fall back option below,
# this would override the unencrypted data stored by the
# fall back option. It is probably OK, or even desirable
# (in order to aggressively wipe out plain-text persisted data),
# unless there would frequently be a desktop session and
# a remote ssh session being active simultaneously.
location,
schema_name="my_schema_name",
attributes={"my_attr1": "foo", "my_attr2": "bar"},
)
except: # pylint: disable=bare-except
if not fallback_to_plaintext:
raise
logging.exception("Encryption unavailable. Opting in to plain text.")
return FilePersistence(location)
# 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
# Known issue: Currently, only Linux
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)
Expand Down
3 changes: 2 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ license = MIT
project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-extensions-for-python/releases
classifiers =
License :: OSI Approved :: MIT License
Development Status :: 4 - Beta
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.
Loading