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)