From 39661637f59d54dd1162167e6b31e8ea032c93ba Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Sat, 24 Feb 2024 13:27:31 -0800 Subject: [PATCH] Refactor django.py to have a common base class --- README.md | 4 +- docs/auth.rst | 24 +++++ docs/django.rst | 106 ++++++++++++++++++++ docs/generic.rst | 59 +++++++++++ docs/index.rst | 178 ++++++---------------------------- identity/django.py | 237 +++++---------------------------------------- identity/web.py | 170 +++++++++++++++++++++++++++++++- 7 files changed, 413 insertions(+), 365 deletions(-) create mode 100644 docs/auth.rst create mode 100644 docs/django.rst create mode 100644 docs/generic.rst diff --git a/README.md b/README.md index 05c0435..02c98d8 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ This Identity library is an authentication/authorization library that: ## Scenarios supported -* [Web app that logs in users](https://identity-library.readthedocs.io/en/latest/#web-app-that-logs-in-users) -* [Web app that logs in users and calls a web API on their behalf](https://identity-library.readthedocs.io/en/latest/#web-app-that-logs-in-users-and-calls-a-web-api-on-their-behalf) +* [Web app that logs in users](https://identity-library.readthedocs.io/en/latest/) +* [Web app that logs in users and calls a web API on their behalf](https://identity-library.readthedocs.io/en/latest/) * [In roadmap] Protected web API that only authenticated users can access * [In roadmap] Protected web API that calls another (downstream) web API on behalf of the signed-in user diff --git a/docs/auth.rst b/docs/auth.rst new file mode 100644 index 0000000..e3e41d2 --- /dev/null +++ b/docs/auth.rst @@ -0,0 +1,24 @@ +.. tip:: + + We recommend + `storing settings in environment variables. `_. + The snippet above read data from environment variables. + +.. + This is a comment. + The table below was built via https://tableconvert.com/restructuredtext-generator + +.. admonition:: Initializing Auth object differently based on Identity Provider type + + +------------------------------------------------+-----------------------------------------------------------+--------------------------------------------------------------------------+ + | | Its authority URL looks like | Initialize Auth() object like this | + +================================================+===========================================================+==========================================================================+ + | Microsoft Entra ID | ``https://login.microsoftonline.com/tenant`` | Auth(..., authority=url, ...) | + +------------------------------------------------+-----------------------------------------------------------+ + + | Microsoft Entra External ID | ``https://contoso.ciamlogin.com/contoso.onmicrosoft.com`` | | + +------------------------------------------------+-----------------------------------------------------------+--------------------------------------------------------------------------+ + | Microsoft Entra External ID with Custom Domain | ``https://contoso.com/tenant`` | Auth(..., oidc_authority=url, ...) | + +------------------------------------------------+-----------------------------------------------------------+--------------------------------------------------------------------------+ + | Azure AD B2C | N/A | Auth(..., b2c_tenant_name="contoso", b2c_signup_signin_user_flow="susi") | + +------------------------------------------------+-----------------------------------------------------------+--------------------------------------------------------------------------+ + diff --git a/docs/django.rst b/docs/django.rst new file mode 100644 index 0000000..f779003 --- /dev/null +++ b/docs/django.rst @@ -0,0 +1,106 @@ +Identity for Django +=================== + +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: + +1. ``django-admin startproject mysite`` +2. ``python manage.py migrate`` +3. ``python manage.py runserver localhost:5000`` + You must use a port matching your redirect_uri that you registered. + +4. Now, add an `index` view to your project. + For now, it can simply return a "hello world" page to any visitor:: + + from django.http import HttpResponse + def index(request): + return HttpResponse("Hello, world. Everyone can read this line.") + +Django configuration +-------------------- + +1. Firstly, 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 dotenv import load_dotenv + from identity.django import Auth + load_dotenv() + AUTH = Auth( + os.getenv('CLIENT_ID'), + client_credential=os.getenv('CLIENT_SECRET'), + redirect_uri=os.getenv('REDIRECT_URI'), + ..., # See below on how to feed in the authority url parameter + ) + + .. include:: auth.rst + +2. Inside the same ``settings.py`` file, + add ``"identity"`` into the ``INSTALLED_APPS`` list, + to enable the default templates came with the identity package:: + + INSTALLED_APPS = [ + ..., + "identity", + ] + +3. Add the built-in views into your ``urls.py``:: + + from django.conf import settings + + urlpatterns = [ + settings.AUTH.urlpattern, + ... + ] + +Django Web App Sign In +---------------------- + +4. In your web project's ``views.py``, decorate some views with the + :py:func:`identity.django.Auth.login_required` decorator:: + + from django.conf import settings + + @settings.AUTH.login_required + def index(request, *, context): + user = context['user'] + return HttpResponse(f"Hello, {user.get('name')}.") + +Web app that logs in users and calls a web API on their behalf +-------------------------------------------------------------- + +5. Decorate your token-consuming views using the same + :py:func:`identity.django.Auth.login_required` decorator, + this time with a parameter ``scopes=["your_scope_1", "your_scope_2"]``. + + Then, inside your view, the token will be readily available via + ``context['access_token']``. For example:: + + @settings.AUTH.login_required(scopes=["your_scope"]) + def call_api(request, *, context): + api_result = requests.get( # Use access token to call a web api + "https://your_api.example.com", + headers={'Authorization': 'Bearer ' + context['access_token']}, + timeout=30, + ).json() # Here we assume the response format is json + ... + +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/generic.rst b/docs/generic.rst new file mode 100644 index 0000000..12dc781 --- /dev/null +++ b/docs/generic.rst @@ -0,0 +1,59 @@ +Low-level API for generic web projects +====================================== + +Web app that logs in users +-------------------------- + +1. Firstly, create an instance of the :py:class:`identity.web.Auth` object, + and assign it to a (typically global) variable:: + + auth = identity.web.Auth( + session=session, # A session object is a key-value storage with a + # dict-like interface. Many web frameworks provide this. + authority="https://login.microsoftonline.com/common", + client_id="your_app_client_id", + client_credential="your_secret", + ) + +2. Now, in your web app's login controller, call the + ``auth.log_in(scopes=["your_scope"], redirect_uri="https://your_app.example.com/redirect_uri")`` + (see also :py:meth:`.log_in`) + to obtain the ``auth_uri`` (and possibly a ``user_code``), + and then render them into your login html page. + +3. The second leg of log-in needs to be implemented in another controller, + which calls ``auth.complete_log_in(incoming_query_parameters)`` + (see also :py:meth:`.complete_log_in`). + If its returned dict contains an ``error``, then render the error to end user, + otherwise your end user has successfully logged in, + and his/her information is available as a dict returned by + :meth:`identity.web.Auth.get_user`. + In particular, the returned dict contains a key named ``sub``, + whose value is the unique identifier which you can use to represent this end user + in your app's local database. + +4. Don't forget to add one more controller for log out. You do it by calling + ``auth.log_out("https://your_app.example.com")``. + Please refer to :meth:`.log_out`'s docs for more details about its return value. + +All of the content above are demonstrated in this sample (link to be provided). + + +Web app that logs in users and calls a web API on their behalf +-------------------------------------------------------------- + +Building on top of the previous scenario, you just need to call +``auth.get_token_for_user(["your_scope"])`` to obtain a token object. +See :py:meth:`identity.web.Auth.get_token_for_user` for more details. +And you can see it in action in this sample (link to be provided). + + +Generic API, currently used for Flask web apps +---------------------------------------------- + +.. autoclass:: identity.web.Auth + :members: + :inherited-members: + + .. automethod:: __init__ + diff --git a/docs/index.rst b/docs/index.rst index 907aa49..b6ee827 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,171 +7,51 @@ Summary .. The following summary is reused in, and needs to be in-sync with, the ../README.md -This Identity library is an authentication/authorization library that: +This Identity library is a Python authentication/authorization library that: * Suitable for apps that are targeting end users on - `Microsoft identity platform `_. - (which includes Work or school accounts provisioned through Azure AD, - and Personal Microsoft accounts such as Skype, Xbox, Outlook.com). + `Microsoft identity platform `_, which includes: -* Currently designed for web apps, - regardless of which Python web framework you are using. + - Microsoft Entra ID + (including Work or school accounts provisioned through Azure AD, + Personal Microsoft accounts such as Skype, Xbox, Outlook.com) + - Microsoft Entra External ID + - Microsoft Entra External ID with Custom Domain + - Azure AD B2C -* Provides a set of high level API that is built on top of, and easier to be used than - `Microsoft's MSAL Python library `_. +* Built on top of + `Microsoft's MSAL Python library `_ + and tailored for web apps. -* Written in Python, for Python apps. + - Currently providing a high level API for + `Django web framework `_, + - and a low level API which would likely work for any Python web framework. + +* Currently supports these features/scenarios: + + - Sign-in/sign-out + + + Automatically renew signed-in session when the ID token expires + + - Acquires an access token to call a web API + + + Step-up consent. If the user needs to consent to more permissions, + the library will automatically redirect the user to the consent page. + + Automatically cache the access token and renew it when needed .. toctree:: :maxdepth: 2 :caption: Contents: :hidden: -The following sections are the API Reference of Identity library. + django + generic .. note:: - Only APIs and their parameters documented in this section are part of public API, + Only APIs and their parameters documented in this document are part of public API, with guaranteed backward compatibility for the entire 1.x series. Other modules in the source code are all considered as internal helpers, which could change at anytime in the future, without prior notice. - -Support for Django web projects -=============================== - -Web app that logs in users --------------------------- - -1. Firstly, 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 dotenv import load_dotenv - from identity.django import Auth - load_dotenv() - AUTH = Auth( - os.getenv('CLIENT_ID'), - client_credential=os.getenv('CLIENT_SECRET'), - redirect_uri=os.getenv('REDIRECT_URI'), - authority=os.getenv('AUTHORITY'), - ) - -2. Add the built-in views into your ``urls.py``:: - - from django.conf import settings - - urlpatterns = [ - settings.AUTH.urlpattern, - ... - ] - -3. Now, in your web project's ``views.py``, decorate some views with the - :py:func:`identity.django.Auth.login_required` decorator:: - - from django.conf import settings - - @settings.AUTH.login_required - def index(request, *, context): - user = context['user'] - ... - -All of the content above are demonstrated in -`this django web app sample `_. - - -Web app that logs in users and calls a web API on their behalf --------------------------------------------------------------- - -Building on top of the previous scenario, you just need to - -4. decorate your token-consuming views using the same - :py:func:`identity.django.Auth.login_required` decorator, - this time with a parameter ``scopes=["your_scope"]``. - - Then, inside your view, call - :py:meth:`identity.django.Auth.get_token_for_user` to obtain a token object. - - For example:: - - @settings.AUTH.login_required(scopes=["your_scope"]) - def call_api(request, *, context): - api_result = requests.get( # Use access token to call downstream api - "https://your_api.example.com", - headers={'Authorization': 'Bearer ' + context['access_token']}, - timeout=30, - ).json() # Here we assume the response format is json - ... - -And you can see it in action in this sample mentioned above. - - -API for Django web projects ---------------------------- - -.. autoclass:: identity.django.Auth - :members: - :inherited-members: - - .. automethod:: __init__ - -Support for generic web projects -================================ - -Web app that logs in users --------------------------- - -1. Firstly, create an instance of the :py:class:`identity.web.Auth` object, - and assign it to a (typically global) variable:: - - auth = identity.web.Auth( - session=session, # A session object is a key-value storage with a - # dict-like interface. Many web frameworks provide this. - authority="https://login.microsoftonline.com/common", - client_id="your_app_client_id", - client_credential="your_secret", - ) - -2. Now, in your web app's login controller, call the - ``auth.log_in(scopes=["your_scope"], redirect_uri="https://your_app.example.com/redirect_uri")`` - (see also :py:meth:`.log_in`) - to obtain the ``auth_uri`` (and possibly a ``user_code``), - and then render them into your login html page. - -3. The second leg of log-in needs to be implemented in another controller, - which calls ``auth.complete_log_in(incoming_query_parameters)`` - (see also :py:meth:`.complete_log_in`). - If its returned dict contains an ``error``, then render the error to end user, - otherwise your end user has successfully logged in, - and his/her information is available as a dict returned by - :meth:`identity.web.Auth.get_user`. - In particular, the returned dict contains a key named ``sub``, - whose value is the unique identifier which you can use to represent this end user - in your app's local database. - -4. Don't forget to add one more controller for log out. You do it by calling - ``auth.log_out("https://your_app.example.com")``. - Please refer to :meth:`.log_out`'s docs for more details about its return value. - -All of the content above are demonstrated in this sample (link to be provided). - - -Web app that logs in users and calls a web API on their behalf --------------------------------------------------------------- - -Building on top of the previous scenario, you just need to call -``auth.get_token_for_user(["your_scope"])`` to obtain a token object. -See :py:meth:`identity.web.Auth.get_token_for_user` for more details. -And you can see it in action in this sample (link to be provided). - - -Generic API, currently used for Flask web apps ----------------------------------------------- - -.. autoclass:: identity.web.Auth - :members: - :inherited-members: - - .. automethod:: __init__ - diff --git a/identity/django.py b/identity/django.py index ac8561b..ddb8a9d 100644 --- a/identity/django.py +++ b/identity/django.py @@ -1,15 +1,13 @@ from functools import partial, wraps -from html import escape import logging import os from typing import List # Needed in Python 3.7 & 3.8 from urllib.parse import urlparse -import warnings from django.shortcuts import redirect, render from django.urls import include, path, reverse -from .web import Auth as _Auth +from .web import WebFrameworkAuth logger = logging.getLogger(__name__) @@ -29,85 +27,16 @@ def _parse_redirect_uri(redirect_uri): else: return "", None -class Auth(object): +class Auth(WebFrameworkAuth): + """A long-live identity auth helper for a Django web project. - def __init__( - self, - client_id: str, - *, - client_credential=None, - redirect_uri: str=None, - scopes: List[str]=None, - authority: str=None, - - # We end up accepting Microsoft Entra ID B2C parameters rather than generic urls - # because it is troublesome to build those urls in settings.py or templates - b2c_tenant_name: str=None, - b2c_signup_signin_user_flow: str=None, - b2c_edit_profile_user_flow: str=None, - b2c_reset_password_user_flow: str=None, - ): - """Create an identity helper for a Django web project. - - This instance is expected to be long-lived with the web project. - - :param str client_id: - The client_id of your web application, issued by its authority. - - :param str client_credential: - It is somtimes a string. - The actual format is decided by the underlying auth library. TBD. - - :param str redirect_uri: - This will be used to mount your project's auth views accordingly. - - For example, if your input here is "https://example.com/x/y/z/redirect", - then your project's redirect page will be mounted at "/x/y/z/redirect", - login page will be at "/x/y/z/login", - and logout page will be at "/x/y/z/logout". - - Afterwards, all you need to do is to insert ``auth.urlpattern`` into - your project's ``urlpatterns`` list in ``your_project/urls.py``. - - :param list[str] scopes: - Deprecated. Use @login_required(..., scopes=[...]) instead. - - :param str authority: - The authority which your application registers with. - For example, ``https://example.com/foo``. - This is a required parameter unless you the following B2C parameters. - - :param str b2c_tenant_name: - The tenant name of your Microsoft Entra ID tenant, such as "contoso". - Required if your project is using Microsoft Entra ID B2C. - - :param str b2c_signup_signin_user_flow: - The name of your Microsoft Entra ID tenant's sign-in flow, - such as "B2C_1_signupsignin1". - Required if your project is using Microsoft Entra ID B2C. - - :param str b2c_edit_profile_user_flow: - The name of your Microsoft Entra ID tenant's edit-profile flow, - such as "B2C_1_profile_editing". - Optional. - - :param str b2c_edit_profile_user_flow: - The name of your Microsoft Entra ID tenant's reset-password flow, - such as "B2C_1_reset_password". - Optional. - - """ - self._client_id = client_id - self._client_credential = client_credential - if scopes: - warnings.warn( - "The 'scopes' parameter is deprecated. " - "Use @login_required(..., scopes=[...]) instead", - DeprecationWarning) - self._http_cache = {} # All subsequent _Auth instances will share this + Afterwards, all you need to do is to insert ``auth.urlpattern`` into + your project's ``urlpatterns`` list in ``your_project/urls.py``. + """ - self._redirect_uri = redirect_uri - route, self._redirect_view = _parse_redirect_uri(redirect_uri) + def __init__(self, *args, **kwargs): + super(Auth, self).__init__(*args, **kwargs) + route, self._redirect_view = _parse_redirect_uri(self._redirect_uri) self.urlpattern = path(route, include([ # Note: path(..., view, ...) does not accept classmethod path('login', self.login), @@ -118,67 +47,6 @@ def __init__( ), ])) - # 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( - tenant=b2c_tenant_name, - user_flow=b2c_signup_signin_user_flow, - ) - self._edit_profile_auth = _Auth( - session={}, - authority=b2c_authority_template.format( - tenant=b2c_tenant_name, - user_flow=b2c_edit_profile_user_flow, - ), - client_id=client_id, - ) if b2c_edit_profile_user_flow else None - self._reset_password_auth = _Auth( - session={}, - authority=b2c_authority_template.format( - tenant=b2c_tenant_name, - user_flow=b2c_reset_password_user_flow, - ), - client_id=client_id, - ) if b2c_reset_password_user_flow else None - else: - self._authority = authority - self._edit_profile_auth = None - self._reset_password_auth = None - if not self._authority: - logger.error( # Do not raise exception, because - # we want to render a nice error page later during login, - # which is a better developer experience especially for deployment - "Either authority or b2c_tenant_name and b2c_signup_signin_user_flow " - "must be provided") - - def _build_auth(self, request): - return _Auth( - session=request.session, - authority=self._authority, - client_id=self._client_id, - client_credential=self._client_credential, - http_cache=self._http_cache, - ) - - def _get_reset_password_url(self, request): - return self._reset_password_auth.log_in( - redirect_uri=request.build_absolute_uri(self._redirect_view), - state=self._reset_password_auth.__STATE_NO_OP, - )["auth_uri"] if self._reset_password_auth and self._redirect_view else None - - def get_edit_profile_url(self, request): - """A helper to get the URL for Microsoft Entra B2C's edit profile page. - - You can pass this URL to your template and render it there. - """ - return self._edit_profile_auth.log_in( - redirect_uri=request.build_absolute_uri(self._redirect_view), - state=self._edit_profile_auth.__STATE_NO_OP, - )["auth_uri"] if self._edit_profile_auth and self._redirect_view else None - def login( self, request, @@ -206,7 +74,7 @@ def login( logger.warning( "redirect_uri mismatch: configured = %s, calculated = %s", self._redirect_uri, redirect_uri) - log_in_result = self._build_auth(request).log_in( + log_in_result = self._build_auth(request.session).log_in( scopes=scopes, # Have user consent to scopes (if any) during log-in redirect_uri=redirect_uri, # Optional. If present, this absolute URL must match your app's redirect_uri registered in Azure Portal prompt="select_account", # Optional. More values defined in https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest @@ -220,21 +88,21 @@ def login( ) return render(request, "identity/login.html", dict( log_in_result, - reset_password_url=self._get_reset_password_url(request), + reset_password_url=self._get_reset_password_url(), auth_response_url=reverse(self.auth_response), )) def _render_auth_error(self, request, error, error_description=None): return render(request, "identity/auth_error.html", dict( # Use flat data types so that the template can be as simple as possible - error=escape(error), - error_description=escape(error_description or ""), - reset_password_url=self._get_reset_password_url(request), + error=error, + error_description=error_description or "", + reset_password_url=self._get_reset_password_url(), )) def auth_response(self, request): # The auth_response view. You should not need to call this view directly. - result = self._build_auth(request).complete_log_in(request.GET) + result = self._build_auth(request.session).complete_log_in(request.GET) if "error" in result: return self._render_auth_error( request, @@ -253,35 +121,7 @@ def logout(self, request): from inside a template. """ return redirect( - self._build_auth(request).log_out(request.build_absolute_uri("/"))) - - def get_user(self, request): - "" # Nullify the following docstring, because we recommend using login_required() - """Get the logged-in user of the request. - - :param request: The request object of the current view. - - :return: - The user object which is a dict of claims, - or None if the user is not logged in. - """ - return self._build_auth(request).get_user() - - def get_token_for_user(self, request, scopes: List[str]): - "" # Nullify the following docstring, because we recommend using login_required() - """Get access token for the current user, with specified scopes. - - :param list scopes: - A list of scopes that your app will need to use. - - :return: A dict representing the json response from identity provider. - - - A successful response would contain "access_token" key, - - An error response would contain "error" and usually "error_description". - - See also `OAuth2 specs `_. - """ - return self._build_auth(request).get_token_for_user(scopes) + self._build_auth(request.session).log_out(request.build_absolute_uri("/"))) def login_required( # Named after Django's login_required self, @@ -335,42 +175,17 @@ def my_view2(request, *, context): # Called without brackets, i.e. @login_required @wraps(function) def wrapper(request, *args, **kwargs): - auth = self._build_auth(request) + auth = self._build_auth(request.session) user = auth.get_user() - if user: - if scopes: - result = auth.get_token_for_user(scopes) # Silently via RT - if isinstance(result, dict) and "access_token" in result: - context = dict( - user=user, - # https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 - access_token=result["access_token"], - token_type=result.get("token_type", "Bearer"), - expires_in=result.get("expires_in", 300), - refresh_token=result.get("refresh_token"), - ) - context["scopes"] = result["scope"].split() if result.get( - "scope") else scopes - else: - logger.error( - "Access token unavailable. Error: %s, Desc: %s, keys: %s", - result.get("error"), result.get("error_description"), - result.keys()) - context = None # Token request failed - else: - context = {"user": user} - if context: - try: - return function(request, *args, context=context, **kwargs) - except TypeError: - if scopes: - raise - warnings.warn( - "The '@login_required(...)' decorated view should accept " - "a keyword argument named 'context'. For example, " - "def my_view(request, *, context): ...", - DeprecationWarning) - return function(request, *args, **kwargs) + context = self._login_required(auth, user, scopes) + if context: + try: + return function(request, *args, context=context, **kwargs) + except TypeError: + raise RuntimeError( + "Since identity 0.6.0, the '@login_required(...)' decorated " + "view should accept a keyword argument named 'context'. " + "For example, def my_view(request, *, context): ...") from None # Save an http 302 by calling self.login(request) instead of redirect(self.login) return self.login( request, diff --git a/identity/web.py b/identity/web.py index c08a963..a20a0bc 100644 --- a/identity/web.py +++ b/identity/web.py @@ -1,6 +1,8 @@ +from abc import ABC, abstractmethod import functools import logging import time +from typing import List # Needed in Python 3.7 & 3.8 import requests import msal @@ -9,13 +11,13 @@ logger = logging.getLogger(__name__) -class Auth(object): +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" _AUTH_FLOW = "_auth_flow" _USER = "_logged_in_user" _EXPLICITLY_REQUESTED_SCOPES = f"{__name__}.explicitly_requested_scopes" - __STATE_NO_OP = f"{__name__}.no_op" # A special state to indicate an auth response shall be ignored + _STATE_NO_OP = f"{__name__}.no_op" # A special state to indicate an auth response shall be ignored __NEXT_LINK = f"{__name__}.next_link" # The next page after a successful auth def __init__( self, @@ -156,7 +158,7 @@ def complete_log_in(self, auth_response=None): * On success, a dict as {"next_link": "/path/to/next/page/if/any"} That dict is actually the claims from an already-validated ID token. """ - if auth_response and auth_response.get("state") == self.__STATE_NO_OP: + if auth_response and auth_response.get("state") == self._STATE_NO_OP: return {} # Return a no-op, as that is what the request opted for auth_flow = self._session.get(self._AUTH_FLOW, {}) if not auth_flow: @@ -316,3 +318,165 @@ def _is_valid(id_token_claims, skew=None, seconds=None): id_token_claims["exp"] if seconds is None else id_token_claims["iat"] + seconds) + +class WebFrameworkAuth(ABC): # This is a mid-level helper to be subclassed + """This is a mid-level helper to be subclassed. Do not use it directly.""" + def __init__( + self, + client_id: str, + *, + client_credential=None, + authority: str=None, + redirect_uri: str=None, + # We end up accepting Microsoft Entra ID B2C parameters rather than generic urls + # because it is troublesome to build those urls in settings.py or templates + b2c_tenant_name: str=None, + b2c_signup_signin_user_flow: str=None, + b2c_edit_profile_user_flow: str=None, + b2c_reset_password_user_flow: str=None, + ): + """Create an identity helper for a web application. + + :param str client_id: + The client_id of your web application, issued by its authority. + + :param str client_credential: + It is somtimes a string. + The actual format is decided by the underlying auth library. TBD. + + :param str redirect_uri: + This will be used to mount your project's auth views accordingly. + + For example, if your input here is ``https://example.com/x/y/z/redirect``, + then your project's redirect page will be mounted at "/x/y/z/redirect", + login page will be at "/x/y/z/login", + and logout page will be at "/x/y/z/logout". + + :param str authority: + The authority which your application registers with. + For example, ``https://example.com/foo``. + This is a required parameter unless you the following B2C parameters. + + :param str b2c_tenant_name: + The tenant name of your Microsoft Entra ID tenant, such as "contoso". + Required if your project is using Microsoft Entra ID B2C. + + :param str b2c_signup_signin_user_flow: + The name of your Microsoft Entra ID tenant's sign-in flow, + such as "B2C_1_signupsignin1". + Required if your project is using Microsoft Entra ID B2C. + + :param str b2c_edit_profile_user_flow: + The name of your Microsoft Entra ID tenant's edit-profile flow, + such as "B2C_1_profile_editing". + Optional. + + :param str b2c_edit_profile_user_flow: + The name of your Microsoft Entra ID tenant's reset-password flow, + such as "B2C_1_reset_password". + Optional. + + """ + self._client_id = client_id + self._client_credential = client_credential + self._redirect_uri = redirect_uri + self._http_cache = {} # All subsequent Auth instances will share this + + # 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( + tenant=b2c_tenant_name, + user_flow=b2c_signup_signin_user_flow, + http_cache=self._http_cache, + ) + self._edit_profile_auth = Auth( + session={}, + authority=b2c_authority_template.format( + tenant=b2c_tenant_name, + user_flow=b2c_edit_profile_user_flow, + ), + client_id=client_id, + http_cache=self._http_cache, + ) if b2c_edit_profile_user_flow else None + self._reset_password_auth = Auth( + session={}, + authority=b2c_authority_template.format( + tenant=b2c_tenant_name, + user_flow=b2c_reset_password_user_flow, + ), + client_id=client_id, + http_cache=self._http_cache, + ) if b2c_reset_password_user_flow else None + else: + self._authority = authority + self._edit_profile_auth = None + self._reset_password_auth = None + if not self._authority: + logger.error( # Do not raise exception, because + # we want to render a nice error page later during login, + # which is a better developer experience especially for deployment + "Either authority or b2c_tenant_name and b2c_signup_signin_user_flow " + "must be provided") + + def _build_auth(self, session): + return Auth( + session=session, + authority=self._authority, + client_id=self._client_id, + client_credential=self._client_credential, + http_cache=self._http_cache, + ) + + 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: + if scopes: + result = auth.get_token_for_user(scopes) # Silently via RT + if isinstance(result, dict) and "access_token" in result: + context = dict( + user=user, + # https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + access_token=result["access_token"], + token_type=result.get("token_type", "Bearer"), + expires_in=result.get("expires_in", 300), + refresh_token=result.get("refresh_token"), + ) + context["scopes"] = result["scope"].split() if result.get( + "scope") else scopes + else: # Token request failed + logger.error( + "Access token unavailable. Error: %s, Desc: %s, keys: %s", + result.get("error"), result.get("error_description"), + result.keys()) + context = None # Token request failed + else: + context = {"user": user} + else: # User has not logged in at all + context = None + return context + + def get_edit_profile_url(self): + """A helper to get the URL for Microsoft Entra B2C's edit profile page. + + You can pass this URL to your template and render it there. + """ + return self._edit_profile_auth.log_in( + redirect_uri=self._redirect_uri, + state=self._edit_profile_auth._STATE_NO_OP, + )["auth_uri"] if self._edit_profile_auth and self._redirect_uri else None + + def _get_reset_password_url(self): + return self._reset_password_auth.log_in( + redirect_uri=self._redirect_uri, + state=self._reset_password_auth._STATE_NO_OP, + )["auth_uri"] if self._reset_password_auth and self._redirect_uri else None + + @abstractmethod + def _render_auth_error(error, *, error_description=None): + # 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