Skip to content

Commit 32914b3

Browse files
authored
Merge pull request #45 from AzureAD/release-0.2.0
Release 0.2.0
2 parents 4efc521 + 578d0b5 commit 32914b3

13 files changed

+597
-175
lines changed

.pylintrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[MESSAGES CONTROL]
2+
good-names=
3+
logger
24
disable=
5+
trailing-newlines,
36
useless-object-inheritance

.travis.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,35 @@ matrix:
66
- python: "2.7"
77
env: TOXENV=py27 PYPI=true
88
os: linux
9+
before_install:
10+
- sudo apt update
11+
- sudo apt install python-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1
912
- python: "3.5"
1013
env: TOXENV=py35
1114
os: linux
15+
before_install:
16+
- sudo apt update
17+
- sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1
1218
- python: "3.6"
1319
env: TOXENV=py36
1420
os: linux
21+
before_install:
22+
- sudo apt update
23+
- sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1
1524
- python: "3.7"
1625
env: TOXENV=py37
1726
os: linux
1827
dist: xenial
28+
before_install:
29+
- sudo apt update
30+
- sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1
1931
- python: "3.8"
2032
env: TOXENV=py38
2133
os: linux
2234
dist: xenial
35+
before_install:
36+
- sudo apt update
37+
- sudo apt install python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-secret-1
2338
- name: "Python 3.7 on macOS"
2439
env: TOXENV=py37
2540
os: osx
@@ -46,7 +61,8 @@ install:
4661
- pip install .
4762

4863
script:
49-
- pylint msal_extensions
64+
- # Difficult to get .pylintrc working on both Python 2 & 3, and we don't have to
65+
- if [ "$TOXENV" = "py37"]; then pylint msal_extensions; fi
5066
- tox
5167

5268
deploy:

msal_extensions/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
"""Provides auxiliary functionality to the `msal` package."""
2-
__version__ = "0.1.3"
2+
__version__ = "0.2.0"
33

44
import sys
55

6+
from .persistence import (
7+
FilePersistence,
8+
FilePersistenceWithDataProtection,
9+
KeychainPersistence,
10+
LibsecretPersistence,
11+
)
12+
from .cache_lock import CrossPlatLock
13+
from .token_cache import PersistedTokenCache
14+
615
if sys.platform.startswith('win'):
716
from .token_cache import WindowsTokenCache as TokenCache
817
elif sys.platform.startswith('darwin'):
918
from .token_cache import OSXTokenCache as TokenCache
1019
else:
11-
from .token_cache import UnencryptedTokenCache as TokenCache
20+
from .token_cache import FileTokenCache as TokenCache

msal_extensions/cache_lock.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,28 @@ class CrossPlatLock(object):
1212
"""
1313
def __init__(self, lockfile_path):
1414
self._lockpath = lockfile_path
15-
self._fh = None
15+
self._lock = portalocker.Lock(
16+
lockfile_path,
17+
mode='wb+',
18+
# In posix systems, we HAVE to use LOCK_EX(exclusive lock) bitwise ORed
19+
# with LOCK_NB(non-blocking) to avoid blocking on lock acquisition.
20+
# More information here:
21+
# https://docs.python.org/3/library/fcntl.html#fcntl.lockf
22+
flags=portalocker.LOCK_EX | portalocker.LOCK_NB,
23+
buffering=0)
1624

1725
def __enter__(self):
18-
pid = os.getpid()
19-
20-
self._fh = open(self._lockpath, 'wb+', buffering=0)
21-
portalocker.lock(self._fh, portalocker.LOCK_EX)
22-
self._fh.write('{} {}'.format(pid, sys.argv[0]).encode('utf-8'))
26+
file_handle = self._lock.__enter__()
27+
file_handle.write('{} {}'.format(os.getpid(), sys.argv[0]).encode('utf-8'))
28+
return file_handle
2329

2430
def __exit__(self, *args):
25-
self._fh.close()
31+
self._lock.__exit__(*args)
2632
try:
2733
# Attempt to delete the lockfile. In either of the failure cases enumerated below, it is
2834
# likely that another process has raced this one and ended up clearing or locking the
2935
# file for itself.
3036
os.remove(self._lockpath)
31-
except OSError as ex:
37+
except OSError as ex: # pylint: disable=invalid-name
3238
if ex.errno != errno.ENOENT and ex.errno != errno.EACCES:
3339
raise

msal_extensions/libsecret.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Implements a Linux specific TokenCache, and provides auxiliary helper types.
2+
3+
This module depends on PyGObject. But `pip install pygobject` would typically fail,
4+
until you install its dependencies first. For example, on a Debian Linux, you need::
5+
6+
sudo apt install libgirepository1.0-dev libcairo2-dev python3-dev gir1.2-secret-1
7+
pip install pygobject
8+
9+
Alternatively, you could skip Cairo & PyCairo, but you still need to do all these
10+
(derived from https://gitlab.gnome.org/GNOME/pygobject/-/issues/395)::
11+
12+
sudo apt install libgirepository1.0-dev python3-dev gir1.2-secret-1
13+
pip install wheel
14+
PYGOBJECT_WITHOUT_PYCAIRO=1 pip install --no-build-isolation pygobject
15+
"""
16+
import logging
17+
18+
import gi # https://pygobject.readthedocs.io/en/latest/getting_started.html
19+
20+
# pylint: disable=no-name-in-module
21+
gi.require_version("Secret", "1") # Would require a package gir1.2-secret-1
22+
# pylint: disable=wrong-import-position
23+
from gi.repository import Secret # Would require a package gir1.2-secret-1
24+
25+
26+
logger = logging.getLogger(__name__)
27+
28+
class LibSecretAgent(object):
29+
"""A loader/saver built on top of low-level libsecret"""
30+
# Inspired by https://developer.gnome.org/libsecret/unstable/py-examples.html
31+
def __init__( # pylint: disable=too-many-arguments
32+
self,
33+
schema_name,
34+
attributes, # {"name": "value", ...}
35+
label="", # Helpful when visualizing secrets by other viewers
36+
attribute_types=None, # {name: SchemaAttributeType, ...}
37+
collection=None, # None means default collection
38+
): # pylint: disable=bad-continuation
39+
"""This agent is built on top of lower level libsecret API.
40+
41+
Content stored via libsecret is associated with a bunch of attributes.
42+
43+
:param string schema_name:
44+
Attributes would conceptually follow an existing schema.
45+
But this class will do it in the other way around,
46+
by automatically deriving a schema based on your attributes.
47+
However, you will still need to provide a schema_name.
48+
load() and save() will only operate on data with matching schema_name.
49+
50+
:param dict attributes:
51+
Attributes are key-value pairs, represented as a Python dict here.
52+
They will be used to filter content during load() and save().
53+
Their arbitrary keys are strings.
54+
Their arbitrary values can MEAN strings, integers and booleans,
55+
but are always represented as strings, according to upstream sample:
56+
https://developer.gnome.org/libsecret/0.18/py-store-example.html
57+
58+
:param string label:
59+
It will not be used during data lookup and filtering.
60+
It is only helpful when/if you visualize secrets by other viewers.
61+
62+
:param dict attribute_types:
63+
Each key is the name of your each attribute.
64+
The corresponding value will be one of the following three:
65+
66+
* Secret.SchemaAttributeType.STRING
67+
* Secret.SchemaAttributeType.INTEGER
68+
* Secret.SchemaAttributeType.BOOLEAN
69+
70+
But if all your attributes are Secret.SchemaAttributeType.STRING,
71+
you do not need to provide this types definition at all.
72+
73+
:param collection:
74+
The default value `None` means default collection.
75+
"""
76+
self._collection = collection
77+
self._attributes = attributes or {}
78+
self._label = label
79+
self._schema = Secret.Schema.new(schema_name, Secret.SchemaFlags.NONE, {
80+
k: (attribute_types or {}).get(k, Secret.SchemaAttributeType.STRING)
81+
for k in self._attributes})
82+
83+
def save(self, data):
84+
"""Store data. Returns a boolean of whether operation was successful."""
85+
return Secret.password_store_sync(
86+
self._schema, self._attributes, self._collection, self._label,
87+
data, None)
88+
89+
def load(self):
90+
"""Load a password in the secret service, return None when found nothing"""
91+
return Secret.password_lookup_sync(self._schema, self._attributes, None)
92+
93+
def clear(self):
94+
"""Returns a boolean of whether any passwords were removed"""
95+
return Secret.password_clear_sync(self._schema, self._attributes, None)
96+
97+
98+
def trial_run():
99+
"""This trial run will raise an exception if libsecret is not functioning.
100+
101+
Even after you installed all the dependencies so that your script can start,
102+
or even if your previous run was successful, your script could fail next time,
103+
for example when it will be running inside a headless SSH session.
104+
105+
You do not have to do trial_run. The exception would also be raised by save().
106+
"""
107+
try:
108+
agent = LibSecretAgent("Test Schema", {"attr1": "foo", "attr2": "bar"})
109+
payload = "Test Data"
110+
agent.save(payload) # It would fail when running inside an SSH session
111+
assert agent.load() == payload # This line is probably not reachable
112+
agent.clear()
113+
except (gi.repository.GLib.Error, AssertionError):
114+
message = (
115+
"libsecret did not perform properly. Please refer to "
116+
"https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/Encryption-on-Linux") # pylint: disable=line-too-long
117+
logger.exception(message) # This log contains trace stack for debugging
118+
logger.warning(message) # This is visible by default
119+
raise
120+

0 commit comments

Comments
 (0)