easyssf-receiver-fastapi 0.7.0__py3-none-any.whl

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,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")
File without changes
@@ -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,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,8 @@
1
+ easyssf/fastapi/__init__.py,sha256=2r3WQ04bbX7YHOrW6JBaxVIR1OUJGBMxw-oAeKJbC9U,1606
2
+ easyssf/fastapi/lifecycle.py,sha256=L2K-uJfJdB_PI96ABep22V_W7JF5wvt5agUiD0pRJhY,2706
3
+ easyssf/fastapi/push.py,sha256=tqF2YxHvk7GJCbcpwEUD_lutG0mKAlU8uVPFvkYI0o8,4291
4
+ easyssf/fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ easyssf/fastapi/revocation.py,sha256=8UJ9cmx-sSFScHAZmaAt_AJm9clUgdSqABPHHliu_3s,4465
6
+ easyssf_receiver_fastapi-0.7.0.dist-info/METADATA,sha256=4ahytsFWSWJKiL_l6aBQDUUbOb-ig9zRIvqbX1gHPss,5644
7
+ easyssf_receiver_fastapi-0.7.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ easyssf_receiver_fastapi-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any