Let agents drive the flow API over MCP
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-16 00:22:41 +02:00
co-authored by Claude Fable 5
parent 3724b68f23
commit 8d82d6c4ec
28 changed files with 2459 additions and 555 deletions
+3
View File
@@ -48,3 +48,6 @@ SENTRY_DSN=
# Docker registry images # Docker registry images
DOCKER_IMAGE_BACKEND=fluksio-backend DOCKER_IMAGE_BACKEND=fluksio-backend
DOCKER_IMAGE_FRONTEND=fluksio-frontend DOCKER_IMAGE_FRONTEND=fluksio-frontend
# The MCP endpoint agents connect to, and the OAuth server behind it.
MCP_ENABLED=true
+6
View File
@@ -27,6 +27,12 @@ Deferring because out of scope is fine, but don't mention deferring than.
- FEAT/UI: publishing and discarding are only reachable while no side panel is open, since the - FEAT/UI: publishing and discarding are only reachable while no side panel is open, since the
floating chrome hides for the panel. Editing a node's code and publishing it is therefore floating chrome hides for the panel. Editing a node's code and publishing it is therefore
close-panel-then-publish. close-panel-then-publish.
- FEAT/UI: no screen for the OAuth clients an agent registers. They can only be listed or
removed in the database, so withdrawing one agent's access means deleting its rows or
rotating `OAUTH_PRIVATE_KEY_FILE`, which cuts off all of them.
- CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer
starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves
to 2.x.
- FEAT/UI: there is no screen for managing the secrets store itself. A node parameter marked - FEAT/UI: there is no screen for managing the secrets store itself. A node parameter marked
`x-secret` offers the stored secrets, but they can only be created through the API. `x-secret` offers the stored secrets, but they can only be created through the API.
- FEAT/FLOW: input discretization drops the trailing edge — if a producer goes quiet inside - FEAT/FLOW: input discretization drops the trailing edge — if a producer goes quiet inside
+7 -1
View File
@@ -75,7 +75,13 @@ external interfaces. See `docs/architecture/structure.canvas` → *Backend M
- [ ] Test nodes: a small node dragged onto an existing one, smoke or unit, blocking - [ ] Test nodes: a small node dragged onto an existing one, smoke or unit, blocking
deployment on failure deployment on failure
- [ ] User management scoped per flow and per data set - [ ] User management scoped per flow and per data set
- [ ] LLM interface for natural-language flow authoring - [x] MCP server over the same API: agents authenticate through a built-in
OAuth 2.1 authorization server (dynamic registration, PKCE, rotating
refresh tokens) and drive the flow API through 20 tools. Tokens are
RS256, signed with their own keypair, so the set can be revoked on its
own — and an additional issuer is one branch in `deps.decode_token`,
which is the seam remote access needs later
- [ ] LLM interface for natural-language flow authoring beyond the MCP tools
## Phase 2 — Backend: processing ## Phase 2 — Backend: processing
@@ -0,0 +1,70 @@
"""add oauth client, code and refresh token
Revision ID: 59e2606ce144
Revises: b7c41d2f8a30
Create Date: 2026-08-15 23:59:41.147202
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = '59e2606ce144'
down_revision = 'b7c41d2f8a30'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('oauth_client',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('client_name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column('redirect_uris', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('oauth_authorization_code',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('code_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('redirect_uri', sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=False),
sa.Column('code_challenge', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column('resource', sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.Column('refresh_token_id', sa.Uuid(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_client.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_authorization_code_code_hash'), 'oauth_authorization_code', ['code_hash'], unique=True)
op.create_table('oauth_refresh_token',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('client_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('family_id', sa.Uuid(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_client.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_refresh_token_token_hash'), 'oauth_refresh_token', ['token_hash'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_oauth_refresh_token_token_hash'), table_name='oauth_refresh_token')
op.drop_table('oauth_refresh_token')
op.drop_index(op.f('ix_oauth_authorization_code_code_hash'), table_name='oauth_authorization_code')
op.drop_table('oauth_authorization_code')
op.drop_table('oauth_client')
# ### end Alembic commands ###
+24 -9
View File
@@ -1,5 +1,5 @@
from collections.abc import Generator from collections.abc import Generator
from typing import Annotated from typing import Annotated, Any
import jwt import jwt
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
@@ -28,16 +28,34 @@ SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)] TokenDep = Annotated[str, Depends(reusable_oauth2)]
def decode_token(token: str) -> dict[str, Any]:
"""Read a bearer token, whichever channel issued it.
A browser session is signed with the app's own secret; an agent's token is
signed with the OAuth keypair, so that set can be revoked on its own and
the public half published. Both name a user, and both grant that user's
rights — the difference is only in who is holding it, which the MCP
endpoint checks separately.
This is also the seam a hosted deployment widens later: trusting an
additional issuer is a third branch here, not a change anywhere else.
"""
try:
session: dict[str, Any] = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
return session
except InvalidTokenError:
return security.decode_oauth_token(token)
def user_from_token(session: Session, token: str) -> User | None: def user_from_token(session: Session, token: str) -> User | None:
"""Resolve a bearer token to its user, or None if it does not hold up. """Resolve a bearer token to its user, or None if it does not hold up.
Shared with the websocket, which cannot use the HTTP security scheme. Shared with the websocket, which cannot use the HTTP security scheme.
""" """
try: try:
payload = jwt.decode( token_data = TokenPayload(**decode_token(token))
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError): except (InvalidTokenError, ValidationError):
return None return None
user = session.get(User, token_data.sub) user = session.get(User, token_data.sub)
@@ -54,10 +72,7 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
not a missing resource to report. not a missing resource to report.
""" """
try: try:
payload = jwt.decode( token_data = TokenPayload(**decode_token(token))
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError): except (InvalidTokenError, ValidationError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+4 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.routes import flows, login, private, secrets, users, utils from app.api.routes import flows, login, oauth, private, secrets, users, utils
from app.core.config import settings from app.core.config import settings
api_router = APIRouter() api_router = APIRouter()
@@ -10,6 +10,9 @@ api_router.include_router(utils.router)
api_router.include_router(flows.router) api_router.include_router(flows.router)
api_router.include_router(flows.ws_router) api_router.include_router(flows.ws_router)
api_router.include_router(secrets.router) api_router.include_router(secrets.router)
# Always mounted so the generated SDK stays the same shape; the endpoints
# themselves refuse to work unless MCP is switched on.
api_router.include_router(oauth.router)
if settings.ENVIRONMENT == "local": if settings.ENVIRONMENT == "local":
+446
View File
@@ -0,0 +1,446 @@
"""OAuth 2.1 authorization server, for agents reaching the MCP endpoint.
An agent cannot be handed a password, so it registers itself, sends a human to
the browser to approve it, and exchanges the resulting code for a token. The
parts that carry the security are the ones with the least room for
interpretation:
* the code is single-use, short-lived, and only its hash is stored;
* PKCE is required (S256 only), so a code intercepted on its way back is
useless without the verifier that started the flow;
* the redirect the browser is finally sent to is the one *registered*, never
the one asked for;
* refresh tokens rotate, and reusing a spent one revokes the whole line, which
is how a stolen token gets noticed.
Errors here follow RFC 6749 — ``{"error": ...}`` rather than FastAPI's
``{"detail": ...}`` — because that is what OAuth clients parse.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import re
import secrets
import time
import uuid
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode, urlparse
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import JSONResponse
from sqlmodel import select
from app.api.deps import CurrentUser, SessionDep, get_current_user
from app.core import security
from app.core.config import settings
from app.models import (
OAuthAuthorizationCode,
OAuthAuthorizeInfo,
OAuthAuthorizeRequest,
OAuthAuthorizeResponse,
OAuthClient,
OAuthClientInfo,
OAuthClientRegister,
OAuthRefreshToken,
User,
)
router = APIRouter(prefix="/oauth", tags=["oauth"])
#: RFC 7636: 43-128 characters from the unreserved set.
_PKCE_RE = re.compile(r"^[A-Za-z0-9._~-]{43,128}$")
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
_NO_STORE = {"Cache-Control": "no-store", "Pragma": "no-cache"}
def _error(code: str, description: str, status: int = 400) -> JSONResponse:
"""An OAuth error, in the shape clients expect to read."""
return JSONResponse(
status_code=status,
content={"error": code, "error_description": description},
headers=_NO_STORE,
)
# -----------------------------------------------------------------------------
# Rate limiting
#
# Registration is open by necessity, so it is capped per address. In-process is
# enough: the engine is one process, and this is a speed bump, not a boundary.
# -----------------------------------------------------------------------------
_hits: dict[str, deque[float]] = defaultdict(deque)
def _too_many(bucket: str, limit: int, window: float) -> bool:
now = time.monotonic()
seen = _hits[bucket]
while seen and now - seen[0] > window:
seen.popleft()
if len(seen) >= limit:
return True
seen.append(now)
return False
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def _valid_redirect_uri(value: str) -> bool:
"""https anywhere, or plain http only on the loopback interface.
An agent running on someone's laptop listens on 127.0.0.1 and has nowhere
to get a certificate, which OAuth 2.1 allows for exactly that reason.
"""
parsed = urlparse(value)
if parsed.fragment:
return False
if parsed.scheme == "https":
return bool(parsed.hostname)
if parsed.scheme == "http":
return parsed.hostname in _LOOPBACK
return False
def _now() -> datetime:
return datetime.now(timezone.utc)
def _aware(value: datetime) -> datetime:
"""Postgres hands back naive datetimes; compare them in UTC."""
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _prune(session: SessionDep) -> None:
"""Drop codes long past the window where a replay could still matter."""
cutoff = _now() - timedelta(days=1)
for code in session.exec(
select(OAuthAuthorizationCode).where(OAuthAuthorizationCode.expires_at < cutoff)
).all():
session.delete(code)
def _issue(
session: SessionDep, user_id: uuid.UUID, client_id: uuid.UUID, family: uuid.UUID
) -> tuple[dict[str, Any], OAuthRefreshToken]:
"""Mint an access token and the refresh token that will replace it."""
access = security.create_oauth_access_token(
user_id,
client_id,
timedelta(minutes=settings.MCP_TOKEN_EXPIRE_MINUTES),
)
refresh_secret = secrets.token_urlsafe(32)
refresh = OAuthRefreshToken(
token_hash=_hash(refresh_secret),
client_id=client_id,
user_id=user_id,
family_id=family,
expires_at=_now() + timedelta(days=settings.MCP_REFRESH_EXPIRE_DAYS),
)
session.add(refresh)
return {
"access_token": access,
"token_type": "Bearer",
"expires_in": settings.MCP_TOKEN_EXPIRE_MINUTES * 60,
"refresh_token": refresh_secret,
"scope": security.MCP_SCOPE,
}, refresh
# -----------------------------------------------------------------------------
# Registration
# -----------------------------------------------------------------------------
@router.post("/register")
def register_client(
body: OAuthClientRegister, request: Request, session: SessionDep
) -> Any:
"""RFC 7591 dynamic client registration.
Open on purpose, and harmless on its own: a registered client can do
nothing until a signed-in human approves it on the consent page.
"""
if not settings.MCP_ENABLED:
return _error("access_denied", "The MCP endpoint is switched off.", 403)
if _too_many(f"register:{_client_ip(request)}", limit=10, window=3600):
return _error("invalid_request", "Too many registrations.", 429)
if not 1 <= len(body.redirect_uris) <= 10:
return _error("invalid_redirect_uri", "Give between 1 and 10 redirect URIs.")
for uri in body.redirect_uris:
if not _valid_redirect_uri(uri):
return _error(
"invalid_redirect_uri",
f"'{uri}' must be https, or http on a loopback address, "
"and carry no fragment.",
)
if body.token_endpoint_auth_method not in (None, "none"):
return _error(
"invalid_client_metadata",
"Only public clients are supported; use PKCE rather than a secret.",
)
if body.response_types not in (None, ["code"]):
return _error("invalid_client_metadata", "Only the 'code' response type.")
if body.grant_types is not None and not set(body.grant_types) <= {
"authorization_code",
"refresh_token",
}:
return _error(
"invalid_client_metadata",
"Only the authorization_code and refresh_token grants.",
)
client = OAuthClient(
client_name=body.client_name, redirect_uris=list(body.redirect_uris)
)
session.add(client)
session.commit()
session.refresh(client)
return JSONResponse(
status_code=201,
content=OAuthClientInfo(
client_id=str(client.id),
client_name=client.client_name,
redirect_uris=client.redirect_uris,
client_id_issued_at=int(client.created_at.timestamp()),
).model_dump(),
headers=_NO_STORE,
)
# -----------------------------------------------------------------------------
# Authorization
# -----------------------------------------------------------------------------
def _load_client(session: SessionDep, client_id: str) -> OAuthClient | None:
try:
return session.get(OAuthClient, uuid.UUID(client_id))
except ValueError:
return None
@router.get(
"/authorize/validate",
response_model=OAuthAuthorizeInfo,
# Signed in, but which user it is does not matter until they approve.
dependencies=[Depends(get_current_user)],
)
def authorize_validate(
client_id: str,
redirect_uri: str,
session: SessionDep,
) -> Any:
"""What the consent page should say, checked before it says it."""
if not settings.MCP_ENABLED:
return _error("access_denied", "The MCP endpoint is switched off.", 403)
client = _load_client(session, client_id)
if client is None:
return _error("invalid_client", "Unknown client.")
if redirect_uri not in client.redirect_uris:
return _error("invalid_request", "That redirect URI is not registered.")
return OAuthAuthorizeInfo(
client_name=client.client_name,
redirect_uri=redirect_uri,
scope=security.MCP_SCOPE,
)
@router.post("/authorize", response_model=OAuthAuthorizeResponse)
def authorize(
body: OAuthAuthorizeRequest,
current_user: CurrentUser,
session: SessionDep,
) -> Any:
"""Approve a client, on behalf of the signed-in user."""
if not settings.MCP_ENABLED:
return _error("access_denied", "The MCP endpoint is switched off.", 403)
client = _load_client(session, body.client_id)
if client is None:
return _error("invalid_client", "Unknown client.")
if body.redirect_uri not in client.redirect_uris:
return _error("invalid_request", "That redirect URI is not registered.")
if body.code_challenge_method != "S256":
return _error("invalid_request", "PKCE must use S256.")
if not _PKCE_RE.match(body.code_challenge):
return _error("invalid_request", "Malformed code challenge.")
if body.resource and body.resource.rstrip("/") != settings.mcp_resource:
return _error("invalid_target", "Unknown resource.")
code = secrets.token_urlsafe(32)
session.add(
OAuthAuthorizationCode(
code_hash=_hash(code),
client_id=client.id,
user_id=current_user.id,
redirect_uri=body.redirect_uri,
code_challenge=body.code_challenge,
resource=body.resource,
expires_at=_now() + timedelta(seconds=settings.OAUTH_CODE_EXPIRE_SECONDS),
)
)
session.commit()
query = {"code": code}
if body.state:
query["state"] = body.state
separator = "&" if urlparse(body.redirect_uri).query else "?"
# Built from the registered URI, never from what the request asked for.
return OAuthAuthorizeResponse(
redirect_url=f"{body.redirect_uri}{separator}{urlencode(query)}"
)
# -----------------------------------------------------------------------------
# Token
# -----------------------------------------------------------------------------
@router.post("/token")
def token( # noqa: PLR0911 - each branch is a distinct OAuth error
request: Request,
session: SessionDep,
grant_type: str = Form(...),
code: str | None = Form(None),
redirect_uri: str | None = Form(None),
client_id: str | None = Form(None),
code_verifier: str | None = Form(None),
refresh_token: str | None = Form(None),
resource: str | None = Form(None),
) -> Any:
"""Exchange a code, or a refresh token, for an access token."""
if not settings.MCP_ENABLED:
return _error("access_denied", "The MCP endpoint is switched off.", 403)
if _too_many(f"token:{_client_ip(request)}", limit=60, window=60):
return _error("invalid_request", "Too many token requests.", 429)
if grant_type == "authorization_code":
return _authorization_code_grant(
session, code, redirect_uri, client_id, code_verifier, resource
)
if grant_type == "refresh_token":
return _refresh_token_grant(session, refresh_token, client_id)
return _error("unsupported_grant_type", f"'{grant_type}' is not supported.")
def _authorization_code_grant(
session: SessionDep,
code: str | None,
redirect_uri: str | None,
client_id: str | None,
code_verifier: str | None,
resource: str | None,
) -> Any:
if not (code and redirect_uri and client_id and code_verifier):
return _error("invalid_request", "Missing a required parameter.")
record = session.exec(
select(OAuthAuthorizationCode).where(
OAuthAuthorizationCode.code_hash == _hash(code)
)
).first()
if record is None:
return _error("invalid_grant", "Unknown or already used code.")
if record.used_at is not None:
# OAuth 2.1 §4.1.3: a replayed code means the first exchange may have
# been someone else's, so everything it produced is withdrawn.
_revoke_family(session, record.refresh_token_id)
session.commit()
return _error("invalid_grant", "That code has already been used.")
if _aware(record.expires_at) < _now():
return _error("invalid_grant", "That code has expired.")
if str(record.client_id) != client_id or record.redirect_uri != redirect_uri:
return _error("invalid_grant", "The code was issued to another client.")
if resource and resource.rstrip("/") != (record.resource or "").rstrip("/"):
return _error("invalid_target", "Resource does not match the authorization.")
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
if not hmac.compare_digest(expected, record.code_challenge):
return _error("invalid_grant", "PKCE verification failed.")
user = session.get(User, record.user_id)
if user is None or not user.is_active:
return _error("invalid_grant", "That account is no longer active.")
payload, refresh = _issue(session, user.id, record.client_id, uuid.uuid4())
record.used_at = _now()
record.refresh_token_id = refresh.id
session.add(record)
_prune(session)
session.commit()
return JSONResponse(content=payload, headers=_NO_STORE)
def _refresh_token_grant(
session: SessionDep, refresh_token: str | None, client_id: str | None
) -> Any:
if not refresh_token:
return _error("invalid_request", "Missing the refresh token.")
record = session.exec(
select(OAuthRefreshToken).where(
OAuthRefreshToken.token_hash == _hash(refresh_token)
)
).first()
if record is None:
return _error("invalid_grant", "Unknown refresh token.")
if record.revoked:
# A revoked token coming back means someone kept a copy: the whole
# family goes, including whatever is in legitimate use.
_revoke_family(session, record.id, family_id=record.family_id)
session.commit()
return _error("invalid_grant", "That refresh token was already used.")
if _aware(record.expires_at) < _now():
return _error("invalid_grant", "That refresh token has expired.")
if client_id and str(record.client_id) != client_id:
return _error("invalid_grant", "The token belongs to another client.")
user = session.get(User, record.user_id)
if user is None or not user.is_active:
return _error("invalid_grant", "That account is no longer active.")
payload, _ = _issue(session, user.id, record.client_id, record.family_id)
record.revoked = True
session.add(record)
session.commit()
return JSONResponse(content=payload, headers=_NO_STORE)
def _revoke_family(
session: SessionDep,
token_id: uuid.UUID | None,
family_id: uuid.UUID | None = None,
) -> None:
"""Withdraw every refresh token descended from one authorization."""
if family_id is None:
if token_id is None:
return
issued = session.get(OAuthRefreshToken, token_id)
if issued is None:
return
family_id = issued.family_id
for token in session.exec(
select(OAuthRefreshToken).where(OAuthRefreshToken.family_id == family_id)
).all():
token.revoked = True
session.add(token)
+28
View File
@@ -41,6 +41,16 @@ class Settings(BaseSettings):
# Flows live on disk as a git repository; secrets stay outside it. # Flows live on disk as a git repository; secrets stay outside it.
FLOWS_DIR: Path = Path("flow-data/flows") FLOWS_DIR: Path = Path("flow-data/flows")
SECRETS_FILE: Path = Path("flow-data/secrets.enc") 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 FLOW_MAX_WORKERS: int = 4
# Without a Redis host the engine keeps its state in memory. # Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None REDIS_HOST: str | None = None
@@ -50,6 +60,24 @@ class Settings(BaseSettings):
list[AnyUrl] | str, BeforeValidator(parse_cors) 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] @computed_field # type: ignore[prop-decorator]
@property @property
def all_cors_origins(self) -> list[str]: def all_cors_origins(self) -> list[str]:
+110
View File
@@ -1,7 +1,12 @@
import base64
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any from typing import Any
import jwt import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from pwdlib import PasswordHash from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher from pwdlib.hashers.bcrypt import BcryptHasher
@@ -17,6 +22,13 @@ password_hash = PasswordHash(
ALGORITHM = "HS256" 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: 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 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( def verify_password(
plain_password: str, hashed_password: str plain_password: str, hashed_password: str
) -> tuple[bool, str | None]: ) -> tuple[bool, str | None]:
+71 -1
View File
@@ -1,13 +1,16 @@
import asyncio import asyncio
import contextlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import AbstractAsyncContextManager, asynccontextmanager
import sentry_sdk import sentry_sdk
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from app.api.main import api_router from app.api.main import api_router
from app.core import security
from app.core.config import settings from app.core.config import settings
from app.flow import logs from app.flow import logs
from app.flow.controller import FlowController from app.flow.controller import FlowController
@@ -32,6 +35,15 @@ def _state_backend() -> StateBackend:
return MemoryState() return MemoryState()
def _mcp_sessions() -> AbstractAsyncContextManager[None]:
"""The MCP session manager's run scope, or nothing when MCP is off."""
if not settings.MCP_ENABLED:
return contextlib.nullcontext()
from app.mcp.server import mcp as mcp_server
return mcp_server.session_manager.run()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Start the flow engine alongside the API.""" """Start the flow engine alongside the API."""
@@ -52,9 +64,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.flow_controller = controller app.state.flow_controller = controller
await controller.start() await controller.start()
try: try:
# A mounted sub-app gets no lifespan of its own, so the MCP session
# manager is entered here; without it every /mcp request fails.
async with _mcp_sessions():
yield yield
finally: finally:
await controller.stop() await controller.stop()
if settings.MCP_ENABLED:
from app.mcp.http import aclose
await aclose()
app = FastAPI( app = FastAPI(
@@ -75,3 +94,54 @@ if settings.all_cors_origins:
) )
app.include_router(api_router, prefix=settings.API_V1_STR) app.include_router(api_router, prefix=settings.API_V1_STR)
# Tagged because the operation-id builder reads the first tag; the route
# itself stays out of the schema.
@app.get(
"/.well-known/oauth-authorization-server",
include_in_schema=False,
tags=["oauth"],
)
def oauth_authorization_server() -> JSONResponse:
"""RFC 8414 metadata, so an agent can find its way in unaided.
The authorization endpoint is the dashboard rather than the API: approving
a client needs a signed-in human, and the browser session lives there.
"""
issuer = settings.oauth_issuer
return JSONResponse(
content={
"issuer": issuer,
"authorization_endpoint": (
f"{settings.FRONTEND_HOST.rstrip('/')}/oauth/authorize"
),
"token_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/token",
"registration_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/register",
"jwks_uri": f"{issuer}/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": [security.MCP_SCOPE],
},
headers={"Cache-Control": "public, max-age=3600"},
)
@app.get("/.well-known/jwks.json", include_in_schema=False, tags=["oauth"])
def jwks() -> JSONResponse:
"""The public half of the MCP signing key."""
return JSONResponse(
content=security.public_jwks(),
headers={"Cache-Control": "public, max-age=3600"},
)
# Mounted last, and at the root: the SDK serves both /mcp and the protected
# resource metadata that has to sit beside it, so mounting under /mcp would put
# that metadata somewhere no client looks for it.
if settings.MCP_ENABLED:
from app.mcp.http import build_http_app
app.mount("/", build_http_app(app))
+7
View File
@@ -0,0 +1,7 @@
"""The MCP endpoint: the same flow API, in the shape an agent can drive.
``server`` holds the tools, ``http`` wires them to the streamable-HTTP
transport and the OAuth resource-server checks. Both are imported lazily, only
when ``MCP_ENABLED`` is set, so an installation that does not want an agent
endpoint does not carry one.
"""
+108
View File
@@ -0,0 +1,108 @@
"""Mounting the MCP endpoint, and deciding whose tokens it accepts.
The transport is streamable HTTP, stateless, one JSON response per call: no
sticky sessions to route and nothing for a proxy to buffer. The SDK serves both
``/mcp`` and the protected-resource metadata beside it, which is why the sub-app
is mounted at the root rather than under ``/mcp``.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
import httpx
import jwt
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from app.core import security
from app.core.config import settings
from app.mcp import server
logger = logging.getLogger(__name__)
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel.
A perfectly valid browser token is refused: it was issued for a person's
session, and honouring it here would make agent traffic indistinguishable
from theirs.
"""
async def verify_token(self, token: str) -> AccessToken | None:
try:
payload = security.decode_oauth_token(token)
except jwt.InvalidTokenError as exc:
logger.debug("MCP token rejected: %s", exc)
return None
if payload.get("mcp") is not True:
logger.debug("MCP token rejected: not an MCP-channel token")
return None
return AccessToken(
token=token,
client_id=str(payload.get("client_id", "")),
scopes=[security.MCP_SCOPE],
subject=str(payload.get("sub", "")),
)
def _transport_security() -> TransportSecuritySettings:
"""Which Host headers to trust.
The SDK defaults to localhost only and answers 421 to anything arriving
through a reverse proxy under the real hostname, so the deployment's own
host is named here rather than the protection being switched off.
"""
host = urlparse(settings.oauth_issuer).netloc
allowed_hosts = [host, f"{host}:*"]
allowed_origins = [f"https://{host}", f"http://{host}"]
if settings.ENVIRONMENT == "local":
# The test client and a direct uvicorn run do not go through Traefik.
allowed_hosts += ["localhost", "localhost:*", "127.0.0.1", "127.0.0.1:*", "testserver"]
allowed_origins += ["http://localhost", "http://127.0.0.1"]
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
)
def build_http_app(app: object) -> Starlette:
"""The MCP sub-app, wired to this app's API and its OAuth server."""
server.mcp.settings.stateless_http = True
server.mcp.settings.json_response = True
server.mcp.settings.auth = AuthSettings(
issuer_url=AnyHttpUrl(settings.oauth_issuer),
resource_server_url=AnyHttpUrl(settings.mcp_resource),
required_scopes=None,
)
server.mcp.settings.transport_security = _transport_security()
# The SDK only takes a verifier through its constructor, and the instance is
# module-level so the tools can be declared at import time.
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
server.set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [server.forward_caller_auth]},
)
)
return server.mcp.streamable_http_app()
async def aclose() -> None:
if server._client is not None: # noqa: SLF001
await server._client.aclose() # noqa: SLF001
server.set_client(None)
+240
View File
@@ -0,0 +1,240 @@
"""The tools an agent can call, each one a request to the flow API.
Tools do not reach into the engine: they call the same REST endpoints the
dashboard calls, over an in-process ASGI transport. That keeps one description
of what a flow is and how it may be changed — validation, the draft/publish
split, the version check that stops two clients overwriting each other — and it
means an agent cannot do anything a person could not do in the browser.
The caller's token rides along on every hop, so the API sees the agent's own
identity rather than some service account.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel.server import request_ctx
logger = logging.getLogger(__name__)
mcp = FastMCP("fluksio")
#: Set by ``http.build_http_app``; the ASGI client that carries tool calls into
#: the REST API without a network hop.
_client: httpx.AsyncClient | None = None
def set_client(client: httpx.AsyncClient | None) -> None:
global _client
_client = client
async def forward_caller_auth(request: httpx.Request) -> None:
"""Carry the calling agent's bearer token onto the REST call.
The transport attaches the originating HTTP request to every JSON-RPC
message, and a tool handler runs inside that request's context, so the
token is read per message rather than per connection.
"""
try:
source = request_ctx.get().request
except LookupError:
return
authorization = getattr(source, "headers", {}).get("authorization")
if authorization:
request.headers["authorization"] = authorization
async def _call(method: str, path: str, **kwargs: Any) -> Any:
"""One REST call, with failures handed back as data rather than raised.
An agent can read an error and try something else; a transport fault just
ends the conversation.
"""
if _client is None:
return {"error": "The MCP endpoint is not running."}
try:
response = await _client.request(method, f"/api/v1{path}", **kwargs)
except httpx.HTTPError as exc:
logger.exception("MCP call to %s failed", path)
return {"error": f"Could not reach the flow API: {exc}"}
if response.status_code >= 400:
detail: Any = response.text
try:
detail = response.json().get("detail", detail)
except ValueError:
pass
return {"error": detail, "status": response.status_code}
if not response.content:
return {"ok": True}
return response.json()
# -----------------------------------------------------------------------------
# Reading
# -----------------------------------------------------------------------------
@mcp.tool()
async def list_flows() -> Any:
"""Every flow, with its node count, errors, and whether it is running."""
return await _call("GET", "/flows/")
@mcp.tool()
async def get_flow(name: str) -> Any:
"""One flow: its definition, the state of its nodes, and its problems.
The definition is the working copy — unpublished edits included — which is
what to base a change on.
"""
return await _call("GET", f"/flows/{name}")
@mcp.tool()
async def list_node_types() -> Any:
"""The node types that can be placed, with their parameter schemas."""
return await _call("GET", "/flows/node-types")
@mcp.tool()
async def get_node_source(name: str, node_id: str) -> Any:
"""The Python source of one node."""
return await _call("GET", f"/flows/{name}/nodes/{node_id}/source")
@mcp.tool()
async def get_flow_state(name: str) -> Any:
"""The last value seen on every message of a flow."""
return await _call("GET", f"/flows/{name}/state")
@mcp.tool()
async def get_message_history(name: str, message: str) -> Any:
"""Recent numeric values of one message, oldest first."""
return await _call("GET", f"/flows/{name}/history/{message}")
@mcp.tool()
async def list_secrets() -> Any:
"""The names of stored secrets, for pointing a node parameter at one.
Values are never returned. Reference one from a node parameter as
``{"$secret": "<name>"}``.
"""
return await _call("GET", "/secrets/")
@mcp.tool()
async def list_shared_nodes() -> Any:
"""Node sources shared across flows, and which nodes use each."""
return await _call("GET", "/flows/library")
# -----------------------------------------------------------------------------
# Building
# -----------------------------------------------------------------------------
@mcp.tool()
async def save_flow(name: str, definition: dict[str, Any]) -> Any:
"""Save a flow as an unpublished draft.
``definition`` is a whole flow document — the shape `get_flow` returns
under ``definition``. It must carry the ``version`` that was read, so an
edit someone else made in between is refused rather than overwritten; on a
conflict, read the flow again and reapply the change.
Nothing here reaches the engine until `publish_flow`.
"""
return await _call("PUT", f"/flows/{name}", json=definition)
@mcp.tool()
async def save_node_source(name: str, node_id: str, code: str) -> Any:
"""Save a node's Python source and report whether it compiles.
A node defines ``process(...)``, taking one argument per input port plus
``params``, and returns a dict keyed by output port.
"""
return await _call(
"PUT", f"/flows/{name}/nodes/{node_id}/source", json={"code": code}
)
@mcp.tool()
async def publish_flow(name: str, version: int) -> Any:
"""Deploy a flow's unpublished changes: this is what puts them live."""
return await _call("POST", f"/flows/{name}/publish", json={"version": version})
@mcp.tool()
async def discard_draft(name: str) -> Any:
"""Throw unpublished changes away and go back to what is running."""
return await _call("POST", f"/flows/{name}/discard-draft")
@mcp.tool()
async def delete_flow(name: str) -> Any:
"""Delete a flow and the code of its nodes."""
return await _call("DELETE", f"/flows/{name}")
@mcp.tool()
async def validate_flow(name: str) -> Any:
"""What would keep this flow from running: loops, unconnected inputs."""
return await _call("POST", f"/flows/{name}/validate")
# -----------------------------------------------------------------------------
# Running
# -----------------------------------------------------------------------------
@mcp.tool()
async def run_flow(name: str, inputs: dict[str, Any] | None = None) -> Any:
"""Run every node of a flow once and return the resulting state.
With unpublished changes this runs the draft, so a change can be tried
before it is published.
"""
return await _call("POST", f"/flows/{name}/run", json={"inputs": inputs or {}})
@mcp.tool()
async def trigger_node(
name: str, node_id: str, values: dict[str, Any] | None = None
) -> Any:
"""Feed values into one node and run everything downstream of it."""
return await _call(
"POST", f"/flows/{name}/nodes/{node_id}/trigger", json={"values": values or {}}
)
@mcp.tool()
async def start_flow(name: str) -> Any:
"""Let the engine run this flow: subscriptions, schedules and webhooks."""
return await _call("POST", f"/flows/{name}/start")
@mcp.tool()
async def stop_flow(name: str) -> Any:
"""Take a flow off the engine. Nothing of it stays subscribed or scheduled."""
return await _call("POST", f"/flows/{name}/stop")
@mcp.tool()
async def pause_flow(name: str) -> Any:
"""Hold a flow's nodes while its incoming values keep arriving."""
return await _call("POST", f"/flows/{name}/pause")
@mcp.tool()
async def resume_flow(name: str) -> Any:
"""Let a paused flow carry on, running whatever was held back."""
return await _call("POST", f"/flows/{name}/resume")
+114 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pydantic import EmailStr from pydantic import EmailStr
from sqlalchemy import DateTime from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@@ -85,3 +85,116 @@ class TokenPayload(SQLModel):
class NewPassword(SQLModel): class NewPassword(SQLModel):
token: str token: str
new_password: str = Field(min_length=8, max_length=128) new_password: str = Field(min_length=8, max_length=128)
# -----------------------------------------------------------------------------
# OAuth 2.1, for the MCP endpoint
#
# Agents cannot be handed a password, so they get their own authorization flow:
# the client registers itself, a human approves it in the browser, and the code
# that comes back is exchanged for a token. Only the hash of a code or a refresh
# token is stored, so a copy of this table is not a set of credentials.
# -----------------------------------------------------------------------------
class OAuthClient(SQLModel, table=True):
"""A registered agent. Registration alone grants nothing."""
__tablename__ = "oauth_client"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
client_name: str = Field(max_length=128)
redirect_uris: list[str] = Field(sa_column=Column(JSON), default_factory=list)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), nullable=False
)
class OAuthAuthorizationCode(SQLModel, table=True):
"""One approved authorization, waiting to be exchanged for a token."""
__tablename__ = "oauth_authorization_code"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
code_hash: str = Field(max_length=64, unique=True, index=True)
client_id: uuid.UUID = Field(
foreign_key="oauth_client.id", nullable=False, ondelete="CASCADE"
)
user_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
redirect_uri: str = Field(max_length=2048)
code_challenge: str = Field(max_length=128)
resource: str | None = Field(default=None, max_length=2048)
expires_at: datetime
used_at: datetime | None = None
#: The refresh token this code produced, so replaying the code can revoke it.
refresh_token_id: uuid.UUID | None = None
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), nullable=False
)
class OAuthRefreshToken(SQLModel, table=True):
"""A rotating refresh token, one family per authorization."""
__tablename__ = "oauth_refresh_token"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
token_hash: str = Field(max_length=64, unique=True, index=True)
client_id: uuid.UUID = Field(
foreign_key="oauth_client.id", nullable=False, ondelete="CASCADE"
)
user_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
#: Every token rotated out of one authorization shares this, so reusing an
#: old one can revoke the whole line rather than just itself.
family_id: uuid.UUID
expires_at: datetime
revoked: bool = False
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), nullable=False
)
class OAuthClientRegister(SQLModel):
"""RFC 7591 dynamic client registration request."""
client_name: str = Field(default="MCP client", max_length=128)
redirect_uris: list[str]
grant_types: list[str] | None = None
response_types: list[str] | None = None
token_endpoint_auth_method: str | None = None
class OAuthClientInfo(SQLModel):
client_id: str
client_name: str
redirect_uris: list[str]
client_id_issued_at: int
grant_types: list[str] = ["authorization_code", "refresh_token"]
response_types: list[str] = ["code"]
token_endpoint_auth_method: str = "none"
class OAuthAuthorizeInfo(SQLModel):
"""What the consent page shows, all of it validated server-side."""
client_name: str
redirect_uri: str
scope: str
class OAuthAuthorizeRequest(SQLModel):
client_id: str
redirect_uri: str
code_challenge: str
code_challenge_method: str = "S256"
state: str | None = None
resource: str | None = None
scope: str | None = None
class OAuthAuthorizeResponse(SQLModel):
redirect_url: str
+4 -1
View File
@@ -2,7 +2,9 @@
name = "app" name = "app"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
requires-python = ">=3.10,<4.0" # Capped below 3.14: the MCP SDK wants a newer starlette there than the
# pinned sentry-sdk allows. Lift it when sentry-sdk moves to 2.x.
requires-python = ">=3.10,<3.14"
dependencies = [ dependencies = [
"fastapi[standard]<1.0.0,>=0.114.2", "fastapi[standard]<1.0.0,>=0.114.2",
"python-multipart<1.0.0,>=0.0.7", "python-multipart<1.0.0,>=0.0.7",
@@ -25,6 +27,7 @@ dependencies = [
"aiomqtt>=2.0.0", "aiomqtt>=2.0.0",
"influxdb-client[async]>=1.40.0", "influxdb-client[async]>=1.40.0",
"croniter>=1.3.0", "croniter>=1.3.0",
"mcp>=1.29,<2",
] ]
[dependency-groups] [dependency-groups]
+4
View File
@@ -9,3 +9,7 @@ package — keeps a test run from touching the development data.
import os import os
os.environ["POSTGRES_DB"] = "app_test" os.environ["POSTGRES_DB"] = "app_test"
# The MCP session manager can only be entered once per instance, and the suite
# builds a TestClient — and so a lifespan — per test module. Tests that want the
# endpoint mount it themselves.
os.environ["MCP_ENABLED"] = "false"
+226
View File
@@ -0,0 +1,226 @@
"""The authorization flow an agent goes through to reach the MCP endpoint."""
import base64
import hashlib
import secrets
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
PREFIX = f"{settings.API_V1_STR}/oauth"
REDIRECT = "http://127.0.0.1:41234/callback"
@pytest.fixture(autouse=True)
def mcp_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""The OAuth endpoints only answer when the MCP endpoint is wanted."""
monkeypatch.setattr(settings, "MCP_ENABLED", True)
def pkce() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def register(client: TestClient, **overrides) -> dict:
body = {"client_name": "Test agent", "redirect_uris": [REDIRECT], **overrides}
return client.post(f"{PREFIX}/register", json=body).json()
def approve(
client: TestClient, headers: dict[str, str], client_id: str, challenge: str
) -> str:
"""Walk the consent step and return the code it hands back."""
response = client.post(
f"{PREFIX}/authorize",
headers=headers,
json={
"client_id": client_id,
"redirect_uri": REDIRECT,
"code_challenge": challenge,
"code_challenge_method": "S256",
"resource": settings.mcp_resource,
},
)
assert response.status_code == 200, response.text
url = response.json()["redirect_url"]
return parse_qs(urlparse(url).query)["code"][0]
def exchange(client: TestClient, client_id: str, code: str, verifier: str):
return client.post(
f"{PREFIX}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": client_id,
"code_verifier": verifier,
},
)
def test_the_metadata_says_how_to_get_in(client: TestClient) -> None:
document = client.get("/.well-known/oauth-authorization-server").json()
assert document["issuer"] == settings.oauth_issuer
assert document["code_challenge_methods_supported"] == ["S256"]
assert document["token_endpoint_auth_methods_supported"] == ["none"]
assert set(document["grant_types_supported"]) == {
"authorization_code",
"refresh_token",
}
def test_a_full_authorization_reaches_the_api(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
tokens = exchange(client, registered["client_id"], code, verifier).json()
assert tokens["token_type"] == "Bearer"
assert tokens["scope"] == "mcp"
# The token works against the ordinary API, as the user who approved it.
whoami = client.post(
f"{settings.API_V1_STR}/login/test-token",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert whoami.status_code == 200
def test_a_wrong_verifier_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
_, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
response = exchange(client, registered["client_id"], code, secrets.token_urlsafe(48))
assert response.status_code == 400
assert response.json()["error"] == "invalid_grant"
def test_replaying_a_code_withdraws_what_it_produced(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
first = exchange(client, registered["client_id"], code, verifier).json()
replay = exchange(client, registered["client_id"], code, verifier)
assert replay.status_code == 400
assert replay.json()["error"] == "invalid_grant"
# The refresh token the first exchange handed out is no longer any good:
# the replay says someone else may be holding a copy.
refreshed = client.post(
f"{PREFIX}/token",
data={
"grant_type": "refresh_token",
"refresh_token": first["refresh_token"],
"client_id": registered["client_id"],
},
)
assert refreshed.status_code == 400
def test_a_refresh_token_rotates_and_cannot_be_reused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
verifier, challenge = pkce()
code = approve(client, superuser_token_headers, registered["client_id"], challenge)
first = exchange(client, registered["client_id"], code, verifier).json()
refresh = lambda token: client.post( # noqa: E731
f"{PREFIX}/token",
data={
"grant_type": "refresh_token",
"refresh_token": token,
"client_id": registered["client_id"],
},
)
second = refresh(first["refresh_token"])
assert second.status_code == 200
assert second.json()["refresh_token"] != first["refresh_token"]
# Presenting the spent one again is what a stolen token looks like.
assert refresh(first["refresh_token"]).status_code == 400
assert refresh(second.json()["refresh_token"]).status_code == 400
def test_an_unregistered_redirect_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
registered = register(client)
_, challenge = pkce()
response = client.post(
f"{PREFIX}/authorize",
headers=superuser_token_headers,
json={
"client_id": registered["client_id"],
"redirect_uri": "https://somewhere.else/callback",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_request"
def test_registration_refuses_plain_http_off_the_loopback(
client: TestClient,
) -> None:
response = client.post(
f"{PREFIX}/register",
json={"client_name": "Nope", "redirect_uris": ["http://example.com/cb"]},
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_redirect_uri"
def test_approving_needs_a_signed_in_user(client: TestClient) -> None:
registered = register(client)
_, challenge = pkce()
response = client.post(
f"{PREFIX}/authorize",
json={
"client_id": registered["client_id"],
"redirect_uri": REDIRECT,
"code_challenge": challenge,
"code_challenge_method": "S256",
},
)
assert response.status_code == 401
def test_an_unsupported_grant_says_so(client: TestClient) -> None:
response = client.post(
f"{PREFIX}/token", data={"grant_type": "client_credentials"}
)
assert response.status_code == 400
assert response.json()["error"] == "unsupported_grant_type"
def test_everything_is_refused_while_mcp_is_off(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "MCP_ENABLED", False)
assert client.post(f"{PREFIX}/register", json={"redirect_uris": [REDIRECT]}).status_code == 403
assert (
client.post(f"{PREFIX}/token", data={"grant_type": "authorization_code"}).status_code
== 403
)
View File
+123
View File
@@ -0,0 +1,123 @@
"""The MCP endpoint: who it lets in, and what happens once they are in."""
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from datetime import timedelta
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
from app.core import security
from app.core.config import settings
from app.main import app
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
def mcp_token(user_id: uuid.UUID) -> str:
return security.create_oauth_access_token(
user_id, uuid.uuid4(), timedelta(minutes=5)
)
@pytest.fixture
def over_mcp(monkeypatch: pytest.MonkeyPatch):
"""Run a block of calls against a live MCP endpoint.
The session manager and the requests have to share one event loop, so the
whole exchange happens inside a single ``asyncio.run``.
"""
monkeypatch.setattr(settings, "MCP_ENABLED", True)
from app.mcp import http as mcp_http
from app.mcp import server as mcp_server
# A FastMCP instance enters its session manager once, and another test in
# the run may have built one already.
monkeypatch.setattr(mcp_server.mcp, "_session_manager", None, raising=False)
mcp_app = mcp_http.build_http_app(app)
def run(block: Callable[[httpx.AsyncClient], Awaitable[Any]]) -> Any:
async def main() -> Any:
async with mcp_server.mcp.session_manager.run():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_app),
base_url="http://api.localhost",
) as client:
return await block(client)
return asyncio.run(main())
return run
def rpc(token: str | None, method: str, **params: Any) -> dict[str, Any]:
headers = dict(MCP_HEADERS)
if token:
headers["Authorization"] = f"Bearer {token}"
body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": method}
if params:
body["params"] = params
return {"json": body, "headers": headers}
def test_an_unauthenticated_call_says_where_to_authenticate(over_mcp) -> None:
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.post("/mcp", **rpc(None, "tools/list"))
response = over_mcp(block)
assert response.status_code == 401
assert "resource_metadata=" in response.headers.get("www-authenticate", "")
def test_the_resource_metadata_names_the_authorization_server(over_mcp) -> None:
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.get("/.well-known/oauth-protected-resource/mcp")
document = over_mcp(block).json()
# The SDK normalises the URL, so compare without the trailing slash.
listed = [url.rstrip("/") for url in document["authorization_servers"]]
assert settings.oauth_issuer in listed
def test_a_browser_token_is_not_an_agent_token(
over_mcp, superuser_token_headers: dict[str, str]
) -> None:
session_token = superuser_token_headers["Authorization"].removeprefix("Bearer ")
async def block(client: httpx.AsyncClient) -> httpx.Response:
return await client.post("/mcp", **rpc(session_token, "tools/list"))
# It validates perfectly well against the API; it is refused here because
# it was issued for a person's session, not for an agent.
assert over_mcp(block).status_code == 401
def test_an_agent_can_list_and_call_tools(
over_mcp, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
me = client.get(
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
).json()
token = mcp_token(uuid.UUID(me["id"]))
async def block(http: httpx.AsyncClient) -> tuple[Any, Any]:
listed = await http.post("/mcp", **rpc(token, "tools/list"))
called = await http.post(
"/mcp",
**rpc(token, "tools/call", name="list_flows", arguments={}),
)
return listed, called
listed, called = over_mcp(block)
assert listed.status_code == 200
names = {tool["name"] for tool in listed.json()["result"]["tools"]}
assert {"list_flows", "save_flow", "publish_flow", "run_flow"} <= names
# The call reached the real API, carrying the agent's own token.
assert called.status_code == 200
assert "error" not in called.json()
assert called.json()["result"]["isError"] is False
+5
View File
@@ -145,6 +145,11 @@ services:
# Flows are files in a git repository; secrets sit encrypted beside it. # Flows are files in a git repository; secrets sit encrypted beside it.
- FLOWS_DIR=/data/flows - FLOWS_DIR=/data/flows
- SECRETS_FILE=/data/secrets.enc - SECRETS_FILE=/data/secrets.enc
# The MCP endpoint for agents, and the key its tokens are signed with.
# The key lives on the same volume as the flows, so it survives a rebuild
# and every issued token with it.
- MCP_ENABLED=${MCP_ENABLED-false}
- OAUTH_PRIVATE_KEY_FILE=/data/oauth-key.pem
volumes: volumes:
- app-flow-data:/data - app-flow-data:/data
+230
View File
@@ -57,6 +57,84 @@ export const Body_login_login_access_tokenSchema = {
title: 'Body_login-login_access_token' title: 'Body_login-login_access_token'
} as const; } as const;
export const Body_oauth_tokenSchema = {
properties: {
grant_type: {
type: 'string',
title: 'Grant Type'
},
code: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Code'
},
redirect_uri: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Redirect Uri'
},
client_id: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Client Id'
},
code_verifier: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Code Verifier'
},
refresh_token: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Refresh Token'
},
resource: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Resource'
}
},
type: 'object',
required: ['grant_type'],
title: 'Body_oauth-token'
} as const;
export const DTypeSchema = { export const DTypeSchema = {
type: 'string', type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json'], enum: ['float', 'int', 'str', 'bool', 'json'],
@@ -702,6 +780,158 @@ export const NodeTypeInfoSchema = {
description: 'A node type the editor can offer, with its parameter schema.' description: 'A node type the editor can offer, with its parameter schema.'
} as const; } as const;
export const OAuthAuthorizeInfoSchema = {
properties: {
client_name: {
type: 'string',
title: 'Client Name'
},
redirect_uri: {
type: 'string',
title: 'Redirect Uri'
},
scope: {
type: 'string',
title: 'Scope'
}
},
type: 'object',
required: ['client_name', 'redirect_uri', 'scope'],
title: 'OAuthAuthorizeInfo',
description: 'What the consent page shows, all of it validated server-side.'
} as const;
export const OAuthAuthorizeRequestSchema = {
properties: {
client_id: {
type: 'string',
title: 'Client Id'
},
redirect_uri: {
type: 'string',
title: 'Redirect Uri'
},
code_challenge: {
type: 'string',
title: 'Code Challenge'
},
code_challenge_method: {
type: 'string',
title: 'Code Challenge Method',
default: 'S256'
},
state: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'State'
},
resource: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Resource'
},
scope: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Scope'
}
},
type: 'object',
required: ['client_id', 'redirect_uri', 'code_challenge'],
title: 'OAuthAuthorizeRequest'
} as const;
export const OAuthAuthorizeResponseSchema = {
properties: {
redirect_url: {
type: 'string',
title: 'Redirect Url'
}
},
type: 'object',
required: ['redirect_url'],
title: 'OAuthAuthorizeResponse'
} as const;
export const OAuthClientRegisterSchema = {
properties: {
client_name: {
type: 'string',
maxLength: 128,
title: 'Client Name',
default: 'MCP client'
},
redirect_uris: {
items: {
type: 'string'
},
type: 'array',
title: 'Redirect Uris'
},
grant_types: {
anyOf: [
{
items: {
type: 'string'
},
type: 'array'
},
{
type: 'null'
}
],
title: 'Grant Types'
},
response_types: {
anyOf: [
{
items: {
type: 'string'
},
type: 'array'
},
{
type: 'null'
}
],
title: 'Response Types'
},
token_endpoint_auth_method: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Token Endpoint Auth Method'
}
},
type: 'object',
required: ['redirect_uris'],
title: 'OAuthClientRegister',
description: 'RFC 7591 dynamic client registration request.'
} as const;
export const PositionSchema = { export const PositionSchema = {
properties: { properties: {
x: { x: {
+89 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class FlowsService { export class FlowsService {
/** /**
@@ -615,6 +615,94 @@ export class LoginService {
} }
} }
export class OauthService {
/**
* Register Client
* RFC 7591 dynamic client registration.
*
* Open on purpose, and harmless on its own: a registered client can do
* nothing until a signed-in human approves it on the consent page.
* @param data The data for the request.
* @param data.requestBody
* @returns unknown Successful Response
* @throws ApiError
*/
public static registerClient(data: OauthRegisterClientData): CancelablePromise<OauthRegisterClientResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/oauth/register',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Authorize Validate
* What the consent page should say, checked before it says it.
* @param data The data for the request.
* @param data.clientId
* @param data.redirectUri
* @returns OAuthAuthorizeInfo Successful Response
* @throws ApiError
*/
public static authorizeValidate(data: OauthAuthorizeValidateData): CancelablePromise<OauthAuthorizeValidateResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/oauth/authorize/validate',
query: {
client_id: data.clientId,
redirect_uri: data.redirectUri
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Authorize
* Approve a client, on behalf of the signed-in user.
* @param data The data for the request.
* @param data.requestBody
* @returns OAuthAuthorizeResponse Successful Response
* @throws ApiError
*/
public static authorize(data: OauthAuthorizeData): CancelablePromise<OauthAuthorizeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/oauth/authorize',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Token
* Exchange a code, or a refresh token, for an access token.
* @param data The data for the request.
* @param data.formData
* @returns unknown Successful Response
* @throws ApiError
*/
public static token(data: OauthTokenData): CancelablePromise<OauthTokenResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/oauth/token',
formData: data.formData,
mediaType: 'application/x-www-form-urlencoded',
errors: {
422: 'Validation Error'
}
});
}
}
export class PrivateService { export class PrivateService {
/** /**
* Create User * Create User
+69
View File
@@ -9,6 +9,16 @@ export type Body_login_login_access_token = {
client_secret?: (string | null); client_secret?: (string | null);
}; };
export type Body_oauth_token = {
grant_type: string;
code?: (string | null);
redirect_uri?: (string | null);
client_id?: (string | null);
code_verifier?: (string | null);
refresh_token?: (string | null);
resource?: (string | null);
};
/** /**
* Serializable payload types. * Serializable payload types.
* *
@@ -228,6 +238,40 @@ export type NodeTypeInfo = {
plugin?: (string | null); plugin?: (string | null);
}; };
/**
* What the consent page shows, all of it validated server-side.
*/
export type OAuthAuthorizeInfo = {
client_name: string;
redirect_uri: string;
scope: string;
};
export type OAuthAuthorizeRequest = {
client_id: string;
redirect_uri: string;
code_challenge: string;
code_challenge_method?: string;
state?: (string | null);
resource?: (string | null);
scope?: (string | null);
};
export type OAuthAuthorizeResponse = {
redirect_url: string;
};
/**
* RFC 7591 dynamic client registration request.
*/
export type OAuthClientRegister = {
client_name?: string;
redirect_uris: Array<(string)>;
grant_types?: (Array<(string)> | null);
response_types?: (Array<(string)> | null);
token_endpoint_auth_method?: (string | null);
};
/** /**
* Where a node sits on the canvas. * Where a node sits on the canvas.
*/ */
@@ -517,6 +561,31 @@ export type LoginRecoverPasswordHtmlContentData = {
export type LoginRecoverPasswordHtmlContentResponse = (string); export type LoginRecoverPasswordHtmlContentResponse = (string);
export type OauthRegisterClientData = {
requestBody: OAuthClientRegister;
};
export type OauthRegisterClientResponse = (unknown);
export type OauthAuthorizeValidateData = {
clientId: string;
redirectUri: string;
};
export type OauthAuthorizeValidateResponse = (OAuthAuthorizeInfo);
export type OauthAuthorizeData = {
requestBody: OAuthAuthorizeRequest;
};
export type OauthAuthorizeResponse = (OAuthAuthorizeResponse);
export type OauthTokenData = {
formData: Body_oauth_token;
};
export type OauthTokenResponse = (unknown);
export type PrivateCreateUserData = { export type PrivateCreateUserData = {
requestBody: PrivateUserCreate; requestBody: PrivateUserCreate;
}; };
+14 -1
View File
@@ -15,6 +15,17 @@ const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null return localStorage.getItem("access_token") !== null
} }
/**
* Where to go after signing in.
*
* Only a path on this origin: an open redirect here would let a link take
* someone through a real login and land them somewhere else entirely.
*/
export function safeRedirect(target: string | undefined): string {
if (!target || !target.startsWith("/") || target.startsWith("//")) return "/"
return target
}
const useAuth = () => { const useAuth = () => {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -48,7 +59,9 @@ const useAuth = () => {
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: login, mutationFn: login,
onSuccess: () => { onSuccess: () => {
navigate({ to: "/" }) // The consent page sends people here with where to come back to.
const target = new URLSearchParams(window.location.search).get("redirect")
navigate({ to: safeRedirect(target ?? undefined) })
}, },
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
}) })
+21
View File
@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as CanvasRouteImport } from './routes/_canvas' import { Route as CanvasRouteImport } from './routes/_canvas'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index' import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
@@ -54,6 +55,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
id: '/oauth/authorize',
path: '/oauth/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
id: '/settings', id: '/settings',
path: '/settings', path: '/settings',
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/flows/': typeof CanvasFlowsIndexRoute '/flows/': typeof CanvasFlowsIndexRoute
} }
@@ -94,6 +101,7 @@ export interface FileRoutesByTo {
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/flows': typeof CanvasFlowsIndexRoute '/flows': typeof CanvasFlowsIndexRoute
} }
@@ -107,6 +115,7 @@ export interface FileRoutesById {
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute '/_layout/admin': typeof LayoutAdminRoute
'/_layout/settings': typeof LayoutSettingsRoute '/_layout/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/_layout/': typeof LayoutIndexRoute '/_layout/': typeof LayoutIndexRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_canvas/flows/': typeof CanvasFlowsIndexRoute '/_canvas/flows/': typeof CanvasFlowsIndexRoute
@@ -121,6 +130,7 @@ export interface FileRouteTypes {
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/settings' | '/settings'
| '/oauth/authorize'
| '/flows/$flowName' | '/flows/$flowName'
| '/flows/' | '/flows/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -132,6 +142,7 @@ export interface FileRouteTypes {
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/settings' | '/settings'
| '/oauth/authorize'
| '/flows/$flowName' | '/flows/$flowName'
| '/flows' | '/flows'
id: id:
@@ -144,6 +155,7 @@ export interface FileRouteTypes {
| '/signup' | '/signup'
| '/_layout/admin' | '/_layout/admin'
| '/_layout/settings' | '/_layout/settings'
| '/oauth/authorize'
| '/_layout/' | '/_layout/'
| '/_canvas/flows/$flowName' | '/_canvas/flows/$flowName'
| '/_canvas/flows/' | '/_canvas/flows/'
@@ -156,6 +168,7 @@ export interface RootRouteChildren {
RecoverPasswordRoute: typeof RecoverPasswordRoute RecoverPasswordRoute: typeof RecoverPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
SignupRoute: typeof SignupRoute SignupRoute: typeof SignupRoute
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -209,6 +222,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutIndexRouteImport preLoaderRoute: typeof LayoutIndexRouteImport
parentRoute: typeof LayoutRoute parentRoute: typeof LayoutRoute
} }
'/oauth/authorize': {
id: '/oauth/authorize'
path: '/oauth/authorize'
fullPath: '/oauth/authorize'
preLoaderRoute: typeof OauthAuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout/settings': { '/_layout/settings': {
id: '/_layout/settings' id: '/_layout/settings'
path: '/settings' path: '/settings'
@@ -275,6 +295,7 @@ const rootRouteChildren: RootRouteChildren = {
RecoverPasswordRoute: RecoverPasswordRoute, RecoverPasswordRoute: RecoverPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
SignupRoute: SignupRoute, SignupRoute: SignupRoute,
OauthAuthorizeRoute: OauthAuthorizeRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+6 -3
View File
@@ -20,7 +20,7 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button" import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input"
import useAuth, { isLoggedIn } from "@/hooks/useAuth" import useAuth, { isLoggedIn, safeRedirect } from "@/hooks/useAuth"
const formSchema = z.object({ const formSchema = z.object({
username: z.email(), username: z.email(),
@@ -34,10 +34,13 @@ type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
component: Login, component: Login,
beforeLoad: async () => { // Optional, so every other `navigate({ to: "/login" })` stays as it was.
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === "string" ? { redirect: search.redirect } : {},
beforeLoad: async ({ search }) => {
if (isLoggedIn()) { if (isLoggedIn()) {
throw redirect({ throw redirect({
to: "/", to: safeRedirect(search.redirect),
}) })
} }
}, },
+147
View File
@@ -0,0 +1,147 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { AlertTriangle } from "lucide-react"
import { OauthService } from "@/client"
import { AuthLayout } from "@/components/Common/AuthLayout"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { LoadingButton } from "@/components/ui/loading-button"
import { Skeleton } from "@/components/ui/skeleton"
import { isLoggedIn } from "@/hooks/useAuth"
/**
* Where an agent sends someone to be let in.
*
* Everything shown here is checked by the server first, and the URL the browser
* is finally sent to is the one the server hands back — never the one in the
* address bar — so a doctored link cannot redirect an approval somewhere else.
*/
export const Route = createFileRoute("/oauth/authorize")({
component: Authorize,
validateSearch: (search: Record<string, unknown>) => ({
client_id: String(search.client_id ?? ""),
redirect_uri: String(search.redirect_uri ?? ""),
code_challenge: String(search.code_challenge ?? ""),
code_challenge_method: String(search.code_challenge_method ?? "S256"),
state: search.state ? String(search.state) : undefined,
resource: search.resource ? String(search.resource) : undefined,
}),
beforeLoad: ({ location }) => {
if (!isLoggedIn()) {
throw redirect({
to: "/login",
search: { redirect: location.href },
})
}
},
head: () => ({
meta: [{ title: "Authorize - Fluksio" }],
}),
})
function Authorize() {
const search = Route.useSearch()
const {
data: info,
isPending,
error,
} = useQuery({
queryKey: ["oauth", "authorize", search.client_id, search.redirect_uri],
queryFn: () =>
OauthService.authorizeValidate({
clientId: search.client_id,
redirectUri: search.redirect_uri,
}),
retry: false,
})
const approve = useMutation({
mutationFn: () =>
OauthService.authorize({
requestBody: {
client_id: search.client_id,
redirect_uri: search.redirect_uri,
code_challenge: search.code_challenge,
code_challenge_method: search.code_challenge_method,
state: search.state,
resource: search.resource,
},
}),
onSuccess: (response) => {
// The server's URL, built from the registered redirect.
window.location.assign(response.redirect_url)
},
})
const deny = () => {
if (!info) return
const url = new URL(info.redirect_uri)
url.searchParams.set("error", "access_denied")
if (search.state) url.searchParams.set("state", search.state)
window.location.assign(url.toString())
}
return (
<AuthLayout>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Authorize access</h1>
</div>
{isPending ? (
<div className="grid gap-3">
<Skeleton className="h-5 w-56" />
<Skeleton className="h-5 w-40" />
</div>
) : error || !info ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertDescription>
This authorization request is not valid. Start it again from the
application that sent you here.
</AlertDescription>
</Alert>
) : (
<>
<p className="text-center text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{info.client_name}
</span>{" "}
wants to read and change your flows, and to run them. It will act
as you. Only allow this if you started it.
</p>
<div className="grid gap-2">
<LoadingButton
loading={approve.isPending}
onClick={() => approve.mutate()}
data-testid="approve-client"
>
Allow
</LoadingButton>
<Button
variant="ghost"
onClick={deny}
disabled={approve.isPending}
data-testid="deny-client"
>
Cancel
</Button>
</div>
{approve.isError ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertDescription>
That did not work. Start again from the application.
</AlertDescription>
</Alert>
) : null}
</>
)}
</div>
</AuthLayout>
)
}
Generated
+282 -535
View File
File diff suppressed because it is too large Load Diff