Add OAuth server implementation and Docker Compose configuration
- Introduced a new `compose.yaml` file to define the Docker service for the OAuth server. - Created `oauth.py` to implement a single-user OAuth authorization server with PKCE, SQLite persistence, and token management. - Added necessary database schema for OAuth clients, flows, codes, tokens, and login failures. - Implemented login routes with CSRF protection and error handling. - Updated `requirements.txt` to include dependencies for the OAuth server.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
secrets
|
||||||
|
data
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
secrets/
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --system --uid 10001 --create-home --home-dir /home/app app \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown app:app /data
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=app:app app.py oauth.py ./
|
||||||
|
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "umask 077 && exec uvicorn app:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*"]
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# WebDAV Notes MCP
|
||||||
|
|
||||||
|
A Dockerized MCP server that lets ChatGPT browse, search, and read an Obsidian vault stored on WebDAV. The server is fixed to one vault root (`/Notes/Personal`) and uses HTTPS Basic authentication to access `dav.ryankegel.com` from inside the container.
|
||||||
|
|
||||||
|
It uses Streamable HTTP for remote MCP connections. ChatGPT's custom MCP connection supports OAuth authentication; this project includes a small, single-user OAuth login flow so the MCP endpoint does not have to be public and anonymous. The SDK serves the MCP authorization discovery and token routes. See [ChatGPT custom MCP server setup](https://developers.openai.com/api/docs/guides/custom-mcp-server) and the [MCP Python SDK authorization guide](https://py.sdk.modelcontextprotocol.io/run/authorization/).
|
||||||
|
|
||||||
|
The local OAuth login is intended for one trusted user and has password protection, CSRF checks, and rate limiting. It does not provide MFA or identity-provider federation. The MCP login password must be at least 16 bytes; use a unique passphrase.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
Read-only tools are available by default:
|
||||||
|
|
||||||
|
- `list_directory(path)` lists the immediate children of a vault folder. Use an empty path for the vault root.
|
||||||
|
- `read_file(path)` reads a UTF-8 text or Markdown file and returns its ETag. Files are limited to 256 KiB.
|
||||||
|
- `search_files(query, path, limit)` searches file and folder paths recursively.
|
||||||
|
- `search_notes(query, path, limit)` searches text in Markdown and `.txt` files recursively.
|
||||||
|
|
||||||
|
When `MCP_READ_ONLY=false`, the server also exposes:
|
||||||
|
|
||||||
|
- `write_file(path, content)` creates a new note. It will not replace an existing file by default.
|
||||||
|
- `write_file(path, content, overwrite=true, expected_etag=...)` replaces a note only when its current ETag matches the value returned by `read_file`.
|
||||||
|
- `create_directory(path)` creates one folder; its parent must already exist.
|
||||||
|
|
||||||
|
There is no delete or move tool. Read and search calls are capped to keep large vaults and responses bounded. Recursive searches visit at most 500 directories and inspect at most 1,500 text files.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Use a dedicated public hostname such as `mcp.ryankegel.com` and terminate HTTPS at your existing reverse proxy. Docker binds the app to `127.0.0.1` by default, so port 8000 is not directly exposed to the internet.
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`. Set `MCP_PUBLIC_URL`, `MCP_ALLOWED_HOSTS`, `WEBDAV_USERNAME`, and `MCP_AUTH_USERNAME`. The public URL must be the HTTPS origin only, without `/mcp`.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
MCP_PUBLIC_URL=https://mcp.ryankegel.com
|
||||||
|
MCP_ALLOWED_HOSTS=mcp.ryankegel.com,mcp.ryankegel.com:*
|
||||||
|
WEBDAV_USERNAME=your-webdav-username
|
||||||
|
MCP_AUTH_USERNAME=ryan
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the Docker secret files. Keep both passwords out of `.env` and source control. On the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p secrets
|
||||||
|
chmod 700 secrets
|
||||||
|
|
||||||
|
read -rsp 'WebDAV password: ' DAV_SECRET
|
||||||
|
printf '%s' "$DAV_SECRET" > secrets/webdav_password
|
||||||
|
unset DAV_SECRET
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
read -rsp 'New MCP login password: ' MCP_SECRET
|
||||||
|
printf '%s' "$MCP_SECRET" > secrets/mcp_auth_password
|
||||||
|
unset MCP_SECRET
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
# Compose file-backed secrets keep the host file's group and permissions.
|
||||||
|
# The container runs as uid/gid 10001, so give that group read access.
|
||||||
|
sudo chgrp 10001 secrets/webdav_password secrets/mcp_auth_password
|
||||||
|
chmod 640 secrets/webdav_password secrets/mcp_auth_password
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose mounts file-backed secrets without remapping their ownership and mode, so the group-readable permission above lets the non-root container user read them while keeping the files private from other users on the server. See the [Compose secrets reference](https://docs.docker.com/reference/compose-file/services/#secrets).
|
||||||
|
|
||||||
|
The MCP login password is separate from your WebDAV password. ChatGPT users enter the MCP login credentials on the OAuth consent page. The WebDAV credential remains server-side.
|
||||||
|
|
||||||
|
3. Start the container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f dav-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Point your reverse proxy to `127.0.0.1:8000`, preserving the incoming `Host` header. Example Caddy site:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
mcp.ryankegel.com {
|
||||||
|
reverse_proxy 127.0.0.1:8000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP endpoint is `https://mcp.ryankegel.com/mcp`. The OAuth discovery and login endpoints share that hostname. The MCP allowed-host setting also prevents requests to unexpected hostnames.
|
||||||
|
|
||||||
|
5. In ChatGPT on the web, add a custom MCP server with that `/mcp` URL and choose OAuth authentication. The server supports Dynamic Client Registration (DCR). On the first authorization, sign in with `MCP_AUTH_USERNAME` and the password stored in `secrets/mcp_auth_password`, then approve the requested vault access.
|
||||||
|
|
||||||
|
## Enable writes
|
||||||
|
|
||||||
|
Keep `MCP_READ_ONLY=true` until you want write tools. To enable them:
|
||||||
|
|
||||||
|
1. Confirm the WebDAV account has only the permissions you want ChatGPT to have. Restrict it to the notes vault if your WebDAV provider supports that.
|
||||||
|
2. Set `MCP_READ_ONLY=false` in `.env` and restart with `docker compose up -d --build`.
|
||||||
|
3. Refresh or recreate the MCP connection in ChatGPT so it discovers the new tools, then reconnect and grant the write scope.
|
||||||
|
|
||||||
|
The write tools still require an OAuth token carrying `notes:write`. Replacing an existing file also requires `overwrite=true` and the exact ETag returned by `read_file`; this reduces the chance of replacing a newer edit.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `WEBDAV_URL` | `https://dav.ryankegel.com` | WebDAV origin or base path |
|
||||||
|
| `WEBDAV_ROOT` | `/Notes/Personal` | The only subtree exposed to MCP tools |
|
||||||
|
| `WEBDAV_USERNAME` | — | WebDAV username |
|
||||||
|
| `WEBDAV_PASSWORD_FILE` | Docker secret | Path to WebDAV password file |
|
||||||
|
| `MCP_PUBLIC_URL` | — | Public HTTPS origin serving the MCP endpoint and OAuth login |
|
||||||
|
| `MCP_ALLOWED_HOSTS` | — | Comma-separated Host values allowed by MCP transport security |
|
||||||
|
| `MCP_ALLOWED_ORIGINS` | empty | Optional comma-separated browser origins allowed by MCP transport security |
|
||||||
|
| `MCP_AUTH_USERNAME` | — | Username for the local OAuth login page |
|
||||||
|
| `MCP_AUTH_PASSWORD_FILE` | Docker secret | Path to the local OAuth login password file |
|
||||||
|
| `MCP_READ_ONLY` | `true` | Hides write tools and disallows the write scope when true |
|
||||||
|
| `MCP_BIND_ADDRESS` | `127.0.0.1` | Host interface for the published Docker port |
|
||||||
|
| `MCP_HOST_PORT` | `8000` | Host port forwarded to the container |
|
||||||
|
|
||||||
|
OAuth clients, authorization codes, and refresh tokens are stored in the `oauth-data` Docker volume. The volume contains credentials and should be backed up and protected like other application data.
|
||||||
|
|
||||||
|
## ChatGPT availability
|
||||||
|
|
||||||
|
Custom MCP servers are connected from ChatGPT on the web. OpenAI's current guide says Pro accounts can use custom servers for read/fetch, while full MCP support including write actions is available on Business, Enterprise, and Edu. Workspace settings also control who can add them. Check [OpenAI's current connection guide](https://help.openai.com/en/articles/12584461-connecting-custom-mcp-servers-in-chatgpt) for current account requirements.
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
"""WebDAV-backed MCP server, scoped to one configured vault directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from collections import deque
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, unquote, urljoin, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
|
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
||||||
|
from mcp.server.auth.routes import create_protected_resource_routes
|
||||||
|
from mcp.server.transport_security import RequestBodyLimitMiddleware, TransportSecuritySettings
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
|
from oauth import LocalOAuthProvider, make_login_routes
|
||||||
|
|
||||||
|
|
||||||
|
DAV_NS = "{DAV:}"
|
||||||
|
MAX_FILE_BYTES = 256 * 1024
|
||||||
|
MAX_PROPFIND_BYTES = 2 * 1024 * 1024
|
||||||
|
MAX_SEARCH_DIRECTORIES = 500
|
||||||
|
MAX_SEARCH_FILES = 1500
|
||||||
|
HTTP_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _secret(name: str, *, required: bool = True) -> str:
|
||||||
|
file_name = os.getenv(f"{name}_FILE")
|
||||||
|
if file_name:
|
||||||
|
try:
|
||||||
|
value = Path(file_name).read_bytes().rstrip(b"\r\n").decode("utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot read secret file configured by {name}_FILE") from exc
|
||||||
|
else:
|
||||||
|
value = os.getenv(name, "")
|
||||||
|
if required and not value:
|
||||||
|
raise RuntimeError(f"Set {name} or {name}_FILE before starting the service")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _https_origin(name: str, default: str) -> str:
|
||||||
|
value = os.getenv(name, default).strip().rstrip("/")
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
|
||||||
|
raise RuntimeError(f"{name} must use HTTPS")
|
||||||
|
if not parsed.scheme or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise RuntimeError(f"{name} must be an absolute URL without credentials, query, or fragment")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
WEBDAV_URL = _https_origin("WEBDAV_URL", "https://dav.ryankegel.com")
|
||||||
|
WEBDAV_USERNAME = os.getenv("WEBDAV_USERNAME", "").strip()
|
||||||
|
WEBDAV_PASSWORD = _secret("WEBDAV_PASSWORD")
|
||||||
|
if not WEBDAV_USERNAME:
|
||||||
|
raise RuntimeError("Set WEBDAV_USERNAME before starting the service")
|
||||||
|
|
||||||
|
WEBDAV_ROOT = os.getenv("WEBDAV_ROOT", "/Notes/Personal")
|
||||||
|
if not WEBDAV_ROOT.startswith("/"):
|
||||||
|
raise RuntimeError("WEBDAV_ROOT must be an absolute WebDAV path, for example /Notes/Personal")
|
||||||
|
_root_raw = WEBDAV_ROOT.strip("/")
|
||||||
|
_root_parts = _root_raw.split("/") if _root_raw else []
|
||||||
|
if not _root_parts or any(part in {"", ".", ".."} for part in _root_parts):
|
||||||
|
raise RuntimeError("WEBDAV_ROOT must be a non-root path without dot segments")
|
||||||
|
for _part in _root_parts:
|
||||||
|
_decoded_part = _part
|
||||||
|
for _ in range(4):
|
||||||
|
_decoded_part = unquote(_decoded_part)
|
||||||
|
if _decoded_part in {".", ".."} or "/" in _decoded_part or "\\" in _decoded_part:
|
||||||
|
raise RuntimeError("WEBDAV_ROOT cannot contain encoded path separators or dot segments")
|
||||||
|
if _decoded_part == _part:
|
||||||
|
break
|
||||||
|
|
||||||
|
MCP_PUBLIC_URL = _https_origin("MCP_PUBLIC_URL", "https://mcp.example.com")
|
||||||
|
if urlsplit(MCP_PUBLIC_URL).path not in {"", "/"}:
|
||||||
|
raise RuntimeError("MCP_PUBLIC_URL must be the HTTPS origin only, without a path")
|
||||||
|
MCP_RESOURCE_URL = f"{MCP_PUBLIC_URL}/mcp"
|
||||||
|
MCP_AUTH_USERNAME = os.getenv("MCP_AUTH_USERNAME", "").strip()
|
||||||
|
MCP_AUTH_PASSWORD = _secret("MCP_AUTH_PASSWORD")
|
||||||
|
if not MCP_AUTH_USERNAME:
|
||||||
|
raise RuntimeError("Set MCP_AUTH_USERNAME before starting the service")
|
||||||
|
if len(MCP_AUTH_PASSWORD.encode("utf-8")) < 16:
|
||||||
|
raise RuntimeError("MCP_AUTH_PASSWORD must contain at least 16 bytes")
|
||||||
|
|
||||||
|
MCP_ALLOWED_HOSTS = [item.strip() for item in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if item.strip()]
|
||||||
|
if not MCP_ALLOWED_HOSTS:
|
||||||
|
raise RuntimeError("Set MCP_ALLOWED_HOSTS to the public MCP host name")
|
||||||
|
MCP_ALLOWED_ORIGINS = [item.strip() for item in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if item.strip()]
|
||||||
|
READ_ONLY = os.getenv("MCP_READ_ONLY", "true").strip().lower() not in {"0", "false", "no"}
|
||||||
|
DATABASE_PATH = os.getenv("OAUTH_DATABASE_PATH", "/data/oauth.sqlite3")
|
||||||
|
OAUTH_SCOPES = ["notes:read"] + ([] if READ_ONLY else ["notes:write"])
|
||||||
|
|
||||||
|
_root_encoded = "/".join(quote(part, safe="-._~") for part in _root_parts)
|
||||||
|
_WEBDAV_ROOT_URL = f"{WEBDAV_URL.rstrip('/')}/{_root_encoded}"
|
||||||
|
_DAV_ORIGIN = urlsplit(WEBDAV_URL)
|
||||||
|
_ROOT_PATH_UNQUOTED = unquote(urlsplit(_WEBDAV_ROOT_URL).path).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_relative_path(path: str, *, allow_empty: bool = True) -> list[str]:
|
||||||
|
if not isinstance(path, str):
|
||||||
|
raise WebDAVError("Path must be text")
|
||||||
|
if len(path) > 2048:
|
||||||
|
raise WebDAVError("Path is too long")
|
||||||
|
if path.startswith("/") or "\\" in path or "\x00" in path:
|
||||||
|
raise WebDAVError("Use a relative path inside the configured vault")
|
||||||
|
raw = path[:-1] if path.endswith("/") else path
|
||||||
|
if not raw:
|
||||||
|
if allow_empty:
|
||||||
|
return []
|
||||||
|
raise WebDAVError("A file path is required")
|
||||||
|
parts = raw.split("/")
|
||||||
|
if any(not part for part in parts):
|
||||||
|
raise WebDAVError("Path cannot contain empty segments")
|
||||||
|
for part in parts:
|
||||||
|
decoded = part
|
||||||
|
for _ in range(4):
|
||||||
|
decoded = unquote(decoded)
|
||||||
|
if decoded in {".", ".."} or "/" in decoded or "\\" in decoded:
|
||||||
|
raise WebDAVError("Path cannot escape the configured vault")
|
||||||
|
if decoded == part:
|
||||||
|
break
|
||||||
|
if any(ord(char) < 32 or ord(char) == 127 for char in decoded):
|
||||||
|
raise WebDAVError("Path cannot contain control characters")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _path_url(path: str, *, collection: bool = False) -> str:
|
||||||
|
parts = _clean_relative_path(path)
|
||||||
|
encoded = "/".join(quote(part, safe="-._~") for part in parts)
|
||||||
|
url = _WEBDAV_ROOT_URL + (f"/{encoded}" if encoded else "")
|
||||||
|
if collection:
|
||||||
|
return url.rstrip("/") + "/"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _same_origin(url: str) -> bool:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
try:
|
||||||
|
base_port = _DAV_ORIGIN.port or (443 if _DAV_ORIGIN.scheme.lower() == "https" else 80)
|
||||||
|
parsed_port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
parsed.scheme.lower() == _DAV_ORIGIN.scheme.lower()
|
||||||
|
and (parsed.hostname or "").lower() == (_DAV_ORIGIN.hostname or "").lower()
|
||||||
|
and parsed_port == base_port
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _href_relative(href: str) -> str | None:
|
||||||
|
absolute = urljoin(WEBDAV_URL.rstrip("/") + "/", href)
|
||||||
|
parsed = urlsplit(absolute)
|
||||||
|
if not _same_origin(absolute) or parsed.query or parsed.fragment:
|
||||||
|
return None
|
||||||
|
candidate = unquote(parsed.path).rstrip("/")
|
||||||
|
if candidate == _ROOT_PATH_UNQUOTED:
|
||||||
|
return ""
|
||||||
|
prefix = _ROOT_PATH_UNQUOTED + "/"
|
||||||
|
if not candidate.startswith(prefix):
|
||||||
|
return None
|
||||||
|
relative = candidate[len(prefix):]
|
||||||
|
try:
|
||||||
|
return "/".join(_clean_relative_path(relative, allow_empty=False))
|
||||||
|
except WebDAVError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_client() -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
auth=httpx.BasicAuth(WEBDAV_USERNAME, WEBDAV_PASSWORD),
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
follow_redirects=False,
|
||||||
|
headers={"User-Agent": "dav-mcp/1.0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _limited_response_body(response: httpx.Response, max_bytes: int) -> bytes:
|
||||||
|
result = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
if len(result) + len(chunk) > max_bytes:
|
||||||
|
raise WebDAVError(f"Response exceeds the {max_bytes}-byte size limit")
|
||||||
|
result.extend(chunk)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _propfind(client: httpx.AsyncClient, path: str, *, depth: str = "1") -> tuple[bytes, int]:
|
||||||
|
body = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>'
|
||||||
|
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/>'
|
||||||
|
'<d:getcontentlength/><d:getlastmodified/><d:getetag/></d:prop></d:propfind>'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with client.stream(
|
||||||
|
"PROPFIND", _path_url(path, collection=True),
|
||||||
|
headers={"Depth": depth, "Content-Type": "application/xml; charset=utf-8"},
|
||||||
|
content=body,
|
||||||
|
) as response:
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise WebDAVError(f"Folder not found inside the vault: {path or '/'}")
|
||||||
|
if response.status_code not in {200, 207}:
|
||||||
|
raise WebDAVError(f"WebDAV PROPFIND failed with HTTP {response.status_code}")
|
||||||
|
return await _limited_response_body(response, MAX_PROPFIND_BYTES), response.status_code
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise WebDAVError(f"WebDAV request failed: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_propfind(xml_body: bytes) -> list[dict[str, object]]:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_body)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
raise WebDAVError("WebDAV returned invalid directory XML") from exc
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for response in root.findall(f"{DAV_NS}response"):
|
||||||
|
href_node = response.find(f"{DAV_NS}href")
|
||||||
|
if href_node is None or not href_node.text:
|
||||||
|
continue
|
||||||
|
rel_path = _href_relative(href_node.text)
|
||||||
|
if rel_path is None:
|
||||||
|
continue
|
||||||
|
props = None
|
||||||
|
for propstat in response.findall(f"{DAV_NS}propstat"):
|
||||||
|
status_node = propstat.find(f"{DAV_NS}status")
|
||||||
|
if status_node is not None and status_node.text and " 200 " not in status_node.text:
|
||||||
|
continue
|
||||||
|
props = propstat.find(f"{DAV_NS}prop")
|
||||||
|
if props is not None:
|
||||||
|
break
|
||||||
|
if props is None:
|
||||||
|
continue
|
||||||
|
resource_type = props.find(f"{DAV_NS}resourcetype")
|
||||||
|
is_directory = resource_type is not None and resource_type.find(f"{DAV_NS}collection") is not None
|
||||||
|
length_node = props.find(f"{DAV_NS}getcontentlength")
|
||||||
|
modified_node = props.find(f"{DAV_NS}getlastmodified")
|
||||||
|
etag_node = props.find(f"{DAV_NS}getetag")
|
||||||
|
try:
|
||||||
|
size = int(length_node.text) if length_node is not None and length_node.text else None
|
||||||
|
except ValueError:
|
||||||
|
size = None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"path": rel_path,
|
||||||
|
"name": rel_path.rsplit("/", 1)[-1] if rel_path else "/",
|
||||||
|
"is_directory": is_directory,
|
||||||
|
"size": size,
|
||||||
|
"last_modified": modified_node.text if modified_node is not None else None,
|
||||||
|
"etag": etag_node.text if etag_node is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_directory(client: httpx.AsyncClient, path: str) -> list[dict[str, object]]:
|
||||||
|
base = "/".join(_clean_relative_path(path))
|
||||||
|
xml_body, _ = await _propfind(client, base, depth="1")
|
||||||
|
all_items = _parse_propfind(xml_body)
|
||||||
|
target = next((item for item in all_items if item["path"] == base), None)
|
||||||
|
if target is not None and not target["is_directory"]:
|
||||||
|
raise WebDAVError(f"Not a folder: {base or '/'}")
|
||||||
|
children = []
|
||||||
|
for item in all_items:
|
||||||
|
item_path = str(item["path"])
|
||||||
|
if item_path == base:
|
||||||
|
continue
|
||||||
|
parent = item_path.rsplit("/", 1)[0] if "/" in item_path else ""
|
||||||
|
if parent == base:
|
||||||
|
children.append(item)
|
||||||
|
children.sort(key=lambda item: (not bool(item["is_directory"]), str(item["name"]).casefold()))
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
async def _walk_directories(client: httpx.AsyncClient, start: str):
|
||||||
|
queue = deque([(start, 0)])
|
||||||
|
visited: set[str] = set()
|
||||||
|
while queue and len(visited) < MAX_SEARCH_DIRECTORIES:
|
||||||
|
current, depth = queue.popleft()
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
entries = await _list_directory(client, current)
|
||||||
|
yield current, entries
|
||||||
|
if depth < 32:
|
||||||
|
for entry in entries:
|
||||||
|
if entry["is_directory"]:
|
||||||
|
queue.append((str(entry["path"]), depth + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_scope_ok() -> bool:
|
||||||
|
access = get_access_token()
|
||||||
|
return bool(access and "notes:write" in access.scopes)
|
||||||
|
|
||||||
|
|
||||||
|
oauth_provider = LocalOAuthProvider(
|
||||||
|
issuer_url=MCP_PUBLIC_URL,
|
||||||
|
resource_url=MCP_RESOURCE_URL,
|
||||||
|
database_path=DATABASE_PATH,
|
||||||
|
username=MCP_AUTH_USERNAME,
|
||||||
|
password=MCP_AUTH_PASSWORD,
|
||||||
|
writable=not READ_ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp = MCPServer(
|
||||||
|
"webdav-personal-notes",
|
||||||
|
title="Personal Obsidian Vault",
|
||||||
|
description="Browse, search, and read the WebDAV vault at /Notes/Personal.",
|
||||||
|
instructions=(
|
||||||
|
"All paths are relative to the configured /Notes/Personal vault. "
|
||||||
|
"Use list_directory, search_files, or search_notes to discover notes before reading. "
|
||||||
|
"The server never exposes paths outside the configured vault."
|
||||||
|
),
|
||||||
|
auth_server_provider=oauth_provider,
|
||||||
|
auth=AuthSettings(
|
||||||
|
issuer_url=AnyHttpUrl(MCP_PUBLIC_URL),
|
||||||
|
resource_server_url=AnyHttpUrl(MCP_RESOURCE_URL),
|
||||||
|
required_scopes=["notes:read"],
|
||||||
|
validate_token_resource=True,
|
||||||
|
client_registration_options=ClientRegistrationOptions(
|
||||||
|
enabled=True,
|
||||||
|
valid_scopes=OAUTH_SCOPES,
|
||||||
|
default_scopes=OAUTH_SCOPES,
|
||||||
|
),
|
||||||
|
revocation_options=RevocationOptions(enabled=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="List files and subfolders immediately inside a vault folder. Use an empty path for the vault root.",
|
||||||
|
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
|
||||||
|
)
|
||||||
|
async def list_directory(path: str = "") -> str:
|
||||||
|
"""List direct children of a relative vault folder."""
|
||||||
|
try:
|
||||||
|
async with _dav_client() as client:
|
||||||
|
entries = await _list_directory(client, path)
|
||||||
|
return json.dumps(entries, ensure_ascii=False, indent=2)
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Read a UTF-8 text or Markdown file from the vault. Returns the file text and ETag; files are limited to 256 KiB.",
|
||||||
|
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
|
||||||
|
)
|
||||||
|
async def read_file(path: str) -> str:
|
||||||
|
"""Read a text note by its relative path."""
|
||||||
|
try:
|
||||||
|
parts = _clean_relative_path(path, allow_empty=False)
|
||||||
|
relative = "/".join(parts)
|
||||||
|
async with _dav_client() as client:
|
||||||
|
async with client.stream("GET", _path_url(relative)) as response:
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise WebDAVError(f"File not found inside the vault: {relative}")
|
||||||
|
if response.status_code >= 300:
|
||||||
|
raise WebDAVError(f"WebDAV GET failed with HTTP {response.status_code}")
|
||||||
|
data = await _limited_response_body(response, MAX_FILE_BYTES)
|
||||||
|
etag = response.headers.get("ETag")
|
||||||
|
text = data.decode("utf-8-sig", errors="replace")
|
||||||
|
return json.dumps({"path": relative, "etag": etag, "content": text}, ensure_ascii=False)
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Find files and folders by a case-insensitive path or filename substring. Searches recursively from the supplied folder.",
|
||||||
|
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
|
||||||
|
)
|
||||||
|
async def search_files(query: str, path: str = "", limit: int = 100) -> str:
|
||||||
|
"""Search file and folder paths recursively."""
|
||||||
|
try:
|
||||||
|
_clean_relative_path(path)
|
||||||
|
if not query or len(query) > 256:
|
||||||
|
raise WebDAVError("Search query must contain 1 to 256 characters")
|
||||||
|
if not 1 <= limit <= 200:
|
||||||
|
raise WebDAVError("limit must be between 1 and 200")
|
||||||
|
needle = query.casefold()
|
||||||
|
matches: list[dict[str, object]] = []
|
||||||
|
dirs_visited = 0
|
||||||
|
truncated = False
|
||||||
|
async with _dav_client() as client:
|
||||||
|
async for _, entries in _walk_directories(client, path):
|
||||||
|
dirs_visited += 1
|
||||||
|
for entry in entries:
|
||||||
|
if needle in str(entry["path"]).casefold():
|
||||||
|
matches.append(entry)
|
||||||
|
if len(matches) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
if dirs_visited >= MAX_SEARCH_DIRECTORIES:
|
||||||
|
truncated = True
|
||||||
|
return json.dumps({"matches": matches, "truncated": truncated}, ensure_ascii=False, indent=2)
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Search note text recursively. Searches Markdown and text files only; each file is limited to 256 KiB and the crawl is capped.",
|
||||||
|
annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True),
|
||||||
|
)
|
||||||
|
async def search_notes(query: str, path: str = "", limit: int = 20) -> str:
|
||||||
|
"""Search Markdown and text file contents for a phrase."""
|
||||||
|
try:
|
||||||
|
_clean_relative_path(path)
|
||||||
|
if not query or len(query) > 256:
|
||||||
|
raise WebDAVError("Search query must contain 1 to 256 characters")
|
||||||
|
if not 1 <= limit <= 100:
|
||||||
|
raise WebDAVError("limit must be between 1 and 100")
|
||||||
|
needle = query.casefold()
|
||||||
|
results: list[dict[str, str]] = []
|
||||||
|
scanned_files = 0
|
||||||
|
dirs_visited = 0
|
||||||
|
truncated = False
|
||||||
|
async with _dav_client() as client:
|
||||||
|
async for _, entries in _walk_directories(client, path):
|
||||||
|
dirs_visited += 1
|
||||||
|
for entry in entries:
|
||||||
|
if entry["is_directory"]:
|
||||||
|
continue
|
||||||
|
file_path = str(entry["path"])
|
||||||
|
if not file_path.casefold().endswith((".md", ".markdown", ".txt")):
|
||||||
|
continue
|
||||||
|
scanned_files += 1
|
||||||
|
if scanned_files > MAX_SEARCH_FILES:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
async with client.stream("GET", _path_url(file_path)) as response:
|
||||||
|
if response.status_code == 404:
|
||||||
|
continue
|
||||||
|
if response.status_code >= 300:
|
||||||
|
raise WebDAVError(f"WebDAV GET failed with HTTP {response.status_code}")
|
||||||
|
data = await _limited_response_body(response, MAX_FILE_BYTES)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise WebDAVError(f"WebDAV request failed: {type(exc).__name__}") from exc
|
||||||
|
except WebDAVError as exc:
|
||||||
|
if "size limit" in str(exc):
|
||||||
|
truncated = True
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
content = data.decode("utf-8-sig", errors="replace")
|
||||||
|
match_at = content.casefold().find(needle)
|
||||||
|
if match_at >= 0:
|
||||||
|
start = max(0, match_at - 180)
|
||||||
|
end = min(len(content), match_at + len(query) + 180)
|
||||||
|
snippet = content[start:end].replace("\r", " ").replace("\n", " ")
|
||||||
|
results.append({"path": file_path, "snippet": snippet})
|
||||||
|
if len(results) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
if dirs_visited >= MAX_SEARCH_DIRECTORIES or scanned_files >= MAX_SEARCH_FILES:
|
||||||
|
truncated = True
|
||||||
|
return json.dumps({"matches": results, "scanned_files": scanned_files, "truncated": truncated}, ensure_ascii=False, indent=2)
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
if not READ_ONLY:
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description=(
|
||||||
|
"Create or replace a UTF-8 text file in the vault. New files are create-only by default. "
|
||||||
|
"To replace a note, set overwrite=true and pass the ETag returned by read_file as expected_etag."
|
||||||
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
read_only_hint=False, destructive_hint=True, idempotent_hint=False, open_world_hint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def write_file(
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
overwrite: bool = False,
|
||||||
|
expected_etag: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a note, or replace it only when an ETag confirms the version read."""
|
||||||
|
if not _write_scope_ok():
|
||||||
|
return "Error: This OAuth connection lacks notes:write. Reconnect and authorize write access."
|
||||||
|
try:
|
||||||
|
parts = _clean_relative_path(path, allow_empty=False)
|
||||||
|
relative = "/".join(parts)
|
||||||
|
if path.endswith("/"):
|
||||||
|
raise WebDAVError("write_file requires a file path, not a folder path")
|
||||||
|
if overwrite and not expected_etag:
|
||||||
|
raise WebDAVError("expected_etag is required when overwrite=true; read the file first")
|
||||||
|
if not overwrite and expected_etag:
|
||||||
|
raise WebDAVError("expected_etag is only used when overwrite=true")
|
||||||
|
data = content.encode("utf-8")
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
raise WebDAVError(f"File exceeds the {MAX_FILE_BYTES}-byte write limit")
|
||||||
|
headers = {"Content-Type": "text/markdown; charset=utf-8"}
|
||||||
|
headers["If-Match" if overwrite else "If-None-Match"] = expected_etag if overwrite else "*"
|
||||||
|
async with _dav_client() as client:
|
||||||
|
async with client.stream("PUT", _path_url(relative), headers=headers, content=data) as response:
|
||||||
|
if response.status_code == 412:
|
||||||
|
raise WebDAVError("The file changed or already exists. Read it again and retry deliberately.")
|
||||||
|
if response.status_code not in {200, 201, 204}:
|
||||||
|
raise WebDAVError(f"WebDAV PUT failed with HTTP {response.status_code}")
|
||||||
|
etag = response.headers.get("ETag")
|
||||||
|
return json.dumps({"saved": relative, "etag": etag, "bytes_written": len(data)}, ensure_ascii=False)
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Create one new folder inside the vault. The parent folder must already exist.",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def create_directory(path: str) -> str:
|
||||||
|
"""Create a single directory using WebDAV MKCOL."""
|
||||||
|
if not _write_scope_ok():
|
||||||
|
return "Error: This OAuth connection lacks notes:write. Reconnect and authorize write access."
|
||||||
|
try:
|
||||||
|
parts = _clean_relative_path(path, allow_empty=False)
|
||||||
|
relative = "/".join(parts)
|
||||||
|
async with _dav_client() as client:
|
||||||
|
async with client.stream("MKCOL", _path_url(relative, collection=True)) as response:
|
||||||
|
if response.status_code == 405:
|
||||||
|
raise WebDAVError("That folder already exists")
|
||||||
|
if response.status_code not in {200, 201, 204}:
|
||||||
|
raise WebDAVError(f"WebDAV MKCOL failed with HTTP {response.status_code}")
|
||||||
|
return f"Created folder: {relative}"
|
||||||
|
except (WebDAVError, httpx.HTTPError) as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def health(_: Request) -> JSONResponse:
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
transport_security = TransportSecuritySettings(
|
||||||
|
allowed_hosts=MCP_ALLOWED_HOSTS,
|
||||||
|
allowed_origins=MCP_ALLOWED_ORIGINS,
|
||||||
|
)
|
||||||
|
mcp_http_app = mcp.streamable_http_app(transport_security=transport_security)
|
||||||
|
_resource_metadata_path = "/.well-known/oauth-protected-resource/mcp"
|
||||||
|
mcp_http_app.router.routes = [
|
||||||
|
route for route in mcp_http_app.router.routes
|
||||||
|
if getattr(route, "path", None) != _resource_metadata_path
|
||||||
|
]
|
||||||
|
mcp_http_app.router.routes.extend(
|
||||||
|
create_protected_resource_routes(
|
||||||
|
resource_url=AnyHttpUrl(MCP_RESOURCE_URL),
|
||||||
|
authorization_servers=[AnyHttpUrl(MCP_PUBLIC_URL)],
|
||||||
|
scopes_supported=OAUTH_SCOPES,
|
||||||
|
resource_name="Personal Obsidian Vault",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _route in make_login_routes(oauth_provider):
|
||||||
|
if getattr(_route, "path", None) == "/login/callback":
|
||||||
|
_route.app = RequestBodyLimitMiddleware(_route.app, 16 * 1024)
|
||||||
|
mcp_http_app.router.routes.append(_route)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: Starlette):
|
||||||
|
await oauth_provider.initialize()
|
||||||
|
async with mcp.session_manager.run():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[Route("/healthz", health, methods=["GET"]), Mount("/", app=mcp_http_app)],
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
dav-mcp:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
- "${MCP_BIND_ADDRESS:-127.0.0.1}:${MCP_HOST_PORT:-8000}:8000"
|
||||||
|
environment:
|
||||||
|
WEBDAV_URL: ${WEBDAV_URL:-https://dav.ryankegel.com}
|
||||||
|
WEBDAV_ROOT: ${WEBDAV_ROOT:-/Notes/Personal}
|
||||||
|
WEBDAV_USERNAME: ${WEBDAV_USERNAME:?Set WEBDAV_USERNAME in .env}
|
||||||
|
WEBDAV_PASSWORD_FILE: /run/secrets/webdav_password
|
||||||
|
MCP_PUBLIC_URL: ${MCP_PUBLIC_URL:?Set MCP_PUBLIC_URL in .env}
|
||||||
|
MCP_ALLOWED_HOSTS: ${MCP_ALLOWED_HOSTS:?Set MCP_ALLOWED_HOSTS in .env}
|
||||||
|
MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-}
|
||||||
|
MCP_AUTH_USERNAME: ${MCP_AUTH_USERNAME:?Set MCP_AUTH_USERNAME in .env}
|
||||||
|
MCP_AUTH_PASSWORD_FILE: /run/secrets/mcp_auth_password
|
||||||
|
MCP_READ_ONLY: ${MCP_READ_ONLY:-true}
|
||||||
|
OAUTH_DATABASE_PATH: /data/oauth.sqlite3
|
||||||
|
secrets:
|
||||||
|
- webdav_password
|
||||||
|
- mcp_auth_password
|
||||||
|
volumes:
|
||||||
|
- oauth-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).read()"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
webdav_password:
|
||||||
|
file: ./secrets/webdav_password
|
||||||
|
mcp_auth_password:
|
||||||
|
file: ./secrets/mcp_auth_password
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
oauth-data:
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
"""Single-user OAuth authorization server for ChatGPT's remote MCP connection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from mcp.server.auth.provider import (
|
||||||
|
AccessToken,
|
||||||
|
AuthorizationCode,
|
||||||
|
AuthorizationParams,
|
||||||
|
AuthorizeError,
|
||||||
|
RegistrationError,
|
||||||
|
RefreshToken,
|
||||||
|
TokenError,
|
||||||
|
construct_redirect_uri,
|
||||||
|
)
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_TOKEN_SECONDS = 60 * 60
|
||||||
|
REFRESH_TOKEN_SECONDS = 60 * 60 * 24 * 30
|
||||||
|
FLOW_SECONDS = 10 * 60
|
||||||
|
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||||
|
LOGIN_FAILURE_LIMIT = 8
|
||||||
|
MAX_REGISTERED_CLIENTS = 32
|
||||||
|
|
||||||
|
|
||||||
|
class LocalOAuthProvider:
|
||||||
|
"""OAuth provider with PKCE, rotating refresh tokens, and SQLite persistence."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
issuer_url: str,
|
||||||
|
resource_url: str,
|
||||||
|
database_path: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
writable: bool,
|
||||||
|
) -> None:
|
||||||
|
self.issuer_url = issuer_url.rstrip("/")
|
||||||
|
self.resource_url = resource_url
|
||||||
|
self.database_path = Path(database_path)
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.allowed_scopes = ["notes:read"] + (["notes:write"] if writable else [])
|
||||||
|
self.default_scopes = list(self.allowed_scopes)
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with aiosqlite.connect(self.database_path) as db:
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
metadata_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_flows (
|
||||||
|
flow_id TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
client_state TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
redirect_uri_provided_explicitly INTEGER NOT NULL,
|
||||||
|
code_challenge TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
scopes_json TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
redirect_uri_provided_explicitly INTEGER NOT NULL,
|
||||||
|
code_challenge TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
scopes_json TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||||
|
access_token TEXT PRIMARY KEY,
|
||||||
|
refresh_token TEXT NOT NULL UNIQUE,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
scopes_json TEXT NOT NULL,
|
||||||
|
access_expires_at INTEGER NOT NULL,
|
||||||
|
refresh_expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_login_failures (
|
||||||
|
client_ip TEXT NOT NULL,
|
||||||
|
failed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_login_failures_by_ip_time
|
||||||
|
ON oauth_login_failures(client_ip, failed_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def _connect(self) -> aiosqlite.Connection:
|
||||||
|
db = await aiosqlite.connect(self.database_path)
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
return db
|
||||||
|
|
||||||
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT metadata_json FROM oauth_clients WHERE client_id = ?", (client_id,)
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return OAuthClientInformationFull.model_validate_json(row["metadata_json"])
|
||||||
|
|
||||||
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||||
|
if not client_info.client_id:
|
||||||
|
raise ValueError("OAuth client registration did not include a client ID")
|
||||||
|
data = client_info.model_dump_json()
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM oauth_clients WHERE client_id != ?",
|
||||||
|
(client_info.client_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is not None and row["n"] >= MAX_REGISTERED_CLIENTS:
|
||||||
|
raise RegistrationError(
|
||||||
|
"invalid_client_metadata",
|
||||||
|
"This single-user MCP server has reached its OAuth client registration limit",
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO oauth_clients (client_id, metadata_json) VALUES (?, ?)",
|
||||||
|
(client_info.client_id, data),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
|
requested_scopes = list(params.scopes or self.default_scopes)
|
||||||
|
requested_scopes = list(dict.fromkeys(requested_scopes))
|
||||||
|
if not set(requested_scopes).issubset(self.allowed_scopes):
|
||||||
|
raise AuthorizeError("invalid_scope", "The requested notes permission is not enabled")
|
||||||
|
if "notes:read" not in requested_scopes:
|
||||||
|
requested_scopes.insert(0, "notes:read")
|
||||||
|
|
||||||
|
if params.resource is not None and params.resource != self.resource_url:
|
||||||
|
raise AuthorizeError("invalid_target", "This authorization server only issues tokens for this MCP endpoint")
|
||||||
|
|
||||||
|
flow_id = secrets.token_urlsafe(32)
|
||||||
|
async with await self._connect() as db:
|
||||||
|
now = int(time.time())
|
||||||
|
await db.execute("DELETE FROM oauth_flows WHERE expires_at < ?", (now,))
|
||||||
|
await db.execute("DELETE FROM oauth_codes WHERE expires_at < ?", (now,))
|
||||||
|
await db.execute("DELETE FROM oauth_tokens WHERE refresh_expires_at < ?", (now,))
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO oauth_flows
|
||||||
|
(flow_id, client_id, client_state, redirect_uri,
|
||||||
|
redirect_uri_provided_explicitly, code_challenge, resource,
|
||||||
|
scopes_json, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
flow_id,
|
||||||
|
client.client_id,
|
||||||
|
params.state,
|
||||||
|
str(params.redirect_uri),
|
||||||
|
int(params.redirect_uri_provided_explicitly),
|
||||||
|
params.code_challenge,
|
||||||
|
self.resource_url,
|
||||||
|
json.dumps(requested_scopes),
|
||||||
|
now + FLOW_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return f"{self.issuer_url}/login?{urlencode({'flow': flow_id})}"
|
||||||
|
|
||||||
|
async def get_flow(self, flow_id: str) -> dict[str, Any] | None:
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT * FROM oauth_flows WHERE flow_id = ?", (flow_id,)
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is not None and row["expires_at"] < int(time.time()):
|
||||||
|
await db.execute("DELETE FROM oauth_flows WHERE flow_id = ?", (flow_id,))
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
result = dict(row)
|
||||||
|
result["scopes"] = json.loads(result.pop("scopes_json"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def login_is_limited(self, client_ip: str) -> bool:
|
||||||
|
cutoff = int(time.time()) - LOGIN_WINDOW_SECONDS
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM oauth_login_failures WHERE client_ip = ? AND failed_at >= ?",
|
||||||
|
(client_ip, cutoff),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return bool(row and row["n"] >= LOGIN_FAILURE_LIMIT)
|
||||||
|
|
||||||
|
async def record_login_failure(self, client_ip: str) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - LOGIN_WINDOW_SECONDS
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute("DELETE FROM oauth_login_failures WHERE failed_at < ?", (cutoff,))
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO oauth_login_failures (client_ip, failed_at) VALUES (?, ?)",
|
||||||
|
(client_ip, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def clear_login_failures(self, client_ip: str) -> None:
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute("DELETE FROM oauth_login_failures WHERE client_ip = ?", (client_ip,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
def credentials_match(self, username: str, password: str) -> bool:
|
||||||
|
name_ok = hmac.compare_digest(username.encode("utf-8"), self.username.encode("utf-8"))
|
||||||
|
password_ok = hmac.compare_digest(password.encode("utf-8"), self.password.encode("utf-8"))
|
||||||
|
return name_ok and password_ok
|
||||||
|
|
||||||
|
async def finish_login(self, flow_id: str, username: str) -> str:
|
||||||
|
flow = await self.get_flow(flow_id)
|
||||||
|
if flow is None:
|
||||||
|
raise HTTPException(status_code=400, detail="This sign-in request has expired. Start again from ChatGPT.")
|
||||||
|
|
||||||
|
code_value = secrets.token_urlsafe(48)
|
||||||
|
now = int(time.time())
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO oauth_codes
|
||||||
|
(code, client_id, redirect_uri, redirect_uri_provided_explicitly,
|
||||||
|
code_challenge, resource, subject, scopes_json, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
code_value,
|
||||||
|
flow["client_id"],
|
||||||
|
flow["redirect_uri"],
|
||||||
|
flow["redirect_uri_provided_explicitly"],
|
||||||
|
flow["code_challenge"],
|
||||||
|
flow["resource"],
|
||||||
|
username,
|
||||||
|
json.dumps(flow["scopes"]),
|
||||||
|
now + 300,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.execute("DELETE FROM oauth_flows WHERE flow_id = ?", (flow_id,))
|
||||||
|
await db.commit()
|
||||||
|
return construct_redirect_uri(
|
||||||
|
flow["redirect_uri"],
|
||||||
|
code=code_value,
|
||||||
|
state=flow["client_state"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: str
|
||||||
|
) -> AuthorizationCode | None:
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT * FROM oauth_codes WHERE code = ? AND client_id = ?",
|
||||||
|
(authorization_code, client.client_id),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row["expires_at"] < int(time.time()):
|
||||||
|
return None
|
||||||
|
return AuthorizationCode(
|
||||||
|
code=row["code"],
|
||||||
|
client_id=row["client_id"],
|
||||||
|
redirect_uri=row["redirect_uri"],
|
||||||
|
redirect_uri_provided_explicitly=bool(row["redirect_uri_provided_explicitly"]),
|
||||||
|
code_challenge=row["code_challenge"],
|
||||||
|
resource=row["resource"],
|
||||||
|
subject=row["subject"],
|
||||||
|
scopes=json.loads(row["scopes_json"]),
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||||
|
) -> OAuthToken:
|
||||||
|
if client.client_id != authorization_code.client_id:
|
||||||
|
raise TokenError("invalid_grant", "Authorization code was issued to a different client")
|
||||||
|
now = int(time.time())
|
||||||
|
access = secrets.token_urlsafe(48)
|
||||||
|
refresh = secrets.token_urlsafe(48)
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT expires_at FROM oauth_codes WHERE code = ? AND client_id = ?",
|
||||||
|
(authorization_code.code, client.client_id),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row["expires_at"] < now:
|
||||||
|
await db.rollback()
|
||||||
|
raise TokenError("invalid_grant", "Authorization code is invalid or expired")
|
||||||
|
await db.execute("DELETE FROM oauth_codes WHERE code = ?", (authorization_code.code,))
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO oauth_tokens
|
||||||
|
(access_token, refresh_token, client_id, resource, subject,
|
||||||
|
scopes_json, access_expires_at, refresh_expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
access,
|
||||||
|
refresh,
|
||||||
|
client.client_id,
|
||||||
|
authorization_code.resource or self.resource_url,
|
||||||
|
authorization_code.subject or self.username,
|
||||||
|
json.dumps(authorization_code.scopes),
|
||||||
|
now + ACCESS_TOKEN_SECONDS,
|
||||||
|
now + REFRESH_TOKEN_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return OAuthToken(
|
||||||
|
access_token=access,
|
||||||
|
token_type="Bearer",
|
||||||
|
expires_in=ACCESS_TOKEN_SECONDS,
|
||||||
|
scope=" ".join(authorization_code.scopes),
|
||||||
|
refresh_token=refresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_refresh_token(
|
||||||
|
self, client: OAuthClientInformationFull, refresh_token: str
|
||||||
|
) -> RefreshToken | None:
|
||||||
|
now = int(time.time())
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"""SELECT * FROM oauth_tokens
|
||||||
|
WHERE refresh_token = ? AND client_id = ? AND refresh_expires_at >= ?""",
|
||||||
|
(refresh_token, client.client_id, now),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return RefreshToken(
|
||||||
|
token=row["refresh_token"],
|
||||||
|
client_id=row["client_id"],
|
||||||
|
scopes=json.loads(row["scopes_json"]),
|
||||||
|
expires_at=row["refresh_expires_at"],
|
||||||
|
resource=row["resource"],
|
||||||
|
subject=row["subject"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_refresh_token(
|
||||||
|
self,
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
scopes: list[str],
|
||||||
|
) -> OAuthToken:
|
||||||
|
now = int(time.time())
|
||||||
|
granted_scopes = list(dict.fromkeys(scopes or refresh_token.scopes))
|
||||||
|
if not set(granted_scopes).issubset(refresh_token.scopes):
|
||||||
|
raise TokenError("invalid_scope", "A refresh token cannot grant additional permissions")
|
||||||
|
access = secrets.token_urlsafe(48)
|
||||||
|
refresh = secrets.token_urlsafe(48)
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
async with db.execute(
|
||||||
|
"""SELECT * FROM oauth_tokens WHERE refresh_token = ? AND client_id = ?
|
||||||
|
AND refresh_expires_at >= ?""",
|
||||||
|
(refresh_token.token, client.client_id, now),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
await db.rollback()
|
||||||
|
raise TokenError("invalid_grant", "Refresh token is invalid or expired")
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM oauth_tokens WHERE refresh_token = ?", (refresh_token.token,)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO oauth_tokens
|
||||||
|
(access_token, refresh_token, client_id, resource, subject,
|
||||||
|
scopes_json, access_expires_at, refresh_expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
access,
|
||||||
|
refresh,
|
||||||
|
client.client_id,
|
||||||
|
row["resource"],
|
||||||
|
row["subject"],
|
||||||
|
json.dumps(granted_scopes),
|
||||||
|
now + ACCESS_TOKEN_SECONDS,
|
||||||
|
now + REFRESH_TOKEN_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return OAuthToken(
|
||||||
|
access_token=access,
|
||||||
|
token_type="Bearer",
|
||||||
|
expires_in=ACCESS_TOKEN_SECONDS,
|
||||||
|
scope=" ".join(granted_scopes),
|
||||||
|
refresh_token=refresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||||
|
now = int(time.time())
|
||||||
|
async with await self._connect() as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT * FROM oauth_tokens WHERE access_token = ? AND access_expires_at >= ?",
|
||||||
|
(token, now),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return AccessToken(
|
||||||
|
token=row["access_token"],
|
||||||
|
client_id=row["client_id"],
|
||||||
|
scopes=json.loads(row["scopes_json"]),
|
||||||
|
expires_at=row["access_expires_at"],
|
||||||
|
resource=row["resource"],
|
||||||
|
subject=row["subject"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||||
|
column = "access_token" if isinstance(token, AccessToken) else "refresh_token"
|
||||||
|
async with await self._connect() as db:
|
||||||
|
await db.execute(f"DELETE FROM oauth_tokens WHERE {column} = ?", (token.token,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_cookie_name(flow_id: str) -> str:
|
||||||
|
return "mcp_csrf_" + hashlib.sha256(flow_id.encode("utf-8")).hexdigest()[:20]
|
||||||
|
|
||||||
|
|
||||||
|
def _protect_login_response(response: Response) -> Response:
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
|
||||||
|
"base-uri 'none'; frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _login_html(flow: dict[str, Any], flow_id: str, csrf: str, error: str | None = None) -> str:
|
||||||
|
scopes = flow["scopes"]
|
||||||
|
requested = []
|
||||||
|
if "notes:read" in scopes:
|
||||||
|
requested.append("Read and search notes in /Notes/Personal")
|
||||||
|
if "notes:write" in scopes:
|
||||||
|
requested.append("Create or replace notes and create folders in /Notes/Personal")
|
||||||
|
scope_list = "".join(f"<li>{html.escape(item)}</li>" for item in requested)
|
||||||
|
error_html = f'<p class="error">{html.escape(error)}</p>' if error else ""
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Connect WebDAV Notes</title>
|
||||||
|
<style>
|
||||||
|
body {{ font: 16px system-ui, sans-serif; max-width: 34rem; margin: 4rem auto; padding: 0 1rem; color: #17202a; }}
|
||||||
|
input {{ display: block; box-sizing: border-box; width: 100%; padding: .7rem; margin: .35rem 0 1rem; }}
|
||||||
|
button {{ padding: .7rem 1rem; }} .error {{ color: #a00; }} li {{ margin: .4rem 0; }}
|
||||||
|
</style>
|
||||||
|
<h1>Connect WebDAV Notes</h1>
|
||||||
|
<p>ChatGPT is requesting access to your Obsidian vault:</p>
|
||||||
|
<ul>{scope_list}</ul>
|
||||||
|
{error_html}
|
||||||
|
<form method="post" action="{html.escape(flow['issuer_url'])}/login/callback" autocomplete="on">
|
||||||
|
<input type="hidden" name="flow" value="{html.escape(flow_id)}">
|
||||||
|
<input type="hidden" name="csrf" value="{html.escape(csrf)}">
|
||||||
|
<label>Username<input name="username" autocomplete="username" required></label>
|
||||||
|
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
|
||||||
|
<label><input type="checkbox" name="approve" value="yes" required style="display:inline;width:auto"> Allow the permissions above</label>
|
||||||
|
<button type="submit">Authorize</button>
|
||||||
|
</form>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_login_routes(provider: LocalOAuthProvider) -> list[Any]:
|
||||||
|
async def login_page(request: Request) -> Response:
|
||||||
|
flow_id = request.query_params.get("flow", "")
|
||||||
|
flow = await provider.get_flow(flow_id)
|
||||||
|
if flow is None:
|
||||||
|
return HTMLResponse("This sign-in request has expired. Start again from ChatGPT.", status_code=400)
|
||||||
|
flow["issuer_url"] = provider.issuer_url
|
||||||
|
csrf = secrets.token_urlsafe(24)
|
||||||
|
response = _protect_login_response(HTMLResponse(_login_html(flow, flow_id, csrf)))
|
||||||
|
response.set_cookie(
|
||||||
|
_csrf_cookie_name(flow_id), csrf, max_age=FLOW_SECONDS, secure=True,
|
||||||
|
httponly=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def login_callback(request: Request) -> Response:
|
||||||
|
form = await request.form()
|
||||||
|
flow_id = form.get("flow")
|
||||||
|
username = form.get("username")
|
||||||
|
password = form.get("password")
|
||||||
|
csrf = form.get("csrf")
|
||||||
|
if not all(isinstance(value, str) for value in (flow_id, username, password, csrf)):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid sign-in request")
|
||||||
|
assert isinstance(flow_id, str)
|
||||||
|
assert isinstance(username, str)
|
||||||
|
assert isinstance(password, str)
|
||||||
|
assert isinstance(csrf, str)
|
||||||
|
|
||||||
|
flow = await provider.get_flow(flow_id)
|
||||||
|
if flow is None:
|
||||||
|
raise HTTPException(status_code=400, detail="This sign-in request has expired. Start again from ChatGPT.")
|
||||||
|
flow["issuer_url"] = provider.issuer_url
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
cookie_csrf = request.cookies.get(_csrf_cookie_name(flow_id), "")
|
||||||
|
if not cookie_csrf or not hmac.compare_digest(cookie_csrf, csrf):
|
||||||
|
return HTMLResponse("Sign-in verification failed. Reload the authorization page and try again.", status_code=403)
|
||||||
|
|
||||||
|
if await provider.login_is_limited(client_ip):
|
||||||
|
return HTMLResponse("Too many failed sign-in attempts. Please wait 15 minutes before trying again.", status_code=429)
|
||||||
|
|
||||||
|
approved = form.get("approve") == "yes"
|
||||||
|
if not approved or not provider.credentials_match(username, password):
|
||||||
|
await provider.record_login_failure(client_ip)
|
||||||
|
response = _protect_login_response(
|
||||||
|
HTMLResponse(
|
||||||
|
_login_html(flow, flow_id, csrf, "Sign-in failed. Check your credentials and permission selection."),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
_csrf_cookie_name(flow_id), csrf, max_age=FLOW_SECONDS, secure=True,
|
||||||
|
httponly=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
await provider.clear_login_failures(client_ip)
|
||||||
|
redirect_url = await provider.finish_login(flow_id, username)
|
||||||
|
response = _protect_login_response(RedirectResponse(redirect_url, status_code=303))
|
||||||
|
response.delete_cookie(_csrf_cookie_name(flow_id), path="/", secure=True, httponly=True, samesite="strict")
|
||||||
|
return response
|
||||||
|
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
return [
|
||||||
|
Route("/login", login_page, methods=["GET"]),
|
||||||
|
Route("/login/callback", login_callback, methods=["POST"]),
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mcp==2.3.0
|
||||||
|
httpx>=0.27,<1
|
||||||
|
aiosqlite>=0.20,<1
|
||||||
|
python-multipart>=0.0.9,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
Reference in New Issue
Block a user