diff --git a/migrations/versions/cli_tokens_table.py b/migrations/versions/cli_tokens_table.py new file mode 100644 index 00000000..44abb848 --- /dev/null +++ b/migrations/versions/cli_tokens_table.py @@ -0,0 +1,34 @@ +"""add cli_tokens table + +Revision ID: cli_tokens_001 +Revises: e8f9a0b1c2d3 +Create Date: 2026-04-25 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "cli_tokens_001" +down_revision = "e8f9a0b1c2d3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "cli_tokens", + sa.Column("id", sa.String(), primary_key=True, nullable=False), + sa.Column("token", sa.String(), nullable=False, unique=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created_at", sa.Integer(), nullable=False), + sa.Column("last_used_at", sa.Integer(), nullable=True), + sa.Column("expires_at", sa.Integer(), nullable=True), + ) + op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_cli_tokens_token", table_name="cli_tokens") + op.drop_table("cli_tokens") diff --git a/routstr/core/admin.py b/routstr/core/admin.py index a8c69327..202203ba 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -20,6 +20,7 @@ from ..wallet import ( from .db import ( ApiKey, CashuTransaction, + CliToken, ModelRow, UpstreamProviderRow, create_session, @@ -38,12 +39,27 @@ ADMIN_SESSION_DURATION = 3600 MAX_USAGE_ANALYTICS_HOURS = 365 * 24 -def require_admin_api(request: Request) -> None: +async def require_admin_api(request: Request) -> None: auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1] - expiry = admin_sessions.get(token) - if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=403, detail="Unauthorized") + + token = auth_header.split(" ", 1)[1] + now_ts = int(datetime.now(timezone.utc).timestamp()) + + # 1) Short-lived session token (in-memory) + expiry = admin_sessions.get(token) + if expiry and expiry > now_ts: + return + + # 2) Long-lived CLI token (DB-backed) + async with create_session() as session: + result = await session.exec(select(CliToken).where(CliToken.token == token)) + cli_token = result.first() + if cli_token and (cli_token.expires_at is None or cli_token.expires_at > now_ts): + cli_token.last_used_at = now_ts + session.add(cli_token) + await session.commit() return raise HTTPException(status_code=403, detail="Unauthorized") @@ -242,6 +258,73 @@ async def admin_logout(request: Request) -> dict[str, object]: return {"ok": True} +# ─── CLI Tokens (long-lived bearer tokens for CLI/agent use) ─── + + +class CliTokenCreate(BaseModel): + name: str + expires_in_days: int | None = None + + +@admin_router.get("/api/cli-tokens", dependencies=[Depends(require_admin_api)]) +async def list_cli_tokens() -> list[dict[str, object]]: + async with create_session() as session: + result = await session.exec(select(CliToken)) + tokens = result.all() + return [ + { + "id": t.id, + "name": t.name, + "token_preview": f"{t.token[:8]}...{t.token[-4:]}", + "created_at": t.created_at, + "last_used_at": t.last_used_at, + "expires_at": t.expires_at, + } + for t in tokens + ] + + +@admin_router.post("/api/cli-tokens", dependencies=[Depends(require_admin_api)]) +async def create_cli_token(payload: CliTokenCreate) -> dict[str, object]: + name = (payload.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Name is required") + + raw_token = secrets.token_urlsafe(32) + expires_at: int | None = None + if payload.expires_in_days is not None and payload.expires_in_days > 0: + expires_at = int(datetime.now(timezone.utc).timestamp()) + ( + payload.expires_in_days * 86400 + ) + + async with create_session() as session: + cli_token = CliToken(token=raw_token, name=name, expires_at=expires_at) + session.add(cli_token) + await session.commit() + await session.refresh(cli_token) + + return { + "id": cli_token.id, + "name": cli_token.name, + "token": raw_token, # full token returned only on creation + "created_at": cli_token.created_at, + "expires_at": cli_token.expires_at, + } + + +@admin_router.delete( + "/api/cli-tokens/{token_id}", dependencies=[Depends(require_admin_api)] +) +async def revoke_cli_token(token_id: str) -> dict[str, object]: + async with create_session() as session: + cli_token = await session.get(CliToken, token_id) + if not cli_token: + raise HTTPException(status_code=404, detail="Token not found") + await session.delete(cli_token) + await session.commit() + return {"ok": True, "deleted_id": token_id} + + class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None diff --git a/routstr/core/db.py b/routstr/core/db.py index b2d855bb..a0992a62 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -239,6 +239,22 @@ class RoutstrFee(SQLModel, table=True): # type: ignore last_paid_at: int | None = Field(default=None) +class CliToken(SQLModel, table=True): # type: ignore + """Long-lived authorization token for CLI/agent use against admin endpoints.""" + + __tablename__ = "cli_tokens" + id: str = Field( + primary_key=True, default_factory=lambda: uuid.uuid4().hex + ) + token: str = Field(unique=True, index=True, description="Bearer token value") + name: str = Field(description="Human-readable label for this token") + created_at: int = Field(default_factory=lambda: int(time.time())) + last_used_at: int | None = Field(default=None) + expires_at: int | None = Field( + default=None, description="Optional expiry unix timestamp; null = never expires" + ) + + async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None: stmt = ( update(RoutstrFee) diff --git a/tests/integration/test_cli_tokens.py b/tests/integration/test_cli_tokens.py new file mode 100644 index 00000000..3e3c75c1 --- /dev/null +++ b/tests/integration/test_cli_tokens.py @@ -0,0 +1,303 @@ +"""Integration tests for CLI token management (/admin/api/cli-tokens). + +Covers: +- GET /admin/api/cli-tokens — list (preview only, no full token) +- POST /admin/api/cli-tokens — create (returns full token once) +- DELETE /admin/api/cli-tokens/{id} — revoke +- Using a CLI token as Bearer auth against admin endpoints +- Expiry enforcement (expired tokens are rejected by require_admin_api) +- last_used_at bump on successful use +- Auth failures: missing token, wrong token, revoked token +""" + +from __future__ import annotations + +import secrets +import time +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlmodel import select + +from routstr.core.admin import admin_sessions +from routstr.core.db import AsyncSession, CliToken + + +# ────────────────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture +async def admin_session_token() -> AsyncGenerator[str, None]: + """Inject a short-lived admin session token into admin_sessions.""" + token = secrets.token_urlsafe(24) + admin_sessions[token] = int(time.time()) + 3600 + yield token + admin_sessions.pop(token, None) + + +@pytest_asyncio.fixture +async def admin_client( + integration_client: AsyncClient, admin_session_token: str +) -> AsyncClient: + """An integration_client pre-authenticated with an admin session token.""" + integration_client.headers["Authorization"] = f"Bearer {admin_session_token}" + return integration_client + + +# ────────────────────────────────────────────────────────────────────────────── +# Creation +# ────────────────────────────────────────────────────────────────────────────── + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_create_cli_token_returns_full_token_once( + admin_client: AsyncClient, +) -> None: + """POST /admin/api/cli-tokens returns the raw token only on creation.""" + resp = await admin_client.post( + "/admin/api/cli-tokens", + json={"name": "my-laptop"}, + ) + assert resp.status_code == 200 + body = resp.json() + + assert body["name"] == "my-laptop" + assert isinstance(body["id"], str) and body["id"] + assert isinstance(body["token"], str) and len(body["token"]) >= 32 + assert body["expires_at"] is None + assert isinstance(body["created_at"], int) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_create_cli_token_with_expiry(admin_client: AsyncClient) -> None: + """expires_in_days sets expires_at ~= now + days * 86400.""" + before = int(time.time()) + resp = await admin_client.post( + "/admin/api/cli-tokens", + json={"name": "ci-runner", "expires_in_days": 7}, + ) + assert resp.status_code == 200 + body = resp.json() + + assert body["expires_at"] is not None + delta = body["expires_at"] - before + # Allow 10s jitter around 7 * 86400 + assert 7 * 86400 - 10 <= delta <= 7 * 86400 + 10 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_create_cli_token_rejects_empty_name( + admin_client: AsyncClient, +) -> None: + resp = await admin_client.post( + "/admin/api/cli-tokens", json={"name": " "} + ) + assert resp.status_code == 400 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_create_cli_token_requires_admin( + integration_client: AsyncClient, +) -> None: + """No admin token / no bearer → 403.""" + resp = await integration_client.post( + "/admin/api/cli-tokens", json={"name": "no-auth"} + ) + assert resp.status_code == 403 + + +# ────────────────────────────────────────────────────────────────────────────── +# Listing +# ────────────────────────────────────────────────────────────────────────────── + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_list_cli_tokens_returns_preview_not_full_token( + admin_client: AsyncClient, +) -> None: + """Listing never leaks the raw token.""" + create = await admin_client.post( + "/admin/api/cli-tokens", json={"name": "secret-keeper"} + ) + assert create.status_code == 200 + full_token = create.json()["token"] + + resp = await admin_client.get("/admin/api/cli-tokens") + assert resp.status_code == 200 + items = resp.json() + assert any(t["name"] == "secret-keeper" for t in items) + + for t in items: + # No 'token' field, only 'token_preview' + assert "token" not in t + assert "token_preview" in t + assert full_token not in t["token_preview"] + assert "..." in t["token_preview"] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_list_cli_tokens_requires_admin( + integration_client: AsyncClient, +) -> None: + resp = await integration_client.get("/admin/api/cli-tokens") + assert resp.status_code == 403 + + +# ────────────────────────────────────────────────────────────────────────────── +# Using a CLI token as admin auth +# ────────────────────────────────────────────────────────────────────────────── + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_cli_token_authorizes_admin_endpoints( + admin_client: AsyncClient, + integration_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + """A freshly-created CLI token can be used as Bearer on admin endpoints.""" + create = await admin_client.post( + "/admin/api/cli-tokens", json={"name": "cli-auth"} + ) + assert create.status_code == 200 + cli_token = create.json()["token"] + token_id = create.json()["id"] + + # Use a NEW client to isolate the header from admin_session_token + integration_client.headers["Authorization"] = f"Bearer {cli_token}" + resp = await integration_client.get("/admin/api/cli-tokens") + assert resp.status_code == 200 + + # last_used_at should be populated after use + row = await integration_session.get(CliToken, token_id) + assert row is not None + assert row.last_used_at is not None + assert row.last_used_at >= row.created_at + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_expired_cli_token_is_rejected( + admin_client: AsyncClient, + integration_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + """A CLI token with expires_at in the past → 403.""" + create = await admin_client.post( + "/admin/api/cli-tokens", + json={"name": "will-expire", "expires_in_days": 1}, + ) + assert create.status_code == 200 + cli_token = create.json()["token"] + token_id = create.json()["id"] + + # Force-expire it in the DB + row = await integration_session.get(CliToken, token_id) + assert row is not None + row.expires_at = int(time.time()) - 1 + integration_session.add(row) + await integration_session.commit() + + integration_client.headers["Authorization"] = f"Bearer {cli_token}" + resp = await integration_client.get("/admin/api/cli-tokens") + assert resp.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_invalid_bearer_token_is_rejected( + integration_client: AsyncClient, +) -> None: + integration_client.headers["Authorization"] = "Bearer not-a-real-token" + resp = await integration_client.get("/admin/api/cli-tokens") + assert resp.status_code == 403 + + +# ────────────────────────────────────────────────────────────────────────────── +# Revocation +# ────────────────────────────────────────────────────────────────────────────── + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_revoke_cli_token_removes_auth( + admin_client: AsyncClient, + integration_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + """After DELETE, the token no longer authorizes.""" + create = await admin_client.post( + "/admin/api/cli-tokens", json={"name": "to-revoke"} + ) + token_id = create.json()["id"] + cli_token = create.json()["token"] + + revoke = await admin_client.delete(f"/admin/api/cli-tokens/{token_id}") + assert revoke.status_code == 200 + assert revoke.json() == {"ok": True, "deleted_id": token_id} + + # Row is gone + row = await integration_session.get(CliToken, token_id) + assert row is None + + # Can no longer be used for auth + integration_client.headers["Authorization"] = f"Bearer {cli_token}" + resp = await integration_client.get("/admin/api/cli-tokens") + assert resp.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_revoke_unknown_cli_token_returns_404( + admin_client: AsyncClient, +) -> None: + resp = await admin_client.delete("/admin/api/cli-tokens/does-not-exist") + assert resp.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_revoke_cli_token_requires_admin( + integration_client: AsyncClient, +) -> None: + resp = await integration_client.delete("/admin/api/cli-tokens/anything") + assert resp.status_code == 403 + + +# ────────────────────────────────────────────────────────────────────────────── +# Lifecycle / uniqueness +# ────────────────────────────────────────────────────────────────────────────── + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_multiple_tokens_are_independent( + admin_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + """Creating N tokens yields N unique tokens that all live in DB.""" + names = ["dev-a", "dev-b", "dev-c"] + raw_tokens: list[str] = [] + ids: list[str] = [] + for name in names: + r = await admin_client.post( + "/admin/api/cli-tokens", json={"name": name} + ) + assert r.status_code == 200 + raw_tokens.append(r.json()["token"]) + ids.append(r.json()["id"]) + + # All unique + assert len(set(raw_tokens)) == len(raw_tokens) + assert len(set(ids)) == len(ids) + + # All in DB + result = await integration_session.exec( + select(CliToken).where(CliToken.name.in_(names)) # type: ignore[attr-defined] + ) + rows = result.all() + assert {r.name for r in rows} == set(names) diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx index 2e999307..ea6727d2 100644 --- a/ui/app/settings/page.tsx +++ b/ui/app/settings/page.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ServerConfigSettings } from '@/components/settings/server-config-settings'; import { AdminSettings } from '@/components/settings/admin-settings'; +import { CliTokensSettings } from '@/components/settings/cli-tokens-settings'; import { AppPageShell } from '@/components/app-page-shell'; import { PageHeader } from '@/components/page-header'; @@ -19,6 +20,7 @@ export default function SettingsPage() { Admin Settings Server Config + CLI Tokens @@ -26,6 +28,9 @@ export default function SettingsPage() { + + + diff --git a/ui/components/settings/cli-tokens-settings.tsx b/ui/components/settings/cli-tokens-settings.tsx new file mode 100644 index 00000000..33921f5d --- /dev/null +++ b/ui/components/settings/cli-tokens-settings.tsx @@ -0,0 +1,265 @@ +'use client'; + +import * as React from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { + AdminService, + type CliTokenListItem, + type CliTokenCreated, +} from '@/lib/api/services/admin'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle, Copy, Trash2, Check } from 'lucide-react'; +import { toast } from 'sonner'; + +function formatTs(ts: number | null): string { + if (!ts) return '—'; + return new Date(ts * 1000).toLocaleString(); +} + +export function CliTokensSettings(): React.ReactElement { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [name, setName] = useState(''); + const [expiresInDays, setExpiresInDays] = useState(''); + const [creating, setCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + + const loadTokens = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const data = await AdminService.listCliTokens(); + setTokens(data); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to load tokens'; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadTokens(); + }, [loadTokens]); + + async function handleCreate(): Promise { + const trimmed = name.trim(); + if (!trimmed) { + toast.error('Name is required'); + return; + } + const days = expiresInDays.trim() + ? Number.parseInt(expiresInDays.trim(), 10) + : undefined; + if (days !== undefined && (Number.isNaN(days) || days <= 0)) { + toast.error('Expiry must be a positive number of days'); + return; + } + + setCreating(true); + try { + const created = await AdminService.createCliToken(trimmed, days); + setNewToken(created); + setName(''); + setExpiresInDays(''); + await loadTokens(); + toast.success('Token created. Copy it now — it will not be shown again.'); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to create token'; + toast.error(message); + } finally { + setCreating(false); + } + } + + async function handleRevoke(id: string): Promise { + if ( + !confirm('Revoke this token? Any CLI/agent using it will lose access.') + ) { + return; + } + try { + await AdminService.revokeCliToken(id); + await loadTokens(); + toast.success('Token revoked'); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Failed to revoke token'; + toast.error(message); + } + } + + async function handleCopy(): Promise { + if (!newToken) return; + await navigator.clipboard.writeText(newToken.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+ + + Create CLI Token + + Generate a long-lived bearer token for the Routstr CLI or AI agents. + Use this token in ~/.routstr/config.json or with{' '} + routstr init --token <token>. + + + + {newToken && ( + + +
+ Token created. Copy it now — it will not be shown again. +
+
+ + {newToken.token} + + +
+ +
+
+ )} +
+
+ + setName(e.target.value)} + disabled={creating} + /> +
+
+ + setExpiresInDays(e.target.value)} + disabled={creating} + /> +
+
+ +
+
+ + + + Active Tokens + + Tokens authorize CLI/agent calls to admin endpoints. Revoke any + token that may have been exposed. + + + + {error && ( + + + {error} + + )} + {loading ? ( +
+ + +
+ ) : tokens.length === 0 ? ( +

+ No tokens yet. Create one above. +

+ ) : ( +
+ + + + + + + + + + + + + {tokens.map((t) => ( + + + + + + + + + ))} + +
NameTokenCreatedLast usedExpires
{t.name} + {t.token_preview} + + {formatTs(t.created_at)} + + {formatTs(t.last_used_at)} + + {t.expires_at ? formatTs(t.expires_at) : 'Never'} + + +
+
+ )} +
+
+
+ ); +} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index f813e8ae..0f7cc3f8 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -966,6 +966,45 @@ export class AdminService { balance_data: number | null | Record; }>(`/admin/api/upstream-providers/${providerId}/balance`); } + + // ── CLI Tokens ── + + static async listCliTokens(): Promise { + return await apiClient.get('/admin/api/cli-tokens'); + } + + static async createCliToken( + name: string, + expiresInDays?: number + ): Promise { + return await apiClient.post('/admin/api/cli-tokens', { + name, + expires_in_days: expiresInDays ?? null, + }); + } + + static async revokeCliToken(tokenId: string): Promise<{ ok: boolean }> { + return await apiClient.delete<{ ok: boolean }>( + `/admin/api/cli-tokens/${encodeURIComponent(tokenId)}` + ); + } +} + +export interface CliTokenListItem { + id: string; + name: string; + token_preview: string; + created_at: number; + last_used_at: number | null; + expires_at: number | null; +} + +export interface CliTokenCreated { + id: string; + name: string; + token: string; + created_at: number; + expires_at: number | null; } export const TemporaryBalanceSchema = z.object({