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.
- easyssf/fastapi/__init__.py +39 -0
- easyssf/fastapi/lifecycle.py +68 -0
- easyssf/fastapi/push.py +93 -0
- easyssf/fastapi/py.typed +0 -0
- easyssf/fastapi/revocation.py +93 -0
- easyssf_receiver_fastapi-0.7.0.dist-info/METADATA +134 -0
- easyssf_receiver_fastapi-0.7.0.dist-info/RECORD +8 -0
- easyssf_receiver_fastapi-0.7.0.dist-info/WHEEL +4 -0
|
@@ -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
|
easyssf/fastapi/push.py
ADDED
|
@@ -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")
|
easyssf/fastapi/py.typed
ADDED
|
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,,
|