atoti.proxy.Proxy#
- final class atoti.proxy.Proxy#
The proxy alllowing to forward requests from Atoti Server to another server.
A request made to
f"{session.url}/proxy/some/path?foo=bar"
will be proxied tof"{session.proxy.url}/some/path?foo=bar"
:The new request will have an Authorization HTTP header containing a JWT. This JWT contains the following claims:
sub
: The name of the user making the request to the proxy.authorities
: The list of roles of the user.
This JWT is signed by Atoti Server, except when using the community edition.
Warning
For maximal flexibility,
f"{session.url}/proxy"
does not require authentication. It is the responsibility of the server based aturl
to handle authentication and authorization if needed.sequenceDiagram participant C as Client participant S as Atoti Server
https://example.com:1337 participant P as Proxy target server
https://custom.com:1991 C->>S: session.proxy.url = "https://custom.com:1991" C->>S: POST /proxy/some/path?foo=bar S->>P: POST /some/path?foo=bar
with extra HTTP headers:
• Authorization: Bearer $jwt
• X-Forwarded-Proto: https
• X-Forwarded-Host: example.com:1337 P->>P: ⚠ Check $jwt P->>S: HTTP $status_code response S->>C: HTTP $status_code responseExample
Using a custom JWT key pair to be able to verify JWT signatures in the local server below:
>>> public_key, public_key_pem, private_key = ( ... (jwt_directory / filename).read_text() ... for filename in ["public-key.txt", "public-key.pem", "private-key.txt"] ... )
>>> from jwt import decode >>> def get_user(jwt: str, /) -> tuple[str, list[str]]: ... """Check the JWT signature and return the user name and roles claims. ... ... Verifying the JWT signature is primordial to ensure ... that the request is legitimate and coming from Atoti Server. ... ... This function does not rely on Atoti Python SDK ... and could actually be implemented in any language. ... """ ... claims = decode(jwt, algorithms=["RS512"], key=public_key_pem) ... user_name = claims["sub"] ... user_roles = claims["authorities"] ... return user_name, user_roles
Starting a secured session:
>>> session_config = tt.SessionConfig( ... security=tt.SecurityConfig( ... jwt=tt.JwtConfig(key_pair=tt.KeyPair(public_key, private_key)) ... ) ... ) >>> secured_session = tt.Session.start(session_config)
The proxy is disabled by default:
>>> import httpx >>> print(secured_session.proxy.url) None >>> response = httpx.get(f"{secured_session.url}/proxy") >>> response.status_code 501 >>> response.text 'The proxy is disabled.'
Starting a local server:
>>> from json import dumps >>> from http import HTTPStatus >>> from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer >>> from threading import Thread >>> from urllib.parse import parse_qs, urlparse
>>> class RequestHandler(BaseHTTPRequestHandler): ... def do_GET(self) -> None: ... parse_result = urlparse(self.path) ... match parse_result.path, parse_qs(parse_result.query): ... case "/whoami", {"mode": [mode]}: ... token_type, jwt = self.headers["Authorization"].split(" ") ... match mode: ... case "jwt": ... # Most projects will want to raise an error if ... # `ROLE_USER` is not included in `user_roles`. ... user_name, user_roles = get_user(jwt) ... case "session": ... # This server does not have to be aware of the Atoti session URL ... # since it can be reconstructed from headers passed by the proxy. ... protocol = self.headers["X-Forwarded-Proto"] ... host = self.headers["X-Forwarded-Host"] ... session_url = f"{protocol}://{host}" ... authentication = tt.TokenAuthentication( ... jwt, token_type=token_type ... ) ... try: ... with tt.Session.connect( ... session_url, authentication=authentication ... ) as session: ... user = session.user ... user_name = user.name ... user_roles = sorted(user.roles) ... except Exception: ... self.send_response_only(HTTPStatus.FORBIDDEN) ... self.end_headers() ... return ... case _: ... self.send_response_only(HTTPStatus.BAD_REQUEST) ... self.end_headers() ... return ... body = dumps({"name": user_name, "roles": user_roles}) ... self.send_response_only(HTTPStatus.OK) ... self.end_headers() ... self.wfile.write(body.encode("utf-8")) ... case _: ... self.send_response_only(HTTPStatus.NOT_FOUND) ... self.end_headers() ... ... def do_POST(self) -> None: ... content_length = int(self.headers["Content-Length"]) ... body = self.rfile.read(content_length) ... self.send_response_only(HTTPStatus.OK) ... # To show that response headers are sent back to the client. ... self.send_header("X-Custom-Header", "lorem ipsum") ... self.end_headers() ... self.wfile.write(body)
>>> local_server = ThreadingHTTPServer(("localhost", 0), RequestHandler) >>> thread = Thread(daemon=True, target=local_server.serve_forever) >>> thread.start()
The server above is implemented in Python but the proxy target can be any HTTP server or even a lambda function in the cloud.
Configuring the proxy to forward requests to the local server:
>>> secured_session.proxy.url = f"http://localhost:{local_server.server_port}"
Calling the proxy without authentication:
>>> response = httpx.get(f"{secured_session.url}/proxy/whoami?mode=jwt") >>> response.status_code 200 >>> response.json() {'name': 'anonymousUser', 'roles': ['ROLE_ANONYMOUS']}
Without authentication, ROLE_USER is missing and thus
atoti.Session.connect()
will raise an error:>>> response = httpx.get(f"{secured_session.url}/proxy/whoami?mode=session") >>> response.status_code 403
Adding a user to the session and calling the proxy as this user:
>>> username, password = "Alice", "abcdef123456" >>> alice_roles = {"ROLE_USER", "ROLE_WONDERLAND"} >>> secured_session.security.individual_roles[username] = alice_roles >>> secured_session.security.basic_authentication.credentials[username] = ( ... password ... ) >>> response = httpx.get( ... f"{secured_session.url}/proxy/whoami?mode=jwt", ... auth=(username, password), ... ) >>> response.status_code 200 >>> response.json() {'name': 'Alice', 'roles': ['ROLE_USER', 'ROLE_WONDERLAND']}
ROLE_USER is present so
atoti.Session.connect()
and the access toatoti.Session.user
will both succeed:>>> response = httpx.get( ... f"{secured_session.url}/proxy/whoami?mode=session", ... auth=(username, password), ... ) >>> response.status_code 200 >>> response.json() {'name': 'Alice', 'roles': ['ROLE_USER', 'ROLE_WONDERLAND']}
Trying an unsupported mode:
>>> response = httpx.get(f"{secured_session.url}/proxy/whoami?mode=invalid") >>> response.status_code 400
Trying an unhandled path:
>>> response = httpx.get(f"{secured_session.url}/proxy") >>> response.status_code 404
Request and response bodies are not limited to strings:
>>> from secrets import token_bytes >>> bytes = token_bytes() >>> response = httpx.post(f"{secured_session.url}/proxy", content=bytes) >>> response.status_code 200 >>> int(response.headers["Content-Length"]) == len(bytes) True >>> response.content == bytes True >>> response.headers["X-Custom-Header"] 'lorem ipsum'
Disabling the proxy:
>>> secured_session.proxy.url = None >>> httpx.get(f"{secured_session.url}/proxy").status_code 501
On a session without security,
atoti.Session.connect()
always succeeds, even for anonymous calls:>>> print(session.proxy.url) None >>> session.proxy.url = f"http://localhost:{local_server.server_port}" >>> response = httpx.get(f"{session.url}/proxy/whoami?mode=session") >>> response.status_code 200 >>> response.json() {'name': 'anonymousUser', 'roles': ['ROLE_ADMIN', 'ROLE_ANONYMOUS', 'ROLE_USER']}
See
atoti.Session.user
for an explanation of the roles above.Stopping the local server:
>>> local_server.shutdown() >>> local_server.server_close() >>> thread.join()
See also
The URL towards which requests are forwarded. |