diff --git a/README.md b/README.md index 1c840df..4df2516 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,40 @@ Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS. The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. `signup` creates a `user` account with no existing session. `login` stores the VeraeTime JWT on this host. `session` checks it. `create_user` adds an account when that JWT is an admin. +## Table of Contents + +- [Install](#install) +- [Usage](#usage) +- [Contributing](#contributing) +- [License](#license) + ## Install +On the machine that will run the server: + +```sh +uvx verae-mcp-timestamping-server setup +``` + +`setup` asks whether this is a new account or an existing one, then writes `~/.verae/auth.properties` (mode 600). It prints the username, role, and expiry. It does not print the token. `uvx verae-mcp-timestamping-server session` prints the same check. + +Assistant config names the command or the public URL. It does not contain the token: + +```json +{ + "mcpServers": { + "verae-timestamping": { + "command": "uvx", + "args": ["verae-mcp-timestamping-server"] + } + } +} +``` + +For the public server, set `url` to `https://mcp.veraetime.net/mcp`. With `VERAE_MCP_OAUTH=1` and `VERAE_MCP_PUBLIC_URL=https://mcp.veraetime.net`, the first connection opens a browser page to create an account or log in. The assistant stores that token in its own OAuth file. + +Development checkout: + ```sh cd /Users/marchon/research/modelcontextprotocol/verae-mcp-timestamping-server uv sync --group dev @@ -59,10 +91,11 @@ open docs/html/index.html Sources: `docs/operator-guide.md`, `docs/architecture.md`, `docs/api.md`, `docs/standards.md`. -Sample page (talks to Streamable HTTP on this machine): +Install page and stamp sample: ```sh open site/index.html +open site/sample.html ``` Public copy of the page and the docs: `https://mcp.georgelambert.org/`. diff --git a/docs/api.md b/docs/api.md index 097fee5..13bc57e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,7 +68,7 @@ Returns `valid`, `username`, `role`, and `expires_at`. | `password` | required | New password | | `role` | required | `user` or `admin` | -`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. There is no public signup; the first admin already exists on the timestamping service. +`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. Public registration is `signup`. `create_user` is how an admin sets a role. A stored login is refreshed when `stamp` or `verify` runs and `expires_at` is missing or within 5 minutes, if `username` and `password` are in the auth file. diff --git a/docs/html/api.html b/docs/html/api.html index 61836ce..d076c29 100644 --- a/docs/html/api.html +++ b/docs/html/api.html @@ -292,9 +292,9 @@ replace the JWT stored for stamp. A new account calls

POST /auth/users with the stored JWT. Refuses unless session reports role admin. Returns -id, username, and role. There is -no public signup; the first admin already exists on the timestamping -service.

+id, username, and role. Public +registration is signup. create_user is how an +admin sets a role.

A stored login is refreshed when stamp or verify runs and expires_at is missing or within 5 minutes, if username and password are diff --git a/docs/html/operator-guide.html b/docs/html/operator-guide.html index 148073b..48f62e0 100644 --- a/docs/html/operator-guide.html +++ b/docs/html/operator-guide.html @@ -112,12 +112,20 @@ site/index.html on the same machine, or put TLS and an access control in front of a non-loopback bind you choose yourself.

Global token

+

A new machine runs +uvx verae-mcp-timestamping-server setup. That creates an +account or logs in and writes ~/.verae/auth.properties +(mode 600). It prints username, role, and expiry, not the JWT. +session checks that file. Starting the server with no login +prints the setup command on stderr.

signup creates a role user account with no -bearer token. Call login with that username and password to -store the JWT in ~/.verae/auth.properties (mode 600). The -server uses it for scope=global without a restart. -session checks that JWT. create_user adds an -account when the stored role is admin.

+bearer token. create_user adds an account when the stored +role is admin.

+

Set VERAE_MCP_OAUTH=1 and +VERAE_MCP_PUBLIC_URL to the public HTTPS origin to require +a browser login on the HTTP MCP endpoint. That login’s JWT is the +caller’s bearer for stamp and is not written over the host +file.

# or skip the tool and export a token yourself:
 export VERAE_GLOBAL_TOKEN=...

Tests

diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 7a47a93..f5b8abd 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -31,7 +31,11 @@ A page served from the public internet still cannot call `127.0.0.1` from curren ## Global token -`signup` creates a role `user` account with no bearer token. Call `login` with that username and password to store the JWT in `~/.verae/auth.properties` (mode 600). The server uses it for `scope=global` without a restart. `session` checks that JWT. `create_user` adds an account when the stored role is `admin`. +A new machine runs `uvx verae-mcp-timestamping-server setup`. That creates an account or logs in and writes `~/.verae/auth.properties` (mode 600). It prints username, role, and expiry, not the JWT. `session` checks that file. Starting the server with no login prints the setup command on stderr. + +`signup` creates a role `user` account with no bearer token. `create_user` adds an account when the stored role is `admin`. + +Set `VERAE_MCP_OAUTH=1` and `VERAE_MCP_PUBLIC_URL` to the public HTTPS origin to require a browser login on the HTTP MCP endpoint. That login's JWT is the caller's bearer for `stamp` and is not written over the host file. ```sh # or skip the tool and export a token yourself: diff --git a/docs/pdf/api.pdf b/docs/pdf/api.pdf index 84eac75..110c716 100644 Binary files a/docs/pdf/api.pdf and b/docs/pdf/api.pdf differ diff --git a/docs/pdf/architecture.pdf b/docs/pdf/architecture.pdf index ed2f271..a3b1fb4 100644 Binary files a/docs/pdf/architecture.pdf and b/docs/pdf/architecture.pdf differ diff --git a/docs/pdf/operator-guide.pdf b/docs/pdf/operator-guide.pdf index 8ae4214..3488ce8 100644 Binary files a/docs/pdf/operator-guide.pdf and b/docs/pdf/operator-guide.pdf differ diff --git a/docs/pdf/standards.pdf b/docs/pdf/standards.pdf index 3c38a76..3963b2e 100644 Binary files a/docs/pdf/standards.pdf and b/docs/pdf/standards.pdf differ diff --git a/site/assets/VeraeFullLogo.png b/site/assets/VeraeFullLogo.png new file mode 100644 index 0000000..4c011e8 Binary files /dev/null and b/site/assets/VeraeFullLogo.png differ diff --git a/site/index.html b/site/index.html index b596b3b..4e3724d 100644 --- a/site/index.html +++ b/site/index.html @@ -3,89 +3,217 @@ -Verae timestamping MCP sample +Install Verae timestamping + + + + + -

Verae timestamping MCP sample

-

This page calls a Model Context Protocol (MCP) server. The three letters are MCP. The server name is verae-timestamping. It stamps a SHA-256 digest. It does not upload a file.

-

Start the server on this machine, then submit. A page on the public internet cannot reach 127.0.0.1.

-
VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server
-
- - - - - - -
-

Result

-
Waiting.
-

Docs in this repo: HTML · standards PDF

-

Same files on the public host: HTML · standards PDF

- +} +

Use the public server

+

No local program. The assistant connects to this host. When per-user sign-in is on, the first connection opens a browser page at /account to create an account or log in. That token stays in the assistant’s OAuth store, not in this file.

+
{
+  "mcpServers": {
+    "verae-timestamping": {
+      "url": "https://mcp.veraetime.net/mcp"
+    }
+  }
+}
+ + +
+

Where the token lives

+
+
+

Your machine

+

~/.verae/auth.properties

+

Mode 600, owned by you. Holds the JWT, its expiry, your username, and your password so a stamp can log in again before the 24-hour token expires. Do not commit this file.

+
+
+

Your assistant

+

OAuth file

+

A browser login on the public server gives the assistant a VeraeTime JWT and a refresh token. The Python client stores that under ~/.verae/oauth/mcp-tokens.json, also mode 600.

+
+
+

Never

+

Chat or mcp.json

+

Do not paste the token into a conversation or into the client config. session shows the username and expiry if you need to confirm the login.

+
+
+
+ + + diff --git a/site/sample.html b/site/sample.html new file mode 100644 index 0000000..b596b3b --- /dev/null +++ b/site/sample.html @@ -0,0 +1,91 @@ + + + + + +Verae timestamping MCP sample + + + +

Verae timestamping MCP sample

+

This page calls a Model Context Protocol (MCP) server. The three letters are MCP. The server name is verae-timestamping. It stamps a SHA-256 digest. It does not upload a file.

+

Start the server on this machine, then submit. A page on the public internet cannot reach 127.0.0.1.

+
VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server
+
+ + + + + + +
+

Result

+
Waiting.
+

Docs in this repo: HTML · standards PDF

+

Same files on the public host: HTML · standards PDF

+ + + diff --git a/src/verae_mcp_timestamping_server/global_client.py b/src/verae_mcp_timestamping_server/global_client.py index bd5508c..af399d8 100644 --- a/src/verae_mcp_timestamping_server/global_client.py +++ b/src/verae_mcp_timestamping_server/global_client.py @@ -13,6 +13,7 @@ import re import time import urllib.error import urllib.request +from contextvars import ContextVar from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Protocol @@ -28,6 +29,9 @@ from verae_mcp_timestamping_server.ledger import ( DEFAULT_GLOBAL_URL = "https://api.veraetime.net" # Refresh a stored login this long before expires_at. TOKEN_REFRESH_SKEW = timedelta(minutes=5) +# Bearer for this MCP request. When set, stamp and verify use it and do not +# refresh the host login. +request_token: ContextVar[str | None] = ContextVar("verae_request_token", default=None) class HttpTransport(Protocol): @@ -223,7 +227,8 @@ class GlobalClient: if self.needs_refresh(): self.login(self._username, self._password) - def login(self, username: str, password: str) -> LoginResult: + def authenticate(self, username: str, password: str) -> tuple[LoginResult, str]: + """Check a username and password. Does not store the JWT.""" if not username or not password: raise ValueError("username and password are required") res = self.http.request( @@ -243,10 +248,21 @@ class GlobalClient: raise ValueError("login response missing token") user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {} expires_at = str(parsed.get("expiresAt") or "") + return ( + LoginResult( + username=str(user.get("username") or username), + role=str(user.get("role") or ""), + expires_at=expires_at, + ), + token, + ) + + def login(self, username: str, password: str) -> LoginResult: + result, token = self.authenticate(username, password) self.token = token self._username = username self._password = password - self.expires_at = parse_expiry(expires_at) + self.expires_at = parse_expiry(result.expires_at) path = self.auth_path or default_auth_path() self.auth_path = path write_properties( @@ -255,16 +271,21 @@ class GlobalClient: "access_token": token, "id_token": token, "auth_token": token, - "expires_at": expires_at, + "expires_at": result.expires_at, "username": username, "password": password, }, ) - return LoginResult( - username=str(user.get("username") or username), - role=str(user.get("role") or ""), - expires_at=expires_at, - ) + return result + + def validate_bearer(self, token: str) -> SessionResult: + """Check a caller-supplied JWT without replacing the host login.""" + saved = self.token + self.token = token + try: + return self.session() + finally: + self.token = saved def session(self) -> SessionResult: if not self.token: @@ -340,7 +361,19 @@ class GlobalClient: return headers def submit(self, digest: str, cube_id: str = "") -> Receipt: - self.ensure_token() + override = request_token.get() + saved = self.token + if override: + self.token = override + else: + self.ensure_token() + try: + return self._submit(digest, cube_id) + finally: + if override: + self.token = saved + + def _submit(self, digest: str, cube_id: str = "") -> Receipt: url = f"{self.base_url}/api/timestamp" res = self.http.request( "POST", @@ -416,7 +449,19 @@ class GlobalClient: ) def verify_certificate(self, receipt_id: str) -> VerifyResult: - self.ensure_token() + override = request_token.get() + saved = self.token + if override: + self.token = override + else: + self.ensure_token() + try: + return self._verify_certificate(receipt_id) + finally: + if override: + self.token = saved + + def _verify_certificate(self, receipt_id: str) -> VerifyResult: url = f"{self.base_url}/api/verify" res = self.http.request( "POST", diff --git a/src/verae_mcp_timestamping_server/oauth_login.py b/src/verae_mcp_timestamping_server/oauth_login.py new file mode 100644 index 0000000..70f9a3a --- /dev/null +++ b/src/verae_mcp_timestamping_server/oauth_login.py @@ -0,0 +1,251 @@ +"""Browser signup and login for assistants that connect to this MCP server. + +The access token issued here is the VeraeTime JWT. The assistant stores it +in its own OAuth file. This process does not put that JWT in mcp.json. +""" + +from __future__ import annotations + +import asyncio +import html +import secrets +import time +from dataclasses import dataclass +from urllib.parse import parse_qs + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, + TokenError, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from verae_mcp_timestamping_server.global_client import GlobalClient, parse_expiry + +_PAGE = """ + + + +VeraeTime account + + + +

VeraeTime

+

Create an account or log in. The token stays in your assistant. It is not shown here.

+{error} +
+ + + + + +
+ + +""" + + +@dataclass +class _Pending: + client_id: str + params: AuthorizationParams + created: float + + +@dataclass +class _Code: + code: str + client_id: str + params: AuthorizationParams + api_token: str + expires_at: int | None + subject: str + + +@dataclass +class _Refresh: + token: str + client_id: str + scopes: list[str] + api_token: str + expires_at: int | None + subject: str + + +class VeraeOAuthProvider: + """OAuth authorization server whose access token is a VeraeTime JWT.""" + + def __init__(self, api: GlobalClient, issuer: str) -> None: + self.api = api + self.issuer = issuer.rstrip("/") + self.clients: dict[str, OAuthClientInformationFull] = {} + self.pending: dict[str, _Pending] = {} + self.codes: dict[str, _Code] = {} + self.refresh: dict[str, _Refresh] = {} + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + if not client_info.client_id: + client_info.client_id = secrets.token_urlsafe(24) + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + txn = secrets.token_urlsafe(32) + self.pending[txn] = _Pending(client_id=client.client_id, params=params, created=time.time()) + return f"{self.issuer}/account?txn={txn}" + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + found = self.codes.get(authorization_code) + if found is None or found.client_id != client.client_id: + return None + return AuthorizationCode( + code=found.code, + scopes=["stamp"], + expires_at=time.time() + 300, + client_id=found.client_id, + code_challenge=found.params.code_challenge, + redirect_uri=found.params.redirect_uri, + redirect_uri_provided_explicitly=found.params.redirect_uri_provided_explicitly, + resource=found.params.resource, + subject=found.subject, + ) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + found = self.codes.pop(authorization_code.code, None) + if found is None or found.client_id != client.client_id: + raise TokenError("invalid_grant", "unknown authorization code") + return self._issue(found.client_id, found.api_token, found.expires_at, found.subject) + + async def load_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: str + ) -> RefreshToken | None: + found = self.refresh.get(refresh_token) + if found is None or found.client_id != client.client_id: + return None + return RefreshToken( + token=found.token, + client_id=found.client_id, + scopes=list(found.scopes), + expires_at=found.expires_at, + subject=found.subject, + ) + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + del scopes + found = self.refresh.get(refresh_token.token) + if found is None or found.client_id != client.client_id: + raise TokenError("invalid_grant", "unknown refresh token") + if found.expires_at is not None and found.expires_at <= int(time.time()): + self.refresh.pop(refresh_token.token, None) + raise TokenError("invalid_grant", "login expired; sign in again") + self.refresh.pop(refresh_token.token, None) + return self._issue(found.client_id, found.api_token, found.expires_at, found.subject) + + async def load_access_token(self, token: str) -> AccessToken | None: + seen = await asyncio.to_thread(self.api.validate_bearer, token) + if not seen.valid: + return None + expires = parse_expiry(seen.expires_at) + return AccessToken( + token=token, + client_id="verae", + scopes=["stamp"], + expires_at=int(expires.timestamp()) if expires else None, + subject=seen.username, + ) + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + self.refresh.pop(getattr(token, "token", ""), None) + + def _issue(self, client_id: str, api_token: str, expires_at: int | None, subject: str) -> OAuthToken: + refresh = secrets.token_urlsafe(32) + self.refresh[refresh] = _Refresh( + token=refresh, + client_id=client_id, + scopes=["stamp"], + api_token=api_token, + expires_at=expires_at, + subject=subject, + ) + expires_in = None + if expires_at is not None: + expires_in = max(0, expires_at - int(time.time())) + return OAuthToken( + access_token=api_token, + expires_in=expires_in, + scope="stamp", + refresh_token=refresh, + ) + + def _drop_old_pending(self) -> None: + cutoff = time.time() - 600 + for key, item in list(self.pending.items()): + if item.created < cutoff: + del self.pending[key] + + async def handle_account(self, request: Request) -> Response: + self._drop_old_pending() + if request.method == "GET": + txn = request.query_params.get("txn", "") + error = request.query_params.get("error", "") + return self._page(txn, error) + form = parse_qs((await request.body()).decode()) + txn = (form.get("txn") or [""])[0] + username = (form.get("username") or [""])[0].strip() + password = (form.get("password") or [""])[0] + mode = (form.get("mode") or ["login"])[0] + pending = self.pending.get(txn) + if pending is None: + return self._page(txn, "This login page expired. Start the connection again.") + if not username or not password: + return self._page(txn, "Username and password are required.") + try: + if mode == "signup": + await asyncio.to_thread(self.api.signup, username, password) + logged, api_token = await asyncio.to_thread(self.api.authenticate, username, password) + except ValueError as exc: + return self._page(txn, str(exc)) + self.pending.pop(txn, None) + code = secrets.token_urlsafe(32) + expires = parse_expiry(logged.expires_at) + self.codes[code] = _Code( + code=code, + client_id=pending.client_id, + params=pending.params, + api_token=api_token, + expires_at=int(expires.timestamp()) if expires else None, + subject=logged.username, + ) + target = construct_redirect_uri( + str(pending.params.redirect_uri), + code=code, + state=pending.params.state, + ) + return RedirectResponse(target, status_code=302) + + def _page(self, txn: str, error: str) -> HTMLResponse: + block = f'

{html.escape(error)}

' if error else "" + body = _PAGE.format(error=block, txn=html.escape(txn)) + return HTMLResponse(body) diff --git a/src/verae_mcp_timestamping_server/server.py b/src/verae_mcp_timestamping_server/server.py index 00afa25..2a486fa 100644 --- a/src/verae_mcp_timestamping_server/server.py +++ b/src/verae_mcp_timestamping_server/server.py @@ -4,9 +4,16 @@ from __future__ import annotations import asyncio import os +import sys +from contextlib import contextmanager from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions from mcp.server.mcpserver.exceptions import ToolError +from pydantic import AnyHttpUrl +from starlette.requests import Request +from starlette.responses import Response from verae_mcp_timestamping_server.global_client import ( CreateUserResult, @@ -14,7 +21,10 @@ from verae_mcp_timestamping_server.global_client import ( GlobalLedger, LoginResult, SessionResult, + request_token, ) +from verae_mcp_timestamping_server.oauth_login import VeraeOAuthProvider +from verae_mcp_timestamping_server.setup_cmd import print_login_hint, run_session, run_setup from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model from verae_mcp_timestamping_server.ledger import ( FileLedger, @@ -25,9 +35,41 @@ from verae_mcp_timestamping_server.ledger import ( VerifyResult, ) -mcp = MCPServer("verae-timestamping") +mcp = MCPServer( + "verae-timestamping", + instructions=( + "Global stamps need a VeraeTime login on the machine that runs this server, " + "or a bearer token from the browser signup on the public server. " + "If session is not valid, ask the user to run " + "`uvx verae-mcp-timestamping-server setup`. " + "Do not ask them to paste a token into the chat or into mcp.json. " + "The token file is ~/.verae/auth.properties, mode 600." + ), +) _ledger: Ledger = MemoryLedger() _global_client: GlobalClient = GlobalClient() +_oauth: VeraeOAuthProvider | None = None + + +@contextmanager +def _caller_token(): + access = get_access_token() + if access is None or not access.token: + yield + return + reset = request_token.set(access.token) + try: + yield + finally: + request_token.reset(reset) + + +@mcp.custom_route("/account", methods=["GET", "POST"]) +async def account_page(request: Request) -> Response: + """Browser signup and login. The JWT is handed to the assistant, not printed.""" + if _oauth is None: + return Response("OAuth is not enabled on this server.", status_code=404) + return await _oauth.handle_account(request) def default_ledger(client: GlobalClient | None = None) -> Ledger: @@ -100,9 +142,10 @@ async def stamp( ) -> Receipts: """Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID.""" try: - receipts = await _ledger.stamp( - sha256, scope=scope, cube_id=cube_id, org_id=org_id - ) + with _caller_token(): + receipts = await _ledger.stamp( + sha256, scope=scope, cube_id=cube_id, org_id=org_id + ) except ValueError as exc: raise _tool_error(exc) from exc return Receipts(receipts=receipts) @@ -111,7 +154,8 @@ async def stamp( @mcp.tool() async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult: """Check a receipt. want_scope=global asks api.veraetime.net (central source of truth).""" - return await _ledger.verify(receipt_id, want_scope=want_scope) + with _caller_token(): + return await _ledger.verify(receipt_id, want_scope=want_scope) @mcp.tool() @@ -163,9 +207,37 @@ async def hash_report( return Receipts(receipts=receipts) +def enable_oauth(public_url: str) -> VeraeOAuthProvider: + """Turn on browser signup for the HTTP server. Stdio stays open unless this runs.""" + global _oauth + issuer = public_url.rstrip("/") + provider = VeraeOAuthProvider(api=_global_client, issuer=issuer) + mcp.settings.auth = AuthSettings( + issuer_url=AnyHttpUrl(issuer), + resource_server_url=AnyHttpUrl(issuer + "/mcp"), + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["stamp"]), + required_scopes=["stamp"], + validate_token_resource=False, + ) + mcp._auth_server_provider = provider + from mcp.server.auth.provider import ProviderTokenVerifier + + mcp._token_verifier = ProviderTokenVerifier(provider) + _oauth = provider + return provider + + def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == "setup": + raise SystemExit(run_setup(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "session": + raise SystemExit(run_session()) client = GlobalClient.from_env() build_mcp(default_ledger(client), global_client=client) + print_login_hint(client) + if os.environ.get("VERAE_MCP_OAUTH") == "1": + public = os.environ.get("VERAE_MCP_PUBLIC_URL", "http://127.0.0.1:8000") + enable_oauth(public) transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio") if transport == "streamable-http": host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1") diff --git a/src/verae_mcp_timestamping_server/setup_cmd.py b/src/verae_mcp_timestamping_server/setup_cmd.py new file mode 100644 index 0000000..bccb532 --- /dev/null +++ b/src/verae_mcp_timestamping_server/setup_cmd.py @@ -0,0 +1,96 @@ +"""First-run registration for a machine that runs this MCP server.""" + +from __future__ import annotations + +import argparse +import getpass +import sys +from collections.abc import Callable + +from verae_mcp_timestamping_server.global_client import ( + GlobalClient, + default_auth_path, +) + + +def login_hint(client: GlobalClient) -> str | None: + """Text for a machine with no stored VeraeTime login. None when one exists.""" + if client.token or client._username: + return None + path = client.auth_path or default_auth_path() + return ( + "No VeraeTime login on this machine.\n" + " uvx verae-mcp-timestamping-server setup\n" + f"Token file: {path} (mode 600)" + ) + + +def print_login_hint(client: GlobalClient) -> None: + text = login_hint(client) + if text: + print(text, file=sys.stderr) + + +def _print_session(client: GlobalClient) -> int: + seen = client.session() + path = client.auth_path or default_auth_path() + print(f"Wrote {path} (mode 600)") + print(f"username={seen.username}") + print(f"role={seen.role}") + print(f"expires_at={seen.expires_at}") + print(f"valid={str(seen.valid).lower()}") + return 0 if seen.valid else 1 + + +def run_session(client: GlobalClient | None = None) -> int: + held = client or GlobalClient.from_env() + seen = held.session() + print(f"valid={str(seen.valid).lower()}") + print(f"username={seen.username}") + print(f"role={seen.role}") + print(f"expires_at={seen.expires_at}") + return 0 if seen.valid else 1 + + +def run_setup( + argv: list[str] | None = None, + *, + client: GlobalClient | None = None, + input_fn: Callable[[str], str] = input, + password_fn: Callable[[str], str] = getpass.getpass, +) -> int: + parser = argparse.ArgumentParser( + prog="verae-mcp-timestamping-server setup", + description="Create a VeraeTime account or log in, and store the token on this machine.", + ) + parser.add_argument("--username", default="") + parser.add_argument("--password", default="") + parser.add_argument( + "--signup", + action="store_true", + help="Create a new user account, then log in.", + ) + args = parser.parse_args(argv) + username = args.username.strip() + password = args.password + signup = args.signup + if not username or not password: + choice = input_fn("Account [new/existing] (new): ").strip().lower() + signup = choice in ("", "new", "signup", "register") + if not username: + username = input_fn("Username: ").strip() + if not password: + password = password_fn("Password: ") + if not username or not password: + print("username and password are required", file=sys.stderr) + return 2 + held = client or GlobalClient.from_env() + try: + if signup: + created = held.signup(username, password) + print(f"created username={created.username} role={created.role}") + held.login(username, password) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + return _print_session(held) diff --git a/tests/test_oauth_login.py b/tests/test_oauth_login.py new file mode 100644 index 0000000..f340d8b --- /dev/null +++ b/tests/test_oauth_login.py @@ -0,0 +1,102 @@ +"""Browser account page issues a VeraeTime JWT without storing it on the host.""" + +import pytest +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl +from starlette.requests import Request + +from verae_mcp_timestamping_server.global_client import GlobalClient, request_token +from verae_mcp_timestamping_server.ledger import SHA256_EMPTY +from verae_mcp_timestamping_server.oauth_login import VeraeOAuthProvider + +from test_global_client import _auth_http + +pytestmark = pytest.mark.anyio + + +def _request(method: str, body: bytes = b"", query: bytes = b"") -> Request: + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + scope = { + "type": "http", + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": "/account", + "raw_path": b"/account", + "query_string": query, + "headers": [(b"content-type", b"application/x-www-form-urlencoded")], + "client": ("127.0.0.1", 1234), + "server": ("127.0.0.1", 8000), + } + return Request(scope, receive) + + +async def test_account_form_issues_code_and_leaves_host_token(tmp_path): + http = _auth_http() + api = GlobalClient( + "https://api.veraetime.net", + token="host-token", + http=http, + auth_path=tmp_path / "auth.properties", + ) + provider = VeraeOAuthProvider(api, "http://127.0.0.1:8000") + client = OAuthClientInformationFull( + client_id="assistant", + redirect_uris=[AnyUrl("http://127.0.0.1/callback")], + ) + await provider.register_client(client) + params = AuthorizationParams( + state="st", + scopes=["stamp"], + code_challenge="challenge", + redirect_uri=AnyUrl("http://127.0.0.1/callback"), + redirect_uri_provided_explicitly=True, + ) + url = await provider.authorize(client, params) + txn = url.split("txn=", 1)[1] + response = await provider.handle_account( + _request( + "POST", + body=f"txn={txn}&username=newcomer&password=secret-pass&mode=signup".encode(), + ) + ) + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith("http://127.0.0.1/callback?") + assert "code=" in location + assert "fresh-token" not in location + assert api.token == "host-token" + assert not (tmp_path / "auth.properties").exists() + code = location.split("code=", 1)[1].split("&", 1)[0] + loaded = await provider.load_authorization_code(client, code) + assert loaded is not None + issued = await provider.exchange_authorization_code(client, loaded) + assert issued.access_token == "fresh-token" + assert issued.refresh_token + seen = await provider.load_access_token("fresh-token") + assert seen is not None + assert seen.subject == "admin" + + +async def test_request_token_does_not_refresh_host_login(tmp_path): + path = tmp_path / "auth.properties" + path.write_text( + "access_token=old-token\n" + "expires_at=2020-01-01T00:00:00Z\n" + "username=admin\n" + "password=secret-pass\n" + ) + http = _auth_http() + client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path) + reset = request_token.set("caller-token") + try: + rec = client.submit(SHA256_EMPTY) + finally: + request_token.reset(reset) + assert rec.receipt_id == "QmFresh" + assert http.calls[0][1].endswith("/api/timestamp") + assert http.calls[0][3]["Authorization"] == "Bearer caller-token" + assert client.token == "old-token" diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..874d387 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,39 @@ +"""First-run setup stores a login and does not print the JWT.""" + +from verae_mcp_timestamping_server.global_client import GlobalClient +from verae_mcp_timestamping_server.setup_cmd import login_hint, run_setup + +from test_global_client import FakeHttp, _auth_http + + +def test_setup_signup_writes_token_file_without_printing_it(tmp_path, capsys): + path = tmp_path / "auth.properties" + http = _auth_http() + client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path) + code = run_setup( + ["--signup", "--username", "newcomer", "--password", "secret-pass"], + client=client, + ) + assert code == 0 + text = capsys.readouterr().out + assert "fresh-token" not in text + assert "username=admin" in text + assert "role=admin" in text + assert f"Wrote {path}" in text + stored = path.read_text() + assert "access_token=fresh-token" in stored + assert path.stat().st_mode & 0o777 == 0o600 + assert any(url.endswith("/auth/signup") for _, url, _, _ in http.calls) + assert any(url.endswith("/auth/login") for _, url, _, _ in http.calls) + + +def test_login_hint_only_when_missing(tmp_path): + empty = GlobalClient("https://api.veraetime.net", http=FakeHttp(), auth_path=tmp_path / "missing.properties") + assert "uvx verae-mcp-timestamping-server setup" in (login_hint(empty) or "") + signed = GlobalClient( + "https://api.veraetime.net", + token="secret", + http=FakeHttp(), + auth_path=tmp_path / "auth.properties", + ) + assert login_hint(signed) is None