diff --git a/README.md b/README.md
index c6bf342..09b7fcb 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ you can read its HTML source code, and find the how-to instructions there.
+ Your Web API Calls another web API on behalf of the user (OBO) |
In roadmap.
diff --git a/docs/app-vs-api.rst b/docs/app-vs-api.rst
new file mode 100644
index 0000000..2cc216d
--- /dev/null
+++ b/docs/app-vs-api.rst
@@ -0,0 +1,18 @@
+.. note::
+
+ Web Application (a.k.a. website) and Web API are different,
+ and are supported by different Identity components.
+ Make sure you are using the right component for your scenario.
+
+ +-------------------------+---------------------------------------------------+-------------------------------------------------------+
+ | Aspects | Web Application (a.k.a. website) | Web API |
+ +=========================+===================================================+=======================================================+
+ | **Definition** | A complete solution that users interact with | A back-end system that provides data (typically in |
+ | | directly through their browsers. | JSON format) to front-end or other system. |
+ +-------------------------+---------------------------------------------------+-------------------------------------------------------+
+ | **Functionality** | - Users interact with views (HTML user interfaces)| - Does not return views (in HTML); only provides data.|
+ | | and data. | - Other systems (clients) hit its endpoints. |
+ | | - Users sign in and establish their sessions. | - Clients presents a token to access your API. |
+ | | | - Each request has no session. They are stateless. |
+ +-------------------------+---------------------------------------------------+-------------------------------------------------------+
+
diff --git a/docs/django-webapi.rst b/docs/django-webapi.rst
new file mode 100644
index 0000000..165a27f
--- /dev/null
+++ b/docs/django-webapi.rst
@@ -0,0 +1,77 @@
+Identity for a Django Web API
+=============================
+
+.. include:: app-vs-api.rst
+
+Prerequisite
+------------
+
+Create a hello world web project in Django.
+
+You can use
+`Django's own tutorial, part 1 `_
+as a reference. What we need are basically these steps:
+
+#. ``django-admin startproject mysite``
+#. ``python manage.py migrate`` (Optinoal if your project does not use a database)
+#. ``python manage.py runserver localhost:5000``
+
+#. Now, add a new `mysite/views.py` file with an `index` view to your project.
+ For now, it can simply return a "hello world" page to any visitor::
+
+ from django.http import JsonResponse
+ def index(request):
+ return JsonResponse({"message": "Hello, world!"})
+
+Configuration
+-------------
+
+#. Install dependency by ``pip install identity[django]``
+
+#. Create an instance of the :py:class:`identity.django.Auth` object,
+ and assign it to a global variable inside your ``settings.py``::
+
+ import os
+ from identity.django import Auth
+ AUTH = Auth(
+ client_id=os.getenv('CLIENT_ID'),
+ ...=..., # See below on how to feed in the authority url parameter
+ )
+
+ .. include:: auth.rst
+
+
+Django Web API protected by an access token
+-------------------------------------------
+
+#. In your web project's ``views.py``, decorate some views with the
+ :py:func:`identity.django.ApiAuth.authorization_required` decorator::
+
+ from django.conf import settings
+
+ @settings.AUTH.authorization_required(expected_scopes={
+ "your_scope_1": "api://your_client_id/your_scope_1",
+ "your_scope_2": "api://your_client_id/your_scope_2",
+ })
+ def index(request, *, context):
+ claims = context['claims']
+ # The user is uniquely identified by claims['sub'] or claims["oid"],
+ # claims['tid'] and/or claims['iss'].
+ return JsonResponse(
+ {"message": f"Data for {claims['sub']}@{claims['tid']}"}
+ )
+
+
+All of the content above are demonstrated in
+`this django web app sample `_.
+
+
+API for Django web projects
+---------------------------
+
+.. autoclass:: identity.django.ApiAuth
+ :members:
+ :inherited-members:
+
+ .. automethod:: __init__
+
diff --git a/docs/django.rst b/docs/django.rst
index 9147462..a6d979e 100644
--- a/docs/django.rst
+++ b/docs/django.rst
@@ -1,5 +1,7 @@
-Identity for Django
-===================
+Identity for a Django Web App
+=============================
+
+.. include:: app-vs-api.rst
Prerequisite
------------
@@ -15,7 +17,7 @@ as a reference. What we need are basically these steps:
#. ``python manage.py runserver localhost:5000``
You must use a port matching your redirect_uri that you registered.
-#. Now, add an `index` view to your project.
+#. Now, add a new `mysite/views.py` file with an `index` view to your project.
For now, it can simply return a "hello world" page to any visitor::
from django.http import HttpResponse
@@ -23,7 +25,7 @@ as a reference. What we need are basically these steps:
return HttpResponse("Hello, world. Everyone can read this line.")
Configuration
----------------------------------
+-------------
#. Install dependency by ``pip install identity[django]``
diff --git a/docs/flask-webapi.rst b/docs/flask-webapi.rst
new file mode 100644
index 0000000..e9b6103
--- /dev/null
+++ b/docs/flask-webapi.rst
@@ -0,0 +1,67 @@
+Identity for a Flask Web API
+============================
+
+.. include:: app-vs-api.rst
+
+Prerequisite
+------------
+
+Create `a hello world web project in Flask `_.
+Here we assume the project's main file is named ``app.py``.
+
+
+Configuration
+-------------
+
+#. Install dependency by ``pip install identity[flask]``
+
+#. Create an instance of the :py:class:`identity.Flask.ApiAuth` object,
+ and assign it to a global variable inside your ``app.py``::
+
+ import os
+ from flask import Flask
+ from identity.flask import ApiAuth
+
+ app = Flask(__name__)
+ auth = ApiAuth(
+ client_id=os.getenv('CLIENT_ID'),
+ ...=..., # See below on how to feed in the authority url parameter
+ )
+
+ .. include:: auth.rst
+
+
+Flask Web API protected by an access token
+------------------------------------------
+
+#. In your web project's ``app.py``, decorate some views with the
+ :py:func:`identity.flask.ApiAuth.authorization_required` decorator.
+ It will automatically put validated token claims into the ``context`` dictionary,
+ under the key ``claims``.
+ or emit an HTTP 401 or 403 response if the token is missing or invalid.
+
+ ::
+
+ @app.route("/")
+ @auth.authorization_required(expected_scopes={
+ "your_scope_1": "api://your_client_id/your_scope_1",
+ "your_scope_2": "api://your_client_id/your_scope_2",
+ })
+ def index(*, context):
+ claims = context['claims']
+ # The user is uniquely identified by claims['sub'] or claims["oid"],
+ # claims['tid'] and/or claims['iss'].
+ return {"message": f"Data for {claims['sub']}@{claims['tid']}"}
+
+All of the content above are demonstrated in
+`this Flask web API sample `_.
+
+API for Flask web API projects
+------------------------------
+
+.. autoclass:: identity.flask.ApiAuth
+ :members:
+ :inherited-members:
+
+ .. automethod:: __init__
+
diff --git a/docs/flask.rst b/docs/flask.rst
index 014a777..09f86f2 100644
--- a/docs/flask.rst
+++ b/docs/flask.rst
@@ -1,5 +1,7 @@
-Identity for Flask
-==================
+Identity for a Flask Web App
+============================
+
+.. include:: app-vs-api.rst
Prerequisite
------------
@@ -9,7 +11,7 @@ Here we assume the project's main file is named ``app.py``.
Configuration
---------------------------------
+-------------
#. Install dependency by ``pip install identity[flask]``
diff --git a/docs/index.rst b/docs/index.rst
index 4ad1835..d213b56 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -47,7 +47,9 @@ This Identity library is a Python authentication/authorization library that:
:hidden:
django
+ django-webapi
flask
+ flask-webapi
quart
abc
generic
@@ -60,3 +62,9 @@ This Identity library is a Python authentication/authorization library that:
Other modules in the source code are all considered as internal helpers,
which could change at anytime in the future, without prior notice.
+This library is designed to be used in either a web app or a web API.
+Understand the difference between the two scenarios,
+before you choose the right component to build your project.
+
+.. include:: app-vs-api.rst
+
diff --git a/identity/django.py b/identity/django.py
index ae943ba..c2d6202 100644
--- a/identity/django.py
+++ b/identity/django.py
@@ -6,8 +6,9 @@
from django.shortcuts import redirect, render
from django.urls import include, path, reverse
+from django.http import HttpResponse
-from .web import WebFrameworkAuth
+from .web import WebFrameworkAuth, HttpError, ApiAuth as _ApiAuth
logger = logging.getLogger(__name__)
@@ -185,3 +186,18 @@ def wrapper(request, *args, **kwargs):
)
return wrapper
+
+class ApiAuth(_ApiAuth):
+ def authorization_required(self, *, expected_scopes, **kwargs):
+ def decorator(function):
+ @wraps(function)
+ def wrapper(request, *args, **kwargs):
+ try:
+ context = self._validate(request, expected_scopes=expected_scopes)
+ except HttpError as e:
+ return HttpResponse(
+ e.description, status=e.status_code, headers=e.headers)
+ return function(request, *args, context=context, **kwargs)
+ return wrapper
+ return decorator
+
diff --git a/identity/flask.py b/identity/flask.py
index 222eec2..e551d4b 100644
--- a/identity/flask.py
+++ b/identity/flask.py
@@ -1,11 +1,14 @@
from typing import List, Optional # Needed in Python 3.7 & 3.8
from flask import (
Blueprint, Flask,
+ abort, make_response, # Used in ApiAuth
redirect, render_template, request, session, url_for,
)
from flask_session import Session
from .pallet import PalletAuth
+from .web import WebFrameworkAuth, ApiAuth as _ApiAuth
+
class Auth(PalletAuth):
"""A long-live identity auth helper for a Flask web project."""
@@ -153,3 +156,19 @@ def call_an_api(*, context):
"""
return super(Auth, self).login_required(function, scopes=scopes)
+
+class ApiAuth(_ApiAuth):
+ def raise_http_error(self, status_code, *, headers=None, description=None):
+ response = make_response(description, status_code)
+ response.headers.extend(headers or {})
+ abort(response)
+
+ def authorization_required(self, *, expected_scopes, **kwargs):
+ def decorator(function):
+ @wraps(function)
+ def wrapper(*args, **kwargs):
+ context = self._validate(request, expected_scopes=expected_scopes)
+ return function(*args, context=context, **kwargs)
+ return wrapper
+ return decorator
+
diff --git a/identity/web.py b/identity/web.py
index 5d5566f..9173306 100644
--- a/identity/web.py
+++ b/identity/web.py
@@ -1,9 +1,14 @@
from abc import ABC, abstractmethod
import functools
+import json
import logging
import time
-from typing import List, Optional # Needed in Python 3.7 & 3.8
+from typing import (
+ List, Dict, Callable, Optional, # Needed in Python 3.7 & 3.8
+ Union, # Needed until Python 3.10+
+)
+import jwt # PyJWT
import requests
import msal
@@ -11,6 +16,24 @@
logger = logging.getLogger(__name__)
+def _get_http_client(): # Better reuse the result of this function to save resources
+ http_client = requests.Session()
+ a = requests.adapters.HTTPAdapter(
+ # Web app/API shall use minimal retry;
+ # let the calling client decide their own retry strategy
+ max_retries=1)
+ http_client.mount("http://", a)
+ http_client.mount("https://", a)
+ return http_client
+
+@functools.lru_cache(maxsize=128)
+def __http_get_json(http_client, url, timestamp):
+ return http_client.get(url).json()
+
+def _http_get_json(url, *, http_client): # Get JSON from a URL or a one-day cache
+ return __http_get_json(http_client, url, time.strftime("%x"))
+
+
class Auth(object): # This a low level helper which is web framework agnostic
# These key names are hopefully unique in session
_TOKEN_CACHE = "_token_cache"
@@ -66,6 +89,7 @@ def __init__(
self._client_id = client_id
self._client_credential = client_credential
self._http_cache = {} if http_cache is None else http_cache # All subsequent MSAL instances will share this
+ self._http_client = _get_http_client()
def _load_cache(self):
cache = msal.SerializableTokenCache()
@@ -287,17 +311,6 @@ def _get_token_for_user(self, scopes, force_refresh=None):
return result
return {"error": "interaction_required", "error_description": "Cache missed"}
- @functools.lru_cache(maxsize=1)
- def _get_oidc_config(self):
- # The self._authority is usually the V1 endpoint of Microsoft Entra ID,
- # which is still good enough for log_out()
- a = self._oidc_authority or self._authority
- conf = requests.get(f"{a}/.well-known/openid-configuration").json()
- if not conf.get(self._END_SESSION_ENDPOINT):
- logger.warning(
- "%s not found from OIDC config: %s", self._END_SESSION_ENDPOINT, conf)
- return conf
-
def log_out(self, homepage):
# The vocabulary is "log out" (rather than "sign out") in the specs
# https://openid.net/specs/openid-connect-frontchannel-1_0.html
@@ -314,9 +327,14 @@ def log_out(self, homepage):
self._session.pop(self._USER, None) # Must
self._session.pop(self._TOKEN_CACHE, None) # Optional
try:
- # Empirically, Microsoft Entra ID's /v2.0 endpoint shows an account picker
- # but its default (i.e. v1.0) endpoint will sign out the (only?) account
- endpoint = self._get_oidc_config().get(self._END_SESSION_ENDPOINT)
+ # The self._authority ends up w/ the V1 endpoint of Microsoft Entra ID,
+ # which is still good enough for log_out()
+ authority = self._oidc_authority or self._authority
+ endpoint = _http_get_json(
+ # Empirically, Microsoft Entra ID /v2 endpoint shows an account picker
+ # but its default (i.e. v1) endpoint will sign out the (only?) account
+ f"{authority}/.well-known/openid-configuration",
+ http_client=self._http_client).get("end_session_endpoint")
if endpoint:
return f"{endpoint}?post_logout_redirect_uri={homepage}"
except requests.exceptions.RequestException:
@@ -535,3 +553,232 @@ def _render_auth_error(
# The default auth_error.html template may or may not escape.
# If a web framework does not escape it by default, a subclass shall escape it.
pass
+
+
+class HttpError(Exception):
+ def __init__(self, status_code, *, headers, description=None):
+ self.status_code = status_code
+ self.headers = headers
+ self.description = description
+
+
+class ApiAuth(ABC): # Unlike Auth, this does not use session
+ _INVALID_REQUEST = "invalid_request"
+ _INVALID_TOKEN = "invalid_token"
+ _INSUFFICIENT_SCOPE = "insufficient_scope"
+
+ def __init__(
+ self,
+ *,
+ client_id,
+ oidc_authority=None,
+ authority=None,
+ client_credential=None,
+ ):
+ """Create an ApiAuth instance for a web API.
+
+ This instance is expected to be long-lived with the web app.
+
+ :param str oidc_authority:
+ The authority which your app registers with your OpenID Connect provider.
+ For example, ``https://example.com/foo``.
+ This library will concatenate ``/.well-known/openid-configuration``
+ to form the metadata endpoint.
+
+ :param str authority:
+ The authority which your app registers with your Microsoft Entra ID.
+ For example, ``https://example.com/foo``.
+ Historically, the underlying library will *sometimes* automatically
+ append "/v2.0" to it.
+ If you do not want that behavior, you may use ``oidc_authority`` instead.
+
+ :param str client_id:
+ The client_id of your web app, issued by its authority.
+
+ :param str client_credential:
+ It is somtimes a string.
+ The actual format is decided by the underlying auth library. TBD.
+ """
+ self._client_id = client_id
+ self._client_credential = client_credential
+ self._oidc_authority = oidc_authority
+ self._authority = authority
+ self._realm = oidc_authority or authority
+ self._http_client = _get_http_client()
+
+ def raise_http_error(self, status_code, *, headers=None, description=None):
+ # This can be overridden by a subclass for each web framework.
+ # If a web framework (Django) does not have a way to raise an HTTP error,
+ # its subclass or app must catch HttpError and render a response accordingly.
+ raise HttpError(status_code, headers=headers, description=description)
+
+ def __raise_oauth2_error(
+ self,
+ error_code: str,
+ *,
+ error_description: str = None,
+ error_uri: str = None,
+ scopes: List[str] = None,
+ ):
+ # https://datatracker.ietf.org/doc/html/rfc6750#section-3
+ auth_params = ", ".join(
+ '{k}: "{v}"'.format(k=k, v=v.replace('"', "'")) for k, v in dict(
+ realm=self._realm,
+ error=error_code,
+ error_description=error_description,
+ error_uri=error_uri,
+ scope=' '.join(scopes) if scopes else None,
+ ).items() if v and isinstance(v, str))
+ self.raise_http_error(
+ { # https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
+ self._INVALID_REQUEST: 400,
+ self._INVALID_TOKEN: 401,
+ self._INSUFFICIENT_SCOPE: 403,
+ }.get(error_code, 400),
+ headers={"WWW-Authenticate": f'Bearer {auth_params}'},
+ description=f"{error_code}: {error_description}",
+ )
+
+ def _oidc_discovery(self):
+ idp = self._oidc_authority or f"{self._authority}/v2.0" # Matching MSAL behavior
+ oidc_discovery = f"{idp}/.well-known/openid-configuration"
+ logger.debug("OIDC Discovery from %s (probably via cache)", oidc_discovery)
+ return _http_get_json(oidc_discovery, http_client=self._http_client)
+
+ @functools.lru_cache(maxsize=128)
+ def __get_keys(self, timestamp):
+ # Returns the keys from the authority's jwks_uri, remap to {kid: PyJWT's key}
+ if not (self._oidc_authority or self._authority):
+ self.raise_http_error( # So the API errors out gracefully
+ 500, description="No authority to fetch keys from")
+ idp = self._oidc_authority or f"{self._authority}/v2.0" # Matching MSAL behavior
+ try:
+ conf = self._oidc_discovery()
+ try:
+ return {
+ jwk["kid"]: dict( # Empirically, kid always exists in the wild
+ jwk, # https://www.rfc-editor.org/rfc/rfc7517.html#section-4.5
+ # Inspired from https://stackoverflow.com/a/68891371/728675
+ pyjwt_key=jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)),
+ ) for jwk in _http_get_json(
+ conf["jwks_uri"], http_client=self._http_client)["keys"]
+ }
+ except KeyError as e:
+ self.raise_http_error(500, description=f'Key should have "kid": {e}')
+ except requests.exceptions.RequestException as e:
+ self.raise_http_error(500, description=f"Failed to get keys: {e}")
+
+ def _get_keys(self):
+ return self.__get_keys(time.strftime("%x")) # Refresh keys daily
+
+ def _validate_bearer_token(
+ self,
+ token:str,
+ *,
+ scopes: Union[List[str], Dict[str, str]],
+ ):
+ # Return claims of the JWT if valid, otherwise calls __raise_oauth2_error()
+ try:
+ kid = jwt.get_unverified_header(token)["kid"]
+ key = self._get_keys().get(kid)
+ if not key:
+ self.__raise_oauth2_error(
+ self._INVALID_TOKEN,
+ error_description=f"Key not found for kid {kid}")
+ claims = jwt.decode(
+ token,
+ key["pyjwt_key"],
+ algorithms=["RS256"], # Hardcode it to prevent downgrade attack
+ audience=self._client_id,
+ ) # https://pyjwt.readthedocs.io/en/stable/api.html#jwt.decode
+
+ expected_scopes = set(scopes or [])
+ authorized_scopes = set(claims.get("scp", "").split())
+ if expected_scopes - authorized_scopes:
+ # TODO: It could also be daemon app. Need to support actor validation
+ # https://learn.microsoft.com/en-us/entra/identity-platform/claims-validation#validate-the-actor
+ self.__raise_oauth2_error(
+ self._INSUFFICIENT_SCOPE,
+ error_description="Insufficient scope(s). "
+ f'''This API expects "{' '.join(expected_scopes)}", '''
+ f'''but got only "{' '.join(authorized_scopes)}".''',
+ # scopes can be a dict of {"scope_in_scp": "scope in request"}
+ scopes=scopes.values() if isinstance(scopes, dict) else scopes)
+
+ # https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#recap
+ expected_issuer = key.get("issuer") or self._oidc_discovery()["issuer"]
+ if self._authority and "tid" in claims: # Microsoft Entra ID code path
+ expected_issuer = expected_issuer.replace("{tenantid}", claims["tid"])
+ if claims.get("iss") != expected_issuer:
+ self.__raise_oauth2_error(
+ self._INVALID_TOKEN, error_description="Issuer mismatch. "
+ f"(Expected {expected_issuer}, got {claims.get('iss')})")
+ return claims
+
+ except (
+ jwt.DecodeError, jwt.InvalidSignatureError, jwt.InvalidAudienceError,
+ ) as e:
+ self.__raise_oauth2_error(self._INVALID_TOKEN, error_description=f"{e}")
+
+ def _validate(
+ self,
+ request, # We expect request.headers to be a dict-like object
+ *,
+ expected_scopes, # Defined in _validate_bearer_token(),
+ # documented in authorization_required()
+ ):
+ authz = request.headers.get("Authorization")
+ # https://datatracker.ietf.org/doc/html/rfc6750#section-3
+ if not authz:
+ self.raise_http_error(
+ 401, # https://stackoverflow.com/a/6937030/728675
+ headers={"WWW-Authenticate": f'Bearer realm="{self._realm}"'},
+ description="Authorization header is missing",
+ )
+ authz_parts = authz.split(maxsplit=1)
+ scheme = authz_parts[0]
+ params = authz_parts[1:] # May be an empty list
+ if scheme == "Bearer" and params:
+ context = {
+ "claims": # TODO: Decide on the name
+ self._validate_bearer_token(
+ params[0], scopes=expected_scopes),
+ }
+ # TODO: Support more schemes
+ else:
+ self.raise_http_error(
+ 401,
+ headers={"WWW-Authenticate": f'Bearer realm="{self._realm}"'},
+ description=f"Authorization header is invalid. ({authz})",
+ )
+ return context
+
+ @abstractmethod
+ def authorization_required( # Lengthy but precise name
+ self,
+ *,
+ expected_scopes:List[str], # TODO: Accept a callable in RESTful API scenario?
+ #extra_scopes: List[str]=None, # TODO: For OBO
+ ):
+ # Sub-classes inherit the docstring, so we only document the commen params.
+ """It returns a decorator that verifies the request's authorization header.
+
+ A request not meeting the requirement(s) will raise an HTTP 401 Unauthorized.
+ For a valid request, the view will be called with a keyword argument
+ named "context" which is a dict containing the user object.
+
+ Usage::
+
+ @settings.AUTH.authorization_required(expected_scopes=["foo", "bar"])
+ def resource(..., *, context): # The ... part is differnt per web framework
+ # The user is uniquely identified by claims['sub'] or claims["oid"],
+ # claims['tid'] and/or claims['iss'].
+ claims = context["claims"]
+ return {"content": f"content for {claims['sub']}@{claims['tid']}"}
+
+ :param expected_scopes:
+ These scopes are expected to be present in the token's "scp" claim.
+ If not, the view will emit an HTTP 401 Unauthorized or HTTP 403 Forbidden.
+ """
+ raise NotImplementedError("Subclass must implement this method")
+
diff --git a/setup.cfg b/setup.cfg
index 6a0a437..b14ef4b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -34,6 +34,10 @@ python_requires = >=3.8
install_requires =
msal>=1.28,<2
requests>=2.0.0,<3
+
+ # CVE-2022-29217 was fixed in PyJWT 2.4+
+ PyJWT[crypto]>=2.4,<3
+
# importlib; python_version == "2.6"
# See also https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#dependency-management
diff --git a/tests/test_flask.py b/tests/test_flask.py
index a63e113..46ec04f 100644
--- a/tests/test_flask.py
+++ b/tests/test_flask.py
@@ -27,14 +27,14 @@ def auth(app):
oidc_authority="https://example.com/foo",
)
+@patch("identity.web._http_get_json", new=Mock(return_value={
+ "end_session_endpoint": "https://example.com/end_session",
+}))
def test_logout(app, auth):
- with patch.object(auth._auth, "_get_oidc_config", new=Mock(return_value={
- "end_session_endpoint": "https://example.com/end_session",
- })):
- with app.test_request_context("/", method="GET"):
- homepage = "http://localhost/app_root"
- assert homepage in auth.logout().get_data(as_text=True), (
- "The homepage should be in the logout URL. There was a bug in 0.9.0.")
+ with app.test_request_context("/", method="GET"):
+ homepage = "http://localhost/app_root"
+ assert homepage in auth.logout().get_data(as_text=True), (
+ "The homepage should be in the logout URL. There was a bug in 0.9.0.")
@patch("msal.authority.tenant_discovery", new=Mock(return_value={
"authorization_endpoint": "https://example.com/placeholder",
|