Plugins¶
A plugin is a self-contained unit of extension. It contributes endpoints, request
hooks, and — when it needs to persist state — database tables. Every feature beyond
core email/password and sessions ships as a plugin, and you add one by passing it to
Auth(plugins=[...]).
The Plugin dataclass¶
A Plugin is a frozen dataclass with five fields:
| Field | Type | Purpose |
|---|---|---|
id |
str |
Stable identifier for the plugin. |
endpoints |
tuple[Endpoint, ...] |
Routes the plugin adds to the registry. |
schema |
tuple[TableSpec, ...] |
Tables the plugin needs (empty if none). |
before |
tuple[Hook, ...] |
Hooks run before the matched handler. |
after |
tuple[Hook, ...] |
Hooks run after the handler, able to rewrite the result. |
Only id is required; the rest default to empty tuples. Because the dataclass is
frozen, a plugin is an immutable description — all of its behavior lives in the
handlers and hooks it references.
How a plugin adds endpoints and tables¶
Each entry in endpoints is an Endpoint(method, path, handler, name). A handler is
an async callable (auth, request) -> EndpointResult. When you construct Auth,
every plugin's endpoints are concatenated onto the core ENDPOINTS and registered, so
plugin routes are served exactly like built-in ones:
plugin_endpoints = tuple(e for p in self.plugins for e in p.endpoints)
registry = Registry(ENDPOINTS + plugin_endpoints)
Entries in schema are TableSpec objects. A TableSpec has a model name and a
fields mapping of column name to FieldSpec (with type, required, unique,
default_value, input, and references). At startup, Auth merges plugin tables
into the schema alongside the core tables:
The username plugin, for example, declares a single username table:
USERNAME_TABLE = TableSpec(
model="username",
fields={
"id": FieldSpec(type="string", required=True, unique=True, input=False),
"user_id": FieldSpec(type="string", required=True, unique=True, references="user.id"),
"username": FieldSpec(type="string", required=True, unique=True),
"display_username": FieldSpec(type="string", required=True),
"created_at": FieldSpec(type="date", required=True, input=False),
"updated_at": FieldSpec(type="date", required=True, input=False),
},
)
Module-level vs config-carrying plugins¶
Plugins are produced by factory functions. There are two common shapes.
Module-level handlers. When a plugin takes no configuration, its handlers can be
plain module-level functions referenced directly by each Endpoint. The username()
factory does exactly this — _set, _available, and _sign_in are defined at module
scope:
def username() -> Plugin:
return Plugin(
id="username",
schema=(USERNAME_TABLE,),
endpoints=(
Endpoint("POST", "/username/set", _set, "username_set"),
Endpoint("GET", "/username/available", _available, "username_available"),
Endpoint("POST", "/sign-in/username", _sign_in, "sign_in_username"),
),
)
Config-carrying handlers. When a plugin accepts options, each handler needs access
to that configuration. Two patterns are used in deadbolt:
- Closures. The
totp(*, issuer="deadbolt", backup_code_count=10)factory defines its handlers as nestedasyncfunctions inside the factory, so they close overissuerandbackup_code_countdirectly. functools.partial. Theadmin(*, admin_emails=(), admin_user_ids=())factory builds anAdminConfig, then binds it to every handler withpartial(handler, cfg=cfg), keeping the handlers as module-level functions with a keyword-onlycfgparameter.
Both approaches yield an async (auth, request) -> EndpointResult callable, which is
all an Endpoint requires — the registry never sees the configuration.
Registering before/after hooks¶
A plugin registers hooks simply by populating its before and after tuples with
Hook objects. Each Hook is bound to an exact path (or all paths when path is
None). The TOTP plugin adds an after-hook to /sign-in/email so it can intercept a
successful password sign-in and turn it into a second-factor challenge:
return Plugin(
id="two-factor-totp",
schema=(TWO_FACTOR_TABLE,),
endpoints=(...),
after=(Hook(_challenge_after_sign_in, path="/sign-in/email"),),
)
When Auth is constructed, plugin hooks are collected into the global before/after
lists (after any hooks you passed via Auth(hooks=...)), so they run for every request
matching their path. See Hooks for the request-hook model and ordering.
Write your own plugin¶
A minimal plugin needs an id and at least one endpoint. This one adds a single
GET /ping route that reports server time, with no tables and no hooks:
import deadbolt as db
from datetime import datetime, timezone
from deadbolt.endpoints.context import EndpointResult
from deadbolt.endpoints.registry import Endpoint
from deadbolt.plugins import Plugin
async def _ping(auth, request) -> EndpointResult:
return EndpointResult(data={"pong": datetime.now(timezone.utc).isoformat()})
def ping() -> Plugin:
return Plugin(
id="ping",
endpoints=(Endpoint("GET", "/ping", _ping, "ping"),),
)
auth = db.Auth(
adapter=db.MemoryAdapter(),
secret=SECRET,
email_and_password=db.EmailPassword(enabled=True),
plugins=[ping()],
)
To carry configuration, take arguments in the factory and bind them into your handlers
with a closure or functools.partial, exactly as totp() and admin() do. To persist
state, add a TableSpec to schema. To react to other endpoints, add a Hook to
before or after.
Note
A handler returns an EndpointResult — its data is JSON-serialized as the
response body, and any cookies it sets are written to the response.