Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
name: Tests and lint (Python ${{ matrix.python }})
runs-on: ubuntu-24.04
strategy:
matrix:
# The oldest Python pairkit supports, and the newest.
python: ["3.12", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python }}
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run pytest -q
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ Reusable QR-code device pairing, extracted from [ScribbleScan](https://github.co
- **The core stays framework-free and auth-agnostic.** The app's auth is touched only through `PairingHooks.issue_grant` / `revoke_grant`. Framework code lives in adapters (`fastapi.py`).
- **Security properties are the product; keep every one** (each has a test): single-use secrets that expire after 2 minutes, stored hashed; the secret goes in the URL fragment; per-side private keys (`shower_key`, `claimer_key`), so a photographed QR is useless after the real scan; approval only by the side that has access; compare-and-set state changes (`Store.swap`); uncollected grants revoked on expiry; rate-limited claims.
- `MemoryStore` and the in-process `EventBus` are for tests and single-process apps. Production stores (Redis, Postgres) must implement `swap` as a real compare-and-set on `version`.
- Every route in `fastapi.py` declares a response model (apps generate client types from the OpenAPI spec); `test_openapi_describes_every_response` enforces it. `Collected` allows extra fields because apps put their own data in `Grant.data`.
- Don't add `from __future__ import annotations` to `fastapi.py`: FastAPI can't resolve the `Annotated` aliases defined inside `pairing_router`.

## Commands

```bash
uv sync
uv run pytest # 18 tests: both flows, permissions, replay, races, expiry, SSE, HTTP API
uv run pytest # 20 tests: both flows, permissions, replay, races, expiry, SSE, HTTP API, OpenAPI contract
uv run ruff check .
uv run --with "fastapi[standard]" --with segno python examples/camera_demo.py # phone-as-camera demo on your Wi-Fi
```
Expand All @@ -25,4 +26,4 @@ ScribbleScan installs pairkit straight from this repo (`[tool.uv.sources]`), so

## Roadmap

Redis store and event bus · `@pairkit/client` JS package (QR display, waiting state, phone helpers) · Django adapter · publish to PyPI.
Postgres store and event bus (LISTEN/NOTIFY; ScribbleScan already runs Postgres) · `@pairkit/client` JS package (QR display, waiting state, phone helpers) · Django adapter · publish to PyPI.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ app.include_router(pairing_router(service, current_user, scan_url="https://app.e
| `POST /pair/offers/{id}/collect` | Receiving device: picks up its token once |
| `GET /pair/devices` · `DELETE /pair/devices/{id}` | User: list and unlink devices |

Every endpoint declares its response model, so your app's OpenAPI spec describes them exactly and client types can be generated from it.

The core (`PairingService`) has no web framework dependency; other adapters can wrap it the same way.

## Try the demo
Expand All @@ -77,7 +79,7 @@ Open the printed address on your PC, scan the QR with your phone's camera, click

```bash
uv sync
uv run pytest # flows, permissions, replay, races, expiry, live events, HTTP API
uv run pytest # flows, permissions, replay, races, expiry, live events, HTTP API, OpenAPI contract
uv run ruff check .
```

Expand All @@ -87,6 +89,6 @@ uv run ruff check .

## Roadmap

- Redis store and event bus
- A Postgres store and event bus (LISTEN/NOTIFY), for apps that already run Postgres and need several server processes
- `@pairkit/client` JavaScript package: QR display, waiting state, phone-side helpers
- Django adapter
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ dev = [
"pytest-asyncio>=0.24",
"ruff>=0.6",
"fastapi>=0.110",
"httpx>=0.27",
"httpx2>=2.13.1",
]

Expand Down
72 changes: 61 additions & 11 deletions src/pairkit/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@

from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from .models import DeviceInfo, GrantTo, LinkedDevice
from .models import DeviceInfo, GrantTo, LinkedDevice, State
from .service import OfferView, PairingError, PairingService

STATUS = {
Expand All @@ -53,6 +53,54 @@ class ClaimIn(BaseModel):
device_label: str = Field("", max_length=100)


# Responses. Declared so the OpenAPI spec describes exactly what clients receive, and
# apps can generate client types from it. Times are ISO 8601 strings.


class OfferCreated(BaseModel):
offer_id: str
secret: str = Field(description="goes in the QR code, in the URL fragment")
shower_key: str = Field(description="this device's private key: X-Pairkit-Key / ?key=")
expires_at: str
qr_url: str | None = Field(None, description="ready-to-encode scan link, when the router has a scan_url")


class Claimed(BaseModel):
offer_id: str
claimer_key: str = Field(description="the scanning device's private key: X-Pairkit-Key / ?key=")
grant_to: GrantTo
scopes: list[str]
shower: str = Field(description="how the device showing the QR describes itself")
expires_at: str


class OfferStatus(BaseModel):
offer_id: str
state: State
grant_to: GrantTo
scopes: list[str]
expires_at: str
shower: str
scanner: str | None


class Collected(BaseModel):
"""The grant from the app's issue_grant hook, plus whatever it put in Grant.data."""

model_config = ConfigDict(extra="allow")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Grant.data under Pydantic v1

Apps using the still-permitted FastAPI 0.110/Pydantic v1 stack regress here: Pydantic v1 treats model_config as a normal response field and leaves its default extra policy as ignore, so /collect silently drops fields supplied through Grant.data (such as space_id) and emits a spurious model_config field. Older permitted Pydantic v1 releases also cannot import ConfigDict at all. Use the v1-compatible Config mechanism (or explicitly require Pydantic v2) before relying on this response model.

Useful? React with 👍 / 👎.

token: str
expires_at: str | None


class LinkedDeviceOut(BaseModel):
id: str
device: str
scopes: list[str]
linked_at: str
via: GrantTo
active: bool


def _device(request: Request, label: str) -> DeviceInfo:
return DeviceInfo(
label=label,
Expand Down Expand Up @@ -108,7 +156,7 @@ def required(user: OptionalUser) -> str:
User = Annotated[str, Depends(required)]
Key = Annotated[str, Header(alias="X-Pairkit-Key")]

@router.post("/offers", status_code=201)
@router.post("/offers", status_code=201, response_model=OfferCreated, response_model_exclude_none=True)
async def create_offer(body: CreateOfferIn, request: Request, user: OptionalUser):
try:
o = await service.create_offer(body.grant_to, body.scopes, _device(request, body.device_label), user)
Expand All @@ -120,7 +168,7 @@ async def create_offer(body: CreateOfferIn, request: Request, user: OptionalUser
out["qr_url"] = o.qr_payload(scan_url)
return out

@router.post("/claim")
@router.post("/claim", response_model=Claimed)
async def claim(body: ClaimIn, request: Request, user: OptionalUser):
device = _device(request, body.device_label)
try:
Expand All @@ -130,14 +178,16 @@ async def claim(body: ClaimIn, request: Request, user: OptionalUser):
return {"offer_id": c.offer_id, "claimer_key": c.claimer_key, "grant_to": c.grant_to.value,
"scopes": list(c.scopes), "shower": c.shower.describe(), "expires_at": c.expires_at.isoformat()}

@router.get("/offers/{offer_id}")
@router.get("/offers/{offer_id}", response_model=OfferStatus)
async def status(offer_id: str, key: Key):
try:
return _view(await service.status(offer_id, key))
except PairingError as e:
raise _fail(e) from None

@router.get("/offers/{offer_id}/events")
@router.get("/offers/{offer_id}/events", response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}},
"description": "Server-sent events: state, claimed, approved, denied, collected, expired"}})
async def events(offer_id: str, key: Annotated[str, Query()]):
try:
await service.status(offer_id, key) # fail fast with a proper status code
Expand All @@ -151,33 +201,33 @@ async def stream():
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})

@router.post("/offers/{offer_id}/approve")
@router.post("/offers/{offer_id}/approve", response_model=OfferStatus)
async def approve(offer_id: str, user: User):
try:
return _view(await service.approve(offer_id, user))
except PairingError as e:
raise _fail(e) from None

@router.post("/offers/{offer_id}/deny")
@router.post("/offers/{offer_id}/deny", response_model=OfferStatus)
async def deny(offer_id: str, user: User):
try:
return _view(await service.deny(offer_id, user))
except PairingError as e:
raise _fail(e) from None

@router.post("/offers/{offer_id}/collect")
@router.post("/offers/{offer_id}/collect", response_model=Collected)
async def collect(offer_id: str, key: Key):
try:
g = await service.collect(offer_id, key)
except PairingError as e:
raise _fail(e) from None
return {"token": g.token, "expires_at": g.expires_at.isoformat() if g.expires_at else None, **g.data}

@router.get("/devices")
@router.get("/devices", response_model=list[LinkedDeviceOut])
async def devices(user: User):
return [_linked(d) for d in await service.list_devices(user)]

@router.delete("/devices/{device_id}")
@router.delete("/devices/{device_id}", response_model=LinkedDeviceOut)
async def unlink(device_id: str, user: User):
try:
return _linked(await service.revoke_device(device_id, user))
Expand Down
22 changes: 22 additions & 0 deletions tests/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,25 @@ def test_error_codes(client):
assert second.status_code == 409 and second.json()["detail"]["code"] == "already_used"
assert client.post(f"/pair/offers/{offer['offer_id']}/approve").status_code == 401
assert client.get(f"/pair/offers/{offer['offer_id']}", headers={"X-Pairkit-Key": "nope"}).status_code == 404


def test_openapi_describes_every_response(client):
"""Apps generate client types from the OpenAPI spec, so every JSON response needs a schema."""
spec = client.get("/openapi.json").json()
for path, methods in spec["paths"].items():
for method, op in methods.items():
content = op["responses"][next(c for c in op["responses"] if c.startswith("2"))]["content"]
if path.endswith("/events"):
assert "text/event-stream" in content
else:
assert content["application/json"]["schema"], f"{method.upper()} {path}"
created = spec["components"]["schemas"]["OfferCreated"]
assert {"offer_id", "secret", "shower_key", "expires_at"} <= set(created["required"])


def test_qr_url_only_when_the_router_has_a_scan_url():
app = FastAPI()
app.include_router(pairing_router(PairingService(PairingHooks(issue)), current_user))
offer = TestClient(app).post("/pair/offers", json={"grant_to": "scanner", "scopes": ["upload"]},
headers=ALMAS).json()
assert "qr_url" not in offer and offer["secret"]
39 changes: 0 additions & 39 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.