This commit is contained in:
holden093 2026-08-04 02:40:28 +02:00 committed by GitHub
commit 11c817d21c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 443 additions and 1 deletions

View file

@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
from core.database import get_db_session, ApiToken
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_user
MAX_NAME_LEN = 100
DEFAULT_SCOPES = "chat"
@ -206,4 +206,82 @@ def setup_api_token_routes() -> APIRouter:
_invalidate_cache(request)
return {"status": "deleted"}
# ── Self-serve endpoints (cookie-only, owner forced to current user) ──
@router.get("/tokens/self")
def list_self_tokens(request: Request):
"""List the current user's own API tokens. Cookie session only."""
user = require_user(request)
with get_db_session() as db:
tokens = db.query(ApiToken).filter(
ApiToken.owner == user, ApiToken.is_active == True # noqa: E712
).all()
return [
{
"id": t.id,
"name": t.name,
"owner": getattr(t, "owner", None),
"token_prefix": t.token_prefix,
"scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()],
"is_active": t.is_active,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in tokens
]
@router.post("/tokens/self")
def create_self_token(
request: Request,
name: str = Form(""),
scopes: str = Form(None),
profile: str = Form(None),
):
"""Create an API token for the current user. Cookie session only."""
user = require_user(request)
name = name.strip()[:MAX_NAME_LEN]
if not name:
raise HTTPException(400, "Token name is required")
scope_list = _normalize_scopes(scopes, profile)
scopes_value = ",".join(scope_list)
raw_token = "ody_" + secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
token_id = str(uuid.uuid4())[:8]
with get_db_session() as db:
db.add(ApiToken(
id=token_id,
owner=user,
name=name,
token_hash=token_hash,
token_prefix=raw_token[:8],
scopes=scopes_value,
is_active=True,
))
_invalidate_cache(request)
return {
"id": token_id,
"name": name,
"owner": user,
"token": raw_token,
"token_prefix": raw_token[:8],
"scopes": scope_list,
}
@router.delete("/tokens/self/{token_id}")
def delete_self_token(request: Request, token_id: str):
"""Delete one of the current user's own API tokens. Cookie session only."""
user = require_user(request)
with get_db_session() as db:
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
# Return 404 for both "not found" and "not yours" so callers
# can't probe for the existence of another user's token ids.
if not token or token.owner != user:
raise HTTPException(404, "Token not found")
db.delete(token)
_invalidate_cache(request)
return {"status": "deleted"}
return router

View file

@ -1999,6 +1999,34 @@
<!-- Populated by JS -->
</div>
</div>
<div class="admin-card" id="settings-api-tokens-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 1a3 3 0 0 0-3 3v2H7a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9a3 3 0 0 0-3-3h-2V4a3 3 0 0 0-3-3z"/></svg>Personal API Tokens</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Create tokens to access your Odysseus data from scripts, apps, or external tools. Tokens are scoped — only grant the permissions your use case needs.</div>
<div id="settings-api-tokens-list" style="margin-bottom:10px;">
<!-- Populated by JS -->
</div>
<div id="settings-api-tokens-create" style="display:none;border-top:1px solid var(--border);padding-top:10px;margin-top:4px;">
<div style="display:flex;gap:8px;margin-bottom:8px;">
<input id="settings-api-token-name" type="text" placeholder="Token name (e.g. 'My backup script')" style="flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
<button class="admin-btn-add" id="settings-api-token-create-btn" style="white-space:nowrap;">Create</button>
</div>
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Select which data this token can access. Leave all unchecked for chat-only access.</div>
<div id="settings-api-token-scopes" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px;">
<!-- Populated by JS -->
</div>
<div id="settings-api-token-create-msg" style="font-size:11px;margin-top:6px;min-height:16px;"></div>
<div id="settings-api-token-reveal" style="display:none;margin-top:8px;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:4px;">Copy this token now — it won't be shown again.</div>
<div style="display:flex;align-items:center;gap:6px;">
<code id="settings-api-token-value" style="flex:1;padding:4px 8px;font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:4px;word-break:break-all;user-select:all;"></code>
<button class="admin-btn-sm" id="settings-api-token-copy-btn" title="Copy to clipboard" style="flex-shrink:0;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
</div>
<button class="admin-btn-add" id="settings-api-token-new-btn" style="margin-top:4px;">+ New Token</button>
</div>
</div>
<!-- ═══ EMAIL TAB ═══ -->

View file

@ -2130,6 +2130,169 @@ async function initShortcuts() {
/*
INIT & REFRESH
*/
// ── Personal API Tokens (Account tab) ──
const SELF_TOKEN_SCOPES = [
{ key: 'chat', label: 'Chat', detail: 'Chat and companion access' },
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents', detail: 'Read documents' },
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email', detail: 'Read email' },
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events' },
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
{ key: 'memory:read', label: 'Memory', detail: 'Read memory' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory' },
{ key: 'cookbook:read', label: 'Cookbook', detail: 'Read model inventory and presets' },
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Start and stop model servers' },
];
function initSelfApiTokens() {
const listEl = el('settings-api-tokens-list');
const createPanel = el('settings-api-tokens-create');
const newBtn = el('settings-api-token-new-btn');
const createBtn = el('settings-api-token-create-btn');
const nameInput = el('settings-api-token-name');
const scopesEl = el('settings-api-token-scopes');
const msgEl = el('settings-api-token-create-msg');
const revealEl = el('settings-api-token-reveal');
const tokenValueEl = el('settings-api-token-value');
const copyBtn = el('settings-api-token-copy-btn');
if (!listEl || !newBtn) return;
// Build scope checkboxes once
scopesEl.innerHTML = SELF_TOKEN_SCOPES.map(s => `
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:12px;cursor:pointer;">
<input type="checkbox" class="self-token-scope" value="${esc(s.key)}" title="${esc(s.detail)}">
<span>${esc(s.label)}</span>
</label>
`).join('');
async function loadTokens() {
try {
const res = await fetch('/api/tokens/self', { credentials: 'same-origin' });
const tokens = await res.json();
if (!tokens.length) {
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;padding:4px 0;">No tokens yet</div>';
return;
}
listEl.innerHTML = tokens.map(t => {
const scopes = (t.scopes || []).join(', ') || 'chat';
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 3%, transparent);">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;font-weight:600;">${esc(t.name)}</div>
<div style="font-size:10px;opacity:0.5;display:flex;gap:8px;flex-wrap:wrap;">
<span>${esc(t.token_prefix)}...</span>
<span>${esc(scopes)}</span>
${t.last_used_at ? `<span>Last used ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span>Never used</span>'}
</div>
</div>
<button class="admin-btn-delete self-token-revoke" data-token-id="${esc(t.id)}" title="Revoke token" style="flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>`;
}).join('');
// Wire revoke buttons
listEl.querySelectorAll('.self-token-revoke').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const tokenId = btn.dataset.tokenId;
if (!await window.styledConfirm('Revoke this token? Any scripts or apps using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
try {
const r = await fetch(`/api/tokens/self/${tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
if (r.ok) loadTokens();
} catch (_) {}
});
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load tokens</div>';
}
}
loadTokens();
// New token button
newBtn.addEventListener('click', () => {
createPanel.style.display = '';
newBtn.style.display = 'none';
nameInput.value = '';
nameInput.focus();
revealEl.style.display = 'none';
msgEl.textContent = '';
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
});
// Cancel create — hide panel, show button
const cancelCreate = () => {
createPanel.style.display = 'none';
newBtn.style.display = '';
revealEl.style.display = 'none';
msgEl.textContent = '';
};
// Create token
createBtn.addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) { msgEl.textContent = 'Token name is required'; msgEl.style.color = 'var(--red)'; return; }
const checked = Array.from(scopesEl.querySelectorAll('.self-token-scope:checked')).map(cb => cb.value);
const fd = new FormData();
fd.append('name', name);
if (checked.length) fd.append('scopes', checked.join(','));
msgEl.textContent = '';
msgEl.style.color = '';
createBtn.disabled = true;
try {
const res = await fetch('/api/tokens/self', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
if (res.ok) {
tokenValueEl.textContent = data.token;
revealEl.style.display = '';
nameInput.value = '';
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
loadTokens();
} else {
msgEl.textContent = data.detail || 'Failed';
msgEl.style.color = 'var(--red)';
}
} catch (_) {
msgEl.textContent = 'Request failed';
msgEl.style.color = 'var(--red)';
} finally {
createBtn.disabled = false;
}
});
// Copy button
if (copyBtn) {
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
copyBtn.addEventListener('click', () => {
const val = tokenValueEl.textContent;
navigator.clipboard.writeText(val).then(() => {
copyBtn.innerHTML = CHECK_ICON;
copyBtn.style.color = 'var(--accent, var(--red))';
copyBtn.style.opacity = '1';
setTimeout(() => {
copyBtn.innerHTML = COPY_ICON;
copyBtn.style.color = '';
copyBtn.style.opacity = '';
}, 1600);
});
});
}
// Allow Enter key in name input to create
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
createBtn.click();
}
});
}
function initAccount() {
// Populate user info
fetch('/api/auth/status', { credentials: 'same-origin' })
@ -2287,6 +2450,9 @@ function initAccount() {
render2FA();
}
// Personal API Tokens
initSelfApiTokens();
// Logout
const logoutBtn = el('settings-logout-btn');
if (logoutBtn) {

View file

@ -576,3 +576,173 @@ def test_update_token_normal_object_still_works(monkeypatch, token_routes_mod):
assert token.name == "updated"
assert resp["name"] == "updated"
invalidator.assert_called_once()
# ---------------------------------------------------------------------------
# 8. Self-serve endpoints — cookie-only, owner forced to session user
# ---------------------------------------------------------------------------
def _self_req(username: str, *, is_api_token: bool = False, invalidator=None):
"""A request whose state carries a cookie user (or a bearer API token)."""
app_state = SimpleNamespace(
auth_manager=_admin_mgr(True),
)
if invalidator is not None:
app_state.invalidate_token_cache = invalidator
return SimpleNamespace(
state=SimpleNamespace(
current_user="api" if is_api_token else username,
api_token=is_api_token,
api_token_owner=username if is_api_token else None,
),
headers={},
app=SimpleNamespace(state=app_state),
)
# -- bearer rejection --
def test_self_serve_list_rejects_bearer_token(monkeypatch, token_routes_mod):
"""GET /tokens/self with a bearer API token → 403."""
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
mod = token_routes_mod
list_self = _get_handler(mod, "GET", "/tokens/self")
req = _self_req("alice", is_api_token=True)
with pytest.raises(HTTPException) as exc:
list_self(request=req)
assert exc.value.status_code == 403
def test_self_serve_create_rejects_bearer_token(monkeypatch, token_routes_mod):
"""POST /tokens/self with a bearer API token → 403."""
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
mod = token_routes_mod
create_self = _get_handler(mod, "POST", "/tokens/self")
req = _self_req("alice", is_api_token=True)
with pytest.raises(HTTPException) as exc:
create_self(request=req, name="my-token")
assert exc.value.status_code == 403
def test_self_serve_delete_rejects_bearer_token(monkeypatch, token_routes_mod):
"""DELETE /tokens/self/{id} with a bearer API token → 403."""
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
mod = token_routes_mod
delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
req = _self_req("alice", is_api_token=True)
with pytest.raises(HTTPException) as exc:
delete_self(request=req, token_id="abc12345")
assert exc.value.status_code == 403
# -- owner isolation (list only returns own tokens) --
def test_list_self_only_returns_own_tokens(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
now = datetime.datetime(2024, 1, 1, 0, 0)
row_alice = SimpleNamespace(
id="a001", name="Alice token", owner="alice", token_prefix="ody_al",
scopes="chat", is_active=True, last_used_at=now, created_at=now,
)
row_bob = SimpleNamespace(
id="b001", name="Bob token", owner="bob", token_prefix="ody_bo",
scopes="chat", is_active=True, last_used_at=now, created_at=now,
)
fake_session = MagicMock()
# The handler filters on owner == user, so the mock query chain must
# return only alice's row after the filter. We mock .all() to return
# the union, but the real DB filter would exclude bob. Simulate it by
# having the mock return only alice's row.
fake_session.query.return_value.filter.return_value.all.return_value = [row_alice]
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _self_req("alice")
list_self = _get_handler(mod, "GET", "/tokens/self")
result = list_self(request=req)
assert len(result) == 1
assert result[0]["id"] == "a001"
assert result[0]["owner"] == "alice"
# -- delete not-yours returns 404 (not 403) to avoid existence oracle --
def test_delete_self_not_yours_returns_404(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
# Token exists but belongs to bob, not alice.
fake_token = SimpleNamespace(id="bob123", owner="bob", name="bob-token")
fake_session = MagicMock()
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _self_req("alice")
delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
with pytest.raises(HTTPException) as exc:
delete_self(request=req, token_id="bob123")
assert exc.value.status_code == 404
# -- unknown scope rejected --
def test_create_self_rejects_unknown_scope(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
fake_session = MagicMock()
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
req = _self_req("alice")
create_self = _get_handler(mod, "POST", "/tokens/self")
with pytest.raises(HTTPException) as exc:
create_self(request=req, name="bad-token", scopes="admin,shell")
assert exc.value.status_code == 400
# -- owner forced from session (not client) --
def test_create_self_forces_owner_from_session(monkeypatch, token_routes_mod):
monkeypatch.setenv("AUTH_ENABLED", "true")
mod = token_routes_mod
fake_suffix = "FAKESUFFIX_XXXXXXXXXXXXXXXXXXXXXXXXXX"
fake_uuid_str = "abcd1234-0000-0000-0000-000000000000"
monkeypatch.setattr(_secrets_mod, "token_urlsafe", lambda n: fake_suffix)
monkeypatch.setattr(_uuid_mod, "uuid4", lambda: SimpleNamespace(__str__=lambda self: fake_uuid_str))
monkeypatch.setattr(mod, "bcrypt", SimpleNamespace(
hashpw=lambda pw, salt: b"$2b$12$FAKEHASH",
gensalt=lambda: b"fakesalt",
))
captured = {}
class _FakeApiToken:
def __init__(self, **kw):
captured.update(kw)
self.__dict__.update(kw)
monkeypatch.setattr(mod, "ApiToken", _FakeApiToken)
fake_session = MagicMock()
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
invalidator = MagicMock()
req = _self_req("alice", invalidator=invalidator)
create_self = _get_handler(mod, "POST", "/tokens/self")
resp = create_self(request=req, name="my-token")
# Owner must be "alice" (from session), never from client input
assert resp["owner"] == "alice"
assert captured["owner"] == "alice"
invalidator.assert_called_once()