Skip to content

Commit

Permalink
Merge pull request #109 from AzureAD/release-1.0.0
Browse files Browse the repository at this point in the history
MSAL EX Python 1.0.0
  • Loading branch information
rayluo authored Feb 14, 2022
2 parents 8404023 + 50bf967 commit 6f77b1e
Show file tree
Hide file tree
Showing 13 changed files with 144 additions and 136 deletions.
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

0 comments on commit 6f77b1e

Please sign in to comment.