Let agents drive the flow API over MCP
The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization server in front of it: an agent registers itself, sends a human to the browser to approve it, and exchanges the resulting code for a token. PKCE is required, codes are single-use and stored only as hashes, the browser is redirected to the URI that was registered rather than the one asked for, and refresh tokens rotate so that replaying a spent one revokes the whole line. Twenty tools cover reading, building, publishing and running flows, and each one calls the same REST endpoint the dashboard calls, in-process, carrying the caller's own token. That keeps one description of what a flow is and what may be done to it — validation, the draft/publish split, the version check — and means an agent can do nothing a person could not do in the browser. Agent tokens are RS256 with a keypair of their own rather than the secret that signs browser sessions, so deleting the key withdraws every agent without logging anyone out, and deps.decode_token grew the branch that trusting a second issuer will need when the hosted login arrives.
This commit is contained in:
@@ -41,6 +41,16 @@ class Settings(BaseSettings):
|
||||
# Flows live on disk as a git repository; secrets stay outside it.
|
||||
FLOWS_DIR: Path = Path("flow-data/flows")
|
||||
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
|
||||
# The MCP endpoint, and the OAuth server agents authenticate against. Off
|
||||
# until someone asks for it: it opens client registration to the network.
|
||||
MCP_ENABLED: bool = False
|
||||
DOMAIN: str = "localhost"
|
||||
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
|
||||
OAUTH_CODE_EXPIRE_SECONDS: int = 60
|
||||
# Short, because an agent's token is a bearer secret held by a program
|
||||
# rather than a person, and it can refresh unattended.
|
||||
MCP_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
MCP_REFRESH_EXPIRE_DAYS: int = 30
|
||||
FLOW_MAX_WORKERS: int = 4
|
||||
# Without a Redis host the engine keeps its state in memory.
|
||||
REDIS_HOST: str | None = None
|
||||
@@ -50,6 +60,24 @@ class Settings(BaseSettings):
|
||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
||||
] = []
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def oauth_issuer(self) -> str:
|
||||
"""Who issues MCP tokens — this app, on its API host.
|
||||
|
||||
Kept separate from the app's own URL because a hosted deployment can
|
||||
later point agents at a different issuer without the resource server
|
||||
changing: it validates whatever issuer it is configured to trust.
|
||||
"""
|
||||
scheme = "http" if self.ENVIRONMENT == "local" else "https"
|
||||
return f"{scheme}://api.{self.DOMAIN}"
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def mcp_resource(self) -> str:
|
||||
"""The resource an MCP token is issued for (RFC 8707)."""
|
||||
return f"{self.oauth_issuer}/mcp"
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def all_cors_origins(self) -> list[str]:
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
@@ -17,6 +22,13 @@ password_hash = PasswordHash(
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||
@@ -26,6 +38,104 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||
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(timezone.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 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]:
|
||||
|
||||
Reference in New Issue
Block a user