From 7707861b8613dbeb3815558678f0a470f233f2e2 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 10 Mar 2026 19:18:44 +0100 Subject: [PATCH 1/5] Start documenting GuardPost --- guardpost/docs/about.md | 31 ++ guardpost/docs/authentication.md | 260 +++++++++++++++ guardpost/docs/authorization.md | 278 ++++++++++++++++ guardpost/docs/css/extra.css | 118 +++++++ guardpost/docs/css/neoteroi.css | 1 + guardpost/docs/dependency-injection.md | 234 +++++++++++++ guardpost/docs/errors.md | 171 ++++++++++ guardpost/docs/getting-started.md | 270 +++++++++++++++ guardpost/docs/img/neoteroi-w.svg | 74 +++++ guardpost/docs/img/neoteroi.ico | Bin 0 -> 305984 bytes guardpost/docs/index.md | 32 ++ guardpost/docs/js/fullscreen.js | 26 ++ guardpost/docs/jwks.md | 270 +++++++++++++++ guardpost/docs/jwt-validation.md | 364 +++++++++++++++++++++ guardpost/docs/protection.md | 193 +++++++++++ guardpost/mkdocs.yml | 89 +++++ guardpost/overrides/main.html | 43 +++ guardpost/overrides/partials/comments.html | 49 +++ guardpost/overrides/partials/content.html | 16 + guardpost/overrides/partials/header.html | 76 +++++ home/docs/index.md | 6 + pack.sh | 1 + 22 files changed, 2602 insertions(+) create mode 100644 guardpost/docs/about.md create mode 100644 guardpost/docs/authentication.md create mode 100644 guardpost/docs/authorization.md create mode 100644 guardpost/docs/css/extra.css create mode 100644 guardpost/docs/css/neoteroi.css create mode 100644 guardpost/docs/dependency-injection.md create mode 100644 guardpost/docs/errors.md create mode 100644 guardpost/docs/getting-started.md create mode 100644 guardpost/docs/img/neoteroi-w.svg create mode 100644 guardpost/docs/img/neoteroi.ico create mode 100644 guardpost/docs/index.md create mode 100644 guardpost/docs/js/fullscreen.js create mode 100644 guardpost/docs/jwks.md create mode 100644 guardpost/docs/jwt-validation.md create mode 100644 guardpost/docs/protection.md create mode 100644 guardpost/mkdocs.yml create mode 100644 guardpost/overrides/main.html create mode 100644 guardpost/overrides/partials/comments.html create mode 100644 guardpost/overrides/partials/content.html create mode 100644 guardpost/overrides/partials/header.html diff --git a/guardpost/docs/about.md b/guardpost/docs/about.md new file mode 100644 index 0000000..0b17500 --- /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/) +- [Azure Active Directory](https://azure.microsoft.com/en-us/products/active-directory) +- [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..722838e --- /dev/null +++ b/guardpost/docs/authentication.md @@ -0,0 +1,260 @@ +# 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(claims={"sub": "user-1"}, scheme="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( + claims=user_info, scheme=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( + claims={"sub": sub}, scheme=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( + claims={"sub": "user-from-cookie"}, scheme=self.scheme + ) +``` + +## The `Identity` class and its claims + +`Identity` wraps a `dict` of claims and a `scheme` string. + +```python {linenums="1"} +from guardpost import Identity + +identity = Identity( + claims={ + "sub": "user-42", + "name": "Bob", + "email": "bob@example.com", + "roles": ["editor"], + "iss": "https://auth.example.com", + }, + scheme="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"] + +# Scheme +print(identity.scheme) # "Bearer" + +# Authentication check +print(identity.is_authenticated()) # True +print(Identity.is_authenticated()) # False (class method, no instance) +``` + +/// admonition | `None` means unauthenticated + type: info + +`context.identity` starts as `None`. A handler only sets it when authentication +succeeds. Code that needs an authenticated user should check `context.identity` +before proceeding, or rely on `AuthorizationStrategy` which raises +`UnauthorizedError` automatically when the identity is `None`. +/// + +## 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( + claims={"sub": "u1", "name": "Alice"}, scheme=self.scheme + ) + + +class ApiKeyHandler(AuthenticationHandler): + scheme = "ApiKey" + + def authenticate(self, context) -> None: + if context.api_key == "svc-key": + context.identity = Identity( + claims={"sub": "service-a"}, scheme=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.scheme) # "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.scheme` 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( + claims={"sub": "user-1"}, scheme=self.scheme + ) + + +class ApiKeyHandler(AuthenticationHandler): + scheme = "ApiKey" + + def authenticate(self, context) -> None: + if context.api_key: + context.identity = Identity( + claims={"sub": "svc-1"}, scheme=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.scheme == "Bearer": + print(f"Human user: {context.identity.sub}") + elif context.identity.scheme == "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..3bd0d89 --- /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(claims={"sub": "u1", "roles": ["admin"]}, scheme="Bearer") + await strategy.authorize("admin", admin) + print("Authorized ✔") + + # ForbiddenError — authenticated but lacks role + viewer = Identity(claims={"sub": "u2", "roles": ["viewer"]}, scheme="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( + claims={"sub": "u1", "roles": ["editor"], "email_verified": True}, + scheme="Bearer", + ) + await strategy.authorize("verified-editor", ok_identity) + print("Authorized ✔") + + bad_identity = Identity( + claims={"sub": "u2", "roles": ["editor"], "email_verified": False}, + scheme="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` — the request is unauthenticated | +| `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..0c7c00d --- /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(claims={"sub": "u1"}, scheme="Bearer") + await strategy.authorize("delete", power_user) + print("Authorized ✔") + + from guardpost.authorization import ForbiddenError + + basic_user = Identity(claims={"sub": "u2"}, scheme="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(claims=user, scheme=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..c2cfe1b --- /dev/null +++ b/guardpost/docs/getting-started.md @@ -0,0 +1,270 @@ +# 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 a **scheme** string that +indicates how the identity was authenticated. + +```python {linenums="1"} +from guardpost import Identity + +# Create an identity with claims +identity = Identity( + claims={ + "sub": "user-123", + "name": "Alice", + "email": "alice@example.com", + "roles": ["admin", "editor"], + }, + scheme="Bearer", +) + +print(identity.sub) # "user-123" +print(identity.name) # "Alice" +print(identity["email"]) # "alice@example.com" — dict-style access +print(identity.is_authenticated()) # True + +# An identity with no claims is still truthy, but conventionally +# a None identity means "not authenticated" +``` + +/// admonition | Unauthenticated identity + type: info + +By convention, `None` represents an unauthenticated request. `Identity.is_authenticated()` +returns `True` for any non-`None` identity instance, regardless of its claims. +/// + +## 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( + claims={"sub": "user-1", "name": "Alice"}, + scheme=self.scheme, + ) + # If the token is missing or wrong we simply leave context.identity as None +``` + +/// 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( + claims={"sub": "user-1", "name": "Alice"}, + scheme=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( + claims={"sub": "u1", "roles": ["admin"]}, scheme="Bearer" + ) + await strategy.authorize("admin", admin_identity) + print("Admin authorized ✔") + + # --- Regular user: forbidden --- + from guardpost.authorization import ForbiddenError + + user_identity = Identity( + claims={"sub": "u2", "roles": ["viewer"]}, scheme="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 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/guardpost/docs/img/neoteroi.ico b/guardpost/docs/img/neoteroi.ico new file mode 100644 index 0000000000000000000000000000000000000000..11cd8148480e2e78fc3647eaac8dc6f32448419b GIT binary patch literal 305984 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 sz4$i$3*pLzTTxOPY3Dp&y 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 CZ0?8v literal 0 HcmV?d00001 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..bef5a31 --- /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/"` +- **Azure Active Directory** — `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..c079363 --- /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( + claims={"sub": username}, scheme=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( + claims={"sub": context.username}, scheme=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 From ba3298aa3ec1fce441dffdc72a03af89ac405711 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 10 Mar 2026 19:31:40 +0100 Subject: [PATCH 2/5] Fix --- guardpost/docs/authentication.md | 38 ++++++++++++++------------ guardpost/docs/authorization.md | 10 +++---- guardpost/docs/dependency-injection.md | 4 +-- guardpost/docs/getting-started.md | 22 +++++++++------ 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/guardpost/docs/authentication.md b/guardpost/docs/authentication.md index 722838e..3c77485 100644 --- a/guardpost/docs/authentication.md +++ b/guardpost/docs/authentication.md @@ -23,7 +23,7 @@ from guardpost import AuthenticationHandler, Identity class MyHandler(AuthenticationHandler): async def authenticate(self, context) -> None: # Read credentials from context, validate them, then: - context.identity = Identity(claims={"sub": "user-1"}, scheme="Bearer") + context.identity = Identity(claims={"sub": "user-1"}, authentication_mode="Bearer") ``` The `context` parameter is whatever your application uses to represent a @@ -99,7 +99,8 @@ class CookieHandler(AuthenticationHandler): ## The `Identity` class and its claims -`Identity` wraps a `dict` of claims and a `scheme` string. +`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 @@ -112,33 +113,36 @@ identity = Identity( "roles": ["editor"], "iss": "https://auth.example.com", }, - scheme="Bearer", + authentication_mode="Bearer", ) # Convenience properties -print(identity.sub) # "user-42" -print(identity.name) # "Bob" -print(identity.access_token) # None — not set +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"] +print(identity["email"]) # "bob@example.com" +print(identity.get("roles")) # ["editor"] -# Scheme -print(identity.scheme) # "Bearer" +# Authentication mode +print(identity.authentication_mode) # "Bearer" # Authentication check -print(identity.is_authenticated()) # True -print(Identity.is_authenticated()) # False (class method, no instance) +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 | `None` means unauthenticated +/// admonition | Anonymous vs no identity type: info -`context.identity` starts as `None`. A handler only sets it when authentication -succeeds. Code that needs an authenticated user should check `context.identity` -before proceeding, or rely on `AuthorizationStrategy` which raises -`UnauthorizedError` automatically when the identity is `None`. +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 diff --git a/guardpost/docs/authorization.md b/guardpost/docs/authorization.md index 3bd0d89..af3ac9a 100644 --- a/guardpost/docs/authorization.md +++ b/guardpost/docs/authorization.md @@ -125,12 +125,12 @@ async def main(): ) # Happy path — admin user - admin = Identity(claims={"sub": "u1", "roles": ["admin"]}, scheme="Bearer") + admin = Identity(claims={"sub": "u1", "roles": ["admin"]}, authentication_mode="Bearer") await strategy.authorize("admin", admin) print("Authorized ✔") # ForbiddenError — authenticated but lacks role - viewer = Identity(claims={"sub": "u2", "roles": ["viewer"]}, scheme="Bearer") + viewer = Identity(claims={"sub": "u2", "roles": ["viewer"]}, authentication_mode="Bearer") try: await strategy.authorize("admin", viewer) except ForbiddenError as exc: @@ -194,14 +194,14 @@ async def main(): ok_identity = Identity( claims={"sub": "u1", "roles": ["editor"], "email_verified": True}, - scheme="Bearer", + authentication_mode="Bearer", ) await strategy.authorize("verified-editor", ok_identity) print("Authorized ✔") bad_identity = Identity( claims={"sub": "u2", "roles": ["editor"], "email_verified": False}, - scheme="Bearer", + authentication_mode="Bearer", ) try: await strategy.authorize("verified-editor", bad_identity) @@ -216,7 +216,7 @@ asyncio.run(main()) | Exception | When raised | |-----------|-------------| -| `UnauthorizedError` | `identity` is `None` — the request is unauthenticated | +| `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`. diff --git a/guardpost/docs/dependency-injection.md b/guardpost/docs/dependency-injection.md index 0c7c00d..acc7ef5 100644 --- a/guardpost/docs/dependency-injection.md +++ b/guardpost/docs/dependency-injection.md @@ -142,13 +142,13 @@ async def main(): container=container, ) - power_user = Identity(claims={"sub": "u1"}, scheme="Bearer") + power_user = Identity(claims={"sub": "u1"}, authentication_mode="Bearer") await strategy.authorize("delete", power_user) print("Authorized ✔") from guardpost.authorization import ForbiddenError - basic_user = Identity(claims={"sub": "u2"}, scheme="Bearer") + basic_user = Identity(claims={"sub": "u2"}, authentication_mode="Bearer") try: await strategy.authorize("delete", basic_user) except ForbiddenError as exc: diff --git a/guardpost/docs/getting-started.md b/guardpost/docs/getting-started.md index c2cfe1b..4a8e8a8 100644 --- a/guardpost/docs/getting-started.md +++ b/guardpost/docs/getting-started.md @@ -39,23 +39,27 @@ identity = Identity( "email": "alice@example.com", "roles": ["admin", "editor"], }, - scheme="Bearer", + authentication_mode="Bearer", ) print(identity.sub) # "user-123" print(identity.name) # "Alice" print(identity["email"]) # "alice@example.com" — dict-style access -print(identity.is_authenticated()) # True +print(identity.is_authenticated()) # True — authentication_mode is set -# An identity with no claims is still truthy, but conventionally -# a None identity means "not authenticated" +# An Identity with no authentication_mode is anonymous (unauthenticated) +anon = Identity({"sub": "guest"}) +print(anon.is_authenticated()) # False ``` -/// admonition | Unauthenticated identity +/// admonition | Anonymous vs unauthenticated type: info -By convention, `None` represents an unauthenticated request. `Identity.is_authenticated()` -returns `True` for any non-`None` identity instance, regardless of its claims. +`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` @@ -200,7 +204,7 @@ async def main(): # --- Admin user: authorized --- admin_identity = Identity( - claims={"sub": "u1", "roles": ["admin"]}, scheme="Bearer" + claims={"sub": "u1", "roles": ["admin"]}, authentication_mode="Bearer" ) await strategy.authorize("admin", admin_identity) print("Admin authorized ✔") @@ -209,7 +213,7 @@ async def main(): from guardpost.authorization import ForbiddenError user_identity = Identity( - claims={"sub": "u2", "roles": ["viewer"]}, scheme="Bearer" + claims={"sub": "u2", "roles": ["viewer"]}, authentication_mode="Bearer" ) try: await strategy.authorize("admin", user_identity) From b6151a4542e964b27240bc5519bddd07c7aa8325 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 10 Mar 2026 19:37:51 +0100 Subject: [PATCH 3/5] Fix scheme -> authentication_mode --- guardpost/docs/authentication.md | 22 +++++++++++----------- guardpost/docs/dependency-injection.md | 2 +- guardpost/docs/getting-started.md | 9 +++++---- guardpost/docs/protection.md | 4 ++-- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/guardpost/docs/authentication.md b/guardpost/docs/authentication.md index 3c77485..c45fedc 100644 --- a/guardpost/docs/authentication.md +++ b/guardpost/docs/authentication.md @@ -51,7 +51,7 @@ Both sync and async implementations are supported: user_info = await fetch_user_info(token) if user_info: context.identity = Identity( - claims=user_info, scheme=self.scheme + claims=user_info, authentication_mode=self.scheme ) ``` @@ -71,7 +71,7 @@ Both sync and async implementations are supported: sub = self._valid_keys.get(api_key) if sub: context.identity = Identity( - claims={"sub": sub}, scheme=self.scheme + claims={"sub": sub}, authentication_mode=self.scheme ) ``` @@ -93,7 +93,7 @@ class CookieHandler(AuthenticationHandler): session_id = getattr(context, "session_id", None) if session_id: context.identity = Identity( - claims={"sub": "user-from-cookie"}, scheme=self.scheme + claims={"sub": "user-from-cookie"}, authentication_mode=self.scheme ) ``` @@ -168,7 +168,7 @@ class BearerHandler(AuthenticationHandler): async def authenticate(self, context) -> None: if context.token == "valid-jwt": context.identity = Identity( - claims={"sub": "u1", "name": "Alice"}, scheme=self.scheme + claims={"sub": "u1", "name": "Alice"}, authentication_mode=self.scheme ) @@ -178,7 +178,7 @@ class ApiKeyHandler(AuthenticationHandler): def authenticate(self, context) -> None: if context.api_key == "svc-key": context.identity = Identity( - claims={"sub": "service-a"}, scheme=self.scheme + claims={"sub": "service-a"}, authentication_mode=self.scheme ) @@ -188,7 +188,7 @@ async def main(): ctx = MockContext(api_key="svc-key") await strategy.authenticate(ctx) print(ctx.identity.sub) # "service-a" - print(ctx.identity.scheme) # "ApiKey" + print(ctx.identity.authentication_mode) # "ApiKey" asyncio.run(main()) @@ -212,7 +212,7 @@ This is useful for APIs that support multiple credential types simultaneously. ## Grouping handlers by scheme -You can inspect `context.identity.scheme` after authentication to know which +You can inspect `context.identity.authentication_mode` after authentication to know which handler authenticated the request, and apply different logic accordingly. ```python {linenums="1"} @@ -233,7 +233,7 @@ class BearerHandler(AuthenticationHandler): async def authenticate(self, context) -> None: if context.token: context.identity = Identity( - claims={"sub": "user-1"}, scheme=self.scheme + claims={"sub": "user-1"}, authentication_mode=self.scheme ) @@ -243,7 +243,7 @@ class ApiKeyHandler(AuthenticationHandler): def authenticate(self, context) -> None: if context.api_key: context.identity = Identity( - claims={"sub": "svc-1"}, scheme=self.scheme + claims={"sub": "svc-1"}, authentication_mode=self.scheme ) @@ -253,9 +253,9 @@ async def handle_request(context): if context.identity is None: print("Anonymous request") - elif context.identity.scheme == "Bearer": + elif context.identity.authentication_mode == "Bearer": print(f"Human user: {context.identity.sub}") - elif context.identity.scheme == "ApiKey": + elif context.identity.authentication_mode == "ApiKey": print(f"Service call: {context.identity.sub}") diff --git a/guardpost/docs/dependency-injection.md b/guardpost/docs/dependency-injection.md index acc7ef5..efd0b23 100644 --- a/guardpost/docs/dependency-injection.md +++ b/guardpost/docs/dependency-injection.md @@ -193,7 +193,7 @@ class ApiKeyHandler(AuthenticationHandler): 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(claims=user, scheme=self.scheme) + context.identity = Identity(claims=user, authentication_mode=self.scheme) else: raise InvalidCredentialsError("Unknown API key.") diff --git a/guardpost/docs/getting-started.md b/guardpost/docs/getting-started.md index 4a8e8a8..032e323 100644 --- a/guardpost/docs/getting-started.md +++ b/guardpost/docs/getting-started.md @@ -25,7 +25,7 @@ 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 a **scheme** string that +principal. It carries a dict of **claims** and an **authentication_mode** string that indicates how the identity was authenticated. ```python {linenums="1"} @@ -88,9 +88,10 @@ class BearerTokenHandler(AuthenticationHandler): if token == "secret-token": context.identity = Identity( claims={"sub": "user-1", "name": "Alice"}, - scheme=self.scheme, + authentication_mode=self.scheme, ) - # If the token is missing or wrong we simply leave context.identity as None + # 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 @@ -123,7 +124,7 @@ class BearerTokenHandler(AuthenticationHandler): if context.token == "secret-token": context.identity = Identity( claims={"sub": "user-1", "name": "Alice"}, - scheme=self.scheme, + authentication_mode=self.scheme, ) diff --git a/guardpost/docs/protection.md b/guardpost/docs/protection.md index c079363..0315c59 100644 --- a/guardpost/docs/protection.md +++ b/guardpost/docs/protection.md @@ -46,7 +46,7 @@ class PasswordHandler(AuthenticationHandler): if username and password: if self._check_credentials(username, password): context.identity = Identity( - claims={"sub": username}, scheme=self.scheme + claims={"sub": username}, authentication_mode=self.scheme ) else: # Signal a failed attempt — the rate limiter will count this @@ -121,7 +121,7 @@ class PasswordHandler(AuthenticationHandler): if context.username and context.password: if context.username == "alice" and context.password == "s3cr3t": context.identity = Identity( - claims={"sub": context.username}, scheme=self.scheme + claims={"sub": context.username}, authentication_mode=self.scheme ) else: raise InvalidCredentialsError("Bad credentials.") From 1bd89b530c4c92146247de16b9945f1b1c122da1 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 10 Mar 2026 19:48:18 +0100 Subject: [PATCH 4/5] Make examples less verbose --- guardpost/docs/authentication.md | 20 +++++++++--------- guardpost/docs/authorization.md | 28 +++++++++++++------------- guardpost/docs/dependency-injection.md | 6 +++--- guardpost/docs/getting-started.md | 16 +++++++-------- guardpost/docs/protection.md | 12 +++++------ 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/guardpost/docs/authentication.md b/guardpost/docs/authentication.md index c45fedc..a5bc6a9 100644 --- a/guardpost/docs/authentication.md +++ b/guardpost/docs/authentication.md @@ -23,7 +23,7 @@ from guardpost import AuthenticationHandler, Identity class MyHandler(AuthenticationHandler): async def authenticate(self, context) -> None: # Read credentials from context, validate them, then: - context.identity = Identity(claims={"sub": "user-1"}, authentication_mode="Bearer") + context.identity = Identity({"sub": "user-1"}, "Bearer") ``` The `context` parameter is whatever your application uses to represent a @@ -51,7 +51,7 @@ Both sync and async implementations are supported: user_info = await fetch_user_info(token) if user_info: context.identity = Identity( - claims=user_info, authentication_mode=self.scheme + user_info, self.scheme ) ``` @@ -71,7 +71,7 @@ Both sync and async implementations are supported: sub = self._valid_keys.get(api_key) if sub: context.identity = Identity( - claims={"sub": sub}, authentication_mode=self.scheme + {"sub": sub}, self.scheme ) ``` @@ -93,7 +93,7 @@ class CookieHandler(AuthenticationHandler): session_id = getattr(context, "session_id", None) if session_id: context.identity = Identity( - claims={"sub": "user-from-cookie"}, authentication_mode=self.scheme + {"sub": "user-from-cookie"}, self.scheme ) ``` @@ -106,14 +106,14 @@ class CookieHandler(AuthenticationHandler): from guardpost import Identity identity = Identity( - claims={ + { "sub": "user-42", "name": "Bob", "email": "bob@example.com", "roles": ["editor"], "iss": "https://auth.example.com", }, - authentication_mode="Bearer", + "Bearer", ) # Convenience properties @@ -168,7 +168,7 @@ class BearerHandler(AuthenticationHandler): async def authenticate(self, context) -> None: if context.token == "valid-jwt": context.identity = Identity( - claims={"sub": "u1", "name": "Alice"}, authentication_mode=self.scheme + {"sub": "u1", "name": "Alice"}, self.scheme ) @@ -178,7 +178,7 @@ class ApiKeyHandler(AuthenticationHandler): def authenticate(self, context) -> None: if context.api_key == "svc-key": context.identity = Identity( - claims={"sub": "service-a"}, authentication_mode=self.scheme + {"sub": "service-a"}, self.scheme ) @@ -233,7 +233,7 @@ class BearerHandler(AuthenticationHandler): async def authenticate(self, context) -> None: if context.token: context.identity = Identity( - claims={"sub": "user-1"}, authentication_mode=self.scheme + {"sub": "user-1"}, self.scheme ) @@ -243,7 +243,7 @@ class ApiKeyHandler(AuthenticationHandler): def authenticate(self, context) -> None: if context.api_key: context.identity = Identity( - claims={"sub": "svc-1"}, authentication_mode=self.scheme + {"sub": "svc-1"}, self.scheme ) diff --git a/guardpost/docs/authorization.md b/guardpost/docs/authorization.md index af3ac9a..fb6bf71 100644 --- a/guardpost/docs/authorization.md +++ b/guardpost/docs/authorization.md @@ -40,11 +40,11 @@ Like `AuthenticationHandler.authenticate`, the `handle` method can be either `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 | +| 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 @@ -125,12 +125,12 @@ async def main(): ) # Happy path — admin user - admin = Identity(claims={"sub": "u1", "roles": ["admin"]}, authentication_mode="Bearer") + admin = Identity({"sub": "u1", "roles": ["admin"]}, "Bearer") await strategy.authorize("admin", admin) print("Authorized ✔") # ForbiddenError — authenticated but lacks role - viewer = Identity(claims={"sub": "u2", "roles": ["viewer"]}, authentication_mode="Bearer") + viewer = Identity({"sub": "u2", "roles": ["viewer"]}, "Bearer") try: await strategy.authorize("admin", viewer) except ForbiddenError as exc: @@ -193,15 +193,15 @@ async def main(): ) ok_identity = Identity( - claims={"sub": "u1", "roles": ["editor"], "email_verified": True}, - authentication_mode="Bearer", + {"sub": "u1", "roles": ["editor"], "email_verified": True}, + "Bearer", ) await strategy.authorize("verified-editor", ok_identity) print("Authorized ✔") bad_identity = Identity( - claims={"sub": "u2", "roles": ["editor"], "email_verified": False}, - authentication_mode="Bearer", + {"sub": "u2", "roles": ["editor"], "email_verified": False}, + "Bearer", ) try: await strategy.authorize("verified-editor", bad_identity) @@ -214,10 +214,10 @@ asyncio.run(main()) ## `UnauthorizedError` vs `ForbiddenError` -| Exception | When raised | -|-----------|-------------| +| 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()` | +| `ForbiddenError` | `identity` is set but a requirement called `context.fail()` | Both are subclasses of `AuthorizationError`. diff --git a/guardpost/docs/dependency-injection.md b/guardpost/docs/dependency-injection.md index efd0b23..6526657 100644 --- a/guardpost/docs/dependency-injection.md +++ b/guardpost/docs/dependency-injection.md @@ -142,13 +142,13 @@ async def main(): container=container, ) - power_user = Identity(claims={"sub": "u1"}, authentication_mode="Bearer") + power_user = Identity({"sub": "u1"}, "Bearer") await strategy.authorize("delete", power_user) print("Authorized ✔") from guardpost.authorization import ForbiddenError - basic_user = Identity(claims={"sub": "u2"}, authentication_mode="Bearer") + basic_user = Identity({"sub": "u2"}, "Bearer") try: await strategy.authorize("delete", basic_user) except ForbiddenError as exc: @@ -193,7 +193,7 @@ class ApiKeyHandler(AuthenticationHandler): 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(claims=user, authentication_mode=self.scheme) + context.identity = Identity(user, self.scheme) else: raise InvalidCredentialsError("Unknown API key.") diff --git a/guardpost/docs/getting-started.md b/guardpost/docs/getting-started.md index 032e323..3b343da 100644 --- a/guardpost/docs/getting-started.md +++ b/guardpost/docs/getting-started.md @@ -33,13 +33,13 @@ from guardpost import Identity # Create an identity with claims identity = Identity( - claims={ + { "sub": "user-123", "name": "Alice", "email": "alice@example.com", "roles": ["admin", "editor"], }, - authentication_mode="Bearer", + "Bearer", ) print(identity.sub) # "user-123" @@ -87,8 +87,8 @@ class BearerTokenHandler(AuthenticationHandler): token = context.token if token == "secret-token": context.identity = Identity( - claims={"sub": "user-1", "name": "Alice"}, - authentication_mode=self.scheme, + {"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 @@ -123,8 +123,8 @@ class BearerTokenHandler(AuthenticationHandler): async def authenticate(self, context: MockContext) -> None: if context.token == "secret-token": context.identity = Identity( - claims={"sub": "user-1", "name": "Alice"}, - authentication_mode=self.scheme, + {"sub": "user-1", "name": "Alice"}, + self.scheme, ) @@ -205,7 +205,7 @@ async def main(): # --- Admin user: authorized --- admin_identity = Identity( - claims={"sub": "u1", "roles": ["admin"]}, authentication_mode="Bearer" + {"sub": "u1", "roles": ["admin"]}, "Bearer" ) await strategy.authorize("admin", admin_identity) print("Admin authorized ✔") @@ -214,7 +214,7 @@ async def main(): from guardpost.authorization import ForbiddenError user_identity = Identity( - claims={"sub": "u2", "roles": ["viewer"]}, authentication_mode="Bearer" + {"sub": "u2", "roles": ["viewer"]}, "Bearer" ) try: await strategy.authorize("admin", user_identity) diff --git a/guardpost/docs/protection.md b/guardpost/docs/protection.md index 0315c59..ccaa5f4 100644 --- a/guardpost/docs/protection.md +++ b/guardpost/docs/protection.md @@ -46,7 +46,7 @@ class PasswordHandler(AuthenticationHandler): if username and password: if self._check_credentials(username, password): context.identity = Identity( - claims={"sub": username}, authentication_mode=self.scheme + {"sub": username}, self.scheme ) else: # Signal a failed attempt — the rate limiter will count this @@ -80,10 +80,10 @@ limiter = RateLimiter( ) ``` -| 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 | +| 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 @@ -121,7 +121,7 @@ class PasswordHandler(AuthenticationHandler): if context.username and context.password: if context.username == "alice" and context.password == "s3cr3t": context.identity = Identity( - claims={"sub": context.username}, authentication_mode=self.scheme + {"sub": context.username}, self.scheme ) else: raise InvalidCredentialsError("Bad credentials.") From 6719bce01ed174c304552baa7eee096075f7b63d Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Tue, 10 Mar 2026 19:53:34 +0100 Subject: [PATCH 5/5] Improvements --- guardpost/docs/about.md | 2 +- guardpost/docs/getting-started.md | 1 + guardpost/docs/jwt-validation.md | 36 +++++++++++++++---------------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/guardpost/docs/about.md b/guardpost/docs/about.md index 0b17500..9b2847b 100644 --- a/guardpost/docs/about.md +++ b/guardpost/docs/about.md @@ -19,7 +19,7 @@ JWT bearer authentication, policy-based authorization, and OIDC integration. GuardPost has been tested with the following identity providers: - [Auth0](https://auth0.com/) -- [Azure Active Directory](https://azure.microsoft.com/en-us/products/active-directory) +- [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/) diff --git a/guardpost/docs/getting-started.md b/guardpost/docs/getting-started.md index 3b343da..06f0fe2 100644 --- a/guardpost/docs/getting-started.md +++ b/guardpost/docs/getting-started.md @@ -60,6 +60,7 @@ to a non-empty string. An `Identity` created without `authentication_mode` (or w `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` diff --git a/guardpost/docs/jwt-validation.md b/guardpost/docs/jwt-validation.md index bef5a31..6bc7e6f 100644 --- a/guardpost/docs/jwt-validation.md +++ b/guardpost/docs/jwt-validation.md @@ -32,9 +32,9 @@ policy-based authorization without needing JWT parsing. `AsymmetricJWTValidator` validates JWTs signed with asymmetric keys: -| Algorithm family | Algorithms | -|-----------------|------------| -| RSA | `RS256`, `RS384`, `RS512` | +| Algorithm family | Algorithms | +| ------------------- | ------------------------- | +| RSA | `RS256`, `RS384`, `RS512` | | EC (Elliptic Curve) | `ES256`, `ES384`, `ES512` | ### RSA keys (RS256) @@ -74,17 +74,17 @@ validator = AsymmetricJWTValidator( ### 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 | +| 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` @@ -251,10 +251,10 @@ validator = AsymmetricJWTValidator( ## Exceptions -| Exception | When raised | -|-----------|-------------| +| 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` | 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. @@ -280,7 +280,7 @@ except InvalidAccessToken as exc: GuardPost has been tested with the following identity providers: - **Auth0** — `authority="https://.auth0.com/"` -- **Azure Active Directory** — `authority="https://login.microsoftonline.com//v2.0"` +- **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"` ///