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. - Web App Calls a web API + Your Web App Calls a Web API on behalf of the user This library supports: @@ -98,7 +98,22 @@ They are demonstrated by the same samples above. - Web API Calls another web API (On-behalf-of) + Your Web API protected by an access token + + +By using this library, it will automatically emit +HTTP 401 or 403 error when the access token is absent or invalid. + +* Sample written in ![Django](https://raw.githubusercontent.com/rayluo/identity/dev/docs/django.webp)(Coming soon) +* [Sample written in ![Flask](https://raw.githubusercontent.com/rayluo/identity/dev/docs/flask.webp)](https://github.com/rayluo/python-webapi-flask.git) +* Need support for more web frameworks? + [Upvote existing feature request or create a new one](https://github.com/rayluo/identity/issues) + + + + + + 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",