Skip to content

Commit d85b8a1

Browse files
PttCodingManclaude
andcommitted
feat: personal API tokens for Bearer auth (list/create/revoke + Profile UI).
Tokens use a fixed jwk_ prefix so the auth path can branch on shape without probing the DB; plaintext is shown exactly once on create, only SHA-256 is stored. Viewers can't mint tokens (no use for them), and tokens can't mint other tokens (prevents a stolen one from outliving its revocation). Also refreshes CLAUDE.md with prior undocumented features (SSO, base_version, AI chat) alongside the new API-token surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9010c30 commit d85b8a1

6 files changed

Lines changed: 717 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,23 @@ cd frontend && npm test -- src/path/to/file.test.jsx
3636
### Backend (`backend/app/`)
3737

3838
- **Framework**: FastAPI (async), aiosqlite for SQLite access (WAL mode)
39-
- **Entry**: `main.py` — app creation, CORS, lifespan hooks, router mounting
40-
- **Auth**: `auth.py` — JWT tokens in httpOnly cookies, bcrypt passwords, rate-limited login
39+
- **Entry**: `main.py` — app creation, CORS, lifespan hooks, router mounting, origin-based CSRF middleware
40+
- **Auth**: `auth.py` — issues and verifies credentials across three paths:
41+
- **Cookie session** (web UI): JWT in httpOnly `token` cookie, bcrypt passwords, rate-limited login
42+
- **Bearer API token** (`Authorization: Bearer jwk_…`): hashed SHA-256 on store, plaintext returned once at creation, revocable; managed via `routers/tokens.py`
43+
- **SSO**: OIDC (Google/GitHub/generic, PKCE, authlib state stored in signed session cookie) via `routers/oauth_router.py` + `services/oidc.py`; LDAP via `services/ldap_auth.py`; invitation-only mode gates first-time signup
44+
- **CSRF**: origin/referer check in `main.py` for mutating cookie-auth requests; Bearer-token and login/logout paths are exempt
4145
- **Config**: `config.py` — Pydantic Settings reading from `.env`
42-
- **Database**: `database.py` — schema DDL, migrations, FTS5 search index setup
43-
- **Routers**: One file per domain — `pages.py`, `search.py`, `media.py`, `versions.py`, `tags.py`, `templates.py`, `users.py`, `diagrams.py`, `comments.py`, `bookmarks.py`, `activity.py`, `backup.py`, `export.py`, `auth_router.py`, `trash.py`, `notifications.py`, `watch.py`, `public.py`, `dashboard.py`, `acl.py`, `groups.py`
44-
- **Services**: `search.py` (FTS5 indexing, CJK segmentation), `wikilink.py` (backlink tracking), `acl.py` (permission resolution — single source of truth, routers must use these helpers), `media_ref.py` (tracks which pages reference each media file), `diagram_ref.py`, `notifications.py` (fan-out to watchers on page events)
46+
- **Schemas**: `schemas.py` — shared Pydantic request/response models
47+
- **Database**: `database.py` — schema DDL and FTS5 search index setup; startup-time migrations live in `migrations.py` and record applied versions in the `schema_migrations` ledger so each migration runs once per DB
48+
- **Routers**: One file per domain — `pages.py`, `search.py`, `media.py`, `versions.py`, `tags.py`, `templates.py`, `users.py`, `diagrams.py`, `comments.py`, `bookmarks.py`, `activity.py`, `backup.py`, `export.py`, `auth_router.py`, `oauth_router.py`, `tokens.py`, `trash.py`, `notifications.py`, `watch.py`, `public.py`, `dashboard.py`, `acl.py`, `groups.py`, `ai.py`
49+
- **Services**: `search.py` (FTS5 indexing, CJK segmentation), `wikilink.py` (backlink tracking), `acl.py` (permission resolution — single source of truth, routers must use these helpers), `media_ref.py` (tracks which pages reference each media file), `diagram_ref.py`, `notifications.py` (fan-out to watchers on page events), `oidc.py` (OIDC client + provider config), `ldap_auth.py` (LDAP bind + attribute mapping)
4550
- **Deps**: Python 3.11+, managed with `uv`
4651

4752
### Frontend (`frontend/src/`)
4853

4954
- **Framework**: React 19, Vite 8, Tailwind CSS 4
50-
- **State**: Zustand stores in `store/` — one per domain (useAuth, usePages, useTags, useBookmarks, useTheme, useSearch, useActivity, useNotifications, usePermissions, useGroups)
55+
- **State**: Zustand stores in `store/` — one per domain (useAuth, usePages, useTags, useBookmarks, useTheme, useSearch, useActivity, useNotifications, usePermissions, useGroups, useChat)
5156
- **API client**: `api/client.js` — Axios instance with interceptors, 401 redirect to login
5257
- **Routing**: React Router v7 in `App.jsx`, PrivateRoute wrapper for auth
5358
- **Keyboard shortcuts**: `hooks/useKeyboard.jsx` (Ctrl+E edit, Ctrl+K search, etc.)
@@ -66,7 +71,11 @@ Key tables: `users`, `pages` (with `parent_id` hierarchy and `slug` URL), `page_
6671

6772
Pages use **soft-delete** (`deleted_at` column) — `DELETE /api/pages/{slug}` sets `deleted_at` rather than removing the row. Trash endpoints (`/api/trash`) handle list/restore/purge. Restore re-indexes FTS and re-parses backlinks.
6873

69-
Schema auto-migrates on startup in `database.py`.
74+
Schema auto-migrates on startup — DDL lives in `database.py`, versioned migrations in `migrations.py`, and the `schema_migrations` ledger records which versions have run. Also relevant: `api_tokens` (token_hash, prefix, expires_at, revoked_at, last_used) backs personal API tokens.
75+
76+
### Optimistic locking on page edits
77+
78+
`PUT /api/pages/{slug}` **requires a `base_version`** in the body whenever `content` or `title` changes. If it doesn't match the current `pages.version`, the server returns **409** with `{"error": "base_version_stale" | "base_version_required", "your_version": …, "current_version": …}` so clients can resolve the conflict rather than silently clobbering a concurrent edit. New clients must send `base_version` — don't add a fallback.
7079

7180
### Wikilinks
7281

@@ -76,12 +85,15 @@ Format: `[[slug]]` or `[[slug|display text]]`. Parsed on both backend (backlink
7685

7786
All endpoints under `/api/`. Vite dev server proxies `/api` to `localhost:8000`. Key routes:
7887

79-
- Pages CRUD: `/api/pages`, `/api/pages/{slug}`, `/api/pages/tree`, `/api/pages/graph`
88+
- Pages CRUD: `/api/pages`, `/api/pages/{slug}`, `/api/pages/tree`, `/api/pages/graph` (content/title edits require `base_version`)
8089
- Versions: `/api/pages/{slug}/versions`, `/api/pages/{slug}/diff/{v1}/{v2}`
8190
- Permission check: `GET /api/pages/{slug}/my-permission``{permission: 'admin'|'write'|'read'|'none'}`
8291
- Search: `/api/search?q=...`
8392
- Media upload: `POST /api/media/upload` (20MB limit)
8493
- Auth: `/api/auth/login`, `/api/auth/me`
94+
- API tokens: `GET/POST/DELETE /api/auth/tokens` — personal Bearer tokens (plaintext returned only on create)
95+
- SSO: `GET /api/auth/providers`, `GET /api/auth/oauth/{provider}/login`, `GET /api/auth/oauth/{provider}/callback`
96+
- AI chat: `/api/ai/*` — RAG over the wiki, scoped to the caller's ACL (only pages they can read are retrievable)
8597
- ACL: `/api/acl/pages/{page_id}` (GET/PUT page-level access rules)
8698
- Groups: `/api/groups` (admin-only CRUD + membership)
8799
- Trash: `/api/trash` (list/restore/purge soft-deleted pages)

backend/app/auth.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
from datetime import datetime, timedelta, timezone
23
from fastapi import Depends, HTTPException, status, Request
34
from jose import JWTError, jwt
@@ -9,6 +10,23 @@
910
ALGORITHM = "HS256"
1011
TOKEN_EXPIRE_HOURS = 24
1112

13+
# Personal API tokens use a fixed prefix so the auth path can branch on
14+
# token shape alone — no need to probe the DB for every request. 32 random
15+
# bytes (urlsafe-base64, ≈43 chars) give >128 bits of entropy, which is
16+
# comfortably larger than the HMAC secret for a forged JWT.
17+
API_TOKEN_PREFIX = "jwk_"
18+
API_TOKEN_DISPLAY_PREFIX_LEN = 12 # "jwk_" + 8 chars shown in the UI
19+
20+
21+
def hash_api_token(token: str) -> str:
22+
"""Return the canonical hash we store in api_tokens.token_hash.
23+
24+
Plain SHA-256 is fine here: the input is already 32 bytes of
25+
cryptographic randomness, so a slow KDF would only add latency without
26+
raising the bar for an attacker who's already stolen the DB.
27+
"""
28+
return hashlib.sha256(token.encode()).hexdigest()
29+
1230

1331
def hash_password(password: str) -> str:
1432
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
@@ -29,6 +47,57 @@ def create_token(user_id: int, username: str, role: str) -> str:
2947
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
3048

3149

50+
async def _resolve_api_token(token: str) -> dict | None:
51+
"""Look up a personal API token and return the owning user dict, or None.
52+
53+
Bumps `last_used` on hit. Rejects tokens that are revoked or past their
54+
`expires_at`; both paths simply return None so the caller emits a single
55+
generic 401 rather than leaking token state.
56+
"""
57+
db = await get_db()
58+
rows = await db.execute_fetchall(
59+
"""SELECT t.id, t.expires_at, t.revoked_at,
60+
u.id AS user_id, u.username, u.role, u.display_name, u.email,
61+
u.deleted_at
62+
FROM api_tokens t
63+
JOIN users u ON u.id = t.user_id
64+
WHERE t.token_hash = ?""",
65+
(hash_api_token(token),),
66+
)
67+
if not rows:
68+
return None
69+
row = dict(rows[0])
70+
if row["deleted_at"] is not None or row["revoked_at"] is not None:
71+
return None
72+
if row["expires_at"] is not None:
73+
# SQLite stores these as strings; parse tolerantly so 'YYYY-MM-DD HH:MM:SS'
74+
# and ISO8601 both round-trip. A naive datetime is interpreted as UTC,
75+
# matching how create-token writes it below.
76+
try:
77+
exp = datetime.fromisoformat(str(row["expires_at"]).replace(" ", "T"))
78+
except ValueError:
79+
exp = None
80+
if exp is not None:
81+
if exp.tzinfo is None:
82+
exp = exp.replace(tzinfo=timezone.utc)
83+
if exp <= datetime.now(timezone.utc):
84+
return None
85+
86+
await db.execute(
87+
"UPDATE api_tokens SET last_used = CURRENT_TIMESTAMP WHERE id = ?",
88+
(row["id"],),
89+
)
90+
await db.commit()
91+
92+
return {
93+
"id": row["user_id"],
94+
"username": row["username"],
95+
"role": row["role"],
96+
"display_name": row["display_name"],
97+
"email": row["email"],
98+
}
99+
100+
32101
async def get_current_user(request: Request):
33102
token = None
34103

@@ -47,6 +116,17 @@ async def get_current_user(request: Request):
47116
detail="Not authenticated",
48117
)
49118

119+
# Personal API tokens are distinguished by a fixed prefix so we don't
120+
# have to guess-and-fall-back. Anything else must parse as a JWT.
121+
if token.startswith(API_TOKEN_PREFIX):
122+
user = await _resolve_api_token(token)
123+
if user is None:
124+
raise HTTPException(
125+
status_code=status.HTTP_401_UNAUTHORIZED,
126+
detail="Invalid token",
127+
)
128+
return user
129+
50130
try:
51131
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
52132
user_id = int(payload["sub"])

backend/app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from app.config import settings
1111
from app.database import init_db, close_db, seed_welcome_page, get_db
1212
from app.auth import ensure_admin_exists
13-
from app.routers import auth_router, oauth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams, users, comments, backup, export, trash, notifications, watch, public, dashboard, acl, groups, ai
13+
from app.routers import auth_router, oauth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams, users, comments, backup, export, trash, notifications, watch, public, dashboard, acl, groups, ai, tokens
1414

1515
logger = logging.getLogger("justwiki")
1616

@@ -179,6 +179,7 @@ def _origin_of(url: str | None) -> str | None:
179179
app.include_router(acl.router)
180180
app.include_router(groups.router)
181181
app.include_router(ai.router)
182+
app.include_router(tokens.router)
182183

183184

184185
@app.get("/api/health")

backend/app/routers/tokens.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Personal API token management.
2+
3+
Endpoints let the authenticated user list, create, and revoke their own
4+
tokens. Tokens inherit the owner's role and ACLs — there is no finer-grained
5+
scoping. Plaintext is shown **once** on creation and never stored; only the
6+
SHA-256 hash lives in the DB. A revoked token keeps its row so that the
7+
audit trail (and any `last_used` timestamp) survives.
8+
9+
Viewers can't create tokens because a viewer can't do anything with one
10+
either. Revoking and listing stay available to every authenticated user.
11+
"""
12+
13+
import secrets
14+
from datetime import datetime, timedelta, timezone
15+
from typing import Optional
16+
17+
from fastapi import APIRouter, Depends, HTTPException, Request
18+
from pydantic import BaseModel, Field
19+
20+
from app.auth import (
21+
API_TOKEN_DISPLAY_PREFIX_LEN,
22+
API_TOKEN_PREFIX,
23+
get_current_user,
24+
hash_api_token,
25+
)
26+
from app.database import get_db
27+
from app.routers.activity import log_activity
28+
29+
router = APIRouter(prefix="/api/auth/tokens", tags=["tokens"])
30+
31+
# Default lifetime for a new token. Callers can override down to 1 or up to
32+
# 365 days, or pass 0 to mean "never expires". The cap stops a token from
33+
# silently outliving the user that created it.
34+
_DEFAULT_EXPIRES_DAYS = 30
35+
_MAX_EXPIRES_DAYS = 365
36+
37+
38+
class TokenCreate(BaseModel):
39+
name: str = Field(min_length=1, max_length=100)
40+
expires_in_days: Optional[int] = Field(
41+
default=_DEFAULT_EXPIRES_DAYS,
42+
ge=0,
43+
le=_MAX_EXPIRES_DAYS,
44+
description="0 = never expires",
45+
)
46+
47+
48+
class TokenResponse(BaseModel):
49+
id: int
50+
name: str
51+
prefix: Optional[str] = None
52+
created_at: Optional[str] = None
53+
last_used: Optional[str] = None
54+
expires_at: Optional[str] = None
55+
revoked_at: Optional[str] = None
56+
57+
58+
class TokenCreateResponse(TokenResponse):
59+
token: str # plaintext — shown exactly once
60+
61+
62+
def _generate_plaintext() -> str:
63+
"""Return a fresh `jwk_<random>` token string.
64+
65+
32 bytes of randomness encoded urlsafe-b64 (no padding) comes out to
66+
≈43 chars, which fits comfortably in a header alongside the 4-char
67+
prefix.
68+
"""
69+
body = secrets.token_urlsafe(32).rstrip("=")
70+
return f"{API_TOKEN_PREFIX}{body}"
71+
72+
73+
@router.get("", response_model=list[TokenResponse])
74+
async def list_tokens(user=Depends(get_current_user)):
75+
"""List the caller's own tokens (plaintext is not included)."""
76+
db = await get_db()
77+
rows = await db.execute_fetchall(
78+
"""SELECT id, name, prefix, created_at, last_used, expires_at, revoked_at
79+
FROM api_tokens
80+
WHERE user_id = ?
81+
ORDER BY revoked_at IS NOT NULL, created_at DESC""",
82+
(user["id"],),
83+
)
84+
return [dict(r) for r in rows]
85+
86+
87+
@router.post("", response_model=TokenCreateResponse, status_code=201)
88+
async def create_token(
89+
body: TokenCreate,
90+
request: Request,
91+
user=Depends(get_current_user),
92+
):
93+
# Refuse if the caller authenticated using an API token themselves.
94+
# Otherwise a stolen token could be used to mint a replacement that
95+
# outlives the victim revoking the original.
96+
auth_header = request.headers.get("Authorization", "")
97+
if auth_header.startswith(f"Bearer {API_TOKEN_PREFIX}"):
98+
raise HTTPException(
99+
status_code=403,
100+
detail="API tokens cannot create other API tokens; sign in as the user",
101+
)
102+
103+
if user.get("role") == "viewer":
104+
raise HTTPException(
105+
status_code=403, detail="Viewers cannot create API tokens"
106+
)
107+
108+
plaintext = _generate_plaintext()
109+
prefix = plaintext[:API_TOKEN_DISPLAY_PREFIX_LEN]
110+
expires_at: Optional[str] = None
111+
if body.expires_in_days and body.expires_in_days > 0:
112+
exp = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
113+
# Store in the same textual shape SQLite uses for CURRENT_TIMESTAMP so
114+
# the resolver can parse both rows written here and any legacy rows
115+
# written by CURRENT_TIMESTAMP defaults without branching.
116+
expires_at = exp.strftime("%Y-%m-%d %H:%M:%S")
117+
118+
db = await get_db()
119+
cursor = await db.execute(
120+
"""INSERT INTO api_tokens (user_id, name, token_hash, prefix, expires_at)
121+
VALUES (?, ?, ?, ?, ?)""",
122+
(user["id"], body.name, hash_api_token(plaintext), prefix, expires_at),
123+
)
124+
token_id = cursor.lastrowid
125+
await log_activity(
126+
db, user["id"], "created", "api_token", token_id,
127+
{"name": body.name, "expires_at": expires_at},
128+
)
129+
await db.commit()
130+
131+
rows = await db.execute_fetchall(
132+
"""SELECT id, name, prefix, created_at, last_used, expires_at, revoked_at
133+
FROM api_tokens WHERE id = ?""",
134+
(token_id,),
135+
)
136+
result = dict(rows[0])
137+
result["token"] = plaintext
138+
return result
139+
140+
141+
@router.delete("/{token_id}", status_code=204)
142+
async def revoke_token(token_id: int, user=Depends(get_current_user)):
143+
"""Revoke one of the caller's tokens. Already-revoked is a no-op.
144+
145+
The row stays in place so the audit log entry still has a target.
146+
"""
147+
db = await get_db()
148+
rows = await db.execute_fetchall(
149+
"SELECT id, user_id, name, revoked_at FROM api_tokens WHERE id = ?",
150+
(token_id,),
151+
)
152+
if not rows or rows[0]["user_id"] != user["id"]:
153+
# Treat cross-user probes as "not found" so an attacker can't
154+
# enumerate someone else's token ids.
155+
raise HTTPException(status_code=404, detail="Token not found")
156+
if rows[0]["revoked_at"] is not None:
157+
return
158+
159+
await db.execute(
160+
"UPDATE api_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?",
161+
(token_id,),
162+
)
163+
await log_activity(
164+
db, user["id"], "revoked", "api_token", token_id,
165+
{"name": rows[0]["name"]},
166+
)
167+
await db.commit()

0 commit comments

Comments
 (0)