diff --git a/guardpost/docs/about.md b/guardpost/docs/about.md
new file mode 100644
index 0000000..9b2847b
--- /dev/null
+++ b/guardpost/docs/about.md
@@ -0,0 +1,31 @@
+# About GuardPost
+
+GuardPost was born from the need for a **framework-agnostic, reusable
+authentication and authorization layer** for Python applications. Rather than
+tying auth logic to a specific web framework, GuardPost provides a clean,
+composable API that works with any async Python application.
+
+The design is inspired by **ASP.NET Core's authorization policies** — the idea
+that authorization rules should be expressed as discrete, named policies made
+up of composable requirements, rather than hard-coded role checks scattered
+throughout the codebase.
+
+GuardPost powers the authentication and authorization system in the
+[BlackSheep](/blacksheep/) web framework, where it underpins features such as
+JWT bearer authentication, policy-based authorization, and OIDC integration.
+
+## Tested identity providers
+
+GuardPost has been tested with the following identity providers:
+
+- [Auth0](https://auth0.com/)
+- [Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id)
+- [Azure Active Directory B2C](https://azure.microsoft.com/en-us/products/active-directory/external-identities/b2c)
+- [Okta](https://www.okta.com/)
+
+## The project's home
+
+The project is hosted in
+[GitHub :fontawesome-brands-github:](https://github.com/Neoteroi/guardpost),
+maintained following DevOps good practices, and published to
+[PyPI](https://pypi.org/project/guardpost/).
diff --git a/guardpost/docs/authentication.md b/guardpost/docs/authentication.md
new file mode 100644
index 0000000..a5bc6a9
--- /dev/null
+++ b/guardpost/docs/authentication.md
@@ -0,0 +1,264 @@
+# Authentication
+
+This page describes GuardPost's authentication API in detail, covering:
+
+- [X] The `AuthenticationHandler` abstract class
+- [X] Synchronous vs asynchronous `authenticate` methods
+- [X] The `scheme` property
+- [X] The `Identity` class and its claims
+- [X] The `AuthenticationStrategy` class
+- [X] Using multiple handlers
+- [X] Grouping handlers by scheme
+
+## The `AuthenticationHandler` abstract class
+
+`AuthenticationHandler` is the base class for all authentication logic. Subclass
+it and implement the `authenticate` method to read credentials from a context
+and, when valid, set `context.identity`.
+
+```python {linenums="1"}
+from guardpost import AuthenticationHandler, Identity
+
+
+class MyHandler(AuthenticationHandler):
+ async def authenticate(self, context) -> None:
+ # Read credentials from context, validate them, then:
+ context.identity = Identity({"sub": "user-1"}, "Bearer")
+```
+
+The `context` parameter is whatever your application uses to represent a
+request — GuardPost imposes no specific type on it. In
+[BlackSheep](https://www.neoteroi.dev/blacksheep/) this is the `Request`
+object; in other frameworks it could be any object you choose.
+
+## Synchronous vs asynchronous handlers
+
+Both sync and async implementations are supported:
+
+=== "Async"
+
+ ```python {linenums="1"}
+ from guardpost import AuthenticationHandler, Identity
+
+
+ class AsyncBearerHandler(AuthenticationHandler):
+ scheme = "Bearer"
+
+ async def authenticate(self, context) -> None:
+ token = getattr(context, "token", None)
+ if token:
+ # e.g. validate token against a remote service
+ user_info = await fetch_user_info(token)
+ if user_info:
+ context.identity = Identity(
+ user_info, self.scheme
+ )
+ ```
+
+=== "Sync"
+
+ ```python {linenums="1"}
+ from guardpost import AuthenticationHandler, Identity
+
+
+ class SyncApiKeyHandler(AuthenticationHandler):
+ scheme = "ApiKey"
+
+ _valid_keys = {"key-abc": "service-a", "key-xyz": "service-b"}
+
+ def authenticate(self, context) -> None:
+ api_key = getattr(context, "api_key", None)
+ sub = self._valid_keys.get(api_key)
+ if sub:
+ context.identity = Identity(
+ {"sub": sub}, self.scheme
+ )
+ ```
+
+## The `scheme` property
+
+The optional `scheme` class property names the authentication scheme this
+handler implements (e.g. `"Bearer"`, `"ApiKey"`, `"Cookie"`). Naming
+schemes is useful when multiple handlers are registered and you need to
+identify which one authenticated a request.
+
+```python {linenums="1"}
+from guardpost import AuthenticationHandler, Identity
+
+
+class CookieHandler(AuthenticationHandler):
+ scheme = "Cookie"
+
+ async def authenticate(self, context) -> None:
+ session_id = getattr(context, "session_id", None)
+ if session_id:
+ context.identity = Identity(
+ {"sub": "user-from-cookie"}, self.scheme
+ )
+```
+
+## The `Identity` class and its claims
+
+`Identity` wraps a `dict` of claims and an `authentication_mode` string.
+`is_authenticated()` returns `True` only when `authentication_mode` is set.
+
+```python {linenums="1"}
+from guardpost import Identity
+
+identity = Identity(
+ {
+ "sub": "user-42",
+ "name": "Bob",
+ "email": "bob@example.com",
+ "roles": ["editor"],
+ "iss": "https://auth.example.com",
+ },
+ "Bearer",
+)
+
+# Convenience properties
+print(identity.sub) # "user-42"
+print(identity.name) # "Bob"
+print(identity.access_token) # None — not set
+
+# Dict-style access
+print(identity["email"]) # "bob@example.com"
+print(identity.get("roles")) # ["editor"]
+
+# Authentication mode
+print(identity.authentication_mode) # "Bearer"
+
+# Authentication check
+print(identity.is_authenticated()) # True — authentication_mode is set
+
+# Anonymous identity: claims present, but no authentication_mode
+anon = Identity({"sub": "guest"})
+print(anon.is_authenticated()) # False
+```
+
+/// admonition | Anonymous vs no identity
+ type: info
+
+An `Identity` created without `authentication_mode` (or `authentication_mode=None`)
+is **anonymous**: it has claims, but `is_authenticated()` returns `False`. This is
+different from `context.identity` being `None`, which means no identity was resolved
+at all. `AuthorizationStrategy` raises `UnauthorizedError` in both cases.
+///
+
+## The `AuthenticationStrategy` class
+
+`AuthenticationStrategy` manages a list of handlers and calls them in sequence.
+Once a handler sets `context.identity`, the remaining handlers are skipped.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import AuthenticationHandler, AuthenticationStrategy, Identity
+
+
+class MockContext:
+ def __init__(self, token=None, api_key=None):
+ self.token = token
+ self.api_key = api_key
+ self.identity = None
+
+
+class BearerHandler(AuthenticationHandler):
+ scheme = "Bearer"
+
+ async def authenticate(self, context) -> None:
+ if context.token == "valid-jwt":
+ context.identity = Identity(
+ {"sub": "u1", "name": "Alice"}, self.scheme
+ )
+
+
+class ApiKeyHandler(AuthenticationHandler):
+ scheme = "ApiKey"
+
+ def authenticate(self, context) -> None:
+ if context.api_key == "svc-key":
+ context.identity = Identity(
+ {"sub": "service-a"}, self.scheme
+ )
+
+
+async def main():
+ strategy = AuthenticationStrategy(BearerHandler(), ApiKeyHandler())
+
+ ctx = MockContext(api_key="svc-key")
+ await strategy.authenticate(ctx)
+ print(ctx.identity.sub) # "service-a"
+ print(ctx.identity.authentication_mode) # "ApiKey"
+
+
+asyncio.run(main())
+```
+
+## Using multiple handlers
+
+When multiple handlers are registered, they are tried in the order they are
+passed to `AuthenticationStrategy`. The first handler to set `context.identity`
+wins; subsequent handlers are not called.
+
+```python {linenums="1", hl_lines="3-4"}
+strategy = AuthenticationStrategy(
+ JWTHandler(), # tried first
+ ApiKeyHandler(), # tried second, only if JWT handler didn't set identity
+ CookieHandler(), # tried third, only if both above didn't set identity
+)
+```
+
+This is useful for APIs that support multiple credential types simultaneously.
+
+## Grouping handlers by scheme
+
+You can inspect `context.identity.authentication_mode` after authentication to know which
+handler authenticated the request, and apply different logic accordingly.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import AuthenticationHandler, AuthenticationStrategy, Identity
+
+
+class MockContext:
+ def __init__(self, token=None, api_key=None):
+ self.token = token
+ self.api_key = api_key
+ self.identity = None
+
+
+class BearerHandler(AuthenticationHandler):
+ scheme = "Bearer"
+
+ async def authenticate(self, context) -> None:
+ if context.token:
+ context.identity = Identity(
+ {"sub": "user-1"}, self.scheme
+ )
+
+
+class ApiKeyHandler(AuthenticationHandler):
+ scheme = "ApiKey"
+
+ def authenticate(self, context) -> None:
+ if context.api_key:
+ context.identity = Identity(
+ {"sub": "svc-1"}, self.scheme
+ )
+
+
+async def handle_request(context):
+ strategy = AuthenticationStrategy(BearerHandler(), ApiKeyHandler())
+ await strategy.authenticate(context)
+
+ if context.identity is None:
+ print("Anonymous request")
+ elif context.identity.authentication_mode == "Bearer":
+ print(f"Human user: {context.identity.sub}")
+ elif context.identity.authentication_mode == "ApiKey":
+ print(f"Service call: {context.identity.sub}")
+
+
+asyncio.run(handle_request(MockContext(api_key="any-key")))
+# Service call: svc-1
+```
diff --git a/guardpost/docs/authorization.md b/guardpost/docs/authorization.md
new file mode 100644
index 0000000..fb6bf71
--- /dev/null
+++ b/guardpost/docs/authorization.md
@@ -0,0 +1,278 @@
+# Authorization
+
+This page describes GuardPost's authorization API in detail, covering:
+
+- [X] The `Requirement` abstract class
+- [X] The `AuthorizationContext` class
+- [X] The `Policy` class
+- [X] The `AuthorizationStrategy` class
+- [X] Multiple requirements per policy
+- [X] `UnauthorizedError` vs `ForbiddenError`
+- [X] `AuthorizationError` base class
+- [X] Async requirements
+
+## The `Requirement` abstract class
+
+A `Requirement` encodes a single authorization rule. Subclass it and implement
+the `handle` method, then call `context.succeed(self)` if the rule passes or
+`context.fail(message)` if it does not.
+
+```python {linenums="1"}
+from guardpost.authorization import AuthorizationContext, Requirement
+
+
+class AuthenticatedRequirement(Requirement):
+ """Passes for any authenticated identity."""
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ # context.identity is guaranteed non-None at this point
+ context.succeed(self)
+```
+
+/// admonition | `handle` can be sync or async
+ type: tip
+
+Like `AuthenticationHandler.authenticate`, the `handle` method can be either
+`async def` or a plain `def`. GuardPost calls it correctly in both cases.
+///
+
+## The `AuthorizationContext` class
+
+`AuthorizationContext` is passed to every requirement and carries:
+
+| Attribute / method | Description |
+| ----------------------- | ------------------------------------------------------------ |
+| `.identity` | The current `Identity` (never `None` inside a requirement) |
+| `.succeed(requirement)` | Mark the given requirement as satisfied |
+| `.fail(message)` | Fail the entire authorization check with an optional message |
+
+```python {linenums="1"}
+from guardpost import Identity
+from guardpost.authorization import AuthorizationContext, Requirement
+
+
+class RoleRequirement(Requirement):
+ def __init__(self, role: str) -> None:
+ self._role = role
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ roles = context.identity.get("roles", [])
+ if self._role in roles:
+ context.succeed(self)
+ else:
+ context.fail(f"Identity does not have role '{self._role}'.")
+```
+
+## The `Policy` class
+
+A `Policy` pairs a **name** with one or more `Requirement` objects. All
+requirements must succeed for the policy to pass.
+
+```python {linenums="1"}
+from guardpost.authorization import Policy, Requirement, AuthorizationContext
+
+
+class AdminRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ if "admin" in context.identity.get("roles", []):
+ context.succeed(self)
+ else:
+ context.fail("Admin role required.")
+
+
+class ActiveAccountRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ if context.identity.get("active", False):
+ context.succeed(self)
+ else:
+ context.fail("Account is not active.")
+
+
+# Both AdminRequirement AND ActiveAccountRequirement must succeed
+admin_policy = Policy("admin", AdminRequirement(), ActiveAccountRequirement())
+```
+
+## The `AuthorizationStrategy` class
+
+`AuthorizationStrategy` holds a collection of policies and exposes
+`authorize(policy_name, identity)`. It raises an error when authorization
+fails and returns normally when it succeeds.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import Identity
+from guardpost.authorization import (
+ AuthorizationStrategy,
+ AuthorizationContext,
+ ForbiddenError,
+ Policy,
+ Requirement,
+ UnauthorizedError,
+)
+
+
+class AdminRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ if "admin" in context.identity.get("roles", []):
+ context.succeed(self)
+ else:
+ context.fail("Admin role required.")
+
+
+async def main():
+ strategy = AuthorizationStrategy(
+ Policy("admin", AdminRequirement()),
+ )
+
+ # Happy path — admin user
+ admin = Identity({"sub": "u1", "roles": ["admin"]}, "Bearer")
+ await strategy.authorize("admin", admin)
+ print("Authorized ✔")
+
+ # ForbiddenError — authenticated but lacks role
+ viewer = Identity({"sub": "u2", "roles": ["viewer"]}, "Bearer")
+ try:
+ await strategy.authorize("admin", viewer)
+ except ForbiddenError as exc:
+ print(f"Forbidden: {exc}")
+
+ # UnauthorizedError — not authenticated at all
+ try:
+ await strategy.authorize("admin", None)
+ except UnauthorizedError:
+ print("Unauthorized — must log in.")
+
+
+asyncio.run(main())
+```
+
+## Multiple requirements per policy
+
+When a policy declares multiple requirements, **every one** must call
+`context.succeed(self)` for the policy to pass. If any requirement calls
+`context.fail(...)` the check stops immediately.
+
+```python {linenums="1", hl_lines="20-21"}
+import asyncio
+from guardpost import Identity
+from guardpost.authorization import (
+ AuthorizationStrategy,
+ AuthorizationContext,
+ ForbiddenError,
+ Policy,
+ Requirement,
+)
+
+
+class HasRoleRequirement(Requirement):
+ def __init__(self, role: str) -> None:
+ self._role = role
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ if self._role in context.identity.get("roles", []):
+ context.succeed(self)
+ else:
+ context.fail(f"Missing role: {self._role!r}")
+
+
+class EmailVerifiedRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ if context.identity.get("email_verified"):
+ context.succeed(self)
+ else:
+ context.fail("Email address not verified.")
+
+
+async def main():
+ strategy = AuthorizationStrategy(
+ Policy(
+ "verified-editor",
+ HasRoleRequirement("editor"),
+ EmailVerifiedRequirement(),
+ )
+ )
+
+ ok_identity = Identity(
+ {"sub": "u1", "roles": ["editor"], "email_verified": True},
+ "Bearer",
+ )
+ await strategy.authorize("verified-editor", ok_identity)
+ print("Authorized ✔")
+
+ bad_identity = Identity(
+ {"sub": "u2", "roles": ["editor"], "email_verified": False},
+ "Bearer",
+ )
+ try:
+ await strategy.authorize("verified-editor", bad_identity)
+ except ForbiddenError as exc:
+ print(f"Forbidden: {exc}") # "Email address not verified."
+
+
+asyncio.run(main())
+```
+
+## `UnauthorizedError` vs `ForbiddenError`
+
+| Exception | When raised |
+| ------------------- | -------------------------------------------------------------------------------------- |
+| `UnauthorizedError` | `identity` is `None`, or `identity.is_authenticated()` is `False` (anonymous identity) |
+| `ForbiddenError` | `identity` is set but a requirement called `context.fail()` |
+
+Both are subclasses of `AuthorizationError`.
+
+```python {linenums="1"}
+from guardpost.authorization import (
+ AuthorizationError,
+ ForbiddenError,
+ UnauthorizedError,
+)
+
+try:
+ await strategy.authorize("admin", identity)
+except UnauthorizedError:
+ # Return HTTP 401 — please authenticate
+ ...
+except ForbiddenError:
+ # Return HTTP 403 — authenticated but not allowed
+ ...
+except AuthorizationError:
+ # Catch-all for any other authorization failure
+ ...
+```
+
+## `AuthorizationError` base class
+
+`AuthorizationError` is the common base class for all authorization
+exceptions. Catch it when you want to handle any authorization failure
+without distinguishing between the specific subtypes.
+
+## Async requirements
+
+Requirements can perform async operations — such as querying a database or
+calling an external service — directly in their `handle` method.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import Identity
+from guardpost.authorization import AuthorizationContext, Requirement
+
+
+async def fetch_user_permissions(user_id: str) -> list[str]:
+ """Simulates an async database lookup."""
+ await asyncio.sleep(0) # real code would await a DB call here
+ return ["read", "write"] if user_id == "u1" else ["read"]
+
+
+class PermissionRequirement(Requirement):
+ def __init__(self, permission: str) -> None:
+ self._permission = permission
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ user_id = context.identity.sub
+ permissions = await fetch_user_permissions(user_id)
+ if self._permission in permissions:
+ context.succeed(self)
+ else:
+ context.fail(f"Missing permission: {self._permission!r}")
+```
diff --git a/guardpost/docs/css/extra.css b/guardpost/docs/css/extra.css
new file mode 100644
index 0000000..2c9be02
--- /dev/null
+++ b/guardpost/docs/css/extra.css
@@ -0,0 +1,118 @@
+[data-md-color-scheme=slate] {
+ --md-code-hl-comment-color: #33b227 !important; /* #b28027 */
+}
+
+[data-md-color-scheme=default] {
+ --md-code-hl-comment-color: #b91414; /* #ab0404; */
+}
+
+html {
+ overflow-y: scroll;
+}
+
+.md-typeset__table tr td code {
+ white-space: nowrap;
+}
+
+@media screen and (min-width: 1000px) {
+ html.fullscreen {
+ .md-grid {
+ max-width: 98%;
+ }
+
+ .md-sidebar {
+ width: auto;
+ min-width: 15%;
+ }
+ }
+}
+
+#fullscreen-form label {
+ display: none;
+}
+
+html:not(.fullscreen) #full-screen {
+ display: inline-block !important;
+}
+
+html.fullscreen #full-screen-exit {
+ display: inline-block !important;
+}
+
+html.fullscreen #full-screen {
+ display: none !important;
+}
+
+[data-md-color-scheme="default"] .md-typeset [type="checkbox"]:checked + .task-list-indicator::before {
+ background-color: #2b579a;
+}
+
+[data-md-color-scheme="slate"] .md-typeset [type="checkbox"]:checked + .task-list-indicator::before {
+ background-color: #5b9bd5;
+}
+
+[data-md-color-scheme="slate"] {
+ --md-accent-fg-color: #5b9bd5;
+ --md-primary-fg-color: #7ab3e0;
+ --md-primary-fg-color--light: #6ea8d8;
+ --md-primary-fg-color--dark: #2b579a;
+}
+
+.md-search__input, .md-header .md-search__input::placeholder, .md-search__input + .md-search__icon {
+ color: #000 !important;
+}
+
+.md-header {
+ background-color: var(--bg-color, #2b579a);
+}
+
+.md-content article {
+ margin-bottom: 3em;
+}
+
+.md-header .md-search__input {
+ background-color: #fff;
+}
+
+.md-header-nav__button.md-logo img, .md-header-nav__button.md-logo svg {
+ width: 1.8rem;
+ height: auto;
+}
+
+.img-full-width + p img {
+ width: 100%;
+}
+
+.img-auto-width + p img {
+ width: none;
+}
+
+.md-typeset h1 {
+ margin: 0 0 1em;
+}
+
+.small {
+ font-size: 14px;
+}
+
+span.task-list-indicator {
+ margin-right: 5px;
+}
+
+@media screen and (max-width: 76.1875em) {
+ .md-nav--primary .md-nav__title[for=__drawer] {
+ background-color: #000000;
+ }
+}
+
+:root {
+ --nt-color-7: #108d10;
+}
+
+.version-warning {
+ margin-top: 5px !important;
+}
+
+.md-typeset .tabbed-labels>label, .md-typeset .admonition, .md-typeset details {
+ font-size: .75rem !important;
+}
diff --git a/guardpost/docs/css/neoteroi.css b/guardpost/docs/css/neoteroi.css
new file mode 100644
index 0000000..cd8c6cf
--- /dev/null
+++ b/guardpost/docs/css/neoteroi.css
@@ -0,0 +1 @@
+:root{--nt-color-0: #CD853F;--nt-color-1: #B22222;--nt-color-2: #000080;--nt-color-3: #4B0082;--nt-color-4: #3CB371;--nt-color-5: #D2B48C;--nt-color-6: #FF00FF;--nt-color-7: #98FB98;--nt-color-8: #FFEBCD;--nt-color-9: #2E8B57;--nt-color-10: #6A5ACD;--nt-color-11: #48D1CC;--nt-color-12: #FFA500;--nt-color-13: #F4A460;--nt-color-14: #A52A2A;--nt-color-15: #FFE4C4;--nt-color-16: #FF4500;--nt-color-17: #AFEEEE;--nt-color-18: #FA8072;--nt-color-19: #2F4F4F;--nt-color-20: #FFDAB9;--nt-color-21: #BC8F8F;--nt-color-22: #FFC0CB;--nt-color-23: #00FA9A;--nt-color-24: #F0FFF0;--nt-color-25: #FFFACD;--nt-color-26: #F5F5F5;--nt-color-27: #FF6347;--nt-color-28: #FFFFF0;--nt-color-29: #7FFFD4;--nt-color-30: #E9967A;--nt-color-31: #7B68EE;--nt-color-32: #FFF8DC;--nt-color-33: #0000CD;--nt-color-34: #D2691E;--nt-color-35: #708090;--nt-color-36: #5F9EA0;--nt-color-37: #008080;--nt-color-38: #008000;--nt-color-39: #FFE4E1;--nt-color-40: #FFFF00;--nt-color-41: #FFFAF0;--nt-color-42: #DCDCDC;--nt-color-43: #ADFF2F;--nt-color-44: #ADD8E6;--nt-color-45: #8B008B;--nt-color-46: #7FFF00;--nt-color-47: #800000;--nt-color-48: #20B2AA;--nt-color-49: #556B2F;--nt-color-50: #778899;--nt-color-51: #E6E6FA;--nt-color-52: #FFFAFA;--nt-color-53: #FF7F50;--nt-color-54: #FF0000;--nt-color-55: #F5DEB3;--nt-color-56: #008B8B;--nt-color-57: #66CDAA;--nt-color-58: #808000;--nt-color-59: #FAF0E6;--nt-color-60: #00BFFF;--nt-color-61: #C71585;--nt-color-62: #00FFFF;--nt-color-63: #8B4513;--nt-color-64: #F0F8FF;--nt-color-65: #FAEBD7;--nt-color-66: #8B0000;--nt-color-67: #4682B4;--nt-color-68: #F0E68C;--nt-color-69: #BDB76B;--nt-color-70: #A0522D;--nt-color-71: #FAFAD2;--nt-color-72: #FFD700;--nt-color-73: #DEB887;--nt-color-74: #E0FFFF;--nt-color-75: #8A2BE2;--nt-color-76: #32CD32;--nt-color-77: #87CEFA;--nt-color-78: #00CED1;--nt-color-79: #696969;--nt-color-80: #DDA0DD;--nt-color-81: #EE82EE;--nt-color-82: #FFB6C1;--nt-color-83: #8FBC8F;--nt-color-84: #D8BFD8;--nt-color-85: #9400D3;--nt-color-86: #A9A9A9;--nt-color-87: #FFFFE0;--nt-color-88: #FFF5EE;--nt-color-89: #FFF0F5;--nt-color-90: #FFDEAD;--nt-color-91: #800080;--nt-color-92: #B0E0E6;--nt-color-93: #9932CC;--nt-color-94: #DAA520;--nt-color-95: #F0FFFF;--nt-color-96: #40E0D0;--nt-color-97: #00FF7F;--nt-color-98: #006400;--nt-color-99: #808080;--nt-color-100: #87CEEB;--nt-color-101: #0000FF;--nt-color-102: #6495ED;--nt-color-103: #FDF5E6;--nt-color-104: #B8860B;--nt-color-105: #BA55D3;--nt-color-106: #C0C0C0;--nt-color-107: #000000;--nt-color-108: #F08080;--nt-color-109: #B0C4DE;--nt-color-110: #00008B;--nt-color-111: #6B8E23;--nt-color-112: #FFE4B5;--nt-color-113: #FFA07A;--nt-color-114: #9ACD32;--nt-color-115: #FFFFFF;--nt-color-116: #F5F5DC;--nt-color-117: #90EE90;--nt-color-118: #1E90FF;--nt-color-119: #7CFC00;--nt-color-120: #FF69B4;--nt-color-121: #F8F8FF;--nt-color-122: #F5FFFA;--nt-color-123: #00FF00;--nt-color-124: #D3D3D3;--nt-color-125: #DB7093;--nt-color-126: #DA70D6;--nt-color-127: #FF1493;--nt-color-128: #228B22;--nt-color-129: #FFEFD5;--nt-color-130: #4169E1;--nt-color-131: #191970;--nt-color-132: #9370DB;--nt-color-133: #483D8B;--nt-color-134: #FF8C00;--nt-color-135: #EEE8AA;--nt-color-136: #CD5C5C;--nt-color-137: #DC143C}:root{--nt-group-0-main: #000000;--nt-group-0-dark: #FFFFFF;--nt-group-0-light: #000000;--nt-group-0-main-bg: #F44336;--nt-group-0-dark-bg: #BA000D;--nt-group-0-light-bg: #FF7961;--nt-group-1-main: #000000;--nt-group-1-dark: #FFFFFF;--nt-group-1-light: #000000;--nt-group-1-main-bg: #E91E63;--nt-group-1-dark-bg: #B0003A;--nt-group-1-light-bg: #FF6090;--nt-group-2-main: #FFFFFF;--nt-group-2-dark: #FFFFFF;--nt-group-2-light: #000000;--nt-group-2-main-bg: #9C27B0;--nt-group-2-dark-bg: #6A0080;--nt-group-2-light-bg: #D05CE3;--nt-group-3-main: #FFFFFF;--nt-group-3-dark: #FFFFFF;--nt-group-3-light: #000000;--nt-group-3-main-bg: #673AB7;--nt-group-3-dark-bg: #320B86;--nt-group-3-light-bg: #9A67EA;--nt-group-4-main: #FFFFFF;--nt-group-4-dark: #FFFFFF;--nt-group-4-light: #000000;--nt-group-4-main-bg: #3F51B5;--nt-group-4-dark-bg: #002984;--nt-group-4-light-bg: #757DE8;--nt-group-5-main: #000000;--nt-group-5-dark: #FFFFFF;--nt-group-5-light: #000000;--nt-group-5-main-bg: #2196F3;--nt-group-5-dark-bg: #0069C0;--nt-group-5-light-bg: #6EC6FF;--nt-group-6-main: #000000;--nt-group-6-dark: #FFFFFF;--nt-group-6-light: #000000;--nt-group-6-main-bg: #03A9F4;--nt-group-6-dark-bg: #007AC1;--nt-group-6-light-bg: #67DAFF;--nt-group-7-main: #000000;--nt-group-7-dark: #000000;--nt-group-7-light: #000000;--nt-group-7-main-bg: #00BCD4;--nt-group-7-dark-bg: #008BA3;--nt-group-7-light-bg: #62EFFF;--nt-group-8-main: #000000;--nt-group-8-dark: #FFFFFF;--nt-group-8-light: #000000;--nt-group-8-main-bg: #009688;--nt-group-8-dark-bg: #00675B;--nt-group-8-light-bg: #52C7B8;--nt-group-9-main: #000000;--nt-group-9-dark: #FFFFFF;--nt-group-9-light: #000000;--nt-group-9-main-bg: #4CAF50;--nt-group-9-dark-bg: #087F23;--nt-group-9-light-bg: #80E27E;--nt-group-10-main: #000000;--nt-group-10-dark: #000000;--nt-group-10-light: #000000;--nt-group-10-main-bg: #8BC34A;--nt-group-10-dark-bg: #5A9216;--nt-group-10-light-bg: #BEF67A;--nt-group-11-main: #000000;--nt-group-11-dark: #000000;--nt-group-11-light: #000000;--nt-group-11-main-bg: #CDDC39;--nt-group-11-dark-bg: #99AA00;--nt-group-11-light-bg: #FFFF6E;--nt-group-12-main: #000000;--nt-group-12-dark: #000000;--nt-group-12-light: #000000;--nt-group-12-main-bg: #FFEB3B;--nt-group-12-dark-bg: #C8B900;--nt-group-12-light-bg: #FFFF72;--nt-group-13-main: #000000;--nt-group-13-dark: #000000;--nt-group-13-light: #000000;--nt-group-13-main-bg: #FFC107;--nt-group-13-dark-bg: #C79100;--nt-group-13-light-bg: #FFF350;--nt-group-14-main: #000000;--nt-group-14-dark: #000000;--nt-group-14-light: #000000;--nt-group-14-main-bg: #FF9800;--nt-group-14-dark-bg: #C66900;--nt-group-14-light-bg: #FFC947;--nt-group-15-main: #000000;--nt-group-15-dark: #FFFFFF;--nt-group-15-light: #000000;--nt-group-15-main-bg: #FF5722;--nt-group-15-dark-bg: #C41C00;--nt-group-15-light-bg: #FF8A50;--nt-group-16-main: #FFFFFF;--nt-group-16-dark: #FFFFFF;--nt-group-16-light: #000000;--nt-group-16-main-bg: #795548;--nt-group-16-dark-bg: #4B2C20;--nt-group-16-light-bg: #A98274;--nt-group-17-main: #000000;--nt-group-17-dark: #FFFFFF;--nt-group-17-light: #000000;--nt-group-17-main-bg: #9E9E9E;--nt-group-17-dark-bg: #707070;--nt-group-17-light-bg: #CFCFCF;--nt-group-18-main: #000000;--nt-group-18-dark: #FFFFFF;--nt-group-18-light: #000000;--nt-group-18-main-bg: #607D8B;--nt-group-18-dark-bg: #34515E;--nt-group-18-light-bg: #8EACBB}.nt-pastello{--nt-group-0-main: #000000;--nt-group-0-dark: #000000;--nt-group-0-light: #000000;--nt-group-0-main-bg: #EF9A9A;--nt-group-0-dark-bg: #BA6B6C;--nt-group-0-light-bg: #FFCCCB;--nt-group-1-main: #000000;--nt-group-1-dark: #000000;--nt-group-1-light: #000000;--nt-group-1-main-bg: #F48FB1;--nt-group-1-dark-bg: #BF5F82;--nt-group-1-light-bg: #FFC1E3;--nt-group-2-main: #000000;--nt-group-2-dark: #000000;--nt-group-2-light: #000000;--nt-group-2-main-bg: #CE93D8;--nt-group-2-dark-bg: #9C64A6;--nt-group-2-light-bg: #FFC4FF;--nt-group-3-main: #000000;--nt-group-3-dark: #000000;--nt-group-3-light: #000000;--nt-group-3-main-bg: #B39DDB;--nt-group-3-dark-bg: #836FA9;--nt-group-3-light-bg: #E6CEFF;--nt-group-4-main: #000000;--nt-group-4-dark: #000000;--nt-group-4-light: #000000;--nt-group-4-main-bg: #9FA8DA;--nt-group-4-dark-bg: #6F79A8;--nt-group-4-light-bg: #D1D9FF;--nt-group-5-main: #000000;--nt-group-5-dark: #000000;--nt-group-5-light: #000000;--nt-group-5-main-bg: #90CAF9;--nt-group-5-dark-bg: #5D99C6;--nt-group-5-light-bg: #C3FDFF;--nt-group-6-main: #000000;--nt-group-6-dark: #000000;--nt-group-6-light: #000000;--nt-group-6-main-bg: #81D4FA;--nt-group-6-dark-bg: #4BA3C7;--nt-group-6-light-bg: #B6FFFF;--nt-group-7-main: #000000;--nt-group-7-dark: #000000;--nt-group-7-light: #000000;--nt-group-7-main-bg: #80DEEA;--nt-group-7-dark-bg: #4BACB8;--nt-group-7-light-bg: #B4FFFF;--nt-group-8-main: #000000;--nt-group-8-dark: #000000;--nt-group-8-light: #000000;--nt-group-8-main-bg: #80CBC4;--nt-group-8-dark-bg: #4F9A94;--nt-group-8-light-bg: #B2FEF7;--nt-group-9-main: #000000;--nt-group-9-dark: #000000;--nt-group-9-light: #000000;--nt-group-9-main-bg: #A5D6A7;--nt-group-9-dark-bg: #75A478;--nt-group-9-light-bg: #D7FFD9;--nt-group-10-main: #000000;--nt-group-10-dark: #000000;--nt-group-10-light: #000000;--nt-group-10-main-bg: #C5E1A5;--nt-group-10-dark-bg: #94AF76;--nt-group-10-light-bg: #F8FFD7;--nt-group-11-main: #000000;--nt-group-11-dark: #000000;--nt-group-11-light: #000000;--nt-group-11-main-bg: #E6EE9C;--nt-group-11-dark-bg: #B3BC6D;--nt-group-11-light-bg: #FFFFCE;--nt-group-12-main: #000000;--nt-group-12-dark: #000000;--nt-group-12-light: #000000;--nt-group-12-main-bg: #FFF59D;--nt-group-12-dark-bg: #CBC26D;--nt-group-12-light-bg: #FFFFCF;--nt-group-13-main: #000000;--nt-group-13-dark: #000000;--nt-group-13-light: #000000;--nt-group-13-main-bg: #FFE082;--nt-group-13-dark-bg: #CAAE53;--nt-group-13-light-bg: #FFFFB3;--nt-group-14-main: #000000;--nt-group-14-dark: #000000;--nt-group-14-light: #000000;--nt-group-14-main-bg: #FFCC80;--nt-group-14-dark-bg: #CA9B52;--nt-group-14-light-bg: #FFFFB0;--nt-group-15-main: #000000;--nt-group-15-dark: #000000;--nt-group-15-light: #000000;--nt-group-15-main-bg: #FFAB91;--nt-group-15-dark-bg: #C97B63;--nt-group-15-light-bg: #FFDDC1;--nt-group-16-main: #000000;--nt-group-16-dark: #000000;--nt-group-16-light: #000000;--nt-group-16-main-bg: #BCAAA4;--nt-group-16-dark-bg: #8C7B75;--nt-group-16-light-bg: #EFDCD5;--nt-group-17-main: #000000;--nt-group-17-dark: #000000;--nt-group-17-light: #000000;--nt-group-17-main-bg: #EEEEEE;--nt-group-17-dark-bg: #BCBCBC;--nt-group-17-light-bg: #FFFFFF;--nt-group-18-main: #000000;--nt-group-18-dark: #000000;--nt-group-18-light: #000000;--nt-group-18-main-bg: #B0BEC5;--nt-group-18-dark-bg: #808E95;--nt-group-18-light-bg: #E2F1F8}.nt-group-0 .nt-plan-group-summary,.nt-group-0 .nt-timeline-dot{color:var(--nt-group-0-dark);background-color:var(--nt-group-0-dark-bg)}.nt-group-0 .period{color:var(--nt-group-0-main);background-color:var(--nt-group-0-main-bg)}.nt-group-1 .nt-plan-group-summary,.nt-group-1 .nt-timeline-dot{color:var(--nt-group-1-dark);background-color:var(--nt-group-1-dark-bg)}.nt-group-1 .period{color:var(--nt-group-1-main);background-color:var(--nt-group-1-main-bg)}.nt-group-2 .nt-plan-group-summary,.nt-group-2 .nt-timeline-dot{color:var(--nt-group-2-dark);background-color:var(--nt-group-2-dark-bg)}.nt-group-2 .period{color:var(--nt-group-2-main);background-color:var(--nt-group-2-main-bg)}.nt-group-3 .nt-plan-group-summary,.nt-group-3 .nt-timeline-dot{color:var(--nt-group-3-dark);background-color:var(--nt-group-3-dark-bg)}.nt-group-3 .period{color:var(--nt-group-3-main);background-color:var(--nt-group-3-main-bg)}.nt-group-4 .nt-plan-group-summary,.nt-group-4 .nt-timeline-dot{color:var(--nt-group-4-dark);background-color:var(--nt-group-4-dark-bg)}.nt-group-4 .period{color:var(--nt-group-4-main);background-color:var(--nt-group-4-main-bg)}.nt-group-5 .nt-plan-group-summary,.nt-group-5 .nt-timeline-dot{color:var(--nt-group-5-dark);background-color:var(--nt-group-5-dark-bg)}.nt-group-5 .period{color:var(--nt-group-5-main);background-color:var(--nt-group-5-main-bg)}.nt-group-6 .nt-plan-group-summary,.nt-group-6 .nt-timeline-dot{color:var(--nt-group-6-dark);background-color:var(--nt-group-6-dark-bg)}.nt-group-6 .period{color:var(--nt-group-6-main);background-color:var(--nt-group-6-main-bg)}.nt-group-7 .nt-plan-group-summary,.nt-group-7 .nt-timeline-dot{color:var(--nt-group-7-dark);background-color:var(--nt-group-7-dark-bg)}.nt-group-7 .period{color:var(--nt-group-7-main);background-color:var(--nt-group-7-main-bg)}.nt-group-8 .nt-plan-group-summary,.nt-group-8 .nt-timeline-dot{color:var(--nt-group-8-dark);background-color:var(--nt-group-8-dark-bg)}.nt-group-8 .period{color:var(--nt-group-8-main);background-color:var(--nt-group-8-main-bg)}.nt-group-9 .nt-plan-group-summary,.nt-group-9 .nt-timeline-dot{color:var(--nt-group-9-dark);background-color:var(--nt-group-9-dark-bg)}.nt-group-9 .period{color:var(--nt-group-9-main);background-color:var(--nt-group-9-main-bg)}.nt-group-10 .nt-plan-group-summary,.nt-group-10 .nt-timeline-dot{color:var(--nt-group-10-dark);background-color:var(--nt-group-10-dark-bg)}.nt-group-10 .period{color:var(--nt-group-10-main);background-color:var(--nt-group-10-main-bg)}.nt-group-11 .nt-plan-group-summary,.nt-group-11 .nt-timeline-dot{color:var(--nt-group-11-dark);background-color:var(--nt-group-11-dark-bg)}.nt-group-11 .period{color:var(--nt-group-11-main);background-color:var(--nt-group-11-main-bg)}.nt-group-12 .nt-plan-group-summary,.nt-group-12 .nt-timeline-dot{color:var(--nt-group-12-dark);background-color:var(--nt-group-12-dark-bg)}.nt-group-12 .period{color:var(--nt-group-12-main);background-color:var(--nt-group-12-main-bg)}.nt-group-13 .nt-plan-group-summary,.nt-group-13 .nt-timeline-dot{color:var(--nt-group-13-dark);background-color:var(--nt-group-13-dark-bg)}.nt-group-13 .period{color:var(--nt-group-13-main);background-color:var(--nt-group-13-main-bg)}.nt-group-14 .nt-plan-group-summary,.nt-group-14 .nt-timeline-dot{color:var(--nt-group-14-dark);background-color:var(--nt-group-14-dark-bg)}.nt-group-14 .period{color:var(--nt-group-14-main);background-color:var(--nt-group-14-main-bg)}.nt-group-15 .nt-plan-group-summary,.nt-group-15 .nt-timeline-dot{color:var(--nt-group-15-dark);background-color:var(--nt-group-15-dark-bg)}.nt-group-15 .period{color:var(--nt-group-15-main);background-color:var(--nt-group-15-main-bg)}.nt-group-16 .nt-plan-group-summary,.nt-group-16 .nt-timeline-dot{color:var(--nt-group-16-dark);background-color:var(--nt-group-16-dark-bg)}.nt-group-16 .period{color:var(--nt-group-16-main);background-color:var(--nt-group-16-main-bg)}.nt-group-17 .nt-plan-group-summary,.nt-group-17 .nt-timeline-dot{color:var(--nt-group-17-dark);background-color:var(--nt-group-17-dark-bg)}.nt-group-17 .period{color:var(--nt-group-17-main);background-color:var(--nt-group-17-main-bg)}.nt-group-18 .nt-plan-group-summary,.nt-group-18 .nt-timeline-dot{color:var(--nt-group-18-dark);background-color:var(--nt-group-18-dark-bg)}.nt-group-18 .period{color:var(--nt-group-18-main);background-color:var(--nt-group-18-main-bg)}.nt-error{border:2px dashed darkred;padding:0 1rem;background:#faf9ba;color:darkred}.nt-timeline{margin-top:30px}.nt-timeline .nt-timeline-title{font-size:1.1rem;margin-top:0}.nt-timeline .nt-timeline-sub-title{margin-top:0}.nt-timeline .nt-timeline-content{font-size:.8rem;border-bottom:2px dashed #ccc;padding-bottom:1.2rem}.nt-timeline.horizontal .nt-timeline-items{flex-direction:row;overflow-x:scroll}.nt-timeline.horizontal .nt-timeline-items>div{min-width:400px;margin-right:50px}.nt-timeline.horizontal.reverse .nt-timeline-items{flex-direction:row-reverse}.nt-timeline.horizontal.center .nt-timeline-before{background-image:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal.center .nt-timeline-after{background-image:linear-gradient(180deg, rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal.center .nt-timeline-items{background-image:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%);background-repeat:no-repeat;background-size:100% 2px;background-position:0 center}.nt-timeline.horizontal .nt-timeline-dot{left:50%}.nt-timeline.horizontal .nt-timeline-dot:not(.bigger){top:calc(50% - 4px)}.nt-timeline.horizontal .nt-timeline-dot.bigger{top:calc(50% - 15px)}.nt-timeline.vertical .nt-timeline-items{flex-direction:column}.nt-timeline.vertical.reverse .nt-timeline-items{flex-direction:column-reverse}.nt-timeline.vertical.center .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat center/2px 100%}.nt-timeline.vertical.center .nt-timeline-dot{left:calc(50% - 10px)}.nt-timeline.vertical.center .nt-timeline-dot:not(.bigger){top:10px}.nt-timeline.vertical.center .nt-timeline-dot.bigger{left:calc(50% - 20px)}.nt-timeline.vertical.left{padding-left:100px}.nt-timeline.vertical.left .nt-timeline-item{padding-left:70px}.nt-timeline.vertical.left .nt-timeline-sub-title{left:-100px;width:100px}.nt-timeline.vertical.left .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat 30px/2px 100%}.nt-timeline.vertical.left .nt-timeline-dot{left:21px;top:8px}.nt-timeline.vertical.left .nt-timeline-dot.bigger{top:0px;left:10px}.nt-timeline.vertical.right{padding-right:100px}.nt-timeline.vertical.right .nt-timeline-sub-title{right:-100px;text-align:left;width:100px}.nt-timeline.vertical.right .nt-timeline-item{padding-right:70px}.nt-timeline.vertical.right .nt-timeline-before{background:linear-gradient(rgba(252, 70, 107, 0) 0%, rgb(252, 70, 107) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-after{background:linear-gradient(rgb(252, 70, 107) 0%, rgba(252, 70, 107, 0) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-items{background:radial-gradient(circle, rgb(63, 94, 251) 0%, rgb(252, 70, 107) 100%) no-repeat calc(100% - 30px)/2px 100%}.nt-timeline.vertical.right .nt-timeline-dot{right:21px;top:8px}.nt-timeline.vertical.right .nt-timeline-dot.bigger{top:10px;right:10px}.nt-timeline-items{display:flex;position:relative}.nt-timeline-items>div{min-height:100px;padding-top:2px;padding-bottom:20px}.nt-timeline-before{content:"";height:15px}.nt-timeline-after{content:"";height:60px;margin-bottom:20px}.nt-timeline-sub-title{position:absolute;width:50%;top:4px;font-size:18px;color:var(--nt-color-50)}[data-md-color-scheme=slate] .nt-timeline-sub-title{color:var(--nt-color-51)}.nt-timeline-item{position:relative}.nt-timeline.vertical.center:not(.alternate) .nt-timeline-item{padding-left:calc(50% + 40px)}.nt-timeline.vertical.center:not(.alternate) .nt-timeline-item .nt-timeline-sub-title{left:0;padding-right:40px;text-align:right}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd){padding-left:calc(50% + 40px)}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd) .nt-timeline-sub-title{left:0;padding-right:40px;text-align:right}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even){text-align:right;padding-right:calc(50% + 40px)}.nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even) .nt-timeline-sub-title{right:0;padding-left:40px;text-align:left}.nt-timeline-dot{position:relative;width:20px;height:20px;border-radius:100%;background-color:#fc5b5b;position:absolute;top:0px;z-index:2;display:flex;justify-content:center;align-items:center;box-shadow:0 2px 1px -1px rgba(0,0,0,.2),0 1px 1px 0 rgba(0,0,0,.14),0 1px 3px 0 rgba(0,0,0,.12);border:3px solid #fff}.nt-timeline-dot:not(.bigger) .icon{font-size:10px}.nt-timeline-dot.bigger{width:40px;height:40px;padding:3px}.nt-timeline-dot .icon{color:#fff}@supports not (-moz-appearance: none){details .nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(odd) .nt-timeline-sub-title,details .nt-timeline.vertical.center:not(.alternate) .nt-timeline-item .nt-timeline-sub-title{left:-40px}details .nt-timeline.vertical.center.alternate .nt-timeline-item:nth-child(even) .nt-timeline-sub-title{right:-40px}details .nt-timeline.vertical.center .nt-timeline-dot{left:calc(50% - 12px)}details .nt-timeline-dot.bigger{font-size:1rem !important}}.nt-timeline-item:nth-child(0) .nt-timeline-dot{background-color:var(--nt-color-0)}.nt-timeline-item:nth-child(1) .nt-timeline-dot{background-color:var(--nt-color-1)}.nt-timeline-item:nth-child(2) .nt-timeline-dot{background-color:var(--nt-color-2)}.nt-timeline-item:nth-child(3) .nt-timeline-dot{background-color:var(--nt-color-3)}.nt-timeline-item:nth-child(4) .nt-timeline-dot{background-color:var(--nt-color-4)}.nt-timeline-item:nth-child(5) .nt-timeline-dot{background-color:var(--nt-color-5)}.nt-timeline-item:nth-child(6) .nt-timeline-dot{background-color:var(--nt-color-6)}.nt-timeline-item:nth-child(7) .nt-timeline-dot{background-color:var(--nt-color-7)}.nt-timeline-item:nth-child(8) .nt-timeline-dot{background-color:var(--nt-color-8)}.nt-timeline-item:nth-child(9) .nt-timeline-dot{background-color:var(--nt-color-9)}.nt-timeline-item:nth-child(10) .nt-timeline-dot{background-color:var(--nt-color-10)}.nt-timeline-item:nth-child(11) .nt-timeline-dot{background-color:var(--nt-color-11)}.nt-timeline-item:nth-child(12) .nt-timeline-dot{background-color:var(--nt-color-12)}.nt-timeline-item:nth-child(13) .nt-timeline-dot{background-color:var(--nt-color-13)}.nt-timeline-item:nth-child(14) .nt-timeline-dot{background-color:var(--nt-color-14)}.nt-timeline-item:nth-child(15) .nt-timeline-dot{background-color:var(--nt-color-15)}.nt-timeline-item:nth-child(16) .nt-timeline-dot{background-color:var(--nt-color-16)}.nt-timeline-item:nth-child(17) .nt-timeline-dot{background-color:var(--nt-color-17)}.nt-timeline-item:nth-child(18) .nt-timeline-dot{background-color:var(--nt-color-18)}.nt-timeline-item:nth-child(19) .nt-timeline-dot{background-color:var(--nt-color-19)}.nt-timeline-item:nth-child(20) .nt-timeline-dot{background-color:var(--nt-color-20)}:root{--nt-scrollbar-color: #2751b0;--nt-plan-actions-height: 24px;--nt-units-background: #ff9800;--nt-months-background: #2751b0;--nt-plan-vertical-line-color: #a3a3a3ad}.nt-pastello{--nt-scrollbar-color: #9fb8f4;--nt-units-background: #f5dc82;--nt-months-background: #5b7fd1}[data-md-color-scheme=slate]{--nt-units-background: #003773}[data-md-color-scheme=slate] .nt-pastello{--nt-units-background: #3f4997}.nt-plan-root{min-height:200px;scrollbar-width:20px;scrollbar-color:var(--nt-scrollbar-color);display:flex}.nt-plan-root ::-webkit-scrollbar{width:20px}.nt-plan-root ::-webkit-scrollbar-track{box-shadow:inset 0 0 5px gray;border-radius:10px}.nt-plan-root ::-webkit-scrollbar-thumb{background:var(--nt-scrollbar-color);border-radius:10px}.nt-plan-root .nt-plan{flex:80%}.nt-plan-root.no-groups .nt-plan-periods{padding-left:0}.nt-plan-root.no-groups .nt-plan-group-summary{display:none}.nt-plan-root .nt-timeline-dot.bigger{top:-10px}.nt-plan-root .nt-timeline-dot.bigger[title]{cursor:help}.nt-plan{white-space:nowrap;overflow-x:auto;display:flex}.nt-plan .ug-timeline-dot{left:368px;top:-8px;cursor:help}.months{display:flex}.month{flex:auto;display:inline-block;box-shadow:rgba(0,0,0,.2) 0px 3px 1px -2px,rgba(0,0,0,.14) 0px 2px 2px 0px,rgba(0,0,0,.12) 0px 1px 5px 0px inset;background-color:var(--nt-months-background);color:#fff;text-transform:uppercase;font-family:Roboto,Helvetica,Arial,sans-serif;padding:2px 5px;font-size:12px;border:1px solid #000;width:150px;border-radius:8px}.nt-plan-group-activities{flex:auto;position:relative}.nt-vline{border-left:1px dashed var(--nt-plan-vertical-line-color);height:100%;left:0;position:absolute;margin-left:-0.5px;top:0;-webkit-transition:all .5s linear !important;-moz-transition:all .5s linear !important;-ms-transition:all .5s linear !important;-o-transition:all .5s linear !important;transition:all .5s linear !important;z-index:-2}.nt-plan-activity{display:flex;margin:2px 0;background-color:rgba(187,187,187,.2509803922)}.actions{height:var(--nt-plan-actions-height)}.actions{position:relative}.period{display:inline-block;height:var(--nt-plan-actions-height);width:120px;position:absolute;left:0px;background:#1da1f2;border-radius:5px;transition:all .5s;cursor:help;-webkit-transition:width 1s ease-in-out;-moz-transition:width 1s ease-in-out;-o-transition:width 1s ease-in-out;transition:width 1s ease-in-out}.period .nt-tooltip{display:none;top:30px;position:relative;padding:1rem;text-align:center;font-size:12px}.period:hover .nt-tooltip{display:inline-block}.period-0{left:340px;visibility:visible;background-color:#456165}.period-1{left:40px;visibility:visible;background-color:green}.period-2{left:120px;visibility:visible;background-color:pink;width:80px}.period-3{left:190px;visibility:visible;background-color:darkred;width:150px}.weeks>span,.days>span{height:25px}.weeks>span{display:inline-block;margin:0;padding:0;font-weight:bold}.weeks>span .week-text{font-size:10px;position:absolute;display:inline-block;padding:3px 4px}.days{z-index:-2;position:relative}.day-text{font-size:10px;position:absolute;display:inline-block;padding:3px 4px}.period span{font-size:12px;vertical-align:top;margin-left:4px;color:#000;background:rgba(255,255,255,.6588235294);border-radius:6px;padding:0 4px}.weeks,.days{height:20px;display:flex;box-sizing:content-box}.months{display:flex}.week,.day{height:20px;position:relative;border:1;flex:auto;border:2px solid #fff;border-radius:4px;background-color:var(--nt-units-background);cursor:help}.years{display:flex}.year{text-align:center;border-right:1px solid var(--nt-plan-vertical-line-color);font-weight:bold}.year:first-child{border-left:1px solid var(--nt-plan-vertical-line-color)}.year:first-child:last-child{width:100%}.quarters{display:flex}.quarter{width:12.5%;text-align:center;border-right:1px solid var(--nt-plan-vertical-line-color);font-weight:bold}.quarter:first-child{border-left:1px solid var(--nt-plan-vertical-line-color)}.nt-plan-group{margin:20px 0;position:relative}.nt-plan-group{display:flex}.nt-plan-group-summary{background:#2751b0;width:150px;white-space:normal;padding:.1rem .5rem;border-radius:5px;color:#fff;z-index:3}.nt-plan-group-summary p{margin:0;padding:0;font-size:.6rem;color:#fff}.nt-plan-group-summary,.month,.period,.week,.day,.nt-tooltip{border:3px solid #fff;box-shadow:0 2px 3px -1px rgba(0,0,0,.2),0 3px 3px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12)}.nt-plan-periods{padding-left:150px}.months{z-index:2;position:relative}.weeks{position:relative;top:-2px;z-index:0}.month,.quarter,.year,.week,.day,.nt-tooltip{font-family:Roboto,Helvetica,Arial,sans-serif;box-sizing:border-box}.nt-cards.nt-grid{display:grid;grid-auto-columns:1fr;gap:.5rem;max-width:100vw;overflow-x:auto;padding:1px}.nt-cards.nt-grid.cols-1{grid-template-columns:repeat(1, 1fr)}.nt-cards.nt-grid.cols-2{grid-template-columns:repeat(2, 1fr)}.nt-cards.nt-grid.cols-3{grid-template-columns:repeat(3, 1fr)}.nt-cards.nt-grid.cols-4{grid-template-columns:repeat(4, 1fr)}.nt-cards.nt-grid.cols-5{grid-template-columns:repeat(5, 1fr)}.nt-cards.nt-grid.cols-6{grid-template-columns:repeat(6, 1fr)}@media only screen and (max-width: 400px){.nt-cards.nt-grid{grid-template-columns:repeat(1, 1fr) !important}}.nt-card{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.nt-card:hover{box-shadow:0 2px 2px 0 rgba(0,0,0,.24),0 3px 1px -2px rgba(0,0,0,.3),0 1px 5px 0 rgba(0,0,0,.22)}[data-md-color-scheme=slate] .nt-card{box-shadow:0 2px 2px 0 rgba(4,40,33,.14),0 3px 1px -2px rgba(40,86,94,.47),0 1px 5px 0 rgba(139,252,255,.64)}[data-md-color-scheme=slate] .nt-card:hover{box-shadow:0 2px 2px 0 rgba(0,255,206,.14),0 3px 1px -2px rgba(33,156,177,.47),0 1px 5px 0 rgba(96,251,255,.64)}.nt-card>a{color:var(--md-default-fg-color)}.nt-card>a>div{cursor:pointer}.nt-card{padding:5px;margin-bottom:.5rem}.nt-card-title{font-size:1rem;font-weight:bold;margin:4px 0 8px 0;line-height:22px}.nt-card-content{padding:.4rem .8rem .8rem .8rem}.nt-card-text{font-size:14px;padding:0;margin:0}.nt-card .nt-card-image{text-align:center;border-radius:2px;background-position:center center;background-size:cover;background-repeat:no-repeat;min-height:120px}.nt-card .nt-card-image.tags img{margin-top:12px}.nt-card .nt-card-image img{height:105px;margin-top:5px}.nt-card a:hover,.nt-card a:focus{color:var(--md-accent-fg-color)}.nt-card h2{margin:0}.span-table-wrapper table{border-collapse:collapse;margin-bottom:2rem;border-radius:.1rem}.span-table td,.span-table th{padding:.2rem;background-color:var(--md-default-bg-color);font-size:.64rem;max-width:100%;overflow:auto;touch-action:auto;border-top:.05rem solid var(--md-typeset-table-color);padding:.9375em 1.25em;vertical-align:top}.span-table tr:first-child td{font-weight:700;min-width:5rem;padding:.9375em 1.25em;vertical-align:top}.span-table td:first-child{border-left:.05rem solid var(--md-typeset-table-color)}.span-table td:last-child{border-right:.05rem solid var(--md-typeset-table-color)}.span-table tr:last-child{border-bottom:.05rem solid var(--md-typeset-table-color)}.span-table [colspan],.span-table [rowspan]{font-weight:bold;border:.05rem solid var(--md-typeset-table-color)}.span-table tr:not(:first-child):hover td:not([colspan]):not([rowspan]),.span-table td[colspan]:hover,.span-table td[rowspan]:hover{background-color:rgba(0,0,0,.035);box-shadow:0 .05rem 0 var(--md-default-bg-color) inset;transition:background-color 125ms}.nt-contribs{margin-top:2rem;font-size:small;border-top:1px dotted #d3d3d3;padding-top:.5rem}.nt-contribs .nt-contributors{padding-top:.5rem;display:flex;flex-wrap:wrap}.nt-contribs .nt-contributor{background:#d3d3d3;background-size:cover;width:40px;height:40px;border-radius:100%;margin:0 6px 6px 0;cursor:help;opacity:.7}.nt-contribs .nt-contributor:hover{opacity:1}.nt-contribs .nt-contributors-title{font-style:italic;margin-bottom:0}.nt-contribs .nt-initials{text-transform:uppercase;font-size:24px;text-align:center;width:40px;height:40px;display:inline-block;vertical-align:middle;position:relative;top:2px;color:inherit;font-weight:bold}.nt-contribs .nt-group-0{background-color:var(--nt-color-0)}.nt-contribs .nt-group-1{background-color:var(--nt-color-1)}.nt-contribs .nt-group-2{background-color:var(--nt-color-2)}.nt-contribs .nt-group-3{background-color:var(--nt-color-3)}.nt-contribs .nt-group-4{background-color:var(--nt-color-4)}.nt-contribs .nt-group-5{background-color:var(--nt-color-5)}.nt-contribs .nt-group-6{background-color:var(--nt-color-6)}.nt-contribs .nt-group-7{color:#000;background-color:var(--nt-color-7)}.nt-contribs .nt-group-8{color:#000;background-color:var(--nt-color-8)}.nt-contribs .nt-group-9{background-color:var(--nt-color-9)}.nt-contribs .nt-group-10{background-color:var(--nt-color-10)}.nt-contribs .nt-group-11{background-color:var(--nt-color-11)}.nt-contribs .nt-group-12{background-color:var(--nt-color-12)}.nt-contribs .nt-group-13{background-color:var(--nt-color-13)}.nt-contribs .nt-group-14{background-color:var(--nt-color-14)}.nt-contribs .nt-group-15{color:#000;background-color:var(--nt-color-15)}.nt-contribs .nt-group-16{background-color:var(--nt-color-16)}.nt-contribs .nt-group-17{color:#000;background-color:var(--nt-color-17)}.nt-contribs .nt-group-18{background-color:var(--nt-color-18)}.nt-contribs .nt-group-19{background-color:var(--nt-color-19)}.nt-contribs .nt-group-20{color:#000;background-color:var(--nt-color-20)}.nt-contribs .nt-group-21{color:#000;background-color:var(--nt-color-21)}.nt-contribs .nt-group-22{color:#000;background-color:var(--nt-color-22)}.nt-contribs .nt-group-23{color:#000;background-color:var(--nt-color-23)}.nt-contribs .nt-group-24{color:#000;background-color:var(--nt-color-24)}.nt-contribs .nt-group-25{color:#000;background-color:var(--nt-color-25)}.nt-contribs .nt-group-26{color:#000;background-color:var(--nt-color-26)}.nt-contribs .nt-group-27{background-color:var(--nt-color-27)}.nt-contribs .nt-group-28{color:#000;background-color:var(--nt-color-28)}.nt-contribs .nt-group-29{color:#000;background-color:var(--nt-color-29)}.nt-contribs .nt-group-30{background-color:var(--nt-color-30)}.nt-contribs .nt-group-31{background-color:var(--nt-color-31)}.nt-contribs .nt-group-32{color:#000;background-color:var(--nt-color-32)}.nt-contribs .nt-group-33{background-color:var(--nt-color-33)}.nt-contribs .nt-group-34{background-color:var(--nt-color-34)}.nt-contribs .nt-group-35{background-color:var(--nt-color-35)}.nt-contribs .nt-group-36{background-color:var(--nt-color-36)}.nt-contribs .nt-group-37{background-color:var(--nt-color-37)}.nt-contribs .nt-group-38{background-color:var(--nt-color-38)}.nt-contribs .nt-group-39{color:#000;background-color:var(--nt-color-39)}.nt-contribs .nt-group-40{color:#000;background-color:var(--nt-color-40)}.nt-contribs .nt-group-41{color:#000;background-color:var(--nt-color-41)}.nt-contribs .nt-group-42{color:#000;background-color:var(--nt-color-42)}.nt-contribs .nt-group-43{color:#000;background-color:var(--nt-color-43)}.nt-contribs .nt-group-44{color:#000;background-color:var(--nt-color-44)}.nt-contribs .nt-group-45{background-color:var(--nt-color-45)}.nt-contribs .nt-group-46{color:#000;background-color:var(--nt-color-46)}.nt-contribs .nt-group-47{background-color:var(--nt-color-47)}.nt-contribs .nt-group-48{background-color:var(--nt-color-48)}.nt-contribs .nt-group-49{background-color:var(--nt-color-49)}
diff --git a/guardpost/docs/dependency-injection.md b/guardpost/docs/dependency-injection.md
new file mode 100644
index 0000000..6526657
--- /dev/null
+++ b/guardpost/docs/dependency-injection.md
@@ -0,0 +1,234 @@
+# Dependency Injection
+
+This page covers how GuardPost supports dependency injection in authentication
+handlers and authorization requirements, including:
+
+- [X] Why DI is useful in auth handlers and requirements
+- [X] Declaring injected dependencies as class properties
+- [X] Passing a `container` to `AuthorizationStrategy`
+- [X] Using `rodi` as the DI container
+- [X] Example: injecting a database service into a `Requirement`
+- [X] Example: injecting a service into an `AuthenticationHandler`
+
+## Why dependency injection in auth?
+
+Authentication handlers and authorization requirements often need external
+services — database connections, caches, configuration objects — to do their
+work. Without DI you'd have to pass these services manually through constructors
+or global singletons.
+
+GuardPost integrates with dependency injection containers so that your handlers
+and requirements can declare their dependencies as class properties, letting the
+container wire them up automatically.
+
+/// admonition | GuardPost works with any DI container
+ type: info
+
+GuardPost uses a generic `container` protocol. Any container that implements a
+`resolve(type)` method works. [Rodi](https://www.neoteroi.dev/rodi/) is the
+recommended container and is used throughout the examples below.
+///
+
+## Declaring injected dependencies
+
+Declare dependencies as **class-level type-annotated properties**. GuardPost
+inspects these annotations and asks the container to provide instances when
+the handler or requirement is invoked.
+
+```python {linenums="1"}
+from guardpost.authorization import AuthorizationContext, Requirement
+
+
+class UserRepository:
+ async def get_permissions(self, user_id: str) -> list[str]:
+ # Simulate a DB lookup
+ return ["read", "write"] if user_id == "u1" else ["read"]
+
+
+class HasPermissionRequirement(Requirement):
+ # Declare the dependency — the container will inject this
+ user_repository: UserRepository
+
+ def __init__(self, permission: str) -> None:
+ self._permission = permission
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ permissions = await self.user_repository.get_permissions(
+ context.identity.sub
+ )
+ if self._permission in permissions:
+ context.succeed(self)
+ else:
+ context.fail(f"Missing permission: {self._permission!r}")
+```
+
+## Passing a container to `AuthorizationStrategy`
+
+Pass the DI container as the `container` keyword argument when constructing
+`AuthorizationStrategy`:
+
+```python {linenums="1"}
+import rodi
+from guardpost.authorization import AuthorizationStrategy, Policy
+
+from myapp.requirements import HasPermissionRequirement
+from myapp.repositories import UserRepository
+
+container = rodi.Container()
+container.register(UserRepository)
+
+strategy = AuthorizationStrategy(
+ Policy("write", HasPermissionRequirement("write")),
+ container=container,
+)
+```
+
+When `authorize` is called, GuardPost resolves `UserRepository` from the
+container and injects it into `HasPermissionRequirement` before calling
+`handle`.
+
+## Full example: injecting a database service into a `Requirement`
+
+```python {linenums="1"}
+import asyncio
+import rodi
+from guardpost import Identity
+from guardpost.authorization import (
+ AuthorizationContext,
+ AuthorizationStrategy,
+ Policy,
+ Requirement,
+)
+
+
+# --- Services ---
+
+class PermissionsDB:
+ """Simulates a database of user permissions."""
+
+ async def get_permissions(self, user_id: str) -> list[str]:
+ await asyncio.sleep(0) # would be a real DB call
+ data = {
+ "u1": ["read", "write", "delete"],
+ "u2": ["read"],
+ }
+ return data.get(user_id, [])
+
+
+# --- Requirement ---
+
+class HasPermissionRequirement(Requirement):
+ permissions_db: PermissionsDB # injected by the container
+
+ def __init__(self, permission: str) -> None:
+ self._permission = permission
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ perms = await self.permissions_db.get_permissions(context.identity.sub)
+ if self._permission in perms:
+ context.succeed(self)
+ else:
+ context.fail(f"Permission '{self._permission}' not granted.")
+
+
+# --- Wiring ---
+
+async def main():
+ container = rodi.Container()
+ container.register(PermissionsDB)
+
+ strategy = AuthorizationStrategy(
+ Policy("delete", HasPermissionRequirement("delete")),
+ container=container,
+ )
+
+ power_user = Identity({"sub": "u1"}, "Bearer")
+ await strategy.authorize("delete", power_user)
+ print("Authorized ✔")
+
+ from guardpost.authorization import ForbiddenError
+
+ basic_user = Identity({"sub": "u2"}, "Bearer")
+ try:
+ await strategy.authorize("delete", basic_user)
+ except ForbiddenError as exc:
+ print(f"Forbidden: {exc}")
+
+
+asyncio.run(main())
+```
+
+## Full example: injecting a service into an `AuthenticationHandler`
+
+Authentication handlers can also receive injected services. Declare them as
+class properties in the same way:
+
+```python {linenums="1"}
+import asyncio
+import rodi
+from guardpost import AuthenticationHandler, AuthenticationStrategy, Identity
+from guardpost.protection import InvalidCredentialsError
+
+
+# --- Service ---
+
+class UserStore:
+ """Simulates a user store."""
+
+ async def find_by_api_key(self, api_key: str) -> dict | None:
+ await asyncio.sleep(0)
+ store = {"key-abc": {"sub": "svc-a"}, "key-xyz": {"sub": "svc-b"}}
+ return store.get(api_key)
+
+
+# --- Handler ---
+
+class ApiKeyHandler(AuthenticationHandler):
+ scheme = "ApiKey"
+ user_store: UserStore # injected
+
+ async def authenticate(self, context) -> None:
+ api_key = getattr(context, "api_key", None)
+ if not api_key:
+ return # no credentials — anonymous, don't count as failure
+ user = await self.user_store.find_by_api_key(api_key)
+ if user:
+ context.identity = Identity(user, self.scheme)
+ else:
+ raise InvalidCredentialsError("Unknown API key.")
+
+
+# --- Wiring ---
+
+class MockContext:
+ def __init__(self, api_key=None):
+ self.api_key = api_key
+ self.identity = None
+
+
+async def main():
+ container = rodi.Container()
+ container.register(UserStore)
+
+ strategy = AuthenticationStrategy(
+ ApiKeyHandler(),
+ container=container,
+ )
+
+ ctx = MockContext(api_key="key-abc")
+ await strategy.authenticate(ctx)
+ print(ctx.identity.sub) # "svc-a"
+
+
+asyncio.run(main())
+```
+
+/// admonition | Constructor injection vs property injection
+ type: tip
+
+GuardPost uses **property injection** (class-level type annotations). This is
+consistent with how [Rodi](https://www.neoteroi.dev/rodi/) works and avoids
+needing to change handler constructors. Dependencies are resolved fresh for
+each invocation when the container is configured for transient or scoped
+lifetimes.
+///
diff --git a/guardpost/docs/errors.md b/guardpost/docs/errors.md
new file mode 100644
index 0000000..49fc143
--- /dev/null
+++ b/guardpost/docs/errors.md
@@ -0,0 +1,171 @@
+# Errors
+
+This page is a reference for all exceptions raised by GuardPost.
+
+## Exception table
+
+| Exception | Module | Description |
+|-----------|--------|-------------|
+| `AuthException` | `guardpost` | Base class for all GuardPost exceptions |
+| `AuthenticationError` | `guardpost` | Base class for authentication failures |
+| `UnauthenticatedError` | `guardpost` | Raised when identity is `None` or missing |
+| `InvalidCredentialsError` | `guardpost.protection` | Raised for invalid credentials; triggers rate-limiting |
+| `AuthorizationError` | `guardpost` | Base class for authorization failures |
+| `UnauthorizedError` | `guardpost` | User is not authenticated (no identity) |
+| `ForbiddenError` | `guardpost` | User is authenticated but lacks the required permission |
+| `OAuthException` | `guardpost.jwts` | Base OAuth-related exception |
+| `InvalidAccessToken` | `guardpost.jwts` | JWT is malformed or the signature / claims are invalid |
+| `ExpiredAccessToken` | `guardpost.jwts` | JWT has a valid signature but is past its `exp` claim |
+| `UnsupportedFeatureError` | `guardpost` | Raised when an unsupported feature is requested (e.g. unknown key type) |
+
+## Exception hierarchy
+
+```mermaid
+classDiagram
+ Exception <|-- AuthException
+ AuthException <|-- AuthenticationError
+ AuthException <|-- AuthorizationError
+ AuthenticationError <|-- UnauthenticatedError
+ AuthenticationError <|-- InvalidCredentialsError
+ AuthorizationError <|-- UnauthorizedError
+ AuthorizationError <|-- ForbiddenError
+ AuthException <|-- UnsupportedFeatureError
+ Exception <|-- OAuthException
+ OAuthException <|-- InvalidAccessToken
+ InvalidAccessToken <|-- ExpiredAccessToken
+```
+
+## Exception details
+
+### `AuthException`
+
+The root base class for all exceptions defined by GuardPost. Catching
+`AuthException` will catch any GuardPost error.
+
+```python {linenums="1"}
+from guardpost import AuthException
+
+try:
+ await strategy.authenticate(context)
+ await authz_strategy.authorize("policy", context.identity)
+except AuthException as exc:
+ print(f"GuardPost error: {exc}")
+```
+
+---
+
+### `AuthenticationError`
+
+Base class for errors that occur during the authentication phase.
+
+---
+
+### `UnauthenticatedError`
+
+Raised when code that requires an authenticated identity finds `None`. Typically
+raised internally by `AuthorizationStrategy` when `identity` is `None`.
+
+```python {linenums="1"}
+from guardpost import UnauthenticatedError
+
+try:
+ await strategy.authorize("admin", None)
+except UnauthenticatedError:
+ # HTTP 401 — client must authenticate first
+ ...
+```
+
+---
+
+### `InvalidCredentialsError`
+
+Raised by `AuthenticationHandler` implementations when credentials are present
+but invalid (wrong password, revoked key, etc.). This exception enables the
+`RateLimiter` to count the failure. See [Brute-force protection](./protection.md).
+
+```python {linenums="1"}
+from guardpost.protection import InvalidCredentialsError
+
+raise InvalidCredentialsError("Invalid password for user 'alice'.")
+```
+
+---
+
+### `AuthorizationError`
+
+Base class for errors that occur during the authorization phase. Catching this
+handles both `UnauthorizedError` and `ForbiddenError`.
+
+---
+
+### `UnauthorizedError`
+
+Raised by `AuthorizationStrategy` when the identity is `None` (the request is
+not authenticated). Map to HTTP **401** in web applications.
+
+---
+
+### `ForbiddenError`
+
+Raised by `AuthorizationStrategy` when the identity is set but a `Requirement`
+called `context.fail(...)`. Map to HTTP **403** in web applications.
+
+```python {linenums="1"}
+from guardpost.authorization import ForbiddenError, UnauthorizedError
+
+try:
+ await strategy.authorize("admin", identity)
+except UnauthorizedError:
+ return response_401()
+except ForbiddenError:
+ return response_403()
+```
+
+---
+
+### `OAuthException`
+
+Base class for OAuth / OIDC related exceptions. Available in the `guardpost.jwts`
+module (requires `pip install guardpost[jwt]`).
+
+---
+
+### `InvalidAccessToken`
+
+Raised when a JWT cannot be validated — the token is malformed, the signature
+does not match, or the claims (issuer, audience, etc.) are invalid.
+
+```python {linenums="1"}
+from guardpost.jwts import InvalidAccessToken
+
+try:
+ claims = await validator.validate_jwt(raw_token)
+except InvalidAccessToken as exc:
+ print(f"Token rejected: {exc}")
+```
+
+---
+
+### `ExpiredAccessToken`
+
+A subclass of `InvalidAccessToken`, raised specifically when the JWT's `exp`
+claim is in the past. Clients should respond by refreshing their token.
+
+```python {linenums="1"}
+from guardpost.jwts import ExpiredAccessToken, InvalidAccessToken
+
+try:
+ claims = await validator.validate_jwt(raw_token)
+except ExpiredAccessToken:
+ print("Token expired — please refresh.")
+except InvalidAccessToken:
+ print("Token is invalid.")
+```
+
+---
+
+### `UnsupportedFeatureError`
+
+Raised when GuardPost encounters a configuration or request that requires a
+feature it does not support — for example, a JWKS key with an unknown `kty`
+value.
diff --git a/guardpost/docs/getting-started.md b/guardpost/docs/getting-started.md
new file mode 100644
index 0000000..06f0fe2
--- /dev/null
+++ b/guardpost/docs/getting-started.md
@@ -0,0 +1,276 @@
+# Getting started with GuardPost
+
+This page introduces the basics of using GuardPost, including:
+
+- [X] Installing GuardPost
+- [X] The `Identity` class
+- [X] Implementing a simple `AuthenticationHandler`
+- [X] Using `AuthenticationStrategy`
+- [X] Implementing a `Requirement` and `Policy`
+- [X] Using `AuthorizationStrategy`
+- [X] Handling authentication and authorization errors
+
+## Installation
+
+```shell
+pip install guardpost
+```
+
+For JWT validation support, install the optional extra:
+
+```shell
+pip install guardpost[jwt]
+```
+
+## The `Identity` class
+
+An `Identity` represents the authenticated entity — a user, a service, or any
+principal. It carries a dict of **claims** and an **authentication_mode** string that
+indicates how the identity was authenticated.
+
+```python {linenums="1"}
+from guardpost import Identity
+
+# Create an identity with claims
+identity = Identity(
+ {
+ "sub": "user-123",
+ "name": "Alice",
+ "email": "alice@example.com",
+ "roles": ["admin", "editor"],
+ },
+ "Bearer",
+)
+
+print(identity.sub) # "user-123"
+print(identity.name) # "Alice"
+print(identity["email"]) # "alice@example.com" — dict-style access
+print(identity.is_authenticated()) # True — authentication_mode is set
+
+# An Identity with no authentication_mode is anonymous (unauthenticated)
+anon = Identity({"sub": "guest"})
+print(anon.is_authenticated()) # False
+```
+
+/// admonition | Anonymous vs unauthenticated
+ type: info
+
+`Identity.is_authenticated()` returns `True` only when `authentication_mode` is set
+to a non-empty string. An `Identity` created without `authentication_mode` (or with
+`authentication_mode=None`) is treated as **anonymous** — it carries claims but is
+not considered authenticated. `context.identity` being `None` means no identity was
+resolved at all.
+
+///
+
+## Implementing an `AuthenticationHandler`
+
+An `AuthenticationHandler` reads credentials from a context object and, if
+valid, sets `context.identity`.
+
+```python {linenums="1"}
+from guardpost import AuthenticationHandler, Identity
+
+
+class MockContext:
+ """A minimal context object — in real apps this might be an HTTP request."""
+ def __init__(self, token: str | None = None):
+ self.token = token
+ self.identity: Identity | None = None
+
+
+class BearerTokenHandler(AuthenticationHandler):
+ """Authenticates requests that carry a hard-coded bearer token."""
+
+ scheme = "Bearer"
+
+ async def authenticate(self, context: MockContext) -> None:
+ token = context.token
+ if token == "secret-token":
+ context.identity = Identity(
+ {"sub": "user-1", "name": "Alice"},
+ self.scheme,
+ )
+ # If the token is missing or wrong you can leave context.identity as None,
+ # or leave authentication_mode unset to create an anonymous identity
+```
+
+/// admonition | Synchronous handlers
+ type: tip
+
+`authenticate` can be either `async def` or a plain `def` — GuardPost
+handles both transparently.
+///
+
+## Using `AuthenticationStrategy`
+
+`AuthenticationStrategy` coordinates one or more handlers, calling them in
+order until one sets `context.identity`.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import AuthenticationHandler, AuthenticationStrategy, Identity
+
+
+class MockContext:
+ def __init__(self, token: str | None = None):
+ self.token = token
+ self.identity: Identity | None = None
+
+
+class BearerTokenHandler(AuthenticationHandler):
+ scheme = "Bearer"
+
+ async def authenticate(self, context: MockContext) -> None:
+ if context.token == "secret-token":
+ context.identity = Identity(
+ {"sub": "user-1", "name": "Alice"},
+ self.scheme,
+ )
+
+
+async def main():
+ strategy = AuthenticationStrategy(BearerTokenHandler())
+
+ # --- Happy path ---
+ ctx = MockContext(token="secret-token")
+ await strategy.authenticate(ctx)
+ print(ctx.identity) # Identity object
+ print(ctx.identity.name) # "Alice"
+
+ # --- Unknown token ---
+ ctx2 = MockContext(token="wrong-token")
+ await strategy.authenticate(ctx2)
+ print(ctx2.identity) # None
+
+
+asyncio.run(main())
+```
+
+## Implementing a `Requirement` and `Policy`
+
+A `Requirement` encodes a single authorization rule. A `Policy` groups a name
+with one or more requirements — all must succeed for the policy to pass.
+
+```python {linenums="1"}
+from guardpost import Identity
+from guardpost.authorization import (
+ AuthorizationContext,
+ Policy,
+ Requirement,
+)
+
+
+class AdminRequirement(Requirement):
+ """Allows only identities that carry the 'admin' role."""
+
+ async def handle(self, context: AuthorizationContext) -> None:
+ identity = context.identity
+ roles = identity.get("roles", [])
+ if "admin" in roles:
+ context.succeed(self)
+ else:
+ context.fail("User does not have the 'admin' role.")
+
+
+# A policy named "admin" that requires AdminRequirement to pass
+admin_policy = Policy("admin", AdminRequirement())
+```
+
+## Using `AuthorizationStrategy`
+
+```python {linenums="1"}
+import asyncio
+from guardpost import Identity
+from guardpost.authorization import (
+ AuthorizationContext,
+ AuthorizationStrategy,
+ Policy,
+ Requirement,
+)
+
+
+class AdminRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ roles = context.identity.get("roles", [])
+ if "admin" in roles:
+ context.succeed(self)
+ else:
+ context.fail("User does not have the 'admin' role.")
+
+
+async def main():
+ strategy = AuthorizationStrategy(
+ Policy("admin", AdminRequirement()),
+ )
+
+ # --- Admin user: authorized ---
+ admin_identity = Identity(
+ {"sub": "u1", "roles": ["admin"]}, "Bearer"
+ )
+ await strategy.authorize("admin", admin_identity)
+ print("Admin authorized ✔")
+
+ # --- Regular user: forbidden ---
+ from guardpost.authorization import ForbiddenError
+
+ user_identity = Identity(
+ {"sub": "u2", "roles": ["viewer"]}, "Bearer"
+ )
+ try:
+ await strategy.authorize("admin", user_identity)
+ except ForbiddenError as e:
+ print(f"Forbidden: {e}")
+
+
+asyncio.run(main())
+```
+
+## Handling errors
+
+GuardPost raises specific exceptions for authentication and authorization failures.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import UnauthenticatedError
+from guardpost.authorization import (
+ AuthorizationStrategy,
+ ForbiddenError,
+ Policy,
+ Requirement,
+ AuthorizationContext,
+)
+
+
+class AuthenticatedRequirement(Requirement):
+ async def handle(self, context: AuthorizationContext) -> None:
+ context.succeed(self)
+
+
+async def main():
+ strategy = AuthorizationStrategy(
+ Policy("authenticated", AuthenticatedRequirement()),
+ )
+
+ # Passing None as identity raises UnauthorizedError
+ from guardpost.authorization import UnauthorizedError
+
+ try:
+ await strategy.authorize("authenticated", None)
+ except UnauthorizedError:
+ print("Not authenticated — must log in first.")
+
+ # A valid identity that fails a requirement raises ForbiddenError
+ # (see full example in the Authorization page)
+
+
+asyncio.run(main())
+```
+
+/// admonition | Error hierarchy
+ type: info
+
+`UnauthorizedError` means the user is not authenticated (no identity).
+`ForbiddenError` means the user is authenticated but lacks the required
+permissions. Both are subclasses of `AuthorizationError`.
+///
diff --git a/guardpost/docs/img/neoteroi-w.svg b/guardpost/docs/img/neoteroi-w.svg
new file mode 100644
index 0000000..45fd9e7
--- /dev/null
+++ b/guardpost/docs/img/neoteroi-w.svg
@@ -0,0 +1,74 @@
+
+
diff --git a/guardpost/docs/img/neoteroi.ico b/guardpost/docs/img/neoteroi.ico
new file mode 100644
index 0000000..11cd814
Binary files /dev/null and b/guardpost/docs/img/neoteroi.ico differ
diff --git a/guardpost/docs/index.md b/guardpost/docs/index.md
new file mode 100644
index 0000000..f928e90
--- /dev/null
+++ b/guardpost/docs/index.md
@@ -0,0 +1,32 @@
+---
+title: GuardPost - Authentication and Authorization for Python
+no_comments: true
+---
+
+# GuardPost is an authentication and authorization framework for Python
+
+```shell
+pip install guardpost
+```
+
+## GuardPost offers...
+
+- A **strategy pattern** for authentication — determine who or what is initiating an action.
+- A **policy-based** authorization model — determine whether the acting identity is allowed to do something.
+- Built-in support for **JSON Web Tokens (JWTs)** validation, including RSA (RS256, RS384, RS512) and EC (ES256, ES384, ES512) asymmetric algorithms, and symmetric HMAC algorithms (HS256, HS384, HS512).
+- Automatic handling of **JWKS** (JSON Web Key Sets) with caching and key rotation support.
+- Support for **dependency injection** in authentication handlers and authorization requirements.
+- Built-in **brute-force protection** with a configurable rate limiter for authentication attempts.
+- A generic code API that works with any Python async application.
+
+## Getting started
+
+To get started with GuardPost, read the [_Getting Started_](./getting-started.md) guide.
+
+To go straight to JWT validation, see [_JWT Validation_](./jwt-validation.md).
+
+## Usage in BlackSheep
+
+GuardPost is the built-in authentication and authorization framework in the
+[BlackSheep](/blacksheep/) web framework. See [BlackSheep authentication](https://www.neoteroi.dev/blacksheep/authentication/)
+and [BlackSheep authorization](https://www.neoteroi.dev/blacksheep/authorization/) for more.
diff --git a/guardpost/docs/js/fullscreen.js b/guardpost/docs/js/fullscreen.js
new file mode 100644
index 0000000..7d18c4d
--- /dev/null
+++ b/guardpost/docs/js/fullscreen.js
@@ -0,0 +1,26 @@
+document.addEventListener("DOMContentLoaded", function () {
+ function setFullScreen() {
+ localStorage.setItem("FULLSCREEN", "Y")
+ document.documentElement.classList.add("fullscreen");
+ }
+ function exitFullScreen() {
+ localStorage.setItem("FULLSCREEN", "N")
+ document.documentElement.classList.remove("fullscreen");
+ }
+
+ // Select all radio inputs with the name "__fullscreen"
+ const fullscreenRadios = document.querySelectorAll('input[name="__fullscreen"]');
+
+ // Add a change event listener to each radio input
+ fullscreenRadios.forEach(function (radio) {
+ radio.addEventListener("change", function () {
+ if (radio.checked) {
+ if (radio.id === "__fullscreen") {
+ setFullScreen();
+ } else if (radio.id === "__fullscreen_no") {
+ exitFullScreen();
+ }
+ }
+ });
+ });
+});
diff --git a/guardpost/docs/jwks.md b/guardpost/docs/jwks.md
new file mode 100644
index 0000000..f6ef41d
--- /dev/null
+++ b/guardpost/docs/jwks.md
@@ -0,0 +1,270 @@
+# JWKS and Key Types
+
+This page covers GuardPost's JWKS (JSON Web Key Sets) API, including:
+
+- [X] `KeyType` enum
+- [X] The `JWK` dataclass
+- [X] The `JWKS` dataclass — parsing and updating
+- [X] `InMemoryKeysProvider`
+- [X] `URLKeysProvider`
+- [X] `AuthorityKeysProvider`
+- [X] `CachingKeysProvider` — TTL and automatic refresh
+- [X] Supported EC curves
+
+## `KeyType` enum
+
+`KeyType` enumerates the supported key types:
+
+| Value | Description |
+|-------|-------------|
+| `KeyType.RSA` | RSA keys (used with RS256, RS384, RS512) |
+| `KeyType.EC` | Elliptic Curve keys (used with ES256, ES384, ES512) |
+| `KeyType.OCT` | Octet sequence / symmetric keys (used with HS256, HS384, HS512) |
+| `KeyType.OKP` | Octet Key Pair (e.g. Ed25519) |
+
+```python {linenums="1"}
+from guardpost.jwks import KeyType
+
+print(KeyType.RSA) # KeyType.RSA
+print(KeyType.EC) # KeyType.EC
+```
+
+## The `JWK` dataclass
+
+`JWK` represents a single JSON Web Key. The fields depend on the key type:
+
+| Field | Key type | Description |
+|-------|----------|-------------|
+| `kty` | all | Key type string (`"RSA"`, `"EC"`, `"oct"`) |
+| `pem` | all | The key material as PEM-encoded bytes |
+| `kid` | optional | Key ID |
+| `n` | RSA | Base64url-encoded modulus |
+| `e` | RSA | Base64url-encoded public exponent |
+| `crv` | EC | Curve name (`"P-256"`, `"P-384"`, `"P-521"`) |
+| `x` | EC | Base64url-encoded x coordinate |
+| `y` | EC | Base64url-encoded y coordinate |
+
+### Parsing from a dict
+
+```python {linenums="1"}
+from guardpost.jwks import JWK
+
+# RSA key
+rsa_jwk = JWK.from_dict({
+ "kty": "RSA",
+ "kid": "rsa-key-1",
+ "use": "sig",
+ "n": "sT6MoYl9dkMnMzT3eLzFfYjpY3oN...",
+ "e": "AQAB",
+})
+print(rsa_jwk.kid) # "rsa-key-1"
+print(rsa_jwk.kty) # "RSA"
+
+# EC key
+ec_jwk = JWK.from_dict({
+ "kty": "EC",
+ "kid": "ec-key-1",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+})
+print(ec_jwk.crv) # "P-256"
+```
+
+### Building RSA and EC PEMs from raw parameters
+
+GuardPost exposes helper functions for building PEM-encoded keys from raw
+base64url parameters — useful when you receive individual key parameters
+instead of a full JWKS document.
+
+```python {linenums="1"}
+from guardpost.jwks import rsa_pem_from_n_and_e, ec_pem_from_x_y_crv
+
+# Build an RSA public key PEM from base64url modulus and exponent
+rsa_pem: bytes = rsa_pem_from_n_and_e(
+ n="sT6MoYl9dkMnMzT3...",
+ e="AQAB",
+)
+
+# Build an EC public key PEM from base64url x, y and curve name
+ec_pem: bytes = ec_pem_from_x_y_crv(
+ x="f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ y="x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+ crv="P-256",
+)
+```
+
+## The `JWKS` dataclass
+
+`JWKS` represents a complete JSON Web Key Set — a collection of `JWK` objects.
+
+### Parsing from a dict
+
+```python {linenums="1"}
+from guardpost.jwks import JWKS
+
+raw = {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "key-1",
+ "use": "sig",
+ "n": "sT6MoYl9dkMnMzT3...",
+ "e": "AQAB",
+ },
+ {
+ "kty": "EC",
+ "kid": "key-2",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+ },
+ ]
+}
+
+jwks = JWKS.from_dict(raw)
+print(len(jwks.keys)) # 2
+print(jwks.keys[0].kid) # "key-1"
+```
+
+### Updating a key set
+
+`JWKS.update(new_set)` merges the keys from another `JWKS` into the current
+one, replacing existing keys that share the same `kid`.
+
+```python {linenums="1"}
+from guardpost.jwks import JWKS
+
+existing = JWKS.from_dict({"keys": [{"kty": "RSA", "kid": "k1", "n": "...", "e": "AQAB"}]})
+updated = JWKS.from_dict({"keys": [{"kty": "RSA", "kid": "k2", "n": "...", "e": "AQAB"}]})
+
+existing.update(updated)
+# existing now contains both k1 and k2
+```
+
+## `InMemoryKeysProvider`
+
+`InMemoryKeysProvider` wraps a static `JWKS` object. Use it in tests or when
+you pre-load keys from configuration.
+
+```python {linenums="1"}
+from guardpost.jwks import JWKS, InMemoryKeysProvider
+from guardpost.jwts import AsymmetricJWTValidator
+
+jwks = JWKS.from_dict({
+ "keys": [
+ {"kty": "RSA", "kid": "k1", "n": "...", "e": "AQAB"}
+ ]
+})
+
+provider = InMemoryKeysProvider(jwks)
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ keys_provider=provider,
+)
+```
+
+## `URLKeysProvider`
+
+`URLKeysProvider` fetches a JWKS document from a URL on demand. Use it when
+your identity provider exposes a dedicated JWKS endpoint without an OpenID
+Connect discovery document.
+
+```python {linenums="1"}
+from guardpost.jwks import URLKeysProvider
+from guardpost.jwts import AsymmetricJWTValidator
+
+provider = URLKeysProvider("https://auth.example.com/.well-known/jwks.json")
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ keys_provider=provider,
+)
+```
+
+## `AuthorityKeysProvider`
+
+`AuthorityKeysProvider` uses OpenID Connect discovery to locate the JWKS URI
+automatically. Provide the issuer URL and it will fetch
+`/.well-known/openid-configuration`, parse the `jwks_uri` field,
+and retrieve the key set from there.
+
+```python {linenums="1"}
+from guardpost.jwks import AuthorityKeysProvider
+from guardpost.jwts import AsymmetricJWTValidator
+
+provider = AuthorityKeysProvider("https://login.microsoftonline.com/tenant/v2.0")
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://login.microsoftonline.com/tenant/v2.0"],
+ valid_audiences=["api://my-app"],
+ keys_provider=provider,
+)
+```
+
+/// admonition | Shorthand
+ type: tip
+
+Passing `authority="..."` to `AsymmetricJWTValidator` automatically creates
+an `AuthorityKeysProvider` internally. You only need to create one manually
+if you want to compose it with `CachingKeysProvider`.
+///
+
+## `CachingKeysProvider`
+
+`CachingKeysProvider` wraps any other `KeysProvider` and adds TTL-based
+caching. This avoids hammering the JWKS endpoint on every token validation.
+
+Key features:
+
+- Keys are cached for `cache_time` seconds after each fetch.
+- When the cache age exceeds `cache_time - refresh_time`, a background refresh
+ is triggered proactively.
+- If a token's `kid` is not found in the cached set, the cache is bypassed
+ and the JWKS endpoint is queried immediately, supporting seamless key rotation.
+
+```python {linenums="1"}
+from guardpost.jwks import AuthorityKeysProvider, CachingKeysProvider
+from guardpost.jwts import AsymmetricJWTValidator
+
+inner_provider = AuthorityKeysProvider("https://auth.example.com/")
+
+caching_provider = CachingKeysProvider(
+ provider=inner_provider,
+ cache_time=3600, # cache for 1 hour
+ refresh_time=120, # refresh 2 minutes before expiry
+)
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ keys_provider=caching_provider,
+)
+```
+
+/// admonition | Default caching
+ type: info
+
+When you use the `authority` or `keys_url` shorthand on `AsymmetricJWTValidator`,
+caching is set up automatically with the `cache_time` and `refresh_time`
+parameters you provide (defaulting to 10800 s and 120 s respectively).
+///
+
+## Supported EC curves
+
+| Curve | JWT algorithm | Description |
+|-------|---------------|-------------|
+| `P-256` | `ES256` | 256-bit NIST curve (most common) |
+| `P-384` | `ES384` | 384-bit NIST curve |
+| `P-521` | `ES512` | 521-bit NIST curve |
+
+```python {linenums="1"}
+from guardpost.jwks import JWK
+
+p256 = JWK.from_dict({"kty": "EC", "crv": "P-256", "x": "...", "y": "..."})
+p384 = JWK.from_dict({"kty": "EC", "crv": "P-384", "x": "...", "y": "..."})
+p521 = JWK.from_dict({"kty": "EC", "crv": "P-521", "x": "...", "y": "..."})
+```
diff --git a/guardpost/docs/jwt-validation.md b/guardpost/docs/jwt-validation.md
new file mode 100644
index 0000000..6bc7e6f
--- /dev/null
+++ b/guardpost/docs/jwt-validation.md
@@ -0,0 +1,364 @@
+# JWT Validation
+
+This page covers GuardPost's built-in JWT validation support, including:
+
+- [X] Installing the JWT extra
+- [X] `AsymmetricJWTValidator` for RSA and EC keys
+- [X] `SymmetricJWTValidator` for HMAC keys
+- [X] `CompositeJWTValidator` — trying multiple validators
+- [X] Key sources: `authority`, `keys_url`, `keys_provider`
+- [X] The `require_kid` parameter
+- [X] Caching behaviour (`cache_time`, `refresh_time`)
+- [X] `InvalidAccessToken` and `ExpiredAccessToken` exceptions
+- [X] Real-world example: validating tokens from popular identity providers
+
+## Installation
+
+JWT validation is an optional feature. Install the extra to enable it:
+
+```shell
+pip install guardpost[jwt]
+```
+
+/// admonition | Dependencies
+ type: info
+
+The `[jwt]` extra pulls in `PyJWT` and `cryptography`. These are not
+installed by default because many applications use GuardPost only for
+policy-based authorization without needing JWT parsing.
+///
+
+## `AsymmetricJWTValidator`
+
+`AsymmetricJWTValidator` validates JWTs signed with asymmetric keys:
+
+| Algorithm family | Algorithms |
+| ------------------- | ------------------------- |
+| RSA | `RS256`, `RS384`, `RS512` |
+| EC (Elliptic Curve) | `ES256`, `ES384`, `ES512` |
+
+### RSA keys (RS256)
+
+```python {linenums="1"}
+from guardpost.jwts import AsymmetricJWTValidator
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ algorithms=["RS256"],
+ # Fetch JWKS from the OpenID Connect discovery endpoint:
+ authority="https://auth.example.com/",
+ # cache_time: how long (seconds) to cache keys before re-fetching
+ cache_time=10800, # 3 hours (default)
+ # refresh_time: how long after cache_time before proactively refreshing
+ refresh_time=120, # 2 minutes (default)
+)
+
+# Validate a token string — raises InvalidAccessToken or ExpiredAccessToken on failure
+claims = await validator.validate_jwt(raw_token)
+print(claims["sub"])
+```
+
+### EC keys (ES256)
+
+```python {linenums="1"}
+from guardpost.jwts import AsymmetricJWTValidator
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ algorithms=["ES256"],
+ authority="https://auth.example.com/",
+)
+```
+
+### Parameters reference
+
+| Parameter | Type | Default | Description |
+| ----------------- | -------------- | ----------- | --------------------------------------------------- |
+| `valid_issuers` | `list[str]` | required | Accepted `iss` claim values |
+| `valid_audiences` | `list[str]` | required | Accepted `aud` claim values |
+| `algorithms` | `list[str]` | `["RS256"]` | Allowed signing algorithms |
+| `authority` | `str` | `None` | OpenID Connect issuer URL (auto-discovers JWKS URI) |
+| `keys_url` | `str` | `None` | Direct JWKS endpoint URL |
+| `keys_provider` | `KeysProvider` | `None` | Custom keys provider instance |
+| `require_kid` | `bool` | `True` | Reject tokens that lack a `kid` header |
+| `cache_time` | `int` | `10800` | Seconds before cached keys expire |
+| `refresh_time` | `int` | `120` | Seconds before expiry to start proactive refresh |
+
+## `SymmetricJWTValidator`
+
+`SymmetricJWTValidator` validates JWTs signed with HMAC shared secrets
+(`HS256`, `HS384`, `HS512`). This is common in server-to-server scenarios
+where both sides share a secret.
+
+```python {linenums="1"}
+from guardpost.jwts import SymmetricJWTValidator
+
+validator = SymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ secret_key="my-super-secret-key", # str, bytes, or Secret
+ algorithms=["HS256"],
+)
+
+claims = await validator.validate_jwt(raw_token)
+print(claims["sub"])
+```
+
+/// admonition | Secret key types
+ type: tip
+
+`secret_key` accepts a plain `str`, `bytes`, or a `Secret` wrapper object,
+so you can keep sensitive values out of your source code by reading them
+from environment variables.
+///
+
+## `CompositeJWTValidator`
+
+When your application must accept tokens from multiple issuers or signed with
+different key types, use `CompositeJWTValidator`. It tries each validator in
+order and returns the first successful result.
+
+```python {linenums="1"}
+from guardpost.jwts import (
+ AsymmetricJWTValidator,
+ CompositeJWTValidator,
+ SymmetricJWTValidator,
+)
+
+validator = CompositeJWTValidator(
+ AsymmetricJWTValidator(
+ valid_issuers=["https://external-idp.com/"],
+ valid_audiences=["my-api"],
+ authority="https://external-idp.com/",
+ ),
+ SymmetricJWTValidator(
+ valid_issuers=["https://internal-service/"],
+ valid_audiences=["my-api"],
+ secret_key="internal-secret",
+ ),
+)
+
+claims = await validator.validate_jwt(raw_token)
+```
+
+## Key sources
+
+GuardPost supports three ways to supply public keys to `AsymmetricJWTValidator`.
+
+=== "OpenID Connect authority"
+
+ The most common approach. Provide the issuer URL and GuardPost will
+ automatically discover the JWKS URI from the `.well-known/openid-configuration`
+ endpoint.
+
+ ```python {linenums="1"}
+ validator = AsymmetricJWTValidator(
+ valid_issuers=["https://login.microsoftonline.com/tenant-id/v2.0"],
+ valid_audiences=["api://my-app-id"],
+ authority="https://login.microsoftonline.com/tenant-id/v2.0",
+ )
+ ```
+
+=== "Direct JWKS URL"
+
+ Provide the JWKS endpoint URL directly, bypassing discovery.
+
+ ```python {linenums="1"}
+ validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ keys_url="https://auth.example.com/.well-known/jwks.json",
+ )
+ ```
+
+=== "Custom keys provider"
+
+ Implement `KeysProvider` or use `InMemoryKeysProvider` for testing.
+
+ ```python {linenums="1"}
+ from guardpost.jwts import AsymmetricJWTValidator
+ from guardpost.jwks import JWKS, JWK, InMemoryKeysProvider
+
+ # Build a provider from a raw JWKS dict (e.g. loaded from a file)
+ jwks_dict = {
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "my-key-1",
+ "use": "sig",
+ "n": "",
+ "e": "AQAB",
+ }
+ ]
+ }
+ jwks = JWKS.from_dict(jwks_dict)
+ provider = InMemoryKeysProvider(jwks)
+
+ validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ keys_provider=provider,
+ )
+ ```
+
+## The `require_kid` parameter
+
+By default, `AsymmetricJWTValidator` rejects tokens that do not contain a
+`kid` (Key ID) header claim. This is a security best practice: `kid` lets the
+validator select the correct key from the JWKS and avoids trying all available
+keys.
+
+```python {linenums="1"}
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ authority="https://auth.example.com/",
+ require_kid=False, # accept tokens without a kid header
+)
+```
+
+/// admonition | When to disable `require_kid`
+ type: warning
+
+Only set `require_kid=False` when your identity provider does not include `kid`
+in tokens. This forces GuardPost to try every key in the JWKS, which is slower
+and slightly less secure.
+///
+
+## Caching behaviour
+
+Fetching JWKS over HTTP on every token validation would be slow. GuardPost
+caches keys automatically:
+
+- After the first fetch, keys are cached for `cache_time` seconds (default 3 hours).
+- When `cache_time - refresh_time` seconds have passed, a background refresh is
+ triggered proactively to avoid downtime during key rotation.
+- If a token carries an unknown `kid`, the cache is bypassed immediately and
+ the JWKS endpoint is re-queried. This handles key rotation without waiting
+ for the cache to expire.
+
+```python {linenums="1"}
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ authority="https://auth.example.com/",
+ cache_time=3600, # cache keys for 1 hour
+ refresh_time=60, # start refreshing 1 minute before expiry
+)
+```
+
+## Exceptions
+
+| Exception | When raised |
+| -------------------- | ----------------------------------------------------------------------- |
+| `InvalidAccessToken` | The JWT is malformed, the signature is invalid, or the claims are wrong |
+| `ExpiredAccessToken` | The JWT has a valid signature but is past its `exp` claim |
+
+`ExpiredAccessToken` is a subclass of `InvalidAccessToken`, so you can catch
+either or both.
+
+```python {linenums="1"}
+from guardpost.jwts import ExpiredAccessToken, InvalidAccessToken
+
+try:
+ claims = await validator.validate_jwt(raw_token)
+except ExpiredAccessToken:
+ # Tell the client to refresh their token
+ print("Token has expired.")
+except InvalidAccessToken as exc:
+ # The token is bad — reject the request
+ print(f"Invalid token: {exc}")
+```
+
+## Real-world example: popular identity providers
+
+/// admonition | Supported identity providers
+ type: info
+
+GuardPost has been tested with the following identity providers:
+
+- **Auth0** — `authority="https://.auth0.com/"`
+- **Entra ID** — `authority="https://login.microsoftonline.com//v2.0"`
+- **Azure AD B2C** — `authority="https://.b2clogin.com/.onmicrosoft.com//v2.0"`
+- **Okta** — `authority="https:///oauth2/default"`
+///
+
+=== "Auth0"
+
+ ```python {linenums="1"}
+ from guardpost.jwts import AsymmetricJWTValidator
+
+ validator = AsymmetricJWTValidator(
+ valid_issuers=["https://my-tenant.auth0.com/"],
+ valid_audiences=["https://my-api.example.com"],
+ authority="https://my-tenant.auth0.com/",
+ algorithms=["RS256"],
+ )
+ ```
+
+=== "Azure AD"
+
+ ```python {linenums="1"}
+ from guardpost.jwts import AsymmetricJWTValidator
+
+ TENANT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ APP_ID = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
+
+ validator = AsymmetricJWTValidator(
+ valid_issuers=[
+ f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
+ f"https://sts.windows.net/{TENANT_ID}/",
+ ],
+ valid_audiences=[f"api://{APP_ID}"],
+ authority=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
+ algorithms=["RS256"],
+ )
+ ```
+
+=== "Okta"
+
+ ```python {linenums="1"}
+ from guardpost.jwts import AsymmetricJWTValidator
+
+ validator = AsymmetricJWTValidator(
+ valid_issuers=["https://my-org.okta.com/oauth2/default"],
+ valid_audiences=["api://default"],
+ authority="https://my-org.okta.com/oauth2/default",
+ algorithms=["RS256"],
+ )
+ ```
+
+## Using the validator as an `AuthenticationHandler`
+
+`AsymmetricJWTValidator` and `SymmetricJWTValidator` implement the
+`AuthenticationHandler` interface, so they can be plugged directly into
+`AuthenticationStrategy`:
+
+```python {linenums="1"}
+from guardpost import AuthenticationStrategy
+from guardpost.jwts import AsymmetricJWTValidator
+
+
+class MockContext:
+ def __init__(self, authorization: str | None = None):
+ self.authorization = authorization
+ self.identity = None
+
+ @property
+ def token(self) -> str | None:
+ if self.authorization and self.authorization.startswith("Bearer "):
+ return self.authorization[7:]
+ return None
+
+
+validator = AsymmetricJWTValidator(
+ valid_issuers=["https://auth.example.com/"],
+ valid_audiences=["my-api"],
+ authority="https://auth.example.com/",
+)
+
+strategy = AuthenticationStrategy(validator)
+# strategy.authenticate(context) will parse and validate the Bearer token
+```
diff --git a/guardpost/docs/protection.md b/guardpost/docs/protection.md
new file mode 100644
index 0000000..ccaa5f4
--- /dev/null
+++ b/guardpost/docs/protection.md
@@ -0,0 +1,193 @@
+# Brute-force Protection
+
+This page describes GuardPost's built-in brute-force protection feature,
+including:
+
+- [X] Overview of the protection feature
+- [X] `InvalidCredentialsError`
+- [X] `RateLimiter` class — configuration and thresholds
+- [X] Integration with `AuthenticationStrategy`
+- [X] Custom storage backends
+
+## Overview
+
+Brute-force attacks against authentication endpoints (login forms, API key
+checks, etc.) are a common threat. GuardPost provides a `RateLimiter` that
+automatically tracks failed authentication attempts and blocks a client after
+a configurable threshold is exceeded.
+
+The mechanism works as follows:
+
+1. Your `AuthenticationHandler` raises `InvalidCredentialsError` when presented
+ with wrong credentials.
+2. `AuthenticationStrategy` catches this exception, increments the failure
+ counter for the client, and re-raises (or blocks) as appropriate.
+3. Once the failure count reaches the threshold, subsequent requests from the
+ same client are rejected immediately without even calling the handler.
+
+## `InvalidCredentialsError`
+
+`InvalidCredentialsError` is a subclass of `AuthenticationError`. Raise it
+inside an `AuthenticationHandler` whenever you detect that credentials are
+present but invalid (wrong password, revoked key, etc.).
+
+```python {linenums="1"}
+from guardpost import AuthenticationHandler, Identity
+from guardpost.protection import InvalidCredentialsError
+
+
+class PasswordHandler(AuthenticationHandler):
+ scheme = "Basic"
+
+ async def authenticate(self, context) -> None:
+ username = getattr(context, "username", None)
+ password = getattr(context, "password", None)
+
+ if username and password:
+ if self._check_credentials(username, password):
+ context.identity = Identity(
+ {"sub": username}, self.scheme
+ )
+ else:
+ # Signal a failed attempt — the rate limiter will count this
+ raise InvalidCredentialsError(f"Invalid password for '{username}'")
+
+ def _check_credentials(self, username: str, password: str) -> bool:
+ # Replace with a real database lookup
+ return username == "alice" and password == "correct-password"
+```
+
+/// admonition | Why a dedicated exception?
+ type: info
+
+Using `InvalidCredentialsError` (rather than simply leaving `context.identity`
+as `None`) lets `AuthenticationStrategy` distinguish between
+_"no credentials provided"_ (anonymous request — don't count) and
+_"wrong credentials provided"_ (brute-force attempt — do count).
+///
+
+## `RateLimiter`
+
+`RateLimiter` stores per-client failure counters and exposes a `check` method
+that blocks clients that exceed the threshold.
+
+```python {linenums="1"}
+from guardpost.protection import RateLimiter
+
+limiter = RateLimiter(
+ max_attempts=5, # allow up to 5 failures before blocking
+ duration=300, # time window in seconds (5 minutes)
+)
+```
+
+| Parameter | Type | Default | Description |
+| -------------- | ----- | ------- | ---------------------------------------------- |
+| `max_attempts` | `int` | `5` | Max failures allowed within `duration` seconds |
+| `duration` | `int` | `300` | Time window in seconds for the failure counter |
+
+By default, counters are stored **in memory** — they do not persist across
+process restarts and are not shared between multiple processes. This is
+sufficient for single-process applications. See
+[Custom storage backends](#custom-storage-backends) for distributed setups.
+
+## Integration with `AuthenticationStrategy`
+
+Pass a `RateLimiter` instance to `AuthenticationStrategy` to enable
+brute-force protection automatically.
+
+```python {linenums="1"}
+import asyncio
+from guardpost import AuthenticationHandler, AuthenticationStrategy, Identity
+from guardpost.protection import InvalidCredentialsError, RateLimiter
+
+
+class MockContext:
+ def __init__(self, username=None, password=None, client_ip="127.0.0.1"):
+ self.username = username
+ self.password = password
+ self.client_ip = client_ip
+ self.identity = None
+
+ # The rate limiter uses this property to identify the client
+ @property
+ def client_id(self) -> str:
+ return self.client_ip
+
+
+class PasswordHandler(AuthenticationHandler):
+ scheme = "Basic"
+
+ async def authenticate(self, context: MockContext) -> None:
+ if context.username and context.password:
+ if context.username == "alice" and context.password == "s3cr3t":
+ context.identity = Identity(
+ {"sub": context.username}, self.scheme
+ )
+ else:
+ raise InvalidCredentialsError("Bad credentials.")
+
+
+async def main():
+ limiter = RateLimiter(max_attempts=3, duration=60)
+ strategy = AuthenticationStrategy(PasswordHandler(), rate_limiter=limiter)
+
+ # Simulate repeated failures from the same IP
+ for attempt in range(4):
+ ctx = MockContext(username="alice", password="wrong", client_ip="10.0.0.1")
+ try:
+ await strategy.authenticate(ctx)
+ except Exception as exc:
+ print(f"Attempt {attempt + 1}: {type(exc).__name__} — {exc}")
+
+
+asyncio.run(main())
+```
+
+Expected output:
+
+```
+Attempt 1: InvalidCredentialsError — Bad credentials.
+Attempt 2: InvalidCredentialsError — Bad credentials.
+Attempt 3: InvalidCredentialsError — Bad credentials.
+Attempt 4: TooManyRequestsError — Too many failed attempts from 10.0.0.1
+```
+
+/// admonition | Client identification
+ type: tip
+
+The rate limiter identifies clients using `context.client_id` if the property
+exists, otherwise it falls back to the string representation of the context.
+In web frameworks like BlackSheep, `client_id` is automatically mapped to the
+client IP address.
+///
+
+## Custom storage backends
+
+The default in-memory storage is suitable for single-process applications. For
+distributed systems (multiple workers or processes), you need a shared store
+such as Redis.
+
+You can implement a custom backend by subclassing `RateLimiter` and overriding
+the `get_attempts` / `increment_attempts` methods:
+
+```python {linenums="1"}
+from guardpost.protection import RateLimiter
+
+
+class RedisRateLimiter(RateLimiter):
+ def __init__(self, redis_client, **kwargs):
+ super().__init__(**kwargs)
+ self._redis = redis_client
+
+ async def get_attempts(self, client_id: str) -> int:
+ value = await self._redis.get(f"guardpost:attempts:{client_id}")
+ return int(value) if value else 0
+
+ async def increment_attempts(self, client_id: str) -> int:
+ key = f"guardpost:attempts:{client_id}"
+ count = await self._redis.incr(key)
+ if count == 1:
+ # Set TTL on first increment
+ await self._redis.expire(key, self.duration)
+ return count
+```
diff --git a/guardpost/mkdocs.yml b/guardpost/mkdocs.yml
new file mode 100644
index 0000000..cb42f60
--- /dev/null
+++ b/guardpost/mkdocs.yml
@@ -0,0 +1,89 @@
+site_name: GuardPost
+site_author: Roberto Prevato
+site_description: GuardPost, an authentication and authorization framework for Python
+site_url: https://www.neoteroi.dev/guardpost/
+repo_name: Neoteroi/guardpost
+repo_url: https://github.com/Neoteroi/guardpost
+edit_uri: ""
+
+nav:
+ - Overview: index.md
+ - Getting started: getting-started.md
+ - Authentication: authentication.md
+ - Authorization: authorization.md
+ - JWT validation: jwt-validation.md
+ - JWKS and key types: jwks.md
+ - Brute-force protection: protection.md
+ - Dependency injection: dependency-injection.md
+ - Errors: errors.md
+ - About: about.md
+ - Neoteroi docs home: "/"
+
+theme:
+ features:
+ - navigation.footer
+ - content.code.copy
+ - content.action.view
+ palette:
+ - scheme: slate
+ toggle:
+ icon: material/toggle-switch
+ name: Switch to light mode
+ - scheme: default
+ toggle:
+ icon: material/toggle-switch-off-outline
+ name: Switch to dark mode
+ name: "material"
+ custom_dir: overrides/
+ favicon: img/neoteroi.ico
+ logo: img/neoteroi-w.svg
+ icon:
+ repo: fontawesome/brands/github
+
+validation:
+ links:
+ absolute_links: ignore
+
+watch:
+ - docs
+ - overrides
+
+extra:
+ header_bg_color: "#2b579a"
+
+extra_css:
+ - css/neoteroi.css
+ - css/extra.css?v=20221120
+
+extra_javascript:
+ - js/fullscreen.js
+
+plugins:
+ - search
+ - neoteroi.contribs:
+ enabled_by_env: "GIT_CONTRIBS_ON"
+
+markdown_extensions:
+ - pymdownx.highlight:
+ use_pygments: true
+ guess_lang: false
+ anchor_linenums: true
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.tasklist:
+ custom_checkbox: true
+ - pymdownx.tabbed:
+ alternate_style: true
+ - toc:
+ permalink: true
+ - pymdownx.blocks.admonition
+ - pymdownx.blocks.details
+ - neoteroi.timeline
+ - neoteroi.cards
+ - neoteroi.projects
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
diff --git a/guardpost/overrides/main.html b/guardpost/overrides/main.html
new file mode 100644
index 0000000..f51ad86
--- /dev/null
+++ b/guardpost/overrides/main.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% block extrahead %}
+ {% set title = config.site_name %}
+ {% if page and page.title and not page.is_homepage %}
+ {% set title = config.site_name ~ " - " ~ page.title | striptags %}
+ {% endif %}
+ {% set image = config.site_url ~ 'img/banner.png' %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block content %}
+ {{ super() }}
+{% endblock %}
+{% block analytics %}
+
+
+{% endblock %}
diff --git a/guardpost/overrides/partials/comments.html b/guardpost/overrides/partials/comments.html
new file mode 100644
index 0000000..88407b5
--- /dev/null
+++ b/guardpost/overrides/partials/comments.html
@@ -0,0 +1,49 @@
+{% if not page.meta.no_comments %}
+
+
+
+
+
+{% endif %}
diff --git a/guardpost/overrides/partials/content.html b/guardpost/overrides/partials/content.html
new file mode 100644
index 0000000..63fa92f
--- /dev/null
+++ b/guardpost/overrides/partials/content.html
@@ -0,0 +1,16 @@
+{% if "tags" in config.plugins %}
+ {% include "partials/tags.html" %}
+{% endif %}
+{% include "partials/actions.html" %}
+{% if not "\x3ch1" in page.content %}
+
{{ page.title | d(config.site_name, true)}}
+{% endif %}
+{{ page.content }}
+{% if page.meta and (
+ page.meta.git_revision_date_localized or
+ page.meta.revision_date
+) %}
+ {% include "partials/source-file.html" %}
+{% endif %}
+{% include "partials/feedback.html" %}
+{% include "partials/comments.html" %}
diff --git a/guardpost/overrides/partials/header.html b/guardpost/overrides/partials/header.html
new file mode 100644
index 0000000..4842e4c
--- /dev/null
+++ b/guardpost/overrides/partials/header.html
@@ -0,0 +1,76 @@
+{#-
+ This file was automatically generated - do not edit
+-#}
+{% set class = "md-header" %}
+{% if "navigation.tabs.sticky" in features %}
+ {% set class = class ~ " md-header--shadow md-header--lifted" %}
+{% elif "navigation.tabs" not in features %}
+ {% set class = class ~ " md-header--shadow" %}
+{% endif %}
+
+
+ {% if "navigation.tabs.sticky" in features %}
+ {% if "navigation.tabs" in features %}
+ {% include "partials/tabs.html" %}
+ {% endif %}
+ {% endif %}
+
diff --git a/home/docs/index.md b/home/docs/index.md
index 303afc8..a3bc370 100644
--- a/home/docs/index.md
+++ b/home/docs/index.md
@@ -23,6 +23,12 @@ the documentation of some of the projects.
image: ./img/gantt.png
url: /mkdocs-plugins/
+- title: GuardPost
+ content: |
+ Authentication and Authorization framework for Python.
+ image: ./img/index.png
+ url: /guardpost/
+
::/cards::
---
diff --git a/pack.sh b/pack.sh
index 248eced..09a2009 100755
--- a/pack.sh
+++ b/pack.sh
@@ -3,6 +3,7 @@ folders=(
blacksheep
rodi
mkdocs-plugins
+ guardpost
)
rm -rf site