diff --git a/README.md b/README.md index ce5930a0..a18c8354 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ as applications written using a preview version of library may no longer work. 1. If you haven't already, [install and/or upgrade the pip](https://pip.pypa.io/en/stable/installing/) of your Python environment to a recent version. We tested with pip 18.1. -2. For now, you can install from our latest dev branch, by `pip install git+https://github.com/AzureAD/microsoft-authentication-library-for-python.git@dev` +2. As usual, just run `pip install msal`. ## Usage @@ -45,7 +45,7 @@ Acquiring tokens with MSAL Python need to follow this 3-step pattern. from msal import PublicClientApplication app = PublicClientApplication("your_client_id", authority="...") ``` - + Later, each time you would want an access token, you start by: ```python result = None # It is just an initial value. Please follow instructions below. @@ -97,7 +97,7 @@ and [Auth protocols](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) are recommended reading. -The API reference of MSAL Python is coming soon. +There is also the [API reference of MSAL Python](https://msal-python.rtfd.io). You can try [runnable samples in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..298ea9e2 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..d0a02003 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = u'MSAL Python' +copyright = u'2018, Microsoft' +author = u'Microsoft' + +# The short X.Y version +from msal import __version__ as version +# The full version, including alpha/beta/rc tags +release = version + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +# html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'MSALPythondoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'MSALPython.tex', u'MSAL Python Documentation', + u'Microsoft', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'msalpython', u'MSAL Python Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'MSALPython', u'MSAL Python Documentation', + author, 'MSALPython', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..baad12fd --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,81 @@ +.. MSAL Python documentation master file, created by + sphinx-quickstart on Tue Dec 18 10:53:22 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +.. This file is also inspired by + https://pythonhosted.org/an_example_pypi_project/sphinx.html#full-code-example + +Welcome to MSAL Python's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +You can find high level conceptual documentations in the project +`README `_ +and +`workable samples inside the project code base +`_ +. + +The documentation hosted here is for API Reference. + + +PublicClientApplication and ConfidentialClientApplication +========================================================= + +MSAL proposes a clean separation between +`public client applications and confidential client applications +`_. + +They are implemented as two separated classes, +with different methods for different authentication scenarios. + +PublicClientApplication +----------------------- +.. autoclass:: msal.PublicClientApplication + :members: + +ConfidentialClientApplication +----------------------------- +.. autoclass:: msal.ConfidentialClientApplication + :members: + + +Shared Methods +-------------- +Both PublicClientApplication and ConfidentialClientApplication +have following methods inherited from their base class. +You typically do not need to initiate this base class, though. + +.. autoclass:: msal.ClientApplication + :members: + + .. automethod:: __init__ + + +TokenCache +========== + +One of the parameter accepted by +both `PublicClientApplication` and `ConfidentialClientApplication` +is the `TokenCache`. + +.. autoclass:: msal.TokenCache + :members: + +You can subclass it to add new behavior, such as, token serialization. +See `SerializableTokenCache` for example. + +.. autoclass:: msal.SerializableTokenCache + :members: + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..27f573b8 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/msal/application.py b/msal/application.py index 31737c26..b667a4a8 100644 --- a/msal/application.py +++ b/msal/application.py @@ -4,19 +4,18 @@ except: # Python 3 from urllib.parse import urljoin import logging -from base64 import b64encode import sys from .oauth2cli import Client, JwtSigner from .authority import Authority from .mex import send_request as mex_send_request from .wstrust_request import send_request as wst_send_request -from .wstrust_response import SAML_TOKEN_TYPE_V1, SAML_TOKEN_TYPE_V2 +from .wstrust_response import * from .token_cache import TokenCache # The __init__.py will import this. Not the other way around. -__version__ = "0.1.0" +__version__ = "0.2.0" logger = logging.getLogger(__name__) @@ -55,25 +54,52 @@ def __init__( client_credential=None, authority=None, validate_authority=True, token_cache=None, verify=True, proxies=None, timeout=None): - """ - :param client_credential: It can be a string containing client secret, - or an X509 certificate container in this form: + """Create an instance of application. + + :param client_id: Your app has a clinet_id after you register it on AAD. + :param client_credential: + For :class:`PublicClientApplication`, you simply use `None` here. + For :class:`ConfidentialClientApplication`, + it can be a string containing client secret, + or an X509 certificate container in this form:: { "private_key": "...-----BEGIN PRIVATE KEY-----...", "thumbprint": "A1B2C3D4E5F6...", } + + :param str authority: + A URL that identifies a token authority. It should be of the format + https://login.microsoftonline.com/your_tenant + By default, we will use https://login.microsoftonline.com/common + :param bool validate_authority: (optional) Turns authority validation + on or off. This parameter default to true. + :param TokenCache cache: + Sets the token cache used by this ClientApplication instance. + By default, an in-memory cache will be created and used. + :param verify: (optional) + It will be passed to the + `verify parameter in the underlying requests library + `_ + :param proxies: (optional) + It will be passed to the + `proxies parameter in the underlying requests library + `_ + :param timeout: (optional) + It will be passed to the + `timeout parameter in the underlying requests library + `_ """ self.client_id = client_id self.client_credential = client_credential + self.verify = verify + self.proxies = proxies + self.timeout = timeout self.authority = Authority( authority or "https://login.microsoftonline.com/common/", - validate_authority) + validate_authority, verify=verify, proxies=proxies, timeout=timeout) # Here the self.authority is not the same type as authority in input self.token_cache = token_cache or TokenCache() - self.verify = verify - self.proxies = proxies - self.timeout = timeout self.client = self._build_client(client_credential, self.authority) def _build_client(self, client_credential, authority): @@ -123,13 +149,14 @@ def get_authorization_request_url( **kwargs): """Constructs a URL for you to start a Authorization Code Grant. - :param scopes: + :param list[str] scopes: (Required) Scopes requested to access a protected API (a resource). :param str state: Recommended by OAuth2 for CSRF protection. - :param login_hint: + :param str login_hint: Identifier of the user. Generally a User Principal Name (UPN). - :param redirect_uri: + :param str redirect_uri: Address to return to upon receiving a response from the authority. + :return: The authorization url as a string. """ """ # TBD: this would only be meaningful in a new acquire_token_interactive() :param additional_scope: Additional scope is a concept only in AAD. @@ -139,7 +166,10 @@ def get_authorization_request_url( (Under the hood, we simply merge scope and additional_scope before sending them on the wire.) """ - the_authority = Authority(authority) if authority else self.authority + the_authority = Authority( + authority, + verify=self.verify, proxies=self.proxies, timeout=self.timeout, + ) if authority else self.authority client = Client( {"authorization_endpoint": the_authority.authorization_endpoint}, self.client_id) @@ -161,7 +191,8 @@ def acquire_token_by_authorization_code( """The second half of the Authorization Code Grant. :param code: The authorization code returned from Authorization Server. - :param scopes: + :param list[str] scopes: (Required) + Scopes requested to access a protected API (a resource). If you requested user consent for multiple resources, here you will typically want to provide a subset of what you required in AuthCode. @@ -175,6 +206,11 @@ def acquire_token_by_authorization_code( recipient, called audience. So the developer need to specify a scope so that we can restrict the token to be issued for the corresponding audience. + + :return: A dict representing the json response from AAD: + + - A successful response would contain "access_token" key, + - an error response would contain "error" and usually "error_description". """ # If scope is absent on the wire, STS will give you a token associated # to the FIRST scope sent during the authorization request. @@ -190,13 +226,15 @@ def acquire_token_by_authorization_code( def get_accounts(self, username=None): """Get a list of accounts which previously signed in, i.e. exists in cache. - An account can later be used in acquire_token_silent() to find its tokens. - Each account is a dict. For now, we only document its "username" field. - Your app can choose to display those information to end user, - and allow them to choose one of them to proceed. + An account can later be used in :func:`~acquire_token_silent` + to find its tokens. :param username: Filter accounts with this username only. Case insensitive. + :return: A list of account objects. + Each account is a dict. For now, we only document its "username" field. + Your app can choose to display those information to end user, + and allow user to choose one of his/her accounts to proceed. """ # The following implementation finds accounts only from saved accounts, # but does NOT correlate them with saved RTs. It probably won't matter, @@ -224,18 +262,23 @@ def acquire_token_silent( or by finding a valid refresh token from cache and then automatically use it to redeem a new access token. - The return value will be an new or cached access token, or None. - - :param scopes: Scopes, represented as a list of strings + :param list[str] scopes: (Required) + Scopes requested to access a protected API (a resource). :param account: - one of the account object returned by get_accounts(), + one of the account object returned by :func:`~get_accounts`, or use None when you want to find an access token for this client. :param force_refresh: If True, it will skip Access Token look-up, and try to find a Refresh Token to obtain a new Access Token. + :return: + - A dict containing "access_token" key, when cache lookup succeeds. + - None when cache lookup does not yield anything. """ assert isinstance(scopes, list), "Invalid parameter type" - the_authority = Authority(authority) if authority else self.authority + the_authority = Authority( + authority, + verify=self.verify, proxies=self.proxies, timeout=self.timeout, + ) if authority else self.authority if not force_refresh: matches = self.token_cache.find( @@ -249,12 +292,14 @@ def acquire_token_silent( }) now = time.time() for entry in matches: - if entry["expires_on"] - now < 5*60: + expires_in = int(entry["expires_on"]) - now + if expires_in < 5*60: continue # Removal is not necessary, it will be overwritten + logger.debug("Cache hit an AT") return { # Mimic a real response "access_token": entry["secret"], "token_type": "Bearer", - "expires_in": entry["expires_on"] - now, + "expires_in": int(expires_in), # OAuth2 specs defines it as int } matches = self.token_cache.find( @@ -268,6 +313,7 @@ def acquire_token_silent( }) client = self._build_client(self.client_credential, the_authority) for entry in matches: + logger.debug("Cache hit an RT") response = client.obtain_token_by_refresh_token( entry, rt_getter=lambda token_item: token_item["secret"], scope=decorate_scope(scopes, self.client_id)) @@ -286,6 +332,16 @@ def __init__(self, client_id, client_credential=None, **kwargs): client_id, client_credential=None, **kwargs) def initiate_device_flow(self, scopes=None, **kwargs): + """Initiate a Device Flow instance, + which will be used in :func:`~acquire_token_by_device_flow`. + + :param list[str] scopes: + Scopes requested to access a protected API (a resource). + :return: A dict representing a newly created Device Flow object. + + - A successful response would contain "user_code" key, among others + - an error response would contain some other readable key/value pairs. + """ return self.client.initiate_device_flow( scope=decorate_scope(scopes or [], self.client_id), **kwargs) @@ -293,11 +349,16 @@ def initiate_device_flow(self, scopes=None, **kwargs): def acquire_token_by_device_flow(self, flow, **kwargs): """Obtain token by a device flow object, with customizable polling effect. - Args: - flow (dict): - A dict previously generated by initiate_device_flow(...). - You can exit the polling loop early, by changing the value of - its "expires_at" key to 0, at any time. + :param dict flow: + A dict previously generated by :func:`~initiate_device_flow`. + By default, this method's polling effect will block current thread. + You can abort the polling loop at any time, + by changing the value of the flow's "expires_at" key to 0. + + :return: A dict representing the json response from AAD: + + - A successful response would contain "access_token" key, + - an error response would contain "error" and usually "error_description". """ return self.client.obtain_token_by_device_flow( flow, @@ -308,7 +369,18 @@ def acquire_token_by_device_flow(self, flow, **kwargs): def acquire_token_by_username_password( self, username, password, scopes=None, **kwargs): - """Gets a token for a given resource via user credentails.""" + """Gets a token for a given resource via user credentails. + + :param str username: Typically a UPN in the form of an email address. + :param str password: The password. + :param list[str] scopes: + Scopes requested to access a protected API (a resource). + + :return: A dict representing the json response from AAD: + + - A successful response would contain "access_token" key, + - an error response would contain "error" and usually "error_description". + """ scopes = decorate_scope(scopes, self.client_id) if not self.authority.is_adfs: user_realm_result = self.authority.user_realm_discovery(username) @@ -320,35 +392,51 @@ def acquire_token_by_username_password( def _acquire_token_by_username_password_federated( self, user_realm_result, username, password, scopes=None, **kwargs): + verify = kwargs.pop("verify", self.verify) + proxies = kwargs.pop("proxies", self.proxies) wstrust_endpoint = {} if user_realm_result.get("federation_metadata_url"): wstrust_endpoint = mex_send_request( - user_realm_result["federation_metadata_url"]) + user_realm_result["federation_metadata_url"], + verify=verify, proxies=proxies) logger.debug("wstrust_endpoint = %s", wstrust_endpoint) wstrust_result = wst_send_request( username, password, user_realm_result.get("cloud_audience_urn"), wstrust_endpoint.get("address", # Fallback to an AAD supplied endpoint user_realm_result.get("federation_active_auth_url")), - wstrust_endpoint.get("action"), **kwargs) + wstrust_endpoint.get("action"), verify=verify, proxies=proxies) if not ("token" in wstrust_result and "type" in wstrust_result): raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result) + GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer' grant_type = { - SAML_TOKEN_TYPE_V1: 'urn:ietf:params:oauth:grant-type:saml1_1-bearer', + SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1, SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2, + WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1, + WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2 }.get(wstrust_result.get("type")) if not grant_type: raise RuntimeError( "RSTR returned unknown token type: %s", wstrust_result.get("type")) + self.client.grant_assertion_encoders.setdefault( # Register a non-standard type + grant_type, self.client.encode_saml_assertion) return self.client.obtain_token_by_assertion( - b64encode(wstrust_result["token"]), - grant_type=grant_type, scope=scopes, **kwargs) + wstrust_result["token"], grant_type, scope=scopes, **kwargs) class ConfidentialClientApplication(ClientApplication): # server-side web app def acquire_token_for_client(self, scopes, **kwargs): - """Acquires token from the service for the confidential client.""" + """Acquires token from the service for the confidential client. + + :param list[str] scopes: (Required) + Scopes requested to access a protected API (a resource). + + :return: A dict representing the json response from AAD: + + - A successful response would contain "access_token" key, + - an error response would contain "error" and usually "error_description". + """ # TBD: force_refresh behavior return self.client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration diff --git a/msal/authority.py b/msal/authority.py index 36d1ae43..e575fbe7 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -21,7 +21,9 @@ class Authority(object): Once constructed, it contains members named "*_endpoint" for this instance. TODO: It will also cache the previously-validated authority instances. """ - def __init__(self, authority_url, validate_authority=True): + def __init__(self, authority_url, validate_authority=True, + verify=True, proxies=None, timeout=None, + ): """Creates an authority instance, and also validates it. :param validate_authority: @@ -30,24 +32,31 @@ def __init__(self, authority_url, validate_authority=True): This parameter only controls whether an instance discovery will be performed. """ + self.verify = verify + self.proxies = proxies + self.timeout = timeout canonicalized, self.instance, tenant = canonicalize(authority_url) tenant_discovery_endpoint = ( # Hard code a V2 pattern as default value 'https://{}/{}/v2.0/.well-known/openid-configuration' .format(WORLD_WIDE, tenant)) if validate_authority and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS: tenant_discovery_endpoint = instance_discovery( - canonicalized + "/oauth2/v2.0/authorize") - openid_config = tenant_discovery(tenant_discovery_endpoint) + canonicalized + "/oauth2/v2.0/authorize", + verify=verify, proxies=proxies, timeout=timeout) + openid_config = tenant_discovery( + tenant_discovery_endpoint, + verify=verify, proxies=proxies, timeout=timeout) self.authorization_endpoint = openid_config['authorization_endpoint'] self.token_endpoint = openid_config['token_endpoint'] _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID self.is_adfs = self.tenant.lower() == 'adfs' - def user_realm_discovery(self, username, **kwargs): + def user_realm_discovery(self, username): resp = requests.get( "https://{netloc}/common/userrealm/{username}?api-version=1.0".format( netloc=self.instance, username=username), - headers={'Accept':'application/json'}, **kwargs) + headers={'Accept':'application/json'}, + verify=self.verify, proxies=self.proxies, timeout=self.timeout) resp.raise_for_status() return resp.json() # It will typically contain "ver", "account_type", @@ -64,17 +73,20 @@ def canonicalize(url): "https://login.microsoftonline.com/" % url) return match_object.group(0), match_object.group(1), match_object.group(2) -def instance_discovery(url, response=None): # Returns tenant discovery endpoint +def instance_discovery(url, response=None, **kwargs): + # Returns tenant discovery endpoint resp = requests.get( # Note: This URL seemingly returns V1 endpoint only 'https://{}/common/discovery/instance'.format(WORLD_WIDE), - params={'authorization_endpoint': url, 'api-version': '1.0'}) + params={'authorization_endpoint': url, 'api-version': '1.0'}, + **kwargs) payload = response or resp.json() if 'tenant_discovery_endpoint' not in payload: raise MsalServiceError(status_code=resp.status_code, **payload) return payload['tenant_discovery_endpoint'] -def tenant_discovery(tenant_discovery_endpoint): # Returns Openid Configuration - resp = requests.get(tenant_discovery_endpoint) +def tenant_discovery(tenant_discovery_endpoint, **kwargs): + # Returns Openid Configuration + resp = requests.get(tenant_discovery_endpoint, **kwargs) payload = resp.json() if 'authorization_endpoint' in payload and 'token_endpoint' in payload: return payload diff --git a/msal/oauth2cli/assertion.py b/msal/oauth2cli/assertion.py index 06187998..bd2373a7 100644 --- a/msal/oauth2cli/assertion.py +++ b/msal/oauth2cli/assertion.py @@ -7,7 +7,7 @@ import jwt -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) class Signer(object): def sign_assertion( diff --git a/msal/oauth2cli/authcode.py b/msal/oauth2cli/authcode.py index 6180ba4e..65eacfbf 100644 --- a/msal/oauth2cli/authcode.py +++ b/msal/oauth2cli/authcode.py @@ -21,7 +21,7 @@ from .oauth2 import Client -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) def obtain_auth_code(listen_port, auth_uri=None): """This function will start a web server listening on http://localhost:port diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 1a0d5bd2..e0096cb0 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -9,6 +9,7 @@ import logging import warnings import time +import base64 import requests @@ -18,12 +19,21 @@ class BaseClient(object): # This low-level interface works. Yet you'll find its sub-class # more friendly to remind you what parameters are needed in each scenario. # More on Client Types at https://tools.ietf.org/html/rfc6749#section-2.1 + + @staticmethod + def encode_saml_assertion(assertion): + return base64.urlsafe_b64encode(assertion).rstrip(b'=') # Per RFC 7522 + + CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + CLIENT_ASSERTION_TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer" + client_assertion_encoders = {CLIENT_ASSERTION_TYPE_SAML2: encode_saml_assertion} + def __init__( self, server_configuration, # type: dict client_id, # type: str client_secret=None, # type: Optional[str] - client_assertion=None, # type: Optional[str] + client_assertion=None, # type: Optional[bytes] client_assertion_type=None, # type: Optional[str] default_headers=None, # type: Optional[dict] default_body=None, # type: Optional[dict] @@ -45,14 +55,14 @@ def __init__( https://example.com/.../.well-known/openid-configuration client_id (str): The client's id, issued by the authorization server client_secret (str): Triggers HTTP AUTH for Confidential Client - client_assertion (str): + client_assertion (bytes): The client assertion to authenticate this client, per RFC 7521. + It can be a raw SAML2 assertion (this method will encode it for you), + or a raw JWT assertion. client_assertion_type (str): - The format of the client_assertion. - If you leave it as the default None, this method will try to make - a guess between SAML2 (RFC 7522) and JWT (RFC 7523), - the only two profiles defined in RFC 7521. - But you can also explicitly provide a value, if needed. + The type of your :attr:`client_assertion` parameter. + It is typically the value of :attr:`CLIENT_ASSERTION_TYPE_SAML2` or + :attr:`CLIENT_ASSERTION_TYPE_JWT`, the only two defined in RFC 7521. default_headers (dict): A dict to be sent in each request header. It is not required by OAuth2 specs, but you may use it for telemetry. @@ -66,12 +76,10 @@ def __init__( self.client_id = client_id self.client_secret = client_secret self.default_body = default_body or {} - if client_assertion is not None: # See https://tools.ietf.org/html/rfc7521#section-4.2 - if client_assertion_type is None: # RFC7521 defines only 2 profiles - TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer" - client_assertion_type = TYPE_JWT if "." in client_assertion else TYPE_SAML2 - self.default_body["client_assertion"] = client_assertion + if client_assertion is not None and client_assertion_type is not None: + # See https://tools.ietf.org/html/rfc7521#section-4.2 + encoder = self.client_assertion_encoders.get(client_assertion_type, lambda a: a) + self.default_body["client_assertion"] = encoder(client_assertion) self.default_body["client_assertion_type"] = client_assertion_type self.logger = logging.getLogger(__name__) self.session = s = requests.Session() @@ -172,6 +180,8 @@ class Client(BaseClient): # We choose to implement all 4 grants in 1 class DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down") GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522 GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523 + grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion} + def initiate_device_flow(self, scope=None, timeout=None, **kwargs): # type: (list, **dict) -> dict @@ -409,22 +419,20 @@ def obtain_token_by_refresh_token(self, token_item, scope=None, raise ValueError("token_item should not be a type %s" % type(token_item)) def obtain_token_by_assertion( - self, assertion, grant_type=None, scope=None, **kwargs): - # type: (str, Union[str, None], Union[str, list, set, tuple]) -> dict + self, assertion, grant_type, scope=None, **kwargs): + # type: (bytes, Union[str, None], Union[str, list, set, tuple]) -> dict """This method implements Assertion Framework for OAuth2 (RFC 7521). See details at https://tools.ietf.org/html/rfc7521#section-4.1 - :param assertion: The assertion string which will be sent on wire as-is + :param assertion: + The assertion bytes can be a raw SAML2 assertion, or a JWT assertion. :param grant_type: - If you leave it as the default None, this method will try to make - a guess between SAML2 (RFC 7522) and JWT (RFC 7523), - the only two profiles defined in RFC 7521. - But you can also explicitly provide a value, if needed. + It is typically either the value of :attr:`GRANT_TYPE_SAML2`, + or :attr:`GRANT_TYPE_JWT`, the only two profiles defined in RFC 7521. :param scope: Optional. It must be a subset of previously granted scopes. """ - if grant_type is None: - grant_type = self.GRANT_TYPE_JWT if "." in assertion else self.GRANT_TYPE_SAML2 + encoder = self.grant_assertion_encoders.get(grant_type, lambda a: a) data = kwargs.pop("data", {}) - data.update(scope=scope, assertion=assertion) + data.update(scope=scope, assertion=encoder(assertion)) return self._obtain_token(grant_type, data=data, **kwargs) diff --git a/msal/token_cache.py b/msal/token_cache.py index 7adcd333..116be878 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -7,6 +7,8 @@ from .authority import canonicalize +logger = logging.getLogger(__name__) + def is_subdict_of(small, big): return dict(big, **small) == big @@ -18,9 +20,9 @@ def base64decode(raw): # This can handle a padding-less raw input class TokenCache(object): """This is considered as a base class containing minimal cache behavior. - Although this class already maintains tokens using unified schema, - it does not serialize/persist them. See subclass SerializableTokenCache - for more details. + Although it maintains tokens using unified schema across all MSAL libraries, + this class does not serialize/persist them. + See subclass :class:`SerializableTokenCache` for details on serialization. """ class CredentialType: @@ -36,21 +38,33 @@ def __init__(self): def find(self, credential_type, target=None, query=None): target = target or [] assert isinstance(target, list), "Invalid parameter type" + target_set = set(target) with self._lock: + # Since the target inside token cache key is (per schema) unsorted, + # there is no point to attempt an O(1) key-value search here. + # So we always do an O(n) in-memory search. return [entry for entry in self._cache.get(credential_type, {}).values() if is_subdict_of(query or {}, entry) - and set(target) <= set(entry.get("target", []))] + and (target_set <= set(entry.get("target", "").split()) + if target else True) + ] - def add(self, event): + def add(self, event, now=None): # type: (dict) -> None # event typically contains: client_id, scope, token_endpoint, # resposne, params, data, grant_type - logging.debug("event=%s", json.dumps(event, indent=4)) + for sensitive in ("password", "client_secret"): + if sensitive in event.get("data", {}): + # Hide them from accidental exposure in logging + event["data"][sensitive] = "********" + logger.debug("event=%s", json.dumps(event, indent=4, sort_keys=True, + default=str, # A workaround when assertion is in bytes in Python 3 + )) response = event.get("response", {}) - access_token = response.get("access_token", {}) - refresh_token = response.get("refresh_token", {}) - id_token = response.get("id_token", {}) + access_token = response.get("access_token") + refresh_token = response.get("refresh_token") + id_token = response.get("id_token") client_info = {} home_account_id = None if "client_info" in response: @@ -59,6 +73,7 @@ def add(self, event): environment = realm = None if "token_endpoint" in event: _, environment, realm = canonicalize(event["token_endpoint"]) + target = ' '.join(event.get("scope", [])) # Per schema, we don't sort it with self._lock: @@ -69,20 +84,22 @@ def add(self, event): self.CredentialType.ACCESS_TOKEN, event.get("client_id", ""), realm or "", - ' '.join(sorted(event.get("scope", []))), + target, ]).lower() - now = time.time() + now = time.time() if now is None else now + expires_in = response.get("expires_in", 3599) self._cache.setdefault(self.CredentialType.ACCESS_TOKEN, {})[key] = { "credential_type": self.CredentialType.ACCESS_TOKEN, "secret": access_token, "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), - "target": event.get("scope"), + "target": target, "realm": realm, - "cached_at": now, - "expires_on": now + response.get("expires_in", 3599), - "extended_expires_on": now + response.get("ext_expires_in", 0), + "cached_at": str(int(now)), # Schema defines it as a string + "expires_on": str(int(now + expires_in)), # Same here + "extended_expires_on": str(int( # Same here + now + response.get("ext_expires_in", expires_in))), } if client_info: @@ -100,7 +117,10 @@ def add(self, event): "local_account_id": decoded_id_token.get( "oid", decoded_id_token.get("sub")), "username": decoded_id_token.get("preferred_username"), - "authority_type": "AAD", # Always AAD? + "authority_type": + "ADFS" if realm == "adfs" + else "MSSTS", # MSSTS means AAD v2 for both AAD & MSA + # "client_info": response.get("client_info"), # Optional } if id_token: @@ -110,6 +130,7 @@ def add(self, event): self.CredentialType.ID_TOKEN, event.get("client_id", ""), realm or "", + "" # Albeit irrelevant, schema requires an empty scope here ]).lower() self._cache.setdefault(self.CredentialType.ID_TOKEN, {})[key] = { "credential_type": self.CredentialType.ID_TOKEN, @@ -124,16 +145,14 @@ def add(self, event): if refresh_token: key = self._build_rt_key( home_account_id, environment, - event.get("client_id", ""), event.get("scope", [])) + event.get("client_id", ""), target) rt = { "credential_type": self.CredentialType.REFRESH_TOKEN, "secret": refresh_token, "home_account_id": home_account_id, "environment": environment, "client_id": event.get("client_id"), - # Fields below are considered optional - "target": event.get("scope"), - "client_info": response.get("client_info"), + "target": target, # Optional per schema though } if "foci" in response: rt["family_id"] = response["foci"] @@ -150,7 +169,7 @@ def _build_rt_key( cls.CredentialType.REFRESH_TOKEN, client_id or "", "", # RT is cross-tenant in AAD - ' '.join(sorted(target or [])), + target or "", # raw value could be None if deserialized from other SDK ]).lower() def remove_rt(self, rt_item): @@ -161,7 +180,8 @@ def remove_rt(self, rt_item): def update_rt(self, rt_item, new_rt): key = self._build_rt_key(**rt_item) with self._lock: - rt = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {})[key] + RTs = self._cache.setdefault(self.CredentialType.REFRESH_TOKEN, {}) + rt = RTs.get(key, {}) # key usually exists, but we'll survive its absence rt["secret"] = new_rt @@ -169,7 +189,8 @@ class SerializableTokenCache(TokenCache): """This serialization can be a starting point to implement your own persistence. This class does NOT actually persist the cache on disk/db/etc.. - Depends on your need, the following file-based persistence may be sufficient: + Depending on your need, + the following simple recipe for file-based persistence may be sufficient:: import atexit cache = SerializableTokenCache() @@ -181,9 +202,13 @@ class SerializableTokenCache(TokenCache): ) app = ClientApplication(..., token_cache=cache) ... + + :var bool has_state_changed: + Indicates whether the cache state has changed since last + :func:`~serialize` or :func:`~deserialize` call. """ - def add(self, event): - super(SerializableTokenCache, self).add(event) + def add(self, event, **kwargs): + super(SerializableTokenCache, self).add(event, **kwargs) self.has_state_changed = True def remove_rt(self, rt_item): @@ -206,5 +231,5 @@ def serialize(self): """Serialize the current cache state into a string.""" with self._lock: self.has_state_changed = False - return json.dumps(self._cache) + return json.dumps(self._cache, indent=4) diff --git a/msal/wstrust_request.py b/msal/wstrust_request.py index 52cbf3de..519ab704 100644 --- a/msal/wstrust_request.py +++ b/msal/wstrust_request.py @@ -27,7 +27,6 @@ import uuid from datetime import datetime, timedelta -import re import logging import requests @@ -36,7 +35,7 @@ from .wstrust_response import parse_response -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) def send_request( username, password, cloud_audience_urn, endpoint_address, soap_action, @@ -44,11 +43,9 @@ def send_request( if not endpoint_address: raise ValueError("WsTrust endpoint address can not be empty") if soap_action is None: - wstrust2005_regex = r'[/trust]?[2005][/usernamemixed]?' - wstrust13_regex = r'[/trust]?[13][/usernamemixed]?' - if re.search(wstrust2005_regex, endpoint_address): + if '/trust/2005/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_2005 - elif re.search(wstrust13_regex, endpoint_address): + elif '/trust/13/usernamemixed' in endpoint_address: soap_action = Mex.ACTION_13 assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005) # A loose check here data = _build_rst( diff --git a/msal/wstrust_response.py b/msal/wstrust_response.py index 00644b13..61458e04 100644 --- a/msal/wstrust_response.py +++ b/msal/wstrust_response.py @@ -37,6 +37,10 @@ SAML_TOKEN_TYPE_V1 = 'urn:oasis:names:tc:SAML:1.0:assertion' SAML_TOKEN_TYPE_V2 = 'urn:oasis:names:tc:SAML:2.0:assertion' +# http://docs.oasis-open.org/wss-m/wss/v1.1.1/os/wss-SAMLTokenProfile-v1.1.1-os.html#_Toc307397288 +WSS_SAML_TOKEN_PROFILE_V1_1 = "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" +WSS_SAML_TOKEN_PROFILE_V2 = "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0" + def parse_response(body): # Returns {"token": "", "type": "..."} token = parse_token_by_re(body) if token: @@ -84,6 +88,5 @@ def parse_token_by_re(raw_response): # Returns the saml:assertion token_types = findall_content(rstr, "TokenType") tokens = findall_content(rstr, "RequestedSecurityToken") if token_types and tokens: - assert token_types[0] in (SAML_TOKEN_TYPE_V1, SAML_TOKEN_TYPE_V2) return {"token": tokens[0].encode('us-ascii'), "type": token_types[0]} diff --git a/sample/client_credential_sample.py b/sample/client_credential_sample.py index cb5ccc26..5f539465 100644 --- a/sample/client_credential_sample.py +++ b/sample/client_credential_sample.py @@ -30,7 +30,8 @@ config["client_id"], authority=config["authority"], client_credential=config["secret"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -42,7 +43,7 @@ result = app.acquire_token_silent(config["scope"], account=None) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") result = app.acquire_token_for_client(scopes=config["scope"]) if "access_token" in result: diff --git a/sample/device_flow_sample.py b/sample/device_flow_sample.py index 182fcce9..8c46c6b0 100644 --- a/sample/device_flow_sample.py +++ b/sample/device_flow_sample.py @@ -4,7 +4,7 @@ { "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", - "scope": ["user.read"] + "scope": ["User.Read"] } You can then run this sample with a JSON configuration file: @@ -28,7 +28,8 @@ app = msal.PublicClientApplication( config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -39,7 +40,7 @@ # We now check the cache to see if we have some end users signed in before. accounts = app.get_accounts() if accounts: - # If so, you could then somehow display these accounts and let end user choose + logging.info("Account(s) exists in cache, probably with token too. Let's try.") print("Pick the account you want to use to proceed:") for a in accounts: print(a["username"]) @@ -49,7 +50,7 @@ result = app.acquire_token_silent(config["scope"], account=chosen) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") flow = app.initiate_device_flow(scopes=config["scope"]) print(flow["message"]) # Ideally you should wait here, in order to save some unnecessary polling diff --git a/sample/username_password_sample.py b/sample/username_password_sample.py index a34acaee..0137ae6e 100644 --- a/sample/username_password_sample.py +++ b/sample/username_password_sample.py @@ -5,7 +5,7 @@ "authority": "https://login.microsoftonline.com/organizations", "client_id": "your_client_id", "username": "your_username@your_tenant.com", - "scope": ["user.read"], + "scope": ["User.Read"], "password": "This is a sample only. You better NOT persist your password." } @@ -30,7 +30,8 @@ app = msal.PublicClientApplication( config["client_id"], authority=config["authority"], # token_cache=... # Default cache is in memory only. - # See SerializableTokenCache for more details. + # You can learn how to use SerializableTokenCache from + # https://msal-python.rtfd.io/en/latest/#msal.SerializableTokenCache ) # The pattern to acquire a token looks like this. @@ -39,11 +40,11 @@ # Firstly, check the cache to see if this end user has signed in before accounts = app.get_accounts(username=config["username"]) if accounts: - # It means the account(s) exists in cache, probably with token too. Let's try. + logging.info("Account(s) exists in cache, probably with token too. Let's try.") result = app.acquire_token_silent(config["scope"], account=accounts[0]) if not result: - # So no suitable token exists in cache. Let's get a new one from AAD. + logging.info("No suitable token exists in cache. Let's get a new one from AAD.") result = app.acquire_token_by_username_password( config["username"], config["password"], scopes=config["scope"]) diff --git a/tests/test_application.py b/tests/test_application.py index b9ca02c8..180bef50 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -109,6 +109,7 @@ def test_device_flow(self): self.app = PublicClientApplication( CONFIG["client_id"], authority=CONFIG["authority"]) flow = self.app.initiate_device_flow(scopes=CONFIG.get("scope")) + assert "user_code" in flow, str(flow) # Provision or policy might block DF logging.warn(flow["message"]) duration = 30 diff --git a/tests/test_authority.py b/tests/test_authority.py index 36583314..41714552 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -94,6 +94,6 @@ def test_instance_discovery_with_unknown_instance(self): def test_instance_discovery_with_mocked_response(self): mock_response = {'tenant_discovery_endpoint': 'http://a.com/t/openid'} endpoint = instance_discovery( - "https://login.microsoftonline.in/tenant.com", mock_response) + "https://login.microsoftonline.in/tenant.com", response=mock_response) self.assertEqual(endpoint, mock_response['tenant_discovery_endpoint']) diff --git a/tests/test_client.py b/tests/test_client.py index 5e72d8f4..8d4166e1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -97,6 +97,7 @@ def setUpClass(cls): audience=CONFIG["openid_configuration"]["token_endpoint"], issuer=CONFIG["client_id"], ), + client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, ) else: cls.client = Client( diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py new file mode 100644 index 00000000..eebd751d --- /dev/null +++ b/tests/test_token_cache.py @@ -0,0 +1,119 @@ +import logging +import base64 +import json + +from msal.token_cache import * +from tests import unittest + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + + +class TokenCacheTestCase(unittest.TestCase): + + def setUp(self): + self.cache = TokenCache() + + def testAdd(self): + client_info = base64.b64encode(b''' + {"uid": "uid", "utid": "utid"} + ''').decode('utf-8') + id_token = "header.%s.signature" % base64.b64encode(b'''{ + "sub": "subject", + "oid": "object1234", + "preferred_username": "John Doe" + }''').decode('utf-8') + self.cache.add({ + "client_id": "my_client_id", + "scope": ["s2", "s1", "s3"], # Not in particular order + "token_endpoint": "https://login.example.com/contoso/v2/token", + "response": { + "access_token": "an access token", + "token_type": "some type", + "expires_in": 3600, + "refresh_token": "a refresh token", + "client_info": client_info, + "id_token": id_token, + }, + }, now=1000) + self.assertEqual( + { + 'cached_at': "1000", + 'client_id': 'my_client_id', + 'credential_type': 'AccessToken', + 'environment': 'login.example.com', + 'expires_on': "4600", + 'extended_expires_on': "4600", + 'home_account_id': "uid.utid", + 'realm': 'contoso', + 'secret': 'an access token', + 'target': 's2 s1 s3', + }, + self.cache._cache["AccessToken"].get( + 'uid.utid-login.example.com-accesstoken-my_client_id-contoso-s2 s1 s3') + ) + self.assertEqual( + { + 'client_id': 'my_client_id', + 'credential_type': 'RefreshToken', + 'environment': 'login.example.com', + 'home_account_id': "uid.utid", + 'secret': 'a refresh token', + 'target': 's2 s1 s3', + }, + self.cache._cache["RefreshToken"].get( + 'uid.utid-login.example.com-refreshtoken-my_client_id--s2 s1 s3') + ) + self.assertEqual( + { + 'home_account_id': "uid.utid", + 'environment': 'login.example.com', + 'realm': 'contoso', + 'local_account_id': "object1234", + 'username': "John Doe", + 'authority_type': "MSSTS", + }, + self.cache._cache["Account"].get('uid.utid-login.example.com-contoso') + ) + self.assertEqual( + { + 'credential_type': 'IdToken', + 'secret': id_token, + 'home_account_id': "uid.utid", + 'environment': 'login.example.com', + 'realm': 'contoso', + 'client_id': 'my_client_id', + }, + self.cache._cache["IdToken"].get( + 'uid.utid-login.example.com-idtoken-my_client_id-contoso-') + ) + + +class SerializableTokenCacheTestCase(TokenCacheTestCase): + # Run all inherited test methods, and have extra check in tearDown() + + def setUp(self): + self.cache = SerializableTokenCache() + self.cache.deserialize(""" + { + "AccessToken": { + "an-entry": { + "foo": "bar" + } + }, + "customized": "whatever" + } + """) + + def tearDown(self): + state = self.cache.serialize() + logger.debug("serialize() = %s", state) + # Now assert all extended content are kept intact + output = json.loads(state) + self.assertEqual(output.get("customized"), "whatever", + "Undefined cache keys and their values should be intact") + self.assertEqual( + output.get("AccessToken", {}).get("an-entry"), {"foo": "bar"}, + "Undefined token keys and their values should be intact") +