pygrindvakt
Python bindings for grindvakt 0.7.2, a runtime-agnostic Rust library for OAuth 2.0, OpenID Connect and OpenID Federation 1.1: an OpenID Provider engine, a Relying Party toolkit, federation trust-chain resolution, DPoP (RFC 9449), and the JOSE/key primitives underneath. Built with PyO3 0.29 + maturin (abi3, Python ≥ 3.10).
The binding mirrors grindvakt's modules as Python submodules:
pygrindvakt.{http, keys, client, metadata, request, tokens, provider, dpop, rp, federation, discovery, jwt, pkce, mac, util}. It is web-framework agnostic:
requests come in as plain dicts/strings, responses go out as a Response
(status, headers, body), and the same Provider object works under Flask,
Django, FastAPI, or anything else. See examples/ for complete adapters.
Example - an OpenID Provider token endpoint (Flask)
from flask import Flask, request, Response as FlaskResponse
from pygrindvakt import OAuthError, client, keys, metadata, provider, tokens
ISSUER = "https://op.example.com"
op = provider.Provider(
metadata.ProviderMetadata(ISSUER),
keys.signing_key_from_pem(open("op-key.pem", "rb").read(), kid="op-1"),
client.InMemoryClientStore([
client.Client("demo", client_secret="s3cret", redirect_uris=["https://rp.example.com/cb"]),
]),
tokens.TokenCodec("a-long-random-secret"),
token_use_store=provider.InMemoryTokenUseStore(), # or RedisStore(...) across workers
)
app = Flask(__name__)
def to_flask(r):
return FlaskResponse(r.body, status=r.status, headers=r.headers)
@app.post("/token")
def token():
try:
tr = op.handle_token_request(
list(request.form.items(multi=True)),
f"{ISSUER}/token", # from config, never from Host
auth_header=request.headers.get("Authorization"),
)
return to_flask(tr.to_response())
except OAuthError as e:
return to_flask(e.to_response()) # correct status, JSON body, no-store
Discovery, JWKS, authorization and userinfo follow the same shape:
op.discovery_document(), op.jwks_document(),
op.validate_authorization_request(req) / op.authorization_redirect(req, sub, claims),
op.userinfo(access_token).
Example - a Relying Party
from pygrindvakt import http, rp, pkce, util
http_client = http.ReqwestClient() # never follows redirects, bounded body
md = rp.discover(http_client, "https://op.example.com")
info = rp.ProviderInfo.from_metadata(md)
me = rp.RpClient("demo", "https://rp.example.com/cb", client_secret="s3cret")
state, nonce, verifier = util.random_token(24), util.random_token(24), util.random_token(32)
url = rp.authorization_url(info, me, state, nonce, code_challenge=pkce.s256_challenge(verifier))
# ... redirect the browser to `url`; on the callback:
tokens_ = rp.exchange_code(http_client, info, me, code, code_verifier=verifier)
jwks = rp.fetch_jwks(http_client, info.jwks_uri, info.issuer)
claims = rp.verify_id_token(
jwks, tokens_.id_token, info.issuer, "demo", nonce, ["ES256"]
)
userinfo = rp.fetch_userinfo(
http_client, info.userinfo_endpoint, tokens_.access_token, claims["sub"], info.issuer
)
Security:
verify_id_tokenrequires the expected nonce and an explicit signing-algorithm allowlist. PassingNoneraises unless you also passunsafe_skip_nonce_check=True, which emits aUserWarning. Other guards the binding adds on top of grindvakt: duplicateclient_ids and unknownClientfields are rejected, reserved id_token claim names inextra_claimsraise, and the built-in HTTP client refuses to follow redirects. Never sendstr(exc)of aGrindvaktErrorto a client; onlyOAuthError.to_response()/.to_redirect()output is client-safe. See the security guide.
Concurrency, workers and stores
grindvakt is async Rust; the Python API is synchronous. Each call runs on a
process-wide tokio runtime with the GIL released, so threads (gunicorn gthread,
FastAPI's threadpool) run in parallel. The runtime is rebuilt lazily after
fork, so a Provider built in a gunicorn master keeps working in workers.
State that must be shared across processes:
- Token-use store (one-time codes / refresh tokens / assertion
jtis):provider.RedisStore(url)(construct it after fork) or any object withconsume(token_hash, ttl_secs) -> bool. - Client store:
client.InMemoryClientStoreor any object withget(client_id)/put(client)(Django ORM, a cache, ...). - DPoP replay store:
dpop.InMemoryReplayStoreor any object withrecord(jti, ttl_secs) -> bool. - Outbound HTTP:
http.ReqwestClientor any object withget(url)/post_form(url, form, headers)returning(status, body, content_type).
Python-implemented stores fail closed: an exception is logged through
sys.unraisablehook and surfaces to the client as server_error.
HSM / PKCS#11 signing
from pygrindvakt import keys
key = keys.signing_key_from_pkcs11("/usr/lib/softhsm/libsofthsm2.so", "1234", "op-signing-key", "ES256", kid="hsm-1")
Prefer building the wheel on (or against) the target host when deploying with an HSM: the PKCS#11 module is
dlopen-ed at runtime from that host.
Development
Installs must go through sfw (Socket Firewall).
uv venv
sfw uv pip install --python .venv/bin/python "maturin==1.14.1" pytest cryptography flask django fastapi httpx
VIRTUAL_ENV=$PWD/.venv .venv/bin/maturin develop --release --uv
.venv/bin/python -m pytest tests/ # PKCS#11 / Redis tests auto-skip
cargo clippy --release -- -D warnings && cargo fmt --check
REDIS_URL=redis://127.0.0.1/ .venv/bin/python -m pytest tests/ -m redis
Documentation
Guides and the full API reference: https://pygrindvakt.readthedocs.io/en/latest/.
Design decisions live in docs/adr/.
Type stubs
The package ships .pyi stubs for every submodule (py.typed is partial).
License
BSD-2-Clause.
Release files for pygrindvakt 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pygrindvakt-0.1.0.tar.gz | 270.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pygrindvakt-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.28+ x86-64 | Details |
Total release size: 4.1 MB
Release files / pygrindvakt-0.1.0.tar.gz
| Download URL | pygrindvakt-0.1.0.tar.gz |
|---|---|
| Size | 270.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5b603cc738ac157c5bd7ad772a6dda7f54543514e4eb3a244897683045a2b185
|
|
BLAKE2b-256 checksum How to use checksums |
9a4188c94fae09446d63e842a16402802e66be42a8709267b62f1270da8b161d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency logRelease files / pygrindvakt-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl
| Download URL | pygrindvakt-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 3.8 MB |
| Tags | CPython 3.10 Linux glibc 2.28+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
38556a4abe6f3728a368aad8c33b24b3d6201ed415e3d7165919c58b7bcd57e7
|
|
BLAKE2b-256 checksum How to use checksums |
a916bacc31f764ed9453046c540408cbcdc5275082a32f36a17d47783df739e1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency log