feat: add signup, login, session, and create_user
signup calls POST /auth/signup and leaves the stored JWT in place. login writes the VeraeTime token to the auth properties file and does not return it. A stored username and password refresh that token when expires_at is missing or within five minutes. create_user still requires an admin session.
This commit is contained in:
parent
326d90a9db
commit
c547804835
15 changed files with 730 additions and 31 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
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. Bearer token: `VERAE_GLOBAL_TOKEN` or `~/.verae/auth.properties`.
|
||||
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.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -40,10 +40,10 @@ uv run python scripts/transaction_log_test.py
|
|||
open debug-trace/2026-09-20T143541Z-verae-debug-test/transaction-log-test.html
|
||||
```
|
||||
|
||||
Global stamp (central VeraeTime). Token lives at `~/.verae/auth.properties` (mode 600):
|
||||
Global stamp (central VeraeTime). `login` writes `~/.verae/auth.properties` (mode 600) and does not return the JWT:
|
||||
|
||||
```sh
|
||||
# refresh: POST https://api.veraetime.net/auth/login → access_token=
|
||||
# MCP tools: signup, login, session, create_user
|
||||
# or: export VERAE_GLOBAL_TOKEN=...
|
||||
# then stamp with scope=global
|
||||
```
|
||||
|
|
|
|||
36
docs/api.md
36
docs/api.md
|
|
@ -36,6 +36,42 @@ Scope rank: local < shared < global.
|
|||
|
||||
Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed.
|
||||
|
||||
## login
|
||||
|
||||
| Argument | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `username` | required | VeraeTime account |
|
||||
| `password` | required | VeraeTime password |
|
||||
|
||||
`POST /auth/login`. Stores `access_token`, `id_token`, `auth_token`, `expires_at`, `username`, and `password` in the auth properties file (mode `0600`) and replaces the in-memory JWT. Returns `username`, `role`, and `expires_at`. The JWT is not returned.
|
||||
|
||||
## session
|
||||
|
||||
No arguments. `GET /auth/validate` with the stored JWT.
|
||||
|
||||
Returns `valid`, `username`, `role`, and `expires_at`.
|
||||
|
||||
## signup
|
||||
|
||||
| Argument | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `username` | required | New account |
|
||||
| `password` | required | New password |
|
||||
|
||||
`POST /auth/signup` with no bearer token. The timestamping service creates the account with role `user`. Returns `id`, `username`, and `role`. Does not replace the JWT stored for `stamp`. A new account calls `login` to become the stored session. `create_user` remains the admin path and can set `admin`.
|
||||
|
||||
## create_user
|
||||
|
||||
| Argument | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `username` | required | New account |
|
||||
| `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.
|
||||
|
||||
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.
|
||||
|
||||
## Environment
|
||||
|
||||
| Name | Default |
|
||||
|
|
|
|||
|
|
@ -201,6 +201,104 @@ when the stored scope ranks lower</td>
|
|||
</table>
|
||||
<p>Returns <code>{ "receipts": [ ... ] }</code> from the local file.
|
||||
Global-only certificates are not listed.</p>
|
||||
<h2 id="login">login</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Argument</th>
|
||||
<th>Default</th>
|
||||
<th>Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>username</code></td>
|
||||
<td>required</td>
|
||||
<td>VeraeTime account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>password</code></td>
|
||||
<td>required</td>
|
||||
<td>VeraeTime password</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>POST /auth/login</code>. Stores <code>access_token</code>,
|
||||
<code>id_token</code>, <code>auth_token</code>, <code>expires_at</code>,
|
||||
<code>username</code>, and <code>password</code> in the auth properties
|
||||
file (mode <code>0600</code>) and replaces the in-memory JWT. Returns
|
||||
<code>username</code>, <code>role</code>, and <code>expires_at</code>.
|
||||
The JWT is not returned.</p>
|
||||
<h2 id="session">session</h2>
|
||||
<p>No arguments. <code>GET /auth/validate</code> with the stored
|
||||
JWT.</p>
|
||||
<p>Returns <code>valid</code>, <code>username</code>, <code>role</code>,
|
||||
and <code>expires_at</code>.</p>
|
||||
<h2 id="signup">signup</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Argument</th>
|
||||
<th>Default</th>
|
||||
<th>Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>username</code></td>
|
||||
<td>required</td>
|
||||
<td>New account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>password</code></td>
|
||||
<td>required</td>
|
||||
<td>New password</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>POST /auth/signup</code> with no bearer token. The timestamping
|
||||
service creates the account with role <code>user</code>. Returns
|
||||
<code>id</code>, <code>username</code>, and <code>role</code>. Does not
|
||||
replace the JWT stored for <code>stamp</code>. A new account calls
|
||||
<code>login</code> to become the stored session.
|
||||
<code>create_user</code> remains the admin path and can set
|
||||
<code>admin</code>.</p>
|
||||
<h2 id="create_user">create_user</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Argument</th>
|
||||
<th>Default</th>
|
||||
<th>Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>username</code></td>
|
||||
<td>required</td>
|
||||
<td>New account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>password</code></td>
|
||||
<td>required</td>
|
||||
<td>New password</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>role</code></td>
|
||||
<td>required</td>
|
||||
<td><code>user</code> or <code>admin</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>POST /auth/users</code> with the stored JWT. Refuses unless
|
||||
<code>session</code> reports role <code>admin</code>. Returns
|
||||
<code>id</code>, <code>username</code>, and <code>role</code>. There is
|
||||
no public signup; the first admin already exists on the timestamping
|
||||
service.</p>
|
||||
<p>A stored login is refreshed when <code>stamp</code> or
|
||||
<code>verify</code> runs and <code>expires_at</code> is missing or
|
||||
within 5 minutes, if <code>username</code> and <code>password</code> are
|
||||
in the auth file.</p>
|
||||
<h2 id="environment">Environment</h2>
|
||||
<table>
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -112,9 +112,14 @@
|
|||
<code>site/index.html</code> on the same machine, or put TLS and an
|
||||
access control in front of a non-loopback bind you choose yourself.</p>
|
||||
<h2 id="global-token">Global token</h2>
|
||||
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># POST https://api.veraetime.net/auth/login and store access_token=</span></span>
|
||||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="co"># in ~/.verae/auth.properties (mode 600), or:</span></span>
|
||||
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div>
|
||||
<p><code>signup</code> creates a role <code>user</code> account with no
|
||||
bearer token. Call <code>login</code> with that username and password to
|
||||
store the JWT in <code>~/.verae/auth.properties</code> (mode 600). The
|
||||
server uses it for <code>scope=global</code> without a restart.
|
||||
<code>session</code> checks that JWT. <code>create_user</code> adds an
|
||||
account when the stored role is <code>admin</code>.</p>
|
||||
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># or skip the tool and export a token yourself:</span></span>
|
||||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div>
|
||||
<h2 id="tests">Tests</h2>
|
||||
<div class="sourceCode" id="cb6"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">uv</span> run pytest</span></code></pre></div>
|
||||
<p>Stamp traces (live VeraeTime) are a separate script. They write
|
||||
|
|
|
|||
|
|
@ -90,6 +90,17 @@ means “not in the local file,” not “VeraeTime has no record.”</p>
|
|||
<code>access_token</code> / <code>id_token</code> /
|
||||
<code>auth_token</code> in <code>~/.verae/auth.properties</code>
|
||||
(<code>VERAE_AUTH_PROPERTIES</code> overrides the path).</li>
|
||||
<li><code>login</code> writes that file (mode <code>0600</code>),
|
||||
including <code>username</code>, <code>password</code>, and
|
||||
<code>expires_at</code>. The tool result does not include the JWT.</li>
|
||||
<li><code>signup</code> calls <code>POST /auth/signup</code> with no
|
||||
bearer token. The account role is <code>user</code>. It does not replace
|
||||
the stored JWT.</li>
|
||||
<li><code>create_user</code> calls <code>POST /auth/users</code> only
|
||||
when the stored token’s role is <code>admin</code>.</li>
|
||||
<li>Before a global stamp or verify, a stored username and password are
|
||||
used to log in again when <code>expires_at</code> is missing or within 5
|
||||
minutes.</li>
|
||||
<li>Do not commit tokens. <code>.env</code> is gitignored.</li>
|
||||
<li>The local receipt file is mode <code>0600</code> after each
|
||||
save.</li>
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ 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`.
|
||||
|
||||
```sh
|
||||
# POST https://api.veraetime.net/auth/login and store access_token=
|
||||
# in ~/.verae/auth.properties (mode 600), or:
|
||||
# or skip the tool and export a token yourself:
|
||||
export VERAE_GLOBAL_TOKEN=...
|
||||
```
|
||||
|
||||
|
|
|
|||
BIN
docs/pdf/api.pdf
BIN
docs/pdf/api.pdf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -29,6 +29,10 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a
|
|||
## Credentials
|
||||
|
||||
- Token: `VERAE_GLOBAL_TOKEN`, else `access_token` / `id_token` / `auth_token` in `~/.verae/auth.properties` (`VERAE_AUTH_PROPERTIES` overrides the path).
|
||||
- `login` writes that file (mode `0600`), including `username`, `password`, and `expires_at`. The tool result does not include the JWT.
|
||||
- `signup` calls `POST /auth/signup` with no bearer token. The account role is `user`. It does not replace the stored JWT.
|
||||
- `create_user` calls `POST /auth/users` only when the stored token's role is `admin`.
|
||||
- Before a global stamp or verify, a stored username and password are used to log in again when `expires_at` is missing or within 5 minutes.
|
||||
- Do not commit tokens. `.env` is gitignored.
|
||||
- The local receipt file is mode `0600` after each save.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from verae_mcp_timestamping_server.ledger import (
|
||||
Receipt,
|
||||
VerifyResult,
|
||||
|
|
@ -22,6 +26,8 @@ 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)
|
||||
|
||||
|
||||
class HttpTransport(Protocol):
|
||||
|
|
@ -81,20 +87,86 @@ class UrllibHttp:
|
|||
return _UrllibResp(status, body, raw, resp_headers)
|
||||
|
||||
|
||||
class LoginResult(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class SessionResult(BaseModel):
|
||||
valid: bool = False
|
||||
username: str = ""
|
||||
role: str = ""
|
||||
expires_at: str = ""
|
||||
|
||||
|
||||
class CreateUserResult(BaseModel):
|
||||
id: str = ""
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
def default_auth_path() -> Path:
|
||||
override = os.environ.get("VERAE_AUTH_PROPERTIES")
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".verae" / "auth.properties"
|
||||
|
||||
|
||||
def read_properties(path: Path) -> dict[str, str]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
props: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
props[key.strip()] = value.strip()
|
||||
return props
|
||||
|
||||
|
||||
def write_properties(path: Path, props: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
merged = read_properties(path)
|
||||
merged.update(props)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("".join(f"{key}={value}\n" for key, value in merged.items()))
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def parse_expiry(value: str) -> datetime | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
match = re.match(r"^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2})?$", text)
|
||||
if match is None:
|
||||
return None
|
||||
frac = match.group(2) or ""
|
||||
if frac:
|
||||
frac = "." + frac[1:7]
|
||||
zone = match.group(3) or "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(match.group(1) + frac + zone)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def load_auth_token() -> str:
|
||||
env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
|
||||
if env:
|
||||
return env
|
||||
path = Path(os.environ.get("VERAE_AUTH_PROPERTIES", Path.home() / ".verae" / "auth.properties"))
|
||||
if not path.is_file():
|
||||
return ""
|
||||
props: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
props[key.strip()] = value.strip()
|
||||
props = read_properties(default_auth_path())
|
||||
return props.get("access_token") or props.get("id_token") or props.get("auth_token") or ""
|
||||
|
||||
|
||||
|
|
@ -104,16 +176,161 @@ class GlobalClient:
|
|||
base_url: str = DEFAULT_GLOBAL_URL,
|
||||
token: str = "",
|
||||
http: HttpTransport | None = None,
|
||||
auth_path: Path | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.http = http or UrllibHttp()
|
||||
self.auth_path = Path(auth_path) if auth_path is not None else None
|
||||
self.expires_at: datetime | None = None
|
||||
self._username = ""
|
||||
self._password = ""
|
||||
if self.auth_path is not None:
|
||||
self._load_stored(keep_token=bool(token))
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> GlobalClient:
|
||||
env_token = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
|
||||
return cls(
|
||||
base_url=os.environ.get("VERAE_GLOBAL_URL", DEFAULT_GLOBAL_URL),
|
||||
token=load_auth_token(),
|
||||
token=env_token,
|
||||
auth_path=default_auth_path(),
|
||||
)
|
||||
|
||||
def _load_stored(self, keep_token: bool) -> None:
|
||||
if self.auth_path is None:
|
||||
return
|
||||
props = read_properties(self.auth_path)
|
||||
if not keep_token:
|
||||
self.token = (
|
||||
props.get("access_token")
|
||||
or props.get("id_token")
|
||||
or props.get("auth_token")
|
||||
or self.token
|
||||
)
|
||||
self._username = props.get("username") or ""
|
||||
self._password = props.get("password") or ""
|
||||
self.expires_at = parse_expiry(props.get("expires_at") or "")
|
||||
|
||||
def needs_refresh(self) -> bool:
|
||||
if not self._username or not self._password:
|
||||
return False
|
||||
if not self.token or self.expires_at is None:
|
||||
return True
|
||||
return self.expires_at <= datetime.now(timezone.utc) + TOKEN_REFRESH_SKEW
|
||||
|
||||
def ensure_token(self) -> None:
|
||||
if self.needs_refresh():
|
||||
self.login(self._username, self._password)
|
||||
|
||||
def login(self, username: str, password: str) -> LoginResult:
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
res = self.http.request(
|
||||
"POST",
|
||||
f"{self.base_url}/auth/login",
|
||||
payload={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
parsed = res.json() if hasattr(res, "json") else {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
if res.status_code >= 300:
|
||||
detail = str(parsed.get("error") or res.text or "").strip()
|
||||
raise ValueError(f"login HTTP {res.status_code}: {detail}")
|
||||
token = str(parsed.get("token") or "")
|
||||
if not token:
|
||||
raise ValueError("login response missing token")
|
||||
user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {}
|
||||
expires_at = str(parsed.get("expiresAt") or "")
|
||||
self.token = token
|
||||
self._username = username
|
||||
self._password = password
|
||||
self.expires_at = parse_expiry(expires_at)
|
||||
path = self.auth_path or default_auth_path()
|
||||
self.auth_path = path
|
||||
write_properties(
|
||||
path,
|
||||
{
|
||||
"access_token": token,
|
||||
"id_token": token,
|
||||
"auth_token": token,
|
||||
"expires_at": 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,
|
||||
)
|
||||
|
||||
def session(self) -> SessionResult:
|
||||
if not self.token:
|
||||
return SessionResult()
|
||||
res = self.http.request(
|
||||
"GET",
|
||||
f"{self.base_url}/auth/validate",
|
||||
payload={},
|
||||
headers=self._headers(),
|
||||
)
|
||||
parsed = res.json() if hasattr(res, "json") else {}
|
||||
if not isinstance(parsed, dict) or res.status_code >= 300 or not parsed.get("valid"):
|
||||
return SessionResult()
|
||||
return SessionResult(
|
||||
valid=True,
|
||||
username=str(parsed.get("username") or ""),
|
||||
role=str(parsed.get("role") or ""),
|
||||
expires_at=str(parsed.get("expiresAt") or ""),
|
||||
)
|
||||
|
||||
def signup(self, username: str, password: str) -> CreateUserResult:
|
||||
"""Create a role=user account. Does not replace this process's JWT."""
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
kept = self.token
|
||||
res = self.http.request(
|
||||
"POST",
|
||||
f"{self.base_url}/auth/signup",
|
||||
payload={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.token = kept
|
||||
parsed = res.json() if hasattr(res, "json") else {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
if res.status_code >= 300:
|
||||
detail = str(parsed.get("error") or res.text or "").strip()
|
||||
raise ValueError(f"signup HTTP {res.status_code}: {detail}")
|
||||
return CreateUserResult(
|
||||
id=str(parsed.get("id") or ""),
|
||||
username=str(parsed.get("username") or username),
|
||||
role=str(parsed.get("role") or "user"),
|
||||
)
|
||||
|
||||
def create_user(self, username: str, password: str, role: str) -> CreateUserResult:
|
||||
if not username or not password or not role:
|
||||
raise ValueError("username, password, and role are required")
|
||||
current = self.session()
|
||||
if not current.valid or current.role != "admin":
|
||||
raise ValueError("admin login required")
|
||||
res = self.http.request(
|
||||
"POST",
|
||||
f"{self.base_url}/auth/users",
|
||||
payload={"username": username, "password": password, "role": role},
|
||||
headers=self._headers(),
|
||||
)
|
||||
parsed = res.json() if hasattr(res, "json") else {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
if res.status_code >= 300:
|
||||
detail = str(parsed.get("error") or res.text or "").strip()
|
||||
raise ValueError(f"create user HTTP {res.status_code}: {detail}")
|
||||
return CreateUserResult(
|
||||
id=str(parsed.get("id") or ""),
|
||||
username=str(parsed.get("username") or username),
|
||||
role=str(parsed.get("role") or role),
|
||||
)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
|
|
@ -123,6 +340,7 @@ class GlobalClient:
|
|||
return headers
|
||||
|
||||
def submit(self, digest: str, cube_id: str = "") -> Receipt:
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/api/timestamp"
|
||||
res = self.http.request(
|
||||
"POST",
|
||||
|
|
@ -132,13 +350,18 @@ class GlobalClient:
|
|||
"digest": digest,
|
||||
"hashAlg": "SHA256",
|
||||
"restamp": False,
|
||||
"wait": True,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
parsed = res.json() if hasattr(res, "json") else {}
|
||||
if res.status_code == 200 and isinstance(parsed, dict):
|
||||
cid = str(parsed.get("certificate") or parsed.get("jobId") or "")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
# 200 + certificate is the first CID for a digest already in the queue.
|
||||
# jobId on that response is the original job, not the CID.
|
||||
if res.status_code == 200 and (
|
||||
parsed.get("certificate") or parsed.get("existing")
|
||||
):
|
||||
cid = str(parsed.get("certificate") or "")
|
||||
if cid:
|
||||
return Receipt(
|
||||
receipt_id=cid,
|
||||
|
|
@ -189,6 +412,7 @@ class GlobalClient:
|
|||
)
|
||||
|
||||
def verify_certificate(self, receipt_id: str) -> VerifyResult:
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/api/verify"
|
||||
res = self.http.request(
|
||||
"POST",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger
|
||||
from verae_mcp_timestamping_server.global_client import (
|
||||
CreateUserResult,
|
||||
GlobalClient,
|
||||
GlobalLedger,
|
||||
LoginResult,
|
||||
SessionResult,
|
||||
)
|
||||
from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model
|
||||
from verae_mcp_timestamping_server.ledger import (
|
||||
FileLedger,
|
||||
|
|
@ -20,15 +27,25 @@ from verae_mcp_timestamping_server.ledger import (
|
|||
|
||||
mcp = MCPServer("verae-timestamping")
|
||||
_ledger: Ledger = MemoryLedger()
|
||||
_global_client: GlobalClient = GlobalClient()
|
||||
|
||||
|
||||
def default_ledger() -> Ledger:
|
||||
def default_ledger(client: GlobalClient | None = None) -> Ledger:
|
||||
held = client or GlobalClient.from_env()
|
||||
return RoutingLedger(
|
||||
local=FileLedger(),
|
||||
global_ledger=GlobalLedger(GlobalClient.from_env()),
|
||||
global_ledger=GlobalLedger(held),
|
||||
)
|
||||
|
||||
|
||||
def _client_of(ledger: Ledger) -> GlobalClient | None:
|
||||
inner = getattr(ledger, "global_ledger", None)
|
||||
client = getattr(inner, "client", None)
|
||||
if isinstance(client, GlobalClient):
|
||||
return client
|
||||
return None
|
||||
|
||||
|
||||
_HARDENED_TOOLS: set[int] = set()
|
||||
|
||||
|
||||
|
|
@ -57,9 +74,18 @@ def _harden_hash_only_tools(server: MCPServer) -> None:
|
|||
_HARDENED_TOOLS.add(tid)
|
||||
|
||||
|
||||
def build_mcp(ledger: Ledger | None = None) -> MCPServer:
|
||||
global _ledger
|
||||
_ledger = ledger or default_ledger()
|
||||
def build_mcp(
|
||||
ledger: Ledger | None = None,
|
||||
global_client: GlobalClient | None = None,
|
||||
) -> MCPServer:
|
||||
global _ledger, _global_client
|
||||
if ledger is None:
|
||||
client = global_client or GlobalClient.from_env()
|
||||
_ledger = default_ledger(client)
|
||||
_global_client = client
|
||||
else:
|
||||
_ledger = ledger
|
||||
_global_client = global_client or _client_of(ledger) or GlobalClient()
|
||||
_harden_hash_only_tools(mcp)
|
||||
return mcp
|
||||
|
||||
|
|
@ -72,7 +98,7 @@ def _tool_error(exc: ValueError) -> ToolError:
|
|||
async def stamp(
|
||||
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
|
||||
) -> Receipts:
|
||||
"""Commit a SHA-256. scope=local is the leaf file ledger; scope=global is api.veraetime.net."""
|
||||
"""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
|
||||
|
|
@ -88,6 +114,41 @@ async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
|
|||
return await _ledger.verify(receipt_id, want_scope=want_scope)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def login(username: str, password: str) -> LoginResult:
|
||||
"""Log in to api.veraetime.net. Stores the JWT on this host and does not return it."""
|
||||
try:
|
||||
return await asyncio.to_thread(_global_client.login, username, password)
|
||||
except ValueError as exc:
|
||||
raise _tool_error(exc) from exc
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def session() -> SessionResult:
|
||||
"""Validate the stored VeraeTime JWT. Returns valid, username, role, and expires_at."""
|
||||
return await asyncio.to_thread(_global_client.session)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def signup(username: str, password: str) -> CreateUserResult:
|
||||
"""Create a VeraeTime user account. Role is user. Does not replace the stored JWT."""
|
||||
try:
|
||||
return await asyncio.to_thread(_global_client.signup, username, password)
|
||||
except ValueError as exc:
|
||||
raise _tool_error(exc) from exc
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_user(username: str, password: str, role: str) -> CreateUserResult:
|
||||
"""Create a VeraeTime user. The stored JWT must belong to an admin."""
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_global_client.create_user, username, password, role
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise _tool_error(exc) from exc
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def hash_report(
|
||||
sha256: str, org_id: str = "", want_scope: str = ""
|
||||
|
|
@ -103,7 +164,8 @@ async def hash_report(
|
|||
|
||||
|
||||
def main() -> None:
|
||||
build_mcp(default_ledger())
|
||||
client = GlobalClient.from_env()
|
||||
build_mcp(default_ledger(client), global_client=client)
|
||||
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
||||
if transport == "streamable-http":
|
||||
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
|
||||
|
|
@ -115,7 +177,7 @@ def main() -> None:
|
|||
h.strip()
|
||||
for h in os.environ.get(
|
||||
"VERAE_MCP_ALLOWED_HOSTS",
|
||||
"mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000",
|
||||
"mcp.veraetime.net,mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000",
|
||||
).split(",")
|
||||
if h.strip()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt():
|
|||
assert payload["digest"] == SHA256_EMPTY
|
||||
assert payload["hashAlg"] == "SHA256"
|
||||
assert payload["restamp"] is False
|
||||
assert payload["wait"] is True
|
||||
assert "wait" not in payload
|
||||
assert "digest" in payload
|
||||
assert headers["Authorization"] == "Bearer secret"
|
||||
assert rec.scope == "global"
|
||||
assert rec.receipt_id == "job-1"
|
||||
|
|
@ -72,6 +73,49 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt():
|
|||
assert rec.cube_id == "cube-1"
|
||||
|
||||
|
||||
async def test_existing_digest_returns_first_certificate_without_status_poll():
|
||||
class ExistingHttp(FakeHttp):
|
||||
def request(self, method, url, payload, headers):
|
||||
self.calls.append((method, url, payload, headers))
|
||||
if url.endswith("/api/timestamp"):
|
||||
return _Resp(
|
||||
200,
|
||||
{
|
||||
"jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd",
|
||||
"existing": True,
|
||||
"certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA",
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected {method} {url}")
|
||||
|
||||
http = ExistingHttp()
|
||||
client = GlobalClient("https://api.veraetime.net", http=http)
|
||||
rec = client.submit(SHA256_EMPTY)
|
||||
assert rec.receipt_id == "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA"
|
||||
assert http.calls[0][2]["digest"] == SHA256_EMPTY
|
||||
assert http.calls[0][2]["restamp"] is False
|
||||
assert len(http.calls) == 1
|
||||
|
||||
|
||||
async def test_existing_without_certificate_polls_job():
|
||||
class PendingExisting(FakeHttp):
|
||||
def request(self, method, url, payload, headers):
|
||||
self.calls.append((method, url, payload, headers))
|
||||
if url.endswith("/api/timestamp"):
|
||||
return _Resp(200, {"jobId": "job-9", "existing": True})
|
||||
if url.endswith("/api/status/job-9"):
|
||||
return _Resp(
|
||||
200,
|
||||
{"id": "job-9", "status": "completed", "result": "QmPending"},
|
||||
)
|
||||
return _Resp(404, {"error": "nope"})
|
||||
|
||||
http = PendingExisting()
|
||||
rec = GlobalClient("https://api.veraetime.net", http=http).submit(SHA256_EMPTY)
|
||||
assert rec.receipt_id == "QmPending"
|
||||
assert http.calls[1][0] == "GET"
|
||||
|
||||
|
||||
async def test_global_verify_posts_certificate():
|
||||
http = FakeHttp()
|
||||
ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", http=http))
|
||||
|
|
@ -106,3 +150,159 @@ async def test_routing_sends_only_global_scope_to_central():
|
|||
|
||||
weak = await routed.verify(local_rec.receipt_id, want_scope="global")
|
||||
assert weak.valid is False
|
||||
|
||||
|
||||
def _auth_http(role: str = "admin"):
|
||||
class AuthHttp(FakeHttp):
|
||||
def request(self, method, url, payload, headers):
|
||||
self.calls.append((method, url, payload, headers))
|
||||
if url.endswith("/auth/login"):
|
||||
return _Resp(
|
||||
200,
|
||||
{
|
||||
"token": "fresh-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z",
|
||||
"user": {"id": "u1", "username": "admin", "role": "admin"},
|
||||
},
|
||||
)
|
||||
if url.endswith("/auth/validate"):
|
||||
if headers.get("Authorization") != "Bearer fresh-token":
|
||||
return _Resp(401, {"error": "Unauthorized"})
|
||||
return _Resp(
|
||||
200,
|
||||
{
|
||||
"valid": True,
|
||||
"userId": "u1",
|
||||
"username": "admin",
|
||||
"role": role,
|
||||
"expiresAt": "2099-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
if url.endswith("/auth/signup"):
|
||||
assert "Authorization" not in headers
|
||||
return _Resp(
|
||||
201,
|
||||
{"id": "u3", "username": payload["username"], "role": "user"},
|
||||
)
|
||||
if url.endswith("/auth/users"):
|
||||
return _Resp(
|
||||
201,
|
||||
{"id": "u2", "username": payload["username"], "role": payload["role"]},
|
||||
)
|
||||
if url.endswith("/api/timestamp"):
|
||||
return _Resp(
|
||||
200,
|
||||
{
|
||||
"jobId": "job-1",
|
||||
"existing": True,
|
||||
"certificate": "QmFresh",
|
||||
},
|
||||
)
|
||||
return _Resp(404, {"error": "nope"})
|
||||
|
||||
return AuthHttp()
|
||||
|
||||
|
||||
async def test_login_stores_token_and_hides_it_from_result(tmp_path):
|
||||
http = _auth_http()
|
||||
path = tmp_path / "auth.properties"
|
||||
client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
|
||||
result = client.login("admin", "secret-pass")
|
||||
assert result.username == "admin"
|
||||
assert result.role == "admin"
|
||||
assert result.expires_at == "2099-01-01T00:00:00Z"
|
||||
assert "fresh-token" not in result.model_dump().values()
|
||||
stored = path.read_text()
|
||||
assert "access_token=fresh-token" in stored
|
||||
assert "username=admin" in stored
|
||||
assert "password=secret-pass" in stored
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert client.token == "fresh-token"
|
||||
assert http.calls[0][2] == {"username": "admin", "password": "secret-pass"}
|
||||
|
||||
|
||||
async def test_session_and_create_user_require_admin(tmp_path):
|
||||
http = _auth_http(role="user")
|
||||
client = GlobalClient(
|
||||
"https://api.veraetime.net",
|
||||
token="fresh-token",
|
||||
http=http,
|
||||
auth_path=tmp_path / "auth.properties",
|
||||
)
|
||||
seen = client.session()
|
||||
assert seen.valid is True
|
||||
assert seen.role == "user"
|
||||
with pytest.raises(ValueError, match="admin login required"):
|
||||
client.create_user("newuser", "secret-pass", "user")
|
||||
assert not any(url.endswith("/auth/users") for _, url, _, _ in http.calls)
|
||||
|
||||
|
||||
async def test_create_user_posts_when_admin(tmp_path):
|
||||
http = _auth_http(role="admin")
|
||||
client = GlobalClient(
|
||||
"https://api.veraetime.net",
|
||||
token="fresh-token",
|
||||
http=http,
|
||||
auth_path=tmp_path / "auth.properties",
|
||||
)
|
||||
created = client.create_user("newuser", "secret-pass", "user")
|
||||
assert created.id == "u2"
|
||||
assert created.username == "newuser"
|
||||
assert created.role == "user"
|
||||
assert "secret-pass" not in created.model_dump().values()
|
||||
method, url, payload, headers = http.calls[-1]
|
||||
assert method == "POST"
|
||||
assert url.endswith("/auth/users")
|
||||
assert payload["password"] == "secret-pass"
|
||||
assert headers["Authorization"] == "Bearer fresh-token"
|
||||
|
||||
|
||||
async def test_signup_creates_user_without_replacing_token(tmp_path):
|
||||
http = _auth_http()
|
||||
client = GlobalClient(
|
||||
"https://api.veraetime.net",
|
||||
token="service-token",
|
||||
http=http,
|
||||
auth_path=tmp_path / "auth.properties",
|
||||
)
|
||||
created = client.signup("newcomer", "secret-pass")
|
||||
assert created == type(created)(id="u3", username="newcomer", role="user")
|
||||
assert client.token == "service-token"
|
||||
method, url, payload, headers = http.calls[0]
|
||||
assert method == "POST"
|
||||
assert url.endswith("/auth/signup")
|
||||
assert payload == {"username": "newcomer", "password": "secret-pass"}
|
||||
assert "Authorization" not in headers
|
||||
assert "role" not in payload
|
||||
|
||||
|
||||
async def test_submit_refreshes_expired_stored_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)
|
||||
rec = client.submit(SHA256_EMPTY)
|
||||
assert rec.receipt_id == "QmFresh"
|
||||
assert http.calls[0][1].endswith("/auth/login")
|
||||
assert http.calls[1][3]["Authorization"] == "Bearer fresh-token"
|
||||
|
||||
|
||||
async def test_submit_skips_login_when_token_is_fresh(tmp_path):
|
||||
path = tmp_path / "auth.properties"
|
||||
path.write_text(
|
||||
"access_token=still-good\n"
|
||||
"expires_at=2099-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)
|
||||
rec = client.submit(SHA256_EMPTY)
|
||||
assert rec.receipt_id == "QmFresh"
|
||||
assert http.calls[0][1].endswith("/api/timestamp")
|
||||
assert http.calls[0][3]["Authorization"] == "Bearer still-good"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import base64
|
|||
import pytest
|
||||
from mcp import Client
|
||||
|
||||
from verae_mcp_timestamping_server.global_client import GlobalClient
|
||||
from verae_mcp_timestamping_server.guard import FORBIDDEN_CONTENT_KEYS
|
||||
from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger
|
||||
from verae_mcp_timestamping_server.server import build_mcp
|
||||
|
|
@ -123,3 +124,60 @@ async def test_verify_and_hash_report_tools():
|
|||
report = await client.call_tool("hash_report", {"sha256": SHA256_EMPTY})
|
||||
items = report.structured_content["receipts"]
|
||||
assert items[0]["receipt_id"] == receipt_id
|
||||
|
||||
|
||||
class _ToolHttp:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, payload, headers):
|
||||
self.calls.append((method, url, payload, headers))
|
||||
|
||||
class Resp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
def json(self):
|
||||
if url.endswith("/auth/login"):
|
||||
return {
|
||||
"token": "tool-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z",
|
||||
"user": {"id": "u1", "username": "admin", "role": "admin"},
|
||||
}
|
||||
if url.endswith("/auth/validate"):
|
||||
return {
|
||||
"valid": True,
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"expiresAt": "2099-01-01T00:00:00Z",
|
||||
}
|
||||
return {}
|
||||
|
||||
return Resp()
|
||||
|
||||
|
||||
async def test_login_tool_does_not_return_token(tmp_path):
|
||||
http = _ToolHttp()
|
||||
held = GlobalClient(
|
||||
"https://api.veraetime.net",
|
||||
http=http,
|
||||
auth_path=tmp_path / "auth.properties",
|
||||
)
|
||||
mcp = build_mcp(MemoryLedger(), global_client=held)
|
||||
async with Client(mcp, raise_exceptions=True) as client:
|
||||
listed = await client.list_tools()
|
||||
login = next(tool for tool in listed.tools if tool.name == "login")
|
||||
assert login.input_schema.get("additionalProperties") is False
|
||||
assert "password" in login.input_schema["properties"]
|
||||
result = await client.call_tool(
|
||||
"login", {"username": "admin", "password": "secret-pass"}
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["username"] == "admin"
|
||||
assert data["role"] == "admin"
|
||||
assert "token" not in data
|
||||
assert "tool-token" not in str(data)
|
||||
session = await client.call_tool("session", {})
|
||||
assert session.structured_content["valid"] is True
|
||||
assert session.structured_content["role"] == "admin"
|
||||
|
|
|
|||
Loading…
Reference in a new issue