diff --git a/docs/abc.rst b/docs/abc.rst index 284fd6d..ebdedcd 100644 --- a/docs/abc.rst +++ b/docs/abc.rst @@ -9,3 +9,6 @@ It is an abstract base class. You cannot use it directly. .. automethod:: __init__ +.. autoclass:: identity.pallet.PalletAuth + :members: + diff --git a/identity/flask.py b/identity/flask.py index 7010c8e..ba16ab1 100644 --- a/identity/flask.py +++ b/identity/flask.py @@ -1,53 +1,66 @@ -from functools import partial, wraps -import logging -import os -from typing import List # Needed in Python 3.7 & 3.8 -from urllib.parse import urlparse - +from typing import List, Optional # Needed in Python 3.7 & 3.8 from flask import ( Blueprint, Flask, redirect, render_template, request, session, url_for, ) from flask_session import Session +from .pallet import PalletAuth -from .web import WebFrameworkAuth - - -logger = logging.getLogger(__name__) - -class Auth(WebFrameworkAuth): +class Auth(PalletAuth): """A long-live identity auth helper for a Flask web project.""" + _Blueprint = Blueprint + _Session = Session + _redirect = redirect - def __init__(self, app: Flask, *args, **kwargs): + def __init__(self, app: Optional[Flask], *args, **kwargs): """Create an identity helper for a web application. :param Flask app: - A Flask app instance. It will be used to register the routes. + It can be a Flask app instance, or ``None``. + + 1. If your app object is globally available, you may pass it in here. + Usage:: + + # In your app.py + app = Flask(__name__) + auth = Auth(app, authority=..., client_id=..., ...) + + 2. But if you are using `Application Factory pattern + `_, + your app is not available globally, so you need to pass ``None`` here, + and call :func:`Auth.init_app()` later, + inside or after your app factory function. Usage:: + + # In your auth.py + auth = Auth(app=None, authority=..., client_id=..., ...) + + # In your other blueprints or modules + from auth import auth + + bp = Blueprint("my_blueprint", __name__) + + @bp.route("/") + @auth.login_required + def my_view(*, context): + ... + + # In your app.py + from auth import auth + import my_blueprint + def build_app(): + app = Flask(__name__) + auth.init_app(app) + app.register_blueprint(my_blueprint.bp) + return app + + app = build_app() It also passes extra parameters to :class:`identity.web.WebFrameworkAuth`. """ - super(Auth, self).__init__(*args, **kwargs) - Session(app) - self._endpoint_prefix = "identity" # A convention to match the template's folder name - bp = Blueprint( - self._endpoint_prefix, - __name__, # It decides blueprint resource folder - template_folder='templates', - ) - # Manually register the routes, since we cannot use @app or @bp on methods - if self._redirect_uri: - redirect_path = urlparse(self._redirect_uri).path - bp.route(redirect_path)(self.auth_response) - bp.route( - f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout") - )(self.logout) - else: # For Device Code Flow, we don't have a redirect_uri - bp.route("/auth_response")(self.auth_response) - bp.route("/logout")(self.logout) - app.register_blueprint(bp) - # "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization - self._auth = self._build_auth(session) + self._request = request # Not available during class definition + self._session = session # Not available during class definition + super(Auth, self).__init__(app, *args, **kwargs) def _render_auth_error(self, *, error, error_description=None): return render_template( @@ -88,9 +101,6 @@ def auth_response(self): ) return redirect(result.get("next_link") or "/") - def logout(self): - return redirect(self._auth.log_out(request.host_url)) - def login_required( # Named after Django's login_required self, function=None, @@ -133,28 +143,5 @@ def call_an_api(*, context): ) ... """ - # With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675 - - # Called with brackets, i.e. @login_required() - if function is None: - logger.debug(f"Called as @login_required(..., scopes={scopes})") - return partial( - self.login_required, - scopes=scopes, - ) - - # Called without brackets, i.e. @login_required - @wraps(function) - def wrapper(*args, **kwargs): - auth = self._auth # In Flask, the entire app uses a singleton _auth - user = auth.get_user() - context = self._login_required(auth, user, scopes) - if context: - return function(*args, context=context, **kwargs) - # Save an http 302 by calling self.login(request) instead of redirect(self.login) - return self.login( - next_link=request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path - scopes=scopes, - ) - return wrapper + return super(Auth, self).login_required(function, scopes=scopes) diff --git a/identity/pallet.py b/identity/pallet.py new file mode 100644 index 0000000..98c6aca --- /dev/null +++ b/identity/pallet.py @@ -0,0 +1,108 @@ +from abc import abstractmethod +from functools import partial, wraps +from inspect import iscoroutinefunction +import logging +import os +from typing import List # Needed in Python 3.7 & 3.8 +from urllib.parse import urlparse +from .web import WebFrameworkAuth + + +logger = logging.getLogger(__name__) + + +class PalletAuth(WebFrameworkAuth): # A common base class for Flask and Quart + _endpoint_prefix = "identity" # A convention to match the template's folder name + _auth = None # None means not initialized yet + + def __init__(self, app, *args, **kwargs): + if not ( + self._Blueprint and self._Session and self._redirect + and getattr(self, "_session", None) is not None + and getattr(self, "_request", None) is not None + ): + raise RuntimeError( + "Subclass must provide " + "_Blueprint, _Session, _redirect, _session, and _request.") + super(PalletAuth, self).__init__(*args, **kwargs) + self._bp = bp = self._Blueprint( + self._endpoint_prefix, + __name__, # It decides blueprint resource folder + template_folder='templates', + ) + # Manually register the routes, since we cannot use @app or @bp on methods + if self._redirect_uri: + redirect_path = urlparse(self._redirect_uri).path + bp.route(redirect_path)(self.auth_response) + bp.route( + f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout") + )(self.logout) + else: # For Device Code Flow, we don't have a redirect_uri + bp.route("/auth_response")(self.auth_response) + bp.route("/logout")(self.logout) + if app: + self.init_app(app) + + def init_app(self, app): + """Initialize the auth helper with your app instance.""" # Note: + # This doc string will be shared by Flask and Quart, + # so we use a vague "your app" without mentioning Flask or Quart here. + self._Session(app) + # "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization + app.register_blueprint(self._bp) + self._auth = self._build_auth(self._session) + + def __getattribute__(self, name): + # self._auth also doubles as a flag for initialization + if name == "_auth" and not super(PalletAuth, self).__getattribute__(name): + # Can't use self._render_auth_error(...) for friendly error message + # because bp has not been registered to the app yet + raise RuntimeError( + "You must call auth.init_app(app) before using " + "@auth.login_required() or auth.logout() etc.") + return super(PalletAuth, self).__getattribute__(name) + + def logout(self): + return self._redirect(self._auth.log_out(self._request.host_url)) + + def login_required( # Named after Django's login_required + self, + function=None, + /, # Requires Python 3.8+ + *, + scopes: List[str]=None, + ): + # With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675 + + # Called with brackets, i.e. @login_required() + if function is None: + logger.debug(f"Called as @login_required(..., scopes={scopes})") + return partial( + self.login_required, + scopes=scopes, + ) + + # Called without brackets, i.e. @login_required + if iscoroutinefunction(function): # For Quart + @wraps(function) + async def wrapper(*args, **kwargs): + user = self._auth.get_user() + context = self._login_required(self._auth, user, scopes) + if context: + return await function(*args, context=context, **kwargs) + # Save an http 302 by calling self.login(request) instead of redirect(self.login) + return await self.login(next_link=self._request.path, scopes=scopes) + else: # For Flask + @wraps(function) + def wrapper(*args, **kwargs): + user = self._auth.get_user() + context = self._login_required(self._auth, user, scopes) + if context: + return function(*args, context=context, **kwargs) + # Save an http 302 by calling self.login(request) instead of redirect(self.login) + return self.login( + next_link=self._request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path + scopes=scopes, + ) + return wrapper + diff --git a/identity/quart.py b/identity/quart.py index 7981cda..a000c2c 100644 --- a/identity/quart.py +++ b/identity/quart.py @@ -1,53 +1,66 @@ -from functools import partial, wraps -import logging -import os -from typing import List # Needed in Python 3.7 & 3.8 -from urllib.parse import urlparse - +from typing import List, Optional # Needed in Python 3.7 & 3.8 from quart import ( Blueprint, Quart, redirect, render_template, request, session, url_for, ) from quart_session import Session +from .pallet import PalletAuth -from .web import WebFrameworkAuth +class Auth(PalletAuth): + """A long-live identity auth helper for a Quart web project.""" + _Blueprint = Blueprint + _Session = Session + _redirect = redirect -logger = logging.getLogger(__name__) + def __init__(self, app: Optional[Quart], *args, **kwargs): + """Create an identity helper for a web application. + :param Quart app: + It can be a Quart app instance, or ``None``. -class Auth(WebFrameworkAuth): - """A long-live identity auth helper for a Flask web project.""" + 1. If your app object is globally available, you may pass it in here. + Usage:: - def __init__(self, app: Quart, *args, **kwargs): - """Create an identity helper for a web application. + # In your app.py + app = Quart(__name__) + auth = Auth(app, authority=..., client_id=..., ...) - :param Quart app: - A Quart app instance. It will be used to register the routes. + 2. But if you are using `Application Factory pattern + `_, + your app is not available globally, so you need to pass ``None`` here, + and call :func:`Auth.init_app()` later, + inside or after your app factory function. Usage:: + + # In your auth.py + auth = Auth(app=None, authority=..., client_id=..., ...) + + # In your other blueprints or modules + from auth import auth + + bp = Blueprint("my_blueprint", __name__) + + @bp.route("/") + @auth.login_required + async def my_view(*, context): + ... + + # In your app.py + from auth import auth + import my_blueprint + def build_app(): + app = Quart(__name__) + auth.init_app(app) + app.register_blueprint(my_blueprint.bp) + return app + + app = build_app() It also passes extra parameters to :class:`identity.web.WebFrameworkAuth`. """ - super(Auth, self).__init__(*args, **kwargs) - Session(app) - self._endpoint_prefix = "identity" # A convention to match the template's folder name - bp = Blueprint( - self._endpoint_prefix, - __name__, # It decides blueprint resource folder - template_folder='templates', - ) - # Manually register the routes, since we cannot use @app or @bp on methods - if self._redirect_uri: - redirect_path = urlparse(self._redirect_uri).path - bp.route(redirect_path)(self.auth_response) - bp.route( - f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout") - )(self.logout) - else: # For Device Code Flow, we don't have a redirect_uri - bp.route("/auth_response")(self.auth_response) - bp.route("/logout")(self.logout) - app.register_blueprint(bp) - # "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization - self._auth = self._build_auth(session) + self._request = request # Not available during class definition + self._session = session # Not available during class definition + super(Auth, self).__init__(app, *args, **kwargs) async def _render_auth_error(self, *, error, error_description=None): return await render_template( @@ -88,9 +101,6 @@ async def auth_response(self): ) return redirect(result.get("next_link") or "/") - def logout(self): - return redirect(self._auth.log_out(request.host_url)) - def login_required( # Named after Django's login_required self, function=None, @@ -134,28 +144,5 @@ async def call_api(*, context): return await render_template('display.html', result=api_result) """ - # With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675 - - # Called with brackets, i.e. @login_required() - if function is None: - logger.debug(f"Called as @login_required(..., scopes={scopes})") - return partial( - self.login_required, - scopes=scopes, - ) - - # Called without brackets, i.e. @login_required - @wraps(function) - async def wrapper(*args, **kwargs): - auth = self._auth # In Flask, the entire app uses a singleton _auth - user = auth.get_user() - context = self._login_required(auth, user, scopes) - if context: - return await function(*args, context=context, **kwargs) - # Save an http 302 by calling self.login(request) instead of redirect(self.login) - return await self.login( - next_link=request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path - scopes=scopes, - ) - return wrapper + return super(Auth, self).login_required(function, scopes=scopes)