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:
parent
b89c4ad4d2
commit
c3e0cd3abb
2 changed files with 974 additions and 105 deletions
102
tests/test_oauth_login.py
Normal file
102
tests/test_oauth_login.py
Normal 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
39
tests/test_setup.py
Normal 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
|
||||
Loading…
Reference in a new issue