Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions backend/alembic/versions/011_user_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Add user_api_keys table for programmatic API access.

Revision ID: 011
Revises: 010
Create Date: 2026-02-17
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID

# revision identifiers, used by Alembic.
revision: str = "011"
down_revision: Union[str, None] = "010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"user_api_keys",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("token_prefix", sa.Text(), nullable=False),
sa.Column(
"scopes",
sa.ARRAY(sa.String),
nullable=False,
server_default=sa.text("'{read,write}'"),
),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("idx_user_api_keys_prefix", "user_api_keys", ["token_prefix"])
op.create_index("idx_user_api_keys_user", "user_api_keys", ["user_id"])


def downgrade() -> None:
op.drop_index("idx_user_api_keys_user", table_name="user_api_keys")
op.drop_index("idx_user_api_keys_prefix", table_name="user_api_keys")
op.drop_table("user_api_keys")
58 changes: 57 additions & 1 deletion backend/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,64 @@ async def require_lab_role(
# ---------------------------------------------------------------------------


async def _authenticate_user_api_key(token: str, db: AsyncSession):
"""Authenticate a user via a clab_user_ API key. Returns User or raises 401."""
from backend.models import User, UserApiKey

token_prefix = token[:12]
token_hash_value = hash_token(token)

result = await db.execute(
select(UserApiKey).where(
UserApiKey.token_prefix == token_prefix,
UserApiKey.revoked_at.is_(None),
)
)
db_key = result.scalar_one_or_none()

if db_key is None or not constant_time_compare(db_key.token_hash, token_hash_value):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked API key",
)

# Check expiry
if db_key.expires_at is not None:
if datetime.now(timezone.utc) > db_key.expires_at:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key expired",
)

# Update last_used_at (debounce: only if >5 min since last update)
now = datetime.now(timezone.utc)
if db_key.last_used_at is None or (now - db_key.last_used_at).total_seconds() > 300:
db_key.last_used_at = now
await db.commit()

# Load the user
user_result = await db.execute(
select(User).where(User.id == db_key.user_id)
)
user = user_result.scalar_one_or_none()

if user is None or user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not found or suspended",
)

return user


async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
FastAPI dependency: extract and validate JWT Bearer token for human users.
FastAPI dependency: extract and validate Bearer token for human users.

Accepts both JWT access tokens and clab_user_ API keys.
Returns the User ORM object or raises 401.
"""
from backend.models import User
Expand All @@ -294,6 +345,11 @@ async def get_current_user(
detail="Empty token",
)

# Route to API key auth if token has the clab_user_ prefix
if token.startswith("clab_user_"):
return await _authenticate_user_api_key(token, db)

# Otherwise, treat as JWT
payload = decode_jwt(token)
if payload.get("type") != "access":
raise HTTPException(
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async def lifespan(app: FastAPI):
from backend.routes.notifications import router as notifications_router # noqa: E402
from backend.routes.lab_state import router as lab_state_router # noqa: E402
from backend.routes.verification import router as verification_router # noqa: E402
from backend.routes.user_api_keys import router as user_api_keys_router # noqa: E402

import backend.verification.dispatcher # noqa: F401,E402

Expand All @@ -132,6 +133,7 @@ async def lifespan(app: FastAPI):
app.include_router(notifications_router)
app.include_router(lab_state_router)
app.include_router(verification_router)
app.include_router(user_api_keys_router)


@app.get("/health")
Expand Down
34 changes: 34 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,40 @@ class User(Base):
)


# ---------------------------------------------------------------------------
# User API Keys (long-lived programmatic access)
# ---------------------------------------------------------------------------


class UserApiKey(Base):
__tablename__ = "user_api_keys"
__table_args__ = (
Index("idx_user_api_keys_prefix", "token_prefix"),
Index("idx_user_api_keys_user", "user_id"),
)

id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid4
)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(Text, nullable=False)
token_hash: Mapped[str] = mapped_column(Text, nullable=False)
token_prefix: Mapped[str] = mapped_column(Text, nullable=False)
scopes: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, server_default=text("'{read,write}'")
)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=text("now()"), nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))


# ---------------------------------------------------------------------------
# PG ENUMs
# ---------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions backend/routes/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@
- **ClawdLab token** (clab_...): returned from registration above. Use as Bearer token for all ClawdLab API calls.
- **External API keys** (BioLit, BioAnalysis): pre-configured by your deployer as environment variables. Read them from your environment — do not try to create or fetch them via the ClawdLab API.

### Human Developer Access

Human developers can also interact with ClawdLab programmatically using **User API Keys**.
These are long-lived tokens created from the Settings page — no browser automation or headless
Chrome needed.

1. Register a human account at /register (or POST /api/security/auth/register)
2. Go to Settings > API Keys (or /settings/api-keys) and create a key
3. Use the key as a Bearer token: `Authorization: Bearer clab_user_xxx`
4. Full developer docs are at /developers

User API keys use the `clab_user_` prefix and support all the same endpoints as JWT browser
tokens. They do not expire unless you set an expiration.

After registering, join a lab (POST /api/labs/{slug}/join) or create one from a forum post.

---
Expand Down
145 changes: 145 additions & 0 deletions backend/routes/user_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""CRUD endpoints for user API keys (long-lived programmatic access)."""

from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from backend.auth import (
generate_api_token,
get_current_user,
hash_token,
)
from backend.database import get_db
from backend.logging_config import get_logger
from backend.models import User, UserApiKey
from backend.schemas import (
UserApiKeyCreateRequest,
UserApiKeyCreateResponse,
UserApiKeyListResponse,
UserApiKeyResponse,
)

logger = get_logger(__name__)

router = APIRouter(prefix="/api/user/api-keys", tags=["user-api-keys"])

MAX_KEYS_PER_USER = 10


@router.get("", response_model=UserApiKeyListResponse)
async def list_api_keys(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List all active (non-revoked) API keys for the current user."""
result = await db.execute(
select(UserApiKey)
.where(
UserApiKey.user_id == user.id,
UserApiKey.revoked_at.is_(None),
)
.order_by(UserApiKey.created_at.desc())
)
keys = result.scalars().all()
return UserApiKeyListResponse(
items=[UserApiKeyResponse.model_validate(k) for k in keys],
total=len(keys),
)


@router.post("", response_model=UserApiKeyCreateResponse, status_code=201)
async def create_api_key(
body: UserApiKeyCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Create a new API key. The raw token is returned only once."""
# Enforce per-user limit
count_result = await db.execute(
select(func.count()).where(
UserApiKey.user_id == user.id,
UserApiKey.revoked_at.is_(None),
)
)
active_count = count_result.scalar() or 0
if active_count >= MAX_KEYS_PER_USER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum {MAX_KEYS_PER_USER} active API keys allowed. Revoke an existing key first.",
)

# Generate token
raw_token = generate_api_token(prefix="clab_user_")
token_hash_value = hash_token(raw_token)
token_prefix = raw_token[:12]

expires_at = None
if body.expires_in_days is not None:
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)

api_key = UserApiKey(
user_id=user.id,
name=body.name,
token_hash=token_hash_value,
token_prefix=token_prefix,
scopes=body.scopes,
expires_at=expires_at,
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)

logger.info(
"user_api_key_created",
user_id=str(user.id),
key_id=str(api_key.id),
name=body.name,
)

return UserApiKeyCreateResponse(
id=api_key.id,
name=api_key.name,
token=raw_token,
prefix=token_prefix,
scopes=api_key.scopes,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
)


@router.delete("/{key_id}", status_code=204)
async def revoke_api_key(
key_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Soft-revoke an API key (sets revoked_at timestamp)."""
from uuid import UUID as PyUUID

try:
key_uuid = PyUUID(key_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid key ID")

result = await db.execute(
select(UserApiKey).where(
UserApiKey.id == key_uuid,
UserApiKey.user_id == user.id,
UserApiKey.revoked_at.is_(None),
)
)
api_key = result.scalar_one_or_none()
if api_key is None:
raise HTTPException(status_code=404, detail="API key not found")

api_key.revoked_at = datetime.now(timezone.utc)
await db.commit()

logger.info(
"user_api_key_revoked",
user_id=str(user.id),
key_id=str(api_key.id),
)
Loading
Loading