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.
- easyssf_receiver_fastapi-0.7.0/.gitignore +22 -0
- easyssf_receiver_fastapi-0.7.0/PKG-INFO +134 -0
- easyssf_receiver_fastapi-0.7.0/README.md +112 -0
- easyssf_receiver_fastapi-0.7.0/pyproject.toml +44 -0
- easyssf_receiver_fastapi-0.7.0/src/easyssf/fastapi/__init__.py +39 -0
- easyssf_receiver_fastapi-0.7.0/src/easyssf/fastapi/lifecycle.py +68 -0
- easyssf_receiver_fastapi-0.7.0/src/easyssf/fastapi/push.py +93 -0
- easyssf_receiver_fastapi-0.7.0/src/easyssf/fastapi/py.typed +0 -0
- easyssf_receiver_fastapi-0.7.0/src/easyssf/fastapi/revocation.py +93 -0
- easyssf_receiver_fastapi-0.7.0/tests/test_fastapi.py +407 -0
|
@@ -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")
|
|
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,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
|