feat: add setup, browser login, and an install homepage

setup registers or logs in and writes ~/.verae/auth.properties
without printing the JWT. A caller bearer is used for stamp and
verify instead of the host login. VERAE_MCP_OAUTH=1 serves the
browser account page. site/index.html is the public install guide.
This commit is contained in:
George Lambert 2026-09-23 12:04:15 -04:00
commit c3e0cd3abb
18 changed files with 974 additions and 105 deletions

View file

@ -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/`.

View file

@ -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.

View file

@ -292,9 +292,9 @@ replace the JWT stored for <code>stamp</code>. A new account calls
</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>
<code>id</code>, <code>username</code>, and <code>role</code>. Public
registration is <code>signup</code>. <code>create_user</code> is how an
admin sets a role.</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

View file

@ -112,12 +112,20 @@
<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>
<p>A new machine runs
<code>uvx verae-mcp-timestamping-server setup</code>. That creates an
account or logs in and writes <code>~/.verae/auth.properties</code>
(mode 600). It prints username, role, and expiry, not the JWT.
<code>session</code> checks that file. Starting the server with no login
prints the setup command on stderr.</p>
<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>
bearer token. <code>create_user</code> adds an account when the stored
role is <code>admin</code>.</p>
<p>Set <code>VERAE_MCP_OAUTH=1</code> and
<code>VERAE_MCP_PUBLIC_URL</code> 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 <code>stamp</code> and is not written over the host
file.</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>

View file

@ -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:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -3,89 +3,217 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verae timestamping MCP sample</title>
<title>Install Verae timestamping</title>
<meta name="description" content="Register a VeraeTime account, store the token on your machine, and connect Claude, Cursor, or another MCP client.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="icon" href="assets/VeraeFullLogo.png">
<style>
body { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
label { display: block; margin-top: 0.8rem; }
input, select, button, textarea { font: inherit; width: 100%; box-sizing: border-box; }
button { width: auto; margin-top: 1rem; }
pre { white-space: pre-wrap; background: #f4f4f4; padding: 0.8rem; }
code { font-family: ui-monospace, monospace; }
:root {
--ink: oklch(0.21 0.020 310);
--ink-soft: oklch(0.42 0.018 310);
--ink-mute: oklch(0.60 0.014 310);
--paper: oklch(0.992 0.004 90);
--soft: oklch(0.972 0.008 85);
--hair: oklch(0.90 0.010 80);
--purple: oklch(0.32 0.110 310);
--purple-mid: oklch(0.42 0.130 312);
--purple-wash: oklch(0.96 0.020 312);
--gold: oklch(0.80 0.090 85);
--dark: oklch(0.19 0.022 312);
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--ink);
background: var(--paper);
line-height: 1.55;
}
a { color: var(--purple); }
header {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 28px 28px 0;
}
.brand img { height: 46px; width: auto; display: block; }
nav { display: flex; gap: 22px; font-size: 14px; font-weight: 600; }
nav a { color: var(--ink-soft); text-decoration: none; }
nav a:hover { color: var(--purple); }
.hero {
max-width: 1100px;
margin: 0 auto;
padding: 64px 28px 24px;
}
.eyebrow {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--purple-mid);
}
h1 {
font-family: Fraunces, Georgia, serif;
font-weight: 560;
font-size: clamp(40px, 6vw, 68px);
line-height: 1.05;
letter-spacing: -0.03em;
margin: 12px 0 18px;
max-width: 16ch;
}
.lede { max-width: 40rem; font-size: 18px; color: var(--ink-soft); margin: 0; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 12px 28px 80px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 36px; }
.card {
background: white;
border: 1px solid var(--hair);
border-radius: 16px;
padding: 22px 22px 18px;
}
.card h2 { font-size: 18px; margin: 0 0 8px; }
.card p { margin: 0; color: var(--ink-soft); font-size: 15px; }
.step { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--purple-mid); }
h2.section {
font-family: Fraunces, Georgia, serif;
font-size: 34px;
font-weight: 560;
margin: 56px 0 8px;
}
.section-note { color: var(--ink-soft); max-width: 42rem; margin-top: 0; }
pre {
background: var(--dark);
color: oklch(0.95 0.01 90);
border-radius: 14px;
padding: 18px 20px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13.5px;
line-height: 1.5;
}
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
.path { background: var(--purple-wash); padding: 2px 6px; border-radius: 6px; }
ol.steps { padding-left: 1.2rem; max-width: 44rem; }
ol.steps li { margin: 0.55rem 0; }
.gold { height: 3px; width: 72px; background: var(--gold); border: 0; margin: 28px 0; }
footer {
border-top: 1px solid var(--hair);
padding: 28px;
color: var(--ink-mute);
font-size: 14px;
}
footer .wrap { padding: 0; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
header { padding-top: 18px; }
nav { gap: 14px; }
}
</style>
</head>
<body>
<h1>Verae timestamping MCP sample</h1>
<p>This page calls a <strong>Model Context Protocol</strong> (MCP) server. The three letters are MCP. The server name is <code>verae-timestamping</code>. It stamps a SHA-256 digest. It does not upload a file.</p>
<p>Start the server on this machine, then submit. A page on the public internet cannot reach <code>127.0.0.1</code>.</p>
<pre>VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server</pre>
<form id="stamp-form">
<label>Server URL <input id="url" value="http://127.0.0.1:8000/mcp"></label>
<label>SHA-256 <input id="sha" value="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" spellcheck="false"></label>
<label>Scope
<select id="scope">
<option>local</option>
<option>shared</option>
<option>global</option>
</select>
</label>
<label>Tool
<select id="tool">
<option value="stamp">stamp</option>
<option value="verify">verify</option>
<option value="hash_report">hash_report</option>
</select>
</label>
<label>Receipt id (verify) <input id="receipt" placeholder="from a previous stamp"></label>
<button type="submit">Call tool</button>
</form>
<h2>Result</h2>
<pre id="out">Waiting.</pre>
<p>Docs in this repo: <a href="../docs/html/index.html">HTML</a> · <a href="../docs/pdf/standards.pdf">standards PDF</a></p>
<p>Same files on the public host: <a href="/docs/html/index.html">HTML</a> · <a href="/docs/pdf/standards.pdf">standards PDF</a></p>
<script>
const out = document.getElementById("out");
function rpc(id, method, params) {
return { jsonrpc: "2.0", id, method, params };
<header>
<a class="brand" href="https://www.verae.com/"><img src="assets/VeraeFullLogo.png" alt="Verae"></a>
<nav>
<a href="#register">Register</a>
<a href="#clients">Clients</a>
<a href="#token">Token</a>
<a href="sample.html">Try a stamp</a>
</nav>
</header>
<main>
<section class="hero">
<p class="eyebrow">Model Context Protocol</p>
<h1>Install Verae timestamping.</h1>
<p class="lede">Register once. The token stays in a private file on your machine, or in your assistant’s own login store. It does not go in the chat, and it does not go in the client config.</p>
<hr class="gold">
</section>
<div class="wrap">
<section id="register">
<h2 class="section">Register on your machine</h2>
<p class="section-note">This is the install for a person or a server you control. <code>uv</code> runs the command. You do not clone this repository.</p>
<pre>uvx verae-mcp-timestamping-server setup</pre>
<ol class="steps">
<li>Choose <strong>new</strong> to create a VeraeTime account, or <strong>existing</strong> to log in.</li>
<li>Enter a username and password. The command calls <code>https://api.veraetime.net</code>.</li>
<li>It writes <span class="path">~/.verae/auth.properties</span> with mode <code>600</code> and prints your username, role, and expiry. It does not print the token.</li>
<li>Check it later with <code>uvx verae-mcp-timestamping-server session</code>.</li>
</ol>
<p>If you start the server before registering, it prints those same steps on its error stream and still starts. Global stamps need the file. Local stamps do not.</p>
</section>
<section id="clients">
<h2 class="section">Connect an assistant</h2>
<p class="section-note">Pick the environment you use. The config names the program. It does not contain the token.</p>
<div class="grid">
<article class="card">
<p class="step">Claude Desktop</p>
<h2>macOS and Windows</h2>
<p>macOS file: <span class="path">~/Library/Application Support/Claude/claude_desktop_config.json</span>. Windows file: <span class="path">%APPDATA%\Claude\claude_desktop_config.json</span>. Restart Claude after saving.</p>
</article>
<article class="card">
<p class="step">Cursor</p>
<h2>Project or user</h2>
<p>User file: <span class="path">~/.cursor/mcp.json</span>. A project file is <span class="path">.cursor/mcp.json</span> in that project. Restart Cursor after saving.</p>
</article>
<article class="card">
<p class="step">VS Code and others</p>
<h2>mcp.json</h2>
<p>The same JSON works for any client that reads the Model Context Protocol <code>mcpServers</code> map, including VS Code with an MCP extension.</p>
</article>
</div>
<h2 class="section">Run it on your computer</h2>
<p class="section-note">The assistant starts the server. Your login in <span class="path">~/.verae/auth.properties</span> is the one it uses.</p>
<pre>{
"mcpServers": {
"verae-timestamping": {
"command": "uvx",
"args": ["verae-mcp-timestamping-server"]
}
async function post(url, body, session) {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"
};
if (session) headers["Mcp-Session-Id"] = session;
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
const text = await res.text();
const sessionId = res.headers.get("mcp-session-id") || session;
let parsed = text;
try { parsed = JSON.parse(text); } catch (e) { /* SSE or plain text */ }
if (!res.ok) throw new Error(res.status + " " + text);
return { parsed, sessionId };
}
document.getElementById("stamp-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const url = document.getElementById("url").value.trim();
const tool = document.getElementById("tool").value;
const sha = document.getElementById("sha").value.trim();
const scope = document.getElementById("scope").value;
const receipt = document.getElementById("receipt").value.trim();
out.textContent = "Calling " + url + " …";
try {
const init = await post(url, rpc(1, "initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "verae-mcp-sample-page", version: "0.1.0" }
}));
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
let args;
if (tool === "verify") args = { receipt_id: receipt, want_scope: scope };
else if (tool === "hash_report") args = { sha256: sha, want_scope: scope };
else args = { sha256: sha, scope };
const call = await post(url, rpc(2, "tools/call", { name: tool, arguments: args }), init.sessionId);
out.textContent = JSON.stringify({ initialize: init.parsed, call: call.parsed }, null, 2);
} catch (err) {
out.textContent = String(err) + "\n\nIf this page is not on the same machine as the server, the browser will block the call.";
}</pre>
<h2 class="section">Use the public server</h2>
<p class="section-note">No local program. The assistant connects to this host. When per-user sign-in is on, the first connection opens a browser page at <a href="/account">/account</a> to create an account or log in. That token stays in the assistant’s OAuth store, not in this file.</p>
<pre>{
"mcpServers": {
"verae-timestamping": {
"url": "https://mcp.veraetime.net/mcp"
}
});
</script>
}
}</pre>
</section>
<section id="token">
<h2 class="section">Where the token lives</h2>
<div class="grid">
<article class="card">
<p class="step">Your machine</p>
<h2>~/.verae/auth.properties</h2>
<p>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.</p>
</article>
<article class="card">
<p class="step">Your assistant</p>
<h2>OAuth file</h2>
<p>A browser login on the public server gives the assistant a VeraeTime JWT and a refresh token. The Python client stores that under <span class="path">~/.verae/oauth/mcp-tokens.json</span>, also mode 600.</p>
</article>
<article class="card">
<p class="step">Never</p>
<h2>Chat or mcp.json</h2>
<p>Do not paste the token into a conversation or into the client config. <code>session</code> shows the username and expiry if you need to confirm the login.</p>
</article>
</div>
</section>
</div>
</main>
<footer>
<div class="wrap">
<span>Verae timestamping · <a href="https://www.verae.com/">verae.com</a></span>
<span><a href="sample.html">Try a stamp</a> · <a href="https://api.veraetime.net/docs/swagger/">API docs</a></span>
</div>
</footer>
</body>
</html>

91
site/sample.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verae timestamping MCP sample</title>
<style>
body { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
label { display: block; margin-top: 0.8rem; }
input, select, button, textarea { font: inherit; width: 100%; box-sizing: border-box; }
button { width: auto; margin-top: 1rem; }
pre { white-space: pre-wrap; background: #f4f4f4; padding: 0.8rem; }
code { font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<h1>Verae timestamping MCP sample</h1>
<p>This page calls a <strong>Model Context Protocol</strong> (MCP) server. The three letters are MCP. The server name is <code>verae-timestamping</code>. It stamps a SHA-256 digest. It does not upload a file.</p>
<p>Start the server on this machine, then submit. A page on the public internet cannot reach <code>127.0.0.1</code>.</p>
<pre>VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server</pre>
<form id="stamp-form">
<label>Server URL <input id="url" value="http://127.0.0.1:8000/mcp"></label>
<label>SHA-256 <input id="sha" value="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" spellcheck="false"></label>
<label>Scope
<select id="scope">
<option>local</option>
<option>shared</option>
<option>global</option>
</select>
</label>
<label>Tool
<select id="tool">
<option value="stamp">stamp</option>
<option value="verify">verify</option>
<option value="hash_report">hash_report</option>
</select>
</label>
<label>Receipt id (verify) <input id="receipt" placeholder="from a previous stamp"></label>
<button type="submit">Call tool</button>
</form>
<h2>Result</h2>
<pre id="out">Waiting.</pre>
<p>Docs in this repo: <a href="../docs/html/index.html">HTML</a> · <a href="../docs/pdf/standards.pdf">standards PDF</a></p>
<p>Same files on the public host: <a href="/docs/html/index.html">HTML</a> · <a href="/docs/pdf/standards.pdf">standards PDF</a></p>
<script>
const out = document.getElementById("out");
function rpc(id, method, params) {
return { jsonrpc: "2.0", id, method, params };
}
async function post(url, body, session) {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"
};
if (session) headers["Mcp-Session-Id"] = session;
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
const text = await res.text();
const sessionId = res.headers.get("mcp-session-id") || session;
let parsed = text;
try { parsed = JSON.parse(text); } catch (e) { /* SSE or plain text */ }
if (!res.ok) throw new Error(res.status + " " + text);
return { parsed, sessionId };
}
document.getElementById("stamp-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const url = document.getElementById("url").value.trim();
const tool = document.getElementById("tool").value;
const sha = document.getElementById("sha").value.trim();
const scope = document.getElementById("scope").value;
const receipt = document.getElementById("receipt").value.trim();
out.textContent = "Calling " + url + " …";
try {
const init = await post(url, rpc(1, "initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "verae-mcp-sample-page", version: "0.1.0" }
}));
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
let args;
if (tool === "verify") args = { receipt_id: receipt, want_scope: scope };
else if (tool === "hash_report") args = { sha256: sha, want_scope: scope };
else args = { sha256: sha, scope };
const call = await post(url, rpc(2, "tools/call", { name: tool, arguments: args }), init.sessionId);
out.textContent = JSON.stringify({ initialize: init.parsed, call: call.parsed }, null, 2);
} catch (err) {
out.textContent = String(err) + "\n\nIf this page is not on the same machine as the server, the browser will block the call.";
}
});
</script>
</body>
</html>

View file

@ -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:
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:
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",

View file

@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VeraeTime account</title>
<style>
body { font: 16px/1.4 system-ui, sans-serif; max-width: 28rem; margin: 3rem auto; padding: 0 1rem; }
label { display: block; margin-top: 0.8rem; }
input { width: 100%; box-sizing: border-box; padding: 0.4rem; }
button { margin-top: 1rem; margin-right: 0.5rem; padding: 0.4rem 0.8rem; }
p.error { color: #9b1c1c; }
</style>
</head>
<body>
<h1>VeraeTime</h1>
<p>Create an account or log in. The token stays in your assistant. It is not shown here.</p>
{error}
<form method="post" action="/account">
<input type="hidden" name="txn" value="{txn}">
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button name="mode" value="signup" type="submit">Create account</button>
<button name="mode" value="login" type="submit">Log in</button>
</form>
</body>
</html>
"""
@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'<p class="error">{html.escape(error)}</p>' if error else ""
body = _PAGE.format(error=block, txn=html.escape(txn))
return HTMLResponse(body)

View file

@ -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,6 +142,7 @@ 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:
with _caller_token():
receipts = await _ledger.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id
)
@ -111,6 +154,7 @@ 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)."""
with _caller_token():
return await _ledger.verify(receipt_id, want_scope=want_scope)
@ -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")

View file

@ -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)

102
tests/test_oauth_login.py Normal file
View file

@ -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"

39
tests/test_setup.py Normal file
View file

@ -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