Skip to content

Commit 6f77b1e

Browse files
authored
Merge pull request #109 from AzureAD/release-1.0.0
MSAL EX Python 1.0.0
2 parents 8404023 + 50bf967 commit 6f77b1e

File tree

13 files changed

+144
-136
lines changed

13 files changed

+144
-136
lines changed

.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
good-names=
33
logger
44
disable=
5+
consider-using-f-string, # For Python < 3.6
56
super-with-arguments, # For Python 2.x
67
raise-missing-from, # For Python 2.x
78
trailing-newlines,

msal_extensions/__init__.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
"""Provides auxiliary functionality to the `msal` package."""
2-
__version__ = "0.3.1"
3-
4-
import sys
2+
__version__ = "1.0.0"
53

64
from .persistence import (
75
FilePersistence,
6+
build_encrypted_persistence,
87
FilePersistenceWithDataProtection,
98
KeychainPersistence,
109
LibsecretPersistence,
1110
)
1211
from .cache_lock import CrossPlatLock
1312
from .token_cache import PersistedTokenCache
1413

15-
if sys.platform.startswith('win'):
16-
from .token_cache import WindowsTokenCache as TokenCache
17-
elif sys.platform.startswith('darwin'):
18-
from .token_cache import OSXTokenCache as TokenCache
19-
else:
20-
from .token_cache import FileTokenCache as TokenCache

msal_extensions/persistence.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import abc
1010
import os
1111
import errno
12+
import hashlib
1213
import logging
1314
import sys
1415
try:
@@ -50,21 +51,52 @@ def _mkdir_p(path):
5051
else:
5152
raise
5253

54+
def _auto_hash(input_string):
55+
return hashlib.sha256(input_string.encode('utf-8')).hexdigest()
56+
5357

5458
# We do not aim to wrap every os-specific exception.
55-
# Here we define only the most common one,
56-
# otherwise caller would need to catch os-specific persistence exceptions.
57-
class PersistenceNotFound(IOError): # Use IOError rather than OSError as base,
59+
# Here we standardize only the most common ones,
60+
# otherwise caller would need to catch os-specific underlying exceptions.
61+
class PersistenceError(IOError): # Use IOError rather than OSError as base,
62+
"""The base exception for persistence."""
5863
# because historically an IOError was bubbled up and expected.
5964
# https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/0.2.2/msal_extensions/token_cache.py#L38
6065
# Now we want to maintain backward compatibility even when using Python 2.x
6166
# It makes no difference in Python 3.3+ where IOError is an alias of OSError.
67+
def __init__(self, err_no=None, message=None, location=None): # pylint: disable=useless-super-delegation
68+
super(PersistenceError, self).__init__(err_no, message, location)
69+
70+
71+
class PersistenceNotFound(PersistenceError):
6272
"""This happens when attempting BasePersistence.load() on a non-existent persistence instance"""
6373
def __init__(self, err_no=None, message=None, location=None):
6474
super(PersistenceNotFound, self).__init__(
65-
err_no or errno.ENOENT,
66-
message or "Persistence not found",
67-
location)
75+
err_no=errno.ENOENT,
76+
message=message or "Persistence not found",
77+
location=location)
78+
79+
class PersistenceEncryptionError(PersistenceError):
80+
"""This could be raised by persistence.save()"""
81+
82+
class PersistenceDecryptionError(PersistenceError):
83+
"""This could be raised by persistence.load()"""
84+
85+
86+
def build_encrypted_persistence(location):
87+
"""Build a suitable encrypted persistence instance based your current OS.
88+
89+
If you do not need encryption, then simply use ``FilePersistence`` constructor.
90+
"""
91+
# Does not (yet?) support fallback_to_plaintext flag,
92+
# because the persistence on Windows and macOS do not support built-in trial_run().
93+
if sys.platform.startswith('win'):
94+
return FilePersistenceWithDataProtection(location)
95+
if sys.platform.startswith('darwin'):
96+
return KeychainPersistence(location)
97+
if sys.platform.startswith('linux'):
98+
return LibsecretPersistence(location)
99+
raise RuntimeError("Unsupported platform: {}".format(sys.platform)) # pylint: disable=consider-using-f-string
68100

69101

70102
class BasePersistence(ABC):
@@ -101,6 +133,11 @@ def get_location(self):
101133
raise NotImplementedError
102134

103135

136+
def _open(location):
137+
return os.open(location, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600)
138+
# The 600 seems no-op on NTFS/Windows, and that is fine
139+
140+
104141
class FilePersistence(BasePersistence):
105142
"""A generic persistence, storing data in a plain-text file"""
106143

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

119156
def load(self):
@@ -168,16 +205,21 @@ def __init__(self, location, entropy=''):
168205

169206
def save(self, content):
170207
# type: (str) -> None
171-
data = self._dp_agent.protect(content)
172-
with open(self._location, 'wb+') as handle:
208+
try:
209+
data = self._dp_agent.protect(content)
210+
except OSError as exception:
211+
raise PersistenceEncryptionError(
212+
err_no=getattr(exception, "winerror", None), # Exists in Python 3 on Windows
213+
message="Encryption failed: {}. Consider disable encryption.".format(exception),
214+
)
215+
with os.fdopen(_open(self._location), 'wb+') as handle:
173216
handle.write(data)
174217

175218
def load(self):
176219
# type: () -> str
177220
try:
178221
with open(self._location, 'rb') as handle:
179222
data = handle.read()
180-
return self._dp_agent.unprotect(data)
181223
except EnvironmentError as exp: # EnvironmentError in Py 2.7 works across platform
182224
if exp.errno == errno.ENOENT:
183225
raise PersistenceNotFound(
@@ -190,26 +232,36 @@ def load(self):
190232
"DPAPI error likely caused by file content not previously encrypted. "
191233
"App developer should migrate by calling save(plaintext) first.")
192234
raise
235+
try:
236+
return self._dp_agent.unprotect(data)
237+
except OSError as exception:
238+
raise PersistenceDecryptionError(
239+
err_no=getattr(exception, "winerror", None), # Exists in Python 3 on Windows
240+
message="Decryption failed: {}. "
241+
"App developer may consider this guidance: "
242+
"https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/PersistenceDecryptionError" # pylint: disable=line-too-long
243+
.format(exception),
244+
location=self._location,
245+
)
193246

194247

195248
class KeychainPersistence(BasePersistence):
196249
"""A generic persistence with data stored in,
197250
and protected by native Keychain libraries on OSX"""
198251
is_encrypted = True
199252

200-
def __init__(self, signal_location, service_name, account_name):
253+
def __init__(self, signal_location, service_name=None, account_name=None):
201254
"""Initialization could fail due to unsatisfied dependency.
202255
203256
:param signal_location: See :func:`persistence.LibsecretPersistence.__init__`
204257
"""
205-
if not (service_name and account_name): # It would hang on OSX
206-
raise ValueError("service_name and account_name are required")
207258
from .osx import Keychain, KeychainError # pylint: disable=import-outside-toplevel
208259
self._file_persistence = FilePersistence(signal_location) # Favor composition
209260
self._Keychain = Keychain # pylint: disable=invalid-name
210261
self._KeychainError = KeychainError # pylint: disable=invalid-name
211-
self._service_name = service_name
212-
self._account_name = account_name
262+
default_service_name = "msal-extensions" # This is also our package name
263+
self._service_name = service_name or default_service_name
264+
self._account_name = account_name or _auto_hash(signal_location)
213265

214266
def save(self, content):
215267
with self._Keychain() as locker:
@@ -247,7 +299,7 @@ class LibsecretPersistence(BasePersistence):
247299
and protected by native libsecret libraries on Linux"""
248300
is_encrypted = True
249301

250-
def __init__(self, signal_location, schema_name, attributes, **kwargs):
302+
def __init__(self, signal_location, schema_name=None, attributes=None, **kwargs):
251303
"""Initialization could fail due to unsatisfied dependency.
252304
253305
:param string signal_location:
@@ -262,7 +314,8 @@ def __init__(self, signal_location, schema_name, attributes, **kwargs):
262314
from .libsecret import ( # This uncertain import is deferred till runtime
263315
LibSecretAgent, trial_run)
264316
trial_run()
265-
self._agent = LibSecretAgent(schema_name, attributes, **kwargs)
317+
self._agent = LibSecretAgent(
318+
schema_name or _auto_hash(signal_location), attributes or {}, **kwargs)
266319
self._file_persistence = FilePersistence(signal_location) # Favor composition
267320

268321
def save(self, content):

msal_extensions/token_cache.py

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
"""Generic functions and types for working with a TokenCache that is not platform specific."""
22
import os
3-
import warnings
43
import time
54
import logging
65

76
import msal
87

98
from .cache_lock import CrossPlatLock
10-
from .persistence import (
11-
_mkdir_p, PersistenceNotFound, FilePersistence,
12-
FilePersistenceWithDataProtection, KeychainPersistence)
9+
from .persistence import _mkdir_p, PersistenceNotFound
1310

1411

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

92-
93-
class FileTokenCache(PersistedTokenCache):
94-
"""A token cache which uses plain text file to store your tokens."""
95-
def __init__(self, cache_location, **ignored): # pylint: disable=unused-argument
96-
warnings.warn("You are using an unprotected token cache", RuntimeWarning)
97-
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
98-
super(FileTokenCache, self).__init__(FilePersistence(cache_location))
99-
100-
UnencryptedTokenCache = FileTokenCache # For backward compatibility
101-
102-
103-
class WindowsTokenCache(PersistedTokenCache):
104-
"""A token cache which uses Windows DPAPI to encrypt your tokens."""
105-
def __init__(
106-
self, cache_location, entropy='',
107-
**ignored): # pylint: disable=unused-argument
108-
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
109-
super(WindowsTokenCache, self).__init__(
110-
FilePersistenceWithDataProtection(cache_location, entropy=entropy))
111-
112-
113-
class OSXTokenCache(PersistedTokenCache):
114-
"""A token cache which uses native Keychain libraries to encrypt your tokens."""
115-
def __init__(self,
116-
cache_location,
117-
service_name='Microsoft.Developer.IdentityService',
118-
account_name='MSALCache',
119-
**ignored): # pylint: disable=unused-argument
120-
warnings.warn("Use PersistedTokenCache(...) instead", DeprecationWarning)
121-
super(OSXTokenCache, self).__init__(
122-
KeychainPersistence(cache_location, service_name, account_name))
123-

msal_extensions/windows.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ def raw(self):
3939
_MEMCPY(blob_buffer, pb_data, cb_data)
4040
return blob_buffer.raw
4141

42+
_err_description = {
43+
# Keys came from real world observation, values came from winerror.h (http://errors (Microsoft internal))
44+
-2146893813: "Key not valid for use in specified state.",
45+
-2146892987: "The requested operation cannot be completed. "
46+
"The computer must be trusted for delegation and "
47+
"the current user account must be configured to allow delegation. "
48+
"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",
49+
13: "The data is invalid",
50+
}
4251

4352
# This code is modeled from a StackOverflow question, which can be found here:
4453
# https://stackoverflow.com/questions/463832/using-dpapi-with-python
@@ -82,7 +91,7 @@ def protect(self, message):
8291
_LOCAL_FREE(result.pbData)
8392

8493
err_code = _GET_LAST_ERROR()
85-
raise OSError(256, '', '', err_code)
94+
raise OSError(None, _err_description.get(err_code), None, err_code)
8695

8796
def unprotect(self, cipher_text):
8897
# type: (bytes) -> str
@@ -111,4 +120,4 @@ def unprotect(self, cipher_text):
111120
finally:
112121
_LOCAL_FREE(result.pbData)
113122
err_code = _GET_LAST_ERROR()
114-
raise OSError(256, '', '', err_code)
123+
raise OSError(None, _err_description.get(err_code), None, err_code)

sample/persistence_sample.py

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,24 @@
1-
import sys
21
import logging
32
import json
43

5-
from msal_extensions import *
4+
from msal_extensions import build_encrypted_persistence, FilePersistence, CrossPlatLock
65

76

87
def build_persistence(location, fallback_to_plaintext=False):
98
"""Build a suitable persistence instance based your current OS"""
10-
if sys.platform.startswith('win'):
11-
return FilePersistenceWithDataProtection(location)
12-
if sys.platform.startswith('darwin'):
13-
return KeychainPersistence(location, "my_service_name", "my_account_name")
14-
if sys.platform.startswith('linux'):
15-
try:
16-
return LibsecretPersistence(
17-
# By using same location as the fall back option below,
18-
# this would override the unencrypted data stored by the
19-
# fall back option. It is probably OK, or even desirable
20-
# (in order to aggressively wipe out plain-text persisted data),
21-
# unless there would frequently be a desktop session and
22-
# a remote ssh session being active simultaneously.
23-
location,
24-
schema_name="my_schema_name",
25-
attributes={"my_attr1": "foo", "my_attr2": "bar"},
26-
)
27-
except: # pylint: disable=bare-except
28-
if not fallback_to_plaintext:
29-
raise
30-
logging.warning("Encryption unavailable. Opting in to plain text.")
31-
return FilePersistence(location)
9+
# Note: This sample stores both encrypted persistence and plaintext persistence
10+
# into same location, therefore their data would likely override with each other.
11+
try:
12+
return build_encrypted_persistence(location)
13+
except: # pylint: disable=bare-except
14+
# Known issue: Currently, only Linux
15+
if not fallback_to_plaintext:
16+
raise
17+
logging.warning("Encryption unavailable. Opting in to plain text.")
18+
return FilePersistence(location)
3219

3320
persistence = build_persistence("storage.bin", fallback_to_plaintext=False)
21+
print("Type of persistence: {}".format(persistence.__class__.__name__))
3422
print("Is this persistence encrypted?", persistence.is_encrypted)
3523

3624
data = { # It can be anything, here we demonstrate an arbitrary json object

sample/token_cache_sample.py

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,24 @@
22
import logging
33
import json
44

5-
from msal_extensions import *
5+
from msal_extensions import build_encrypted_persistence, FilePersistence
66

77

88
def build_persistence(location, fallback_to_plaintext=False):
99
"""Build a suitable persistence instance based your current OS"""
10-
if sys.platform.startswith('win'):
11-
return FilePersistenceWithDataProtection(location)
12-
if sys.platform.startswith('darwin'):
13-
return KeychainPersistence(location, "my_service_name", "my_account_name")
14-
if sys.platform.startswith('linux'):
15-
try:
16-
return LibsecretPersistence(
17-
# By using same location as the fall back option below,
18-
# this would override the unencrypted data stored by the
19-
# fall back option. It is probably OK, or even desirable
20-
# (in order to aggressively wipe out plain-text persisted data),
21-
# unless there would frequently be a desktop session and
22-
# a remote ssh session being active simultaneously.
23-
location,
24-
schema_name="my_schema_name",
25-
attributes={"my_attr1": "foo", "my_attr2": "bar"},
26-
)
27-
except: # pylint: disable=bare-except
28-
if not fallback_to_plaintext:
29-
raise
30-
logging.exception("Encryption unavailable. Opting in to plain text.")
31-
return FilePersistence(location)
10+
# Note: This sample stores both encrypted persistence and plaintext persistence
11+
# into same location, therefore their data would likely override with each other.
12+
try:
13+
return build_encrypted_persistence(location)
14+
except: # pylint: disable=bare-except
15+
# Known issue: Currently, only Linux
16+
if not fallback_to_plaintext:
17+
raise
18+
logging.warning("Encryption unavailable. Opting in to plain text.")
19+
return FilePersistence(location)
3220

3321
persistence = build_persistence("token_cache.bin")
22+
print("Type of persistence: {}".format(persistence.__class__.__name__))
3423
print("Is this persistence encrypted?", persistence.is_encrypted)
3524

3625
cache = PersistedTokenCache(persistence)

setup.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ license = MIT
88
project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-extensions-for-python/releases
99
classifiers =
1010
License :: OSI Approved :: MIT License
11-
Development Status :: 4 - Beta
11+
Development Status :: 5 - Production/Stable
12+
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.

0 commit comments

Comments
 (0)