diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c0180ff --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,205 @@ +# Copilot Instructions for Identity Library + +## Repository Overview + +This is the **Identity** library repository, a Python authentication/authorization library that provides high-level APIs for Microsoft identity platform integration. The library is built on top of Microsoft's MSAL Python and is designed specifically for web applications. + +### Key Facts +- **Language**: Python (3.8-3.12 supported) +- **Type**: Authentication/Authorization library +- **Target**: Web applications (Django, Flask, Quart) +- **Current Version**: 0.11.0 +- **License**: MIT +- **Package Name**: `identity` (available on PyPI) + +### Purpose +The library provides simplified APIs for: +- Microsoft Entra ID authentication +- Microsoft Entra External ID +- Azure AD B2C integration +- Web app sign-in/sign-out with automatic session renewal +- Access token acquisition for web APIs +- Incremental consent handling + +## Build Instructions & Environment Setup + +### Python Environment Setup +**ALWAYS configure the Python environment first before any operations:** +```bash +# The repository uses tox for environment management +# Configure Python environment using the configure_python_environment tool +# Or use tox which creates isolated environments automatically +``` + +### Installation & Dependencies +```bash +# 1. Install dependencies (REQUIRED before any other operations) +pip install -r requirements.txt + +# This installs the package in editable mode with ALL dependencies including: +# - Django, Flask, Quart web frameworks +# - MSAL, requests for authentication +# - pytest, pytest-asyncio for testing +``` + + +### Coding Style + +For existing source code, do not make modification only for coding style. +For newly added code snippets or lines that need to be changed in the current task, +follow these guidelines: + +- Follow PEP 8 guidelines for Python code. +- Use 4 spaces per indentation level. +- Keep lines ideally under 79 characters, don't exceed 100 characters, + unless the line contains a long url which we don't want to split into two lines. +- Use docstrings to describe public classes and methods. +- Include type hints for function signatures, using Python 3.9 syntax. + +### Running Tests +```bash +# Method 1: Direct pytest (after installing requirements.txt) +pytest + +# Method 2: Using tox (RECOMMENDED - creates isolated environment) +tox -e py3 + +# Tests are located in tests/ directory: +# - test_django.py: Django integration tests +# - test_flask.py: Flask integration tests +# - test_quart.py: Quart integration tests + +# Expected results: 9 tests pass with some deprecation warnings +``` + +### Building Documentation +```bash +# Install documentation dependencies +pip install -r docs/requirements.txt + +# Build docs using Sphinx (from repository root) +sphinx-build docs docs/_build + +# OR using tox (RECOMMENDED) +tox -e docs + +# Documentation outputs to docs/_build/ +``` + +### Type Checking +```bash +# Run type checking with mypy (NOTE: currently has known issues) +tox -e type + +# Known issues: Missing type stubs for external libraries +# These errors are expected and do not block development +``` + +### Building Package +```bash +# Clean previous builds +rm -rf build dist + +# Install build dependencies +pip install build + +# Build source and wheel distributions +python -m build --sdist --wheel --outdir dist/ . +``` + +## Project Layout & Architecture + +### Root Directory Structure +``` +├── identity/ # Main package source code +│ ├── __init__.py # Package initialization +│ ├── version.py # Version information (__version__ = "0.11.0") +│ ├── web.py # Core web framework-agnostic Auth class +│ ├── django.py # Django-specific integrations +│ ├── flask.py # Flask-specific integrations +│ ├── quart.py # Quart-specific integrations +│ ├── pallet.py # Shared utilities for Flask/Quart +│ └── templates/identity/ # HTML templates for login/error pages +├── tests/ # Test suite +├── docs/ # Sphinx documentation +├── .github/workflows/ # CI/CD pipeline +├── requirements.txt # Development dependencies +├── setup.cfg # Package configuration +├── pyproject.toml # Build system configuration +├── tox.ini # Testing environments +└── README.md # User documentation +``` + +### Key Configuration Files +- **setup.cfg**: Main package metadata and dependencies +- **pyproject.toml**: Build system (setuptools) +- **tox.ini**: Test environments and commands +- **requirements.txt**: Development dependencies with ALL web frameworks +- **.github/workflows/python-package.yml**: CI/CD pipeline + +### Core Architecture +1. **web.py**: Contains the main `Auth` class that is web framework-agnostic +2. **Framework-specific modules**: django.py, flask.py, quart.py provide decorators and helpers +3. **pallet.py**: Shared code between Flask and Quart (both use Pallets ecosystem) +4. **Templates**: Built-in HTML templates for authentication flows + +### Known Issues & TODOs +Based on code analysis, there are several TODOs in web.py: +- Line 231: "TODO: Reject a re-log-in with a different account?" +- Line 340: "TODO: Where shall token cache come from?" +- Line 433: "TODO: Support custom domain" for B2C + +## CI/CD & Validation Pipeline + +### GitHub Actions Workflow +Located in `.github/workflows/python-package.yml`: + +**Triggers**: Push to any branch, labeled PRs to dev branch + +**Test Matrix**: Python 3.8, 3.9, 3.10, 3.11, 3.12 on Ubuntu + +**Pipeline Steps**: +1. Install dependencies (pip, flake8, pytest, requirements.txt) +2. ~~Linting with flake8~~ (currently commented out) +3. Run pytest test suite +4. **Release Pipeline** (conditional): + - Triggers on tags or release-* branches + - Builds source and wheel distributions + - Publishes to TestPyPI (release branches) or PyPI (tags) + +### Pre-commit Validation +When making changes, ensure: +1. **Tests pass**: `tox -e py3` or `pytest` +2. **Documentation builds**: `tox -e docs` +3. **Type checking** (optional due to known issues): `tox -e type` + +### Dependencies by Framework +The package supports conditional installation: +- **Django**: `pip install "identity[django]"` +- **Flask**: `pip install "identity[flask]"` +- **Quart**: `pip install "identity[quart]"` +- **All (for docs)**: `pip install "identity[all_for_docs]"` + +### Working with the Repository + +**ALWAYS follow this sequence for development:** +1. Configure Python environment (already done if using tox) +2. Install requirements: `pip install -r requirements.txt` +3. Make your changes +4. Run tests: `tox -e py3` or `pytest` +5. Build docs if needed: `tox -e docs` +6. For releases: Update version in `identity/version.py` + +**Package Installation Patterns**: +- Development: `pip install -r requirements.txt` (editable install with all frameworks) +- Production: `pip install "identity[framework]"` where framework is django/flask/quart + +**Trust these instructions** - they have been validated against the actual repository structure and build process. Only search for additional information if these instructions are incomplete or incorrect for your specific task. + +### Framework-Specific Notes +- **Django**: Uses Django's session system, provides `@login_required` decorator +- **Flask**: Requires Flask-Session, provides `@login_required` decorator +- **Quart**: Async framework, uses Quart-Session, provides `@login_required` decorator +- **All frameworks**: Share the core `Auth` class from web.py for authentication logic + +The library is designed to minimize Microsoft identity platform integration complexity while supporting multiple Python web frameworks through a unified API. diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index fb1e532..c31c03b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: # See also https://endoflife.date/python - python-version: [3.8, 3.9, "3.10", 3.11, 3.12] + python-version: [3.9, "3.10", 3.11, 3.12, 3.13] steps: - uses: actions/checkout@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e137fad --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 991367b..a51e518 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,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: @@ -118,7 +118,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..07de9d2 --- /dev/null +++ b/docs/app-vs-api.rst @@ -0,0 +1,30 @@ +.. note:: + + Web Application (a.k.a. website) and Web API are different scenarios, + both supported by the same Identity Auth component with different decorators. + Make sure you are using the right decorator for your scenario. + +.. list-table:: + :header-rows: 1 + :widths: 20 40 40 + + * - Aspects + - Web Application (a.k.a. website) + - Web API + * - **Definition** + - A complete solution that users interact with directly through their browsers. + - A back-end system that provides data (typically in JSON format) to front-end or other system. + * - **Functionality** + - | - Users interact with views (HTML user interfaces) and data. + | - Users sign in and establish their sessions. + - | - Does not return views (in HTML); only provides data. + | - Other systems (clients) hit its endpoints. + | - Clients presents a token to access your API. + | - Each request has no session. They are stateless. + * - **Identity component** + - Same ``Auth`` class + - Same ``Auth`` class + * - **Decorator to use** + - ``@auth.login_required`` + - ``@auth.authorization_required`` + diff --git a/docs/django-webapi.rst b/docs/django-webapi.rst new file mode 100644 index 0000000..d826846 --- /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.Auth.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.Auth + :members: + :inherited-members: + + .. automethod:: __init__ + diff --git a/docs/django.rst b/docs/django.rst index 3e4dcb0..7c2870d 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..b6d05b4 --- /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.Auth` object, + and assign it to a global variable inside your ``app.py``:: + + import os + from flask import Flask + from identity.flask import Auth + + app = Flask(__name__) + auth = Auth( + 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.Auth.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.Auth + :members: + :inherited-members: + + .. automethod:: __init__ + diff --git a/docs/flask.rst b/docs/flask.rst index 8fc21c8..dbfc6e3 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..a42ca4d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -47,8 +47,11 @@ This Identity library is a Python authentication/authorization library that: :hidden: django + django-webapi flask + flask-webapi quart + quart-webapi abc generic @@ -60,3 +63,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/docs/quart-webapi.rst b/docs/quart-webapi.rst new file mode 100644 index 0000000..2a713c0 --- /dev/null +++ b/docs/quart-webapi.rst @@ -0,0 +1,67 @@ +Identity for a Quart Web API +============================== + +.. include:: app-vs-api.rst + +Prerequisite +------------ + +Create `a hello world web project in Quart `_. +Here we assume the project's main file is named ``app.py``. + + +Configuration +------------- + +#. Install dependency by ``pip install identity[quart]`` + +#. Create an instance of the :py:class:`identity.quart.Auth` object, + and assign it to a global variable inside your ``app.py``:: + + import os + from quart import Quart + from identity.quart import Auth + + app = Quart(__name__) + auth = Auth( + client_id=os.getenv('CLIENT_ID'), + ...=..., # See below on how to feed in the authority url parameter + ) + + .. include:: auth.rst + + +Quart Web API protected by an access token +------------------------------------------ + +#. In your web project's ``app.py``, decorate some views with the + :py:func:`identity.quart.Auth.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", + }) + async 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 follows the same pattern as +`Flask web API sample `_ +but uses async/await syntax for Quart. + +API for Quart web API projects +------------------------------ + +.. autoclass:: identity.quart.Auth + :members: + :inherited-members: + + .. automethod:: __init__ diff --git a/identity/django.py b/identity/django.py index c490fc4..94c69c9 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__) @@ -221,3 +222,17 @@ def wrapper(request, *args, **kwargs): scopes=scopes, ) return wrapper + + 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 848c65e..42877e0 100644 --- a/identity/flask.py +++ b/identity/flask.py @@ -1,6 +1,8 @@ +import functools from typing import List, Optional # Needed in Python 3.7 & 3.8 from flask import ( Blueprint, Flask, + abort, make_response, redirect, render_template, request, session, url_for, ) from flask_session import Session @@ -13,10 +15,12 @@ class Auth(PalletAuth): _Session = Session _redirect = redirect _url_for = url_for + _abort = abort + _make_response = make_response def __init__( self, - app: Optional[Flask], + app: Optional[Flask] = None, *args, post_logout_view: Optional[callable] = None, **kwargs, @@ -166,3 +170,10 @@ def call_an_api(*, context): ... """ return super(Auth, self).login_required(function, scopes=scopes) + + def raise_http_error(self, status_code, *, headers=None, description=None): + """Flask-specific implementation using Flask's make_response and abort.""" + response = self.__class__._make_response(description, status_code) + response.headers.extend(headers or {}) + self.__class__._abort(response) + diff --git a/identity/pallet.py b/identity/pallet.py index 0827bce..89886d2 100644 --- a/identity/pallet.py +++ b/identity/pallet.py @@ -129,3 +129,29 @@ def wrapper(*args, **kwargs): # Save an http 302 by calling self.login(request) instead of redirect(self.login) return self.login(next_link=self._request.url, scopes=scopes) return wrapper + + def authorization_required(self, *, expected_scopes, **kwargs): + def decorator(function): + if iscoroutinefunction(function): # For Quart + @wraps(function) + async def async_wrapper(*args, **kwargs): + try: + context = self._validate(self._request, expected_scopes=expected_scopes) + except Exception as e: + # Handle HttpError for Quart - convert to response + if hasattr(e, 'status_code'): + response = await self.__class__._make_response(e.description, e.status_code) + if e.headers: + response.headers.update(e.headers) + self.__class__._abort(response) + raise + return await function(*args, context=context, **kwargs) + return async_wrapper + else: # For Flask + @wraps(function) + def wrapper(*args, **kwargs): + context = self._validate(self._request, expected_scopes=expected_scopes) + return function(*args, context=context, **kwargs) + return wrapper + return decorator + diff --git a/identity/quart.py b/identity/quart.py index 7edf420..5800532 100644 --- a/identity/quart.py +++ b/identity/quart.py @@ -1,6 +1,7 @@ from typing import List, Optional # Needed in Python 3.7 & 3.8 from quart import ( Blueprint, Quart, + abort, make_response, redirect, render_template, request, session, url_for, ) from quart_session import Session @@ -13,10 +14,12 @@ class Auth(PalletAuth): _Session = Session _redirect = redirect _url_for = url_for + _abort = abort + _make_response = make_response def __init__( self, - app: Optional[Quart], + app: Optional[Quart] = None, *args, post_logout_view: Optional[callable] = None, **kwargs, @@ -165,3 +168,17 @@ async def call_api(*, context): """ return super(Auth, self).login_required(function, scopes=scopes) + + def raise_http_error(self, status_code, *, headers=None, description=None): + """Override to use HttpError exception instead of direct response creation. + + Unlike Flask's synchronous make_response and abort functions, Quart's + make_response is async and requires await. Since this method is called + from the synchronous _validate() method, we cannot await here. + + Instead, we raise an HttpError exception which is caught and properly + converted to a Quart response in the async wrapper of PalletAuth's + authorization_required decorator. + """ + from .web import HttpError + raise HttpError(status_code, headers=headers, description=description) diff --git a/identity/web.py b/identity/web.py index 48ffc9a..f2601bf 100644 --- a/identity/web.py +++ b/identity/web.py @@ -1,9 +1,13 @@ 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 ( + Optional, Union, # Needed until Python 3.10+ +) +import jwt # PyJWT import requests import msal @@ -11,6 +15,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 +88,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() @@ -99,7 +122,7 @@ def _save_user_into_session(self, id_token_claims): def log_in( self, *, - scopes: Optional[List[str]] = None, + scopes: Optional[list[str]] = None, redirect_uri: Optional[str] = None, state: Optional[str] = None, prompt: Optional[str] = None, @@ -287,17 +310,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, post_logout_redirect_uri: str) -> str: # The vocabulary is "log out" (rather than "sign out") in the specs # https://openid.net/specs/openid-connect-frontchannel-1_0.html @@ -314,9 +326,14 @@ def log_out(self, post_logout_redirect_uri: str) -> str: 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={post_logout_redirect_uri}" except requests.exceptions.RequestException: @@ -354,7 +371,236 @@ def _is_valid(id_token_claims, skew=None, seconds=None): else id_token_claims["iat"] + seconds) -class WebFrameworkAuth(ABC): # This is a mid-level helper to be subclassed +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): + _INVALID_REQUEST = "invalid_request" + _INVALID_TOKEN = "invalid_token" + _INSUFFICIENT_SCOPE = "insufficient_scope" + _ERROR_MISSING_AUTHORIZATION = "Authorization header is missing" + + def __init__( + self, + client_id: str, + *, + oidc_authority: Optional[str] = None, + authority: Optional[str] = None, + # Unlike web application's auth, this does not use session + ): + """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. + """ + self._client_id = client_id + 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): + """A web api will use this to emit http error response. + + 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=self._ERROR_MISSING_AUTHORIZATION, + ) + 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. UPDATE: No, OBO shall be done by ApiCaller(context, scope) + ): + # 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") + + +class WebFrameworkAuth( # This is a mid-level helper to be subclassed + ApiAuth, # After prototyping, we chose to have one class for web app and web API +): """This is a mid-level helper to be subclassed. Do not use it directly.""" def __init__( self, @@ -421,18 +667,18 @@ def __init__( Optional. """ - self._client_id = client_id + # self._client_id = client_id self._client_credential = client_credential self._redirect_uri = redirect_uri self._http_cache: dict = {} # All subsequent Auth instances will share this - self._authority: Optional[str] = None # It makes mypy happy + _authority: Optional[str] = None # It makes mypy happy # Note: We do not use overload, because we want to allow the caller to # have only one code path that relay in all the optional parameters. if b2c_tenant_name and b2c_signup_signin_user_flow: b2c_authority_template = ( # TODO: Support custom domain "https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/{user_flow}") - self._authority = b2c_authority_template.format( + _authority = b2c_authority_template.format( tenant=b2c_tenant_name, user_flow=b2c_signup_signin_user_flow, http_cache=self._http_cache, @@ -456,10 +702,12 @@ def __init__( http_cache=self._http_cache, ) if b2c_reset_password_user_flow else None else: - self._authority = authority + _authority = authority self._edit_profile_auth = None self._reset_password_auth = None - self._oidc_authority = oidc_authority + # self._oidc_authority = oidc_authority + # self._authority = self._authority or _authority + super().__init__(client_id, oidc_authority=oidc_authority, authority=_authority) def _get_configuration_error(self): # Do not raise exception, because @@ -483,7 +731,7 @@ def _build_auth(self, session) -> Auth: http_cache=self._http_cache, ) - def _login_required(self, auth: Auth, user: dict, scopes: List[str]): + def _login_required(self, auth: Auth, user: dict, scopes: list[str]): # Returns the context. This logic is reused in the login_required decorators. context = None if user: diff --git a/setup.cfg b/setup.cfg index 6a0a437..9c77b7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,11 +18,11 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 # NOTE: Settings of this section below this line should not need to be changed @@ -30,10 +30,14 @@ long_description = file: README.md long_description_content_type = text/markdown [options] -python_requires = >=3.8 +python_requires = >=3.9 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_django.py b/tests/test_django.py index 0fcf348..6df5453 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -44,19 +44,18 @@ def test_logout(): request = mock.MagicMock( build_absolute_uri=lambda relative_uri: f"http://localhost{relative_uri}" ) - with mock.patch('identity.web.requests.get', new=mock.MagicMock( - return_value=mock.MagicMock( - json=mock.MagicMock(return_value={ - "end_session_endpoint": "https://example.com/end_session", - }), - status_code=200, - ) - )): - response = Auth("client_id").logout(request) + with mock.patch('identity.web._http_get_json', return_value={ + "end_session_endpoint": "https://example.com/end_session", + }): + response = Auth("client_id", authority="https://example.com").logout(request) assert response.status_code == 302 assert response.url == "https://example.com/end_session?post_logout_redirect_uri=http://localhost/" - auth = Auth("client_id", post_logout_view=lambda r: "You have logged out") + auth = Auth( + "client_id", + authority="https://example.com", + post_logout_view=lambda r: "You have logged out", + ) with mock.patch('identity.django.reverse', return_value="/post_logout"): response = auth.logout(request) assert response.status_code == 302 diff --git a/tests/test_flask.py b/tests/test_flask.py index 6761448..6c264db 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -16,7 +16,10 @@ def app(): # https://flask.palletsprojects.com/en/3.0.x/testing/ # see also https://stackoverflow.com/questions/26080872 }) yield app - shutil.rmtree("flask_session") # clean up + try: + shutil.rmtree("flask_session") # clean up + except FileNotFoundError: + pass def build_auth(app, post_logout_view=None): return Auth( @@ -41,7 +44,7 @@ def post_logout_view(): app, post_logout_view=post_logout_view if customize_post_logout else None, ) - with patch.object(auth._auth, "_get_oidc_config", new=Mock(return_value={ + with patch("identity.web._http_get_json", new=Mock(return_value={ "end_session_endpoint": "https://example.com/end_session", })): with app.test_request_context("/", method="GET"): @@ -73,3 +76,25 @@ def dummy_view(): "http://localhost/app_root/path?foo=bar" # The full url ), "Next path should honor APPLICATION_ROOT" +def test_authorization(app): + auth = Auth(client_id="fake", oidc_authority="https://example.com/foo") + + @app.route("/path") + @auth.authorization_required(expected_scopes=["foo"]) + def dummy_view(): + return "content visible when authorized" + + with app.test_request_context("/path", method="GET"): + with app.test_client() as client: + result = client.get("/path") + assert result.status_code == 401 + assert "WWW-Authenticate" in result.headers + assert "Bearer" in result.headers["WWW-Authenticate"] + assert result.text == auth._ERROR_MISSING_AUTHORIZATION + + result = client.get("/path", headers={"Authorization": "Bearer h.b.s"}) + assert result.status_code == 401 + assert "WWW-Authenticate" in result.headers + assert "Bearer" in result.headers["WWW-Authenticate"] + assert result.text != auth._ERROR_MISSING_AUTHORIZATION + diff --git a/tests/test_quart.py b/tests/test_quart.py index 0f16864..7a3e7c7 100644 --- a/tests/test_quart.py +++ b/tests/test_quart.py @@ -37,3 +37,29 @@ def test_logout(): In the future, we might remove the session dependency anyway and revisit this. """ + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorization(): + app = Quart(__name__) + auth = Auth(client_id="fake", oidc_authority="https://example.com/foo") + + @app.route("/path") + @auth.authorization_required(expected_scopes=["foo"]) + async def dummy_view(*, context): + return "content visible when authorized" + + async with app.test_client() as client: + result = await client.get("/path") + assert result.status_code == 401 + assert "WWW-Authenticate" in result.headers + assert "Bearer" in result.headers["WWW-Authenticate"] + response_data = await result.get_data() + assert response_data.decode() == auth._ERROR_MISSING_AUTHORIZATION + + result = await client.get("/path", headers={"Authorization": "Bearer h.b.s"}) + assert result.status_code == 401 + assert "WWW-Authenticate" in result.headers + assert "Bearer" in result.headers["WWW-Authenticate"] + response_data = await result.get_data() + assert response_data.decode() != auth._ERROR_MISSING_AUTHORIZATION +