From 3e6f1d5866ef3bcccef23f7655a617dc94e453ab Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 20 Jun 2026 14:22:48 +0200 Subject: [PATCH 1/3] feat(api): add self-serve API token management for users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/tokens/self — list current user's own tokens - POST /api/tokens/self — create a token (cookie-only, owner forced) - DELETE /api/tokens/self/{id} — revoke own token - All self-serve endpoints use require_user (reject bearer tokens) Frontend: - 'Personal API Tokens' card in Settings → Account tab - List existing tokens with name, prefix, scopes, last used, revoke button - Create flow with scope checkboxes, one-time token reveal + copy - Styled confirm dialog before revocation --- routes/api_token_routes.py | 80 +++++++++++++++++- static/index.html | 28 +++++++ static/js/settings.js | 164 +++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 1 deletion(-) diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py index cbc828731..6ac88ca0d 100644 --- a/routes/api_token_routes.py +++ b/routes/api_token_routes.py @@ -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() + if not token: + raise HTTPException(404, "Token not found") + if token.owner != user: + raise HTTPException(403, "Not your token") + db.delete(token) + _invalidate_cache(request) + return {"status": "deleted"} + return router diff --git a/static/index.html b/static/index.html index 8257660fe..cf77499f9 100644 --- a/static/index.html +++ b/static/index.html @@ -1999,6 +1999,34 @@ +
+

Personal API Tokens

+
Create tokens to access your Odysseus data from scripts, apps, or external tools. Tokens are scoped — only grant the permissions your use case needs.
+
+ +
+ + +
diff --git a/static/js/settings.js b/static/js/settings.js index 72936adee..19176eb08 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2130,6 +2130,167 @@ 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' }, +]; + +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 => ` + + `).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 = '
No tokens yet
'; + return; + } + listEl.innerHTML = tokens.map(t => { + const scopes = (t.scopes || []).join(', ') || 'chat'; + return `
+
+
${esc(t.name)}
+
+ ${esc(t.token_prefix)}... + ${esc(scopes)} + ${t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : 'Never used'} +
+
+ +
`; + }).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 = '
Could not load tokens
'; + } + } + + 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 = ''; + const CHECK_ICON = ''; + 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 +2448,9 @@ function initAccount() { render2FA(); } + // Personal API Tokens + initSelfApiTokens(); + // Logout const logoutBtn = el('settings-logout-btn'); if (logoutBtn) { From c983fc5f5131047253534c6df15bc2c0945c9bf7 Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 20 Jun 2026 14:43:34 +0200 Subject: [PATCH 2/3] feat(api): add cookbook scopes to self-serve token UI --- static/js/settings.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/js/settings.js b/static/js/settings.js index 19176eb08..14bccad28 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2144,6 +2144,8 @@ const SELF_TOKEN_SCOPES = [ { 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() { From 751b78299a42f99e58d9aafb236067a743159a54 Mon Sep 17 00:00:00 2001 From: holden093 Date: Fri, 26 Jun 2026 18:26:14 +0200 Subject: [PATCH 3/3] fix(tokens): close existence oracle in delete_self_token, add self-serve tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return 404 (not 403) when a user tries to delete another user's token via the self-serve endpoint, closing an information leak. - Add tests covering: bearer-token rejection (403), owner isolation (list only returns own tokens), not-yours delete → 404, unknown scope rejection (400), and owner forced from session on create. --- routes/api_token_routes.py | 6 +- tests/test_api_token_routes.py | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py index 6ac88ca0d..6072e43fd 100644 --- a/routes/api_token_routes.py +++ b/routes/api_token_routes.py @@ -276,10 +276,10 @@ def setup_api_token_routes() -> APIRouter: user = require_user(request) with get_db_session() as db: token = db.query(ApiToken).filter(ApiToken.id == token_id).first() - if not token: + # 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") - if token.owner != user: - raise HTTPException(403, "Not your token") db.delete(token) _invalidate_cache(request) return {"status": "deleted"} diff --git a/tests/test_api_token_routes.py b/tests/test_api_token_routes.py index 40afc2226..b7af68c8f 100644 --- a/tests/test_api_token_routes.py +++ b/tests/test_api_token_routes.py @@ -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()