easyssf-receiver-fastapi 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ coverage.xml
13
+ htmlcov/
14
+
15
+ # IDE
16
+ .idea/
17
+ .vscode/
18
+
19
+ # Local
20
+ scratch/
21
+ .DS_Store
22
+ conformance/certs/
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: easyssf-receiver-fastapi
3
+ Version: 0.7.0
4
+ Summary: The easyssf SSF receiver for FastAPI: the push endpoint, the rejection of revoked access tokens and the receiver's lifecycle
5
+ Project-URL: Homepage, https://easyssf.org
6
+ Project-URL: Repository, https://github.com/easyssf/easyssf-python
7
+ Project-URL: Changelog, https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md
8
+ Author: the easyssf authors
9
+ License-Expression: Apache-2.0
10
+ Keywords: caep,fastapi,openid,risc,security-events,ssf
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Security
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: easyssf-receiver<0.8,>=0.7
20
+ Requires-Dist: fastapi<1,>=0.115
21
+ Description-Content-Type: text/markdown
22
+
23
+ # easyssf-receiver-fastapi
24
+
25
+ The [easyssf](https://github.com/easyssf/easyssf-python) SSF receiver for FastAPI: the push
26
+ endpoint as a router, the rejection of revoked access tokens as a dependency or middleware, and
27
+ the receiver's lifecycle as a lifespan, with its health as a route. The Python counterpart of the
28
+ Rust `easyssf-receiver-axum` crate. Everything else, the configuration, the handlers, the stream
29
+ management and polling, is `easyssf-receiver`; see its [README](../README.md).
30
+
31
+ ```sh
32
+ uv add easyssf-receiver-fastapi # or: pip install easyssf-receiver-fastapi
33
+ ```
34
+
35
+ ## The receiver with the application
36
+
37
+ `receiver_lifespan` starts the receiver when the application starts (streams are registered and
38
+ polling begins in the background) and closes it on shutdown (the acknowledgements it owes are
39
+ sent, the HTTP client it made for itself is closed). With a lifespan of your own, the receiver
40
+ starts after yours entered and stops before it exits, so the handlers find what the application
41
+ set up:
42
+
43
+ ```python
44
+ from fastapi import FastAPI
45
+ from easyssf.fastapi import receiver_lifespan
46
+ from easyssf.receiver import Receiver, ReceiverConfig
47
+
48
+ receiver = Receiver(ReceiverConfig.from_mapping(settings["easyssf"]["receiver"]), handlers=[audit])
49
+ app = FastAPI(lifespan=receiver_lifespan(receiver)) # or receiver_lifespan(receiver, my_lifespan)
50
+ ```
51
+
52
+ ## The push endpoint
53
+
54
+ `push_router_for` mounts `POST easyssf.receiver.push.endpoint-path` (`/ssf/push` by default),
55
+ `push_router(handler, path)` any push handler at any path. The transmitter authenticates with its
56
+ `Authorization` header and the SET with its signature, so the route belongs outside the
57
+ authentication of the application; it is left out of the OpenAPI schema. The response is the
58
+ one of RFC 8935: `202` for an accepted SET, `400` with an error document (`application/json`,
59
+ `Content-Language: en`) for an invalid one, `401` for a transmitter that could not be
60
+ authenticated, `413` for a body beyond 256 KiB, `503` or `500` to have the SET delivered again.
61
+
62
+ ```python
63
+ from easyssf.fastapi import push_router_for
64
+
65
+ app.include_router(push_router_for(receiver))
66
+ ```
67
+
68
+ ## Rejecting revoked access tokens
69
+
70
+ The receiver's revocation handler records the sessions and users that `CaepSessionRevoked`
71
+ events name in `receiver.token_revocation_store`. `revocation_dependency` turns the store into a
72
+ dependency that answers `401` with `WWW-Authenticate: Bearer error="invalid_token",
73
+ error_description="The token was revoked"` for a token of a revoked session or user, and `503`
74
+ when the store cannot answer (it fails closed). It does not validate tokens: `extract` reads the
75
+ claims the authentication of the application left on the request, `None` for a request without
76
+ one.
77
+
78
+ ```python
79
+ from datetime import UTC, datetime
80
+ from fastapi import Depends, Request
81
+ from easyssf.fastapi import revocation_dependency
82
+ from easyssf.receiver import TokenClaims
83
+
84
+
85
+ def token_claims(request: Request) -> TokenClaims | None:
86
+ token = getattr(request.state, "token", None) # left there by your authentication dependency
87
+ if token is None:
88
+ return None
89
+ return TokenClaims(
90
+ issuer=token["iss"],
91
+ subject=token["sub"],
92
+ session_id=token.get("sid", ""),
93
+ issued_at=datetime.fromtimestamp(token["iat"], tz=UTC),
94
+ )
95
+
96
+
97
+ reject_revoked = revocation_dependency(receiver.token_revocation_store, token_claims)
98
+
99
+
100
+ @app.get("/api/orders", dependencies=[Depends(require_access_token), Depends(reject_revoked)])
101
+ async def orders() -> list[str]: ...
102
+ ```
103
+
104
+ For the whole application, the raw ASGI `RevocationMiddleware` of the receiver does the same from
105
+ the scope, added after (inside) the middleware that validates the tokens:
106
+
107
+ ```python
108
+ from easyssf.fastapi import RevocationMiddleware
109
+
110
+ app.add_middleware(
111
+ RevocationMiddleware,
112
+ store=receiver.token_revocation_store,
113
+ extract=lambda scope: scope["state"].get("token_claims"),
114
+ )
115
+ ```
116
+
117
+ ## Health
118
+
119
+ `health_router` serves `receiver.health()` as JSON at `/health`: `200` while the receiver is
120
+ healthy or degraded (no contact with a transmitter yet; the application starts without it),
121
+ `503` when it is unhealthy (a poll failed, a stream cannot be used).
122
+
123
+ ```python
124
+ from easyssf.fastapi import health_router
125
+
126
+ app.include_router(health_router(receiver)) # or health_router(receiver, path="/ready")
127
+ ```
128
+
129
+ ## Example
130
+
131
+ [`examples/fastapi/resource-server`](../examples/fastapi/resource-server) is an OAuth2 resource
132
+ server on FastAPI against the Keycloak of [`examples/keycloak`](../examples/keycloak): it
133
+ registers its stream, validates access tokens with the keys of the transmitter and rejects them
134
+ once their session was revoked; see [`examples/README.md`](../examples/README.md).
@@ -0,0 +1,112 @@
1
+ # easyssf-receiver-fastapi
2
+
3
+ The [easyssf](https://github.com/easyssf/easyssf-python) SSF receiver for FastAPI: the push
4
+ endpoint as a router, the rejection of revoked access tokens as a dependency or middleware, and
5
+ the receiver's lifecycle as a lifespan, with its health as a route. The Python counterpart of the
6
+ Rust `easyssf-receiver-axum` crate. Everything else, the configuration, the handlers, the stream
7
+ management and polling, is `easyssf-receiver`; see its [README](../README.md).
8
+
9
+ ```sh
10
+ uv add easyssf-receiver-fastapi # or: pip install easyssf-receiver-fastapi
11
+ ```
12
+
13
+ ## The receiver with the application
14
+
15
+ `receiver_lifespan` starts the receiver when the application starts (streams are registered and
16
+ polling begins in the background) and closes it on shutdown (the acknowledgements it owes are
17
+ sent, the HTTP client it made for itself is closed). With a lifespan of your own, the receiver
18
+ starts after yours entered and stops before it exits, so the handlers find what the application
19
+ set up:
20
+
21
+ ```python
22
+ from fastapi import FastAPI
23
+ from easyssf.fastapi import receiver_lifespan
24
+ from easyssf.receiver import Receiver, ReceiverConfig
25
+
26
+ receiver = Receiver(ReceiverConfig.from_mapping(settings["easyssf"]["receiver"]), handlers=[audit])
27
+ app = FastAPI(lifespan=receiver_lifespan(receiver)) # or receiver_lifespan(receiver, my_lifespan)
28
+ ```
29
+
30
+ ## The push endpoint
31
+
32
+ `push_router_for` mounts `POST easyssf.receiver.push.endpoint-path` (`/ssf/push` by default),
33
+ `push_router(handler, path)` any push handler at any path. The transmitter authenticates with its
34
+ `Authorization` header and the SET with its signature, so the route belongs outside the
35
+ authentication of the application; it is left out of the OpenAPI schema. The response is the
36
+ one of RFC 8935: `202` for an accepted SET, `400` with an error document (`application/json`,
37
+ `Content-Language: en`) for an invalid one, `401` for a transmitter that could not be
38
+ authenticated, `413` for a body beyond 256 KiB, `503` or `500` to have the SET delivered again.
39
+
40
+ ```python
41
+ from easyssf.fastapi import push_router_for
42
+
43
+ app.include_router(push_router_for(receiver))
44
+ ```
45
+
46
+ ## Rejecting revoked access tokens
47
+
48
+ The receiver's revocation handler records the sessions and users that `CaepSessionRevoked`
49
+ events name in `receiver.token_revocation_store`. `revocation_dependency` turns the store into a
50
+ dependency that answers `401` with `WWW-Authenticate: Bearer error="invalid_token",
51
+ error_description="The token was revoked"` for a token of a revoked session or user, and `503`
52
+ when the store cannot answer (it fails closed). It does not validate tokens: `extract` reads the
53
+ claims the authentication of the application left on the request, `None` for a request without
54
+ one.
55
+
56
+ ```python
57
+ from datetime import UTC, datetime
58
+ from fastapi import Depends, Request
59
+ from easyssf.fastapi import revocation_dependency
60
+ from easyssf.receiver import TokenClaims
61
+
62
+
63
+ def token_claims(request: Request) -> TokenClaims | None:
64
+ token = getattr(request.state, "token", None) # left there by your authentication dependency
65
+ if token is None:
66
+ return None
67
+ return TokenClaims(
68
+ issuer=token["iss"],
69
+ subject=token["sub"],
70
+ session_id=token.get("sid", ""),
71
+ issued_at=datetime.fromtimestamp(token["iat"], tz=UTC),
72
+ )
73
+
74
+
75
+ reject_revoked = revocation_dependency(receiver.token_revocation_store, token_claims)
76
+
77
+
78
+ @app.get("/api/orders", dependencies=[Depends(require_access_token), Depends(reject_revoked)])
79
+ async def orders() -> list[str]: ...
80
+ ```
81
+
82
+ For the whole application, the raw ASGI `RevocationMiddleware` of the receiver does the same from
83
+ the scope, added after (inside) the middleware that validates the tokens:
84
+
85
+ ```python
86
+ from easyssf.fastapi import RevocationMiddleware
87
+
88
+ app.add_middleware(
89
+ RevocationMiddleware,
90
+ store=receiver.token_revocation_store,
91
+ extract=lambda scope: scope["state"].get("token_claims"),
92
+ )
93
+ ```
94
+
95
+ ## Health
96
+
97
+ `health_router` serves `receiver.health()` as JSON at `/health`: `200` while the receiver is
98
+ healthy or degraded (no contact with a transmitter yet; the application starts without it),
99
+ `503` when it is unhealthy (a poll failed, a stream cannot be used).
100
+
101
+ ```python
102
+ from easyssf.fastapi import health_router
103
+
104
+ app.include_router(health_router(receiver)) # or health_router(receiver, path="/ready")
105
+ ```
106
+
107
+ ## Example
108
+
109
+ [`examples/fastapi/resource-server`](../examples/fastapi/resource-server) is an OAuth2 resource
110
+ server on FastAPI against the Keycloak of [`examples/keycloak`](../examples/keycloak): it
111
+ registers its stream, validates access tokens with the keys of the transmitter and rejects them
112
+ once their session was revoked; see [`examples/README.md`](../examples/README.md).
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "easyssf-receiver-fastapi"
3
+ version = "0.7.0"
4
+ description = "The easyssf SSF receiver for FastAPI: the push endpoint, the rejection of revoked access tokens and the receiver's lifecycle"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "the easyssf authors" }]
9
+ keywords = ["ssf", "caep", "risc", "openid", "security-events", "fastapi"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Framework :: FastAPI",
16
+ "Topic :: Security",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "easyssf-receiver>=0.7,<0.8",
21
+ "fastapi>=0.115,<1",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://easyssf.org"
26
+ Repository = "https://github.com/easyssf/easyssf-python"
27
+ Changelog = "https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md"
28
+
29
+ [build-system]
30
+ requires = ["hatchling>=1.27"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/easyssf"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src", "README.md", "tests"]
38
+
39
+ [tool.uv.sources]
40
+ easyssf-receiver = { workspace = true }
41
+ easyssf-test = { workspace = true }
42
+
43
+ [dependency-groups]
44
+ dev = ["easyssf-test", "itsdangerous>=2.2"]
@@ -0,0 +1,39 @@
1
+ """The easyssf receiver for FastAPI: the push endpoint as a router, the rejection of revoked access
2
+ tokens as a dependency or middleware, and the receiver's lifecycle as a lifespan, plus its health
3
+ as a route. The Python counterpart of the Rust ``easyssf-receiver-axum`` crate.
4
+
5
+ Example:
6
+ >>> from fastapi import Depends, FastAPI, Request
7
+ >>> from easyssf.fastapi import health_router, push_router_for, receiver_lifespan, revocation_dependency
8
+ >>>
9
+ >>> receiver = Receiver(config)
10
+ >>> app = FastAPI(lifespan=receiver_lifespan(receiver))
11
+ >>> app.include_router(push_router_for(receiver)) # POST /ssf/push, or easyssf.receiver.push.endpoint-path
12
+ >>> app.include_router(health_router(receiver)) # GET /health
13
+ >>> reject_revoked = revocation_dependency(receiver.token_revocation_store, lambda request: request.state.token)
14
+ >>> @app.get("/api/orders", dependencies=[Depends(require_access_token), Depends(reject_revoked)])
15
+ ... async def orders() -> list[str]: ...
16
+ """
17
+
18
+ from .lifecycle import DEFAULT_HEALTH_PATH, health_router, receiver_lifespan
19
+ from .push import DEFAULT_PUSH_PATH, into_response, push_router, push_router_for
20
+ from .revocation import (
21
+ REVOKED_WWW_AUTHENTICATE,
22
+ RequestTokenClaimsExtractor,
23
+ RevocationMiddleware,
24
+ revocation_dependency,
25
+ )
26
+
27
+ __all__ = [
28
+ "DEFAULT_HEALTH_PATH",
29
+ "DEFAULT_PUSH_PATH",
30
+ "REVOKED_WWW_AUTHENTICATE",
31
+ "RequestTokenClaimsExtractor",
32
+ "RevocationMiddleware",
33
+ "health_router",
34
+ "into_response",
35
+ "push_router",
36
+ "push_router_for",
37
+ "receiver_lifespan",
38
+ "revocation_dependency",
39
+ ]
@@ -0,0 +1,68 @@
1
+ """The lifecycle of the receiver on FastAPI: ``receiver_lifespan`` starts and stops the receiver
2
+ with the application, ``health_router`` serves its health."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, Final
9
+
10
+ from fastapi import APIRouter, FastAPI
11
+ from fastapi.responses import JSONResponse
12
+ from starlette.types import Lifespan
13
+
14
+ from easyssf.receiver.receiver import Receiver
15
+ from easyssf.receiver.transmitter import HealthStatus
16
+
17
+ __all__ = ["DEFAULT_HEALTH_PATH", "health_router", "receiver_lifespan"]
18
+
19
+ DEFAULT_HEALTH_PATH: Final = "/health"
20
+ """Where :func:`health_router` mounts the endpoint by default."""
21
+
22
+ _HTTP_OK: Final = 200
23
+ _HTTP_UNAVAILABLE: Final = 503
24
+
25
+
26
+ def receiver_lifespan(receiver: Receiver, inner: Lifespan[Any] | None = None) -> Lifespan[Any]:
27
+ """A lifespan for ``FastAPI(lifespan=...)`` that starts the receiver when the application
28
+ starts (the streams are registered and polling begins in the background) and closes it when
29
+ the application shuts down (the acknowledgements it owes are sent, the HTTP client it made for
30
+ itself is closed, a given one stays open). With ``inner``, the
31
+ lifespan of the application, the receiver starts after ``inner`` entered and stops before it
32
+ exits, so that the handlers find what the application set up, and whatever ``inner`` yields
33
+ becomes the state of the application.
34
+
35
+ Example:
36
+ >>> app = FastAPI(lifespan=receiver_lifespan(receiver))
37
+ """
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(app: FastAPI) -> AsyncIterator[Any]:
41
+ if inner is None:
42
+ async with receiver:
43
+ yield None
44
+ return
45
+ async with inner(app) as state, receiver:
46
+ yield state
47
+
48
+ return lifespan
49
+
50
+
51
+ def health_router(receiver: Receiver, path: str = DEFAULT_HEALTH_PATH) -> APIRouter:
52
+ """A router with ``GET path`` answering :meth:`~easyssf.receiver.receiver.Receiver.health` as
53
+ JSON (:meth:`~easyssf.receiver.receiver.ReceiverHealth.to_mapping`): 200 while the receiver is
54
+ healthy or degraded (no contact with a transmitter yet, the application starts without it),
55
+ 503 when it is unhealthy (a poll failed, a stream cannot be used).
56
+
57
+ Example:
58
+ >>> app.include_router(health_router(receiver)) # GET /health
59
+ """
60
+ router = APIRouter()
61
+
62
+ @router.get(path, name="easyssf-health")
63
+ async def health() -> JSONResponse:
64
+ current = await receiver.health()
65
+ status = _HTTP_UNAVAILABLE if current.status is HealthStatus.UNHEALTHY else _HTTP_OK
66
+ return JSONResponse(current.to_mapping(), status_code=status)
67
+
68
+ return router
@@ -0,0 +1,93 @@
1
+ """The push endpoint (RFC 8935) as a FastAPI router: ``push_router`` mounts a
2
+ :class:`~easyssf.receiver.push.PushHandler` at a path, ``push_router_for`` the one of a configured
3
+ :class:`~easyssf.receiver.receiver.Receiver` at ``easyssf.receiver.push.endpoint-path``. The
4
+ status and header behaviour is that of :func:`easyssf.receiver.asgi.push_app`; the Rust
5
+ ``easyssf-receiver-axum`` crate is the model."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Final
11
+
12
+ from fastapi import APIRouter, Request, Response
13
+
14
+ from easyssf.receiver.errors import ErrorCode
15
+ from easyssf.receiver.push import MAX_SET_SIZE, PUSH_CONTENT_LANGUAGE, PUSH_CONTENT_TYPE, PushHandler, PushResponse
16
+ from easyssf.receiver.receiver import Receiver
17
+
18
+ __all__ = ["DEFAULT_PUSH_PATH", "into_response", "push_router", "push_router_for"]
19
+
20
+ DEFAULT_PUSH_PATH: Final = "/ssf/push"
21
+ """Where :func:`push_router` mounts the endpoint by default."""
22
+
23
+ _HTTP_TOO_LARGE: Final = 413
24
+ _log: Final = logging.getLogger("easyssf.fastapi")
25
+
26
+
27
+ def push_router(push_handler: PushHandler, path: str = DEFAULT_PUSH_PATH) -> APIRouter:
28
+ """A router with ``POST path`` serving the push endpoint: the SET is the body
29
+ (``application/secevent+jwt``), the transmitter authenticates with its ``Authorization`` header
30
+ and the SET with its signature, so the route belongs outside the authentication of the
31
+ application. The response carries the status of
32
+ :meth:`~easyssf.receiver.push.PushHandler.handle` and, with an error document, the
33
+ ``Content-Type`` and ``Content-Language`` headers of RFC 8935, section 2.3. A body beyond
34
+ :data:`~easyssf.receiver.push.MAX_SET_SIZE` is answered with 413 without being read in full.
35
+ The route is left out of the OpenAPI schema: it is for the transmitter, not for the users of
36
+ the API.
37
+
38
+ Example:
39
+ >>> app.include_router(push_router(receiver.push_handler)) # POST /ssf/push
40
+ """
41
+ router = APIRouter()
42
+
43
+ @router.post(path, include_in_schema=False, name="easyssf-push")
44
+ async def push(request: Request) -> Response:
45
+ declared = request.headers.get("content-length")
46
+ if declared is not None and declared.isdigit() and int(declared) > MAX_SET_SIZE:
47
+ return into_response(_too_large())
48
+ # read up to one byte beyond the limit, as the raw ASGI app does, whatever the size of the
49
+ # chunks the server hands over: no more is needed to reject the request, and a transmitter
50
+ # cannot make the endpoint buffer anything it likes
51
+ body = b""
52
+ async for chunk in request.stream():
53
+ body += chunk[: MAX_SET_SIZE + 1 - len(body)]
54
+ if len(body) > MAX_SET_SIZE:
55
+ break
56
+ response = await push_handler.handle(request.headers.get("authorization"), body)
57
+ return into_response(response)
58
+
59
+ return router
60
+
61
+
62
+ def push_router_for(receiver: Receiver) -> APIRouter:
63
+ """A router with the push endpoint of a configured receiver, at
64
+ :attr:`~easyssf.receiver.receiver.Receiver.push_endpoint_path`
65
+ (``easyssf.receiver.push.endpoint-path``, ``/ssf/push`` by default).
66
+
67
+ Example:
68
+ >>> app.include_router(push_router_for(receiver))
69
+ """
70
+ path = receiver.push_endpoint_path
71
+ _log.info("Receiving SSF security events at %s", path, extra={"path": path})
72
+ return push_router(receiver.push_handler, path)
73
+
74
+
75
+ def into_response(push_response: PushResponse) -> Response:
76
+ """The response of the push handler as a FastAPI response: its status and, with an error
77
+ document, the body as :data:`~easyssf.receiver.push.PUSH_CONTENT_TYPE` with the
78
+ :data:`~easyssf.receiver.push.PUSH_CONTENT_LANGUAGE` header (RFC 8935, section 2.3); no
79
+ headers of the two without a body."""
80
+ content = push_response.body_bytes
81
+ if content is None:
82
+ return Response(status_code=push_response.status)
83
+ return Response(
84
+ content=content,
85
+ status_code=push_response.status,
86
+ media_type=PUSH_CONTENT_TYPE,
87
+ headers={"Content-Language": PUSH_CONTENT_LANGUAGE},
88
+ )
89
+
90
+
91
+ def _too_large() -> PushResponse:
92
+ """The response of :meth:`~easyssf.receiver.push.PushHandler.handle` to a SET beyond the limit."""
93
+ return PushResponse.error(_HTTP_TOO_LARGE, ErrorCode.INVALID_REQUEST, "The SET is too large")
@@ -0,0 +1,93 @@
1
+ """Rejecting the access tokens that security events revoked, for a resource server on FastAPI: a
2
+ dependency for ``Depends`` on the routes that need it, and the ASGI
3
+ :class:`~easyssf.receiver.revocation.RevocationMiddleware` of the receiver for the whole
4
+ application. Neither validates tokens: ``extract`` reads the claims the authentication of the
5
+ application left on the request, so both work with any of it (an OAuth2 dependency of your own,
6
+ a JWT middleware, ...). A request without claims passes; whether anonymous requests are allowed
7
+ is for the application to decide."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Final, TypeAlias
15
+
16
+ from fastapi import HTTPException, Request
17
+
18
+ from easyssf.receiver.revocation import (
19
+ REVOKED_WWW_AUTHENTICATE,
20
+ TokenClaims,
21
+ TokenRevocationStore,
22
+ reject_if_revoked,
23
+ )
24
+ from easyssf.receiver.revocation import RevocationMiddleware as _RevocationMiddleware
25
+
26
+ __all__ = [
27
+ "REVOKED_WWW_AUTHENTICATE",
28
+ "RequestTokenClaimsExtractor",
29
+ "RevocationMiddleware",
30
+ "revocation_dependency",
31
+ ]
32
+
33
+ _HTTP_UNAUTHORIZED: Final = 401
34
+ _HTTP_UNAVAILABLE: Final = 503
35
+ _log: Final = logging.getLogger("easyssf.fastapi.revocation")
36
+
37
+ RequestTokenClaimsExtractor: TypeAlias = Callable[[Request], "TokenClaims | Awaitable[TokenClaims | None] | None"]
38
+ """Reads the :class:`~easyssf.receiver.revocation.TokenClaims` of the access token a request
39
+ carries, as left on it (``request.state``, say) by the dependency or middleware that validated
40
+ it; ``None`` for a request without a token. Sync or async."""
41
+
42
+
43
+ class RevocationMiddleware(_RevocationMiddleware):
44
+ """The raw ASGI :class:`easyssf.receiver.revocation.RevocationMiddleware`, for the whole
45
+ application: it rejects requests whose access token was revoked with 401 Unauthorized and the
46
+ ``WWW-Authenticate`` challenge of :data:`REVOKED_WWW_AUTHENTICATE`, answers 503 when the store
47
+ fails, and lets everything else through. Add it with the store and the extractor as keyword
48
+ arguments, after (that is, inside) the middleware that validates the access token and leaves
49
+ its claims on the scope::
50
+
51
+ app.add_middleware(RevocationMiddleware, store=receiver.token_revocation_store, extract=token_claims)
52
+
53
+ where ``extract`` reads the :class:`~easyssf.receiver.revocation.TokenClaims` from the ASGI
54
+ scope (``scope["state"]`` is what ``request.state`` writes to) and is sync or async. For the
55
+ routes that need it only, :func:`revocation_dependency` reads the :class:`fastapi.Request`
56
+ instead.
57
+ """
58
+
59
+
60
+ def revocation_dependency(
61
+ store: TokenRevocationStore, extract: RequestTokenClaimsExtractor
62
+ ) -> Callable[[Request], Awaitable[None]]:
63
+ """A dependency for ``Depends`` that rejects a request whose access token was revoked with
64
+ 401 Unauthorized and the ``WWW-Authenticate`` challenge of
65
+ :data:`~easyssf.receiver.revocation.REVOKED_WWW_AUTHENTICATE` (RFC 6750, section 3.1), and
66
+ answers 503 Service Unavailable when the store fails: a store that cannot answer must not let
67
+ a revoked token through. Put it after the dependency that validates the token and leaves its
68
+ claims where ``extract`` reads them.
69
+
70
+ Args:
71
+ store: the revocations, :attr:`~easyssf.receiver.receiver.Receiver.token_revocation_store`.
72
+ extract: reads the claims of the request's token, ``None`` for a request without one.
73
+
74
+ Example:
75
+ >>> reject_revoked = revocation_dependency(receiver.token_revocation_store, token_claims_of)
76
+ >>> @app.get("/api/me", dependencies=[Depends(require_access_token), Depends(reject_revoked)])
77
+ ... async def me(request: Request) -> dict[str, str]: ...
78
+ """
79
+
80
+ async def reject_revoked(request: Request) -> None:
81
+ claims = extract(request)
82
+ if inspect.isawaitable(claims):
83
+ claims = await claims
84
+ try:
85
+ rejected = await reject_if_revoked(store, claims)
86
+ except Exception as error:
87
+ _log.exception("The revocation store failed, rejecting the request")
88
+ raise HTTPException(_HTTP_UNAVAILABLE, detail="revocation check failed") from error
89
+ if rejected:
90
+ _log.debug("Rejecting a revoked access token", extra={"subject": claims.subject if claims else ""})
91
+ raise HTTPException(_HTTP_UNAUTHORIZED, headers={"WWW-Authenticate": REVOKED_WWW_AUTHENTICATE})
92
+
93
+ return reject_revoked
@@ -0,0 +1,407 @@
1
+ """The FastAPI integration: the push router end to end against the test transmitter, the
2
+ revocation dependency and middleware, the lifespan and the health route. The counterpart of the
3
+ tests of the Rust easyssf-receiver-axum crate."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ import httpx
12
+ import pytest
13
+ from fastapi import Depends, FastAPI, Request
14
+
15
+ from easyssf.core.subjects import complex_subject, iss_sub, opaque
16
+ from easyssf.fastapi import (
17
+ REVOKED_WWW_AUTHENTICATE,
18
+ RevocationMiddleware,
19
+ health_router,
20
+ into_response,
21
+ push_router,
22
+ push_router_for,
23
+ receiver_lifespan,
24
+ revocation_dependency,
25
+ )
26
+ from easyssf.receiver.config import ReceiverConfig, default_receiver_config
27
+ from easyssf.receiver.errors import StoreError
28
+ from easyssf.receiver.event import EventContext, for_event
29
+ from easyssf.receiver.push import MAX_SET_SIZE, PushResponse
30
+ from easyssf.receiver.receiver import Receiver
31
+ from easyssf.receiver.revocation import InMemoryTokenRevocationStore, TokenClaims
32
+ from easyssf.testing import AUDIENCE, SECEVENT_JWT_TYPE, TestTransmitter, eventually, shared_key, sign
33
+
34
+ SECRET = "Bearer s3cr3t"
35
+ PUSH_HEADERS = {"Authorization": SECRET, "Content-Type": "application/secevent+jwt"}
36
+
37
+
38
+ class Recorder:
39
+ """Counts the SETs it handled."""
40
+
41
+ def __init__(self) -> None:
42
+ self.count = 0
43
+
44
+ async def handle(self, context: EventContext) -> None:
45
+ self.count += 1
46
+
47
+
48
+ class FailingStore:
49
+ """A revocation store that fails every call."""
50
+
51
+ async def revoke_session(self, issuer: str, session_id: str) -> None:
52
+ raise StoreError(RuntimeError("down"))
53
+
54
+ async def revoke_subject(self, issuer: str, subject: str, revoked_at: datetime) -> None:
55
+ raise StoreError(RuntimeError("down"))
56
+
57
+ async def is_session_revoked(self, issuer: str, session_id: str) -> bool:
58
+ raise StoreError(RuntimeError("down"))
59
+
60
+ async def subject_revoked_at(self, issuer: str, subject: str) -> datetime | None:
61
+ raise StoreError(RuntimeError("down"))
62
+
63
+
64
+ def push_config(transmitter: TestTransmitter) -> ReceiverConfig:
65
+ config = default_receiver_config()
66
+ config.transmitter_issuer = transmitter.issuer
67
+ config.expected_audience = AUDIENCE
68
+ config.push.expected_auth_header = SECRET
69
+ return config
70
+
71
+
72
+ def client_of(app: FastAPI) -> httpx.AsyncClient:
73
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://app.example")
74
+
75
+
76
+ def claims_of_request(request: Request) -> TokenClaims | None:
77
+ token = request.headers.get("x-token")
78
+ if token is None:
79
+ return None
80
+ issuer, subject, session_id = token.split("|")
81
+ return TokenClaims(issuer=issuer, subject=subject, session_id=session_id, issued_at=datetime.now(tz=UTC))
82
+
83
+
84
+ def claims_of_scope(scope: dict[str, object]) -> TokenClaims | None:
85
+ headers = dict(scope.get("headers") or ()) # type: ignore[call-overload]
86
+ token = headers.get(b"x-token")
87
+ if token is None:
88
+ return None
89
+ issuer, subject, session_id = token.decode().split("|")
90
+ return TokenClaims(issuer=issuer, subject=subject, session_id=session_id, issued_at=datetime.now(tz=UTC))
91
+
92
+
93
+ # -- the push router --------------------------------------------------------------------------------
94
+
95
+
96
+ async def test_push_route_rejects_a_request_without_the_authorization_header():
97
+ transmitter = TestTransmitter()
98
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
99
+ app = FastAPI()
100
+ app.include_router(push_router(receiver.push_handler))
101
+ encoded_set = transmitter.set("CaepSessionRevoked", iss_sub(transmitter.issuer, "alice"))
102
+ async with client_of(app) as client:
103
+ response = await client.post(
104
+ "/ssf/push", content=encoded_set, headers={"Content-Type": "application/secevent+jwt"}
105
+ )
106
+ assert response.status_code == 401
107
+ assert response.headers["content-type"] == "application/json"
108
+ assert response.headers["content-language"] == "en"
109
+ assert response.json()["err"] == "authentication_failed"
110
+
111
+
112
+ async def test_push_route_accepts_a_set_and_invokes_the_handlers():
113
+ transmitter = TestTransmitter()
114
+ recorder = Recorder()
115
+ receiver = Receiver(
116
+ push_config(transmitter), http_client=transmitter.client, handlers=[for_event("CaepSessionRevoked", recorder)]
117
+ )
118
+ app = FastAPI()
119
+ app.include_router(push_router(receiver.push_handler))
120
+ subject = complex_subject(iss_sub(transmitter.issuer, "alice"), opaque("s1"))
121
+ encoded_set = transmitter.set("CaepSessionRevoked", subject)
122
+ async with client_of(app) as client:
123
+ response = await client.post("/ssf/push", content=encoded_set, headers=PUSH_HEADERS)
124
+ assert response.status_code == 202
125
+ assert response.content == b""
126
+ assert "content-type" not in response.headers
127
+ assert "content-language" not in response.headers
128
+ assert recorder.count == 1
129
+ token = TokenClaims(issuer=transmitter.issuer, subject="alice", session_id="s1", issued_at=datetime.now(tz=UTC))
130
+ assert await receiver.token_revocation_store.is_session_revoked(token.issuer, token.session_id)
131
+
132
+
133
+ async def test_push_route_rejects_an_invalid_set_with_an_error_document():
134
+ transmitter = TestTransmitter()
135
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
136
+ app = FastAPI()
137
+ app.include_router(push_router(receiver.push_handler))
138
+ claims = transmitter.set_claims("CaepSessionRevoked", iss_sub(transmitter.issuer, "alice"))
139
+ unknown_key = sign(shared_key().with_key_id("unknown"), SECEVENT_JWT_TYPE, claims)
140
+ async with client_of(app) as client:
141
+ garbage = await client.post("/ssf/push", content="not-a-security-event-token", headers=PUSH_HEADERS)
142
+ response = await client.post("/ssf/push", content=unknown_key, headers=PUSH_HEADERS)
143
+ # not a JWS at all: no issuer to route it by
144
+ assert garbage.status_code == 400
145
+ assert garbage.json()["err"] == "invalid_issuer"
146
+ assert response.status_code == 400
147
+ assert response.headers["content-type"] == "application/json"
148
+ assert response.headers["content-language"] == "en"
149
+ assert response.json() == {"err": "invalid_key", "description": "The SET is signed with an unknown key"}
150
+
151
+
152
+ async def test_push_route_rejects_an_oversized_body_by_its_content_length():
153
+ transmitter = TestTransmitter()
154
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
155
+ app = FastAPI()
156
+ app.include_router(push_router(receiver.push_handler))
157
+ async with client_of(app) as client:
158
+ response = await client.post("/ssf/push", content=b"a" * (MAX_SET_SIZE + 1), headers=PUSH_HEADERS)
159
+ assert response.status_code == 413
160
+ assert response.json() == {"err": "invalid_request", "description": "The SET is too large"}
161
+ assert response.headers["content-language"] == "en"
162
+
163
+
164
+ async def test_push_route_rejects_an_oversized_body_sent_in_chunks():
165
+ transmitter = TestTransmitter()
166
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
167
+ app = FastAPI()
168
+ app.include_router(push_router(receiver.push_handler))
169
+
170
+ async def chunks() -> AsyncIterator[bytes]:
171
+ for _ in range(MAX_SET_SIZE // 1024 + 1):
172
+ yield b"a" * 1024
173
+
174
+ async with client_of(app) as client:
175
+ response = await client.post("/ssf/push", content=chunks(), headers=PUSH_HEADERS)
176
+ assert response.status_code == 413
177
+ assert response.json()["err"] == "invalid_request"
178
+
179
+
180
+ async def test_push_route_buffers_no_more_than_the_limit_whatever_the_chunks():
181
+ seen: list[int] = []
182
+
183
+ class Spy:
184
+ async def handle(self, authorization: str | None, body: bytes) -> PushResponse:
185
+ seen.append(len(body))
186
+ return PushResponse(413, None)
187
+
188
+ app = FastAPI()
189
+ app.include_router(push_router(Spy())) # type: ignore[arg-type]
190
+
191
+ async def one_chunk() -> AsyncIterator[bytes]:
192
+ yield b"a" * (4 * MAX_SET_SIZE)
193
+
194
+ async with client_of(app) as client:
195
+ response = await client.post("/ssf/push", content=one_chunk(), headers=PUSH_HEADERS)
196
+ assert response.status_code == 413
197
+ assert seen == [MAX_SET_SIZE + 1], "one byte beyond the limit, not the chunk"
198
+
199
+
200
+ async def test_push_route_answers_other_methods_with_405():
201
+ transmitter = TestTransmitter()
202
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
203
+ app = FastAPI()
204
+ app.include_router(push_router(receiver.push_handler))
205
+ async with client_of(app) as client:
206
+ response = await client.get("/ssf/push")
207
+ assert response.status_code == 405
208
+
209
+
210
+ async def test_push_router_for_mounts_at_the_configured_path():
211
+ transmitter = TestTransmitter()
212
+ config = push_config(transmitter)
213
+ config.push.endpoint_path = "/events/ssf"
214
+ receiver = Receiver(config, http_client=transmitter.client)
215
+ app = FastAPI()
216
+ app.include_router(push_router_for(receiver))
217
+ encoded_set = transmitter.set("CaepSessionRevoked", iss_sub(transmitter.issuer, "alice"))
218
+ async with client_of(app) as client:
219
+ assert (await client.post("/events/ssf", content=encoded_set, headers=PUSH_HEADERS)).status_code == 202
220
+ assert (await client.post("/ssf/push", content=encoded_set, headers=PUSH_HEADERS)).status_code == 404
221
+
222
+
223
+ def test_into_response_sets_the_headers_only_with_a_body():
224
+ accepted = into_response(PushResponse(202))
225
+ assert accepted.status_code == 202
226
+ assert accepted.body == b""
227
+ assert "content-type" not in accepted.headers
228
+ rejected = into_response(PushResponse.error(400, "invalid_key", "The signature of the SET is invalid"))
229
+ assert rejected.status_code == 400
230
+ assert rejected.headers["content-type"] == "application/json"
231
+ assert rejected.headers["content-language"] == "en"
232
+ assert rejected.body == b'{"err":"invalid_key","description":"The signature of the SET is invalid"}'
233
+
234
+
235
+ # -- the revocation dependency and middleware -----------------------------------------------------
236
+
237
+
238
+ def guarded_app(store: object, extract: object = claims_of_request) -> FastAPI:
239
+ app = FastAPI()
240
+ reject_revoked = revocation_dependency(store, extract) # type: ignore[arg-type]
241
+
242
+ @app.get("/api/me", dependencies=[Depends(reject_revoked)])
243
+ async def me() -> dict[str, str]:
244
+ return {"user": "alice"}
245
+
246
+ return app
247
+
248
+
249
+ async def test_revocation_dependency_lets_a_request_without_or_with_a_valid_token_through():
250
+ store = InMemoryTokenRevocationStore()
251
+ app = guarded_app(store)
252
+ async with client_of(app) as client:
253
+ assert (await client.get("/api/me")).status_code == 200, "no token"
254
+ response = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
255
+ assert response.status_code == 200
256
+ assert response.json() == {"user": "alice"}
257
+
258
+
259
+ async def test_revocation_dependency_rejects_a_revoked_token_with_401_and_the_challenge():
260
+ store = InMemoryTokenRevocationStore()
261
+ await store.revoke_session("https://idp.example", "s1")
262
+ app = guarded_app(store)
263
+ async with client_of(app) as client:
264
+ response = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
265
+ other = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s2"})
266
+ assert response.status_code == 401
267
+ assert response.headers["www-authenticate"] == REVOKED_WWW_AUTHENTICATE
268
+ assert other.status_code == 200, "another session of the same user"
269
+
270
+
271
+ async def test_revocation_dependency_accepts_an_async_extractor():
272
+ store = InMemoryTokenRevocationStore()
273
+ await store.revoke_subject("https://idp.example", "alice", datetime.now(tz=UTC) + timedelta(minutes=1))
274
+
275
+ async def extract(request: Request) -> TokenClaims | None:
276
+ return claims_of_request(request)
277
+
278
+ app = guarded_app(store, extract)
279
+ async with client_of(app) as client:
280
+ response = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
281
+ assert response.status_code == 401
282
+
283
+
284
+ async def test_revocation_dependency_answers_503_when_the_store_fails():
285
+ app = guarded_app(FailingStore())
286
+ async with client_of(app) as client:
287
+ response = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
288
+ anonymous = await client.get("/api/me")
289
+ assert response.status_code == 503
290
+ assert anonymous.status_code == 200, "no token, no lookup"
291
+
292
+
293
+ async def test_revocation_middleware_guards_the_application():
294
+ store = InMemoryTokenRevocationStore()
295
+ await store.revoke_session("https://idp.example", "s1")
296
+ app = FastAPI()
297
+ app.add_middleware(RevocationMiddleware, store=store, extract=claims_of_scope)
298
+
299
+ @app.get("/api/me")
300
+ async def me() -> dict[str, str]:
301
+ return {"user": "alice"}
302
+
303
+ async with client_of(app) as client:
304
+ allowed = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s2"})
305
+ revoked = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
306
+ assert allowed.status_code == 200
307
+ assert revoked.status_code == 401
308
+ assert revoked.headers["www-authenticate"] == REVOKED_WWW_AUTHENTICATE
309
+
310
+
311
+ async def test_revocation_middleware_answers_503_when_the_store_fails():
312
+ app = FastAPI()
313
+ app.add_middleware(RevocationMiddleware, store=FailingStore(), extract=claims_of_scope)
314
+
315
+ @app.get("/api/me")
316
+ async def me() -> dict[str, str]:
317
+ return {"user": "alice"}
318
+
319
+ async with client_of(app) as client:
320
+ response = await client.get("/api/me", headers={"X-Token": "https://idp.example|alice|s1"})
321
+ assert response.status_code == 503
322
+
323
+
324
+ # -- the lifespan -------------------------------------------------------------------------------------
325
+
326
+
327
+ async def test_lifespan_starts_and_stops_the_receiver():
328
+ transmitter = TestTransmitter()
329
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
330
+ app = FastAPI(lifespan=receiver_lifespan(receiver))
331
+ assert not receiver.started
332
+ async with app.router.lifespan_context(app):
333
+ assert receiver.started
334
+ assert not receiver.started
335
+
336
+
337
+ async def test_lifespan_wraps_the_lifespan_of_the_application():
338
+ transmitter = TestTransmitter()
339
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
340
+ events: list[str] = []
341
+
342
+ @asynccontextmanager
343
+ async def inner(app: FastAPI) -> AsyncIterator[dict[str, str]]:
344
+ events.append(f"inner enter, receiver started: {receiver.started}")
345
+ yield {"database": "ready"}
346
+ events.append(f"inner exit, receiver started: {receiver.started}")
347
+
348
+ app = FastAPI(lifespan=receiver_lifespan(receiver, inner))
349
+ async with app.router.lifespan_context(app) as state:
350
+ assert receiver.started
351
+ assert state == {"database": "ready"}
352
+ assert events == ["inner enter, receiver started: False", "inner exit, receiver started: False"]
353
+ assert not receiver.started
354
+
355
+
356
+ # -- the health route -----------------------------------------------------------------------------
357
+
358
+
359
+ async def test_health_route_answers_200_while_healthy_or_degraded():
360
+ transmitter = TestTransmitter()
361
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
362
+ app = FastAPI()
363
+ app.include_router(health_router(receiver))
364
+ async with client_of(app) as client:
365
+ degraded = await client.get("/health")
366
+ assert degraded.status_code == 200
367
+ assert degraded.json()["status"] == "degraded"
368
+ assert degraded.json()["transmitters"][0]["name"] == "default"
369
+ # a verified SET means the metadata and the keys were fetched: healthy
370
+ encoded_set = transmitter.set("CaepSessionRevoked", iss_sub(transmitter.issuer, "alice"))
371
+ assert (await receiver.push_handler.handle(SECRET, encoded_set)).status == 202
372
+ healthy = await client.get("/health")
373
+ assert healthy.status_code == 200
374
+ assert healthy.json()["status"] == "healthy"
375
+ assert healthy.json()["description"] == "In contact with the SSF transmitter(s)"
376
+
377
+
378
+ async def test_health_route_answers_503_when_unhealthy():
379
+ transmitter = TestTransmitter()
380
+ config = default_receiver_config()
381
+ config.transmitter_issuer = transmitter.issuer
382
+ config.expected_audience = AUDIENCE
383
+ config.delivery_method = "poll"
384
+ config.poll.endpoint_url = transmitter.poll_url
385
+ config.poll.interval = timedelta(milliseconds=50)
386
+ config.transmitter_access_token = "token"
387
+ receiver = Receiver(config, http_client=transmitter.client)
388
+ transmitter.set_available(False)
389
+ app = FastAPI(lifespan=receiver_lifespan(receiver))
390
+ app.include_router(health_router(receiver, path="/ready"))
391
+ async with app.router.lifespan_context(app), client_of(app) as client:
392
+ poller = receiver.transmitters[0].poller
393
+ await eventually(lambda: bool(poller.last_poll_error), what="a failed poll")
394
+ response = await client.get("/ready")
395
+ assert response.status_code == 503
396
+ assert response.json()["status"] == "unhealthy"
397
+ assert response.json()["transmitters"][0]["pollError"]
398
+
399
+
400
+ @pytest.mark.parametrize("path", ["/health", "/ready"])
401
+ async def test_health_route_is_mounted_at_the_given_path(path: str):
402
+ transmitter = TestTransmitter()
403
+ receiver = Receiver(push_config(transmitter), http_client=transmitter.client)
404
+ app = FastAPI()
405
+ app.include_router(health_router(receiver, path=path))
406
+ async with client_of(app) as client:
407
+ assert (await client.get(path)).status_code == 200