"""Token lifecycle: redeem an invite, list, self-issue, revoke.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from ..auth import Ctx, issue_token from ..db import record_audit from ..deps import get_ctx from ..ratelimit import SlidingWindow, limit_invite_attempts from ..util import USERNAME_RE, now_iso router = APIRouter() class TokenRequest(BaseModel): username: str invite_code: str | None = None token_name: str = "cli" class SelfTokenRequest(BaseModel): token_name: str = "cli" def _limiter(request: Request) -> SlidingWindow: return request.app.state.invite_limiter @router.post("/v1/tokens") def create_token(body: TokenRequest, request: Request, ctx: Ctx = Depends(get_ctx)): """Exchange a team invite code for a personal token (new users only). Unauthenticated, so it is the one endpoint worth brute-forcing: rate limited per peer address before the invite code is compared. """ limiter = _limiter(request) bucket = limit_invite_attempts(request, limiter) cfg = ctx.cfg if not USERNAME_RE.match(body.username or ""): raise HTTPException(400, "invalid username") if cfg.invite_code is None or body.invite_code != cfg.invite_code: raise HTTPException(403, "invalid invite code") row = ctx.conn.execute("SELECT id FROM users WHERE username = %s", (body.username,)).fetchone() if row is not None: raise HTTPException(409, "user already exists; ask an admin for a new token") cur = ctx.conn.execute( "INSERT INTO users (username, role, created_at) VALUES (%s, 'member', %s) RETURNING id", (body.username, now_iso()), ) user_id = cur.fetchone()["id"] raw = issue_token(ctx.conn, user_id, body.token_name) record_audit(ctx.conn, user_id, "user.create", {"username": body.username, "via": "invite"}) record_audit(ctx.conn, user_id, "token.create", {"name": body.token_name}) limiter.clear(bucket) return { "token": raw, "user": {"id": user_id, "username": body.username, "role": "member"}, } @router.post("/v1/tokens/self") def create_self_token(body: SelfTokenRequest, request: Request, ctx: Ctx = Depends(get_ctx)): """Issue an additional token for the calling user (re-login helper).""" user = ctx.require_user(request) raw = issue_token(ctx.conn, user["id"], body.token_name) record_audit(ctx.conn, user["id"], "token.create", {"name": body.token_name, "via": "self"}) return {"token": raw} @router.get("/v1/tokens") def list_tokens(request: Request, ctx: Ctx = Depends(get_ctx)): user = ctx.require_user(request) rows = ctx.conn.execute( """SELECT token_hash, name, created_at, last_used_at FROM tokens WHERE user_id = %s AND revoked = FALSE ORDER BY created_at DESC""", (user["id"],), ).fetchall() return {"tokens": [dict(r) for r in rows]} @router.delete("/v1/tokens/{token_hash}") def revoke_token(token_hash: str, request: Request, ctx: Ctx = Depends(get_ctx)): user = ctx.require_user(request) row = ctx.conn.execute( "SELECT token_hash, user_id FROM tokens WHERE token_hash = %s", (token_hash,) ).fetchone() if row is None: raise HTTPException(404, "token not found") if user["role"] != "admin" and row["user_id"] != user["id"]: raise HTTPException(403, "cannot revoke another user's token") ctx.conn.execute("UPDATE tokens SET revoked = TRUE WHERE token_hash = %s", (token_hash,)) ctx.conn.commit() record_audit(ctx.conn, user["id"], "token.revoke", {"token_hash": token_hash[:12] + "…"}) return {"revoked": True}