16 |
17 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/testing-api/data/mocks.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, List
2 |
3 | from domain import ToDo, ToDosRepository
4 | from rodi import Container
5 |
6 |
7 | class MockedToDosRepository(ToDosRepository):
8 | def __init__(self) -> None:
9 | self._todos: Dict[int, ToDo] = {}
10 |
11 | async def get_todos(self) -> List[ToDo]:
12 | return list(self._todos.values())
13 |
14 | async def store_todo(self, item: ToDo) -> None:
15 | self._todos[item.id] = item
16 |
17 | async def delete_todo(self, item: ToDo) -> None:
18 | try:
19 | del self._todos[item.id]
20 | except KeyError:
21 | pass
22 |
23 |
24 | def register_mocked_services(container: Container) -> None:
25 | container.add_scoped(ToDosRepository, MockedToDosRepository)
26 |
--------------------------------------------------------------------------------
/websocket-chat/app/main.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 |
3 | from blacksheep import Application, WebSocket, WebSocketDisconnectError
4 | from blacksheep.server.responses import redirect
5 | from .connection import ConnectionManager
6 |
7 | APP_PATH = pathlib.Path(__file__).parent / 'static'
8 |
9 | app = Application()
10 | app.serve_files(APP_PATH, root_path='app')
11 |
12 | manager = ConnectionManager()
13 |
14 |
15 | @app.router.ws('/ws/{client_id}')
16 | async def ws(websocket: WebSocket, client_id: str):
17 | conn = await manager.connect(websocket, client_id)
18 |
19 | try:
20 | while True:
21 | await manager.manage(conn)
22 | except WebSocketDisconnectError:
23 | await manager.disconnect(conn)
24 |
25 |
26 | @app.router.get('/')
27 | def index():
28 | return redirect('/app')
29 |
--------------------------------------------------------------------------------
/proxy-2/README.md:
--------------------------------------------------------------------------------
1 | # Example showing an HTTP Proxy implemented with BlackSheep
2 |
3 | This example shows an HTTP Proxy implementation, proxying requests for another
4 | `blacksheep` back-end.
5 |
6 | Run the frst BlackSheep application:
7 |
8 | ```bash
9 | python blacksheep_app/server.py
10 | ```
11 |
12 | Run the BlackSheep proxy application:
13 |
14 | ```bash
15 | python blacksheep_proxy/server.py
16 | ```
17 |
18 | Open `example-2.html` in a browser and use its forms to test uploading to the
19 | server directly, and to the BlackSheep proxy. The result should be the same.
20 |
21 | ## Note
22 | The example proxy in `blacksheep_proxy` handles memory in the proper way:
23 |
24 | - it reads input streams as chunks (never whole in memory)
25 | - it reads response streams from the back-end in chunks (never whole in memory)
26 |
--------------------------------------------------------------------------------
/testing-api/README.md:
--------------------------------------------------------------------------------
1 | # TestClient example
2 |
3 | This folder contains an example showing how to test an API using the built-in
4 | `TestClient` and `pytest`.
5 |
6 | The preparation of this example is described in the official documentation under [testing](https://www.neoteroi.dev/blacksheep/testing/).
7 | This demo uses SQLite, refer to Piccolo's documentation to use PostgreSQL.
8 |
9 | ## Getting started
10 |
11 | 1. Create a Python virtual environment
12 | 2. Activate the virtual environment
13 | 3. Install dependencies in `requirements.txt`
14 | 4. Run tests using `pytest`
15 |
16 | ```bash
17 | # create a Python virtual environment
18 | python -m venv venv
19 |
20 | # activate
21 | source venv/bin/activate # (Linux)
22 |
23 | venv\Scripts\activate # (Windows)
24 |
25 | # install dependencies
26 | pip install -r requirements.txt
27 |
28 | # run tests
29 | pytest
30 | ```
31 |
--------------------------------------------------------------------------------
/oidc/basic_okta.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Okta, obtaining
3 | only an id_token, exchanged with the client using a response cookie.
4 | """
5 | import uvicorn
6 | from blacksheep.server.application import Application
7 | from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect
8 |
9 | from common.routes import register_routes
10 |
11 | app = Application(show_error_details=True)
12 |
13 |
14 | # basic Okta integration that handles only an id_token
15 | use_openid_connect(
16 | app,
17 | OpenIDSettings(
18 | authority="https://dev-34685660.okta.com",
19 | client_id="0oa2gy88qiVyuOClI5d7",
20 | callback_path="/authorization-code/callback",
21 | ),
22 | )
23 |
24 | register_routes(app)
25 |
26 |
27 | if __name__ == "__main__":
28 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
29 |
--------------------------------------------------------------------------------
/oidc/basic_aad.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Azure Active
3 | Directory, obtaining only an id_token, exchanged with the client using a response
4 | cookie.
5 | """
6 | import uvicorn
7 | from blacksheep.server.application import Application
8 | from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect
9 |
10 | from common.routes import register_routes
11 |
12 | app = Application(show_error_details=True)
13 |
14 |
15 | # basic AAD integration that handles only an id_token
16 | use_openid_connect(
17 | app,
18 | OpenIDSettings(
19 | authority="https://login.microsoftonline.com/b62b317a-19c2-40c0-8650-2d9672324ac4/v2.0/",
20 | client_id="499adb65-5e26-459e-bc35-b3e1b5f71a9d",
21 | ),
22 | )
23 |
24 | register_routes(app)
25 |
26 |
27 | if __name__ == "__main__":
28 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
29 |
--------------------------------------------------------------------------------
/oidc/basic_auth0.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Auth0, obtaining
3 | only an id_token, exchanged with the client using a response cookie.
4 | """
5 | import uvicorn
6 | from blacksheep.server.application import Application
7 | from blacksheep.server.authentication.oidc import (
8 | OpenIDSettings,
9 | use_openid_connect,
10 | )
11 |
12 | from common.routes import register_routes
13 |
14 | app = Application(show_error_details=True)
15 |
16 |
17 | # basic Auth0 integration that handles only an id_token
18 | use_openid_connect(
19 | app,
20 | OpenIDSettings(
21 | authority="https://neoteroi.eu.auth0.com",
22 | client_id="OOGPl4dgG7qKsm2IOWq72QhXV4wsLhbQ",
23 | callback_path="/signin-oidc",
24 | ),
25 | )
26 |
27 | register_routes(app)
28 |
29 |
30 | if __name__ == "__main__":
31 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
32 |
--------------------------------------------------------------------------------
/aad-machine-to-machine/certs/README.md:
--------------------------------------------------------------------------------
1 | # Example with cliet credential flow using a certificate
2 |
3 | This folder contains an utility script that can be used to generate certificates
4 | for app registrations in Azure Active Directory.
5 |
6 | It is for Bash (it also works in the Git Bash for Windows, but it requires an
7 | extra variable like explained below).
8 | Example usage:
9 |
10 | ```bash
11 | # Examples:
12 | NAME=foo ./create-cert.sh
13 |
14 | # With subject:
15 | NAME=example SUBJECT="/C=IT/ST=TO/L=TO/O=E/OU=Example Team/CN=example.com" ./create-cert.sh
16 |
17 | # With password for PFX:
18 | NAME=example SUBJECT="/C=IT/ST=TO/L=TO/O=E/OU=Example Team/CN=example.com" PFX_PASS=FooFoo ./create-cert.sh
19 | ```
20 |
21 | When using the Git Bash for Windows, include **MSYS_NO_PATHCONV=1**, like in:
22 |
23 | ```bash
24 | MSYS_NO_PATHCONV=1 NAME=example SUBJECT="/C=IT/ST=TO/L=TO/O=E/OU=Example Team/CN=example.com" ./create-cert.sh
25 | ```
26 |
--------------------------------------------------------------------------------
/oauth2-password-provider/README.md:
--------------------------------------------------------------------------------
1 | # Example of Self Hosted OAuth2 Password Provider
2 |
3 | A simple example of a self hosted OAuth2 password provider.
4 | It can be used to authenticate users in own applications with password flow.
5 |
6 | ## Running the example
7 |
8 | - create a Python virtual environment
9 |
10 | ```bash
11 | python -m venv venv
12 | ```
13 |
14 | - install dependencies
15 |
16 | ```bash
17 | pip install -r requirements.txt
18 | ```
19 |
20 | - run the dev server
21 |
22 | ```bash
23 | python server.py
24 | ```
25 |
26 | - look at [example.http](example.http) to find at the example requests
27 |
28 | ## Server has 4 endpoints
29 |
30 | - `/api/register` - register a new user
31 | - `/api/token` - get a new access token
32 | - `/api/refresh` - refresh an access token
33 | - `/api/revoke` - revoke an access token
34 | - `/api/anonymous` - get a resource without authentication
35 | - `/api/protected` - get a resource with authentication
--------------------------------------------------------------------------------
/oidc/basic_google.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Google, obtaining
3 | only an id_token, exchanged with the client using a response cookie.
4 | """
5 | import uvicorn
6 | from blacksheep.server.application import Application
7 | from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect
8 |
9 | from common.routes import register_routes
10 |
11 | app = Application(show_error_details=True)
12 |
13 | client_id = "349036756498-715barque0aq00qplb3fon9i6hig7ib9.apps.googleusercontent.com"
14 |
15 | # basic Google integration that handles only an id_token
16 | use_openid_connect(
17 | app,
18 | OpenIDSettings(
19 | authority="https://accounts.google.com",
20 | client_id=client_id,
21 | callback_path="/authorization-callback",
22 | ),
23 | )
24 |
25 | register_routes(app)
26 |
27 |
28 | if __name__ == "__main__":
29 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
30 |
--------------------------------------------------------------------------------
/piccolo-admin/README.md:
--------------------------------------------------------------------------------
1 | # Piccolo Admin example
2 |
3 | This folder contains an example showing how to use the `mount` feature to run
4 | a `Piccolo Admin` application in `BlackSheep`.
5 |
6 | For more information on the `mount` feature, refer to the [documentation](https://www.neoteroi.dev/blacksheep/mounting/).
7 |
8 | For more information on Piccolo Admin, refer to its [project in GitHub](https://github.com/piccolo-orm/piccolo_admin).
9 |
10 | ## Getting started
11 |
12 | 1. Create a Python virtual environment
13 | 2. Activate the virtual environment
14 | 3. Install dependencies in `requirements.txt`
15 | 4. Run, using the desired HTTP server (e.g. `uvicorn server:app --reload`)
16 |
17 | ```bash
18 | # create a Python virtual environment
19 | python -m venv venv
20 |
21 | # activate
22 | source venv/bin/activate # (Linux)
23 |
24 | venv\Scripts\activate # (Windows)
25 |
26 | # install dependencies
27 | pip install -r requirements.txt
28 |
29 | # run
30 | uvicorn server:app --reload
31 | ```
32 |
--------------------------------------------------------------------------------
/server-sent-events/README.md:
--------------------------------------------------------------------------------
1 | ## Server-Sent events example
2 |
3 | This example illustrates how to use built-in features for [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) (SSE).
4 |
5 | Note that, even though BlackSheep supports built-in features for SSE only since
6 | version 2.0.6, previous versions of the web framework also could support SSE,
7 | as they support response streaming.
8 |
9 | Running the example:
10 |
11 | 1. create a Python virtual environment
12 | 2. install dependencies
13 | 3. run with `uvicorn server:app` to test with `uvicorn`, or
14 | `hypercorn server:app` to test with `hypercorn`
15 | 4. open the page in a web browser, you should see the message on the page
16 | updated every second, using information from the server
17 |
18 | ---
19 |
20 | This example also shows how the `is_stopping` function can be used to detect
21 | when the application server is shutting down.
22 |
23 | ```python
24 | from blacksheep.server.application import is_stopping
25 | ```
26 |
--------------------------------------------------------------------------------
/jwt-validation/example.py:
--------------------------------------------------------------------------------
1 | from blacksheep.server.application import Application
2 | from blacksheep.server.authentication.jwt import JWTBearerAuthentication
3 | from blacksheep.server.authorization import auth
4 | from guardpost.common import AuthenticatedRequirement, Policy
5 |
6 | app = Application()
7 |
8 |
9 | app.use_authentication().add(
10 | JWTBearerAuthentication(
11 | authority="https://login.microsoftonline.com/robertoprevatogmail.onmicrosoft.com",
12 | valid_audiences=["104bca60-c5a7-4ab9-83e1-7b9c8dad71e2"],
13 | valid_issuers=[
14 | "https://login.microsoftonline.com/b62b317a-19c2-40c0-8650-2d9672324ac4/v2.0"
15 | ],
16 | )
17 | )
18 |
19 | authorization = app.use_authorization()
20 |
21 | authorization += Policy("any_name", AuthenticatedRequirement())
22 |
23 | get = app.router.get
24 |
25 |
26 | @get("/")
27 | def home():
28 | return "Hello, World"
29 |
30 |
31 | @auth("any_name")
32 | @get("/api/message")
33 | def example():
34 | return "This is only for authenticated users"
35 |
--------------------------------------------------------------------------------
/server-sent-events/server.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import os
3 | from collections.abc import AsyncIterable
4 |
5 | from blacksheep import Application, Request, get
6 | from blacksheep.server.process import is_stopping
7 | from blacksheep.server.sse import ServerSentEvent
8 |
9 | app = Application(show_error_details=True)
10 | app.serve_files("static")
11 |
12 |
13 | # Enable the signal handler to detect when the application is stopping.
14 | os.environ["APP_SIGNAL_HANDLER"] = "1"
15 |
16 |
17 | @get("/events")
18 | async def events_handler(request: Request) -> AsyncIterable[ServerSentEvent]:
19 | i = 0
20 |
21 | while True:
22 | if await request.is_disconnected():
23 | print("The request is disconnected!")
24 | break
25 |
26 | if is_stopping():
27 | print("The application is stopping!")
28 | break
29 |
30 | i += 1
31 | yield ServerSentEvent({"message": f"Hello World {i}"})
32 |
33 | try:
34 | await asyncio.sleep(1)
35 | except asyncio.exceptions.CancelledError:
36 | break
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Neoteroi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/max-body-size/README.md:
--------------------------------------------------------------------------------
1 | # Validating Max Body Size
2 | This example shows a way to validate the maximum body size using
3 | `request.stream`, and how to post a file to a BlackSheep server using the HTML5
4 | `fetch` API.
5 |
6 | ## Current limitations
7 |
8 | At the time of this writing, blacksheep does not support configuring a maximum body size
9 | globally, nor configuring a maximum body size for specific request handlers that would
10 | validate the request body size also when trying to read the whole request content as
11 | JSON, text, form data.
12 |
13 | The following methods:
14 |
15 | ```python
16 | text = await request.text()
17 | data = await request.json()
18 | data = await request.form()
19 | ```
20 |
21 | all cause the whole request body to be read.
22 |
23 | ## Running the example
24 |
25 | - create a Python virtual environment
26 | - install dependencies
27 | - run the dev server `python main.py`
28 | - navigate to [http://localhost:44555](http://localhost:44555)
29 | - use the HTML page to select a file and upload it: only files smaller than
30 | ~1.5 MB are accepted by the server, and written to an `out` folder under
31 | `CWD`
32 |
--------------------------------------------------------------------------------
/piccolo-admin/Piccolo-Admin-LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Daniel Townsend
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/websocket-chat/README.md:
--------------------------------------------------------------------------------
1 | # Chat app example
2 |
3 | This folder contains a simple chat application built using WebSocket and VueJS.
4 | You can use it as a starting point to build your own real-time application using
5 | WebSocket.
6 |
7 | Bear in mind, though, that this is merely an example. In the real world, you would
8 | probably like to use a message queue like Redis to broadcast messages to the clients
9 | and some persistent storage like PostgreSQL or MongoDB to store your messages, users, etc.
10 |
11 | ## Getting started
12 |
13 | 1. Create a Python virtual environment
14 | 2. Activate the virtual environment
15 | 3. Install dependencies in `requirements.txt`
16 | 4. Run the application using `uvicorn --reload server:app`
17 | 5. Navigate to `http://localhost:8000` in your browser and try sending
18 | some messages. You can open it in multiple tabs to simulate
19 | multiple clients connected.
20 |
21 | ```bash
22 | # create a Python virtual environment
23 | python -m venv venv
24 |
25 | # activate
26 | source venv/bin/activate # (Linux)
27 |
28 | venv\Scripts\activate # (Windows)
29 |
30 | # install dependencies
31 | pip install -r requirements.txt
32 |
33 | # run app
34 | uvicorn --reload server:app
35 | ```
--------------------------------------------------------------------------------
/proxy-1/flask_app/server.py:
--------------------------------------------------------------------------------
1 | """
2 | Example application to test a proxy implemented with BlackSheep.
3 | """
4 | from essentials.folders import ensure_folder
5 | from flask import Flask, jsonify, request
6 | from markupsafe import escape
7 |
8 | # https://flask.palletsprojects.com/en/1.1.x/server/#server
9 | app = Flask(__name__, static_url_path="", static_folder="static")
10 |
11 |
12 | @app.route("/hello-world")
13 | def hello_world():
14 | name = request.args.get("name", "World")
15 | return f"Hello, {escape(name)}!", 200, {"Content-Type": "text/plain"}
16 |
17 |
18 | # https://flask.palletsprojects.com/en/1.1.x/patterns/fileuploads/
19 | @app.route("/upload", methods=["POST"])
20 | def upload_files():
21 | files = request.files
22 |
23 | assert bool(files)
24 |
25 | folder = "out"
26 |
27 | ensure_folder(folder)
28 | all_files = files.getlist("files")
29 |
30 | for part in all_files:
31 | part.save(f"./{folder}/{part.filename}")
32 |
33 | return jsonify(
34 | {
35 | "folder": folder,
36 | "data": request.form,
37 | "files": [{"name": file.filename} for file in all_files],
38 | }
39 | )
40 |
41 |
42 | if __name__ == "__main__":
43 | app.run(host="localhost", port=44777, debug=True)
44 |
--------------------------------------------------------------------------------
/oidc/scopes_okta.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Okta, obtaining
3 | an id_token, an access_token, and a refresh_token. The id_token is exchanged with the
4 | client using a response cookie (also used to authenticate users
5 | for following requests), while access token and the refresh token are not stored and
6 | can only be accessed using optional events.
7 | """
8 | import uvicorn
9 | from blacksheep.server.application import Application
10 | from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect
11 | from dotenv import load_dotenv
12 |
13 | from common.routes import register_routes
14 | from common.secrets import Secrets
15 |
16 | load_dotenv()
17 | secrets = Secrets.from_env()
18 | app = Application(show_error_details=True)
19 |
20 |
21 | # Okta with custom scope
22 | use_openid_connect(
23 | app,
24 | OpenIDSettings(
25 | discovery_endpoint="https://dev-34685660.okta.com/oauth2/default/.well-known/oauth-authorization-server",
26 | client_id="0oa2gy88qiVyuOClI5d7",
27 | client_secret=secrets.okta_client_secret,
28 | callback_path="/authorization-code/callback",
29 | scope="openid read:todos",
30 | ),
31 | )
32 |
33 | register_routes(app)
34 |
35 |
36 | if __name__ == "__main__":
37 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
38 |
--------------------------------------------------------------------------------
/proxy-1/README.md:
--------------------------------------------------------------------------------
1 | # Example showing an HTTP Proxy implemented with BlackSheep
2 |
3 | Run the Flask application:
4 |
5 | ```bash
6 | python flask_app/server.py
7 | ```
8 |
9 | Run the BlackSheep proxy application:
10 |
11 | ```bash
12 | python blacksheep_proxy/server.py
13 | ```
14 |
15 | Open the file "example.html" and use its forms to test uploading to the Flask
16 | server directly (uploaded files should be written to the `out` folder), and
17 | test uploading to the BlackSheep proxy. The result should be the same.
18 |
19 | ## Note
20 | The example proxy in `blacksheep_proxy` handles memory in the proper way:
21 |
22 | - it reads input streams as chunks (never whole in memory)
23 | - it reads response streams from the back-end in chunks (never whole in memory)
24 |
25 | It always sends response contents backs using `Transfer-Encoding: chunked`,
26 | which might or might not be desirable, but ensures memory is handled
27 | efficiently.
28 |
29 | ## Other example
30 | `other-example.html` is similar to `example.html`, with the exception that both
31 | the back-end app and the proxy server are implemented using BlackSheep.
32 |
33 | Run the frst BlackSheep application:
34 |
35 | ```bash
36 | python blacksheep_app/server.py
37 | ```
38 |
39 | Run the BlackSheep proxy application:
40 |
41 | ```bash
42 | python blacksheep_proxy/server.py
43 | ```
44 |
45 | Open `other-example.html` in a browser.
46 |
--------------------------------------------------------------------------------
/oidc/scopes_auth0.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Auth0, obtaining
3 | an id_token, an access_token, and a refresh_token. The id_token is exchanged with the
4 | client using a response cookie (also used to authenticate users
5 | for following requests), while access token and the refresh token are not stored and
6 | can only be accessed using optional events.
7 | """
8 | import uvicorn
9 | from blacksheep.server.application import Application
10 | from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect
11 | from dotenv import load_dotenv
12 |
13 | from common.routes import register_routes
14 | from common.secrets import Secrets
15 |
16 | load_dotenv()
17 | secrets = Secrets.from_env()
18 | app = Application(show_error_details=True)
19 |
20 |
21 | # Auth0 with custom scope
22 | use_openid_connect(
23 | app,
24 | OpenIDSettings(
25 | authority="https://neoteroi.eu.auth0.com",
26 | audience="http://localhost:5000/api/todos",
27 | client_id="OOGPl4dgG7qKsm2IOWq72QhXV4wsLhbQ",
28 | client_secret=secrets.auth0_client_secret,
29 | callback_path="/signin-oidc",
30 | scope="openid profile read:todos",
31 | error_redirect_path="/sign-in-error",
32 | ),
33 | )
34 |
35 | register_routes(app)
36 |
37 |
38 | if __name__ == "__main__":
39 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
40 |
--------------------------------------------------------------------------------
/oauth2-password-provider/src/app.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from blacksheep import Application
4 | from blacksheep.server.responses import json
5 | from blacksheep.server.authorization import allow_anonymous, auth
6 | from blacksheep.server.bindings import FromJSON
7 | from .db import User
8 |
9 |
10 | from .password_auth import HMACAlgorithm, OAuth2PasswordSettings, use_oauth2_password
11 | from .user import UserDAL
12 |
13 | app = Application()
14 | use_oauth2_password(
15 | app,
16 | OAuth2PasswordSettings(
17 | secret="secret",
18 | algorithm=HMACAlgorithm("HS256"),
19 | token_path="/api/token",
20 | refresh_path="/api/refresh",
21 | revoke_path="/api/revoke",
22 | ),
23 | )
24 |
25 |
26 | @allow_anonymous()
27 | @app.router.get("/api/anonymous")
28 | def anonymous():
29 | return json({"message": "Hello, anonymous!"})
30 |
31 |
32 | @allow_anonymous()
33 | @app.router.post("/api/register")
34 | async def register(
35 | user_registration: FromJSON[User],
36 | ):
37 | """Register user."""
38 | username = user_registration.value.username
39 | password = user_registration.value.password
40 |
41 | user_dal = UserDAL()
42 | user = user_dal.register(username, password)
43 |
44 | return json(data=user)
45 |
46 |
47 | @auth("authenticated")
48 | @app.router.get("/api/protected")
49 | async def protected():
50 | """Protected endpoint."""
51 | return json({"message": "Hello, authenticated user!"})
52 |
--------------------------------------------------------------------------------
/piccolo-admin/server.py:
--------------------------------------------------------------------------------
1 | import uvicorn
2 | from blacksheep.messages import Response
3 | from blacksheep.server import Application
4 | from blacksheep.server.responses import html
5 |
6 | from piccoloexample import APP, create_schema, populate_data, set_engine
7 |
8 | app = Application()
9 |
10 |
11 | @app.route("/")
12 | def home() -> Response:
13 | return html(
14 | """
15 |
16 |
17 |
18 |
19 |
20 | BlackSheep - mount example
21 |
22 |
23 |
Start the first BlackSheep application (localhost:44777).
26 |
Start the proxy BlackSheep application (localhost:44555).
27 |
Try using this page.
28 |
Try uploading files.
29 |
Check the response, and the `out` folder.
30 |
31 |
32 |
33 |
Static files
34 |
The following image is served by the first BlackSheep app.
35 |
36 |
37 |
The following image is served by the BlackSheep proxy app.
38 |
39 |
40 |
41 |
42 |
Test the upload to the first BlackSheep app directly:
43 |
The response should display JSON information about the request, and uploaded files should be written to the
44 | "out" folder.
45 |
54 |
55 |
56 |
Test the upload to the BlackSheep proxy (which proxies to the first BlackSheep app):
57 |
The response should display JSON information about the request, and uploaded files should be written to the
58 | "out" folder.
59 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/oidc/scopes_aad.py:
--------------------------------------------------------------------------------
1 | """
2 | This example shows how to configure an OpenID Connect integration with Azure Active
3 | Directory, obtaining an id_token, an access_token, and a refresh_token. The id_token
4 | is exchanged with the client using a response cookie (also used to authenticate users
5 | for following requests), while the access token and the refresh token are stored using
6 | a custom implementation of TokensStore.
7 | """
8 | from datetime import datetime
9 |
10 | import uvicorn
11 | from blacksheep import Request, Response
12 | from blacksheep.server.application import Application
13 | from blacksheep.server.authentication.oidc import (
14 | CookiesOpenIDTokensHandler,
15 | OpenIDSettings,
16 | TokensStore,
17 | use_openid_connect,
18 | )
19 | from dotenv import load_dotenv
20 |
21 | from common.routes import register_routes
22 | from common.secrets import Secrets
23 |
24 | load_dotenv()
25 | secrets = Secrets.from_env()
26 | app = Application(show_error_details=True)
27 |
28 |
29 | class TestTokensStore(TokensStore):
30 | def __init__(self) -> None:
31 | super().__init__()
32 | self._access_token: str | None = None
33 | self._refresh_token: str | None = None
34 |
35 | async def store_tokens(
36 | self,
37 | request: Request,
38 | response: Response,
39 | access_token: str,
40 | refresh_token: str | None,
41 | expires: datetime | None = None,
42 | ):
43 | """
44 | Applies a strategy to store an access token and an optional refresh token for
45 | the given request and response.
46 | """
47 | self._access_token = access_token
48 | self._refresh_token = refresh_token
49 |
50 | async def unset_tokens(self, request: Request):
51 | """
52 | Optional method, to unset access tokens upon sign-out.
53 | """
54 | self._access_token = None
55 | self._refresh_token = None
56 |
57 | async def restore_tokens(self, request: Request) -> None:
58 | """
59 | Applies a strategy to restore an access token and an optional refresh token for
60 | the given request.
61 | """
62 | assert request.identity is not None
63 | request.identity.access_token = self._access_token
64 | request.identity.refresh_token = self._refresh_token
65 |
66 |
67 | # AAD with custom scope
68 | handler = use_openid_connect(
69 | app,
70 | OpenIDSettings(
71 | authority="https://login.microsoftonline.com/b62b317a-19c2-40c0-8650-2d9672324ac4/v2.0/",
72 | client_id="499adb65-5e26-459e-bc35-b3e1b5f71a9d",
73 | client_secret=secrets.aad_client_secret,
74 | scope="openid profile offline_access email "
75 | "api://65d21481-4f1a-4731-9508-ad965cb4d59f/example",
76 | ),
77 | )
78 |
79 | assert isinstance(handler.auth_handler, CookiesOpenIDTokensHandler)
80 | handler.auth_handler.tokens_store = TestTokensStore()
81 |
82 | register_routes(app)
83 |
84 |
85 | if __name__ == "__main__":
86 | uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug")
87 |
--------------------------------------------------------------------------------
/otel/README.md:
--------------------------------------------------------------------------------
1 | # Examples for OpenTelemetry integration
2 |
3 | This folder contains examples to use [OpenTelemetry](https://opentelemetry.io/)
4 | integration with [Grafana](https://grafana.com/), and with [Azure Application
5 | Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview).
6 |
7 | > [!IMPORTANT]
8 | > This example includes code that can be used to integrate OpenTelemetry telemetries with
9 | > versions of the framework before `2.3.2`. Since `2.3.2`, vendor-agnostic code from this
10 | > example have been included in the BlackSheep framework.
11 | > For more information, refer to the documentation at https://www.neoteroi.dev/blacksheep/opentelemetry/
12 |
13 | ## Requirements
14 |
15 | ```bash
16 | pip install opentelemetry-distro opentelemetry-exporter-otlp
17 |
18 | opentelemetry-bootstrap --action=install
19 | ```
20 |
21 | For *Azure Application Insights*, also install:
22 |
23 | ```bash
24 | pip install azure-monitor-opentelemetry-exporter
25 | ```
26 |
27 | To test, install also `blacksheep` and an ASGI server of your choice. For instance, `uvicorn`.
28 |
29 | ```bash
30 | pip install blacksheep uvicorn
31 | ```
32 |
33 | ### Running the Azure example
34 |
35 | 1. Install the dependencies like documented above, including
36 | `azure-monitor-opentelemetry-exporter`.
37 | 2. Configure an environment variable `APP_INSIGHTS_CONNECTION_STRING` containing
38 | the connection string of an Azure Application Insights service.
39 | 3. Run the application with `uvicorn azureexample:app`.
40 | 4. Generate some web requests to the example endpoints `/`, `/bad-request`,
41 | `/crash`, `/example`.
42 | 5. Observe how logs appear in the Azure Application Insights service.
43 |
44 | 
45 |
46 |
47 | ### Running the Grafana example
48 |
49 | 1. Install the dependencies like documented above, including
50 | `azure-monitor-opentelemetry-exporter`.
51 | 2. Obtain the necessary environment variables from the Grafana interface.
52 | 3. Configure the environment variables. These variables can also be configured
53 | in a `.env` file.
54 | 4. Run the application with `uvicorn grafanaexample:app`.
55 | 5. Generate some web requests to the example endpoints `/`, `/bad-request`,
56 | `/crash`, `/example`.
57 | 6. Observe how logs appear in the Azure Application Insights service.
58 |
59 | Environment variables look like the following:
60 |
61 | ```
62 | OTEL_RESOURCE_ATTRIBUTES="service.name=my-app,service.namespace=my-application-group,deployment.environment=production"
63 | OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-gateway-prod-eu-north-0.grafana.net/otlp"
64 | OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20******"
65 | OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
66 | ```
67 |
68 | 
69 |
70 | ## Folder structure
71 |
72 | - The `otel` package contains reusable code.
73 | - `otel.otlp` contains generic code that can be used with many services adhering to the OpenTelemetry standard, including Grafana.
74 |
--------------------------------------------------------------------------------
/oauth2-password-provider/src/user.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from datetime import datetime
4 | import hashlib
5 | import os
6 | from .db import User
7 |
8 | from pydantic import UUID4
9 |
10 | from .db import TOKEN_DB, USER_DB, Token
11 |
12 |
13 | def hashpw(password: str):
14 | """Hash a password for storing."""
15 |
16 | salt = os.urandom(32)
17 | digest = hashlib.pbkdf2_hmac("sha512", password.encode("utf8"), salt, 1024)
18 | hashed_password = f"{salt.hex()}${digest.hex()}"
19 | return hashed_password
20 |
21 |
22 | def checkpw(password: str, hashed_password: str):
23 | """Check a hashed password."""
24 |
25 | salt_hex, digest_hex = hashed_password.split("$")
26 | return (
27 | digest_hex
28 | == hashlib.pbkdf2_hmac(
29 | "sha512",
30 | password.encode("utf8"),
31 | bytes.fromhex(salt_hex),
32 | 1024,
33 | ).hex()
34 | )
35 |
36 |
37 | class UserDAL:
38 | def register(self, username: str, password: str):
39 | """Register a user.
40 |
41 | Save user to storage and return user data.
42 | """
43 |
44 | hashed_password = hashpw(password)
45 | new_user = User(username=username, password=hashed_password)
46 | USER_DB[username] = new_user
47 | return new_user.dict()
48 |
49 | async def get_authuser_by_username(self, username):
50 | """Get user by username."""
51 |
52 | return USER_DB.get(username)
53 |
54 | async def save_refresh_token(
55 | self,
56 | user_id: UUID4,
57 | jti: UUID4,
58 | session_id: UUID4,
59 | expired_at: datetime,
60 | ):
61 | """Save refresh token to storage."""
62 |
63 | TOKEN_DB[jti] = Token(
64 | id=jti,
65 | user_id=user_id,
66 | session_id=session_id,
67 | expired_at=expired_at,
68 | )
69 |
70 | async def get_user_by_refresh_token(
71 | self,
72 | user_id: UUID4,
73 | refresh_jti: UUID4,
74 | session_id: UUID4,
75 | ) -> User | None:
76 | """Get user by refresh token."""
77 |
78 | token = TOKEN_DB.get(refresh_jti)
79 | if not token:
80 | return None
81 | if token.expired_at < datetime.utcnow():
82 | return None
83 | if token.user_id != user_id:
84 | return None
85 | if token.session_id != session_id:
86 | return None
87 |
88 | user = USER_DB.get(user_id)
89 | if not user:
90 | return None
91 |
92 | return user
93 |
94 | async def revoke_refresh_token(self, user_id: UUID4):
95 | """Revoke all refresh tokens for user."""
96 |
97 | for token in TOKEN_DB.values():
98 | if token.user_id == user_id:
99 | del TOKEN_DB[token.id]
100 |
101 | async def remove_used_refresh_token(self, token_id: UUID4):
102 | """Remove used refresh token from storage."""
103 |
104 | del TOKEN_DB[token_id]
105 |
--------------------------------------------------------------------------------
/websocket-chat/app/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BlackSheep WebSocket Example
6 |
7 |
13 |
14 |
15 |
16 |
Your client ID is {{ CLIENT_ID }}
17 |
Status: {{ status }}
18 |
Messages:
19 |
20 |
21 |
From: {{ message.author }}
22 |
Sent: {{ renderTimestamp(message.timestamp) }}
23 |
Text: {{ message.text }}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
99 |
100 |
--------------------------------------------------------------------------------
/aad-machine-to-machine/README.md:
--------------------------------------------------------------------------------
1 | [](https://youtu.be/-SPEcQxgOOQ)
2 |
3 | # Machine to machine (M2M) communication using Azure Active Directory
4 |
5 | This example shows:
6 | * how to configure an API to require access tokens issued by Azure Active Directory
7 | * how to obtain access tokens for a confidential client (meaning an application that is
8 | able to handle secrets), running as a background worker or daemon, without user interaction
9 |
10 | `server.py` contains the server definition that requires and validates access tokens.
11 | `client_using_secret.py` contains a client definition that, using [MSAL for Python](https://github.com/AzureAD/microsoft-authentication-library-for-python), obtains access
12 | tokens using the `client credentials flow` **with a secret**, and calls the server.
13 |
14 | `client_using_certificate.py` contains a client definition that, using [MSAL for Python](https://github.com/AzureAD/microsoft-authentication-library-for-python), obtains access
15 | tokens using the `client credentials flow` **with a certificate**, and calls the server.
16 | Refer to the information under `certs` folder to have a reference on how to generate valid
17 | certificates for Azure Active Directory.
18 |
19 | `client_http_example.py` shows an example using the client credentials flow
20 | with secret with an HTTP POST request to the token endpoint, without using MSAL for Python.
21 |
22 | The following scheme describes the flow of this example.
23 |
24 | 
25 |
26 | * Client is the application running as daemon, connecting to the API
27 | * AAD is Azure Active Directory
28 | * API is the web application exposing an API and requiring access tokens
29 |
30 | ## How to run this example
31 |
32 | To run the example using the secret:
33 |
34 | 1. configure app registrations in a Azure Active Directory tenant
35 | 2. create a `.env` file with appropriate values, like in the example below,
36 | or in alternative, configure the environmental variables as in the same
37 | example
38 | 3. create a Python virtual environment, install the dependencies in `requirements.txt`
39 | 4. activate the virtual environment in two terminals, then:
40 | 5. run the server in one terminal `python server.py`
41 | 6. run the client file in another terminal `python client_using_secret.py`
42 |
43 | The client file should display that an access token is obtained successfully
44 | from Azure Active Directory and a call to the running server was successful.
45 |
46 | `http_example.py` shows an example of how the client credentials flow with secret can be
47 | used with HTTP, without using MSAL for Python.
48 |
49 | ## Example .env file
50 |
51 | To configure application settings to run these examples, create an `.env` file
52 | with contents like in the following block:
53 |
54 | ```bash
55 | # Server configuration
56 | API_ISSUER="https://sts.windows.net//"
57 | API_AUDIENCE=""
58 |
59 | # Client configuration
60 | AAD_AUTHORITY="https://login.microsoftonline.com//"
61 | APP_CLIENT_ID=""
62 | APP_CLIENT_SECRET=""
63 | APP_CLIENT_SCOPE="/.default"
64 |
65 | # For the example using a certificate:
66 | APP_CLIENT_CERT_THUMBPRINT=""
67 | ```
68 |
69 | The `.env` file is read using `python-dotenv` when the examples are run.
70 |
--------------------------------------------------------------------------------
/oidc/common/routes.py:
--------------------------------------------------------------------------------
1 | from textwrap import dedent
2 |
3 | import jwt
4 | from blacksheep.messages import Request
5 | from blacksheep.server.application import Application
6 | from blacksheep.server.authorization import allow_anonymous
7 | from blacksheep.server.responses import html, pretty_json, redirect, unauthorized
8 | from essentials.json import dumps
9 | from guardpost.authentication import Identity
10 |
11 |
12 | def _render_access_token(user: Identity) -> str:
13 | if not user.access_token:
14 | return ""
15 |
16 | # parse without validating the access token
17 | # (the id_token was validated upon sign-in!)
18 | claims = jwt.decode(user.access_token, options={"verify_signature": False})
19 |
20 | return dedent(
21 | f"""
22 |
87 | Sign in here.
88 |
89 |
90 | """
91 | )
92 | )
93 |
94 | @app.route("/auth/me")
95 | async def user_info(user: Identity):
96 | if not user.is_authenticated():
97 | return unauthorized("Unauthorized")
98 | return pretty_json(user.claims)
99 |
--------------------------------------------------------------------------------
/dependency-injector/docs/example1.py:
--------------------------------------------------------------------------------
1 | from typing import Type, TypeVar, get_type_hints
2 |
3 | from dependency_injector import containers, providers
4 |
5 | from blacksheep import Application, get
6 |
7 | T = TypeVar("T")
8 |
9 |
10 | class APIClient: ...
11 |
12 |
13 | class SomeService:
14 |
15 | def __init__(self, api_client: APIClient) -> None:
16 | self.api_client = api_client
17 |
18 |
19 | # Define the Dependency Injector container
20 | class AppContainer(containers.DeclarativeContainer):
21 | APIClient = providers.Singleton(APIClient)
22 | SomeService = providers.Factory(SomeService, api_client=APIClient)
23 |
24 |
25 | # Create the container instance
26 | container = AppContainer()
27 |
28 |
29 | class DependencyInjectorConnector:
30 | """
31 | This class connects a Dependency Injector container with a
32 | BlackSheep application.
33 | Dependencies are registered using the code API offered by
34 | Dependency Injector. The BlackSheep application activates services
35 | using the container when needed.
36 | """
37 |
38 | def __init__(self, container: containers.Container) -> None:
39 | self._container = container
40 |
41 | def register(self, obj_type: Type[T]) -> None:
42 | """
43 | Registers a type with the container.
44 | The code below inspects the object's constructor's types annotations to
45 | automatically configure the provider to activate the type.
46 |
47 | It is not necessary to use @inject or Provide core on the __init__ method. This
48 | helps reducing code verbosity and keeping the source code not polluted by DI
49 | specific code.
50 | """
51 | constructor = getattr(obj_type, "__init__", None)
52 |
53 | if not constructor:
54 | raise ValueError(
55 | f"Type {obj_type.__name__} does not have an __init__ method."
56 | )
57 |
58 | # Get the type hints for the constructor parameters
59 | type_hints = get_type_hints(constructor)
60 |
61 | # Exclude 'self' from the parameters
62 | dependencies = {
63 | param_name: getattr(self._container, param_type.__name__)
64 | for param_name, param_type in type_hints.items()
65 | if param_name not in {"self", "return"}
66 | and hasattr(self._container, param_type.__name__)
67 | }
68 |
69 | # Create a provider for the type with its dependencies
70 | provider = providers.Factory(obj_type, **dependencies)
71 | setattr(self._container, obj_type.__name__, provider)
72 |
73 | def resolve(self, obj_type: Type[T], _) -> T:
74 | """Resolves an instance of the given type."""
75 | provider = getattr(self._container, obj_type.__name__, None)
76 | if provider is None:
77 | raise TypeError(
78 | f"Type {obj_type.__name__} is not registered in the container."
79 | )
80 | return provider()
81 |
82 | def __contains__(self, item: Type[T]) -> bool:
83 | """Checks if a type is registered in the container."""
84 | return hasattr(self._container, item.__name__)
85 |
86 |
87 | app = Application(
88 | services=DependencyInjectorConnector(container), show_error_details=True
89 | )
90 |
91 |
92 | @get("/")
93 | def home(service: SomeService):
94 | print(service)
95 | # DependencyInjector resolved the dependencies
96 | assert isinstance(service, SomeService)
97 | assert isinstance(service.api_client, APIClient)
98 | return id(service)
99 |
100 |
101 | if __name__ == "__main__":
102 | import uvicorn
103 |
104 | uvicorn.run(app, port=44777)
105 |
--------------------------------------------------------------------------------
/oidc/common/redis.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime
3 | from json import JSONDecodeError, dumps, loads
4 | from typing import Optional
5 | from uuid import uuid4
6 |
7 | import redis.asyncio as redis
8 | from blacksheep import Cookie, Request, Response
9 | from blacksheep.server.authentication.oidc import TokensStore, logger
10 | from guardpost import Identity
11 |
12 | logger.setLevel(logging.DEBUG)
13 | logger.addHandler(logging.StreamHandler())
14 |
15 |
16 | class RedisTokensStore(TokensStore):
17 | """
18 | A tokens store that can stores access tokens and refresh tokens using Redis,
19 | with the given `redis.asyncio.redis` client. This tokens store configures a trace_id
20 | """
21 |
22 | def __init__(
23 | self,
24 | client: redis.Redis,
25 | trace_cookie_name: str = "tokenstraceid",
26 | expiration_seconds: int = 60 * 120,
27 | ) -> None:
28 | super().__init__()
29 | self._client = client
30 | self._trace_id_cookie_name = trace_cookie_name
31 | self._expiration_seconds = expiration_seconds
32 |
33 | def get_trace_id(self, request: Request) -> str:
34 | trace_id = request.cookies.get(self._trace_id_cookie_name)
35 |
36 | if trace_id:
37 | return trace_id
38 | return str(uuid4())
39 |
40 | def set_cookie(
41 | self,
42 | response: Response,
43 | cookie_name: str,
44 | value: str,
45 | secure: bool,
46 | expires: Optional[datetime] = None,
47 | path: str = "/",
48 | ) -> None:
49 | response.set_cookie(
50 | Cookie(
51 | cookie_name,
52 | value,
53 | domain=None,
54 | path=path,
55 | http_only=True,
56 | secure=secure,
57 | expires=expires,
58 | )
59 | )
60 |
61 | async def store_tokens(
62 | self,
63 | request: Request,
64 | response: Response,
65 | access_token: str,
66 | refresh_token: str | None,
67 | ):
68 | """
69 | Applies a strategy to store an access token and an optional refresh token for
70 | the given request and response.
71 | """
72 | trace_id = self.get_trace_id(request)
73 | secure = request.scheme == "https"
74 | self.set_cookie(
75 | response,
76 | self._trace_id_cookie_name,
77 | trace_id,
78 | secure=secure,
79 | expires=None,
80 | )
81 | await self._client.set(
82 | trace_id,
83 | dumps({"access_token": access_token, "refresh_token": refresh_token}),
84 | ex=self._expiration_seconds,
85 | )
86 |
87 | async def restore_tokens(self, request: Request) -> None:
88 | """
89 | Applies a strategy to restore an access token and an optional refresh token for
90 | the given request.
91 | """
92 | trace_id = request.cookies.get(self._trace_id_cookie_name)
93 |
94 | if not trace_id:
95 | return
96 |
97 | value = await self._client.get(trace_id)
98 | if not value:
99 | return
100 |
101 | try:
102 | data = loads(value)
103 | except JSONDecodeError as json_error:
104 | logger.debug(
105 | "Ignoring tokens because the cached value cannot be parsed. "
106 | "Trace id %s",
107 | trace_id,
108 | exc_info=json_error,
109 | )
110 | else:
111 | if request.identity is None:
112 | request.identity = Identity({})
113 | request.identity.access_token = data.get("access_token")
114 | request.identity.refresh_token = data.get("refresh_token")
115 |
116 | async def unset_tokens(self, request: Request):
117 | """
118 | Unsets access tokens upon sign-out.
119 | """
120 | cookie = request.cookies.get(self._trace_id_cookie_name)
121 |
122 | if cookie:
123 | await self._client.delete(cookie)
124 |
--------------------------------------------------------------------------------
/proxy-1/blacksheep_proxy/server.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import uvicorn
4 | from blacksheep import Application, Request, Response, StreamedContent
5 | from blacksheep.client import ClientSession
6 | from blacksheep.client.pool import ClientConnectionPools
7 | from blacksheep.headers import Headers
8 |
9 | app = Application(show_error_details=True)
10 |
11 |
12 | @app.lifespan
13 | async def register_http_client():
14 | async with ClientSession(
15 | # This is the URL of the application to which we are proxying,
16 | # set this value in the request handler if you want to support dynamic proxies
17 | base_url="http://localhost:44777",
18 | pools=ClientConnectionPools(asyncio.get_running_loop()),
19 | ) as client:
20 | print("HTTP client created and registered as singleton")
21 | app.services.add_instance(client)
22 | yield
23 |
24 | print("HTTP client disposed")
25 |
26 |
27 | def get_content_length(headers: Headers) -> int:
28 | content_length_header = headers.get_first(b"content-length")
29 | return int(content_length_header) if content_length_header else -1
30 |
31 |
32 | def _get_proxied_request(request: Request) -> Request:
33 | """
34 | Gets a Request for the destination server, from a request of a source client.
35 |
36 | Note: the code should probably set X-Forwarded-* headers, and related headers.
37 | This is left as an exercise!
38 | """
39 |
40 | async def read_request_stream():
41 | async for chunk in request.stream():
42 | yield chunk
43 |
44 | content_length = get_content_length(request.headers)
45 | content_type = request.headers.get_first(b"Content-Type")
46 | content = (
47 | None
48 | if content_type is None
49 | else StreamedContent(
50 | content_type or b"application/octet-stream",
51 | read_request_stream,
52 | content_length,
53 | )
54 | )
55 | headers = [
56 | (key, value)
57 | for key, value in request.headers
58 | if key.lower()
59 | not in {
60 | b"content-type",
61 | b"content-length",
62 | b"transfer-encoding",
63 | }
64 | ]
65 | new_request = Request(
66 | request.method,
67 | request.url.value,
68 | headers,
69 | )
70 |
71 | return new_request if content is None else new_request.with_content(content)
72 |
73 |
74 | def _get_proxied_response(response: Response) -> Response:
75 | """
76 | Gets a Response for the source client, from a Response obtained from the back-end
77 | for which requests are proxied.
78 | """
79 |
80 | # The above line completes when the original server sends the headers, but
81 | # we need to wait for the response content, too!
82 | async def response_content_reader():
83 | async for chunk in response.stream():
84 | yield chunk
85 |
86 | content_type = response.headers.get_first(b"Content-Type")
87 | response_headers = [
88 | (key, value)
89 | for key, value in response.headers
90 | if key.lower()
91 | not in {
92 | b"date",
93 | b"server",
94 | b"content-type",
95 | b"content-length",
96 | b"transfer-encoding",
97 | }
98 | ]
99 |
100 | content_length = get_content_length(response.headers)
101 |
102 | content = (
103 | StreamedContent(
104 | content_type or b"application/octet-stream",
105 | response_content_reader,
106 | content_length,
107 | )
108 | if content_type
109 | else None
110 | )
111 |
112 | return Response(
113 | response.status,
114 | response_headers,
115 | content=content,
116 | )
117 |
118 |
119 | @app.route("*", methods="HEAD OPTIONS GET PATCH POST PUT DELETE".split())
120 | async def proxy_all(request: Request, http_client: ClientSession) -> Response:
121 | proxied_request = _get_proxied_request(request)
122 | response = await http_client.send(proxied_request)
123 | return _get_proxied_response(response)
124 |
125 |
126 | uvicorn.run(app, host="localhost", port=44555, lifespan="on") # , http="h11"
127 |
--------------------------------------------------------------------------------
/proxy-2/blacksheep_proxy/server.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import uvicorn
4 | from blacksheep import Application, Request, Response, StreamedContent
5 | from blacksheep.client import ClientSession
6 | from blacksheep.client.pool import ClientConnectionPools
7 | from blacksheep.headers import Headers
8 |
9 | app = Application(show_error_details=True)
10 |
11 |
12 | @app.lifespan
13 | async def register_http_client():
14 | async with ClientSession(
15 | # This is the URL of the application to which we are proxying,
16 | # set this value in the request handler if you want to support dynamic proxies
17 | base_url="http://localhost:44777",
18 | pools=ClientConnectionPools(asyncio.get_running_loop()),
19 | ) as client:
20 | print("HTTP client created and registered as singleton")
21 | app.services.add_instance(client)
22 | yield
23 |
24 | print("HTTP client disposed")
25 |
26 |
27 | def get_content_length(headers: Headers) -> int:
28 | content_length_header = headers.get_first(b"content-length")
29 | return int(content_length_header) if content_length_header else -1
30 |
31 |
32 | def _get_proxied_request(request: Request) -> Request:
33 | """
34 | Gets a Request for the destination server, from a request of a source client.
35 |
36 | Note: the code should probably set X-Forwarded-* headers, and related headers.
37 | This is left as an exercise!
38 | """
39 |
40 | async def read_request_stream():
41 | async for chunk in request.stream():
42 | yield chunk
43 |
44 | content_length = get_content_length(request.headers)
45 | content_type = request.headers.get_first(b"Content-Type")
46 | content = (
47 | None
48 | if content_type is None
49 | else StreamedContent(
50 | content_type or b"application/octet-stream",
51 | read_request_stream,
52 | content_length,
53 | )
54 | )
55 | headers = [
56 | (key, value)
57 | for key, value in request.headers
58 | if key.lower()
59 | not in {
60 | b"content-type",
61 | b"content-length",
62 | b"transfer-encoding",
63 | }
64 | ]
65 | new_request = Request(
66 | request.method,
67 | request.url.value,
68 | headers,
69 | )
70 |
71 | return new_request if content is None else new_request.with_content(content)
72 |
73 |
74 | def _get_proxied_response(response: Response) -> Response:
75 | """
76 | Gets a Response for the source client, from a Response obtained from the back-end
77 | for which requests are proxied.
78 | """
79 |
80 | # The above line completes when the original server sends the headers, but
81 | # we need to wait for the response content, too!
82 | async def response_content_reader():
83 | async for chunk in response.stream():
84 | yield chunk
85 |
86 | content_type = response.headers.get_first(b"Content-Type")
87 | response_headers = [
88 | (key, value)
89 | for key, value in response.headers
90 | if key.lower()
91 | not in {
92 | b"date",
93 | b"server",
94 | b"content-type",
95 | b"content-length",
96 | b"transfer-encoding",
97 | }
98 | ]
99 |
100 | content_length = get_content_length(response.headers)
101 |
102 | content = (
103 | StreamedContent(
104 | content_type or b"application/octet-stream",
105 | response_content_reader,
106 | content_length,
107 | )
108 | if content_type
109 | else None
110 | )
111 |
112 | return Response(
113 | response.status,
114 | response_headers,
115 | content=content,
116 | )
117 |
118 |
119 | @app.route("*", methods="HEAD OPTIONS GET PATCH POST PUT DELETE".split())
120 | async def proxy_all(request: Request, http_client: ClientSession) -> Response:
121 | proxied_request = _get_proxied_request(request)
122 | response = await http_client.send(proxied_request)
123 | return _get_proxied_response(response)
124 |
125 |
126 | uvicorn.run(app, host="localhost", port=44555, lifespan="on") # , http="h11"
127 |
--------------------------------------------------------------------------------
/dependency-injector/docs/example2.py:
--------------------------------------------------------------------------------
1 | from typing import Type, TypeVar, get_type_hints
2 |
3 | from dependency_injector import containers, providers
4 |
5 | from blacksheep import Application, get
6 |
7 | T = TypeVar("T")
8 |
9 |
10 | class APIClient: ...
11 |
12 |
13 | class SomeService:
14 |
15 | def __init__(self, api_client: APIClient) -> None:
16 | self.api_client = api_client
17 |
18 |
19 | # Define the Dependency Injector container
20 | class AppContainer(containers.DeclarativeContainer):
21 | api_client = providers.Singleton(APIClient)
22 | some_service = providers.Factory(SomeService, api_client=api_client)
23 |
24 |
25 | # Create the container instance
26 | container = AppContainer()
27 |
28 |
29 | class DependencyInjectorConnector:
30 | """
31 | This class connects a Dependency Injector container with a
32 | BlackSheep application.
33 | Dependencies are registered using the code API offered by
34 | Dependency Injector. The BlackSheep application activates services
35 | using the container when needed.
36 | """
37 |
38 | def __init__(self, container: containers.Container) -> None:
39 | self._container = container
40 |
41 | def register(self, obj_type: Type[T]) -> None:
42 | """
43 | Registers a type with the container.
44 | The code below inspects the object's constructor's types annotations to
45 | automatically configure the provider to activate the type.
46 |
47 | It is not necessary to use @inject or Provide core on the __init__ method. This
48 | helps reducing code verbosity and keeping the source code not polluted by DI
49 | specific code.
50 | """
51 | constructor = getattr(obj_type, "__init__", None)
52 |
53 | if not constructor:
54 | raise ValueError(
55 | f"Type {obj_type.__name__} does not have an __init__ method."
56 | )
57 |
58 | # Get the type hints for the constructor parameters
59 | type_hints = get_type_hints(constructor)
60 |
61 | # Exclude 'self' from the parameters
62 | dependencies = {
63 | param_name: getattr(self._container, self._get_provider_name(param_type))
64 | for param_name, param_type in type_hints.items()
65 | if param_name not in {"self", "return"}
66 | and hasattr(self._container, self._get_provider_name(param_type))
67 | }
68 |
69 | # Create a provider for the type with its dependencies
70 | provider = providers.Factory(obj_type, **dependencies)
71 | setattr(self._container, self._get_provider_name(obj_type), provider)
72 |
73 | def resolve(self, obj_type: Type[T], _) -> T:
74 | """Resolves an instance of the given type."""
75 | provider = getattr(self._container, self._get_provider_name(obj_type), None)
76 | if provider is None:
77 | raise TypeError(
78 | f"Type {obj_type.__name__} is not registered in the container."
79 | )
80 | return provider()
81 |
82 | def __contains__(self, item: Type[T]) -> bool:
83 | """Checks if a type is registered in the container."""
84 | return hasattr(self._container, item.__name__)
85 |
86 | def _get_provider_name(self, obj_type) -> str:
87 | """
88 | Gets a provider name by object type.
89 | """
90 | return self._to_snake_case(obj_type.__name__)
91 |
92 | def _to_snake_case(self, name: str) -> str:
93 | """
94 | Converts a PascalCase or camelCase string to snake_case.
95 |
96 | Args:
97 | name (str): The string to convert.
98 |
99 | Returns:
100 | str: The converted string in snake_case.
101 | """
102 | return re.sub(r"(? None:
25 | self.closing = False
26 | self._active_requests: list[ActiveRequest] = []
27 | self._timeout: float = 60
28 |
29 | @property
30 | def active_requests(self) -> list[ActiveRequest]:
31 | return self._active_requests
32 |
33 | async def subscribe(self, request: Request):
34 | if self.closing:
35 | return text("")
36 |
37 | request_queue = asyncio.Queue()
38 | task = asyncio.create_task(self.wait_for_message(request, request_queue))
39 | active_request = ActiveRequest(request, task, request_queue)
40 | self._active_requests.append(active_request)
41 |
42 | try:
43 | response = await task
44 | except asyncio.CancelledError:
45 | # Tasks are cancelled when the application stops, or periodically when
46 | # a request is disconnected
47 | print("Task cancelled...")
48 | return no_content()
49 | else:
50 | return response
51 | finally:
52 | try:
53 | self._active_requests.remove(active_request)
54 | except ValueError:
55 | # All is good, the item was already removed
56 | pass
57 |
58 | async def wait_for_message(self, request, queue):
59 | try:
60 | async with asyncio.timeout(self._timeout):
61 | message = await queue.get()
62 |
63 | # Note: here it is possible to check if the request is
64 | # disconnected using: if await request.is_disconnected()
65 | #
66 | # This can be useful to avoid consuming operations from this point,
67 | # or to cancel tasks.
68 | if await request.is_disconnected():
69 | print("🔥🔥🔥 Request is disconnected!")
70 | return
71 | return text(message)
72 | except TimeoutError:
73 | # Waited for the timeout period, now closing a Long-Polling request.
74 | # The client must create a new request.
75 | return text("")
76 |
77 | async def add_message(self, message):
78 | for item in self._active_requests:
79 | await item.queue.put(message)
80 |
81 | def cancel_all_tasks(self):
82 | self.closing = True # Stop processing new requests
83 | for item in self._active_requests:
84 | item.task.cancel()
85 |
86 | def __len__(self):
87 | return len(self._active_requests)
88 |
89 |
90 | manager = MessageManager()
91 |
92 |
93 | async def periodic_check():
94 | """
95 | Periodically checks if active long-polling requests are disconnected, and cancels
96 | them if needed.
97 | """
98 | while True:
99 | await asyncio.sleep(5) # Example: check every 5 seconds
100 |
101 | print("Checking active connections...")
102 |
103 | for item in manager.active_requests:
104 | request = item.request
105 | if await request.is_disconnected():
106 | print(f"Request {id(request)} is disconnected, cancelling its task...")
107 | item.task.cancel()
108 |
109 |
110 | @app.on_start
111 | async def start_periodic_check():
112 | asyncio.create_task(periodic_check())
113 |
114 |
115 | @app.on_start
116 | async def configure_sigint_handler():
117 | # See the conversation here:
118 | # https://github.com/encode/uvicorn/issues/1579#issuecomment-1419635974
119 | default_sigint_handler = signal.getsignal(signal.SIGINT)
120 |
121 | def terminate_now(signum, frame):
122 | print(f"Cancelling the tasks ({len(manager)})...")
123 | manager.cancel_all_tasks()
124 | default_sigint_handler(signum, frame) # type: ignore
125 |
126 | signal.signal(signal.SIGINT, terminate_now)
127 |
128 |
129 | @get("/subscribe")
130 | async def on_subscribe(request):
131 | return await manager.subscribe(request)
132 |
133 |
134 | @get("/stats")
135 | def get_stats():
136 | return json({"active_requests": len(manager._active_requests)})
137 |
138 |
139 | @post("/publish")
140 | async def publish_message(data: MessageInput):
141 | await manager.add_message(data.text)
142 | return ok()
143 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BlackSheep-Examples
2 |
3 | Various examples for BlackSheep.
4 |
5 | | Example | Description |
6 | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7 | | [./testing-api/](./testing-api) | Shows how to test a BlackSheep API using `pytest` and the provided `TestClient` (see also [testing](https://www.neoteroi.dev/blacksheep/testing/)). |
8 | | [./piccolo-admin/](./piccolo-admin) | Shows how to use the mount feature to use [Piccolo Admin](https://github.com/piccolo-orm/piccolo_admin) in BlackSheep. |
9 | | [./jwt-validation](./jwt-validation) | Shows how to configure a BlackSheep API that uses JWTs to implement authentication and authorization for users. |
10 | | [./oidc](./oidc) | Shows how to configure a BlackSheep app to use OpenID Connect and integrate with: Azure Active Directory, Auth0, Google, or Okta. |
11 | | [./aad-machine-to-machine](./aad-machine-to-machine) | Shows how to configure an API that requires access tokens issued by Azure Active Directory, and how to obtain access tokens using [MSAL for Python](https://github.com/AzureAD/microsoft-authentication-library-for-python) for a confidential client (machine to machine communication). |
12 | | [./oauth2-password-provider](./oauth2-password-provider) | Shows an implementation of OAuth2 Server with password authentication. |
13 | | [./websocket-chat](./websocket-chat) | Shows how to use WebSocket with BlackSheep, the example consists of a simple chat application built using WebSocket and VueJS. |
14 | | [./proxy-1](./proxy-1) | Shows how to create a proxy server with BlackSheep, to proxy all requests to a back-end, and return responses from that back-end. (Proxying to a Flask back-end). |
15 | | [./proxy-2](./proxy-2) | Shows how to create a proxy server with BlackSheep, to proxy all requests to a back-end, and return responses from that back-end. (Proxying to a BlackSheep back-end). |
16 | | [./max-body-size](./max-body-size) | Shows a way to control the maximum body size when reading requests streams. |
17 | | [./long-polling](./long-polling) | Shows an example of long polling implemented using BlackSheep. |
18 | | [./server-sent-events](./server-sent-events) | Shows a basic example of how to use [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) with BlackSheep (>=2.0.6). |
19 | | [./dependency-injector](./dependency-injector/) | Shows how to use [Dependency Injector](https://python-dependency-injector.ets-labs.org/) instead of [Rodi](https://www.neoteroi.dev/rodi/). |
20 | | [./otel](./otel/) | Shows how to use [OpenTelemetry](https://opentelemetry.io/) integration with [Grafana](https://grafana.com/), and with [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview). |
21 |
--------------------------------------------------------------------------------
/oidc/static/jwt-decode.js:
--------------------------------------------------------------------------------
1 | /*
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2015 Auth0, Inc. (http://auth0.com)
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
24 | https://github.com/auth0/jwt-decode/tree/master
25 | */
26 | (function (factory) {
27 | typeof define === 'function' && define.amd ? define(factory) :
28 | factory();
29 | })((function () { 'use strict';
30 |
31 | /**
32 | * The code was extracted from:
33 | * https://github.com/davidchambers/Base64.js
34 | */
35 |
36 | var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
37 |
38 | function InvalidCharacterError(message) {
39 | this.message = message;
40 | }
41 |
42 | InvalidCharacterError.prototype = new Error();
43 | InvalidCharacterError.prototype.name = "InvalidCharacterError";
44 |
45 | function polyfill(input) {
46 | var str = String(input).replace(/=+$/, "");
47 | if (str.length % 4 == 1) {
48 | throw new InvalidCharacterError(
49 | "'atob' failed: The string to be decoded is not correctly encoded."
50 | );
51 | }
52 | for (
53 | // initialize result and counters
54 | var bc = 0, bs, buffer, idx = 0, output = "";
55 | // get next character
56 | (buffer = str.charAt(idx++));
57 | // character found in table? initialize bit storage and add its ascii value;
58 | ~buffer &&
59 | ((bs = bc % 4 ? bs * 64 + buffer : buffer),
60 | // and if not first of each 4 characters,
61 | // convert the first 8 bits to one ascii character
62 | bc++ % 4) ?
63 | (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) :
64 | 0
65 | ) {
66 | // try to find character in table (0-63, not found => -1)
67 | buffer = chars.indexOf(buffer);
68 | }
69 | return output;
70 | }
71 |
72 | var atob = (typeof window !== "undefined" &&
73 | window.atob &&
74 | window.atob.bind(window)) ||
75 | polyfill;
76 |
77 | function b64DecodeUnicode(str) {
78 | return decodeURIComponent(
79 | atob(str).replace(/(.)/g, function(m, p) {
80 | var code = p.charCodeAt(0).toString(16).toUpperCase();
81 | if (code.length < 2) {
82 | code = "0" + code;
83 | }
84 | return "%" + code;
85 | })
86 | );
87 | }
88 |
89 | function base64_url_decode(str) {
90 | var output = str.replace(/-/g, "+").replace(/_/g, "/");
91 | switch (output.length % 4) {
92 | case 0:
93 | break;
94 | case 2:
95 | output += "==";
96 | break;
97 | case 3:
98 | output += "=";
99 | break;
100 | default:
101 | throw new Error("base64 string is not of the correct length");
102 | }
103 |
104 | try {
105 | return b64DecodeUnicode(output);
106 | } catch (err) {
107 | return atob(output);
108 | }
109 | }
110 |
111 | function InvalidTokenError(message) {
112 | this.message = message;
113 | }
114 |
115 | InvalidTokenError.prototype = new Error();
116 | InvalidTokenError.prototype.name = "InvalidTokenError";
117 |
118 | function jwtDecode(token, options) {
119 | if (typeof token !== "string") {
120 | throw new InvalidTokenError("Invalid token specified: must be a string");
121 | }
122 |
123 | options = options || {};
124 | var pos = options.header === true ? 0 : 1;
125 |
126 | var part = token.split(".")[pos];
127 | if (typeof part !== "string") {
128 | throw new InvalidTokenError("Invalid token specified: missing part #" + (pos + 1));
129 | }
130 |
131 | try {
132 | var decoded = base64_url_decode(part);
133 | } catch (e) {
134 | throw new InvalidTokenError("Invalid token specified: invalid base64 for part #" + (pos + 1) + ' (' + e.message + ')');
135 | }
136 |
137 | try {
138 | return JSON.parse(decoded);
139 | } catch (e) {
140 | throw new InvalidTokenError("Invalid token specified: invalid json for part #" + (pos + 1) + ' (' + e.message + ')');
141 | }
142 | }
143 |
144 | /*
145 | * Expose the function on the window object
146 | */
147 |
148 | //use amd or just through the window object.
149 | if (window) {
150 | if (typeof window.define == "function" && window.define.amd) {
151 | window.define("jwt_decode", function() {
152 | return jwtDecode;
153 | });
154 | } else if (window) {
155 | window.jwt_decode = jwtDecode;
156 | }
157 | }
158 |
159 | }));
160 |
--------------------------------------------------------------------------------
/oidc/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | OIDC Example with JWT Authentication
5 |
6 |
7 |
8 |
9 |
Example OIDC integration with JWT Authentication
10 |
11 | This example avoids using Cookie based authentication: session
12 | tokens are not shared with the client using cookies, but using HTML documents
13 | that write information using the HTML5 Storage API.
14 |
15 |
16 |
17 |
18 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/otel/otel/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | This module provides middleware, context managers, and helper functions to enable
3 | distributed tracing and logging using OpenTelemetry.
4 |
5 | Features:
6 | - OTELMiddleware: Middleware for automatic tracing of HTTP requests.
7 | - Environment-based configuration for OpenTelemetry resource attributes.
8 | - Logging and tracing setup using user-provided exporters.
9 | - Context manager and decorator utilities for tracing custom operations and function calls.
10 |
11 | Usage:
12 | from otel import use_open_telemetry
13 | use_open_telemetry(app, log_exporter, span_exporter)
14 | """
15 |
16 | import logging
17 | import os
18 | from contextlib import contextmanager
19 | from functools import wraps
20 | from typing import Awaitable, Callable, Dict
21 |
22 | from blacksheep import Application, Response
23 | from blacksheep.messages import Request, Response
24 | from blacksheep.server.env import get_env
25 | from opentelemetry import trace
26 | from opentelemetry.instrumentation.logging import LoggingInstrumentor
27 | from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
28 | from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExporter
29 | from opentelemetry.sdk.trace import TracerProvider
30 | from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
31 | from opentelemetry.trace import SpanKind
32 |
33 |
34 | ExceptionHandler = Callable[[Request, Exception], Awaitable[Response]]
35 |
36 |
37 | class OTELMiddleware:
38 | """
39 | Middleware configuring OpenTelemetry for all web requests.
40 | """
41 |
42 | def __init__(self, exc_handler: ExceptionHandler) -> None:
43 | self._exc_handler = exc_handler
44 | self._tracer = trace.get_tracer(__name__)
45 |
46 | async def __call__(self, request: Request, handler):
47 | path = request.url.path.decode("utf8")
48 | method = request.method
49 | with self._tracer.start_as_current_span(
50 | f"{method} {path}", kind=SpanKind.SERVER
51 | ) as span:
52 | try:
53 | response = await handler(request)
54 | except Exception as exc:
55 | # This approach is correct because it supports controlling the response
56 | # using exceptions. Unhandled exceptions are handled by the Span.
57 | response = await self._exc_handler(request, exc)
58 |
59 | self.set_span_attributes(span, request, response, path)
60 | return response
61 |
62 | def set_span_attributes(
63 | self, span: trace.Span, request: Request, response: Response, path: str
64 | ) -> None:
65 | """
66 | Configure the attributes on the span for a given request-response cycle.
67 | """
68 | # To reduce cardinality, update the span name to use the
69 | # route that matched the request
70 | route = request.route # type: ignore
71 | span.update_name(f"{request.method} {route}")
72 |
73 | span.set_attribute("http.status_code", response.status)
74 | span.set_attribute("http.method", request.method)
75 | span.set_attribute("http.path", path)
76 | span.set_attribute("http.url", request.url.value.decode())
77 | span.set_attribute("http.route", route)
78 | span.set_attribute("http.status_code", response.status)
79 | span.set_attribute("client.ip", request.original_client_ip)
80 |
81 | if response.status >= 400:
82 | span.set_status(trace.Status(trace.StatusCode.ERROR))
83 |
84 |
85 | def _configure_logging(log_exporter: LogExporter, span_exporter: SpanExporter):
86 | log_provider = LoggerProvider()
87 | log_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
88 | logging.getLogger("opentelemetry").setLevel(logging.WARNING)
89 | logging.getLogger().addHandler(
90 | LoggingHandler(level=logging.NOTSET, logger_provider=log_provider)
91 | )
92 |
93 | logging.basicConfig(
94 | level=logging.INFO,
95 | format="%(asctime)s %(levelname)s [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s] %(message)s",
96 | )
97 |
98 | LoggingInstrumentor().instrument(set_logging_format=True)
99 |
100 | trace.set_tracer_provider(TracerProvider())
101 | trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(span_exporter)) # type: ignore
102 |
103 |
104 | def set_attributes(
105 | service_name: str,
106 | service_namespace: str = "default",
107 | env: str = "",
108 | ):
109 | """
110 | Sets the OTEL_RESOURCE_ATTRIBUTES environment variable with service metadata
111 | for OpenTelemetry.
112 |
113 | Args:
114 | service_name (str): The name of the service.
115 | service_namespace (str, optional): The namespace of the service. Defaults to
116 | "default".
117 | env (str, optional): The deployment environment. If not provided, it is
118 | determined from the environment.
119 |
120 | Returns:
121 | None
122 | """
123 | if not env:
124 | env = get_env()
125 | os.environ["OTEL_RESOURCE_ATTRIBUTES"] = (
126 | f"service.name={service_name},"
127 | f"service.namespace={service_namespace},"
128 | f"deployment.environment={env}"
129 | )
130 |
131 |
132 | def use_open_telemetry(
133 | app: Application,
134 | log_exporter: LogExporter,
135 | span_exporter: SpanExporter,
136 | ):
137 | if os.getenv("OTEL_RESOURCE_ATTRIBUTES") is None:
138 | # set a default value
139 | set_attributes("blacksheep-app")
140 |
141 | _configure_logging(log_exporter, span_exporter)
142 |
143 | app.middlewares.append(OTELMiddleware(app.handle_request_handler_exception))
144 |
145 | @app.on_start
146 | async def on_start(app):
147 | # Patch the router to keep track of the route pattern that matched the request,
148 | # if any
149 | # https://www.neoteroi.dev/blacksheep/routing/#how-to-track-routes-that-matched-a-request
150 | def wrap_get_route_match(fn):
151 | @wraps(fn)
152 | def get_route_match(request):
153 | match = fn(request)
154 | request.route = match.pattern.decode() if match else "Not Found" # type: ignore
155 | return match
156 |
157 | return get_route_match
158 |
159 | app.router.get_match = wrap_get_route_match(app.router.get_match) # type: ignore
160 |
161 | @app.on_stop
162 | async def on_stop(app):
163 | # Try calling shutdown() on app stop to flush all remaining spans.
164 | try:
165 | trace.get_tracer_provider().shutdown() # type: ignore
166 | except TypeError:
167 | pass
168 |
169 |
170 | @contextmanager
171 | def client_span_context(
172 | operation_name: str, attributes: Dict[str, str], *args, **kwargs
173 | ):
174 | tracer = trace.get_tracer(__name__)
175 | with tracer.start_as_current_span(operation_name, kind=SpanKind.CLIENT) as span:
176 | span.set_attributes(attributes)
177 | for i, value in enumerate(args):
178 | span.set_attribute(f"@arg{i}", str(value))
179 | for key, value in kwargs.items():
180 | span.set_attribute(f"@{key}", str(value))
181 | try:
182 | yield
183 | except Exception as ex:
184 | span.record_exception(ex)
185 | span.set_attribute("ERROR", str(ex))
186 | span.set_attribute("http.status_code", 500)
187 | span.set_status(trace.Status(trace.StatusCode.ERROR))
188 | raise
189 |
190 |
191 | def logcall(component="Service"):
192 | """
193 | Wraps a function to log each call using OpenTelemetry, as SpanKind.CLIENT.
194 | """
195 |
196 | def log_decorator(fn):
197 | @wraps(fn)
198 | async def wrapper(*args, **kwargs):
199 | with client_span_context(
200 | fn.__name__, {"component": component}, *args, **kwargs
201 | ):
202 | return await fn(*args, **kwargs)
203 |
204 | return wrapper
205 |
206 | return log_decorator
207 |
--------------------------------------------------------------------------------
/piccolo-admin/piccoloexample.py:
--------------------------------------------------------------------------------
1 | """
2 | An example of how to configure and run the admin.
3 | Can be run from the command line using `python -m piccolo_admin.example`,
4 | or `admin_demo`.
5 |
6 | Refer to Piccolo-Admin-LICENSE for this file, and to the source repository:
7 | https://github.com/piccolo-orm/piccolo_admin
8 | """
9 | import asyncio
10 | import datetime
11 | import decimal
12 | import enum
13 | import os
14 | import random
15 | import typing as t
16 |
17 | from hypercorn.asyncio import serve
18 | from hypercorn.config import Config
19 | from piccolo_api.session_auth.tables import SessionsBase
20 | from piccolo.engine.sqlite import SQLiteEngine
21 | from piccolo.engine.postgres import PostgresEngine
22 | from piccolo.apps.user.tables import BaseUser
23 | from piccolo.table import Table
24 | from piccolo.columns import (
25 | Array,
26 | BigInt,
27 | Varchar,
28 | Integer,
29 | ForeignKey,
30 | Boolean,
31 | Interval,
32 | Text,
33 | Timestamp,
34 | Numeric,
35 | Real,
36 | SmallInt,
37 | )
38 | from piccolo.columns.readable import Readable
39 | import targ
40 |
41 | from piccolo_admin.endpoints import create_admin
42 | from piccolo_admin.example_data import DIRECTORS, MOVIES, MOVIE_WORDS
43 |
44 |
45 | class Sessions(SessionsBase):
46 | pass
47 |
48 |
49 | class User(BaseUser, tablename="piccolo_user"):
50 | pass
51 |
52 |
53 | class Director(Table, help_text="The main director for a movie."):
54 | class Gender(enum.Enum):
55 | male = "m"
56 | female = "f"
57 | non_binary = "n"
58 |
59 | name = Varchar(length=300, null=False)
60 | years_nominated = Array(
61 | base_column=Integer(),
62 | help_text=(
63 | "Which years this director was nominated for a best director " "Oscar."
64 | ),
65 | )
66 | gender = Varchar(length=1, choices=Gender)
67 |
68 | @classmethod
69 | def get_readable(cls):
70 | return Readable(template="%s", columns=[cls.name])
71 |
72 |
73 | class Movie(Table):
74 | class Genre(int, enum.Enum):
75 | fantasy = 1
76 | sci_fi = 2
77 | documentary = 3
78 | horror = 4
79 | action = 5
80 | comedy = 6
81 | romance = 7
82 | musical = 8
83 |
84 | name = Varchar(length=300)
85 | rating = Real(help_text="The rating on IMDB.")
86 | duration = Interval()
87 | director = ForeignKey(references=Director)
88 | oscar_nominations = Integer()
89 | won_oscar = Boolean()
90 | description = Text()
91 | release_date = Timestamp()
92 | box_office = Numeric(digits=(5, 1), help_text="In millions of US dollars.")
93 | tags = Array(base_column=Varchar())
94 | barcode = BigInt(default=0)
95 | genre = SmallInt(choices=Genre, null=True)
96 |
97 |
98 | TABLE_CLASSES: t.Tuple[t.Type[Table]] = (Director, Movie, User, Sessions)
99 | APP = create_admin([Director, Movie], auth_table=User, session_table=Sessions)
100 |
101 |
102 | def set_engine(engine: str = "sqlite"):
103 | if engine == "postgres":
104 | db = PostgresEngine(config={"database": "piccolo_admin"})
105 | else:
106 | sqlite_path = os.path.join(os.path.dirname(__file__), "example.sqlite")
107 | db = SQLiteEngine(path=sqlite_path)
108 |
109 | for table_class in TABLE_CLASSES:
110 | table_class._meta._db = db
111 |
112 |
113 | def create_schema(persist: bool = False):
114 | if not persist:
115 | for table_class in reversed(TABLE_CLASSES):
116 | table_class.alter().drop_table(if_exists=True).run_sync()
117 |
118 | for table_class in TABLE_CLASSES:
119 | table_class.create_table(if_not_exists=True).run_sync()
120 |
121 |
122 | def populate_data(inflate: int = 0, engine: str = "sqlite"):
123 | """
124 | Populate the database with some example data.
125 | :param inflate:
126 | If set, this number of extra rows are inserted containing dummy data.
127 | This is useful for testing.
128 | """
129 | # Add some rows
130 | Director.insert(*[Director(**d) for d in DIRECTORS]).run_sync()
131 | Movie.insert(*[Movie(**m) for m in MOVIES]).run_sync()
132 |
133 | if engine == "postgres":
134 | # We need to update the sequence, as we explicitly set the IDs for the
135 | # directors we just inserted
136 | Director.raw(
137 | "SELECT setval('director_id_seq', max(id)) FROM director"
138 | ).run_sync()
139 |
140 | # Create a user for testing login
141 | user = User(
142 | username="piccolo",
143 | password="piccolo123",
144 | admin=True,
145 | email="admin@test.com",
146 | active=True,
147 | )
148 | user.save().run_sync()
149 |
150 | if inflate:
151 | try:
152 | import faker
153 | except ImportError:
154 | print(
155 | "Install faker to use this feature: "
156 | "`pip install piccolo_admin[faker]`"
157 | )
158 | else:
159 | fake = faker.Faker()
160 | remaining = inflate
161 | chunk_size = 100
162 |
163 | while remaining > 0:
164 | if remaining < chunk_size:
165 | chunk_size = remaining
166 | remaining = 0
167 | else:
168 | remaining = remaining - chunk_size
169 |
170 | directors = []
171 | genders = ["m", "f", "n"]
172 | for _ in range(chunk_size):
173 | gender = random.choice(genders)
174 | if gender == "m":
175 | name = fake.name_male()
176 | elif gender == "f":
177 | name = fake.name_female()
178 | else:
179 | name = fake.name_nonbinary()
180 | directors.append(Director(name=name, gender=gender))
181 |
182 | Director.insert(*directors).run_sync()
183 |
184 | director_ids = (
185 | Director.select(Director.id)
186 | .order_by(Director.id, ascending=False)
187 | .limit(chunk_size)
188 | .output(as_list=True)
189 | .run_sync()
190 | )
191 |
192 | movies = []
193 | genres = [i.value for i in Movie.Genre]
194 | for _ in range(chunk_size):
195 | oscar_nominations = random.sample([0, 0, 0, 0, 0, 1, 1, 3, 5], 1)[0]
196 | won_oscar = oscar_nominations > 0
197 | rating = (
198 | random.randint(80, 100) if won_oscar else random.randint(1, 100)
199 | ) / 10
200 |
201 | movie = Movie(
202 | name="{} {}".format(
203 | fake.word().title(),
204 | fake.word(ext_word_list=MOVIE_WORDS),
205 | ),
206 | rating=rating,
207 | duration=datetime.timedelta(minutes=random.randint(60, 210)),
208 | director=random.sample(director_ids, 1)[0],
209 | oscar_nominations=oscar_nominations,
210 | won_oscar=won_oscar,
211 | description=fake.sentence(30),
212 | release_date=fake.date_time(),
213 | box_office=decimal.Decimal(str(random.randint(10, 1500) / 10)),
214 | barcode=random.randint(1_000_000_000, 9_999_999_999),
215 | genre=random.choice(genres),
216 | )
217 | movies.append(movie)
218 |
219 | Movie.insert(*movies).run_sync()
220 |
221 |
222 | def run(persist: bool = False, engine: str = "sqlite", inflate: int = 0):
223 | """
224 | Start the Piccolo admin.
225 | :param persist:
226 | If True, we don't rebuild all of the data each time.
227 | :param engine:
228 | Options are sqlite and postgres. By default sqlite is used.
229 | :param inflate:
230 | If set, this number of extra rows are inserted containing dummy data.
231 | This is useful when you need to test with lots of data. Example
232 | `--inflate=10000`.
233 | """
234 | set_engine(engine)
235 | create_schema(persist=persist)
236 |
237 | if not persist:
238 | populate_data(inflate=inflate, engine=engine)
239 |
240 | # Server
241 | class CustomConfig(Config):
242 | use_reloader = True
243 | accesslog = "-"
244 |
245 | asyncio.run(serve(APP, CustomConfig()))
246 |
247 |
248 | def main():
249 | cli = targ.CLI(description="Piccolo Admin")
250 | cli.register(run)
251 | cli.run(solo=True)
252 |
253 |
254 | if __name__ == "__main__":
255 | main()
256 |
--------------------------------------------------------------------------------
/oauth2-password-provider/src/password_auth.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import logging
4 | from dataclasses import asdict, dataclass
5 | from datetime import datetime, timedelta
6 | from enum import Enum
7 | from json import JSONEncoder
8 | from typing import Any, Iterable, List, Literal, Optional, Union
9 | from uuid import UUID, uuid4
10 |
11 | import jwt
12 | from blacksheep import Application
13 | from blacksheep.messages import Request, Response
14 | from blacksheep.server.authorization import allow_anonymous
15 | from blacksheep.server.headers.cache import cache_control
16 | from blacksheep.server.responses import json, no_content
17 | from essentials.exceptions import UnauthorizedException
18 | from guardpost.asynchronous.authentication import AuthenticationHandler
19 | from guardpost.authentication import Identity
20 | from guardpost.authorization import Policy
21 | from guardpost.common import AuthenticatedRequirement
22 | from jwt import InvalidTokenError
23 | from pydantic import UUID4, BaseModel
24 |
25 | from .user import UserDAL, checkpw
26 |
27 | logger = logging.getLogger(__name__)
28 |
29 |
30 | class UUIDJSONEncode(JSONEncoder):
31 | def default(self, obj: Any):
32 | if isinstance(obj, UUID):
33 | return obj.hex
34 | return JSONEncoder.default(self, obj)
35 |
36 |
37 | class TokenPayload(BaseModel):
38 | """Token model."""
39 |
40 | sub: UUID4
41 | jti: UUID4
42 | sid: UUID4
43 | iat: datetime
44 | nbf: datetime
45 | exp: datetime
46 | typ: Literal["access", "refresh"]
47 |
48 |
49 | class HMACAlgorithm(Enum):
50 | """HMAC algorithms."""
51 |
52 | HS256 = "HS256"
53 | HS384 = "HS384"
54 | HS512 = "HS512"
55 |
56 |
57 | @dataclass
58 | class OAuth2PasswordAuthData:
59 | """Dataclass for OAuth2 Password flow."""
60 |
61 | username: Optional[str] = None
62 | password: Optional[str] = None
63 |
64 |
65 | @dataclass
66 | class OAuth2PasswordRefreshData:
67 | """Dataclass for OAuth2 Password flow."""
68 |
69 | refresh_token: Optional[str] = None
70 |
71 |
72 | @dataclass
73 | class OAuth2PasswordSettings:
74 | """Settings for OAuth2 Password flow."""
75 |
76 | secret: str
77 | algorithm: HMACAlgorithm = HMACAlgorithm.HS256
78 | issuer: Optional[str] = None
79 | audience: Optional[Union[str, Iterable[str]]] = None
80 | token_path: str = "/token"
81 | refresh_path: str = "/refresh"
82 | revoke_path: str = "/revoke"
83 | access_token_ttl: int = 60 * 60
84 | refresh_token_ttl: int = 60 * 60 * 24 * 30
85 | username_field: str = "username"
86 | password_field: str = "password"
87 |
88 |
89 | class FailedTokenDecode(Exception):
90 | """Raised when a token cannot be decoded."""
91 |
92 |
93 | class HMACJWTSerializerBase:
94 | """Base class for JWT token generatoration/extraction."""
95 |
96 | def __init__(
97 | self,
98 | secret: str,
99 | algorithm: HMACAlgorithm = HMACAlgorithm.HS256,
100 | issuer: Optional[str] = None,
101 | audience: Optional[Union[str, Iterable[str]]] = None,
102 | verify_options: Optional[dict[str, Any]] = None,
103 | ):
104 | self.secret = secret
105 | self.algorithm = algorithm
106 | self.issuer = issuer
107 | self.audience = audience
108 | self.verify_options = verify_options
109 |
110 | def encode(self, payload: dict) -> str:
111 | """Encode a payload into a JWT token."""
112 | raise NotImplementedError()
113 |
114 | def decode(self, token: str) -> dict:
115 | """Decode a JWT token into a payload."""
116 | raise NotImplementedError()
117 |
118 |
119 | class HMACJWTSerializer(HMACJWTSerializerBase):
120 | """JWT token encode/decode using HMAC."""
121 |
122 | def encode(self, payload: dict) -> str:
123 | """Encode a payload into a JWT token."""
124 | return jwt.encode(
125 | payload=payload,
126 | key=self.secret,
127 | algorithm=self.algorithm.value,
128 | json_encoder=UUIDJSONEncode,
129 | )
130 |
131 | def decode(self, token: str) -> dict:
132 | """Decode a JWT token into a payload."""
133 | try:
134 | return jwt.decode(
135 | jwt=token,
136 | key=self.secret,
137 | algorithms=[self.algorithm.value],
138 | issuer=self.issuer,
139 | audience=self.audience,
140 | options=self.verify_options,
141 | )
142 | except InvalidTokenError as e:
143 | logger.debug("Failed to decode token", exc_info=e)
144 | raise FailedTokenDecode from e
145 |
146 |
147 | class BearerAuthentication(AuthenticationHandler):
148 | """Authentication handler for Bearer tokens with dynamic validation."""
149 |
150 | token_type = b"Bearer"
151 | header_name = b"Authorization"
152 |
153 | def __init__(self, serializer: HMACJWTSerializerBase):
154 | self.serializer = serializer
155 |
156 | async def authenticate(self, request: Request) -> Optional[Identity]:
157 | raw_token = self._get_request_token(request)
158 | if not raw_token:
159 | request.identity = Identity({})
160 | return None
161 |
162 | try:
163 | token = self.serializer.decode(raw_token)
164 | TokenPayload.parse_obj(token)
165 | except FailedTokenDecode:
166 | request.identity = Identity({})
167 | return None
168 |
169 | if token["typ"] != "access":
170 | request.identity = Identity({})
171 | return None
172 |
173 | request.identity = Identity(token, self.token_type.decode())
174 |
175 | return request.identity
176 |
177 | def _get_request_token(self, request: Request) -> str | None:
178 | auth_header = request.headers.get_first(self.header_name)
179 | if not auth_header:
180 | return None
181 | if auth_header.startswith(self.token_type + b" ") is False:
182 | return None
183 |
184 | try:
185 | token = auth_header[7:].decode()
186 | except UnicodeDecodeError:
187 | return None
188 |
189 | return token
190 |
191 |
192 | class AuthProviderBase:
193 | """Base class for authentication storage.
194 |
195 | Verifies creadentials, refresh and revoke tokens.
196 | """
197 |
198 | async def authenticate(self, username: str, password: str) -> Identity:
199 | """Authenticate a user.
200 |
201 | Check if the user exists and the password is correct. Returns an identity with access and refresh tokens.
202 | """
203 | raise NotImplementedError()
204 |
205 | async def refresh_token(self, raw_token: str) -> Identity:
206 | """Verify a refresh token.
207 |
208 | Check if the refresh token is valid and not expired. Returns an identity with access and refresh tokens.
209 | """
210 | raise NotImplementedError()
211 |
212 | async def revoke_refresh_token(self, session_id: Any) -> None:
213 | """Revoke a refresh token.
214 |
215 | Remove the refresh token from the storage for a given session id.
216 | """
217 | raise NotImplementedError()
218 |
219 |
220 | class AppAuthProvider(AuthProviderBase):
221 | def __init__(
222 | self,
223 | storage: UserDAL,
224 | serializer: HMACJWTSerializerBase,
225 | settings: OAuth2PasswordSettings,
226 | ):
227 | self.storage = storage
228 | self.serializer = serializer
229 | self.settings = settings
230 |
231 | async def authenticate(self, username: str, password: str) -> Identity:
232 | user = await self.storage.get_authuser_by_username(username)
233 | if not user:
234 | raise UnauthorizedException("User or password is invalid")
235 | if not checkpw(password, user.password):
236 | raise UnauthorizedException("User or password is invalid")
237 | if not user.active:
238 | raise UnauthorizedException("User or password is invalid")
239 |
240 | access_payload, refresh_payload = self._get_tokens_pair(user.id)
241 | access_token, refresh_token = self._encode_tokens(
242 | access_payload,
243 | refresh_payload,
244 | )
245 | await self._store_refresh_token(refresh_payload)
246 |
247 | identity = self._make_identity(access_payload, access_token, refresh_token)
248 | return identity
249 |
250 | def _make_identity(
251 | self, payload: TokenPayload, access_token: str, refresh_token: str
252 | ) -> Identity:
253 | identity = Identity(payload.dict())
254 | identity.access_token = access_token
255 | identity.refresh_token = refresh_token
256 | return identity
257 |
258 | def _get_tokens_pair(
259 | self, sub: UUID4, sid: UUID4 | None = None
260 | ) -> tuple[TokenPayload, TokenPayload]:
261 | sid = sid or uuid4()
262 | jti = uuid4()
263 | now = datetime.utcnow()
264 | access = TokenPayload(
265 | sub=sub,
266 | jti=jti,
267 | sid=sid,
268 | iat=now,
269 | nbf=now,
270 | exp=now + timedelta(minutes=self.settings.access_token_ttl),
271 | typ="access",
272 | )
273 | refresh = TokenPayload(
274 | sub=sub,
275 | jti=jti,
276 | sid=sid,
277 | iat=now,
278 | nbf=now,
279 | exp=now + timedelta(minutes=self.settings.refresh_token_ttl),
280 | typ="refresh",
281 | )
282 | return access, refresh
283 |
284 | def _encode_tokens(
285 | self,
286 | access_payload: TokenPayload,
287 | refresh_payload: TokenPayload,
288 | ) -> tuple[str, str]:
289 | access_token = self.serializer.encode(access_payload.dict())
290 | refresh_token = self.serializer.encode(refresh_payload.dict())
291 | return access_token, refresh_token
292 |
293 | async def _store_refresh_token(self, token_payload: TokenPayload) -> None:
294 | await self.storage.save_refresh_token(
295 | token_payload.sub,
296 | token_payload.jti,
297 | token_payload.sid,
298 | token_payload.exp,
299 | )
300 |
301 | async def _remove_used_refresh_token(self, jti: UUID4):
302 | await self.storage.remove_used_refresh_token(jti)
303 |
304 | async def refresh_token(self, raw_token: str) -> Identity:
305 | try:
306 | used_refresh_token = self.serializer.decode(raw_token)
307 | except FailedTokenDecode as e:
308 | raise UnauthorizedException(
309 | f"Invalid refresh token: {FailedTokenDecode}",
310 | ) from e
311 |
312 | if used_refresh_token["typ"] != "refresh":
313 | raise UnauthorizedException("Invalid type of refresh token")
314 |
315 | user = await self.storage.get_user_by_refresh_token(
316 | UUID(used_refresh_token["sub"]),
317 | UUID(used_refresh_token["jti"]),
318 | UUID(used_refresh_token["sid"]),
319 | )
320 | if not user:
321 | # someone is trying to use already used refresh token. revoke all refresh tokens for this user
322 | await self.revoke_refresh_token(used_refresh_token["sub"])
323 | raise UnauthorizedException("Invalid refresh token")
324 |
325 | # TODO: wrap delete old and store new refresh token to transaction
326 | access_payload, refresh_payload = self._get_tokens_pair(
327 | sub=user.id,
328 | sid=used_refresh_token["sid"],
329 | )
330 | access_token, refresh_token = self._encode_tokens(
331 | access_payload, refresh_payload
332 | )
333 | await self._store_refresh_token(refresh_payload)
334 | await self._remove_used_refresh_token(UUID(used_refresh_token["jti"]))
335 | identity = self._make_identity(access_payload, access_token, refresh_token)
336 | return identity
337 |
338 | async def revoke_refresh_token(self, user_id: str) -> None:
339 | await self.storage.revoke_refresh_token(UUID(user_id))
340 |
341 |
342 | class OAuth2PasswordHandler:
343 | def __init__(
344 | self,
345 | *,
346 | settings: OAuth2PasswordSettings,
347 | auth_provider: AuthProviderBase,
348 | ):
349 | self.settings = settings
350 | self.auth_provider = auth_provider
351 |
352 | async def token_handler(self, request: Request) -> Response:
353 | """Handler for OAuth2 Password flow.
354 |
355 | Issue access and refresh tokens when user logs in.
356 |
357 | Response headers should contain:
358 | Cache-Control: no-store
359 | Pragma: no-cache
360 | """
361 | userdata = await self._fetch_credentials(request)
362 | identity = await self.auth_provider.authenticate(**asdict(userdata))
363 | if not identity:
364 | raise UnauthorizedException("Invalid username or password")
365 | return json(
366 | dict(
367 | access_token=identity.access_token,
368 | refresh_token=identity.refresh_token,
369 | token_type="Bearer",
370 | )
371 | )
372 |
373 | async def _fetch_credentials(self, request: Request) -> OAuth2PasswordAuthData:
374 | """Extract user credentials from request."""
375 | content_type = request.headers.get_first(b"Content-Type")
376 | if content_type == b"application/x-www-form-urlencoded":
377 | userdata = await self._form_userdata(request)
378 | elif content_type == b"application/json":
379 | userdata = await self._json_userdata(request)
380 | else:
381 | raise UnauthorizedException(
382 | "Unsupported Content-Type. Supported: application/x-www-form-urlencoded, application/json"
383 | )
384 |
385 | return userdata
386 |
387 | async def _form_userdata(self, request: Request) -> OAuth2PasswordAuthData:
388 | """Extract user credentials from form payload."""
389 | form = await request.form()
390 | if not form:
391 | raise ValueError("Cannot parse form data")
392 | username = form.get(self.settings.username_field, None)
393 | if username is None or not isinstance(username, str):
394 | raise ValueError("Username filed is required and must be a string")
395 | password = form.get(self.settings.password_field, None)
396 | if password is None or not isinstance(password, str):
397 | raise ValueError("Password filed is required and must be a string")
398 | return OAuth2PasswordAuthData(
399 | username=username,
400 | password=password,
401 | )
402 |
403 | async def _json_userdata(self, request: Request) -> OAuth2PasswordAuthData:
404 | """Extract user credentials from JSON payload."""
405 | data = await request.json()
406 | username = data.get(self.settings.username_field)
407 | if username is None or not isinstance(username, str):
408 | raise ValueError("Username filed is required and must be a string")
409 | password = data.get(self.settings.password_field)
410 | if password is None or not isinstance(password, str):
411 | raise ValueError("Password filed is required and must be a string")
412 | return OAuth2PasswordAuthData(
413 | username=username,
414 | password=password,
415 | )
416 |
417 | async def refresh_handler(self, request: Request) -> Response:
418 | """Handler for OAuth2 Refresh flow.
419 |
420 | Issue new access and refresh tokens when user make request with refresh token.
421 |
422 | Response headers should contain:
423 | Cache-Control: no-store
424 | Pragma: no-cache
425 | """
426 | refreshdata = await self._fetch_refresh_token(request)
427 | identity = await self.auth_provider.refresh_token(**asdict(refreshdata))
428 | if not identity:
429 | raise UnauthorizedException("Invalid refresh token")
430 | return json(
431 | dict(
432 | access_token=identity.access_token,
433 | refresh_token=identity.refresh_token,
434 | token_type="Bearer",
435 | )
436 | )
437 |
438 | async def _fetch_refresh_token(self, request: Request) -> OAuth2PasswordRefreshData:
439 | """Extract refresh token from request."""
440 | content_type = request.headers.get_first(b"Content-Type")
441 | if content_type == b"application/x-www-form-urlencoded":
442 | refreshdata = await self._form_refreshdata(request)
443 | elif content_type == b"application/json":
444 | refreshdata = await self._json_refreshdata(request)
445 | else:
446 | raise ValueError(
447 | "Unsupported Content-Type. Supported: application/x-www-form-urlencoded, application/json"
448 | )
449 |
450 | return refreshdata
451 |
452 | async def _form_refreshdata(self, request: Request) -> OAuth2PasswordRefreshData:
453 | """Extract refresh token from form payload."""
454 | form = await request.form()
455 | if not form:
456 | raise ValueError("Cannot parse form data")
457 | refresh_token = form.get("refresh_token", None)
458 | if refresh_token is None or not isinstance(refresh_token, str):
459 | raise ValueError("Refresh token filed is required and must be a string")
460 | return OAuth2PasswordRefreshData(
461 | refresh_token=refresh_token,
462 | )
463 |
464 | async def _json_refreshdata(self, request: Request) -> OAuth2PasswordRefreshData:
465 | """Extract refresh token from JSON payload."""
466 | data = await request.json()
467 | refresh_token = data.get("refresh_token")
468 | if refresh_token is None or not isinstance(refresh_token, str):
469 | raise ValueError("Refresh token field is required and must be a string")
470 | return OAuth2PasswordRefreshData(
471 | refresh_token=refresh_token,
472 | )
473 |
474 | async def revoke_handler(self, request: Request) -> Response:
475 | """Handler for revoke token.
476 |
477 | Revoke refresh tokens and/or access tokens during logout.
478 | """
479 | if not request.identity or request.identity.is_authenticated() is False:
480 | raise UnauthorizedException("Authentication required")
481 | session_id = request.identity["session_id"]
482 | if session_id is None or not isinstance(session_id, str):
483 | raise ValueError("Session ID filed is required and must be a string")
484 | await self.auth_provider.revoke_refresh_token(session_id)
485 |
486 | return no_content()
487 |
488 |
489 | def use_oauth2_password(
490 | app: Application,
491 | settings: OAuth2PasswordSettings,
492 | auth_provider: Optional[AuthProviderBase] = None,
493 | auth_handler: Optional[AuthenticationHandler] = None,
494 | authz_policies: Optional[List[Policy]] = None,
495 | ):
496 | """Register OAuth2 Password handlers."""
497 |
498 | serializer = HMACJWTSerializer(
499 | secret=settings.secret,
500 | algorithm=settings.algorithm,
501 | issuer=settings.issuer,
502 | audience=settings.audience,
503 | verify_options={
504 | "verify_exp": True,
505 | "verify_iat": True,
506 | "verify_nbf": True,
507 | "verify_signature": True,
508 | },
509 | )
510 |
511 | auth_handler = auth_handler or BearerAuthentication(
512 | serializer,
513 | )
514 |
515 | authz_policies = authz_policies or [
516 | Policy("authenticated", AuthenticatedRequirement())
517 | ]
518 |
519 | auth_provider = auth_provider or AppAuthProvider(
520 | storage=UserDAL(),
521 | serializer=serializer,
522 | settings=settings,
523 | )
524 | handler = OAuth2PasswordHandler(
525 | settings=settings,
526 | auth_provider=auth_provider,
527 | )
528 |
529 | @allow_anonymous()
530 | @app.router.post(settings.token_path)
531 | @cache_control(no_cache=True, no_store=True)
532 | async def token_handler(request: Request):
533 | return await handler.token_handler(request)
534 |
535 | @allow_anonymous()
536 | @app.router.post(settings.refresh_path)
537 | @cache_control(no_cache=True, no_store=True)
538 | async def refresh_handler(request: Request):
539 | return await handler.refresh_handler(request)
540 |
541 | @app.router.get(settings.revoke_path)
542 | @cache_control(no_cache=True, no_store=True)
543 | async def revoke_handler(request: Request):
544 | return await handler.revoke_handler(request)
545 |
546 | authentication = app.use_authentication()
547 | authentication.add(auth_handler)
548 |
549 | authorization = app.use_authorization()
550 | for policy in authz_policies:
551 | authorization.add(policy)
552 |
--------------------------------------------------------------------------------