Docs / docs (push) Successful in 49s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m11s
Playwright Tests / test-playwright (2, 2) (push) Failing after 23s
pre-commit / pre-commit (push) Successful in 3m2s
Test Backend / test-backend (push) Successful in 2m22s
Compose Smoke Test / test-compose (push) Failing after 22s
Playwright Tests / merge-reports (push) Canceled after 1s
The gates have never gone green on the new runners. Three separate reasons: - backend/Dockerfile shipped Python 3.10 while the code imports typing.Self and datetime.UTC, so the container exited on import and the suite could not even load its conftest. The image moves to 3.13 and the packages declare >=3.12, which is the floor the tests actually pass on; ruff's target follows and rewrites timezone.utc and asyncio.TimeoutError accordingly. Relocking drops the 3.10 branch, which bumps FastAPI and so regenerates the SDK. - frontend/README.md had no trailing newline and two dashboard widgets used arbitrary text-[…] sizes. Both are em-relative on purpose, so they move to the inline style the neighbouring ramp already uses. - Every commit left its own run queued: without a concurrency group a runner that was offline for a while works through a backlog nobody reads. A stack that fails to come up now prints its logs before the teardown removes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
7.7 KiB
Python
239 lines
7.7 KiB
Python
import base64
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
import jwt
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from pwdlib import PasswordHash
|
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
|
|
from fluksio.core.config import settings
|
|
|
|
password_hash = PasswordHash(
|
|
(
|
|
Argon2Hasher(),
|
|
BcryptHasher(),
|
|
)
|
|
)
|
|
|
|
|
|
ALGORITHM = "HS256"
|
|
#: MCP tokens are signed with a keypair of their own, so the public half can be
|
|
#: published and the whole set revoked by rotating it — without logging anyone
|
|
#: out of the browser, and without the resource server needing the secret that
|
|
#: signs browser sessions.
|
|
OAUTH_ALGORITHM = "RS256"
|
|
#: The one scope an MCP token carries.
|
|
MCP_SCOPE = "mcp"
|
|
#: What a remote worker's credential says it is for. Its own audience, so an
|
|
#: agent's token cannot attach a worker and a worker's cannot call the API.
|
|
WORKER_SCOPE = "worker"
|
|
WORKER_AUDIENCE = "fluksio-worker"
|
|
#: What a paired wall panel's credential says it is for. Its own audience, so
|
|
#: the session decode refuses it outright and the scope check in ``deps`` is
|
|
#: the only door it fits.
|
|
PANEL_AUDIENCE = "fluksio-panel"
|
|
|
|
|
|
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
|
expire = datetime.now(UTC) + expires_delta
|
|
to_encode = {"exp": expire, "sub": str(subject)}
|
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
|
return encoded_jwt
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# OAuth signing key
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _load_or_create_key() -> rsa.RSAPrivateKey:
|
|
"""The RSA key MCP tokens are signed with, generated on first use."""
|
|
path = settings.OAUTH_PRIVATE_KEY_FILE
|
|
if path.exists():
|
|
loaded = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
|
if not isinstance(loaded, rsa.RSAPrivateKey):
|
|
raise TypeError(f"{path} is not an RSA private key")
|
|
return loaded
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(
|
|
key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
)
|
|
)
|
|
path.chmod(0o600)
|
|
return key
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def oauth_key() -> rsa.RSAPrivateKey:
|
|
return _load_or_create_key()
|
|
|
|
|
|
def public_jwks() -> dict[str, Any]:
|
|
"""The public half, for anything that wants to check a token itself."""
|
|
numbers = oauth_key().public_key().public_numbers()
|
|
|
|
def b64(value: int) -> str:
|
|
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
return {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": OAUTH_ALGORITHM,
|
|
"kid": "fluksio-oauth",
|
|
"n": b64(numbers.n),
|
|
"e": b64(numbers.e),
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
def create_oauth_access_token(
|
|
user_id: uuid.UUID, client_id: uuid.UUID, expires_delta: timedelta
|
|
) -> str:
|
|
"""An access token for the MCP channel, told apart from a browser session.
|
|
|
|
The ``mcp`` claim is what the MCP endpoint checks: a perfectly valid token
|
|
from someone's browser is refused there, so agent traffic never arrives
|
|
looking like a person's.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
payload = {
|
|
"sub": str(user_id),
|
|
"iss": settings.oauth_issuer,
|
|
"aud": settings.mcp_resource,
|
|
"iat": now,
|
|
"exp": now + expires_delta,
|
|
"mcp": True,
|
|
"client_id": str(client_id),
|
|
"scope": MCP_SCOPE,
|
|
}
|
|
return jwt.encode(
|
|
payload,
|
|
oauth_key().private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
),
|
|
algorithm=OAUTH_ALGORITHM,
|
|
headers={"kid": "fluksio-oauth"},
|
|
)
|
|
|
|
|
|
def create_worker_token(name: str, expires_delta: timedelta) -> str:
|
|
"""A credential a remote worker presents when it dials in.
|
|
|
|
Signed with the same keypair the agent tokens use, so the whole set can be
|
|
revoked by rotating one key, and told apart from them by its audience: a
|
|
worker's token grants no API access, and an agent's opens no worker
|
|
connection.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
payload = {
|
|
"sub": name,
|
|
"iss": settings.oauth_issuer,
|
|
"aud": WORKER_AUDIENCE,
|
|
"iat": now,
|
|
"exp": now + expires_delta,
|
|
"scope": WORKER_SCOPE,
|
|
}
|
|
return jwt.encode(
|
|
payload,
|
|
oauth_key().private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
),
|
|
algorithm=OAUTH_ALGORITHM,
|
|
headers={"kid": "fluksio-oauth"},
|
|
)
|
|
|
|
|
|
def decode_worker_token(token: str) -> dict[str, Any]:
|
|
"""Validate a worker credential. Raises ``InvalidTokenError`` if it does not."""
|
|
payload: dict[str, Any] = jwt.decode(
|
|
token,
|
|
oauth_key().public_key(),
|
|
algorithms=[OAUTH_ALGORITHM],
|
|
audience=WORKER_AUDIENCE,
|
|
issuer=settings.oauth_issuer,
|
|
)
|
|
return payload
|
|
|
|
|
|
def create_panel_token(
|
|
panel: str, user_id: uuid.UUID | str, expires_delta: timedelta, nonce: int = 0
|
|
) -> str:
|
|
"""The credential a paired wall panel holds.
|
|
|
|
Signed with the app's own secret like a browser session, because it names a
|
|
person in exactly the same way: ``sub`` is the account that approved the
|
|
pairing, so everything the panel does is attributable to them. What keeps
|
|
it from being a full session is the ``panel`` claim — the request filter in
|
|
``fluksio.api.deps`` lets it reach only that panel's dashboards and the message
|
|
endpoints its widgets need.
|
|
|
|
``pnc`` is the panel's nonce at the moment of pairing, and the filter
|
|
refuses a credential naming any other. Bumping the panel's nonce is
|
|
therefore how one screen is re-paired without deleting the panel out from
|
|
under its dashboards.
|
|
|
|
Long-lived on purpose: a wall tablet is set up once and left running, and
|
|
it has no keyboard to log in again with.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
payload = {
|
|
"sub": str(user_id),
|
|
"aud": PANEL_AUDIENCE,
|
|
"panel": panel,
|
|
"pnc": nonce,
|
|
"iat": now,
|
|
"exp": now + expires_delta,
|
|
}
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
|
|
|
|
|
def decode_panel_token(token: str) -> dict[str, Any]:
|
|
"""Validate a panel credential. Raises ``InvalidTokenError`` if it does not."""
|
|
payload: dict[str, Any] = jwt.decode(
|
|
token,
|
|
settings.SECRET_KEY,
|
|
algorithms=[ALGORITHM],
|
|
audience=PANEL_AUDIENCE,
|
|
)
|
|
return payload
|
|
|
|
|
|
def decode_oauth_token(token: str) -> dict[str, Any]:
|
|
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
|
|
payload: dict[str, Any] = jwt.decode(
|
|
token,
|
|
oauth_key().public_key(),
|
|
algorithms=[OAUTH_ALGORITHM],
|
|
audience=settings.mcp_resource,
|
|
issuer=settings.oauth_issuer,
|
|
)
|
|
return payload
|
|
|
|
|
|
def verify_password(
|
|
plain_password: str, hashed_password: str
|
|
) -> tuple[bool, str | None]:
|
|
return password_hash.verify_and_update(plain_password, hashed_password)
|
|
|
|
|
|
def get_password_hash(password: str) -> str:
|
|
return password_hash.hash(password)
|