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
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:
co-authored by
Claude Fable 5
parent
3724b68f23
commit
8d82d6c4ec
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user